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>
308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""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
|