diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..bddf5b1 --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,348 @@ +"""Unit tests for `_fpforge/profile.py`. + +Covers `_validate_pin_key`, `_apply_pins_to_raw`, and `generate_profile`. +Test cases derived via ECP/BVA/error guessing. +""" +from dataclasses import FrozenInstanceError + +import pytest + +from invisible_playwright._fpforge import generate_profile +from invisible_playwright._fpforge.profile import ( + Profile, + _PIN_GROUPS, + _PIN_TO_RAW, + _apply_pins_to_raw, + _validate_pin_key, +) + + +# ───────────────────────────────────────────────────────────────────── +# _validate_pin_key +# ───────────────────────────────────────────────────────────────────── + +@pytest.mark.unit +def test_validate_pin_key_top_level_fonts(): + """VK1 — `fonts` is a known top-level key.""" + _validate_pin_key("fonts") + + +@pytest.mark.unit +def test_validate_pin_key_top_level_dark_theme(): + """VK2 — `dark_theme` is a known top-level key.""" + _validate_pin_key("dark_theme") + + +@pytest.mark.unit +def test_validate_pin_key_dotted_screen_width(): + """VK3 — valid dotted path `screen.width`.""" + _validate_pin_key("screen.width") + + +@pytest.mark.unit +def test_validate_pin_key_dotted_gpu_renderer(): + """VK4 — valid dotted path `gpu.renderer`.""" + _validate_pin_key("gpu.renderer") + + +@pytest.mark.unit +def test_validate_pin_key_dotted_webgl_msaa_samples(): + """VK5 — valid dotted path `webgl.msaa_samples`.""" + _validate_pin_key("webgl.msaa_samples") + + +@pytest.mark.unit +def test_validate_pin_key_no_dot_not_top_level_raises(): + """VK6 — bare key not in top-level set raises with hint.""" + with pytest.raises(ValueError, match="group.field"): + _validate_pin_key("bogus") + + +@pytest.mark.unit +def test_validate_pin_key_unknown_group_raises(): + """VK7 — unknown group prefix.""" + with pytest.raises(ValueError, match="unknown group"): + _validate_pin_key("network.port") + + +@pytest.mark.unit +def test_validate_pin_key_unknown_field_in_valid_group_raises(): + """VK8 — known group, unknown field.""" + with pytest.raises(ValueError, match="unknown field"): + _validate_pin_key("screen.brightness") + + +@pytest.mark.unit +def test_validate_pin_key_empty_string_raises(): + """VK9 — empty key fails the dotted-form check.""" + with pytest.raises(ValueError): + _validate_pin_key("") + + +@pytest.mark.unit +@pytest.mark.parametrize("group,fields", sorted(_PIN_GROUPS.items())) +def test_validate_pin_key_all_groups_first_field(group, fields): + """VK10 — every defined group accepts its sorted-first field.""" + first = sorted(fields)[0] + _validate_pin_key(f"{group}.{first}") + + +# ───────────────────────────────────────────────────────────────────── +# _apply_pins_to_raw +# ───────────────────────────────────────────────────────────────────── + +def _raw_baseline(): + """A minimal raw dict for pin tests — only the keys we care about.""" + return { + "screen_w": 1920, + "screen_h": 1080, + "webgl_vendor": "Google Inc. (Intel)", + "webgl_renderer": "ANGLE (Intel)", + "font_whitelist": "arial,calibri", + "dark_theme": 0, + } + + +@pytest.mark.unit +def test_apply_pins_to_raw_screen_width(): + """AP1 — `screen.width` rewrites `screen_w` in raw.""" + out = _apply_pins_to_raw(_raw_baseline(), {"screen.width": 2560}) + assert out["screen_w"] == 2560 + + +@pytest.mark.unit +def test_apply_pins_to_raw_fonts_list(): + """AP2 — list pin joined into comma-separated whitelist.""" + out = _apply_pins_to_raw(_raw_baseline(), {"fonts": ["Arial", "Verdana"]}) + assert out["font_whitelist"] == "Arial,Verdana" + + +@pytest.mark.unit +def test_apply_pins_to_raw_fonts_tuple(): + """AP3 — tuple pin is also accepted.""" + out = _apply_pins_to_raw(_raw_baseline(), {"fonts": ("Arial",)}) + assert out["font_whitelist"] == "Arial" + + +@pytest.mark.unit +def test_apply_pins_to_raw_fonts_string_raises(): + """AP4 — bare string is not a list/tuple, must raise.""" + with pytest.raises(TypeError, match="list/tuple"): + _apply_pins_to_raw(_raw_baseline(), {"fonts": "Arial"}) + + +@pytest.mark.unit +def test_apply_pins_to_raw_fonts_int_raises(): + """AP5 — int is also rejected.""" + with pytest.raises(TypeError): + _apply_pins_to_raw(_raw_baseline(), {"fonts": 42}) + + +@pytest.mark.unit +def test_apply_pins_to_raw_multiple_pins(): + """AP6 — multiple pins all land in raw.""" + pin = {"gpu.vendor": "X", "gpu.renderer": "Y"} + out = _apply_pins_to_raw(_raw_baseline(), pin) + assert out["webgl_vendor"] == "X" + assert out["webgl_renderer"] == "Y" + + +@pytest.mark.unit +def test_apply_pins_to_raw_returns_copy_not_mutation(): + """AP7 — input dict is not mutated.""" + raw = _raw_baseline() + snapshot = dict(raw) + _apply_pins_to_raw(raw, {"screen.width": 9999}) + assert raw == snapshot + + +@pytest.mark.unit +def test_apply_pins_to_raw_unknown_key_silent(): + """AP8 — key not in `_PIN_TO_RAW` (and not 'fonts') is ignored. + + Validation happens upstream in `generate_profile`; the inner helper + guards defensively but does not raise. + """ + raw = _raw_baseline() + out = _apply_pins_to_raw(raw, {"some.unknown": 123}) + # No change to known fields + assert out["screen_w"] == raw["screen_w"] + # No new key added + assert "some.unknown" not in out + + +# ───────────────────────────────────────────────────────────────────── +# generate_profile +# ───────────────────────────────────────────────────────────────────── + +@pytest.mark.unit +def test_generate_profile_happy_path(): + """GP1 — returns a fully populated Profile.""" + p = generate_profile(seed=42) + assert isinstance(p, Profile) + assert p.seed == 42 + assert p.gpu.vendor + assert p.gpu.renderer + assert p.gpu.class_tier in _PIN_GROUPS["gpu"].union({"low_end", "mid_range", + "high_end", "integrated_old", "integrated_modern", "workstation"}) + assert p.screen.width > 0 + assert p.screen.height > 0 + assert p.hardware.concurrency > 0 + assert p.audio.sample_rate > 0 + + +@pytest.mark.unit +def test_generate_profile_deterministic(): + """GP2 — same seed → identical Profile (equality on frozen dataclass).""" + a = generate_profile(seed=42) + b = generate_profile(seed=42) + assert a == b + + +@pytest.mark.unit +def test_generate_profile_seed_float_coerced(): + """GP3 — float seed is coerced to int (truncated).""" + a = generate_profile(seed=42.7) + b = generate_profile(seed=42) + assert a == b + + +@pytest.mark.unit +def test_generate_profile_seed_string_coerced(): + """GP4 — numeric string seed works via int() coercion.""" + a = generate_profile(seed="42") + b = generate_profile(seed=42) + assert a == b + + +@pytest.mark.unit +def test_generate_profile_no_pin_samples_freely(): + """GP5 — no pin: every field is sampler-derived (sanity: 2 seeds differ).""" + a = generate_profile(seed=1) + b = generate_profile(seed=2) + assert a != b + + +@pytest.mark.unit +def test_generate_profile_pin_overrides_screen_width(): + """GP6 — pinned width visible on the Profile dataclass.""" + p = generate_profile(seed=42, pin={"screen.width": 9999}) + assert p.screen.width == 9999 + + +@pytest.mark.unit +def test_generate_profile_pin_visible_in_prefs_dict(): + """GP7 — pinned values flow through to to_prefs_dict().""" + p = generate_profile(seed=42, pin={"screen.width": 9999}) + assert p.to_prefs_dict()["screen_w"] == 9999 + + +@pytest.mark.unit +def test_generate_profile_invalid_pin_raises(): + """GP8 — bad pin key surfaces ValueError from validation.""" + with pytest.raises(ValueError): + generate_profile(seed=42, pin={"bogus": 1}) + + +@pytest.mark.unit +def test_generate_profile_empty_pin_equals_no_pin(): + """GP9 — empty pin dict is a no-op.""" + a = generate_profile(seed=42, pin={}) + b = generate_profile(seed=42) + assert a == b + + +@pytest.mark.unit +def test_generate_profile_is_frozen(): + """GP10 — Profile dataclass is immutable.""" + p = generate_profile(seed=42) + with pytest.raises(FrozenInstanceError): + p.seed = 99 # type: ignore[misc] + + +@pytest.mark.unit +def test_generate_profile_fonts_is_list_of_strings(): + """GP11 — fonts is a non-empty list of stripped strings.""" + p = generate_profile(seed=42) + assert isinstance(p.fonts, list) + assert len(p.fonts) > 0 + assert all(isinstance(f, str) and f.strip() == f for f in p.fonts) + + +@pytest.mark.unit +def test_generate_profile_to_prefs_dict_flat_and_matches_raw(): + """GP12 — to_prefs_dict() returns a flat dict containing core sampler keys.""" + p = generate_profile(seed=42) + d = p.to_prefs_dict() + assert isinstance(d, dict) + for key in ("screen_w", "screen_h", "webgl_vendor", "webgl_renderer", + "hw_concurrency", "stealth_seed"): + assert key in d + + +@pytest.mark.unit +def test_generate_profile_seed_zero(): + """GP13 — seed=0 is a valid lowest-value boundary.""" + p = generate_profile(seed=0) + assert p.seed == 0 + + +@pytest.mark.unit +def test_generate_profile_seed_max_int31(): + """GP14 — seed at int31 upper bound works.""" + seed = (1 << 31) - 1 + p = generate_profile(seed=seed) + assert p.seed == seed + + +@pytest.mark.unit +def test_generate_profile_dark_theme_is_bool(): + """GP15 — dark_theme is coerced to bool on the dataclass.""" + p = generate_profile(seed=42) + assert isinstance(p.dark_theme, bool) + + +# ───────────────────────────────────────────────────────────────────── +# Additional pin coverage (recheck pass) +# ───────────────────────────────────────────────────────────────────── + +@pytest.mark.unit +def test_generate_profile_pin_dark_theme_true(): + """Pinning dark_theme=True flows through coercion to bool.""" + p = generate_profile(seed=42, pin={"dark_theme": True}) + assert p.dark_theme is True + + +@pytest.mark.unit +def test_generate_profile_pin_dark_theme_false(): + p = generate_profile(seed=42, pin={"dark_theme": False}) + assert p.dark_theme is False + + +@pytest.mark.unit +def test_generate_profile_pin_fonts_list_visible_on_profile(): + """fonts pin: list → joined raw string → split back to list on Profile.""" + p = generate_profile(seed=42, pin={"fonts": ["Arial", "Verdana"]}) + assert p.fonts == ["Arial", "Verdana"] + + +@pytest.mark.unit +def test_generate_profile_pin_gpu_renderer_propagates(): + p = generate_profile(seed=42, pin={"gpu.renderer": "FORCED_RENDERER"}) + assert p.gpu.renderer == "FORCED_RENDERER" + assert p.to_prefs_dict()["webgl_renderer"] == "FORCED_RENDERER" + + +@pytest.mark.unit +def test_generate_profile_pin_to_raw_keymap_complete(): + """Every dotted pin key (besides 'fonts') has a `_PIN_TO_RAW` mapping. + + Guards against silently-ignored pins if someone adds a key to `_PIN_GROUPS` + but forgets the raw-key mapping. + """ + dotted = {f"{group}.{field}" for group, fields in _PIN_GROUPS.items() + for field in fields} + # 'dark_theme' is top-level and present in _PIN_TO_RAW; 'fonts' is handled + # specially and intentionally absent. + missing = dotted - set(_PIN_TO_RAW.keys()) + assert missing == set(), f"pin keys without raw mapping: {sorted(missing)}"