Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

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()