Files
C.E.L_Slide_test2/tests/test_text_path_stamper.py
kyeongmin 4da22adb43
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
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>
2026-05-26 06:12:13 +09:00

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