"""IMP-56 (#90) u4 — tests for ``src.text_override_resolver``. Covers the resolver contract called out in the Stage 2 plan : 1. ``parse_text_path`` happy path : ``slot_title.0`` → ``("slot_title", 0)``. 2. ``parse_text_path`` rejects: empty / non-string / no '.' / negative index / non-int index / empty slot_key / empty line_index. 3. ``parse_text_path`` ``rpartition`` semantics — compound slot_key with internal '.' is preserved (split is on LAST '.'). 4. ``validate_text_overrides`` returns ``{}`` for non-mapping input. 5. ``validate_text_overrides`` drops malformed per-entry rows without rejecting the whole batch (mirrors image_id_stamper per-entry tolerance). 6. ``validate_text_overrides`` preserves well-formed entries. 7. ``validate_text_overrides`` returns a fresh dict (caller can mutate without aliasing the source). 8. ``apply_text_override`` happy path mutates in-place and returns ``True``. 9. ``apply_text_override`` returns ``False`` (no mutation) on absent slot. 10. ``apply_text_override`` returns ``False`` (no mutation) on line_index >= len(lines) (forward-compat with frame swap / layout regression dropping the slot row). 11. ``apply_text_override`` returns ``False`` (no mutation) on a non-list slot (defensive against malformed zone wrappers). 12. ``apply_text_override`` preserves untouched lines / other slots. All tests are pure-Python — no filesystem, no Selenium, no fixtures. """ from __future__ import annotations import pytest from src.text_override_resolver import ( InvalidTextOverride, apply_text_override, parse_text_path, validate_text_overrides, ) # -- parse_text_path -------------------------------------------------------- def test_parse_text_path_simple(): assert parse_text_path("slot_title.0") == ("slot_title", 0) assert parse_text_path("slot_body.5") == ("slot_body", 5) def test_parse_text_path_compound_slot_key(): # rpartition semantics — split on LAST '.' so compound keys survive. # Note: a numeric-looking compound suffix (e.g., 'slot.1.5') parses to # ('slot.1', 5) by design; callers that want a strict identifier-only # slot_key should enforce that in their own stamper (u8). assert parse_text_path("slot.compound.2") == ("slot.compound", 2) assert parse_text_path("slot_title.1.5") == ("slot_title.1", 5) @pytest.mark.parametrize( "bad_path", [ "", "no_dot", ".0", "slot_title.", "slot_title.-1", "slot_title.abc", ], ) def test_parse_text_path_rejects_malformed(bad_path): with pytest.raises(InvalidTextOverride): parse_text_path(bad_path) @pytest.mark.parametrize("bad_type", [None, 0, 1.5, [], {}, b"slot.0"]) def test_parse_text_path_rejects_non_string(bad_type): with pytest.raises(InvalidTextOverride): parse_text_path(bad_type) # type: ignore[arg-type] # -- validate_text_overrides ------------------------------------------------ @pytest.mark.parametrize("bad_input", [None, [], "string", 42, 1.5]) def test_validate_text_overrides_non_mapping_returns_empty(bad_input): assert validate_text_overrides(bad_input) == {} def test_validate_text_overrides_passes_well_formed(): payload = { "zone-top": {"slot_title.0": "edited headline"}, "zone-bottom": {"slot_body.0": "line A", "slot_body.1": "line B"}, } out = validate_text_overrides(payload) assert out == payload assert out is not payload # fresh dict def test_validate_text_overrides_per_entry_tolerance(): # Mix of well-formed + every variety of malformed; only the well-formed # rows should survive. payload = { "zone-top": { "slot_title.0": "good", "no_dot": "bad path", "slot_x.abc": "bad index", "slot_y.-1": "negative", 42: "non-string path", "slot_z.0": 99, # non-string value }, "": {"slot_title.0": "bad zone id"}, # empty zone_id dropped 42: {"slot_title.0": "non-string zone id"}, # non-string dropped "zone-empty-after-filter": {"no_dot": "x"}, # entire zone drops "zone-non-mapping": "not a dict", # non-mapping dropped "zone-bottom": {"slot_body.0": "kept"}, } out = validate_text_overrides(payload) assert out == { "zone-top": {"slot_title.0": "good"}, "zone-bottom": {"slot_body.0": "kept"}, } def test_validate_text_overrides_returns_fresh_nested_dicts(): # Mutating the returned dict's per-zone payload must not leak back # into the source (callers should be able to use the result as a # working buffer). payload = {"zone-top": {"slot_title.0": "v"}} out = validate_text_overrides(payload) out["zone-top"]["slot_title.0"] = "mutated" assert payload["zone-top"]["slot_title.0"] == "v" # -- apply_text_override ---------------------------------------------------- def test_apply_text_override_happy_path_mutates_in_place(): zone: dict = {"slot_title": ["orig"]} assert apply_text_override(zone, "slot_title.0", "edited") is True assert zone == {"slot_title": ["edited"]} def test_apply_text_override_multi_line_slot(): zone: dict = {"slot_body": ["line A", "line B", "line C"]} assert apply_text_override(zone, "slot_body.1", "REPLACED") is True assert zone == {"slot_body": ["line A", "REPLACED", "line C"]} def test_apply_text_override_absent_slot_returns_false_no_mutation(): zone: dict = {"slot_title": ["orig"]} assert apply_text_override(zone, "slot_missing.0", "x") is False assert zone == {"slot_title": ["orig"]} def test_apply_text_override_out_of_range_returns_false_no_mutation(): # Forward-compat: a prior render's text_path may target an index the # new render's slot no longer emits (frame swap / layout regression). # This is NOT an error — the override is silently skipped. zone: dict = {"slot_body": ["only one line"]} assert apply_text_override(zone, "slot_body.5", "x") is False assert zone == {"slot_body": ["only one line"]} def test_apply_text_override_non_list_slot_returns_false_no_mutation(): # Defensive: if a wrapper passes a non-list slot value, do not crash. zone: dict = {"slot_title": "not a list"} assert apply_text_override(zone, "slot_title.0", "x") is False assert zone == {"slot_title": "not a list"} def test_apply_text_override_preserves_other_slots_and_lines(): zone: dict = { "slot_title": ["title line"], "slot_body": ["body line A", "body line B"], "slot_caption": ["caption"], } assert apply_text_override(zone, "slot_body.0", "EDITED A") is True assert zone == { "slot_title": ["title line"], # untouched "slot_body": ["EDITED A", "body line B"], # only line 0 changed "slot_caption": ["caption"], # untouched } def test_apply_text_override_propagates_parse_errors(): # apply delegates path parsing to parse_text_path; malformed paths # raise (NOT return False) so callers can distinguish "skipped because # path didn't match the live DOM" from "path was malformed at source". zone: dict = {"slot_title": ["x"]} with pytest.raises(InvalidTextOverride): apply_text_override(zone, "no_dot", "x")