"""Tests for Tools/visual_loop.py — metrics, comparison and feedback rules. Synthetic 8-bit images are written to a temp directory and measured, so the numbers can be reasoned about directly (a mid-grey flat frame has near-zero edge density and contrast; a checkerboard has a lot of both). """ import os import sys _TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, _TOOLS) for _sub in ("review", "gen", "unreal", "blender", "input"): sys.path.insert(0, os.path.join(_TOOLS, _sub)) import os import sys import tempfile import unittest from PIL import Image ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) sys.path.insert(0, os.path.join(ROOT, "Tools")) import visual_loop as vl # noqa: E402 def write_image(path, kind, size=(64, 64)): img = Image.new("RGB", size) if kind == "flat": img.paste((128, 128, 128), (0, 0, size[0], size[1])) elif kind == "dark": img.paste((10, 10, 10), (0, 0, size[0], size[1])) elif kind == "checker": px = img.load() for y in range(size[1]): for x in range(size[0]): px[x, y] = (240, 240, 240) if (x // 4 + y // 4) % 2 else (20, 20, 20) elif kind == "sky": # bright top third, mid stone bottom: composition like an elevation px = img.load() for y in range(size[1]): for x in range(size[0]): px[x, y] = (150, 190, 230) if y < size[1] // 3 else (170, 165, 155) img.save(path) return path class MetricsTest(unittest.TestCase): def test_flat_frame_has_no_edges_and_no_contrast(self): with tempfile.TemporaryDirectory() as tmp: m = vl.metrics(write_image(os.path.join(tmp, "a.png"), "flat")) self.assertEqual(m["size"], [64, 64]) self.assertAlmostEqual(m["mean_luma"], 128.0, delta=1.0) self.assertLess(m["rms_contrast"], 1.0) self.assertLess(m["edge_density"], vl.MIN_EDGE_DENSITY) def test_checker_frame_has_edges_and_contrast(self): with tempfile.TemporaryDirectory() as tmp: m = vl.metrics(write_image(os.path.join(tmp, "b.png"), "checker")) self.assertGreater(m["edge_density"], 0.01) self.assertGreater(m["rms_contrast"], 50.0) def test_composition_grid_is_nine_cells(self): with tempfile.TemporaryDirectory() as tmp: m = vl.metrics(write_image(os.path.join(tmp, "c.png"), "sky")) self.assertEqual(len(m["grid_luma"]), 9) # sky row should be brighter than the stone rows self.assertGreater(m["grid_luma"][0], m["grid_luma"][8]) def test_dominant_palette_is_reported(self): with tempfile.TemporaryDirectory() as tmp: m = vl.metrics(write_image(os.path.join(tmp, "d.png"), "flat")) self.assertTrue(m["dominant"]) self.assertAlmostEqual(m["dominant"][0]["share"], 1.0, delta=0.05) class CompareTest(unittest.TestCase): def _metrics(self, kind): with tempfile.TemporaryDirectory() as tmp: return vl.metrics(write_image(os.path.join(tmp, "x.png"), kind)) def test_identical_images_score_high(self): m = self._metrics("checker") delta = vl.compare(m, m) self.assertGreater(delta["score"], 0.9) self.assertEqual(delta["delta_luma"], 0.0) def test_flat_against_checker_scores_low_on_edges(self): flat, checker = self._metrics("flat"), self._metrics("checker") delta = vl.compare(flat, checker) self.assertLess(delta["edge_ratio"], 0.5) self.assertLess(delta["score"], 0.9) def test_dark_against_bright_reports_a_luma_delta(self): dark, bright = self._metrics("dark"), self._metrics("checker") delta = vl.compare(dark, bright) self.assertLess(delta["delta_luma"], -50) class FeedbackTest(unittest.TestCase): def test_low_edge_ratio_names_the_modelling_skills(self): notes = vl.feedback("x", {}, {}, { "delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 0.3, "palette_distance": 10, "composition_correlation": 0.8, "score": 0.5}) joined = " ".join(notes) self.assertIn("Relief", joined) self.assertIn("blender-modeler", joined) def test_bright_and_compressed_flags_lighting_and_lookdev(self): notes = vl.feedback("x", {}, {}, { "delta_luma": 40, "contrast_ratio": 0.5, "edge_ratio": 1.0, "palette_distance": 10, "composition_correlation": 0.8, "score": 0.5}) joined = " ".join(notes) self.assertIn("Too bright", joined) self.assertIn("lookdev", joined) def test_framing_mismatch_names_the_camera_skill(self): notes = vl.feedback("x", {}, {}, { "delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 1.0, "palette_distance": 10, "composition_correlation": 0.1, "score": 0.5}) self.assertIn("camera-cinematography", " ".join(notes)) def test_no_delta_reports_no_action(self): notes = vl.feedback("x", {}, {}, { "delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 1.0, "palette_distance": 5, "composition_correlation": 0.9, "score": 0.95}) self.assertIn("No metric is outside its tolerance", notes[0]) class TargetSpecTest(unittest.TestCase): def test_every_target_has_expectation_and_reference(self): for target in vl.load_targets(): self.assertTrue(target["expect"].strip(), target["name"]) self.assertTrue(target["references"], target["name"]) self.assertTrue(target["shot"].startswith("Screenshots/demo/"), f"{target['name']} must be a demo-map shot") def test_iteration_output_is_gitignored(self): # Iterations are deliberately uncommitted until the owner confirms a shot. ignore = open(os.path.join(ROOT, ".gitignore"), encoding="utf-8").read() self.assertIn("/Art/reference/iterations/", ignore) if __name__ == "__main__": unittest.main()