a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
324 lines
12 KiB
Python
Executable File
324 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Layout efficiency matrix for the jcode iOS app.
|
|
|
|
A single screenshot only measures one content state. Real UI quality means the
|
|
layout stays efficient across the *range* of content (empty, short, one tool,
|
|
long thread, code-heavy) and devices. This harness:
|
|
|
|
1. builds the app once,
|
|
2. for each (scenario, device) cell: starts the mock gateway pre-seeded with
|
|
that scenario, seeds a paired credential, launches, screenshots, and
|
|
scores the screenshot with ui_metrics.py,
|
|
3. prints an aggregate table + a single mean overall score.
|
|
|
|
That mean is the hill to climb: a layout change is an improvement only if it
|
|
raises the mean across the whole matrix, not just one lucky screen.
|
|
|
|
Beyond pixels, each cell also records best-effort *runtime* metrics
|
|
(cold_launch_ms, first_frame_ms) measured around `xcrun simctl launch`, in the
|
|
schema reward/scorers/perf.py consumes. When measurement fails the cell simply
|
|
omits the runtime dict and the reward degrades gracefully to "unavailable".
|
|
|
|
The matrix axes are device x Dynamic Type size x scenario: by default a large
|
|
phone and an SE-class small phone, plus an accessibility text-size variant on
|
|
the primary device (via `simctl ui ... content_size`), so layout robustness is
|
|
measured against real size pressure, not just one happy path.
|
|
|
|
Usage:
|
|
python3 ui_matrix.py [--devices "iPhone 17,iPhone SE (3rd generation)"] \
|
|
[--scenarios empty,short,tool,long,code] \
|
|
[--a11y-size accessibility-large] [--no-perf] [--out DIR] [--json]
|
|
python3 ui_matrix.py --baseline-json before.json --candidate-json after.json
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
IOS = HERE.parent
|
|
BUNDLE = "com.jcode.mobile"
|
|
APP = IOS / ".build-ios/Build/Products/Debug-iphonesimulator/JCodeMobile.app"
|
|
PORT = 7643
|
|
TOKEN = "mocktoken0123456789abcdef"
|
|
CRED = ('[{"host":"127.0.0.1","port":7643,"token":"%s","serverName":'
|
|
'"mock-jcode","serverVersion":"mock-0.32.0","pairedAt":770000000}]' % TOKEN)
|
|
|
|
# Default matrix axes. The SE-class device exercises the small-screen end;
|
|
# the a11y content size exercises Dynamic Type pressure on the primary device.
|
|
DEFAULT_DEVICES = "iPhone 17,iPhone SE (3rd generation)"
|
|
DEFAULT_CONTENT_SIZE = "large" # iOS default Dynamic Type category
|
|
DEFAULT_A11Y_SIZE = "accessibility-large"
|
|
|
|
# App background token (mirrors Theme.swift) used for first-frame detection.
|
|
APP_BG = (0x0F, 0x0F, 0x14)
|
|
# A frame "is the app" once this fraction of pixels sits near the background.
|
|
FIRST_FRAME_BG_FRAC = 0.30
|
|
# Give the app this long (total, from launch) before giving up on first frame.
|
|
FIRST_FRAME_TIMEOUT_S = 12.0
|
|
# Keep total post-launch settle constant so screenshots stay comparable.
|
|
SETTLE_TOTAL_S = 5.0
|
|
|
|
sys.path.insert(0, str(HERE))
|
|
import ui_metrics # noqa: E402
|
|
|
|
|
|
def sh(cmd, **kw):
|
|
return subprocess.run(cmd, shell=True, text=True, capture_output=True, **kw)
|
|
|
|
|
|
def build_app(device):
|
|
sh("xcodegen generate", cwd=IOS)
|
|
r = sh(
|
|
"xcodebuild build -project JCodeMobile.xcodeproj -scheme JCodeMobile "
|
|
f"-destination 'platform=iOS Simulator,name={device}' "
|
|
"-derivedDataPath .build-ios",
|
|
cwd=IOS,
|
|
)
|
|
if "BUILD SUCCEEDED" not in r.stdout:
|
|
print(r.stdout[-2000:], file=sys.stderr)
|
|
raise SystemExit("build failed")
|
|
|
|
|
|
def boot(device):
|
|
sh(f'xcrun simctl boot "{device}"')
|
|
time.sleep(3)
|
|
|
|
|
|
def set_content_size(device, size):
|
|
"""Set the simulator's Dynamic Type category. Returns True on success."""
|
|
r = sh(f'xcrun simctl ui "{device}" content_size {size}')
|
|
ok = r.returncode == 0
|
|
if not ok:
|
|
print(f"warning: content_size {size} on {device} failed: "
|
|
f"{(r.stderr or r.stdout).strip()}", file=sys.stderr)
|
|
return ok
|
|
|
|
|
|
def start_gateway(scenario):
|
|
sh("pkill -f mock_gateway.py")
|
|
time.sleep(0.4)
|
|
# background process; inherit no pipes so it stays alive
|
|
return subprocess.Popen(
|
|
[sys.executable, str(HERE / "mock_gateway.py"),
|
|
"--port", str(PORT), "--host", "127.0.0.1", "--scenario", scenario],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
|
|
def _app_bg_frac(png_path):
|
|
"""Fraction of pixels close to the app background color (0..1)."""
|
|
try:
|
|
import numpy as np
|
|
from PIL import Image
|
|
arr = np.asarray(Image.open(png_path).convert("RGB"), dtype=np.float64)
|
|
bg = np.array(APP_BG, dtype=np.float64)
|
|
dist = np.sqrt(((arr - bg) ** 2).sum(axis=2))
|
|
return float((dist < 28.0).mean())
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
def measure_first_frame(device, t_launch):
|
|
"""Poll screenshots until the app's background dominates the screen.
|
|
|
|
Returns elapsed ms from launch, or None. Includes screenshot-capture
|
|
latency, so treat it as a harness-relative (but consistent) measure.
|
|
"""
|
|
deadline = t_launch + FIRST_FRAME_TIMEOUT_S
|
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf:
|
|
probe = tf.name
|
|
try:
|
|
while time.monotonic() < deadline:
|
|
r = sh(f'xcrun simctl io "{device}" screenshot "{probe}"')
|
|
if r.returncode == 0 and _app_bg_frac(probe) >= FIRST_FRAME_BG_FRAC:
|
|
return (time.monotonic() - t_launch) * 1000.0
|
|
time.sleep(0.15)
|
|
finally:
|
|
try:
|
|
os.unlink(probe)
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def seed_and_launch(device, collect_perf=True):
|
|
"""Install fresh, seed the credential, cold-launch, and (best-effort) time it.
|
|
|
|
Returns a runtime metrics dict in the reward/scorers/perf.py schema, or {}
|
|
when nothing could be measured. The uninstall/install makes every launch a
|
|
true cold launch, so the timing is comparable across cells.
|
|
"""
|
|
sh(f'xcrun simctl uninstall "{device}" {BUNDLE}')
|
|
sh(f'xcrun simctl install "{device}" "{APP}"')
|
|
container = sh(
|
|
f'xcrun simctl get_app_container "{device}" {BUNDLE} data'
|
|
).stdout.strip()
|
|
appsup = Path(container) / "Library/Application Support"
|
|
appsup.mkdir(parents=True, exist_ok=True)
|
|
(appsup / "jcode-servers.json").write_text(CRED + "\n")
|
|
|
|
runtime = {}
|
|
t0 = time.monotonic()
|
|
r = sh(f'xcrun simctl launch "{device}" {BUNDLE}')
|
|
launched_ok = r.returncode == 0
|
|
if collect_perf and launched_ok:
|
|
# Time for simctl to spawn the process and hand back a pid: the
|
|
# closest cheap analogue of "cold launch to process ready".
|
|
runtime["cold_launch_ms"] = round((time.monotonic() - t0) * 1000.0, 1)
|
|
ff = measure_first_frame(device, t0)
|
|
if ff is not None:
|
|
runtime["first_frame_ms"] = round(ff, 1)
|
|
# Keep total settle constant so transcript streaming finishes and shots
|
|
# stay comparable regardless of how long first-frame polling took.
|
|
remaining = SETTLE_TOTAL_S - (time.monotonic() - t0)
|
|
if remaining > 0:
|
|
time.sleep(remaining)
|
|
return runtime
|
|
|
|
|
|
def screenshot(device, path):
|
|
sh(f'xcrun simctl io "{device}" screenshot "{path}"')
|
|
|
|
|
|
def device_scale(device):
|
|
"""Device pixels per point. SE/mini-class phones and iPads are 2x."""
|
|
d = device.lower()
|
|
if "iphone se" in d or "mini" in d or "ipad" in d:
|
|
return 2
|
|
return 3
|
|
|
|
|
|
def run_matrix(devices, scenarios, out_dir, a11y_size=DEFAULT_A11Y_SIZE,
|
|
collect_perf=True):
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
results = []
|
|
first_device = devices[0]
|
|
build_app(first_device)
|
|
|
|
# Cell plan: every device at the default Dynamic Type size, plus the
|
|
# primary device again at the accessibility size (if requested).
|
|
plan = [(d, DEFAULT_CONTENT_SIZE) for d in devices]
|
|
if a11y_size:
|
|
plan.append((first_device, a11y_size))
|
|
|
|
booted = set()
|
|
for device, content_size in plan:
|
|
if device not in booted:
|
|
boot(device)
|
|
booted.add(device)
|
|
scale = device_scale(device)
|
|
size_ok = set_content_size(device, content_size)
|
|
if content_size != DEFAULT_CONTENT_SIZE and not size_ok:
|
|
print(f"skipping {device} @ {content_size} (unsupported)",
|
|
file=sys.stderr)
|
|
continue
|
|
for scenario in scenarios:
|
|
gw = start_gateway(scenario)
|
|
try:
|
|
runtime = seed_and_launch(device, collect_perf=collect_perf)
|
|
shot = out_dir / (f"{slug(device)}__{slug(content_size)}"
|
|
f"__{scenario}.png")
|
|
screenshot(device, str(shot))
|
|
card = ui_metrics.analyze(str(shot), scale=scale)
|
|
row = {
|
|
"device": device, "scenario": scenario,
|
|
"content_size": content_size,
|
|
"scale": scale,
|
|
"shot": str(shot),
|
|
"overall": card.overall, "space": card.space,
|
|
"consistency": card.consistency,
|
|
"legibility": card.legibility, "rhythm": card.rhythm,
|
|
"fill_ratio": card.fill_ratio,
|
|
"dead_zone_frac": card.dead_zone_frac,
|
|
}
|
|
if runtime:
|
|
row["runtime"] = runtime
|
|
results.append(row)
|
|
finally:
|
|
gw.terminate()
|
|
# Never leave a booted simulator stuck on an accessibility size.
|
|
if content_size != DEFAULT_CONTENT_SIZE:
|
|
set_content_size(device, DEFAULT_CONTENT_SIZE)
|
|
sh("pkill -f mock_gateway.py")
|
|
return results
|
|
|
|
|
|
def slug(s):
|
|
return s.replace(" ", "-").replace("(", "").replace(")", "")
|
|
|
|
|
|
def print_table(results):
|
|
print(f"{'device':22} {'size':14} {'scenario':9} {'ovr':>5} {'spc':>5} "
|
|
f"{'fill':>5} {'dead':>5} {'launch':>7} {'frame':>7}")
|
|
print("-" * 92)
|
|
for r in results:
|
|
rt = r.get("runtime") or {}
|
|
launch = f"{rt['cold_launch_ms']:.0f}ms" if "cold_launch_ms" in rt else "-"
|
|
frame = f"{rt['first_frame_ms']:.0f}ms" if "first_frame_ms" in rt else "-"
|
|
size = r.get("content_size", DEFAULT_CONTENT_SIZE)
|
|
size = size.replace("accessibility", "a11y")
|
|
print(f"{r['device'][:22]:22} {size[:14]:14} {r['scenario']:9} "
|
|
f"{r['overall']:5.1f} {r['space']:5.1f} "
|
|
f"{r['fill_ratio']:5.2f} {r['dead_zone_frac']:5.2f} "
|
|
f"{launch:>7} {frame:>7}")
|
|
print("-" * 92)
|
|
mean = sum(r["overall"] for r in results) / max(1, len(results))
|
|
worst = min(results, key=lambda r: r["overall"]) if results else None
|
|
print(f"MEAN overall: {mean:5.1f}")
|
|
if worst:
|
|
print(f"WORST cell: {worst['overall']:.1f} "
|
|
f"({worst['device']} / "
|
|
f"{worst.get('content_size', DEFAULT_CONTENT_SIZE)} / "
|
|
f"{worst['scenario']})")
|
|
return mean
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--devices", default=DEFAULT_DEVICES)
|
|
ap.add_argument("--scenarios", default="empty,short,tool,long,code")
|
|
ap.add_argument("--a11y-size", default=DEFAULT_A11Y_SIZE,
|
|
help="Dynamic Type category to re-run the primary device "
|
|
"at (empty string disables the variant)")
|
|
ap.add_argument("--no-perf", action="store_true",
|
|
help="skip runtime (launch/first-frame) measurement")
|
|
ap.add_argument("--out", default=str(Path(os.environ.get("TMPDIR", "/tmp"))
|
|
/ "jcode-ui-matrix"))
|
|
ap.add_argument("--json", action="store_true")
|
|
ap.add_argument("--baseline-json")
|
|
ap.add_argument("--candidate-json")
|
|
args = ap.parse_args()
|
|
|
|
if args.baseline_json and args.candidate_json:
|
|
a = json.loads(Path(args.baseline_json).read_text())
|
|
b = json.loads(Path(args.candidate_json).read_text())
|
|
am = sum(r["overall"] for r in a) / max(1, len(a))
|
|
bm = sum(r["overall"] for r in b) / max(1, len(b))
|
|
print(f"baseline mean {am:5.1f}")
|
|
print(f"candidate mean {bm:5.1f}")
|
|
print(f"delta {'+' if bm >= am else ''}{bm - am:.1f}")
|
|
sys.exit(0 if bm >= am - 0.5 else 1)
|
|
|
|
devices = [d.strip() for d in args.devices.split(",") if d.strip()]
|
|
scenarios = [s.strip() for s in args.scenarios.split(",") if s.strip()]
|
|
out_dir = Path(args.out)
|
|
results = run_matrix(devices, scenarios, out_dir,
|
|
a11y_size=args.a11y_size.strip(),
|
|
collect_perf=not args.no_perf)
|
|
|
|
if args.json:
|
|
print(json.dumps(results, indent=2))
|
|
else:
|
|
print_table(results)
|
|
print(f"\nscreenshots: {out_dir}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|