a789495a98
CI / Quality Guardrails (push) Waiting to run
CI / Build & Test (macos-latest) (push) Waiting to run
CI / Build & Test (ubuntu-latest) (push) Waiting to run
CI / Build & Test (windows-latest) (push) Waiting to run
CI / Format (push) Waiting to run
CI / PowerShell Syntax (push) Waiting to run
CI / Windows Cross-Target Check (Linux) (push) Waiting to run
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
194 lines
7.0 KiB
Python
Executable File
194 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Quantitative quality metrics for Jcode Desktop inline widgets.
|
|
|
|
Captures widget states headlessly and scores each card from its PNG plus the
|
|
geometry in the capture manifest:
|
|
|
|
bottom_clip ink fraction in the last few px above the card's bottom
|
|
border. Sliced glyphs leave ink touching the boundary;
|
|
clean cards end with padding. Lower is better.
|
|
right_clip same for the right border (text running off the card).
|
|
row_fit |content_height mod line_height| in px, normalized to
|
|
[0, 0.5*lh]. Non-zero means the card cannot end on a whole
|
|
text row.
|
|
edge_margin min distance in px from any ink to the card border
|
|
(should be >= ~4px padding).
|
|
|
|
Usage:
|
|
scripts/widget_quality.py [--size WxH] [--states a,b,c] [--json]
|
|
|
|
Exit code 1 if any state fails a budget.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from PIL import Image
|
|
except ImportError:
|
|
print("error: pillow required", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DEFAULT_BIN = ROOT / "target" / "debug" / "jcode-desktop"
|
|
WIDGET_STATES = [
|
|
"hotkey-help",
|
|
"model-picker",
|
|
"session-info",
|
|
"session-switcher",
|
|
"slash-suggestions",
|
|
]
|
|
|
|
# Budgets: a card fails when any metric exceeds these.
|
|
BOTTOM_CLIP_BUDGET = 0.02 # <=2% ink density in the clip band
|
|
RIGHT_CLIP_BUDGET = 0.02
|
|
EDGE_MARGIN_BUDGET = 3.0 # ink should keep >=3px from the border
|
|
ROW_CUT_BUDGET = 0.15 # clip window must end within 0.15 rows of a whole row
|
|
CLIP_BAND_PX = 4 # band width inspected inside each border
|
|
|
|
|
|
def luminance_ink(img: Image.Image, background_estimate: int = 16) -> Image.Image:
|
|
"""Binary ink mask: pixels notably darker than their card background."""
|
|
gray = img.convert("L")
|
|
# Card backgrounds are near-white (>=230); text is dark (<=120).
|
|
return gray.point(lambda v: 255 if v < 180 else 0)
|
|
|
|
|
|
def analyze(image_path: Path, geometry: dict) -> dict:
|
|
img = Image.open(image_path)
|
|
card = geometry["card"]
|
|
line_height = geometry["line_height"]
|
|
text_top = geometry["text_top"]
|
|
clip_bottom = geometry["visible_text_bottom"]
|
|
clip_right = geometry.get("visible_text_right")
|
|
x0 = max(int(card["x"]), 0)
|
|
y0 = max(int(card["y"]), 0)
|
|
x1 = min(int(card["x"] + card["width"]), img.width)
|
|
y1 = min(int(card["y"] + card["height"]), img.height)
|
|
if x1 - x0 < 8 or y1 - y0 < 8:
|
|
return {"error": "card rect out of bounds"}
|
|
|
|
ink = luminance_ink(img.crop((x0, y0, x1, y1)))
|
|
width, height = ink.size
|
|
pixels = ink.load()
|
|
|
|
def band_density(band) -> float:
|
|
bx0, by0, bx1, by1 = band
|
|
bx0 = max(bx0, 0); by0 = max(by0, 0)
|
|
bx1 = min(bx1, width); by1 = min(by1, height)
|
|
total = max((bx1 - bx0) * (by1 - by0), 1)
|
|
hits = sum(
|
|
1
|
|
for yy in range(by0, by1)
|
|
for xx in range(bx0, bx1)
|
|
if pixels[xx, yy]
|
|
)
|
|
return hits / total
|
|
|
|
# Glyphs sliced by the text clip line leave ink right at the boundary.
|
|
clip_y = int(clip_bottom - card["y"])
|
|
bottom_clip = band_density((0, clip_y - CLIP_BAND_PX, width, clip_y))
|
|
if clip_right is not None:
|
|
clip_x = int(clip_right - card["x"])
|
|
right_clip = band_density((clip_x - CLIP_BAND_PX, 0, clip_x, height))
|
|
else:
|
|
right_clip = band_density((width - 4 - CLIP_BAND_PX, 0, width - 4, height))
|
|
|
|
# Fraction of a text row that the visible clip window cuts through.
|
|
visible_rows = (clip_bottom - text_top) / line_height
|
|
row_cut = visible_rows - int(visible_rows)
|
|
row_cut = min(row_cut, 1.0 - row_cut) # distance to nearest whole row
|
|
|
|
# Min ink margin to the card border itself.
|
|
bbox = ink.getbbox()
|
|
if bbox:
|
|
_, _, ink_right, ink_bottom = bbox
|
|
edge_margin = min(height - ink_bottom, width - ink_right)
|
|
else:
|
|
edge_margin = min(width, height)
|
|
|
|
failures = []
|
|
if bottom_clip > BOTTOM_CLIP_BUDGET:
|
|
failures.append(f"bottom_clip {bottom_clip:.3f} > {BOTTOM_CLIP_BUDGET} (glyphs sliced at clip line)")
|
|
if right_clip > RIGHT_CLIP_BUDGET:
|
|
failures.append(f"right_clip {right_clip:.3f} > {RIGHT_CLIP_BUDGET} (text cut at right edge)")
|
|
if row_cut > ROW_CUT_BUDGET:
|
|
failures.append(f"row_cut {row_cut:.2f} rows > {ROW_CUT_BUDGET} (clip not row-aligned)")
|
|
if edge_margin < EDGE_MARGIN_BUDGET:
|
|
failures.append(f"edge_margin {edge_margin}px < {EDGE_MARGIN_BUDGET}px")
|
|
|
|
return {
|
|
"bottom_clip": round(bottom_clip, 4),
|
|
"right_clip": round(right_clip, 4),
|
|
"row_cut": round(row_cut, 3),
|
|
"edge_margin_px": edge_margin,
|
|
"failures": failures,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--binary", type=Path, default=DEFAULT_BIN)
|
|
parser.add_argument("--size", default=None)
|
|
parser.add_argument("--states", default=",".join(WIDGET_STATES))
|
|
parser.add_argument("--json", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
states = [s for s in args.states.split(",") if s]
|
|
results = {}
|
|
failed = False
|
|
with tempfile.TemporaryDirectory(prefix="jcode-widget-quality-") as tmp:
|
|
out = Path(tmp)
|
|
for state in states:
|
|
cmd = [
|
|
str(args.binary),
|
|
"--capture-gallery-screens", str(out),
|
|
"--gallery-state", state,
|
|
]
|
|
if args.size:
|
|
cmd += ["--capture-size", args.size]
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
if proc.returncode != 0:
|
|
results[state] = {"error": proc.stderr.strip()[:200]}
|
|
failed = True
|
|
continue
|
|
manifest = json.loads(proc.stdout)
|
|
screen = manifest["screens"][0]
|
|
geometry = screen.get("inline_widget")
|
|
if not geometry:
|
|
results[state] = {"error": "no inline widget geometry"}
|
|
failed = True
|
|
continue
|
|
metrics = analyze(out / screen["file"], geometry)
|
|
results[state] = metrics
|
|
if metrics.get("failures") or metrics.get("error"):
|
|
failed = True
|
|
|
|
if args.json:
|
|
print(json.dumps(results, indent=2))
|
|
else:
|
|
for state, metrics in results.items():
|
|
if "error" in metrics:
|
|
print(f"FAIL {state:20s} {metrics['error']}")
|
|
continue
|
|
status = "FAIL" if metrics["failures"] else "ok "
|
|
print(
|
|
f"{status} {state:20s} bottom_clip={metrics['bottom_clip']:.3f} "
|
|
f"right_clip={metrics['right_clip']:.3f} "
|
|
f"row_cut={metrics['row_cut']:.2f} "
|
|
f"edge_margin={metrics['edge_margin_px']}px"
|
|
)
|
|
for failure in metrics["failures"]:
|
|
print(f" - {failure}")
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|