Organize tools and move Python tests into tools/tests
This commit is contained in:
1 parent
0aacee6518
commit
e4e1566e55
22 files changed
+209
-3
No files matched your search
@@ -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.
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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", "")
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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("", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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("", out)
|
||||
self.assertIn("A venue, day.", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user