"""IMP-56 (#90) u8 — scoped tests for src.text_path_stamper. Covers: path formatting, slot iteration, DOM injection, idempotence, excess-element handling, compound slot keys, and inverse symmetry with src.text_override_resolver.parse_text_path. """ from __future__ import annotations import pytest from src.text_override_resolver import parse_text_path from src.text_path_stamper import ( TEXT_PATH_ATTR, build_text_path, iter_zone_stamps, stamp_zone_html, ) # ─── build_text_path ────────────────────────────────────────────────────── def test_build_text_path_basic(): assert build_text_path("slot_title", 0) == "slot_title.0" def test_build_text_path_nonzero_index(): assert build_text_path("row_3_left_body", 5) == "row_3_left_body.5" def test_build_text_path_compound_slot_key(): # slot_key may itself contain '.'; parse_text_path uses rpartition. path = build_text_path("group.slot.compound", 2) assert path == "group.slot.compound.2" slot_key, line_index = parse_text_path(path) assert slot_key == "group.slot.compound" assert line_index == 2 def test_build_text_path_round_trip_with_resolver(): # Inverse symmetry with src.text_override_resolver.parse_text_path. for slot_key, idx in [ ("title", 0), ("row_1_left_body", 7), ("a.b", 3), ]: assert parse_text_path(build_text_path(slot_key, idx)) == (slot_key, idx) def test_build_text_path_rejects_empty_slot_key(): with pytest.raises(ValueError): build_text_path("", 0) def test_build_text_path_rejects_non_string_slot_key(): with pytest.raises(ValueError): build_text_path(123, 0) # type: ignore[arg-type] def test_build_text_path_rejects_negative_index(): with pytest.raises(ValueError): build_text_path("slot", -1) def test_build_text_path_rejects_non_int_index(): with pytest.raises(ValueError): build_text_path("slot", "0") # type: ignore[arg-type] def test_build_text_path_rejects_bool_index(): # bool is an int subclass — must still be rejected to avoid silent # path corruption (True → 'slot.1', False → 'slot.0'). with pytest.raises(ValueError): build_text_path("slot", True) # type: ignore[arg-type] # ─── iter_zone_stamps ───────────────────────────────────────────────────── def test_iter_zone_stamps_list_slots(): payload = { "body": ["line A", "line B", "line C"], } assert list(iter_zone_stamps(payload)) == [ ("body", 0), ("body", 1), ("body", 2), ] def test_iter_zone_stamps_preserves_dict_order(): payload = { "row_2": ["x", "y"], "row_1": ["p"], "row_3": ["q", "r"], } assert list(iter_zone_stamps(payload)) == [ ("row_2", 0), ("row_2", 1), ("row_1", 0), ("row_3", 0), ("row_3", 1), ] def test_iter_zone_stamps_skips_non_list_values(): payload = { "title": "Frame Title", # scalar — skipped "label": 42, # scalar — skipped "lines": ["a", "b"], # list — yielded "meta": {"k": "v"}, # mapping — skipped } assert list(iter_zone_stamps(payload)) == [("lines", 0), ("lines", 1)] def test_iter_zone_stamps_skips_empty_or_non_string_keys(): payload = { "": ["x"], 123: ["y"], # type: ignore[dict-item] "ok": ["z"], } assert list(iter_zone_stamps(payload)) == [("ok", 0)] def test_iter_zone_stamps_empty_payload(): assert list(iter_zone_stamps({})) == [] def test_iter_zone_stamps_non_mapping(): assert list(iter_zone_stamps(None)) == [] # type: ignore[arg-type] assert list(iter_zone_stamps([])) == [] # type: ignore[arg-type] def test_iter_zone_stamps_empty_list_value(): payload = {"lines": []} assert list(iter_zone_stamps(payload)) == [] # ─── stamp_zone_html ────────────────────────────────────────────────────── def test_stamp_zone_html_basic_stamping(): html = ( '
' '
A
' '
B
' '
' ) out = stamp_zone_html(html, {"body": ["A", "B"]}) assert f'
A
' in out assert f'
B
' in out def test_stamp_zone_html_preserves_modifier_classes(): html = ( '
A
' '
B
' ) out = stamp_zone_html(html, {"body": ["A", "B"]}) assert 'class="text-line text-line--bullet"' in out assert 'class="text-line text-line--indent-1"' in out assert f'{TEXT_PATH_ATTR}="body.0"' in out assert f'{TEXT_PATH_ATTR}="body.1"' in out def test_stamp_zone_html_idempotent(): html = '
A
' once = stamp_zone_html(html, {"body": ["A"]}) twice = stamp_zone_html(once, {"body": ["A"]}) assert once == twice # Only one occurrence of the attribute on the tag. assert twice.count(TEXT_PATH_ATTR) == 1 def test_stamp_zone_html_excess_text_lines_unstamped(): # 3 text-line divs, only 2 stamps available — last div left alone. html = ( '
A
' '
B
' '
C
' ) out = stamp_zone_html(html, {"body": ["A", "B"]}) assert f'{TEXT_PATH_ATTR}="body.0"' in out assert f'{TEXT_PATH_ATTR}="body.1"' in out # The third div remains unstamped (no data-text-path). assert out.count(TEXT_PATH_ATTR) == 2 def test_stamp_zone_html_excess_stamps_no_crash(): # 1 text-line, 3 stamps available — only the first is consumed. html = '
A
' out = stamp_zone_html(html, {"body": ["A", "B", "C"]}) assert f'{TEXT_PATH_ATTR}="body.0"' in out assert f'{TEXT_PATH_ATTR}="body.1"' not in out def test_stamp_zone_html_no_text_lines_no_op(): html = '
frame title
' out = stamp_zone_html(html, {"body": ["A"]}) assert out == html def test_stamp_zone_html_empty_payload_no_op(): html = '
A
' assert stamp_zone_html(html, {}) == html assert stamp_zone_html(html, []) == html def test_stamp_zone_html_empty_html_no_op(): assert stamp_zone_html("", {"body": ["A"]}) == "" def test_stamp_zone_html_non_string_html_no_op(): assert stamp_zone_html(None, {"body": ["A"]}) is None # type: ignore[arg-type] def test_stamp_zone_html_walks_multiple_slots_in_order(): # Mirrors the bim_current_problems_paired family template line shape: # multiple slots producing interleaved text-line divs. html = ( '
title-x
' # slot_a.0 '
title-y
' # slot_a.1 '
body-1
' # slot_b.0 '
body-2
' # slot_b.1 '
body-3
' # slot_b.2 ) payload = { "slot_a": ["title-x", "title-y"], "slot_b": ["body-1", "body-2", "body-3"], } out = stamp_zone_html(html, payload) assert f'{TEXT_PATH_ATTR}="slot_a.0"' in out assert f'{TEXT_PATH_ATTR}="slot_a.1"' in out assert f'{TEXT_PATH_ATTR}="slot_b.0"' in out assert f'{TEXT_PATH_ATTR}="slot_b.1"' in out assert f'{TEXT_PATH_ATTR}="slot_b.2"' in out def test_stamp_zone_html_does_not_match_unrelated_divs(): # A div that is NOT a text-line must not be stamped. html = ( '
' '
untouched
' '
A
' '
' ) out = stamp_zone_html(html, {"body": ["A"]}) assert 'class="other-class"' in out assert f'{TEXT_PATH_ATTR}="body.0"' in out assert out.count(TEXT_PATH_ATTR) == 1 def test_stamp_zone_html_accepts_explicit_stamp_sequence(): # When the caller wants to override dict-iteration order (or stamp # a subset), they can pass a list of (slot_key, line_index) tuples. html = ( '
first
' '
second
' ) stamps = [("custom", 7), ("custom", 9)] out = stamp_zone_html(html, stamps) assert f'{TEXT_PATH_ATTR}="custom.7"' in out assert f'{TEXT_PATH_ATTR}="custom.9"' in out def test_stamp_zone_html_explicit_sequence_drops_malformed(): html = '
A
' # Mix valid + malformed: only the valid stamp is consumed. stamps = [("", 0), ("ok", -1), ("ok", "0"), ("ok", 3)] # type: ignore[list-item] out = stamp_zone_html(html, stamps) # type: ignore[arg-type] assert f'{TEXT_PATH_ATTR}="ok.3"' in out assert out.count(TEXT_PATH_ATTR) == 1 def test_stamp_zone_html_compound_slot_key(): # Compound slot keys (with embedded '.') round-trip through stamp + # parse symmetry — the resolver's rpartition split recovers the # original (slot_key, line_index). html = '
x
' out = stamp_zone_html(html, {"a.b.c": ["x"]}) assert f'{TEXT_PATH_ATTR}="a.b.c.0"' in out # Spot-check resolver inverse on the emitted path. assert parse_text_path("a.b.c.0") == ("a.b.c", 0) def test_stamp_zone_html_idempotent_when_some_lines_prestamped(): # If some text-line elements are already stamped, the prestamped tag # is preserved verbatim AND the stamp counter is NOT advanced for it # (so the next unstamped tag gets the next-in-sequence stamp). html = ( f'
A
' '
B
' ) out = stamp_zone_html(html, {"body": ["A", "B"]}) # First tag preserved as-is (idempotent short-circuit). assert 'manual.0' in out # Second tag consumes the first available stamp ('body.0'); the # prestamped tag does NOT consume from the stamp sequence. assert f'{TEXT_PATH_ATTR}="body.0"' in out # Total occurrences of the attribute = 2 (one manual + one fresh). assert out.count(TEXT_PATH_ATTR) == 2 def test_stamp_zone_html_text_line_with_attributes_before_class(): # The regex must match text-line opening tags regardless of attribute # ordering — class may not be the first attribute. html = '
A
' out = stamp_zone_html(html, {"body": ["A"]}) assert f'{TEXT_PATH_ATTR}="body.0"' in out assert 'data-foo="x"' in out assert 'class="text-line"' in out