#!/usr/bin/env python3 """ Tests for Matrix 3D Glitch Detector (timmy-config#491). Covers: glitch_patterns, matrix_glitch_detector core logic. """ import json import sys import tempfile import unittest from pathlib import Path # Ensure bin/ is importable sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "bin")) from glitch_patterns import ( GlitchCategory, GlitchPattern, GlitchSeverity, MATRIX_GLITCH_PATTERNS, build_vision_prompt, get_pattern_by_category, get_patterns_by_severity, ) from matrix_glitch_detector import ( DetectedGlitch, ScanResult, _infer_severity, _parse_vision_response, build_report, generate_scan_angles, run_demo, ) class TestGlitchPatterns(unittest.TestCase): """Tests for glitch_patterns module.""" def test_pattern_count(self): """Verify we have a reasonable number of defined patterns.""" self.assertGreaterEqual(len(MATRIX_GLITCH_PATTERNS), 8) def test_all_patterns_have_required_fields(self): """Every pattern must have category, name, description, severity, prompts.""" for p in MATRIX_GLITCH_PATTERNS: self.assertIsInstance(p.category, GlitchCategory) self.assertTrue(p.name) self.assertTrue(p.description) self.assertIsInstance(p.severity, GlitchSeverity) self.assertGreater(len(p.detection_prompts), 0) self.assertGreater(len(p.visual_indicators), 0) self.assertGreater(p.confidence_threshold, 0) self.assertLessEqual(p.confidence_threshold, 1.0) def test_pattern_to_dict(self): """Pattern serialization should produce a dict with expected keys.""" p = MATRIX_GLITCH_PATTERNS[0] d = p.to_dict() self.assertIn("category", d) self.assertIn("name", d) self.assertIn("severity", d) self.assertEqual(d["category"], p.category.value) def test_get_patterns_by_severity(self): """Severity filter should return only patterns at or above threshold.""" high_patterns = get_patterns_by_severity(GlitchSeverity.HIGH) self.assertTrue(all(p.severity.value in ("high", "critical") for p in high_patterns)) self.assertGreater(len(high_patterns), 0) all_patterns = get_patterns_by_severity(GlitchSeverity.INFO) self.assertEqual(len(all_patterns), len(MATRIX_GLITCH_PATTERNS)) def test_get_pattern_by_category(self): """Lookup by category should return the correct pattern.""" p = get_pattern_by_category(GlitchCategory.FLOATING_ASSETS) self.assertIsNotNone(p) self.assertEqual(p.category, GlitchCategory.FLOATING_ASSETS) missing = get_pattern_by_category("nonexistent_category_value") self.assertIsNone(missing) def test_build_vision_prompt(self): """Vision prompt should contain pattern names and be non-trivial.""" prompt = build_vision_prompt() self.assertGreater(len(prompt), 200) self.assertIn("Floating Object", prompt) self.assertIn("Z-Fighting", prompt) self.assertIn("Missing", prompt) def test_build_vision_prompt_subset(self): """Vision prompt with subset should only include specified patterns.""" subset = MATRIX_GLITCH_PATTERNS[:3] prompt = build_vision_prompt(subset) self.assertIn(subset[0].name, prompt) self.assertNotIn(MATRIX_GLITCH_PATTERNS[-1].name, prompt) class TestGlitchDetector(unittest.TestCase): """Tests for matrix_glitch_detector module.""" def test_generate_scan_angles_default(self): """Default 4 angles should return front, right, back, left.""" angles = generate_scan_angles(4) self.assertEqual(len(angles), 4) labels = [a["label"] for a in angles] self.assertIn("front", labels) self.assertIn("right", labels) self.assertIn("back", labels) self.assertIn("left", labels) def test_generate_scan_angles_many(self): """Requesting more angles than base should still return correct count.""" angles = generate_scan_angles(12) self.assertEqual(len(angles), 12) # Should still have the standard ones labels = [a["label"] for a in angles] self.assertIn("front", labels) def test_generate_scan_angles_few(self): """Requesting fewer angles should return fewer.""" angles = generate_scan_angles(2) self.assertEqual(len(angles), 2) def test_detected_glitch_dataclass(self): """DetectedGlitch should serialize cleanly.""" g = DetectedGlitch( id="test001", category="floating_assets", name="Test Glitch", description="A test glitch", severity="high", confidence=0.85, location_x=50.0, location_y=30.0, screenshot_index=0, screenshot_angle="front", ) self.assertEqual(g.id, "test001") self.assertTrue(g.timestamp) # Auto-generated def test_infer_severity_critical(self): """Missing textures should infer critical/high severity.""" sev = _infer_severity("missing_textures", 0.9) self.assertEqual(sev, "critical") sev_low = _infer_severity("missing_textures", 0.5) self.assertEqual(sev_low, "high") def test_infer_severity_floating(self): """Floating assets should infer high/medium severity.""" sev = _infer_severity("floating_assets", 0.8) self.assertEqual(sev, "high") sev_low = _infer_severity("floating_assets", 0.5) self.assertEqual(sev_low, "medium") def test_infer_severity_default(self): """Unknown categories should default to medium/low.""" sev = _infer_severity("unknown_thing", 0.7) self.assertEqual(sev, "medium") sev_low = _infer_severity("unknown_thing", 0.3) self.assertEqual(sev_low, "low") def test_parse_vision_response_json_array(self): """Should parse a JSON array response.""" response = json.dumps([ { "category": "floating_assets", "name": "Float Test", "description": "Chair floating", "confidence": 0.9, "severity": "high", "location_x": 40, "location_y": 60, } ]) glitches = _parse_vision_response(response, 0, "front") self.assertEqual(len(glitches), 1) self.assertEqual(glitches[0].category, "floating_assets") self.assertAlmostEqual(glitches[0].confidence, 0.9) def test_parse_vision_response_wrapped(self): """Should parse a response with 'glitches' wrapper key.""" response = json.dumps({ "glitches": [ { "category": "z_fighting", "name": "Shimmer", "confidence": 0.6, } ] }) glitches = _parse_vision_response(response, 1, "right") self.assertEqual(len(glitches), 1) self.assertEqual(glitches[0].category, "z_fighting") def test_parse_vision_response_empty(self): """Should return empty list for non-JSON text.""" glitches = _parse_vision_response("No glitches found.", 0, "front") self.assertEqual(len(glitches), 0) def test_parse_vision_response_code_block(self): """Should extract JSON from markdown code blocks.""" response = '```json\n[{"category": "clipping", "name": "Clip", "confidence": 0.7}]\n```' glitches = _parse_vision_response(response, 0, "front") self.assertEqual(len(glitches), 1) def test_build_report(self): """Report should have correct summary statistics.""" angles = generate_scan_angles(4) screenshots = [Path(f"/tmp/ss_{i}.png") for i in range(4)] glitches = [ DetectedGlitch( id="a", category="floating_assets", name="Float", description="", severity="high", confidence=0.8, screenshot_index=0, screenshot_angle="front", ), DetectedGlitch( id="b", category="missing_textures", name="Missing", description="", severity="critical", confidence=0.95, screenshot_index=1, screenshot_angle="right", ), ] report = build_report("https://test.com", angles, screenshots, glitches) self.assertEqual(report.total_screenshots, 4) self.assertEqual(len(report.glitches), 2) self.assertEqual(report.summary["total_glitches"], 2) self.assertEqual(report.summary["by_severity"]["critical"], 1) self.assertEqual(report.summary["by_severity"]["high"], 1) self.assertEqual(report.summary["by_category"]["floating_assets"], 1) self.assertEqual(report.metadata["reference"], "timmy-config#491") def test_build_report_json_roundtrip(self): """Report JSON should parse back correctly.""" angles = generate_scan_angles(2) screenshots = [Path(f"/tmp/ss_{i}.png") for i in range(2)] report = build_report("https://test.com", angles, screenshots, []) json_str = report.to_json() parsed = json.loads(json_str) self.assertEqual(parsed["url"], "https://test.com") self.assertEqual(parsed["total_screenshots"], 2) def test_run_demo(self): """Demo mode should produce a report with simulated glitches.""" with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: output_path = Path(f.name) try: report = run_demo(output_path) self.assertEqual(len(report.glitches), 4) self.assertGreater(report.summary["total_glitches"], 0) self.assertTrue(output_path.exists()) # Verify the saved JSON is valid saved = json.loads(output_path.read_text()) self.assertIn("scan_id", saved) self.assertIn("glitches", saved) finally: output_path.unlink(missing_ok=True) class TestIntegration(unittest.TestCase): """Integration-level tests.""" def test_full_pipeline_demo(self): """End-to-end demo pipeline should complete without errors.""" report = run_demo() self.assertIsNotNone(report.scan_id) self.assertTrue(report.timestamp) self.assertGreater(report.total_screenshots, 0) def test_patterns_cover_matrix_themes(self): """Patterns should cover the main Matrix glitch themes.""" category_values = {p.category.value for p in MATRIX_GLITCH_PATTERNS} expected = {"floating_assets", "z_fighting", "missing_textures", "clipping", "broken_normals"} self.assertTrue(expected.issubset(category_values)) if __name__ == "__main__": unittest.main()