feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
u1: text_overrides axis in user_overrides_io u2: structure_overrides axis in user_overrides_io u3: vite allowlist for new endpoints u4: text_override_resolver u5: Step 12 text_overrides apply in phase_z2_pipeline u6: structure_override_resolver u7: text_path_stamper u8: SlideCanvas text-edit capture u9: SlideCanvas structure-edit overlay u10: userOverridesApi service extension u11: designAgent types extension u12: slidePlanUtils restore u13: user_overrides endpoint tests u14: user_overrides restore tests u15: pipeline fallback tests u16: edit-mode state + gating tests u17: slide_base print mode CSS u18: /api/connect endpoint (vite) u19: /api/export endpoint (vite) Recovery scope: 29 files (12 modified + 17 new). u20 already pushed in 9439575; this commit lands u1-u19 that were authored but not committed before #90 was externally closed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
307
tests/test_text_path_stamper.py
Normal file
307
tests/test_text_path_stamper.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""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 = (
|
||||
'<div class="zone">'
|
||||
'<div class="text-line">A</div>'
|
||||
'<div class="text-line">B</div>'
|
||||
'</div>'
|
||||
)
|
||||
out = stamp_zone_html(html, {"body": ["A", "B"]})
|
||||
assert f'<div {TEXT_PATH_ATTR}="body.0" class="text-line">A</div>' in out
|
||||
assert f'<div {TEXT_PATH_ATTR}="body.1" class="text-line">B</div>' in out
|
||||
|
||||
|
||||
def test_stamp_zone_html_preserves_modifier_classes():
|
||||
html = (
|
||||
'<div class="text-line text-line--bullet">A</div>'
|
||||
'<div class="text-line text-line--indent-1">B</div>'
|
||||
)
|
||||
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 = '<div class="text-line">A</div>'
|
||||
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 = (
|
||||
'<div class="text-line">A</div>'
|
||||
'<div class="text-line">B</div>'
|
||||
'<div class="text-line">C</div>'
|
||||
)
|
||||
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 = '<div class="text-line">A</div>'
|
||||
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 = '<div class="zone"><span>frame title</span></div>'
|
||||
out = stamp_zone_html(html, {"body": ["A"]})
|
||||
assert out == html
|
||||
|
||||
|
||||
def test_stamp_zone_html_empty_payload_no_op():
|
||||
html = '<div class="text-line">A</div>'
|
||||
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 = (
|
||||
'<div class="text-line">title-x</div>' # slot_a.0
|
||||
'<div class="text-line">title-y</div>' # slot_a.1
|
||||
'<div class="text-line">body-1</div>' # slot_b.0
|
||||
'<div class="text-line">body-2</div>' # slot_b.1
|
||||
'<div class="text-line">body-3</div>' # 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 = (
|
||||
'<div class="zone">'
|
||||
'<div class="other-class">untouched</div>'
|
||||
'<div class="text-line">A</div>'
|
||||
'</div>'
|
||||
)
|
||||
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 = (
|
||||
'<div class="text-line">first</div>'
|
||||
'<div class="text-line">second</div>'
|
||||
)
|
||||
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 = '<div class="text-line">A</div>'
|
||||
# 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 = '<div class="text-line">x</div>'
|
||||
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'<div {TEXT_PATH_ATTR}="manual.0" class="text-line">A</div>'
|
||||
'<div class="text-line">B</div>'
|
||||
)
|
||||
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 = '<div data-foo="x" class="text-line">A</div>'
|
||||
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
|
||||
Reference in New Issue
Block a user