Organize tools and move Python tests into tools/tests
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-03 19:45:00 +01:00
1 parent 0aacee6518
commit e4e1566e55
22 files changed
+209 -3

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# Python Tooling Tests
Unit tests for the scripts in `tools/`. They use the standard library
`unittest` plus Pillow; no Godot or Blender process is started.
Run them from the repository root:
```bash
python3 -m pip install -r requirements-tools.txt
python3 -m unittest discover -s tools/tests -t tools -p 'test_*.py'
```
The `-t tools` top-level directory is required: the tests import the tool
modules by their plain names (for example `import blender_assets`).
Individual example:
```bash
python3 -m unittest tools.tests.test_model_review -v
```
(Module-style invocation requires `tools/tests/__init__.py`, which is committed.)
These tests do not call OpenRouter. The judge/image tests mock the HTTP layer;
live generation and judging remain manual, development-only operations.
View File
Whitespace-only changes.
+207
View File
@@ -0,0 +1,207 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageDraw
import asset_pipeline
class AssetPipelineTests(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_crop_uses_center_and_exact_dimensions(self):
source = self.root / "source.png"
output = self.root / "crop.png"
Image.new("RGB", (80, 60), (20, 40, 60)).save(source)
asset_pipeline.crop_image(source, output, 32, 24)
with Image.open(output) as image:
self.assertEqual(image.size, (32, 24))
def test_resize_contain_preserves_aspect_and_exact_canvas(self):
source = self.root / "source.png"
output = self.root / "resized.png"
Image.new("RGB", (80, 40), (20, 40, 60)).save(source)
asset_pipeline.resize_image(source, output, 64, 64, "contain")
with Image.open(output) as image:
self.assertEqual(image.size, (64, 64))
self.assertEqual(image.getpixel((32, 0)), (0, 0, 0, 0))
def test_texture_audit_accepts_valid_tile_and_rejects_bad_seams(self):
good = self.root / "good.png"
bad = self.root / "bad.png"
Image.new("RGB", (512, 512), (80, 80, 80)).save(good)
mismatch = Image.new("RGB", (512, 512), (80, 80, 80))
for y in range(512):
mismatch.putpixel((0, y), (255, 0, 0))
mismatch.save(bad)
self.assertEqual(asset_pipeline.audit_image(
good, "tile")["quality"], "good")
self.assertEqual(asset_pipeline.audit_image(
bad, "tile")["quality"], "bad")
def test_seamless_transform_makes_tile_edges_continuous(self):
image_path = self.root / "tile.png"
output = self.root / "seamless.png"
image = Image.new("RGB", (512, 512))
for x in range(512):
for y in range(512):
image.putpixel((x, y), (x // 2, y // 2, (x + y) // 4))
image.save(image_path)
asset_pipeline.seamless_image(image_path, output)
self.assertEqual(asset_pipeline.audit_image(
output, "tile")["quality"], "good")
def test_tile_generation_applies_seamless_transform(self):
output = self.root / "generated_tile.png"
args = type("Args", (), {
"prompt": "brick", "role": "tile", "output": str(output),
"width": None, "height": None, "transparent": False,
"model": "test-model", "request_size": 1024,
})()
generated = Image.new("RGB", (512, 512), (80, 80, 80))
for y in range(512):
generated.putpixel((0, y), (255, 0, 0))
with patch.object(asset_pipeline.generate_textures, "generate_image",
return_value=generated):
asset_pipeline._generate(args)
self.assertEqual(asset_pipeline.audit_image(
output, "tile")["quality"], "good")
def test_background_audit_rejects_small_image(self):
image_path = self.root / "small.png"
Image.new("RGB", (640, 360)).save(image_path)
result = asset_pipeline.audit_image(image_path, "background")
self.assertEqual(result["quality"], "bad")
self.assertTrue(
any("too small" in error for error in result["errors"]))
def test_grayscale_vignette_is_valid_without_alpha(self):
image_path = self.root / "vignette.png"
image = Image.new("L", (512, 512), 0)
draw = ImageDraw.Draw(image)
for radius in range(255, -1, -1):
value = int(255 * (1 - radius / 255))
draw.ellipse((256 - radius, 256 - radius,
256 + radius, 256 + radius), fill=value)
image.save(image_path)
self.assertEqual(asset_pipeline.audit_image(
image_path, "overlay")["quality"], "good")
def test_overlay_audit_rejects_blank_transparent_image(self):
image_path = self.root / "blank_overlay.png"
Image.new("RGBA", (512, 512), (0, 0, 0, 0)).save(image_path)
result = asset_pipeline.audit_image(image_path, "overlay")
self.assertEqual(result["quality"], "bad")
self.assertTrue(any("near-blank frame" in error
for error in result["errors"]))
def test_icon_audit_rejects_opaque_rgba_images(self):
image_path = self.root / "opaque_icon.png"
Image.new("RGBA", (64, 64), (255, 255, 255, 255)).save(image_path)
result = asset_pipeline.audit_image(image_path, "icon")
self.assertEqual(result["quality"], "bad")
self.assertTrue(any("transparency required" in error
for error in result["errors"]))
def test_backdrop_audit_requires_transparency(self):
image_path = self.root / "skyline.png"
Image.new("RGB", (1024, 256), (20, 30, 40)).save(image_path)
result = asset_pipeline.audit_image(image_path, "backdrop")
self.assertEqual(result["quality"], "bad")
self.assertTrue(any("transparency required" in error
for error in result["errors"]))
def test_generation_reuses_existing_backend_and_targets_role_dimensions(self):
output = self.root / "background.png"
args = type("Args", (), {
"prompt": "Hollywood at night", "role": "background", "output": str(output),
"width": 1920, "height": 1080, "transparent": None,
"model": "test-model", "request_size": 2048,
})()
generated = Image.new("RGB", (256, 256), (20, 40, 60))
with patch.object(asset_pipeline.generate_textures, "generate_image",
return_value=generated) as backend:
asset_pipeline._generate(args)
self.assertIn("Hollywood at night", backend.call_args.args[0])
self.assertIn("wide 16:9", backend.call_args.args[0])
self.assertEqual(backend.call_args.kwargs["aspect_ratio"], "3:2")
with Image.open(output) as image:
self.assertEqual(image.size, (1920, 1080))
def test_generation_can_recolor_transparent_glyph(self):
output = self.root / "icon.png"
args = type("Args", (), {
"prompt": "flashlight", "role": "icon", "output": str(output),
"width": None, "height": None, "transparent": True,
"model": "test-model", "request_size": 1024,
"foreground": (255, 255, 255, 255),
})()
generated = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
generated.putpixel((32, 32), (20, 20, 20, 255))
with patch.object(asset_pipeline.generate_textures, "generate_image",
return_value=generated):
asset_pipeline._generate(args)
with Image.open(output) as image:
self.assertEqual(image.getpixel((32, 32)), (255, 255, 255, 255))
self.assertEqual(image.getpixel((0, 0))[3], 0)
def test_screenshot_audit_accepts_game_frame_and_rejects_blank_capture(self):
valid = self.root / "frame.png"
blank = self.root / "blank.png"
frame = Image.new("RGB", (1280, 720), (0, 0, 0))
ImageDraw.Draw(frame).rectangle(
(640, 0, 1279, 719), fill=(255, 255, 255))
frame.save(valid)
Image.new("RGB", (1280, 720), (0, 0, 0)).save(blank)
self.assertEqual(asset_pipeline.audit_image(
valid, "screenshot")["quality"], "good")
self.assertEqual(asset_pipeline.audit_image(
blank, "screenshot")["quality"], "bad")
def test_role_inference_uses_game_asset_paths(self):
self.assertEqual(asset_pipeline.infer_role(
Path("assets/textures/icons/wand.png")), "icon")
self.assertEqual(asset_pipeline.infer_role(
Path("assets/textures/cards/ticket.png")), "card")
self.assertEqual(asset_pipeline.infer_role(
Path("assets/textures/skyline.png")), "backdrop")
def test_role_generator_defaults_to_validated_image_model(self):
args = asset_pipeline.build_parser().parse_args([
"generate", "--prompt", "test", "--role", "tile", "--output", "tile.png"])
self.assertEqual(args.model, asset_pipeline.DEFAULT_ROLE_IMAGE_MODEL)
if __name__ == "__main__":
unittest.main()
+251
View File
@@ -0,0 +1,251 @@
import json
import unittest
import blender_assets as ba
import npc_pipeline
class CatalogueTests(unittest.TestCase):
def test_contract_matches_npc_pipeline(self):
self.assertEqual(ba.SLOTS, npc_pipeline.SLOTS)
self.assertEqual(sorted(ba.BONES), sorted(npc_pipeline.BONES))
# The base mesh keeps the frozen 6000/4000 contract; the eight library
# variants have their own 8000/8000 detail budget (documented in
# blender-assets.md and covered by smoke_npc_budget.gd).
self.assertLessEqual(ba.CHAR_MAX_TRIS, 8000)
self.assertLessEqual(ba.CHAR_MAX_VERTS, 8000)
def test_gear_uses_known_attach_points(self):
for name, attach in ba.GEAR.items():
self.assertIn(attach, ba.GEAR_ATTACH, name)
self.assertIn(attach, ba.ATTACH_BLENDER, name)
def test_character_gear_exists(self):
layers = set()
for name, spec in ba.CHARACTERS.items():
layers.add(spec["layer"])
for gear in spec.get("gear", []):
self.assertIn(gear, ba.GEAR, f"{name} -> {gear}")
# Every NpcAvatar.layer_for() value has a character.
for layer in ("none", "jacket", "coat", "vest", "dress"):
self.assertIn(layer, layers)
def test_emissive_props_use_emissive_palette_entries(self):
for name, spec in ba.PROPS.items():
for mat in spec.get("emissive", []):
self.assertGreater(ba.PALETTE[mat][3], 0.0, f"{name}:{mat}")
@unittest.skipUnless(ba.MANIFEST.is_file(), "Blender assets not built")
class BuiltAssetTests(unittest.TestCase):
def test_every_asset_audits_clean(self):
bad = {}
for category, name in ba.catalogue():
result = ba.audit_asset(category, name)
if not result["ok"]:
bad[name] = result["issues"]
self.assertEqual(bad, {})
def test_fit_report_measures_gear_against_body(self):
report = {entry["character"]: entry for entry in ba.fit_report()}
self.assertIn("npc_coat", report)
coat = report["npc_coat"]
self.assertAlmostEqual(coat["reference"]["head_width"], 0.315, places=2)
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.225, places=3)
sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses")
self.assertEqual(sunglasses["attach"], "head")
self.assertLessEqual(sunglasses["width"],
0.70 * coat["reference"]["head_width"])
bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag")
self.assertEqual(bag["attach"], "back")
self.assertGreaterEqual(bag["x_extent"], 0.2)
self.assertGreater(bag["x_clearance"], 0)
self.assertGreater(bag["inner_x"], coat["reference"]["clothing_radius"])
for character in report.values():
for row in character["gear"]:
self.assertEqual(len(row["min"]), 3)
self.assertEqual(len(row["max"]), 3)
self.assertIn("npc_dress", report)
dress = report["npc_dress"]
self.assertAlmostEqual(dress["reference"]["clothing_radius"], 0.26, places=3)
dress_bag = next(row for row in dress["gear"] if row["name"] == "shoulder_bag")
self.assertGreater(dress_bag["x_clearance"], 0)
def test_manifest_covers_catalogue(self):
manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))
self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()})
def test_manifest_paths_stay_in_the_library(self):
manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))
for name, entry in manifest["assets"].items():
self.assertTrue(entry["path"].startswith("assets/models/blender/"), name)
if entry["category"] == "npc_gear":
self.assertIn(entry["attach"], ba.GEAR_ATTACH, name)
def test_baked_ao_maps_exist_for_every_asset(self):
from PIL import Image
for name, entry in json.loads(
ba.MANIFEST.read_text(encoding="utf-8"))["assets"].items():
path = ba.OUT_DIR / entry["category"] / f"{name}_ao.png"
self.assertTrue(path.is_file(), path)
with Image.open(path) as image:
self.assertEqual(image.size, (256, 256), path)
def test_sources_are_hidden_from_godot(self):
self.assertTrue((ba.SRC_DIR / ".gdignore").is_file())
@unittest.skipUnless(ba.MANIFEST.is_file(), "Blender assets not built")
class TexelDensityTests(unittest.TestCase):
def test_character_slots_report_density_and_area(self):
report = ba.texel_density("npc_casual")
self.assertEqual(report["resolution"], 256)
self.assertEqual(set(report["slots"]), set(ba.SLOTS) | {"eye_white", "rubber"})
for slot in ba.SLOTS:
row = report["slots"][slot]
self.assertGreater(row["triangles"], 0, slot)
self.assertGreater(row["world_area_m2"], 0.0, slot)
self.assertGreater(row["td_px_per_m"], 30.0, slot)
self.assertGreater(report["total_world_area_m2"], 1.0)
self.assertLess(report["total_world_area_m2"], 6.0)
def test_fabric_slots_apply_the_runtime_uv_repeat(self):
report = ba.texel_density("npc_casual")
for slot, factor in ba.TEXEL_RUNTIME_UV_SCALE.items():
row = report["slots"][slot]
self.assertAlmostEqual(row["td_runtime_px_per_m"],
row["td_px_per_m"] * factor, places=0)
skin = report["slots"]["skin"]
self.assertEqual(skin["td_px_per_m"], skin["td_runtime_px_per_m"])
def test_every_character_reports_a_plausible_density_for_every_slot(self):
# Gate for the 3D review: a new or rebuilt character must keep all five
# tint slots measurable and inside the documented stylized band.
for name in ba.CHARACTERS:
report = ba.texel_density(name)
self.assertIsNotNone(report, name)
for slot in ba.SLOTS:
self.assertIn(slot, report["slots"], f"{name}:{slot}")
td = report["slots"][slot]["td_px_per_m"]
self.assertGreaterEqual(td, 40.0, f"{name}/{slot} below 40 px/m")
self.assertLessEqual(td, 400.0, f"{name}/{slot} above 400 px/m")
for slot in ba.TEXEL_RUNTIME_UV_SCALE:
if slot in report["slots"]:
self.assertGreaterEqual(report["slots"][slot]["td_runtime_px_per_m"],
250.0, f"{name}/{slot} fabric below 250 px/m")
def test_base_fallback_is_measurable_from_its_own_path(self):
from pathlib import Path
path = ba.ROOT / "assets" / "models" / "generated" / "npc_base.glb"
report = ba.texel_density("npc_base", path)
self.assertIsNotNone(report)
self.assertGreater(report["min_td"], 30.0)
class InspectionTests(unittest.TestCase):
"""`inspect` reports on imported GLBs; the contract logic is pure and tested
here with synthetic measurements so Blender is not a test dependency."""
def stats(self, category="npc", **overrides):
base = {
"file": "/tmp/synthetic.glb",
"category": category,
"objects": 1,
"mesh_nodes": ["npc_base"] if category == "npc" else ["prop"],
"armatures": ["Armature"] if category == "npc" else [],
"helpers": [],
"bones": list(ba.BONES) + ["Hips", "Spine", "Neck"],
"bone_heads": {},
"materials": list(ba.SLOTS),
"images": 5,
"uv_layers": ["UVMap"],
"meshes_without_uv": 0,
"meshes_without_material": 0,
"unskinned": [],
"skinned": category == "npc",
"animations": 0,
"triangles": 4500,
"vertices": 4200,
"split_vertices": 4400,
"local_bounds": {"min": [-0.3, 0.0, -0.2], "max": [0.3, 1.82, 0.2]},
"world_bounds": {"min": [-0.3, 0.0, -0.2], "max": [0.3, 1.82, 0.2]},
"transform_delta": 0.0,
"transforms_applied": True,
}
base.update(overrides)
return base
def test_clean_character_passes(self):
result = ba.analyse_inspection(self.stats())
self.assertTrue(result["ok"], result["issues"])
self.assertEqual(result["bones_missing"], [])
self.assertEqual(result["budget"]["max_triangles"], ba.CHAR_MAX_TRIS)
def test_character_contract_failures_are_listed(self):
stats = self.stats(mesh_nodes=["Armature"], skinned=False, unskinned=["Armature"],
materials=["cloth"], bones=["Head"], meshes_without_uv=1,
triangles=9000, split_vertices=9000,
world_bounds={"min": [-0.3, -0.4, -0.2], "max": [0.3, 1.2, 0.2]})
issues = "\n".join(ba.analyse_inspection(stats)["issues"])
self.assertIn("no mesh node named npc_base", issues)
self.assertIn("missing bone HipL", issues)
self.assertIn("missing material slot skin", issues)
self.assertIn("mesh not skinned", issues)
self.assertIn("mesh(es) without UVs", issues)
self.assertIn(f"triangles 9000 > {ba.CHAR_MAX_TRIS}", issues)
self.assertIn(f"glTF vertices 9000 > {ba.CHAR_MAX_VERTS}", issues)
self.assertIn("feet not on the ground", issues)
self.assertIn("unexpected height", issues)
def test_budget_gates_follow_the_category(self):
gear = ba.analyse_inspection(self.stats("npc_gear", mesh_nodes=["cap"],
triangles=ba.GEAR_MAX_TRIS + 1))
self.assertIn(f"triangles {ba.GEAR_MAX_TRIS + 1} > {ba.GEAR_MAX_TRIS}",
"\n".join(gear["issues"]))
prop = ba.analyse_inspection(self.stats("props", mesh_nodes=["bollard"],
triangles=ba.PROP_MAX_TRIS + 1))
self.assertIn(f"triangles {ba.PROP_MAX_TRIS + 1} > {ba.PROP_MAX_TRIS}",
"\n".join(prop["issues"]))
external = ba.analyse_inspection(self.stats("external", mesh_nodes=["body"],
materials=[], skinned=False,
triangles=999_999,
world_bounds={"min": [-3.0, -1.0, -3.0],
"max": [3.0, 9.0, 3.0]}))
self.assertTrue(external["ok"], external["issues"])
self.assertIsNone(external["budget"]["max_triangles"])
self.assertTrue(any("no project budget" in w for w in external["warnings"]))
def test_unapplied_transforms_are_reported_not_failed(self):
result = ba.analyse_inspection(self.stats(transform_delta=1.9))
self.assertTrue(result["ok"])
self.assertTrue(any("node transforms changed the pose" in w
for w in result["warnings"]))
def test_missing_mesh_or_bounds_is_a_failure(self):
result = ba.analyse_inspection(self.stats(mesh_nodes=[], objects=0, triangles=0,
world_bounds={"min": None, "max": None}))
self.assertFalse(result["ok"])
self.assertIn("no mesh objects", result["issues"])
self.assertIn("no triangles", result["issues"])
self.assertIn("empty world bounds", result["issues"])
def test_category_inference_from_path(self):
self.assertEqual(ba.category_for_path("assets/models/blender/npc/npc_casual.glb"), "npc")
self.assertEqual(ba.category_for_path("assets/models/blender/npc_gear/cap.glb"),
"npc_gear")
self.assertEqual(ba.category_for_path("assets/models/blender/props/bollard.glb"),
"props")
self.assertEqual(ba.category_for_path("assets/models/generated/bollard.glb"), "props")
self.assertEqual(ba.category_for_path("tmp/quaternius/Worker.glb"), "external")
def test_inspect_requires_blender(self):
# The stdlib test run has no bpy; inspect must fail cleanly, not crash.
if ba.bpy is not None: # pragma: no cover - only inside Blender
self.skipTest("running inside Blender")
result = ba.inspect_glb("does-not-exist.glb")
self.assertFalse(result["ok"])
self.assertIn("inside Blender", result["error"])
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
import tempfile
import unittest
from pathlib import Path
import check_docs
class CheckDocsTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
def tearDown(self):
self.temp.cleanup()
def _file(self, rel, text=""):
path = self.root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def test_accepts_tracked_directory_with_links_and_images(self):
self._file("docs/README.md", "[x](guide.md)")
self._file("docs/guide.md", "guide")
self._file("docs/images/preview.png", "png")
self._file("docs/images/README.md", "![preview](preview.png)")
tracked = [Path("docs/README.md"), Path("docs/guide.md"),
Path("docs/images/preview.png"), Path("docs/images/README.md")]
self.assertEqual(check_docs.check_docs(self.root, tracked), [])
def test_requires_readme_only_in_tracked_dirs(self):
self._file("docs/README.md")
self._file("docs/ignored/file.txt")
tracked = [Path("docs/README.md"), Path("docs/ignored/file.txt")]
errors = check_docs.check_docs(self.root, tracked)
self.assertTrue(any("docs/ignored" in e for e in errors))
# A directory with no tracked files is not required to have a README.
self._file("docs/generated/file.txt")
tracked = [Path("docs/README.md")]
self.assertEqual(check_docs.check_docs(self.root, tracked), [])
def test_reports_broken_link_and_unlinked_image(self):
self._file("docs/README.md", "[broken](missing.md)")
self._file("docs/image.png", "png")
tracked = [Path("docs/README.md"), Path("docs/image.png")]
errors = check_docs.check_docs(self.root, tracked)
self.assertTrue(any("broken link" in e for e in errors))
self.assertTrue(any("image not linked" in e for e in errors))
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
"""Tests for the reference-anchored texture path (no network)."""
import unittest
from unittest import mock
from PIL import Image
import generate_textures as gt
class ReferenceGateTests(unittest.TestCase):
def test_reference_without_licence_note_is_refused(self):
for argv in (["--ai", "--prompt", "x", "--name", "a.png",
"--reference", "r.jpg"],
["--ai", "--prompt", "x", "--name", "a.png",
"--reference-licence", "CC BY 2.0"]):
with mock.patch.object(gt, "generate_ai_textures") as gen:
with self.assertRaises(SystemExit):
gt._run_ai(gt._build_arg_parser().parse_args(argv))
gen.assert_not_called()
def test_reference_pair_calls_with_the_path(self):
argv = ["--ai", "--prompt", "x", "--name", "a.png",
"--reference", "r.jpg", "--reference-licence", "CC BY 2.0"]
with mock.patch.object(gt, "generate_ai_textures") as gen:
gt._run_ai(gt._build_arg_parser().parse_args(argv))
gen.assert_called_once_with("x", "a.png", gt.DEFAULT_IMAGE_MODEL,
gt.GEN_SIZE, reference_path="r.jpg")
class ReferenceCropTests(unittest.TestCase):
def test_reference_results_are_cropped_square_before_save(self):
wide = Image.new("RGB", (1344, 768), (10, 20, 30))
with mock.patch.object(gt, "generate_image", return_value=wide) as gen, \
mock.patch.object(gt, "save") as saved:
gt.generate_ai_textures("p", "t.png", "m", 1024,
reference_path="r.jpg")
gen.assert_called_once()
img = saved.call_args[0][0]
self.assertEqual(img.size, (768, 768))
def test_plain_results_keep_their_size(self):
wide = Image.new("RGB", (1344, 768), (10, 20, 30))
with mock.patch.object(gt, "generate_image", return_value=wide), \
mock.patch.object(gt, "save") as saved:
gt.generate_ai_textures("p", "t.png", "m", 1024)
img = saved.call_args[0][0]
self.assertEqual(img.size, (1024, 585)) # _fit preserves aspect ratio
if __name__ == "__main__":
unittest.main()
+307
View File
@@ -0,0 +1,307 @@
import json
import struct
import tempfile
import unittest
from pathlib import Path
from PIL import Image
import model_pipeline
def parse_glb(payload: bytes):
magic, version, length = struct.unpack_from("<III", payload, 0)
json_len, json_type = struct.unpack_from("<II", payload, 12)
gltf = json.loads(payload[20:20 + json_len].decode("utf-8"))
bin_offset = 20 + json_len
bin_len, bin_type = struct.unpack_from("<II", payload, bin_offset)
binary = payload[bin_offset + 8:bin_offset + 8 + bin_len]
return {
"magic": magic,
"version": version,
"length": length,
"json_type": json_type,
"bin_type": bin_type,
"bin_len": bin_len,
"binary": binary,
"gltf": gltf,
}
def prop(name):
return model_pipeline.DIRECTORY_BY_NAME[name]
class CatalogueTests(unittest.TestCase):
def test_props_are_well_formed(self):
names = [p["name"] for p in model_pipeline.PROPS]
self.assertEqual(len(names), len(set(names)),
"prop names must be unique")
for entry in model_pipeline.PROPS:
self.assertTrue(entry["concept"].strip())
self.assertTrue(entry["parts"])
for part in entry["parts"]:
self.assertIn(part["shape"], ("box", "cylinder"))
self.assertEqual(len(part["at"]), 3)
self.assertIn(part["role"], model_pipeline.PALETTE)
if part["shape"] == "box":
self.assertEqual(len(part["size"]), 3)
self.assertTrue(all(value > 0 for value in part["size"]))
else:
self.assertGreater(part["radius"], 0)
self.assertGreater(part["height"], 0)
def test_every_prop_has_triangles(self):
for entry in model_pipeline.PROPS:
mesh = model_pipeline.build_prop_mesh(entry)
self.assertGreater(mesh.triangle_count(), 0, entry["name"])
self.assertLess(mesh.triangle_count(), 4000, entry["name"])
self.assertEqual(len(mesh.positions), len(mesh.normals))
class MeshWindingTests(unittest.TestCase):
def test_box_face_normals_point_outward(self):
mesh = model_pipeline.MeshData()
model_pipeline._add_box(mesh, (2.0, 2.0, 2.0), (0.0, 0.0, 0.0))
for tri in range(0, len(mesh.indices), 3):
a, b, c = (mesh.positions[mesh.indices[tri + i]] for i in range(3))
normal = mesh.normals[mesh.indices[tri]]
centroid = tuple((a[i] + b[i] + c[i]) / 3.0 for i in range(3))
self.assertGreater(
model_pipeline._dot(normal, centroid), 0.0,
"box face normal must point away from the centre")
def test_cylinder_caps_face_up_and_down(self):
mesh = model_pipeline.MeshData()
model_pipeline._add_cylinder(mesh, 0.5, 1.0, (0.0, 0.5, 0.0), 8)
ups = [n for n in mesh.normals if n[1] > 0.9]
downs = [n for n in mesh.normals if n[1] < -0.9]
self.assertTrue(ups, "cylinder needs an upward cap")
self.assertTrue(downs, "cylinder needs a downward cap")
class GlbTests(unittest.TestCase):
def test_glb_container_and_structure(self):
payload = model_pipeline.build_glb(prop("bench"))
parsed = parse_glb(payload)
self.assertEqual(parsed["magic"], 0x46546C67)
self.assertEqual(parsed["version"], 2)
self.assertEqual(parsed["length"], len(payload))
self.assertEqual(parsed["json_type"], 0x4E4F534A)
self.assertEqual(parsed["bin_type"], 0x004E4942)
self.assertEqual(len(payload) % 4, 0)
gltf = parsed["gltf"]
self.assertEqual(gltf["asset"]["version"], "2.0")
self.assertEqual(len(gltf["scenes"]), 1)
primitives = gltf["meshes"][0]["primitives"]
self.assertEqual(len(primitives), 1,
"parts share one primitive (one material)")
self.assertEqual(len(gltf["materials"]), 1,
"the prop draws with a single material")
for primitive in primitives:
self.assertIn("POSITION", primitive["attributes"])
self.assertIn("NORMAL", primitive["attributes"])
self.assertIn("TEXCOORD_0", primitive["attributes"],
"atlas UVs are required")
self.assertIsNotNone(primitive["indices"])
self.assertEqual(primitive["material"], 0)
for accessor in gltf["accessors"]:
self.assertIn(accessor["bufferView"],
range(len(gltf["bufferViews"])))
self.assertGreater(accessor["count"], 0)
self.assertLessEqual(gltf["buffers"][0]["byteLength"],
len(parsed["binary"]))
def test_glb_is_deterministic(self):
first = model_pipeline.build_glb(prop("bollard"))
second = model_pipeline.build_glb(prop("bollard"))
self.assertEqual(first, second)
def test_palette_override_changes_the_atlas_colour(self):
palette = {role: list(rgb)
for role, rgb in model_pipeline.PALETTE.items()}
palette["metal"] = [0.9, 0.1, 0.1]
atlas = model_pipeline.atlas_for(prop("bollard"), palette)
self.assertIn((0.9, 0.1, 0.1), atlas["colours"].values())
default = model_pipeline.build_glb(prop("bollard"))
overridden = model_pipeline.build_glb(prop("bollard"), palette)
self.assertNotEqual(default, overridden,
"the atlas texture carries the palette")
def test_material_uses_both_atlas_textures(self):
gltf = parse_glb(model_pipeline.build_glb(prop("bollard")))["gltf"]
pbr = gltf["materials"][0]["pbrMetallicRoughness"]
self.assertEqual(pbr["metallicFactor"], 1.0)
self.assertEqual(pbr["roughnessFactor"], 1.0)
self.assertIn("baseColorTexture", pbr)
self.assertIn("metallicRoughnessTexture", pbr)
self.assertEqual(len(gltf["images"]), 2)
self.assertEqual(len(gltf["textures"]), 2)
for image in gltf["images"]:
self.assertEqual(image["mimeType"], "image/png")
self.assertEqual(gltf["samplers"][0]["magFilter"], 9728,
"nearest sampling keeps palette cells flat")
def test_atlas_is_deterministic_and_power_of_two(self):
first = model_pipeline.atlas_for(prop("planter_box"))
second = model_pipeline.atlas_for(prop("planter_box"))
self.assertEqual(first["base_png"], second["base_png"])
size = first["size"]
self.assertEqual(size & (size - 1), 0, "atlas size is a power of two")
for u, v in first["role_uv"].values():
self.assertTrue(0.0 < u < 1.0 and 0.0 < v < 1.0,
"UVs stay on the texture")
self.assertEqual(len(first["roles"]), len(set(first["roles"])))
def test_indices_stay_inside_their_primitive(self):
payload = model_pipeline.build_glb(prop("planter_box"))
parsed = parse_glb(payload)
gltf = parsed["gltf"]
binary = parsed["binary"]
for primitive in gltf["meshes"][0]["primitives"]:
position_accessor = gltf["accessors"][primitive["attributes"]["POSITION"]]
index_accessor = gltf["accessors"][primitive["indices"]]
view = gltf["bufferViews"][index_accessor["bufferView"]]
raw = binary[view["byteOffset"] :view["byteOffset"] + view["byteLength"]]
indices = struct.unpack(f"<{len(raw) // 2}H", raw)
self.assertLess(max(indices), position_accessor["count"])
self.assertEqual(index_accessor["count"], len(indices))
class AuditTests(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_audit_accepts_a_written_file(self):
path = self.root / "bench.glb"
path.write_bytes(model_pipeline.build_glb(prop("bench")))
result = model_pipeline.audit_glb(path)
self.assertTrue(result["ok"], result["issues"])
self.assertGreater(result["triangles"], 0)
def test_audit_reports_missing_file(self):
result = model_pipeline.audit_glb(self.root / "nope.glb")
self.assertFalse(result["ok"])
self.assertIn("missing file", result["issues"])
def test_audit_reports_truncated_file(self):
path = self.root / "cut.glb"
path.write_bytes(model_pipeline.build_glb(prop("bench"))[:40])
result = model_pipeline.audit_glb(path)
self.assertFalse(result["ok"])
self.assertTrue(result["issues"])
def test_audit_enforces_the_triangle_budget(self):
old = model_pipeline.MAX_TRIANGLES
model_pipeline.MAX_TRIANGLES = 10
try:
path = self.root / "bench.glb"
path.write_bytes(model_pipeline.build_glb(prop("bench")))
result = model_pipeline.audit_glb(path)
finally:
model_pipeline.MAX_TRIANGLES = old
self.assertFalse(result["ok"])
self.assertTrue(any("triangle budget" in issue for issue in result["issues"]),
result["issues"])
def test_degenerate_triangle_helper(self):
self.assertEqual(
model_pipeline._triangle_area2((0, 0, 0), (1, 0, 0), (2, 0, 0)), 0.0)
self.assertGreater(
model_pipeline._triangle_area2((0, 0, 0), (1, 0, 0), (0, 1, 0)), 0.0)
def test_audit_reads_positions_and_uvs_from_the_binary(self):
parsed = parse_glb(model_pipeline.build_glb(prop("bollard")))
gltf, binary = parsed["gltf"], parsed["binary"]
primitive = gltf["meshes"][0]["primitives"][0]
positions = model_pipeline._read_float_accessor(
gltf, binary, primitive["attributes"]["POSITION"])
uvs = model_pipeline._read_float_accessor(
gltf, binary, primitive["attributes"]["TEXCOORD_0"])
indices = model_pipeline._read_index_accessor(
gltf, binary, primitive["indices"])
self.assertEqual(len(positions), gltf["accessors"][
primitive["attributes"]["POSITION"]]["count"])
self.assertEqual(len(uvs), len(positions))
self.assertEqual(len(indices) % 3, 0)
self.assertLess(min(p[1] for p in positions), 0.01)
for u, v in uvs:
self.assertTrue(0.0 <= u <= 1.0 and 0.0 <= v <= 1.0)
def test_build_writes_into_the_models_directory(self):
old = model_pipeline.MODELS_DIR
model_pipeline.MODELS_DIR = self.root
try:
written = model_pipeline.build("bollard", quiet=True)
self.assertEqual(len(written), 1)
self.assertTrue(written[0].is_file())
self.assertTrue(model_pipeline.audit_glb(written[0])["ok"])
finally:
model_pipeline.MODELS_DIR = old
class PaletteTests(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_dominant_colors_ignores_white_background(self):
image = Image.new("RGB", (96, 96), (250, 250, 250))
for x in range(20, 70):
for y in range(20, 70):
image.putpixel((x, y), (150, 40, 40))
colors = model_pipeline.dominant_colors(image)
self.assertTrue(colors)
self.assertLess(max(colors[0]), 0.9,
"background must not win the vote")
def test_palette_uses_concept_image_when_present(self):
old = model_pipeline.CONCEPTS_DIR
model_pipeline.CONCEPTS_DIR = self.root
try:
image = Image.new("RGB", (96, 96), (250, 250, 250))
for x in range(20, 70):
for y in range(20, 70):
image.putpixel((x, y), (30, 90, 40))
image.save(self.root / "bench.png")
palette = model_pipeline.palette_for_prop("bench")
self.assertLess(palette["body"][1], 0.6)
self.assertGreater(palette["body"][1], palette["body"][0])
finally:
model_pipeline.CONCEPTS_DIR = old
def test_palette_falls_back_to_defaults(self):
old = model_pipeline.CONCEPTS_DIR
model_pipeline.CONCEPTS_DIR = self.root
try:
palette = model_pipeline.palette_for_prop("bench")
self.assertEqual(palette["wood"], model_pipeline.PALETTE["wood"])
finally:
model_pipeline.CONCEPTS_DIR = old
def test_assign_roles_separates_wood_from_metal(self):
colors = [[0.62, 0.37, 0.18], [0.05, 0.05, 0.05], [0.70, 0.70, 0.70]]
palette = model_pipeline.assign_roles(colors)
self.assertAlmostEqual(palette["wood"][0], 0.62, places=3)
self.assertLess(model_pipeline._luminance(palette["metal"]), 0.1)
def test_assign_roles_finds_foliage_and_stone(self):
colors = [[0.55, 0.55, 0.53], [0.18, 0.42, 0.20]]
palette = model_pipeline.assign_roles(colors)
self.assertGreater(palette["foliage"][1], palette["foliage"][0])
self.assertGreater(palette["stone"][0], 0.5)
def test_assign_roles_without_colours_keeps_defaults(self):
palette = model_pipeline.assign_roles([])
self.assertEqual(palette["body"], model_pipeline.PALETTE["body"])
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
"""Keep the 3D model review scorecard honest.
Every catalogue asset (characters, gear, props) must have a row in
`docs/3d-model-review.md`, and the character rows must carry a verdict. This does
not judge the art; it only fails when an asset is added or renamed without being
reviewed, which is how a scorecard silently rots.
"""
import re
import unittest
from pathlib import Path
import blender_assets as model
ROOT = Path(model.__file__).resolve().parent.parent
REVIEW = ROOT / "docs" / "3d-model-review.md"
def catalogue_names() -> set[str]:
return set(model.CHARACTERS) | set(model.GEAR) | set(model.PROPS)
def reviewed_rows(text: str) -> dict[str, list[str]]:
"""Map asset name -> table cells for rows written as ``| `name` | ... |``."""
rows = {}
for line in text.splitlines():
match = re.match(r"^\| `([a-z0-9_]+)` \|(.*)\|\s*$", line)
if match:
rows[match.group(1)] = [cell.strip() for cell in match.group(2).split("|")]
return rows
class ModelReviewTests(unittest.TestCase):
def test_every_catalogue_asset_has_a_scorecard_row(self):
text = REVIEW.read_text(encoding="utf-8")
rows = reviewed_rows(text)
missing = sorted(catalogue_names() - set(rows))
self.assertEqual(missing, [], f"no scorecard row for: {', '.join(missing)}")
def test_character_rows_carry_a_verdict(self):
rows = reviewed_rows(REVIEW.read_text(encoding="utf-8"))
for character in model.CHARACTERS:
self.assertIn(character, rows, character)
verdict = rows[character][-1]
self.assertTrue(verdict, f"{character} has no verdict")
self.assertNotEqual(verdict, "—", f"{character} verdict not filled in")
def test_gear_and_prop_rows_have_a_verdict_column(self):
rows = reviewed_rows(REVIEW.read_text(encoding="utf-8"))
for prop in model.PROPS:
self.assertIn(prop, rows, prop)
self.assertTrue(rows[prop][-1], f"{prop} has no verdict column")
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
import os
import tempfile
import unittest
from pathlib import Path
import npc_baseline
class NpcBaselineTests(unittest.TestCase):
def test_expected_files_cover_all_variants_and_views(self):
rows = npc_baseline.expected_files()
self.assertEqual(len(rows), 48)
self.assertEqual({row[0] for row in rows}, set(npc_baseline.VARIANTS))
self.assertEqual({row[1] for row in rows}, set(npc_baseline.VIEWS))
self.assertEqual(len({row[2] for row in rows}), 48)
def test_sheet_accepts_a_custom_view_list(self):
from PIL import Image
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
for variant in npc_baseline.VARIANTS:
for view in ("idle", "sit"):
Image.new("RGB", (32, 18), (40, 60, 80)).save(
folder / f"{variant}__{view}.png")
out = npc_baseline.build_sheet(folder, folder / "sheet.jpg", ["idle", "sit"])
with Image.open(out) as sheet:
# 2 views x 320 px cells.
self.assertEqual(sheet.width, 640)
def test_latest_capture_prefers_the_full_stem(self):
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
(folder / "npc_casual__front.png").write_bytes(b"old")
newer = folder / "npc_casual__front-v2.png"
newer.write_bytes(b"new")
os.utime(newer, (2_000_000_000, 2_000_000_000))
self.assertEqual(npc_baseline.latest_for(folder, "npc_casual__front"), newer)
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
import os
import unittest
from pathlib import Path
import npc_pipeline
class NpcContractTests(unittest.TestCase):
def test_contract_constants(self):
self.assertEqual(npc_pipeline.SLOTS,
["skin", "cloth", "pants", "accessory", "hair"])
for bone in ["Head", "HipL", "HipR", "KneeL", "KneeR",
"ShoulderL", "ShoulderR", "ElbowL", "ElbowR",
"HandL", "HandR"]:
self.assertIn(bone, npc_pipeline.BONES)
self.assertLessEqual(npc_pipeline.MAX_TRIANGLES, 6000)
self.assertLessEqual(npc_pipeline.MAX_VERTICES, 4000)
def test_textures_exist_for_every_slot(self):
for slot in npc_pipeline.SLOTS:
for suffix in ("albedo", "normal", "orm"):
path = npc_pipeline.TEX_DIR / f"{slot}_{suffix}.png"
self.assertTrue(path.is_file(), path)
self.assertGreater(path.stat().st_size, 100, path)
@unittest.skipUnless(npc_pipeline.GLB_PATH.is_file(), "npc_base.glb not built")
def test_committed_model_audits_clean(self):
result = npc_pipeline.audit_glb()
self.assertTrue(result["ok"], result["issues"])
self.assertLessEqual(result["triangles"], npc_pipeline.MAX_TRIANGLES)
self.assertLessEqual(result["vertices"], npc_pipeline.MAX_VERTICES)
for bone in npc_pipeline.BONES:
self.assertIn(bone, result["bones"])
@unittest.skipUnless(npc_pipeline.GLB_PATH.is_file(), "npc_base.glb not built")
def test_blend_source_is_committed(self):
self.assertTrue(npc_pipeline.BLEND_PATH.is_file())
# Godot must not try to import the .blend (it hangs without Blender).
gdignore = npc_pipeline.BLEND_PATH.parent / ".gdignore"
self.assertTrue(gdignore.is_file(), "assets/models/npc/.gdignore is required")
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -0,0 +1,22 @@
import unittest
import npc_proportions
class NpcProportionTests(unittest.TestCase):
def test_every_library_character_is_measured(self):
rows = npc_proportions.report()
self.assertEqual(len(rows), 8)
self.assertEqual(len({row["character"] for row in rows}), 8)
for row in rows:
self.assertGreater(row["measurements"]["height"], 1.6)
def test_current_library_stays_inside_the_stylized_guide(self):
for row in npc_proportions.report():
for key, check in row["checks"].items():
self.assertEqual(check["status"], "ok",
f"{row['character']} {key}={check['value']}")
if __name__ == "__main__":
unittest.main()
+89
View File
@@ -0,0 +1,89 @@
import os
import unittest
from unittest import mock
import openrouter_judge as oj
def _model(model_id, prompt, completion, *, image=True, response_format=True, batch=False):
return {
"id": model_id,
"architecture": {
"input_modalities": ["text", "image"] if image else ["text"],
"output_modalities": ["text"],
},
"supported_parameters": ["response_format"] if response_format else ["temperature"],
"pricing": {"prompt": str(prompt), "completion": str(completion)},
}
class ModelSelectionTests(unittest.TestCase):
def test_candidates_are_image_json_models_sorted_by_price(self):
catalog = [
_model("expensive/model", 0.10, 0.40),
_model("cheap/model", 0.01, 0.02),
_model("text-only", 0.001, 0.001, image=False),
_model("no-json", 0.001, 0.001, response_format=False),
_model("batch/model:batch", 0.0001, 0.0001),
]
self.assertEqual(oj.vision_candidates(catalog), ["cheap/model", "expensive/model"])
def test_explicit_model_wins_over_auto_and_env(self):
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
self.assertEqual(oj.resolve_models("explicit/model", catalog=[]),
["explicit/model"])
def test_auto_uses_env_override(self):
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
self.assertEqual(oj.resolve_models("auto", catalog=[]), ["env/model"])
def test_auto_uses_catalog_and_falls_back_offline(self):
catalog = [_model("cheap/model", 0.01, 0.02)]
self.assertEqual(oj.resolve_models("auto", catalog=catalog), ["cheap/model"])
self.assertEqual(oj.resolve_models("auto", catalog=[]), oj.FALLBACK_MODELS[:6])
class FallbackTests(unittest.TestCase):
def test_fallback_switches_model_after_provider_error(self):
seen = []
def call(body):
seen.append(body["model"])
if body["model"] == "bad/model":
raise ValueError("OpenRouter HTTP 400: provider rejected image")
return {"ok": True}
model, payload = oj.call_with_fallback(
["bad/model", "good/model"], lambda m: {"model": m}, call, attempts=1)
self.assertEqual(model, "good/model")
self.assertEqual(payload, {"ok": True})
self.assertEqual(seen, ["bad/model", "good/model"])
def test_auth_error_does_not_try_another_model(self):
seen = []
def call(body):
seen.append(body["model"])
raise ValueError("OpenRouter HTTP 401: API key not valid")
with self.assertRaises(ValueError):
oj.call_with_fallback(["one", "two"], lambda m: {"model": m}, call)
self.assertEqual(seen, ["one"])
def test_transient_error_retries_same_model(self):
seen = []
def call(body):
seen.append(body["model"])
if len(seen) == 1:
raise ValueError("OpenRouter HTTP 500")
return {"ok": True}
model, _ = oj.call_with_fallback(
["one"], lambda m: {"model": m}, call, attempts=2)
self.assertEqual(model, "one")
self.assertEqual(seen, ["one", "one"])
if __name__ == "__main__":
unittest.main()
+103
View File
@@ -0,0 +1,103 @@
"""Tests for the Commons reference-board helper (stdlib only, no network)."""
import json
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
import refboard as rb
class LicenceGateTests(unittest.TestCase):
def test_committable_licences_pass(self):
for licence in ("Public domain", "CC0", "CC BY 2.0", "CC BY 3.0", "CC BY 4.0"):
clean, review = rb.normalise_licence(licence)
self.assertEqual(clean, licence, licence)
self.assertIsNone(review, licence)
def test_ccbysa_is_research_only_by_default(self):
clean, review = rb.normalise_licence("CC BY-SA 4.0")
self.assertIsNone(clean)
self.assertEqual(review, "CC BY-SA 4.0 (review)")
def test_ccbysa_becomes_committable_only_with_the_flag(self):
clean, review = rb.normalise_licence("CC BY-SA 3.0", allow_ccbysa=True)
self.assertEqual(clean, "CC BY-SA 3.0 (review)")
self.assertIsNone(review)
def test_unknown_licence_is_never_committable(self):
clean, review = rb.normalise_licence("Fair use")
self.assertIsNone(clean)
self.assertEqual(review, "Fair use")
class SearchFilterTests(unittest.TestCase):
@staticmethod
def _row(title, licence="CC0", date="2026-09-30", width=1600):
clean, review = rb.normalise_licence(licence)
return {"title": title, "licence": licence, "clean": clean, "review": review,
"date": date, "url": "https://example/" + title, "width": width,
"artist": "a", "page": "p", "height": 1000, "description": "d",
"credit": "", "licence_url": ""}
def test_recent_and_usable_selection(self):
# `d` is small-width (skipped); `c` is old but CC0, so it is usable and
# sorted after the recent one; only `b` is review-only.
rows = [self._row("a"), self._row("b", licence="CC BY-SA 4.0"),
self._row("c", date="2015-01-01"), self._row("d", width=800)]
with mock.patch.object(rb.Commons, "meta_for_titles", return_value={
r["title"]: r for r in rows}), \
mock.patch.object(rb, "collect", return_value=[r["title"] for r in rows]):
args = mock.Mock(source="geo", query="34.1,-118.3", limit=10, months=12,
min_width=1200, json=True, allow_ccbysa=False)
with mock.patch("builtins.print") as printed:
rb.cmd_search(args)
payload = json.loads(printed.call_args[0][0])
usable = [r["title"] for r in payload if r["usable"]]
review = [r["title"] for r in payload if r.get("research_only")]
self.assertEqual(usable, ["a", "c"])
self.assertEqual(review, ["b"])
def test_dates_pull_the_first_iso_component(self):
text = rb.Commons().meta_for_titles # method exists
self.assertEqual(rb._age_months("2026-09-30", "2026-11-03"), 2)
self.assertIsNone(rb._age_months("", "2026-11-03"))
class FetchTests(unittest.TestCase):
def test_fetch_skips_review_rows_unless_flagged(self):
rows = [{"title": "File:Good.jpg", "usable": True, "url": "u1", "clean": "CC0",
"licence": "CC0", "date": "2026-01-01"},
{"title": "File:ShareAlike.jpg", "usable": False, "research_only": True,
"url": "u2", "clean": None, "review": "CC BY-SA 3.0 (review)",
"licence": "CC BY-SA 3.0", "date": "2026-01-01"}]
with TemporaryDirectory() as tmp:
manifest = Path(tmp) / "meta.json"
manifest.write_text(json.dumps(rows))
args = mock.Mock(manifest=str(manifest), dir=str(Path(tmp) / "out"),
max=10, only_recent=False, force=True, include_review=False)
with mock.patch.object(rb.Commons, "_get", return_value=b"png"):
rb.cmd_fetch(args)
names = sorted(p.name for p in (Path(tmp) / "out").iterdir())
self.assertEqual(names, ["Good.jpg", "meta.json"])
def test_rows_write_sidecar_and_readme_lines(self):
rows = [{"title": "File:Some Photo.jpg", "usable": True, "date": "2026-05-01",
"clean": "CC0", "licence": "CC0", "artist": "An Author",
"page": "https://commons/x", "description": "A photo",
"url": "u", "width": 1600, "height": 900, "review": None,
"credit": "", "licence_url": ""}]
with TemporaryDirectory() as tmp:
manifest = Path(tmp) / "meta.json"
manifest.write_text(json.dumps(rows))
args = mock.Mock(manifest=str(manifest), append_sidecar="")
with mock.patch("builtins.print") as printed:
self.assertEqual(rb.cmd_rows(args), 0)
output = "\n".join(str(c[0][0]) for c in printed.call_args_list)
self.assertIn("| `some-photo-2026-05-01.jpg` |", output)
self.assertIn("![File:Some Photo.jpg](some-photo-2026-05-01.jpg)", output)
if __name__ == "__main__":
unittest.main()
+214
View File
@@ -0,0 +1,214 @@
"""Tests for the local-only reference collector (no network)."""
import datetime
import email.utils
import gzip
import json
import socket
import unittest
import urllib.error
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
import refcollect as rc
def _fake_clock(start=0.0):
"""Return (clock, monotonic, sleep) that advance virtual time on sleep."""
clock = {"now": start}
def monotonic():
return clock["now"]
def sleep(seconds):
clock["now"] += seconds
return clock, monotonic, sleep
class _FakeResponse:
"""Minimal stand-in for the urlopen context manager."""
def __init__(self, body=b"x", content_encoding=None):
self._body = body
self.headers = ({"Content-Encoding": content_encoding}
if content_encoding else {})
def read(self):
return self._body
def __enter__(self):
return self
def __exit__(self, *exc):
return False
class SlugTests(unittest.TestCase):
def test_slug_folds_and_strips(self):
self.assertEqual(rc._slug("El Capitan Entertainment Centre"),
"el-capitan-entertainment-centre")
self.assertEqual(rc._slug(" !!! "), "block")
class ManifestTests(unittest.TestCase):
def test_manifest_rows_are_marked_local_only_and_never_licenced(self):
rows = [{"title": "t", "image": "https://x/i.jpg", "thumbnail": "",
"source": "https://x/", "width": 640, "height": 480}]
out = rc._manifest_rows(rows)
self.assertTrue(out[0]["local_only"])
self.assertEqual(out[0]["licence"], "unchecked-local")
class SheetTests(unittest.TestCase):
def test_sheet_requires_a_manifest(self):
with TemporaryDirectory() as tmp:
with mock.patch.object(rc, "RESEARCH_DIR", Path(tmp)):
with self.assertRaises(SystemExit):
rc.cmd_sheet(mock.Mock(slug="nope"))
def test_sheet_composes_from_downloaded_frames(self):
from PIL import Image
with TemporaryDirectory() as tmp:
root = Path(tmp)
slug_dir = root / "block"
slug_dir.mkdir()
for i in range(3):
Image.new("RGB", (640, 400), (i * 60, 0, 0)
).save(slug_dir / f"{i:02d}.jpg")
(slug_dir / "manifest.json").write_text(json.dumps([
{"file": "00.jpg"}, {"file": "01.jpg"}, {"file": "02.jpg"}]))
with mock.patch.object(rc, "RESEARCH_DIR", root):
rc.cmd_sheet(mock.Mock(slug="block"))
self.assertTrue((slug_dir / "sheet.jpg").is_file())
class TokenBucketTests(unittest.TestCase):
def test_burst_is_spent_before_sleeping(self):
clock, monotonic, sleep = _fake_clock()
with mock.patch.object(rc.time, "monotonic", monotonic), \
mock.patch.object(rc.time, "sleep", sleep):
bucket = rc.TokenBucket(rate=1.0, capacity=2.0)
self.assertEqual(bucket.acquire(), 0.0)
self.assertEqual(bucket.acquire(), 0.0)
self.assertAlmostEqual(bucket.acquire(), 1.0)
self.assertAlmostEqual(bucket.acquire(), 1.0)
self.assertAlmostEqual(clock["now"], 2.0)
def test_tokens_refill_up_to_capacity_only(self):
clock, monotonic, sleep = _fake_clock()
with mock.patch.object(rc.time, "monotonic", monotonic), \
mock.patch.object(rc.time, "sleep", sleep):
bucket = rc.TokenBucket(rate=1.0, capacity=2.0)
bucket.acquire(2.0)
self.assertAlmostEqual(bucket.tokens, 0.0)
clock["now"] += 100.0
self.assertAlmostEqual(bucket.tokens, 2.0) # capped at capacity
clock["now"] += 100.0
self.assertAlmostEqual(bucket.tokens, 2.0)
def test_invalid_configuration_is_rejected(self):
with self.assertRaises(ValueError):
rc.TokenBucket(rate=0)
with self.assertRaises(ValueError):
rc.TokenBucket(rate=1.0, capacity=0.5)
class BackoffTests(unittest.TestCase):
def test_backoff_grows_and_stays_within_its_window(self):
for attempt in range(1, 6):
ceiling = min(rc.BACKOFF_CAP_S, rc.BACKOFF_BASE_S *
2 ** (attempt - 1))
for _ in range(20):
delay = rc._backoff_delay(attempt)
self.assertGreaterEqual(delay, ceiling / 2)
self.assertLessEqual(delay, ceiling)
def test_backoff_is_capped(self):
self.assertLessEqual(rc._backoff_delay(20), rc.BACKOFF_CAP_S)
def test_retry_after_overrides_the_exponential_guess(self):
self.assertEqual(rc._backoff_delay(1, retry_after=30.0), 30.0)
self.assertEqual(rc._backoff_delay(
1, retry_after=0.0), 1.0) # never instant
self.assertEqual(rc._backoff_delay(
1, retry_after=10_000.0), rc.BACKOFF_CAP_S)
def test_retry_after_parses_seconds_and_http_dates(self):
self.assertEqual(rc._parse_retry_after("7"), 7.0)
self.assertIsNone(rc._parse_retry_after(None))
self.assertIsNone(rc._parse_retry_after("soon"))
when = email.utils.format_datetime(
datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(seconds=30))
self.assertAlmostEqual(rc._parse_retry_after(when), 30.0, delta=2.0)
class PolitenessTests(unittest.TestCase):
def test_user_agent_is_the_current_firefox_string_on_linux(self):
self.assertEqual(rc.BROWSER_HEADERS["User-Agent"], rc.USER_AGENT)
self.assertIn("X11; Linux x86_64", rc.USER_AGENT)
self.assertIn("Firefox/", rc.USER_AGENT)
# A UA on its own is a tell: the supporting browser headers must match.
for header in ("Accept", "Accept-Language", "Sec-Fetch-Dest", "Referer"):
self.assertIn(header, rc.BROWSER_HEADERS)
def test_gzip_bodies_are_decoded(self):
payload = gzip.compress(b"jpeg-bytes")
self.assertEqual(rc._decode_body(payload, "gzip"), b"jpeg-bytes")
self.assertEqual(rc._decode_body(b"plain", None), b"plain")
def test_polite_get_retries_transient_errors_then_succeeds(self):
error = urllib.error.HTTPError("https://x/i.jpg", 429,
"Too Many Requests",
{"Retry-After": "3"}, None)
bucket = rc.TokenBucket(rate=1000, capacity=1000)
with mock.patch.object(rc.urllib.request, "urlopen",
side_effect=[error, _FakeResponse(b"jpeg")]) as opened, \
mock.patch.object(rc.time, "sleep") as sleep:
body, _ = rc.polite_get("https://x/i.jpg", bucket=bucket)
self.assertEqual(body, b"jpeg")
self.assertEqual(opened.call_count, 2)
# Retry-After is honoured instead of the exponential guess.
sleep.assert_called_once_with(3.0)
sent = opened.call_args_list[0][0][0]
self.assertEqual(sent.get_header("User-agent"), rc.USER_AGENT)
def test_polite_get_does_not_retry_permanent_errors(self):
error = urllib.error.HTTPError(
"https://x/i.jpg", 404, "Not Found", {}, None)
bucket = rc.TokenBucket(rate=1000, capacity=1000)
with mock.patch.object(rc.urllib.request, "urlopen",
side_effect=error) as opened, \
mock.patch.object(rc.time, "sleep") as sleep, \
self.assertRaises(urllib.error.HTTPError):
rc.polite_get("https://x/i.jpg", bucket=bucket)
self.assertEqual(opened.call_count, 1)
sleep.assert_not_called()
def test_polite_get_does_not_retry_dns_failures(self):
error = urllib.error.URLError(socket.gaierror(-2, "Name not known"))
bucket = rc.TokenBucket(rate=1000, capacity=1000)
with mock.patch.object(rc.urllib.request, "urlopen",
side_effect=error) as opened, \
mock.patch.object(rc.time, "sleep") as sleep, \
self.assertRaises(urllib.error.URLError):
rc.polite_get("https://nope.invalid/i.jpg", bucket=bucket)
self.assertEqual(opened.call_count, 1)
sleep.assert_not_called()
def test_polite_get_gives_up_after_the_attempt_budget(self):
error = urllib.error.HTTPError(
"https://x/i.jpg", 503, "Unavailable", {}, None)
bucket = rc.TokenBucket(rate=1000, capacity=1000)
with mock.patch.object(rc.urllib.request, "urlopen",
side_effect=error) as opened, \
mock.patch.object(rc.time, "sleep"), \
self.assertRaises(urllib.error.HTTPError):
rc.polite_get("https://x/i.jpg", bucket=bucket, attempts=3)
self.assertEqual(opened.call_count, 3)
if __name__ == "__main__":
unittest.main()
+304
View File
@@ -0,0 +1,304 @@
import json
import re
import tempfile
import unittest
from unittest import mock
from pathlib import Path
from PIL import Image
import scene_concepts as sc
class CatalogueTests(unittest.TestCase):
def test_groups_and_prompts(self):
groups = {g for g, _ in sc.CATALOGUE.values()}
self.assertEqual(groups, {"scene", "backdrop", "texture"})
for name in sc.CATALOGUE:
prompt = sc.compose_prompt(name)
self.assertGreater(len(prompt), 80, name)
for day in ("skyline_day", "skyline_dusk", "skyline_night"):
self.assertEqual(sc.CATALOGUE[day][0], "backdrop")
def test_shots_point_at_scene_concepts(self):
for shot, concept in sc.SHOTS.items():
self.assertEqual(sc.CATALOGUE[concept][0], "scene", shot)
def test_paths_stay_in_their_folders(self):
self.assertTrue(sc.raw_path("hollywood_boulevard_entrance_day").is_relative_to(sc.CONCEPT_DIR))
self.assertTrue(sc.raw_path("hollywood_boulevard_entrance_day").is_relative_to(
sc.CONCEPT_DIR / "hollywood_boulevard"))
self.assertTrue(sc.raw_path("joke_shop_entrance_day").is_relative_to(
sc.CONCEPT_DIR / "joke_shop"))
self.assertTrue(sc.raw_path("skyline_day").is_relative_to(sc.RAW_DIR))
self.assertTrue(sc.raw_path("asphalt").is_relative_to(sc.RAW_DIR))
class ReferenceGateTests(unittest.TestCase):
ROOT = Path(sc.__file__).resolve().parent.parent
def test_refs_photo_listed_in_sidecar_passes_with_provenance(self):
result = sc._reference_source(
self.ROOT / "docs/screens/refs/hollywood/"
"el-capitan-facade-day-wikimedia-2009-10-27.jpg")
self.assertTrue(result["checked"])
self.assertEqual(result["licence"], "CC BY 2.0")
self.assertIn("commons.wikimedia.org", result["page"])
def test_refs_photo_missing_from_sidecar_is_refused(self):
with tempfile.TemporaryDirectory(dir=self.ROOT / "docs/screens/refs") as tmp:
path = Path(tmp) / "unlisted.jpg"
path.write_bytes(b"x")
with self.assertRaises(SystemExit):
sc._reference_source(path)
def test_outside_photo_requires_the_explicit_override(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "photo.jpg"
path.write_bytes(b"x")
with self.assertRaises(SystemExit):
sc._reference_source(path)
result = sc._reference_source(path, allow_unchecked=True)
self.assertFalse(result["checked"])
self.assertEqual(result["licence"], "unchecked")
def test_missing_file_is_refused(self):
with self.assertRaises(SystemExit):
sc._reference_source(self.ROOT / "docs/screens/refs/nope.jpg")
def test_generate_one_writes_reference_provenance(self):
reference = {"path": self.ROOT / "docs/screens/refs/hollywood/"
"el-capitan-facade-day-wikimedia-2009-10-27.jpg",
"licence": "CC BY 2.0", "page": "https://commons/x",
"checked": True}
class FakeImage:
def convert(self, mode):
return Image.new("RGB", (1600, 900))
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "hollywood_boulevard_entrance_day.png"
with mock.patch.object(sc.generate_textures, "generate_image",
return_value=FakeImage()) as mocked, \
mock.patch.object(sc, "next_free_path", return_value=out), \
mock.patch.object(sc, "_rel", side_effect=str):
sc.generate_one("hollywood_boulevard_entrance_day", "m", 1536,
reference=reference)
call = mocked.call_args
self.assertIn("keep the building", call.args[0].lower())
self.assertEqual(call.kwargs["reference_path"], reference["path"])
provenance = json.loads(out.with_suffix(".refgen.json").read_text())
self.assertEqual(provenance["reference"]["licence"], "CC BY 2.0")
self.assertTrue(provenance["reference"]["licence_checked"])
class NoOverwriteTests(unittest.TestCase):
def test_next_free_path_never_returns_an_existing_file(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp) / "a.png"
self.assertEqual(sc.next_free_path(base), base)
base.write_bytes(b"x")
second = sc.next_free_path(base)
self.assertEqual(second.name, "a-v2.png")
second.write_bytes(b"x")
self.assertEqual(sc.next_free_path(base).name, "a-v3.png")
class ProcessingTests(unittest.TestCase):
def _skyline(self, opaque=False):
image = Image.new("RGBA", (300, 200), (120, 170, 230, 255 if opaque else 0))
for x in range(300):
top = 80 + (x * 7) % 50
for y in range(top, 200):
image.putpixel((x, y), (40 + x % 30, 40, 50, 255))
return image
def test_key_sky_makes_an_opaque_sky_transparent(self):
keyed = sc.key_sky(self._skyline(opaque=True))
self.assertEqual(keyed.getpixel((5, 5))[3], 0)
self.assertEqual(keyed.getpixel((5, 199))[3], 255)
def test_band_keeps_tower_tops(self):
band = sc._band(self._skyline())
self.assertAlmostEqual(band.size[0] / band.size[1], 2.0, delta=0.6)
alpha = band.getchannel("A")
self.assertEqual(alpha.getpixel((0, band.size[1] - 1)), 255)
def test_processed_backdrop_audits_clean(self):
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "raw.png"
self._skyline().save(src)
out = sc.process_backdrop(src, Path(tmp) / "skyline.png")
result = sc.audit_backdrop(out)
self.assertEqual(result["errors"], [])
def test_texture_processing_writes_tile_and_normal(self):
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "raw.png"
Image.effect_noise((128, 128), 40).convert("RGB").save(src)
out = sc.process_texture(src, Path(tmp) / "tile.png", size=64)
self.assertEqual(Image.open(out).size, (64, 64))
normal = Image.open(out.with_name("tile_normal.png"))
self.assertEqual(normal.size, (64, 64))
r, g, b = normal.getpixel((10, 10))
self.assertGreater(b, 128) # normals point out of the surface
def _striped(period=64, size=256):
image = Image.new("RGB", (size, size), (200, 190, 170))
for x in range(size):
if (x // (period // 2)) % 2 == 0:
for y in range(size):
image.putpixel((x, y), (150, 40, 45))
return image
class StripeTests(unittest.TestCase):
def test_period_found_and_tile_wraps(self):
tile = _striped()
self.assertEqual(sc.stripe_period(tile), 64)
aligned = sc.align_stripes(tile)
self.assertEqual(aligned.size, tile.size)
# The wrap step along x matches a normal neighbouring-column step.
profile = sc._column_profile(aligned)
wrap = sc._step(profile[-1], profile[0])
hardest = max(sc._step(profile[x], profile[x + 1]) for x in range(aligned.size[0] - 1))
self.assertLessEqual(wrap, hardest * 1.25 + 0.5)
def test_flat_texture_has_no_period(self):
self.assertEqual(sc.stripe_period(Image.new("RGB", (128, 128), (90, 90, 90))), 0)
flat = Image.new("RGB", (64, 64), (128, 128, 128))
self.assertEqual(sc.align_stripes(flat).size, flat.size)
def test_striped_audit_accepts_a_boundary_on_the_edge(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "awning_canvas.png"
image = _striped(period=64, size=sc.TILE)
image.save(path)
result = sc.audit_striped_tile(path)
self.assertEqual(result["errors"], [])
class PromoteTests(unittest.TestCase):
def test_promote_copies_and_never_overwrites(self):
with tempfile.TemporaryDirectory() as tmp:
picks = Path(tmp) / "picks"
global_dir = sc.PICKS_DIR
sc.PICKS_DIR = picks
try:
sc.READY_DIR = Path(tmp)
Image.new("RGB", (16, 16), (10, 20, 30)).save(Path(tmp) / "src.png")
Image.new("RGB", (16, 16), (128, 128, 255)).save(Path(tmp) / "src_normal.png")
target = sc.promote("pick", "src")
self.assertTrue(target.is_file())
self.assertTrue((picks / "pick_normal.png").is_file())
Image.new("RGB", (16, 16), (0, 0, 0)).save(Path(tmp) / "src2.png")
again = sc.promote("pick", "src2")
self.assertEqual(again, target)
self.assertNotEqual(Image.open(target).getpixel((0, 0)), (0, 0, 0))
finally:
sc.PICKS_DIR = global_dir
sc.READY_DIR = Path(tmp).parent # restored by the loader below
def test_picks_exist_in_catalogue(self):
for name, source in sc.PICKS.items():
self.assertTrue(source.startswith(name), f"{name} <- {source}")
class RealismTests(unittest.TestCase):
def test_metrics_and_gap(self):
with tempfile.TemporaryDirectory() as tmp:
flat = Path(tmp) / "flat.png"
busy = Path(tmp) / "busy.png"
Image.new("RGB", (320, 180), (128, 128, 128)).save(flat)
Image.effect_noise((320, 180), 60).convert("RGB").save(busy)
m_flat, m_busy = sc.image_metrics(flat), sc.image_metrics(busy)
self.assertLess(m_flat["edge_density"], m_busy["edge_density"])
self.assertEqual(sc.metric_gap(m_busy, m_busy), 0.0)
def test_parse_realism_response_clamps_and_limits(self):
payload = {"choices": [{"message": {"content":
'{"scores": {"overall": 14, "lighting": "3"}, "fixes": ["a","b","c","d","e","f"]}'}}]}
verdict = sc.parse_realism_response(payload)
self.assertEqual(verdict["scores"], {"overall": 10.0, "lighting": 3.0})
self.assertEqual(len(verdict["fixes"]), 5)
self.assertEqual(sc.parse_realism_response({})["scores"], {})
def test_summary_averages_per_set(self):
report = {"sets": {"a": {
"x": {"metric_gap": 0.2, "judge": {"scores": {"overall": 2.0}}},
"y": {"metric_gap": 0.4, "judge": {"scores": {"overall": 4.0}}}}}}
summary = sc.summarize(report)
self.assertEqual(summary["a"]["overall"], 3.0)
self.assertAlmostEqual(summary["a"]["metric_gap"], 0.3)
self.assertIn("Mean scores", sc.render_markdown(
{"run": "r", "judge_model": None, "sets": report["sets"] | {}} | {
"sets": {"a": {k: dict(v, target="t.png", metrics={
"edge_density": 1, "fine_detail": 1, "contrast": 1})
for k, v in report["sets"]["a"].items()}}}))
def test_request_carries_both_images(self):
with tempfile.TemporaryDirectory() as tmp:
a = Path(tmp) / "a.png"
Image.new("RGB", (8, 8)).save(a)
body = sc.build_realism_request("m", a, a)
parts = body["messages"][0]["content"]
self.assertEqual([p["type"] for p in parts], ["text", "image_url", "image_url"])
if __name__ == "__main__":
unittest.main()
class AnchorTests(unittest.TestCase):
ROOT = Path(sc.__file__).resolve().parent.parent
def test_treatment_prompt_carries_context_suffix_and_clause(self):
prompt = sc._anchor_treatment_prompt("A 1920s masonic facade.", "night")
self.assertTrue(prompt.startswith("A 1920s masonic facade."))
self.assertIn("keep the building", prompt.lower())
self.assertIn("Restage the scene:", prompt)
self.assertIn("practical lights", prompt)
def test_unknown_treatment_names_are_rejected(self):
with self.assertRaises(SystemExit):
sc._anchor_treatment_prompt("c", "underwater")
def test_name_derivation_strips_the_capture_date(self):
reference = {"path": self.ROOT / "x" / "some-facade-2009-10-27",
"licence": "CC0", "page": "", "checked": True}
# exercised through cmd_anchor's stem logic via a tiny local copy
import re as _re
stem = _re.sub(r"-[0-9]{4}-[0-9]{2}-[0-9]{2}$", "",
"el-capitan-facade-day-wikimedia-2009-10-27")
self.assertEqual(stem, "el-capitan-facade-day-wikimedia")
def test_cmd_anchor_generates_each_treatment_with_provenance(self):
reference = {"path": self.ROOT / "docs/screens/refs/hollywood/"
"el-capitan-facade-day-wikimedia-2009-10-27.jpg",
"licence": "CC BY 2.0", "page": "https://commons/x",
"checked": True}
class FakeImage:
def convert(self, mode):
return Image.new("RGB", (1600, 900))
with tempfile.TemporaryDirectory() as tmp:
out_dir = Path(tmp) / "frames"
with mock.patch.object(sc, "_reference_source", return_value=reference), \
mock.patch.object(sc.generate_textures, "generate_image",
return_value=FakeImage()) as mocked, \
mock.patch.object(sc, "next_free_path",
side_effect=lambda p: p), \
mock.patch.object(sc, "_rel", side_effect=str):
args = mock.Mock(reference="r", reference_unchecked=False,
context="A facade.", treatments="day,night",
out_dir=str(out_dir), model="m",
request_size=1536)
args.name = "" # `name` is reserved on Mock; set it explicitly
self.assertEqual(sc.cmd_anchor(args), 0)
stems = [c.args[0] for c in mocked.call_args_list]
self.assertEqual(len(stems), 2)
for treatment, call in zip(("day", "night"), mocked.call_args_list):
self.assertIn("Restage the scene:", call.args[0])
self.assertEqual(call.kwargs["reference_path"],
reference["path"])
self.assertTrue((out_dir /
f"el_capitan_facade_day_wikimedia_{treatment}.png")
.exists())
+225
View File
@@ -0,0 +1,225 @@
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()
+71
View File
@@ -0,0 +1,71 @@
"""Tests for the three-up board composer (no network, no fixture images)."""
import json
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
from PIL import Image
import three_up as tu
def _img(w, h, colour=(200, 100, 50)):
return Image.new("RGB", (w, h), colour)
class ComposeTests(unittest.TestCase):
def test_panels_share_the_height_and_labels_are_baked_in(self):
board = tu.compose_board(_img(1600, 800), _img(1280, 720), _img(640, 480))
self.assertEqual(board.height, tu.PANEL_H + tu.LABEL_H)
widths = [p.width for p in
[board.crop((tu.GUTTER, tu.LABEL_H,
tu.GUTTER + 720, board.height))]]
# three panels + four gutters span the full width
expected = (720 + 640 + 480) + tu.GUTTER * 4
self.assertEqual(board.width, expected)
def test_board_is_never_darker_than_the_frame_colour(self):
board = tu.compose_board(_img(100, 100, (0, 0, 0)), _img(100, 100),
_img(100, 100))
# the label strip keeps the frame colour even with black panels
strip = board.crop((0, 0, board.width, tu.LABEL_H))
colours = set(strip.getdata())
self.assertIn((24, 24, 24), colours)
class NamingTests(unittest.TestCase):
def test_board_name_must_be_a_slug_ending_in_board(self):
for bad in ("", "with space-board", "slash/board", "no-suffix", "x.jpg"):
with self.assertRaises(SystemExit, msg=bad):
tu._board_path(bad)
def test_board_path_lands_in_the_boards_dir(self):
self.assertEqual(tu._board_path("elcapitan-day-board"),
tu.BOARDS_DIR / "elcapitan-day-board.jpg")
class MissingInputTests(unittest.TestCase):
def test_compose_exits_on_a_missing_image(self):
with self.assertRaises(SystemExit):
tu._load("definitely/not/here.png")
class RowsTests(unittest.TestCase):
def test_rows_emit_captions_from_the_sidecars(self):
with mock.patch.object(tu, "BOARDS_DIR",
Path(self.enterContext(TemporaryDirectory()))):
board = tu.BOARDS_DIR / "x-board.jpg"
board.write_bytes(b"jpg")
board.with_suffix(".json").write_text(json.dumps(
{"caption": "A venue, day."}))
with mock.patch("builtins.print") as printed:
self.assertEqual(tu.cmd_rows(None), 0)
out = "\n".join(str(c[0][0]) for c in printed.call_args_list)
self.assertIn("![three-up board: x](x-board.jpg)", out)
self.assertIn("A venue, day.", out)
if __name__ == "__main__":
unittest.main()