"""Unit tests for src.analysis.difficulty_analyzer -- verifies the exact spec formula.""" from __future__ import annotations import math from src.analysis.difficulty_analyzer import DifficultyAnalyzer from src.analysis.geometry_analyzer import GeometryResult from src.config.schema import DifficultyWeights def _geometry( polygon_overlap_proxy: float, whitespace_ratio: float, mean_dark_intensity: float, signature_area_ratio: float, gray_stddev: float, ) -> GeometryResult: box = (0.0, 0.0, 100.0, 20.0) return GeometryResult( found=True, signature_bbox_px=box, min_rotated_rect={}, signature_polygon_px=[[0, 0], [1, 0], [1, 1]], convex_hull_px=[], centroid_px=(50.0, 10.0), orientation_deg=0.0, ink_pixels=100, target_box=box, actual_box=box, search_box=box, box_coverage_pct=0.0, containment_pct=0.0, ink_fill_ratio=1 - whitespace_ratio, polygon_overlap_proxy=polygon_overlap_proxy, distance_from_box_center_px=0.0, margin_utilization=0.0, mean_dark_intensity=mean_dark_intensity, gray_stddev=gray_stddev, signature_area_ratio=signature_area_ratio, whitespace_ratio=whitespace_ratio, ) def test_difficulty_score_matches_exact_spec_formula(): weights = DifficultyWeights() analyzer = DifficultyAnalyzer(weights) geom = _geometry( polygon_overlap_proxy=0.4, # misplacement = 0.6 whitespace_ratio=0.5, # whitespace = 0.5 mean_dark_intensity=150.0, # faintness = clamp((150-90)/120) = 0.5 signature_area_ratio=0.02, # smallness = clamp(1 - 0.02/0.04) = 0.5 gray_stddev=30.0, # low_texture = clamp(1 - 30/60) = 0.5 ) result = analyzer.analyze(geom) assert math.isclose(result.misplacement, 0.6, abs_tol=1e-6) assert math.isclose(result.whitespace, 0.5, abs_tol=1e-6) assert math.isclose(result.faintness, 0.5, abs_tol=1e-6) assert math.isclose(result.smallness, 0.5, abs_tol=1e-6) assert math.isclose(result.low_texture, 0.5, abs_tol=1e-6) expected = 0.35 * 0.6 + 0.20 * 0.5 + 0.20 * 0.5 + 0.15 * 0.5 + 0.10 * 0.5 assert math.isclose(result.score, expected, abs_tol=1e-6) def test_difficulty_components_are_clamped_to_unit_interval(): weights = DifficultyWeights() analyzer = DifficultyAnalyzer(weights) geom = _geometry( polygon_overlap_proxy=-5.0, # misplacement would exceed 1 -> clamp to 1 whitespace_ratio=2.0, # clamp to 1 mean_dark_intensity=1000.0, # faintness clamp to 1 signature_area_ratio=-1.0, # smallness clamp to 1 gray_stddev=-10.0, # low_texture clamp to 1 ) result = analyzer.analyze(geom) assert result.misplacement == 1.0 assert result.whitespace == 1.0 assert result.faintness == 1.0 assert result.smallness == 1.0 assert result.low_texture == 1.0 assert result.score == 1.0 assert result.tier == "hard" def test_difficulty_tier_boundaries(): weights = DifficultyWeights() analyzer = DifficultyAnalyzer(weights) # All-zero components -> score 0 -> easy. geom_easy = _geometry( polygon_overlap_proxy=1.0, whitespace_ratio=0.0, mean_dark_intensity=90.0, signature_area_ratio=0.04, gray_stddev=60.0, ) assert analyzer.analyze(geom_easy).tier == "easy" assert analyzer.analyze(geom_easy).score == 0.0 # All components at 1.0 -> score 1.0 -> hard. geom_hard = _geometry( polygon_overlap_proxy=0.0, whitespace_ratio=1.0, mean_dark_intensity=300.0, signature_area_ratio=0.0, gray_stddev=0.0, ) assert analyzer.analyze(geom_hard).tier == "hard" def test_difficulty_score_is_reproducible_given_same_geometry(): weights = DifficultyWeights() analyzer = DifficultyAnalyzer(weights) geom = _geometry(0.3, 0.4, 140.0, 0.01, 25.0) r1 = analyzer.analyze(geom) r2 = analyzer.analyze(geom) assert r1 == r2 def test_difficulty_weights_must_sum_to_one(): import pytest from pydantic import ValidationError with pytest.raises(ValidationError): DifficultyWeights(misplacement=0.9, whitespace=0.2, faintness=0.2, smallness=0.15, low_texture=0.10)