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
|
||||
1. Ensure you have Python 3 and `adb` installed and in your PATH.
|
||||
2. Navigate to the TUI directory: `cd test/render-validation`
|
||||
3. Create a virtual environment: `python3 -m venv venv`
|
||||
4. Activate it: `source venv/bin/activate` (Mac/Linux) or `venv\Scripts\activate` (Windows)
|
||||
5. Install requirements: `pip install -r requirements.txt` (Installs the `textual` framework)
|
||||
### Setup & Requirements
|
||||
The results processor requires `numpy` and `Pillow`. These are not included in the main `requirements.txt` to keep the TUI dependencies minimal.
|
||||
|
||||
1. Install processing dependencies:
|
||||
```bash
|
||||
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
|
||||
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
|
||||
- **Auto-Polling Mechanism**: Syncs the file lists with your device every 2 seconds.
|
||||
- **Generate Test/Result Buttons**: One-click execution of the `am start` intents.
|
||||
- **Upload Local Test Bundle**: Automatically pushes a local `.zip` file from your PC to the correct directory on the Android device.
|
||||
- **Per-File Actions**:
|
||||
- `▶` (Load): Restarts the app with `--es zip_path <filename>` to set it as the active test on device.
|
||||
- `↓` (Download): Pulls the `.zip` to your PC's current working directory.
|
||||
- `✎` (Rename): Quickly renames the file directly on the Android file system.
|
||||
- `✗` (Delete): Quickly removes the file from the Android device to free up storage.
|
||||
This script:
|
||||
- Extracts images and metadata from the result zips.
|
||||
- Generates thumbnails for efficient browser performance.
|
||||
- Packages the exact tolerance configurations for the web viewer.
|
||||
|
||||
### 2. View Results
|
||||
Because the viewer uses ES modules and fetches data, it must be served via a web server.
|
||||
|
||||
```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
|
||||
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"):
|
||||
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_"):
|
||||
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_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 Button("Save", id="btn_save_rename", variant="success", 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):
|
||||
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:
|
||||
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.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:
|
||||
self.stop_server()
|
||||
pass
|
||||
|
||||
async def load_on_device(self) -> None:
|
||||
cmd_args = [
|
||||
|
||||
Reference in New Issue
Block a user