"""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 = (
'
'
)
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 = (
''
)
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