Add emergency aftermath: evacuation, investigation, review copy
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 4m20s

- Responder outcomes now reach the shift review
  (ShiftCoordinator.responder_outcomes), and the review screen names them.
- "evacuation" (emergency call, low danger, no weapon) actually clears the entrance:
  the door is locked, every NPC flees and is marked as leaving, and a
  security_failure reputation hit applies.
- An emergency call the situation did not justify (no weapon, danger <= 1) appends
  an investigation note, records investigation_opened, and applies the existing
  false_report hit. The review lists the investigations.
- Fix dispatch dying after the first shift: coordinator.setup() builds a new
  RadioSystem, so the responder_arrived connection made in _ready was lost on every
  venue switch/restart. _connect_radio() re-wires it beside _connect_crowd().

Tests/docs
- Headless suite 105 -> 113 (test_emergency_aftermath.gd), new smoke_emergency.gd
  covering the investigation, evacuation aftermath, and review copy.
- README controls/status/smoke checklist, TODO section moved to DONE, MEMORY notes.
- Version 0.2.5.
This commit is contained in:
zwitschi committed 2026-10-31 20:59:06 +01:00
1 parent 71b0b53705
commit 921bab796e
25 files changed
+1019 -23

No files matched your search

+570
View File
@@ -0,0 +1,570 @@
#!/usr/bin/env python3
"""Generate screen-design reference images for Step Line and compare captures.
This tool turns the screen descriptions that already live in `docs/`
(README screenshot captions, ADRs 0007-0010, PLAN.md, architecture.md, and the
HUD strings in `content/strings.en.json`) into reference "design" images, then
uses those images as the expected state to check the game's captured
screenshots against.
Two commands:
generate write one reference design per screen to docs/screens/designs/.
Two modes:
--ai photorealistic mockups via the OpenRouter image API
(default; requires OPENROUTER_API_KEY, same as
tools/asset_pipeline.py).
--draft deterministic wireframe sketches drawn with Pillow
(no network or API key; good for CI and layout checks).
compare compare docs/screenshots/*.png against the reference designs and
flag gross drift. Semantic composition is judged with coarse,
Pillow-only signals (aspect ratio, frame size, blank-frame
contrast, grayscale MAE, colour-histogram distance, edge density).
These catch "wrong screen / blank frame / wrong time of day"
regressions; they are NOT a pixel-exact or semantic match. A
vision-model pass is the documented next step (see PLAN.md).
Run from the repo root:
python3 tools/screen_designs.py generate
python3 tools/screen_designs.py generate --draft
python3 tools/screen_designs.py compare
"""
import argparse
import os
import sys
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
import generate_textures
ROOT = Path(__file__).resolve().parent.parent
DESIGNS_DIR = ROOT / "docs" / "screens" / "designs"
SCREENSHOTS_DIR = ROOT / "docs" / "screenshots"
# Reference "design" dimensions — match the capture tool and the README gallery.
WIDTH, HEIGHT = 1280, 720
ASPECT = 16 / 9
# Default image model for screen mockups (validated role-aware model, per
# MEMORY.md; seedream rejects small sizes and ignores transparency, so we use
# the model asset_pipeline.py already trusts for non-alpha roles).
DEFAULT_MODEL = "openai/gpt-5-image-mini"
# ---------------------------------------------------------------------------
# Screen catalogue. Each entry is grounded in a doc; `prompt` feeds --ai and
# `regions` feed --draft (normalised rects + a short label). `ref` names the
# captured screenshot, if one exists, so `compare` can pair them up.
# ---------------------------------------------------------------------------
SCREENS = [
{
"name": "venue_overview",
"title": "Venue overview",
"source": "README screenshot 01",
"ref": "01-venue-overview.png",
"description": "First-person view from the guard post at the top of the "
"steps: Hollywood Boulevard entrance, wide concrete steps, "
"sidewalk, street lamps, planters, crosswalk, and building "
"signage/marquee under early-morning light.",
"prompt": (
"First-person video game screenshot, security guard standing at the "
"top of wide concrete steps outside a Hollywood Boulevard building "
"entrance, daytime early morning light, sidewalk with street lamps, "
"planters and a crosswalk, building facade with awning and marquee "
"signage, clean UI-free gameplay view, 16:9"),
"regions": [
(0.00, 0.55, 1.00, 1.00, "steps + sidewalk"),
(0.20, 0.08, 0.80, 0.60, "building facade / marquee"),
(0.05, 0.60, 0.20, 0.75, "street lamp"),
(0.78, 0.60, 0.95, 0.75, "street lamp"),
],
},
{
"name": "targeting_npc",
"title": "Targeting an NPC",
"source": "README screenshot 02 · ADR-0008 cone focus + head ring",
"ref": "02-targeting-npc.png",
"description": "An approaching NPC is in the focus cone: a thin pale-gold "
"ring billboards around their head, a display name under "
"the ring, and the HUD shows a target line with the "
"interaction hint.",
"prompt": (
"First-person video game screenshot, a pedestrian NPC walking toward "
"a security guard on a city street, a thin pale-gold ring floating "
"around the NPC's head as a selection marker, small name label under "
"the ring, minimal HUD text at the bottom of the screen, daytime, 16:9"),
"regions": [
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
(0.44, 0.10, 0.56, 0.16, "head ring"),
(0.05, 0.85, 0.95, 0.95, "HUD: name + hint"),
],
},
{
"name": "access_inspection",
"title": "Access inspection",
"source": "README screenshot 03 · ADR-0003 access verification",
"ref": "03-access-inspection.png",
"description": "An inspection panel shows the access artifact (wristband / "
"ID / ticket / credential / guest list) with its colour, "
"pattern, date, and event marking, plus allow and deny "
"prompts.",
"prompt": (
"First-person video game screenshot with a centered access inspection "
"panel: a card showing a wristband or ID credential with a photo "
"placeholder, colour band, date and event marking, and two buttons "
"labelled allow and deny, dark translucent panel over a city street "
"scene, 16:9"),
"regions": [
(0.25, 0.10, 0.75, 0.90, "inspection panel"),
(0.30, 0.18, 0.70, 0.60, "artifact card"),
(0.30, 0.65, 0.48, 0.72, "allow"),
(0.52, 0.65, 0.70, 0.72, "deny"),
],
},
{
"name": "radio_report",
"title": "Radio report",
"source": "README screenshot 04 · PLAN level 4 radio assistance",
"ref": "04-radio-report.png",
"description": "A radio dispatch panel with fields for location, "
"description, behaviour, whether weapons were seen, and "
"whether anyone is in immediate danger, plus a send action.",
"prompt": (
"First-person video game screenshot with a radio dispatch report "
"panel open: form fields for location, description, behaviour, "
"weapons seen, and immediate danger, with a send button, dark "
"translucent panel over a city street scene, 16:9"),
"regions": [
(0.30, 0.08, 0.70, 0.92, "radio panel"),
(0.34, 0.16, 0.66, 0.30, "location / description"),
(0.34, 0.34, 0.66, 0.56, "behaviour / weapons / danger"),
(0.42, 0.60, 0.58, 0.66, "send"),
],
},
{
"name": "late_night_avenue",
"title": "Late night avenue",
"source": "README screenshot 05",
"ref": "05-late-night-avenue.png",
"description": "Night-time view down Hollywood Boulevard: street lamps "
"lit, marquee glowing, dark blue street, no flashlight.",
"prompt": (
"First-person video game screenshot at night, a dark Hollywood "
"Boulevard avenue with lit street lamps, a glowing marquee sign, "
"deep blue night sky, no flashlight beam, 16:9"),
"regions": [
(0.00, 0.55, 1.00, 1.00, "dark avenue"),
(0.15, 0.25, 0.85, 0.55, "glowing marquee"),
(0.10, 0.45, 0.30, 0.70, "lit street lamp"),
(0.70, 0.45, 0.90, 0.70, "lit street lamp"),
],
},
{
"name": "night_flashlight",
"title": "Night flashlight",
"source": "README screenshot 06",
"ref": "06-night-flashlight.png",
"description": "Night shift: the guard's flashlight beam cuts through the "
"dark toward the entrance, illuminating the steps and door.",
"prompt": (
"First-person video game screenshot at night, a flashlight beam "
"cutting through darkness toward a building entrance and steps, "
"bright spotlight pool on the door, dark surroundings, 16:9"),
"regions": [
(0.35, 0.30, 0.65, 0.80, "flashlight beam pool"),
(0.40, 0.20, 0.60, 0.35, "lit door"),
(0.00, 0.00, 1.00, 0.25, "dark sky"),
],
},
{
"name": "radial_menu",
"title": "Radial action menu",
"source": "ADR-0009 · content/strings.en.json",
"ref": None,
"description": "A round doughnut menu centred on screen, 12 slices in "
"de-escalation order: Stop, Look, Point, No, Ask, Inspect, "
"Wand, Warn, Block, Reason, Recognize, Radio. The focused "
"person's display name sits in the centre.",
"prompt": (
"First-person video game screenshot with a radial pie menu centered "
"on screen, twelve labelled slices in a circle reading Stop, Look, "
"Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, "
"Radio, a highlighted slice, a name in the center of the ring, "
"over a city street scene, 16:9"),
"regions": [
(0.38, 0.12, 0.62, 0.88, "radial wheel (12 slices)"),
(0.46, 0.44, 0.54, 0.56, "name"),
(0.62, 0.12, 0.70, 0.20, "highlight"),
],
},
{
"name": "shift_review",
"title": "Shift review",
"source": "PLAN reputation / shift review · architecture.md ShiftReview",
"ref": None,
"description": "End-of-shift performance review: access decisions, "
"escalation choices, incident list, battery and crowd "
"outcomes, and reputation/score feedback.",
"prompt": (
"Video game end-of-shift review screen: a performance summary panel "
"listing incident decisions, escalation choices, reputation score, "
"crowd reaction and battery use, with a continue button, dark themed "
"UI, 16:9"),
"regions": [
(0.30, 0.06, 0.70, 0.94, "review panel"),
(0.36, 0.14, 0.64, 0.24, "score / reputation"),
(0.36, 0.28, 0.64, 0.70, "incident list"),
(0.42, 0.76, 0.58, 0.84, "continue"),
],
},
{
"name": "start_menu",
"title": "Start menu",
"source": "MEMORY session shell (StartMenu, layer 20)",
"ref": None,
"description": "The boot screen: a venue-selection menu (Hollywood "
"Boulevard, The Joke Shop, TV Studio) before a shift.",
"prompt": (
"Video game start menu screen: a title and three venue selection "
"buttons labelled Hollywood Boulevard, The Joke Shop, and TV Studio, "
"dark themed menu over a city street backdrop, 16:9"),
"regions": [
(0.25, 0.10, 0.75, 0.26, "title"),
(0.36, 0.36, 0.64, 0.44, "Hollywood Boulevard"),
(0.36, 0.48, 0.64, 0.56, "The Joke Shop"),
(0.36, 0.60, 0.64, 0.68, "TV Studio"),
],
},
{
"name": "pause_overlay",
"title": "Pause overlay",
"source": "MEMORY session shell (PauseOverlay, layer 18)",
"ref": None,
"description": "Pause menu: resume, restart, settings, and quit-to-menu.",
"prompt": (
"Video game pause menu overlay: buttons labelled Resume, Restart, "
"Settings, and Quit to Menu, dark translucent overlay over a paused "
"city street scene, 16:9"),
"regions": [
(0.38, 0.26, 0.62, 0.74, "pause menu"),
(0.42, 0.32, 0.58, 0.38, "Resume"),
(0.42, 0.42, 0.58, 0.48, "Restart"),
(0.42, 0.52, 0.58, 0.58, "Settings"),
(0.42, 0.62, 0.58, 0.68, "Quit to Menu"),
],
},
{
"name": "settings_panel",
"title": "Settings",
"source": "MEMORY session shell (SettingsPanel, layer 22)",
"ref": None,
"description": "Settings: look sensitivity and HUD font scale controls.",
"prompt": (
"Video game settings screen: sliders labelled look sensitivity and "
"HUD font scale, dark themed settings panel, 16:9"),
"regions": [
(0.30, 0.16, 0.70, 0.84, "settings panel"),
(0.36, 0.30, 0.64, 0.40, "look sensitivity"),
(0.36, 0.46, 0.64, 0.56, "HUD font scale"),
],
},
{
"name": "emergency_radio",
"title": "Emergency radio",
"source": "PLAN level 5 · strings radio.emergency_dispatched",
"ref": None,
"description": "Hold-to-confirm emergency radio panel with a confirmation "
"prompt and a cooldown indicator.",
"prompt": (
"Video game emergency radio hold-to-confirm panel: a large warning "
"prompt with a hold progress bar and a cooldown label, red accent, "
"dark translucent overlay over a city street, 16:9"),
"regions": [
(0.30, 0.28, 0.70, 0.72, "emergency panel"),
(0.36, 0.36, 0.64, 0.46, "confirm prompt"),
(0.38, 0.52, 0.62, 0.60, "hold progress"),
(0.36, 0.64, 0.64, 0.68, "cooldown"),
],
},
]
def resolve_path(value):
path = Path(value).expanduser()
return path if path.is_absolute() else ROOT / path
def _design_path(name):
return DESIGNS_DIR / f"{name}.png"
# ---------------------------------------------------------------------------
# Draft (wireframe) rendering — deterministic, offline.
# ---------------------------------------------------------------------------
def _draw_wireframe(screen, path):
image = Image.new("RGB", (WIDTH, HEIGHT), (24, 26, 38))
draw = ImageDraw.Draw(image)
title_font = generate_textures.font(52)
body_font = generate_textures.font(24)
label_font = generate_textures.font(26)
draw.text((40, 28), screen["title"], font=title_font, fill=(240, 240, 245))
draw.text((40, 98), screen["source"], font=body_font, fill=(150, 155, 175))
# wrap the description into a small caption block
words = screen["description"].split()
lines, line = [], ""
for word in words:
if draw.textlength(line + " " + word, font=body_font) < WIDTH - 80:
line = (line + " " + word).strip()
else:
lines.append(line)
line = word
lines.append(line)
for index, text in enumerate(lines[:4]):
draw.text((40, 132 + index * 30), text,
font=body_font, fill=(120, 125, 145))
for region in screen["regions"]:
x0, y0, x1, y1, label = region
box = (int(x0 * WIDTH), int(y0 * HEIGHT),
int(x1 * WIDTH), int(y1 * HEIGHT))
draw.rounded_rectangle(box, radius=10,
outline=(120, 130, 170), width=2)
cx = (box[0] + box[2]) / 2
cy = (box[1] + box[3]) / 2
draw.text((cx - draw.textlength(label, font=label_font) / 2,
cy - 14), label, font=label_font, fill=(170, 180, 210))
image.save(path)
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
print(f"wrote {shown} {image.size}")
# ---------------------------------------------------------------------------
# AI generation (reuses the OpenRouter backend from generate_textures).
# ---------------------------------------------------------------------------
def _generate_ai(screen, model, request_size):
image = generate_textures.generate_image(
screen["prompt"], model, request_size, transparent=False,
aspect_ratio="1:1")
image = ImageOps.fit(image, (WIDTH, HEIGHT),
method=Image.Resampling.LANCZOS)
if image.mode in ("RGBA", "LA", "P"):
image = image.convert("RGB")
path = _design_path(screen["name"])
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path)
print(f"wrote {path.relative_to(ROOT)} {image.size}")
def generate(args):
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
for screen in SCREENS:
print(f"generating {screen['name']} ...")
if args.draft:
_draw_wireframe(screen, _design_path(screen["name"]))
else:
_generate_ai(screen, args.model, args.request_size)
print("done")
# ---------------------------------------------------------------------------
# Comparison metrics (Pillow only; no numpy/scikit-image dependency).
# ---------------------------------------------------------------------------
def _load_rgb(path):
with Image.open(path) as image:
image.load()
return image.convert("RGB")
def _grayscale(image):
return ImageOps.grayscale(image)
def _mae(left, right):
"""Mean absolute error between two same-size grayscale images, 0..255."""
left_gray = _grayscale(left).resize((256, 144))
right_gray = _grayscale(right).resize((256, 144))
diff = ImageChops.difference(left_gray, right_gray)
return ImageStat.Stat(diff).mean[0]
def _histogram_distance(left, right):
"""Normalised L1 distance between 3x256-bin colour histograms, 0..1."""
left_hist = left.resize((256, 144)).histogram()
right_hist = right.resize((256, 144)).histogram()
total = sum(left_hist)
if total == 0:
return 1.0
distance = sum(abs(a - b) for a, b in zip(left_hist, right_hist))
return distance / (2 * total)
def _edge_density(image):
"""Mean brightness of an edge-detected frame; higher = more detail."""
edges = _grayscale(image).filter(ImageFilter.FIND_EDGES)
return ImageStat.Stat(edges).mean[0]
def _contrast(image):
"""Standard deviation of grayscale brightness; low = near-blank frame."""
return ImageStat.Stat(_grayscale(image)).stddev[0]
def _aspect_ok(image, tolerance=0.12):
width, height = image.size
actual = width / height
return abs(actual - ASPECT) / ASPECT <= tolerance
def compare_screenshot(reference, capture):
"""Compare one capture against its reference design; return a dict report."""
ref = _load_rgb(reference)
cap = _load_rgb(capture)
width, height = cap.size
report = {
"capture": str(capture),
"reference": str(reference),
"size": [width, height],
}
errors, warnings = [], []
if width < WIDTH or height < HEIGHT:
errors.append(f"too small: {width}x{height}; minimum {WIDTH}x{HEIGHT}")
if not _aspect_ok(cap):
errors.append(f"wrong aspect ratio: {width / height:.3f}; "
f"expected about {ASPECT:.3f}")
contrast = _contrast(cap)
report["contrast"] = round(contrast, 1)
if contrast < 5.0:
errors.append(f"near-blank frame: contrast {contrast:.1f} below 5.0")
mae = _mae(ref, cap)
hist = _histogram_distance(ref, cap)
edge_ref = _edge_density(ref)
edge_cap = _edge_density(cap)
report["mae"] = round(mae, 1)
report["hist_distance"] = round(hist, 3)
report["edge_density_ref"] = round(edge_ref, 1)
report["edge_density_cap"] = round(edge_cap, 1)
# Coarse drift warnings (not hard failures: an AI mockup will never match a
# real render pixel-for-pixel, but a wildly different frame will still
# stand out on these signals).
if mae > 90:
warnings.append(
f"grayscale MAE {mae:.1f} is high (very different layout)")
if hist > 0.75:
warnings.append(f"colour histogram distance {hist:.3f} is high "
"(very different palette / time of day)")
edge_delta = abs(edge_cap - edge_ref)
report["edge_delta"] = round(edge_delta, 1)
if edge_delta > 18:
warnings.append(f"edge density differs by {edge_delta:.1f} "
"(very different amount of detail)")
report["quality"] = "bad" if errors else "good"
report["errors"] = errors
report["warnings"] = warnings
return report
def compare(args):
if not SCREENSHOTS_DIR.is_dir():
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}; "
"run src/test/capture_screenshots.gd first")
captures = sorted(SCREENSHOTS_DIR.glob("*.png"))
if not captures:
sys.exit(f"error: no .png screenshots found in {SCREENSHOTS_DIR}")
refs = {screen["name"]: screen["ref"]
for screen in SCREENS if screen["ref"]}
results = []
for capture in captures:
name = capture.name
# find the screen whose ref matches this capture filename
design_name = next((n for n, ref in refs.items() if ref == name), None)
if design_name is None:
results.append({"capture": str(capture), "reference": None,
"errors": [f"no design reference for {name}"],
"warnings": [], "quality": "bad"})
continue
reference = _design_path(design_name)
if not reference.is_file():
results.append({"capture": str(capture),
"reference": str(reference),
"errors": [f"missing reference design "
f"({reference.name}); run generate first"],
"warnings": [], "quality": "bad"})
continue
results.append(compare_screenshot(reference, capture))
if args.json:
import json
print(json.dumps(results, indent=2))
else:
good_count = sum(r["quality"] == "good" for r in results)
for result in results:
ref = (Path(result["reference"]).name
if result.get("reference") else "-")
size = ("x".join(map(str, result["size"]))
if result.get("size") else "unreadable")
print(f"{result['quality'].upper():4} {result['capture']}")
print(f" ref={ref} size={size} "
f"mae={result.get('mae', '-')} "
f"hist={result.get('hist_distance', '-')}")
for message in result["errors"] + result["warnings"]:
print(f" - {message}")
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
return 1 if any(r["quality"] == "bad" for r in results) else 0
# ---------------------------------------------------------------------------
def build_parser():
parser = argparse.ArgumentParser(
description="Generate and compare Step Line screen-design references.")
commands = parser.add_subparsers(dest="command", required=True)
gen = commands.add_parser(
"generate", help="write reference design images to docs/screens/designs/")
mode = gen.add_mutually_exclusive_group()
mode.add_argument("--draft", action="store_true",
help="draw deterministic wireframes (no API/network)")
mode.add_argument("--ai", action="store_true",
help="generate mockups via OpenRouter (default)")
gen.add_argument("--model", default=DEFAULT_MODEL)
gen.add_argument("--request-size", type=int,
default=generate_textures.GEN_SIZE)
gen.set_defaults(handler=generate, draft=False)
cmp = commands.add_parser(
"compare", help="compare docs/screenshots/*.png against the designs")
cmp.add_argument("--json", action="store_true")
cmp.set_defaults(handler=compare)
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
try:
result = args.handler(args)
return result if isinstance(result, int) else 0
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+80
View File
@@ -0,0 +1,80 @@
import tempfile
import unittest
from pathlib import Path
from PIL import Image
import screen_designs
class ScreenDesignsTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
def tearDown(self):
self.temp_dir.cleanup()
def test_catalogue_is_well_formed(self):
names = [screen["name"] for screen in screen_designs.SCREENS]
self.assertEqual(len(names), len(set(names)),
"screen names must be unique")
refs = [screen["ref"] for screen in screen_designs.SCREENS
if screen["ref"]]
self.assertEqual(len(refs), len(set(refs)),
"screenshot refs must be unique")
for screen in screen_designs.SCREENS:
self.assertTrue(screen["prompt"].strip())
self.assertTrue(screen["regions"])
for region in screen["regions"]:
x0, y0, x1, y1, label = region
self.assertLess(x0, x1)
self.assertLess(y0, y1)
self.assertTrue(0 <= x0 <= 1 and 0 <= x1 <= 1)
self.assertTrue(0 <= y0 <= 1 and 0 <= y1 <= 1)
self.assertTrue(label.strip())
def test_draft_wireframe_matches_reference_dimensions(self):
output = self.root / "draft.png"
screen = screen_designs.SCREENS[0]
screen_designs._draw_wireframe(screen, output)
with Image.open(output) as image:
self.assertEqual(image.size,
(screen_designs.WIDTH, screen_designs.HEIGHT))
def test_mae_is_zero_for_identical_images(self):
path = self.root / "identical.png"
Image.new("RGB", (1280, 720), (40, 80, 120)).save(path)
left = screen_designs._load_rgb(path)
right = screen_designs._load_rgb(path)
self.assertAlmostEqual(screen_designs._mae(left, right), 0.0, places=1)
def test_histogram_distance_is_zero_for_identical_images(self):
path = self.root / "identical.png"
Image.new("RGB", (1280, 720), (40, 80, 120)).save(path)
left = screen_designs._load_rgb(path)
right = screen_designs._load_rgb(path)
self.assertAlmostEqual(
screen_designs._histogram_distance(left, right), 0.0, places=3)
def test_blank_frame_is_flagged_as_bad(self):
reference = self.root / "ref.png"
capture = self.root / "cap.png"
Image.new("RGB", (1280, 720), (200, 200, 200)).save(reference)
Image.new("RGB", (1280, 720), (0, 0, 0)).save(capture)
report = screen_designs.compare_screenshot(reference, capture)
self.assertEqual(report["quality"], "bad")
self.assertTrue(any("near-blank" in e for e in report["errors"]))
def test_wrong_aspect_is_flagged_as_bad(self):
reference = self.root / "ref.png"
capture = self.root / "cap.png"
Image.new("RGB", (1280, 720), (100, 120, 140)).save(reference)
Image.new("RGB", (640, 640), (100, 120, 140)).save(capture)
report = screen_designs.compare_screenshot(reference, capture)
self.assertEqual(report["quality"], "bad")
self.assertTrue(any("aspect" in e for e in report["errors"]))
if __name__ == "__main__":
unittest.main()