Rendering and presentation - Shared surface-material module (src/game/materials.gd) with per-surface roughness/metallic/uv profiles; ACES tonemapping, glow, fog and phase-driven sky energy. WorldBuilder rebuilt on those profiles with facade windows that light up, door trim/handles, curb, lane dashes, bollards, bench, bin and planter foliage. - NPC silhouettes: per-role clothing palettes, caps/hoods/backpacks/sunglasses, hands and shoes, arm/leg walk swing. - Six generated low-poly GLB props (bollards, bench, bin, planter, newspaper box, hydrant) built by tools/model_pipeline.py, loaded by WorldBuilder with a procedural fallback; ADR-0012. Selection and movement - Focus ranking by view angle then distance (NpcFocus.rank_indices); Tab and the mouse wheel cycle nearby people initially, right click clears focus with a re-acquire grace, HUD shows the candidate count, Shift walks at half speed. ADR-0011. Windowed smoke extended to cover wheel cycling and clearing. QA tooling - screen_designs.py catalogue grown to 19 screens (8 scene, 11 UI) plus a `list` subcommand; draft generation keeps existing AI references unless --force. Web export - New Web preset using the non-threaded Godot template (static hosting, no COOP/COEP). tools/build_releases.sh exports build/web and packages build/step-line-web-<ver>.zip; publish_release.sh uploads it with the desktop artifacts. test_web_export.gd covers the preset and release wiring. Verification - Headless suite 177 -> 181, Python tooling 53 tests, all eight smokes pass (including the windowed focus/wheel smoke). Web bundle serves index.html, index.wasm (35 MB) and index.pck (30 MB) with HTTP 200 from a static server. - Version 0.3.0; README, PLAN, RELEASING, TODO/DONE and MEMORY updated.
226 lines
9.7 KiB
Python
226 lines
9.7 KiB
Python
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.assertIn(screen["kind"], ("scene", "ui"))
|
|
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_catalogue_covers_the_shipped_screens(self):
|
|
names = [screen["name"] for screen in screen_designs.SCREENS]
|
|
required = [
|
|
"venue_overview", "targeting_npc", "access_inspection",
|
|
"radio_report", "late_night_avenue", "night_flashlight",
|
|
"radial_menu", "shift_review", "start_menu", "pause_overlay",
|
|
"settings_panel", "emergency_radio", "wand_screening",
|
|
"joke_shop_queue", "studio_arrival", "crowd_panic",
|
|
"responder_arrival", "body_camera_timeline", "escalation_hud",
|
|
]
|
|
for name in required:
|
|
self.assertIn(name, names)
|
|
self.assertGreaterEqual(len(screen_designs.SCREENS), len(required))
|
|
for screen in screen_designs.SCREENS:
|
|
self.assertTrue(screen["title"].strip())
|
|
|
|
def test_list_command_prints_the_catalogue(self):
|
|
import contextlib
|
|
import io
|
|
buffer = io.StringIO()
|
|
with contextlib.redirect_stdout(buffer):
|
|
code = screen_designs.main(["list"])
|
|
self.assertEqual(code, 0)
|
|
output = buffer.getvalue()
|
|
self.assertIn("escalation_hud", output)
|
|
self.assertIn("wand_screening", output)
|
|
self.assertIn("Total:", output)
|
|
|
|
def test_judge_dry_run_builds_requests_without_network(self):
|
|
import contextlib
|
|
import io
|
|
if not screen_designs.SCREENSHOTS_DIR.is_dir():
|
|
self.skipTest("no screenshots checked out")
|
|
buffer = io.StringIO()
|
|
with contextlib.redirect_stdout(buffer):
|
|
code = screen_designs.main(["judge", "--dry-run"])
|
|
# A dry run must not call the API and must not crash on missing verdicts.
|
|
self.assertEqual(code, 0)
|
|
output = buffer.getvalue()
|
|
self.assertIn("dry run", output)
|
|
self.assertIn("Summary:", output)
|
|
|
|
def test_compose_prompt_appends_kind_specific_style(self):
|
|
scene = {"kind": "scene", "prompt": "a street"}
|
|
ui = {"kind": "ui", "prompt": "a panel"}
|
|
self.assertIn(screen_designs.SCENE_STYLE,
|
|
screen_designs._compose_prompt(scene))
|
|
self.assertIn(screen_designs.UI_STYLE,
|
|
screen_designs._compose_prompt(ui))
|
|
self.assertIn("16:9", screen_designs._compose_prompt(scene))
|
|
|
|
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"]))
|
|
|
|
|
|
class VisionJudgeTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp_dir = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.temp_dir.name)
|
|
self.capture = self.root / "capture.png"
|
|
self.design = self.root / "design.png"
|
|
Image.new("RGB", (64, 36), (20, 30, 40)).save(self.capture)
|
|
Image.new("RGB", (64, 36), (200, 190, 180)).save(self.design)
|
|
self.screen = screen_designs.SCREENS[0]
|
|
|
|
def tearDown(self):
|
|
self.temp_dir.cleanup()
|
|
|
|
def test_judge_request_embeds_both_images(self):
|
|
body = screen_designs.build_judge_request(
|
|
"model-x", self.screen, self.capture, self.design)
|
|
self.assertEqual(body["model"], "model-x")
|
|
self.assertEqual(body["temperature"], 0)
|
|
self.assertEqual(body["response_format"], {"type": "json_object"})
|
|
parts = body["messages"][0]["content"]
|
|
self.assertEqual(parts[0]["type"], "text")
|
|
images = [p for p in parts if p["type"] == "image_url"]
|
|
self.assertEqual(
|
|
len(images), 2, "design and capture are both attached")
|
|
for image in images:
|
|
self.assertTrue(image["image_url"]["url"].startswith(
|
|
"data:image/png;base64,"))
|
|
self.assertIn(self.screen["title"], parts[0]["text"])
|
|
self.assertIn("mockup", parts[0]["text"].lower())
|
|
|
|
def test_parse_plain_json_verdict(self):
|
|
payload = {"choices": [{"message": {"content":
|
|
'{"match": true, "confidence": 0.9, "differences": ["style"]}'}}]}
|
|
verdict = screen_designs.parse_judge_response(payload)
|
|
self.assertTrue(verdict["match"])
|
|
self.assertAlmostEqual(verdict["confidence"], 0.9)
|
|
self.assertEqual(verdict["differences"], ["style"])
|
|
|
|
def test_parse_fenced_json_verdict(self):
|
|
payload = {"choices": [{"message": {"content":
|
|
'Sure:\n```json\n{"match": false, "confidence": 0.7}\n```'}}]}
|
|
verdict = screen_designs.parse_judge_response(payload)
|
|
self.assertFalse(verdict["match"])
|
|
self.assertAlmostEqual(verdict["confidence"], 0.7)
|
|
|
|
def test_parse_content_parts(self):
|
|
payload = {"choices": [{"message": {"content": [
|
|
{"type": "text", "text": '{"match": true, "confidence": 0.5}'}]}}]}
|
|
verdict = screen_designs.parse_judge_response(payload)
|
|
self.assertTrue(verdict["match"])
|
|
|
|
def test_parse_non_json_is_unknown(self):
|
|
payload = {"choices": [{"message": {"content": "no idea"}}]}
|
|
verdict = screen_designs.parse_judge_response(payload)
|
|
self.assertIsNone(verdict["match"])
|
|
self.assertTrue(verdict["differences"])
|
|
|
|
def test_parse_missing_choices_is_unknown(self):
|
|
verdict = screen_designs.parse_judge_response({})
|
|
self.assertIsNone(verdict["match"])
|
|
|
|
def test_classify_thresholds(self):
|
|
self.assertEqual(screen_designs.classify(
|
|
{"match": True, "confidence": 0.1}), "match")
|
|
self.assertEqual(screen_designs.classify(
|
|
{"match": False, "confidence": 0.9}), "bad")
|
|
self.assertEqual(screen_designs.classify(
|
|
{"match": False, "confidence": 0.3}), "review")
|
|
self.assertEqual(screen_designs.classify(
|
|
{"match": None, "confidence": 0.0}), "unknown")
|
|
self.assertEqual(screen_designs.classify(
|
|
{"match": False, "confidence": 0.55}, threshold=0.5), "bad")
|
|
|
|
def test_env_file_does_not_override_existing(self):
|
|
env_path = self.root / ".env"
|
|
env_path.write_text('OPENROUTER_API_KEY=from-file\nOTHER=1\n',
|
|
encoding="utf-8")
|
|
import os
|
|
old = os.environ.get("OPENROUTER_API_KEY")
|
|
os.environ["OPENROUTER_API_KEY"] = "from-env"
|
|
os.environ.pop("OTHER", None)
|
|
try:
|
|
screen_designs._load_env_file(env_path)
|
|
self.assertEqual(os.environ["OPENROUTER_API_KEY"], "from-env")
|
|
self.assertEqual(os.environ.get("OTHER"), "1")
|
|
finally:
|
|
if old is None:
|
|
os.environ.pop("OPENROUTER_API_KEY", None)
|
|
else:
|
|
os.environ["OPENROUTER_API_KEY"] = old
|
|
os.environ.pop("OTHER", None)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|