Compare commits
1 Commits
main
...
android-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d869223c82 |
@@ -57,29 +57,47 @@ Depending on the Android version and device storage policy, the app's file locat
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Python Terminal UI (TUI) Dashboard
|
## Render Validation Results Viewer
|
||||||
|
|
||||||
To make managing tests and results easier, a Python-based Textual TUI (`validation_app.py`) is provided in the `validation_tui` directory. It automatically polls the connected device using ADB, acts as a GUI for the intent commands above, and handles downloading/uploading `.zip` bundles to circumvent Android's scoped storage limits.
|
The project includes a static web viewer to visualize and compare test results across different devices. The viewer supports high-resolution image comparison with zoom/pan controls and dynamic diffing.
|
||||||
|
|
||||||
### Setup
|
### Setup & Requirements
|
||||||
1. Ensure you have Python 3 and `adb` installed and in your PATH.
|
The results processor requires `numpy` and `Pillow`. These are not included in the main `requirements.txt` to keep the TUI dependencies minimal.
|
||||||
2. Navigate to the TUI directory: `cd test/render-validation`
|
|
||||||
3. Create a virtual environment: `python3 -m venv venv`
|
1. Install processing dependencies:
|
||||||
4. Activate it: `source venv/bin/activate` (Mac/Linux) or `venv\Scripts\activate` (Windows)
|
```bash
|
||||||
5. Install requirements: `pip install -r requirements.txt` (Installs the `textual` framework)
|
pip install numpy Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Process Result Bundles
|
||||||
|
The `process_results.py` script takes a directory of `.zip` result files (exported from the Android app) and generates a static web folder.
|
||||||
|
|
||||||
### Usage
|
|
||||||
Start the dashboard by running:
|
|
||||||
```bash
|
```bash
|
||||||
python validation_app.py
|
# Usage: python process_results.py <input_zip_dir> <output_web_dir>
|
||||||
|
python process_results.py ./my_results ./web_output
|
||||||
```
|
```
|
||||||
|
|
||||||
### TUI Features
|
This script:
|
||||||
- **Auto-Polling Mechanism**: Syncs the file lists with your device every 2 seconds.
|
- Extracts images and metadata from the result zips.
|
||||||
- **Generate Test/Result Buttons**: One-click execution of the `am start` intents.
|
- Generates thumbnails for efficient browser performance.
|
||||||
- **Upload Local Test Bundle**: Automatically pushes a local `.zip` file from your PC to the correct directory on the Android device.
|
- Packages the exact tolerance configurations for the web viewer.
|
||||||
- **Per-File Actions**:
|
|
||||||
- `▶` (Load): Restarts the app with `--es zip_path <filename>` to set it as the active test on device.
|
### 2. View Results
|
||||||
- `↓` (Download): Pulls the `.zip` to your PC's current working directory.
|
Because the viewer uses ES modules and fetches data, it must be served via a web server.
|
||||||
- `✎` (Rename): Quickly renames the file directly on the Android file system.
|
|
||||||
- `✗` (Delete): Quickly removes the file from the Android device to free up storage.
|
```bash
|
||||||
|
cd ./web_output
|
||||||
|
python3 -m http.server 1234
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigate to `http://localhost:1234` in your desktop browser.
|
||||||
|
|
||||||
|
### Web Viewer Features
|
||||||
|
- **Tabular Overview**: Compare results across multiple devices and test runs in a single grid.
|
||||||
|
- **High-Res Viewer**: Click any thumbnail to open a full-size modal.
|
||||||
|
- **Zoom & Pan**: Use the mouse wheel to zoom and left-click-drag to pan around the render.
|
||||||
|
- **Comparison Modes**: Cycle between "Rendered", "Golden", and "Diff" views.
|
||||||
|
- **Dynamic JS Diffing**: The `imagediff` algorithm (including `shiftRadius`, `blurRadius`, and complex tolerance trees) is implemented in JavaScript and computed on-the-fly.
|
||||||
|
- **Fail Highlighting**: Toggle "Highlight Failing Pixels" in the Diff view to see exactly which pixels exceeded the tolerance threshold in pure red.
|
||||||
|
- **Contrast Control**: Use the contrast slider to amplify subtle rendering differences.
|
||||||
|
|
||||||
|
|||||||
161
test/render-validation/process_results.py
Normal file
161
test/render-validation/process_results.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
def get_test_tolerance(config, full_test_name):
|
||||||
|
# test name is e.g. "basic.opengl.DamagedHelmet"
|
||||||
|
parts = full_test_name.split('.')
|
||||||
|
if not parts: return None
|
||||||
|
test_id = parts[0]
|
||||||
|
|
||||||
|
for test in config.get("tests", []):
|
||||||
|
if test.get("name") == test_id:
|
||||||
|
return test.get("tolerance")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def process_zip(zip_path, output_dir, device_name):
|
||||||
|
"""Extract and process a single zip file from the results folder."""
|
||||||
|
extract_dir = os.path.join(output_dir, "tmp", device_name)
|
||||||
|
os.makedirs(extract_dir, exist_ok=True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as z:
|
||||||
|
z.extractall(extract_dir)
|
||||||
|
|
||||||
|
results_json_path = os.path.join(extract_dir, "results.json")
|
||||||
|
if not os.path.exists(results_json_path):
|
||||||
|
return {}, []
|
||||||
|
|
||||||
|
with open(results_json_path, 'r') as f:
|
||||||
|
try:
|
||||||
|
device_results = json.load(f)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}, []
|
||||||
|
|
||||||
|
metadata = device_results['metadata']
|
||||||
|
|
||||||
|
bundle_zip_path = os.path.join(extract_dir, "bundle.zip")
|
||||||
|
bundle_dir = os.path.join(extract_dir, "bundle")
|
||||||
|
|
||||||
|
if os.path.exists(bundle_zip_path):
|
||||||
|
os.makedirs(bundle_dir, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(bundle_zip_path, 'r') as z:
|
||||||
|
z.extractall(bundle_dir)
|
||||||
|
|
||||||
|
config_path = os.path.join(bundle_dir, "default_test", "config.json")
|
||||||
|
config = {}
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
run_results = []
|
||||||
|
|
||||||
|
for test_result in device_results.get("results", []):
|
||||||
|
test_name = test_result.get("test_name")
|
||||||
|
passed = test_result.get("passed", False)
|
||||||
|
|
||||||
|
rendered_path = os.path.join(extract_dir, f"{test_name}.png")
|
||||||
|
golden_path = os.path.join(bundle_dir, "default_test", "goldens", f"{test_name}.png")
|
||||||
|
|
||||||
|
if not os.path.exists(rendered_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
rendered_img = Image.open(rendered_path).convert("RGBA")
|
||||||
|
|
||||||
|
# Output paths for web
|
||||||
|
rel_test_dir = f"{device_name}/{test_name}"
|
||||||
|
out_test_dir = os.path.join(output_dir, "assets", rel_test_dir)
|
||||||
|
os.makedirs(out_test_dir, exist_ok=True)
|
||||||
|
|
||||||
|
out_golden = os.path.join(out_test_dir, "golden.png")
|
||||||
|
out_rendered = os.path.join(out_test_dir, "rendered.png")
|
||||||
|
out_thumb = os.path.join(out_test_dir, "thumb.png")
|
||||||
|
|
||||||
|
rendered_img.save(out_rendered)
|
||||||
|
|
||||||
|
# Generate thumbnail
|
||||||
|
thumb_size = (128, 128)
|
||||||
|
thumb_img = rendered_img.copy()
|
||||||
|
thumb_img.thumbnail(thumb_size)
|
||||||
|
thumb_img.save(out_thumb)
|
||||||
|
|
||||||
|
has_golden = False
|
||||||
|
if os.path.exists(golden_path):
|
||||||
|
has_golden = True
|
||||||
|
shutil.copy2(golden_path, out_golden)
|
||||||
|
else:
|
||||||
|
Image.new("RGBA", rendered_img.size, (0,0,0,0)).save(out_golden)
|
||||||
|
|
||||||
|
tolerance_config = get_test_tolerance(config, test_name)
|
||||||
|
|
||||||
|
run_results.append({
|
||||||
|
"testName": test_name,
|
||||||
|
"passed": passed,
|
||||||
|
"golden": f"assets/{rel_test_dir}/golden.png",
|
||||||
|
"rendered": f"assets/{rel_test_dir}/rendered.png",
|
||||||
|
"thumb": f"assets/{rel_test_dir}/thumb.png",
|
||||||
|
"hasGolden": has_golden,
|
||||||
|
"config": tolerance_config
|
||||||
|
})
|
||||||
|
|
||||||
|
return [metadata, run_results]
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Process render validation zip results.")
|
||||||
|
parser.add_argument("input_dir", help="Directory containing .zip result files")
|
||||||
|
parser.add_argument("output_dir", help="Directory to output the static web viewer")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
input_dir = Path(args.input_dir)
|
||||||
|
output_dir = Path(args.output_dir)
|
||||||
|
|
||||||
|
if not input_dir.exists():
|
||||||
|
print(f"Error: Input directory {input_dir} does not exist.")
|
||||||
|
return
|
||||||
|
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
os.makedirs(output_dir / "assets", exist_ok=True)
|
||||||
|
|
||||||
|
all_results = []
|
||||||
|
|
||||||
|
for zip_file in input_dir.glob("*.zip"):
|
||||||
|
device_name = zip_file.stem
|
||||||
|
print(f"Processing {zip_file.name} for device {device_name}...")
|
||||||
|
|
||||||
|
metadata, device_results = process_zip(zip_file, output_dir, device_name)
|
||||||
|
if device_results:
|
||||||
|
all_results.append({
|
||||||
|
"metadata": metadata,
|
||||||
|
"device": device_name,
|
||||||
|
"runs": device_results
|
||||||
|
})
|
||||||
|
|
||||||
|
# Cleanup tmp
|
||||||
|
tmp_dir = output_dir / "tmp"
|
||||||
|
if tmp_dir.exists():
|
||||||
|
shutil.rmtree(tmp_dir)
|
||||||
|
|
||||||
|
# Write data.json
|
||||||
|
with open(output_dir / "data.json", "w") as f:
|
||||||
|
json.dump(all_results, f, indent=2)
|
||||||
|
|
||||||
|
# Copy web viewer files
|
||||||
|
viewer_src = Path(__file__).parent / "result-viewer"
|
||||||
|
if viewer_src.exists():
|
||||||
|
for item in viewer_src.iterdir():
|
||||||
|
if item.is_file():
|
||||||
|
shutil.copy2(item, output_dir)
|
||||||
|
elif item.is_dir():
|
||||||
|
dest_dir = output_dir / item.name
|
||||||
|
if dest_dir.exists():
|
||||||
|
shutil.rmtree(dest_dir)
|
||||||
|
shutil.copytree(item, dest_dir)
|
||||||
|
|
||||||
|
print("Done. To view results, run a static server in the output directory:")
|
||||||
|
print(f"cd {output_dir} && python3 -m http.server 1234")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1 +1,3 @@
|
|||||||
textual>=0.52.0
|
textual>=0.52.0
|
||||||
|
Pillow
|
||||||
|
numpy
|
||||||
|
|||||||
35
test/render-validation/result-viewer/app.js
Normal file
35
test/render-validation/result-viewer/app.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import './components/result-table.js';
|
||||||
|
import './components/image-viewer.js';
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const container = document.getElementById('container');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('./data.json');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<result-table></result-table>
|
||||||
|
<image-viewer></image-viewer>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resultTable = container.querySelector('result-table');
|
||||||
|
const imageViewer = container.querySelector('image-viewer');
|
||||||
|
|
||||||
|
resultTable.results = data;
|
||||||
|
|
||||||
|
// Listen for view events from the table
|
||||||
|
container.addEventListener('view-result', (e) => {
|
||||||
|
imageViewer.open(e.detail.device, e.detail.run);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div style="color: red; padding: 20px;">Error loading results: ${e.message}.<br>Make sure you are running a local server.</div>`;
|
||||||
|
console.error("Failed to load data:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
699
test/render-validation/result-viewer/components/image-viewer.js
Normal file
699
test/render-validation/result-viewer/components/image-viewer.js
Normal file
@@ -0,0 +1,699 @@
|
|||||||
|
import { html, css, LitElement } from 'lit';
|
||||||
|
import './tiff-viewer.js';
|
||||||
|
|
||||||
|
const RES_EQUAL = "equal";
|
||||||
|
const RES_MISMATCHED_DIMENSIONS = "mismatched dimensions";
|
||||||
|
const RES_DIFFERENT_PIXELS = "different pixels";
|
||||||
|
const RES_NOT_READY = "not ready";
|
||||||
|
|
||||||
|
export class ImageViewer extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
runData: { type: Object },
|
||||||
|
deviceName: { type: String },
|
||||||
|
isOpen: { type: Boolean },
|
||||||
|
|
||||||
|
diffResult: { type: Object },
|
||||||
|
magnifierEnabled: { type: Boolean },
|
||||||
|
highlightFailing: { type: Boolean },
|
||||||
|
currentDiffImageData: { type: Object },
|
||||||
|
|
||||||
|
viewMode: { type: String },
|
||||||
|
toggleState: { type: String },
|
||||||
|
autoAlternate: { type: Boolean }
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: black;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
padding: 15px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
z-index: 10;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #eee; }
|
||||||
|
.close-btn {
|
||||||
|
background: #e74c3c;
|
||||||
|
border-color: #c0392b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.close-btn:hover { background: #c0392b; color: white; }
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.viewer-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.control-panel {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.viewer-wrap {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 33%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.viewer-wrap.full-width {
|
||||||
|
max-width: 512px;
|
||||||
|
}
|
||||||
|
.viewer-label {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
tiff-viewer {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.isOpen = false;
|
||||||
|
this.magnifierEnabled = true;
|
||||||
|
this.highlightFailing = false;
|
||||||
|
this.originalDiffImageData = null;
|
||||||
|
this.currentDiffImageData = null;
|
||||||
|
this.diffResult = null;
|
||||||
|
|
||||||
|
this.leftImageLoaded = false;
|
||||||
|
this.rightImageLoaded = false;
|
||||||
|
|
||||||
|
this.viewMode = 'side-by-side';
|
||||||
|
this.toggleState = 'rendered';
|
||||||
|
this.autoAlternate = false;
|
||||||
|
this._alternateInterval = null;
|
||||||
|
|
||||||
|
this.addEventListener(
|
||||||
|
'image-loaded',
|
||||||
|
(ev) => {
|
||||||
|
if (!this.runData) return;
|
||||||
|
if (ev.detail.url === this.runData.golden) {
|
||||||
|
this.leftImageLoaded = true;
|
||||||
|
}
|
||||||
|
if (ev.detail.url === this.runData.rendered) {
|
||||||
|
this.rightImageLoaded = true;
|
||||||
|
}
|
||||||
|
if (this.leftImageLoaded && this.rightImageLoaded) {
|
||||||
|
this._triggerDiff();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
open(deviceName, runData) {
|
||||||
|
this.deviceName = deviceName;
|
||||||
|
this.runData = runData;
|
||||||
|
this.isOpen = true;
|
||||||
|
|
||||||
|
this.diffResult = null;
|
||||||
|
this.currentDiffImageData = null;
|
||||||
|
this.originalDiffImageData = null;
|
||||||
|
this.leftImageLoaded = false;
|
||||||
|
this.rightImageLoaded = false;
|
||||||
|
this.highlightFailing = false;
|
||||||
|
|
||||||
|
this.viewMode = 'side-by-side';
|
||||||
|
this.toggleState = 'rendered';
|
||||||
|
this.autoAlternate = false;
|
||||||
|
if (this._alternateInterval) {
|
||||||
|
clearInterval(this._alternateInterval);
|
||||||
|
this._alternateInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
if (this._alternateInterval) {
|
||||||
|
clearInterval(this._alternateInterval);
|
||||||
|
this._alternateInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeDiff() {
|
||||||
|
const tiffViewerLeft = this.shadowRoot.querySelector('#viewer-left');
|
||||||
|
const tiffViewerRight = this.shadowRoot.querySelector('#viewer-right');
|
||||||
|
|
||||||
|
if (!tiffViewerLeft || !tiffViewerRight) {
|
||||||
|
return { "result": RES_NOT_READY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasLeft = tiffViewerLeft.shadowRoot.querySelector('canvas');
|
||||||
|
const canvasRight = tiffViewerRight.shadowRoot.querySelector('canvas');
|
||||||
|
|
||||||
|
if (!canvasLeft || !canvasRight) {
|
||||||
|
return { "result": RES_NOT_READY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgLeft = tiffViewerLeft.imgdata;
|
||||||
|
const imgRight = tiffViewerRight.imgdata;
|
||||||
|
|
||||||
|
if (!imgLeft || !imgRight) {
|
||||||
|
return { "result": RES_NOT_READY };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imgLeft.width !== imgRight.width || imgLeft.height !== imgRight.height) {
|
||||||
|
console.error("Images have different dimensions");
|
||||||
|
return {
|
||||||
|
"result": RES_MISMATCHED_DIMENSIONS,
|
||||||
|
"explanation": "Images have different dimensions " +
|
||||||
|
"left=(" + imgLeft.width + ", " + imgLeft.height + ") " +
|
||||||
|
"right=(" + imgRight.width + ", " + imgRight.height + ")",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = imgLeft.width;
|
||||||
|
const height = imgLeft.height;
|
||||||
|
const goldenData = imgLeft.data;
|
||||||
|
const renderedData = imgRight.data;
|
||||||
|
const imgDiff = new Uint8ClampedArray(width * height * 4);
|
||||||
|
const maxDiff = [0, 0, 0, 0];
|
||||||
|
|
||||||
|
const config = this.runData?.config;
|
||||||
|
const highlight = this.highlightFailing;
|
||||||
|
|
||||||
|
const blurCacheG = new Map();
|
||||||
|
const blurCacheR = new Map();
|
||||||
|
if (highlight && config) {
|
||||||
|
blurCacheG.set(0, goldenData);
|
||||||
|
blurCacheR.set(0, renderedData);
|
||||||
|
|
||||||
|
const applyBlur = (data, radius) => {
|
||||||
|
if (radius === 0) return data;
|
||||||
|
const out = new Uint8ClampedArray(width * height * 4);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
let r=0, g=0, b=0, a=0, count=0;
|
||||||
|
for (let dy = -radius; dy <= radius; dy++) {
|
||||||
|
for (let dx = -radius; dx <= radius; dx++) {
|
||||||
|
let nx = Math.max(0, Math.min(width - 1, x + dx));
|
||||||
|
let ny = Math.max(0, Math.min(height - 1, y + dy));
|
||||||
|
let idx = (ny * width + nx) * 4;
|
||||||
|
r += data[idx];
|
||||||
|
g += data[idx+1];
|
||||||
|
b += data[idx+2];
|
||||||
|
a += data[idx+3];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let oidx = (y * width + x) * 4;
|
||||||
|
out[oidx] = r/count;
|
||||||
|
out[oidx+1] = g/count;
|
||||||
|
out[oidx+2] = b/count;
|
||||||
|
out[oidx+3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUniqueBlurs = (cfg, out = new Set()) => {
|
||||||
|
if (!cfg) return out;
|
||||||
|
if (cfg.mode === "LEAF" || !cfg.mode) {
|
||||||
|
out.add(cfg.blurRadius || 0);
|
||||||
|
} else if (cfg.children) {
|
||||||
|
for (let c of cfg.children) getUniqueBlurs(c, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const blurs = getUniqueBlurs(config);
|
||||||
|
for (let rad of blurs) {
|
||||||
|
if (rad > 0 && !blurCacheG.has(rad)) {
|
||||||
|
blurCacheG.set(rad, applyBlur(goldenData, rad));
|
||||||
|
blurCacheR.set(rad, applyBlur(renderedData, rad));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkPixel = (x, y, cfg) => {
|
||||||
|
const mode = cfg.mode || "LEAF";
|
||||||
|
if (mode === "LEAF") {
|
||||||
|
const shiftRad = cfg.shiftRadius || 0;
|
||||||
|
const blurRad = cfg.blurRadius || 0;
|
||||||
|
const maxAllowedDiff = (cfg.maxAbsDiff || 0.0) * 255.0;
|
||||||
|
const channelMask = cfg.channelMask !== undefined ? cfg.channelMask : 15;
|
||||||
|
|
||||||
|
const gData = blurCacheG.get(blurRad);
|
||||||
|
const rData = blurCacheR.get(blurRad);
|
||||||
|
|
||||||
|
const activeCh = [];
|
||||||
|
if (channelMask & 1) activeCh.push(0);
|
||||||
|
if (channelMask & 2) activeCh.push(1);
|
||||||
|
if (channelMask & 4) activeCh.push(2);
|
||||||
|
if (channelMask & 8) activeCh.push(3);
|
||||||
|
|
||||||
|
let rIdx = (y * width + x) * 4;
|
||||||
|
let rc = [rData[rIdx], rData[rIdx+1], rData[rIdx+2], rData[rIdx+3]];
|
||||||
|
|
||||||
|
for (let dy = -shiftRad; dy <= shiftRad; dy++) {
|
||||||
|
for (let dx = -shiftRad; dx <= shiftRad; dx++) {
|
||||||
|
let nx = Math.max(0, Math.min(width - 1, x + dx));
|
||||||
|
let ny = Math.max(0, Math.min(height - 1, y + dy));
|
||||||
|
let idx = (ny * width + nx) * 4;
|
||||||
|
|
||||||
|
let match = true;
|
||||||
|
for (let c of activeCh) {
|
||||||
|
if (Math.abs(gData[idx+c] - rc[c]) > maxAllowedDiff) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} else if (mode === "AND") {
|
||||||
|
for (let child of (cfg.children || [])) {
|
||||||
|
if (!checkPixel(x, y, child)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else if (mode === "OR") {
|
||||||
|
for (let child of (cfg.children || [])) {
|
||||||
|
if (checkPixel(x, y, child)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < width * height; i++) {
|
||||||
|
const idx = i * 4;
|
||||||
|
const x = i % width;
|
||||||
|
const y = Math.floor(i / width);
|
||||||
|
|
||||||
|
let rDiff = Math.abs(goldenData[idx] - renderedData[idx]);
|
||||||
|
let gDiff = Math.abs(goldenData[idx+1] - renderedData[idx+1]);
|
||||||
|
let bDiff = Math.abs(goldenData[idx+2] - renderedData[idx+2]);
|
||||||
|
let aDiff = Math.abs(goldenData[idx+3] - renderedData[idx+3]);
|
||||||
|
|
||||||
|
maxDiff[0] = Math.max(maxDiff[0], rDiff);
|
||||||
|
maxDiff[1] = Math.max(maxDiff[1], gDiff);
|
||||||
|
maxDiff[2] = Math.max(maxDiff[2], bDiff);
|
||||||
|
maxDiff[3] = Math.max(maxDiff[3], aDiff);
|
||||||
|
|
||||||
|
if (highlight) {
|
||||||
|
let pass = true;
|
||||||
|
if (config) {
|
||||||
|
pass = checkPixel(x, y, config);
|
||||||
|
} else {
|
||||||
|
pass = (rDiff === 0 && gDiff === 0 && bDiff === 0 && aDiff === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pass) {
|
||||||
|
imgDiff[idx] = 255;
|
||||||
|
imgDiff[idx+1] = 0;
|
||||||
|
imgDiff[idx+2] = 0;
|
||||||
|
imgDiff[idx+3] = 255;
|
||||||
|
} else {
|
||||||
|
imgDiff[idx] = rDiff;
|
||||||
|
imgDiff[idx+1] = gDiff;
|
||||||
|
imgDiff[idx+2] = bDiff;
|
||||||
|
imgDiff[idx+3] = 255;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
imgDiff[idx] = rDiff;
|
||||||
|
imgDiff[idx+1] = gDiff;
|
||||||
|
imgDiff[idx+2] = bDiff;
|
||||||
|
imgDiff[idx+3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxDiff[0] == 0 && maxDiff[1] == 0 && maxDiff[2] == 0 && maxDiff[3] == 0) {
|
||||||
|
return {
|
||||||
|
"result": RES_EQUAL,
|
||||||
|
"explanation": "Equal",
|
||||||
|
"dim": {"width": width, "height": height },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"result": RES_DIFFERENT_PIXELS,
|
||||||
|
"explanation": "Images are different",
|
||||||
|
"dim": {"width": width, "height": height },
|
||||||
|
"maxDiff": maxDiff,
|
||||||
|
"diffImg": imgDiff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_triggerDiff() {
|
||||||
|
const diff = this._computeDiff();
|
||||||
|
if (diff.result == RES_DIFFERENT_PIXELS) {
|
||||||
|
this.diffResult = diff;
|
||||||
|
this.originalDiffImageData = null;
|
||||||
|
const multDiv = this.shadowRoot.querySelector('#diffMultiplier');
|
||||||
|
if (multDiv) {
|
||||||
|
this._updateDiffCanvas(this.diffResult, multDiv.value);
|
||||||
|
} else {
|
||||||
|
this._updateDiffCanvas(this.diffResult, 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.diffResult = null;
|
||||||
|
this.currentDiffImageData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateDiffCanvas(diffResult, mult) {
|
||||||
|
const diffImgCopy = diffResult.diffImg.slice();
|
||||||
|
for (let i = 0; i < diffImgCopy.length; i += 4) {
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
diffImgCopy[i + j] = Math.min(255, mult * diffImgCopy[i + j]);
|
||||||
|
}
|
||||||
|
diffImgCopy[i + 3] = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgData = new ImageData(diffImgCopy, diffResult.dim.width, diffResult.dim.height);
|
||||||
|
|
||||||
|
if (!this.originalDiffImageData) {
|
||||||
|
this.originalDiffImageData = new ImageData(diffResult.diffImg.slice(), diffResult.dim.width, diffResult.dim.height);
|
||||||
|
}
|
||||||
|
this.currentDiffImageData = imgData;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onGlobalMouseLeave(event) {
|
||||||
|
for (const name of ['left', 'right', 'diff']) {
|
||||||
|
const viewer = this.shadowRoot.querySelector('#viewer-' + name);
|
||||||
|
if (viewer) {
|
||||||
|
const mag = viewer.shadowRoot?.getElementById('magnifier');
|
||||||
|
if (mag) {
|
||||||
|
mag.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onGlobalMouseMove(event) {
|
||||||
|
if (!this.magnifierEnabled) return;
|
||||||
|
for (const name of ['left', 'right', 'diff']) {
|
||||||
|
const viewer = this.shadowRoot.querySelector('#viewer-' + name);
|
||||||
|
if (!viewer) continue;
|
||||||
|
|
||||||
|
const canvas = viewer.shadowRoot?.querySelector('canvas');
|
||||||
|
if (!canvas) continue;
|
||||||
|
|
||||||
|
const imageData = viewer.imgdata;
|
||||||
|
if (!imageData) continue;
|
||||||
|
|
||||||
|
const magnifier = viewer.shadowRoot?.getElementById('magnifier');
|
||||||
|
|
||||||
|
if (!this._isMouseOverAnyView(event)) {
|
||||||
|
magnifier.hide();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const { imageX, imageY, mouseX, mouseY } = this._calculateEquivalentPosition(event, rect, imageData);
|
||||||
|
|
||||||
|
this._lastImageX = imageX;
|
||||||
|
this._lastImageY = imageY;
|
||||||
|
|
||||||
|
const origData = name == 'diff' ? this.originalDiffImageData : null;
|
||||||
|
viewer.updateMagnifier(imageX, imageY, origData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isMouseOverElement(event, rect) {
|
||||||
|
return event.clientX >= rect.left &&
|
||||||
|
event.clientX <= rect.right &&
|
||||||
|
event.clientY >= rect.top &&
|
||||||
|
event.clientY <= rect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isMouseOverAnyView(event) {
|
||||||
|
const checkView = (id) => {
|
||||||
|
const viewer = this.shadowRoot.querySelector(id);
|
||||||
|
if (viewer) {
|
||||||
|
const canvas = viewer.shadowRoot?.querySelector('canvas');
|
||||||
|
if (canvas && this._isMouseOverElement(event, canvas.getBoundingClientRect())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return checkView('#viewer-left') || checkView('#viewer-right') || checkView('#viewer-diff');
|
||||||
|
}
|
||||||
|
|
||||||
|
_calculateEquivalentPosition(event, targetRect, targetImageData) {
|
||||||
|
let sourceRect = null;
|
||||||
|
let sourceImageData = null;
|
||||||
|
|
||||||
|
const getSource = (id) => {
|
||||||
|
const viewer = this.shadowRoot.querySelector(id);
|
||||||
|
if (viewer) {
|
||||||
|
const canvas = viewer.shadowRoot?.querySelector('canvas');
|
||||||
|
if (canvas && this._isMouseOverElement(event, canvas.getBoundingClientRect())) {
|
||||||
|
return { rect: canvas.getBoundingClientRect(), img: viewer.imgdata };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let src = getSource('#viewer-left') || getSource('#viewer-right') || (this.diffResult ? getSource('#viewer-diff') : null);
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
sourceRect = src.rect;
|
||||||
|
sourceImageData = src.img;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceRect || !sourceImageData) {
|
||||||
|
sourceRect = targetRect;
|
||||||
|
sourceImageData = targetImageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceMouseX = event.clientX - sourceRect.left;
|
||||||
|
const sourceMouseY = event.clientY - sourceRect.top;
|
||||||
|
const sourceScaleX = sourceImageData.width / sourceRect.width;
|
||||||
|
const sourceScaleY = sourceImageData.height / sourceRect.height;
|
||||||
|
const sourceImageX = Math.floor(sourceMouseX * sourceScaleX);
|
||||||
|
const sourceImageY = Math.floor(sourceMouseY * sourceScaleY);
|
||||||
|
|
||||||
|
const targetScaleX = targetImageData.width / targetRect.width;
|
||||||
|
const targetScaleY = targetImageData.height / targetRect.height;
|
||||||
|
const targetMouseX = sourceImageX / targetScaleX;
|
||||||
|
const targetMouseY = sourceImageY / targetScaleY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageX: sourceImageX,
|
||||||
|
imageY: sourceImageY,
|
||||||
|
mouseX: targetMouseX,
|
||||||
|
mouseY: targetMouseY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateMagnifiersForToggle() {
|
||||||
|
if (!this.magnifierEnabled || this._lastImageX === undefined) return;
|
||||||
|
const activeViewerId = this.toggleState === 'rendered' ? '#viewer-right' : '#viewer-left';
|
||||||
|
const viewer = this.shadowRoot.querySelector(activeViewerId);
|
||||||
|
if (!viewer || !viewer.imgdata) return;
|
||||||
|
const origData = null; // No origData needed for golden/rendered
|
||||||
|
viewer.updateMagnifier(this._lastImageX, this._lastImageY, origData);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.isOpen || !this.runData) return html``;
|
||||||
|
|
||||||
|
const showDiff = !!this.diffResult;
|
||||||
|
|
||||||
|
const onMultiplierChange = (ev) => {
|
||||||
|
const multiplierValue = this.shadowRoot.querySelector('#multiplierValue');
|
||||||
|
multiplierValue.textContent = ev.target.value;
|
||||||
|
this._updateDiffCanvas(this.diffResult, ev.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMagnifierToggle = (ev) => {
|
||||||
|
this.magnifierEnabled = ev.target.checked;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHighlightToggle = (ev) => {
|
||||||
|
this.highlightFailing = ev.target.checked;
|
||||||
|
this._triggerDiff();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleViewMode = async () => {
|
||||||
|
this.viewMode = this.viewMode === 'side-by-side' ? 'toggle' : 'side-by-side';
|
||||||
|
if (this.viewMode === 'toggle') {
|
||||||
|
this.autoAlternate = true;
|
||||||
|
if (!this._alternateInterval) {
|
||||||
|
this._alternateInterval = setInterval(async () => {
|
||||||
|
this.toggleState = this.toggleState === 'rendered' ? 'golden' : 'rendered';
|
||||||
|
await this.updateComplete;
|
||||||
|
this._updateMagnifiersForToggle();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.autoAlternate = false;
|
||||||
|
if (this._alternateInterval) {
|
||||||
|
clearInterval(this._alternateInterval);
|
||||||
|
this._alternateInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.updateComplete;
|
||||||
|
if (this.viewMode === 'toggle') {
|
||||||
|
this._updateMagnifiersForToggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchToggleState = async () => {
|
||||||
|
if (this.autoAlternate) return;
|
||||||
|
this.toggleState = this.toggleState === 'rendered' ? 'golden' : 'rendered';
|
||||||
|
await this.updateComplete;
|
||||||
|
this._updateMagnifiersForToggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAutoAlternateChange = (ev) => {
|
||||||
|
this.autoAlternate = ev.target.checked;
|
||||||
|
if (this.autoAlternate) {
|
||||||
|
this._alternateInterval = setInterval(async () => {
|
||||||
|
this.toggleState = this.toggleState === 'rendered' ? 'golden' : 'rendered';
|
||||||
|
await this.updateComplete;
|
||||||
|
this._updateMagnifiersForToggle();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
if (this._alternateInterval) {
|
||||||
|
clearInterval(this._alternateInterval);
|
||||||
|
this._alternateInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="modal-overlay">
|
||||||
|
<div class="header">
|
||||||
|
<div class="title">${this.deviceName} - ${this.runData.testName}</div>
|
||||||
|
<div class="controls">
|
||||||
|
<button class="btn" @click="${toggleViewMode}">
|
||||||
|
${this.viewMode === 'side-by-side' ? 'Switch to Toggle Mode' : 'Switch to Side-by-Side'}
|
||||||
|
</button>
|
||||||
|
<button class="btn close-btn" @click="${this.close}">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-container">
|
||||||
|
<div class="viewer-container ${this.viewMode === 'toggle' ? 'toggle-mode' : ''}" @mousemove="${this._onGlobalMouseMove}" @mouseleave="${this._onGlobalMouseLeave}">
|
||||||
|
<div class="viewer-wrap ${this.viewMode === 'toggle' ? 'full-width' : ''}"
|
||||||
|
style="${this.viewMode === 'toggle' && this.toggleState !== 'golden' ? 'display: none;' : ''}">
|
||||||
|
<div class="viewer-label">Golden</div>
|
||||||
|
<tiff-viewer id="viewer-left" class="viewer"
|
||||||
|
fileurl="${this.runData.golden}"
|
||||||
|
?magnifier-enabled="${this.magnifierEnabled}"
|
||||||
|
disable-mouse-handlers></tiff-viewer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="viewer-wrap" style="${(!showDiff || this.viewMode === 'toggle') ? 'display: none;' : ''}">
|
||||||
|
<div class="viewer-label">Diff</div>
|
||||||
|
<tiff-viewer id="viewer-diff" class="viewer"
|
||||||
|
name="diff"
|
||||||
|
.srcdata="${this.currentDiffImageData}"
|
||||||
|
?magnifier-enabled="${this.magnifierEnabled}"
|
||||||
|
disable-mouse-handlers></tiff-viewer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="viewer-wrap ${this.viewMode === 'toggle' ? 'full-width' : ''}"
|
||||||
|
style="${this.viewMode === 'toggle' && this.toggleState !== 'rendered' ? 'display: none;' : ''}">
|
||||||
|
<div class="viewer-label">Rendered</div>
|
||||||
|
<tiff-viewer id="viewer-right" class="viewer"
|
||||||
|
fileurl="${this.runData.rendered}"
|
||||||
|
?magnifier-enabled="${this.magnifierEnabled}"
|
||||||
|
disable-mouse-handlers></tiff-viewer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-panel" style="${(this.viewMode === 'toggle' || !showDiff) ? 'display: none;' : ''}">
|
||||||
|
<div>
|
||||||
|
<strong>Difference Multiplier:</strong> <span id="multiplierValue">1</span>x
|
||||||
|
</div>
|
||||||
|
<input type="range" min="1" max="100" value="1" id="diffMultiplier" @input=${onMultiplierChange} style="width: 200px;">
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 20px; margin-top: 10px;">
|
||||||
|
<label style="cursor: pointer; display: flex; align-items: center; gap: 5px;">
|
||||||
|
<input type="checkbox" .checked="${this.magnifierEnabled}" @change=${onMagnifierToggle}>
|
||||||
|
Enable Magnifier
|
||||||
|
</label>
|
||||||
|
<label style="cursor: pointer; display: flex; align-items: center; gap: 5px;">
|
||||||
|
<input type="checkbox" .checked="${this.highlightFailing}" @change=${onHighlightToggle}>
|
||||||
|
Highlight Failing Pixels (Red)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-panel" style="${this.viewMode === 'side-by-side' ? 'display: none;' : ''}">
|
||||||
|
<div style="display: flex; gap: 20px; align-items: center;">
|
||||||
|
<button class="btn" @click="${switchToggleState}" ?disabled="${this.autoAlternate}">
|
||||||
|
Switch to ${this.toggleState === 'rendered' ? 'Golden' : 'Rendered'}
|
||||||
|
</button>
|
||||||
|
<label style="cursor: pointer; display: flex; align-items: center; gap: 5px;">
|
||||||
|
<input type="checkbox" .checked="${this.autoAlternate}" @change=${onAutoAlternateChange}>
|
||||||
|
Auto-Alternate (2s)
|
||||||
|
</label>
|
||||||
|
<label style="cursor: pointer; display: flex; align-items: center; gap: 5px;">
|
||||||
|
<input type="checkbox" .checked="${this.magnifierEnabled}" @change=${onMagnifierToggle}>
|
||||||
|
Enable Magnifier
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('image-viewer', ImageViewer);
|
||||||
333
test/render-validation/result-viewer/components/result-table.js
Normal file
333
test/render-validation/result-viewer/components/result-table.js
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import { html, css, LitElement } from 'lit';
|
||||||
|
import './test-filter.js';
|
||||||
|
|
||||||
|
export class ResultTable extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
results: { type: Array },
|
||||||
|
sortBy: { type: String },
|
||||||
|
backendFilter: { type: String },
|
||||||
|
nameFilters: { type: Array },
|
||||||
|
showOnlyFailures: { type: Boolean },
|
||||||
|
gpuFilters: { type: Array }
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.sortBy = 'name-gpu-os';
|
||||||
|
this.backendFilter = 'all';
|
||||||
|
this.nameFilters = [];
|
||||||
|
this.showOnlyFailures = false;
|
||||||
|
this.gpuFilters = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 100vh;
|
||||||
|
max-width: 100vw;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.filter-bar {
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
margin-left: 200px;
|
||||||
|
margin-right: -200px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 4px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: sticky;
|
||||||
|
top: 35px; /* Offset by filter-bar height approx */
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.device-col {
|
||||||
|
color: #34495e;
|
||||||
|
border-right: 1px solid #eee;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
z-index: 5;
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 200px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
th.device-col {
|
||||||
|
z-index: 15;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.result-cell {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.result-container {
|
||||||
|
display: flex;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
.result-container:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.pass {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
.fail {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.test-name {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.test-name-container {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.results || this.results.length === 0) {
|
||||||
|
return html`<p style="padding: 20px;">No results found.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract all unique test names to build columns
|
||||||
|
const allTests = new Set();
|
||||||
|
this.results.forEach(device => {
|
||||||
|
device.runs.forEach(run => allTests.add(run.testName));
|
||||||
|
});
|
||||||
|
|
||||||
|
let testNames = Array.from(allTests).sort(
|
||||||
|
(a, b) => {
|
||||||
|
let [atest, abackend, amodel] = a.split('.');
|
||||||
|
let [btest, bbackend, bmodel] = b.split('.');
|
||||||
|
if (abackend === bbackend) {
|
||||||
|
return atest < btest ? -1 : 1;
|
||||||
|
}
|
||||||
|
return abackend < bbackend ? -1 : 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply backend filter
|
||||||
|
if (this.backendFilter !== 'all') {
|
||||||
|
testNames = testNames.filter(name => {
|
||||||
|
const [, backend] = name.split('.');
|
||||||
|
return backend === this.backendFilter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply name filters (OR logic)
|
||||||
|
if (this.nameFilters && this.nameFilters.length > 0) {
|
||||||
|
testNames = testNames.filter(name => {
|
||||||
|
return this.nameFilters.some(filterStr => name.includes(filterStr));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply failure filter
|
||||||
|
if (this.showOnlyFailures) {
|
||||||
|
testNames = testNames.filter(name => {
|
||||||
|
return this.results.some(device => {
|
||||||
|
const run = device.runs.find(r => r.testName === name);
|
||||||
|
return run && !run.passed;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameToDiv = (name) => {
|
||||||
|
const [testName, backend, modelName] = name.split('.');
|
||||||
|
const sname = testName.split('_').map((n,i) => {
|
||||||
|
const style = (i > 0) ? 'font-size:8px' : '';
|
||||||
|
return html`<span style="${style}">${n}</span>`
|
||||||
|
});
|
||||||
|
const border = "border:1px solid black;border-radius:5px;padding:3px;";
|
||||||
|
const buttonStyle = border + "font-size:9px;";
|
||||||
|
const buttonColor = backend == 'opengl' ?
|
||||||
|
"background-color:#e0e3c0" :
|
||||||
|
"background-color:#b3b0f0";
|
||||||
|
return html`
|
||||||
|
<th class="test-name-container">
|
||||||
|
<div style="margin-bottom:5px;display:inline-flex;flex-direction:column;${border}">
|
||||||
|
${sname}
|
||||||
|
</div>
|
||||||
|
<span style="display:flex">
|
||||||
|
<div style="${buttonStyle};${buttonColor}">
|
||||||
|
${backend}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
const testRow = testNames.map(nameToDiv);
|
||||||
|
|
||||||
|
const getShortGPUName = (device) => {
|
||||||
|
const gpuStr = device.metadata?.gpu_driver_info?.opengl || '';
|
||||||
|
if (gpuStr.includes('PowerVR')) return 'PowerVR';
|
||||||
|
if (gpuStr.includes('Mali')) return 'Mali';
|
||||||
|
if (gpuStr.includes('Adreno')) return 'Adreno';
|
||||||
|
if (gpuStr.includes('Xclipse')) return 'Xclipse';
|
||||||
|
return gpuStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
let filteredDevices = this.results;
|
||||||
|
if (this.gpuFilters && this.gpuFilters.length > 0) {
|
||||||
|
filteredDevices = filteredDevices.filter(device => {
|
||||||
|
const gpuStr = device.metadata?.gpu_driver_info?.opengl || '';
|
||||||
|
return this.gpuFilters.some(filterStr => gpuStr.toLowerCase().includes(filterStr.toLowerCase()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedResults = [...filteredDevices].sort((a, b) => {
|
||||||
|
const nameA = a.metadata.device_name || '';
|
||||||
|
const nameB = b.metadata.device_name || '';
|
||||||
|
|
||||||
|
const gpuA = getShortGPUName(a);
|
||||||
|
const gpuB = getShortGPUName(b);
|
||||||
|
|
||||||
|
const osA = parseInt(a.metadata.android_version, 10) || 0;
|
||||||
|
const osB = parseInt(b.metadata.android_version, 10) || 0;
|
||||||
|
|
||||||
|
const cmpName = nameA.localeCompare(nameB);
|
||||||
|
const cmpGpu = gpuA.localeCompare(gpuB);
|
||||||
|
const cmpOs = osA - osB;
|
||||||
|
|
||||||
|
if (this.sortBy === 'name-gpu-os') {
|
||||||
|
if (cmpName !== 0) return cmpName;
|
||||||
|
if (cmpGpu !== 0) return cmpGpu;
|
||||||
|
return cmpOs;
|
||||||
|
} else if (this.sortBy === 'gpu-os-name') {
|
||||||
|
if (cmpGpu !== 0) return cmpGpu;
|
||||||
|
if (cmpOs !== 0) return cmpOs;
|
||||||
|
return cmpName;
|
||||||
|
} else if (this.sortBy === 'os-gpu-name') {
|
||||||
|
if (cmpOs !== 0) return cmpOs;
|
||||||
|
if (cmpGpu !== 0) return cmpGpu;
|
||||||
|
return cmpName;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div style="position:absolute;width:200px;height:50px;background:white;z-index:10;"> </div>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<span style="font-weight: 600;">Backend Filter:</span>
|
||||||
|
<select style="font-size: 11px; padding: 4px;" @change="${(e) => this.backendFilter = e.target.value}">
|
||||||
|
<option value="all" ?selected="${this.backendFilter === 'all'}">opengl / vulkan</option>
|
||||||
|
<option value="opengl" ?selected="${this.backendFilter === 'opengl'}">opengl</option>
|
||||||
|
<option value="vulkan" ?selected="${this.backendFilter === 'vulkan'}">vulkan</option>
|
||||||
|
</select>
|
||||||
|
<div style="width: 1px; height: 20px; background: #ccc; margin: 0 10px;"></div>
|
||||||
|
<test-filter @filters-changed="${(e) => this.nameFilters = e.detail.filters}"></test-filter>
|
||||||
|
<div style="width: 1px; height: 20px; background: #ccc; margin: 0 10px;"></div>
|
||||||
|
<label style="display: flex; align-items: center; gap: 4px; font-weight: 600; font-size: 11px; cursor: pointer;">
|
||||||
|
<input type="checkbox" .checked="${this.showOnlyFailures}" @change="${(e) => this.showOnlyFailures = e.target.checked}">
|
||||||
|
Only columns with failed tests
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="device-col" style="font-size:15px; vertical-align: top; padding-top: 8px;">
|
||||||
|
<div>Device</div>
|
||||||
|
<select style="font-size: 11px; margin-top: 6px; width: 100%; padding: 2px;" @change="${(e) => this.sortBy = e.target.value}">
|
||||||
|
<option value="name-gpu-os" ?selected="${this.sortBy === 'name-gpu-os'}">Device -> GPU -> Android Ver</option>
|
||||||
|
<option value="gpu-os-name" ?selected="${this.sortBy === 'gpu-os-name'}">GPU -> Android Ver -> Device</option>
|
||||||
|
<option value="os-gpu-name" ?selected="${this.sortBy === 'os-gpu-name'}">Android Ver -> GPU -> Device</option>
|
||||||
|
</select>
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
<test-filter label="GPU:" placeholder="Filter GPU..." @filters-changed="${(e) => this.gpuFilters = e.detail.filters}"></test-filter>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
${testRow}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${
|
||||||
|
sortedResults.map(device => {
|
||||||
|
const processBuildNumber = (rawBuild) => {
|
||||||
|
if (rawBuild.indexOf(' ') >=0 && rawBuild.indexOf('dev-keys') >= 0) {
|
||||||
|
return rawBuild.split(' ')[2].split('.')[0];
|
||||||
|
}
|
||||||
|
return rawBuild.split('.')[0];
|
||||||
|
};
|
||||||
|
const androidVersion = device.metadata.android_version + " " +
|
||||||
|
processBuildNumber(device.metadata.android_build_number);
|
||||||
|
const glGPU = device.metadata.gpu_driver_info.opengl.split(' | ');
|
||||||
|
const driverInfo = device.metadata.gpu_driver_info.vulkan.split(' | ')[2];
|
||||||
|
const truncatedStyle = "display:block;width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
|
||||||
|
const marginLow = "margin-bottom:4px;"
|
||||||
|
const androidVerStyle = marginLow + (this.sortBy.startsWith('os') ? "color:#f09090;" : '');
|
||||||
|
const gpuStyle = marginLow + (this.sortBy.startsWith('gpu') ? "color:#f09090;" : '');
|
||||||
|
const hardwareStyle = marginLow + "color:#bbb;";
|
||||||
|
const deviceNameStyle = marginLow + "font-size:15px;font-weight:bold;";
|
||||||
|
return html`
|
||||||
|
<tr>
|
||||||
|
<td class="device-col">
|
||||||
|
<div style="${deviceNameStyle}">${device.metadata.device_name} </div>
|
||||||
|
<div style="${hardwareStyle}">${device.metadata.device_hardware} </div>
|
||||||
|
<div style="${androidVerStyle}">Android ${androidVersion}</div>
|
||||||
|
<div style="${gpuStyle}">${glGPU[0]} ${glGPU[1]}</div>
|
||||||
|
<div style="${truncatedStyle}">${driverInfo}</div>
|
||||||
|
</td>
|
||||||
|
${
|
||||||
|
testNames.map(testName => {
|
||||||
|
const run = device.runs.find(r => r.testName === testName);
|
||||||
|
if (!run) return html`<td>-</td>`;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<td class="result-cell">
|
||||||
|
<div class="result-container ${run.passed ? 'pass' : 'fail'}"
|
||||||
|
@click="${() => this._handleThumbnailClick(device.device, run)}">
|
||||||
|
<img class="thumb" src="${run.thumb}" loading="lazy" alt="Thumbnail" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
`})
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleThumbnailClick(device, run) {
|
||||||
|
this.dispatchEvent(new CustomEvent('view-result', {
|
||||||
|
detail: { device, run },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('result-table', ResultTable);
|
||||||
100
test/render-validation/result-viewer/components/test-filter.js
Normal file
100
test/render-validation/result-viewer/components/test-filter.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { html, css, LitElement } from 'lit';
|
||||||
|
|
||||||
|
export class TestFilter extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
filters: { type: Array },
|
||||||
|
label: { type: String },
|
||||||
|
placeholder: { type: String }
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.filters = [];
|
||||||
|
this.label = 'Test Name:';
|
||||||
|
this.placeholder = 'Filter tests...';
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #e0e0e0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.remove-btn:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
${this.label ? html`<span style="font-weight: 600; font-size: 11px;">${this.label}</span>` : ''}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
.placeholder="${this.placeholder}"
|
||||||
|
@keydown="${this._handleKeydown}"
|
||||||
|
>
|
||||||
|
${this.filters.map((filter, index) => html`
|
||||||
|
<div class="tag">
|
||||||
|
${filter}
|
||||||
|
<button class="remove-btn" @click="${() => this._removeFilter(index)}">×</button>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleKeydown(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const val = e.target.value.trim();
|
||||||
|
if (val && !this.filters.includes(val)) {
|
||||||
|
this.filters = [...this.filters, val];
|
||||||
|
this._dispatchChange();
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeFilter(index) {
|
||||||
|
this.filters = this.filters.filter((_, i) => i !== index);
|
||||||
|
this._dispatchChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
_dispatchChange() {
|
||||||
|
this.dispatchEvent(new CustomEvent('filters-changed', {
|
||||||
|
detail: { filters: this.filters },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('test-filter', TestFilter);
|
||||||
330
test/render-validation/result-viewer/components/tiff-viewer.js
Normal file
330
test/render-validation/result-viewer/components/tiff-viewer.js
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
// Copyright (C) 2025 The Android Open Source Project
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { LitElement, html, css } from "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js";
|
||||||
|
|
||||||
|
class ImageMagnifier extends LitElement {
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([visible]) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magnifier {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 75px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.magnifier-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 73px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-info {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 15px;
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: pre-line;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
static properties = {
|
||||||
|
visible: {type: Boolean, reflect: true},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<div class="magnifier">
|
||||||
|
<canvas class="magnifier-canvas" id="magnifierCanvas"></canvas>
|
||||||
|
<div class="pixel-info" id="pixelInfo"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMagnifier(imageData, parentRect, imageX, imageY, originalImageData = null) {
|
||||||
|
const zoomFactor = 8;
|
||||||
|
if (!imageData) return;
|
||||||
|
|
||||||
|
if (imageX < 0 || imageX >= imageData.width || imageY < 0 || imageY >= imageData.height) {
|
||||||
|
this.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixelIndex = (imageY * imageData.width + imageX) * 4;
|
||||||
|
const r = imageData.data[pixelIndex];
|
||||||
|
const g = imageData.data[pixelIndex + 1];
|
||||||
|
const b = imageData.data[pixelIndex + 2];
|
||||||
|
const a = imageData.data[pixelIndex + 3];
|
||||||
|
|
||||||
|
let pixelInfoText = `(${r}, ${g}, ${b}, ${a})\n@ (${imageX}, ${imageY})`;
|
||||||
|
|
||||||
|
// If original image data is provided, show the unmultiplied values too
|
||||||
|
if (originalImageData) {
|
||||||
|
const origR = originalImageData.data[pixelIndex];
|
||||||
|
const origG = originalImageData.data[pixelIndex + 1];
|
||||||
|
const origB = originalImageData.data[pixelIndex + 2];
|
||||||
|
const origA = originalImageData.data[pixelIndex + 3];
|
||||||
|
pixelInfoText = `Orig: (${origR}, ${origG}, ${origB}, ${origA})\nMult: (${r}, ${g}, ${b}, ${a})\n@ (${imageX}, ${imageY})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const magnifierSize = 150;
|
||||||
|
const sourceSize = magnifierSize / zoomFactor;
|
||||||
|
const halfSource = sourceSize / 2;
|
||||||
|
|
||||||
|
const sourceX = Math.max(0, Math.min(imageData.width - sourceSize, imageX - halfSource));
|
||||||
|
const sourceY = Math.max(0, Math.min(imageData.height - sourceSize, imageY - halfSource));
|
||||||
|
|
||||||
|
const magnifierCanvas = this.shadowRoot.getElementById('magnifierCanvas');
|
||||||
|
const pixelInfo = this.shadowRoot.getElementById('pixelInfo');
|
||||||
|
|
||||||
|
magnifierCanvas.width = magnifierSize;
|
||||||
|
magnifierCanvas.height = magnifierSize;
|
||||||
|
const magnifierCtx = magnifierCanvas.getContext('2d');
|
||||||
|
|
||||||
|
magnifierCtx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = imageData.width;
|
||||||
|
tempCanvas.height = imageData.height;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d');
|
||||||
|
tempCtx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
magnifierCtx.drawImage(
|
||||||
|
tempCanvas,
|
||||||
|
sourceX, sourceY, sourceSize, sourceSize,
|
||||||
|
0, 0, magnifierSize, magnifierSize
|
||||||
|
);
|
||||||
|
|
||||||
|
const centerX = magnifierSize / 2;
|
||||||
|
const centerY = magnifierSize / 2;
|
||||||
|
const lineWidth = 1;
|
||||||
|
const boxWidth = zoomFactor + lineWidth;
|
||||||
|
magnifierCtx.strokeStyle = 'red';
|
||||||
|
magnifierCtx.lineWidth = lineWidth;
|
||||||
|
magnifierCtx.beginPath();
|
||||||
|
magnifierCtx.moveTo(centerX, centerY);
|
||||||
|
magnifierCtx.lineTo(centerX + boxWidth, centerY);
|
||||||
|
magnifierCtx.lineTo(centerX + boxWidth, centerY + boxWidth);
|
||||||
|
magnifierCtx.lineTo(centerX, centerY + boxWidth);
|
||||||
|
magnifierCtx.lineTo(centerX, centerY);
|
||||||
|
magnifierCtx.stroke();
|
||||||
|
|
||||||
|
// Position relative to the TiffViewer container
|
||||||
|
this.style.left = Math.round(-centerX +
|
||||||
|
(imageX / imageData.width) * parentRect.width -
|
||||||
|
boxWidth) + 'px';
|
||||||
|
this.style.top = Math.round(-centerY +
|
||||||
|
(imageY / imageData.height) * parentRect.height -
|
||||||
|
boxWidth) + 'px';
|
||||||
|
pixelInfo.textContent = pixelInfoText;
|
||||||
|
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('image-magnifier', ImageMagnifier);
|
||||||
|
|
||||||
|
// Generated by Gemini with some modifications
|
||||||
|
export class TiffViewer extends LitElement {
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
static properties = {
|
||||||
|
fileurl: {type: String, attribute: 'fileurl'},
|
||||||
|
failedToFetch: {type: Boolean },
|
||||||
|
magnifierEnabled: {type: Boolean, attribute: 'magnifier-enabled'},
|
||||||
|
disableMouseHandlers: {type: Boolean, attribute: 'disable-mouse-handlers'},
|
||||||
|
srcdata: {type: Object, attribute: 'srcdata'},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.fileurl = null;
|
||||||
|
this.failedToFetch = false;
|
||||||
|
this.magnifierEnabled = false;
|
||||||
|
this.disableMouseHandlers = false;
|
||||||
|
this.imgdata = null;
|
||||||
|
this.srcdata = null;
|
||||||
|
this.canvasRect = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.failedToFetch) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<canvas id="tiffCanvas"
|
||||||
|
@mousemove="${this._onMouseMove}"
|
||||||
|
@mouseenter="${this._onMouseEnter}"
|
||||||
|
@mouseleave="${this._onMouseLeave}">
|
||||||
|
</canvas>
|
||||||
|
<image-magnifier id="magnifier"></image-magnifier>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(props) {
|
||||||
|
if (props.has('fileurl') && this.fileurl) {
|
||||||
|
this._updateImage(this.fileurl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.has('srcdata') && this.srcdata) {
|
||||||
|
this._drawImage(this.srcdata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawImage(imageData) {
|
||||||
|
const canvas = this.shadowRoot.getElementById('tiffCanvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
canvas.width = imageData.width;
|
||||||
|
canvas.height = imageData.height;
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
this.imgdata = imageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _updateImage(fileurl) {
|
||||||
|
this.failedToFetch = false;
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "Anonymous";
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = this.shadowRoot.getElementById('tiffCanvas');
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||||
|
|
||||||
|
// The default mode would set alpha to 1 so that RGB differences would be displayed as non-transparent
|
||||||
|
for (let i = 3; i < imageData.data.length; i += 4) {
|
||||||
|
imageData.data[i] = 255;
|
||||||
|
}
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
this.imgdata = imageData;
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('url-hit', {
|
||||||
|
detail: { value: fileurl },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('image-loaded', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: {
|
||||||
|
url: this.fileurl,
|
||||||
|
img: imageData,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
this.failedToFetch = true;
|
||||||
|
this.dispatchEvent(new CustomEvent('url-miss', {
|
||||||
|
detail: { value: fileurl },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
this._clearCanvas();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = fileurl;
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearCanvas() {
|
||||||
|
const canvas = this.shadowRoot.getElementById('tiffCanvas');
|
||||||
|
if (canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMouseEnter(event) {
|
||||||
|
if (this.disableMouseHandlers || !this.magnifierEnabled || !this.imgdata) return;
|
||||||
|
this.canvasRect = event.target.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMouseLeave(event) {
|
||||||
|
if (this.disableMouseHandlers || !this.magnifierEnabled) return;
|
||||||
|
const magnifier = this.shadowRoot.getElementById('magnifier');
|
||||||
|
magnifier.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMouseMove(event) {
|
||||||
|
if (this.disableMouseHandlers || !this.canvasRect) return;
|
||||||
|
const rect = this.canvasRect;
|
||||||
|
|
||||||
|
const scaleX = this.imgdata.width / rect.width;
|
||||||
|
const scaleY = this.imgdata.height / rect.height;
|
||||||
|
|
||||||
|
const mouseX = event.clientX - rect.left;
|
||||||
|
const mouseY = event.clientY - rect.top;
|
||||||
|
|
||||||
|
const imageX = Math.floor(mouseX * scaleX);
|
||||||
|
const imageY = Math.floor(mouseY * scaleY);
|
||||||
|
|
||||||
|
this.updateMagnifier(imageX, imageY);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMagnifier(imageX, imageY, origData = null) {
|
||||||
|
let rect = null;
|
||||||
|
const canvas = this.shadowRoot.getElementById('tiffCanvas');
|
||||||
|
if (canvas) {
|
||||||
|
rect = canvas.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
if (!this.magnifierEnabled || !this.imgdata || !rect) return;
|
||||||
|
const magnifier = this.shadowRoot.getElementById('magnifier');
|
||||||
|
magnifier.updateMagnifier(this.imgdata, rect, imageX, imageY, origData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tiff-viewer', TiffViewer);
|
||||||
225
test/render-validation/result-viewer/components/tools.js
Normal file
225
test/render-validation/result-viewer/components/tools.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
// Copyright (C) 2025 The Android Open Source Project
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { LitElement, html, css } from "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js";
|
||||||
|
|
||||||
|
// Generated by Gemini
|
||||||
|
export class RadioButtonGroup extends LitElement {
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
.radio-group-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
label:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
input[type="radio"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
/* Custom radio button appearance */
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid #ccc;
|
||||||
|
border-radius: 50%;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
input[type="radio"]:checked {
|
||||||
|
border-color: #656565;
|
||||||
|
background-color: #656565; /* Optional: fill color when checked */
|
||||||
|
}
|
||||||
|
input[type="radio"]:checked::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin: 3px; /* Adjust to center the dot */
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
input[type="radio"]:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
.label-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
static properties = {
|
||||||
|
/**
|
||||||
|
* An array of strings representing the choices for the radio buttons.
|
||||||
|
* @type {Array<string>}
|
||||||
|
*/
|
||||||
|
choices: { type: Array },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name for the radio button group. This is important for accessibility
|
||||||
|
* and ensuring only one radio button in the group can be selected.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
name: { type: String },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currently selected value.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
value: { type: String, reflect: true },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label or title for the radio group.
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
groupLabel: { type: String }
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.choices = [];
|
||||||
|
this.name = 'radio-group'; // Default name
|
||||||
|
this.value = '';
|
||||||
|
this.groupLabel = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleChange(event) {
|
||||||
|
const selectedValue = event.target.value;
|
||||||
|
if (this.value !== selectedValue) {
|
||||||
|
this.value = selectedValue;
|
||||||
|
// Dispatch a custom event with the new value
|
||||||
|
this.dispatchEvent(new CustomEvent('radio-change', {
|
||||||
|
detail: { value: this.value, radioId: this.id },
|
||||||
|
bubbles: true, // Allows the event to bubble up through the DOM
|
||||||
|
composed: true // Allows the event to cross shadow DOM boundaries
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<div class="radio-group-container" role="radiogroup" aria-labelledby="group-label">
|
||||||
|
${this.groupLabel ? html`<span id="group-label" class="group-label">${this.groupLabel}</span>` : ''}
|
||||||
|
${this.choices.map(choice => html`
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name=${this.name}
|
||||||
|
.value=${choice}
|
||||||
|
.checked=${choice === this.value}
|
||||||
|
@change=${this._handleChange}
|
||||||
|
>
|
||||||
|
<span class="label-text">${choice}</span>
|
||||||
|
</label>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('radio-button-group', RadioButtonGroup);
|
||||||
|
|
||||||
|
// Generated by Gemini with some modifications
|
||||||
|
class ModalDialog extends LitElement {
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: none; /* Hidden by default */
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([open]) {
|
||||||
|
display: block; /* Show when open attribute is present */
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent black */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000; /* Ensure it's on top */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 300px; /* Or your desired width */
|
||||||
|
z-index: 1001; /* Above the backdrop */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 15px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
open: { type: Boolean, reflect: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleBackdropClick(event) {
|
||||||
|
// Close only if the click is directly on the backdrop, not on the dialog itself
|
||||||
|
if (event.target === this.shadowRoot.querySelector('.backdrop')) {
|
||||||
|
this.open = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('dialog-closed', { bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleDialogClick(event) {
|
||||||
|
// Prevent clicks inside the dialog from bubbling up to the backdrop
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.open) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="backdrop" @click="${this._handleBackdropClick}">
|
||||||
|
<div class="dialog" @click="${this._handleDialogClick}">
|
||||||
|
<slot name="header"><h2>Default Header</h2></slot>
|
||||||
|
<slot>
|
||||||
|
<p>This is the default content of the modal.</p>
|
||||||
|
</slot>
|
||||||
|
<slot name="footer">
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('modal-dialog', ModalDialog);
|
||||||
54
test/render-validation/result-viewer/index.html
Normal file
54
test/render-validation/result-viewer/index.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Render Validation Results</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #3498db;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
#app {
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
#container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- Use ES modules to load Lit -->
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"lit": "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="container">
|
||||||
|
<div class="loading">Loading data...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -135,11 +135,8 @@ class FileItem(Static):
|
|||||||
with Horizontal(id="file_row", classes="file-item-row"):
|
with Horizontal(id="file_row", classes="file-item-row"):
|
||||||
yield Label(self.filename, id="lbl_filename", classes="file-name")
|
yield Label(self.filename, id="lbl_filename", classes="file-name")
|
||||||
|
|
||||||
# Only show Load button for test configurations, not results
|
|
||||||
if not self.filename.startswith("results_"):
|
if not self.filename.startswith("results_"):
|
||||||
yield Button("▶", id="btn_load", variant="success", classes="compact-btn", tooltip="Load this test on device")
|
yield Button("▶", id="btn_load", variant="success", classes="compact-btn", tooltip="Load this test on device")
|
||||||
else:
|
|
||||||
yield Button("🌐", id="btn_serve", variant="success", classes="compact-btn", tooltip="Serve and view results locally")
|
|
||||||
|
|
||||||
yield Button("↓", id="btn_download", variant="primary", classes="compact-btn", tooltip="Download to PC")
|
yield Button("↓", id="btn_download", variant="primary", classes="compact-btn", tooltip="Download to PC")
|
||||||
yield Button("✎", id="btn_start_rename", variant="warning", classes="compact-btn", tooltip="Rename on device")
|
yield Button("✎", id="btn_start_rename", variant="warning", classes="compact-btn", tooltip="Rename on device")
|
||||||
@@ -148,11 +145,9 @@ class FileItem(Static):
|
|||||||
yield Input(value=self.filename, id="inp_rename", classes="rename-input")
|
yield Input(value=self.filename, id="inp_rename", classes="rename-input")
|
||||||
yield Button("Save", id="btn_save_rename", variant="success", classes="compact-btn")
|
yield Button("Save", id="btn_save_rename", variant="success", classes="compact-btn")
|
||||||
yield Button("Cancel", id="btn_cancel_rename", classes="compact-btn")
|
yield Button("Cancel", id="btn_cancel_rename", classes="compact-btn")
|
||||||
yield Label("", id="lbl_server_url", classes="server-url")
|
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
self.query_one("#rename_row").display = False
|
self.query_one("#rename_row").display = False
|
||||||
self.query_one("#lbl_server_url").display = False
|
|
||||||
|
|
||||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
btn_id = event.button.id
|
btn_id = event.button.id
|
||||||
@@ -210,81 +205,8 @@ class FileItem(Static):
|
|||||||
self.app.notify(f"Loading {self.filename} on device...", title="Load Test")
|
self.app.notify(f"Loading {self.filename} on device...", title="Load Test")
|
||||||
self.run_worker(self.load_on_device(), exclusive=True)
|
self.run_worker(self.load_on_device(), exclusive=True)
|
||||||
|
|
||||||
elif btn_id == "btn_serve":
|
|
||||||
if hasattr(self, "server_proc") and self.server_proc:
|
|
||||||
self.stop_server(event.button)
|
|
||||||
else:
|
|
||||||
event.button.disabled = True
|
|
||||||
self.run_worker(self.start_server(event.button), exclusive=True)
|
|
||||||
|
|
||||||
async def start_server(self, button: Button) -> None:
|
|
||||||
try:
|
|
||||||
# Create a tmp directory for the results
|
|
||||||
tmp_dir = os.path.join(os.getcwd(), "tmp")
|
|
||||||
os.makedirs(tmp_dir, exist_ok=True)
|
|
||||||
dest = os.path.join(tmp_dir, self.filename)
|
|
||||||
|
|
||||||
# Download file to tmp
|
|
||||||
if not os.path.exists(dest):
|
|
||||||
self.app.notify(f"Downloading {self.filename} for viewer...", title="Preparing Server")
|
|
||||||
if self.is_internal:
|
|
||||||
cmd = f"adb -s {self.serial} shell \"run-as {PACKAGE} cat {self.filepath}\" > \"{dest}\""
|
|
||||||
proc = await asyncio.create_subprocess_shell(cmd)
|
|
||||||
await proc.communicate()
|
|
||||||
else:
|
|
||||||
await run_adb_cmd("-s", self.serial, "pull", self.filepath, dest)
|
|
||||||
|
|
||||||
# Find unoccupied port
|
|
||||||
import socket
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.bind(("", 0))
|
|
||||||
port = s.getsockname()[1]
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
# Start server
|
|
||||||
server_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "result-viewer", "server.py")
|
|
||||||
self.server_proc = await asyncio.create_subprocess_exec(
|
|
||||||
"python3", server_script, dest, "--port", str(port)
|
|
||||||
)
|
|
||||||
|
|
||||||
button.label = "🛑"
|
|
||||||
button.variant = "error"
|
|
||||||
button.tooltip = "Stop Server"
|
|
||||||
|
|
||||||
lbl_url = self.query_one("#lbl_server_url", Label)
|
|
||||||
lbl_url.update(f" ↳ Server: http://localhost:{port}")
|
|
||||||
lbl_url.display = True
|
|
||||||
|
|
||||||
self.app.notify(f"Result viewer started on http://localhost:{port}", title="Server Started")
|
|
||||||
except Exception as e:
|
|
||||||
self.app.notify(f"Failed to start server: {e}", title="Server Error", severity="error")
|
|
||||||
button.label = "🌐"
|
|
||||||
button.variant = "success"
|
|
||||||
button.tooltip = "Serve and view results locally"
|
|
||||||
self.query_one("#lbl_server_url", Label).display = False
|
|
||||||
self.server_proc = None
|
|
||||||
finally:
|
|
||||||
button.disabled = False
|
|
||||||
|
|
||||||
def stop_server(self, button: Button = None) -> None:
|
|
||||||
if hasattr(self, "server_proc") and self.server_proc:
|
|
||||||
try:
|
|
||||||
self.server_proc.terminate()
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
self.server_proc = None
|
|
||||||
if button:
|
|
||||||
button.label = "🌐"
|
|
||||||
button.variant = "success"
|
|
||||||
button.tooltip = "Serve and view results locally"
|
|
||||||
try:
|
|
||||||
self.query_one("#lbl_server_url", Label).display = False
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.app.notify("Server stopped", title="Server Stopped")
|
|
||||||
|
|
||||||
def on_unmount(self) -> None:
|
def on_unmount(self) -> None:
|
||||||
self.stop_server()
|
pass
|
||||||
|
|
||||||
async def load_on_device(self) -> None:
|
async def load_on_device(self) -> None:
|
||||||
cmd_args = [
|
cmd_args = [
|
||||||
|
|||||||
Reference in New Issue
Block a user