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:
151
tests/phase_z2/test_slide_base_print_mode.py
Normal file
151
tests/phase_z2/test_slide_base_print_mode.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""IMP-90 (#90) u17 — slide_base.html print-mode contract tests.
|
||||
|
||||
Stage 2 plan contract (unit u17):
|
||||
Step 22 user-edit + Export track. The Phase Z2 print path MUST
|
||||
auto-expand <details> popups so the FULL raw_content (MDX 원문 무손실
|
||||
보존) is included when the user prints / exports from the browser.
|
||||
|
||||
u17 introduces two coordinated surfaces in
|
||||
``templates/phase_z2/slide_base.html``:
|
||||
|
||||
1. ``@media print`` CSS block — neutralizes the on-screen-only body
|
||||
centering / box-shadow / 280px popup card clipping so the slide
|
||||
prints at 1280×720 with the expanded popup body in static flow.
|
||||
|
||||
2. ``beforeprint`` / ``afterprint`` JavaScript hook at body level —
|
||||
toggles ``details.open`` to ``true`` before the print snapshot
|
||||
and restores the user's prior open/closed state afterwards. Body
|
||||
level (outside any ``<details>...</details>`` block) preserves
|
||||
the IMP-35 u8 popup-render JS-free invariant
|
||||
(tests/phase_z2/test_slide_base_popup_render.py
|
||||
``test_popup_emits_no_javascript_on_render_path``).
|
||||
|
||||
Invariants locked here:
|
||||
P-1: ``@media print`` block is emitted exactly once in the render.
|
||||
P-2: ``@page`` size matches the 1280×720 slide canvas.
|
||||
P-3: ``.slide`` box-shadow + body padding/min-height neutralized at
|
||||
print time.
|
||||
P-4: ``.zone__popup-summary`` hidden, popup body switches from
|
||||
absolute to static flow with unconstrained height — the popup
|
||||
card chrome (border / shadow / 280px max-height) is unset.
|
||||
P-5: ``beforeprint`` + ``afterprint`` listeners are wired at body
|
||||
level (NOT inside the per-zone details block) so the popup
|
||||
render path stays JS-free.
|
||||
P-6: Restore semantics — the script preserves the user's prior
|
||||
open/closed state via a single ``dataset.imp90PrintRestore`` key
|
||||
(no global state, no event-bus mutation).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from src.phase_z2_pipeline import render_slide
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def _zone(**overrides) -> dict:
|
||||
base = {
|
||||
"position": "primary",
|
||||
"template_id": "__empty__",
|
||||
"slot_payload": {},
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _render() -> str:
|
||||
return render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=[_zone()],
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
)
|
||||
|
||||
|
||||
# ─── P-1 ─ media print block presence ───────────────────────────────
|
||||
|
||||
|
||||
def test_media_print_block_emitted_once():
|
||||
html = _render()
|
||||
matches = re.findall(r"@media\s+print\s*\{", html)
|
||||
assert len(matches) == 1
|
||||
|
||||
|
||||
# ─── P-2 ─ @page size matches slide canvas ──────────────────────────
|
||||
|
||||
|
||||
def test_page_size_matches_slide_canvas():
|
||||
html = _render()
|
||||
flat = re.sub(r"\s+", " ", html)
|
||||
assert "@page { size: 1280px 720px; margin: 0; }" in flat
|
||||
|
||||
|
||||
# ─── P-3 ─ standalone chrome neutralized at print ───────────────────
|
||||
|
||||
|
||||
def test_slide_box_shadow_neutralized_at_print():
|
||||
html = _render()
|
||||
flat = re.sub(r"\s+", " ", html)
|
||||
print_block = re.search(r"@media\s+print\s*\{(.*?)\}\s*</style>", flat)
|
||||
assert print_block is not None
|
||||
body = print_block.group(1)
|
||||
assert "box-shadow: none !important" in body
|
||||
assert "padding: 0 !important" in body
|
||||
assert "min-height: 0 !important" in body
|
||||
|
||||
|
||||
# ─── P-4 ─ popup body switches to static flow, summary hidden ───────
|
||||
|
||||
|
||||
def test_popup_card_chrome_unset_at_print():
|
||||
html = _render()
|
||||
flat = re.sub(r"\s+", " ", html)
|
||||
print_block = re.search(r"@media\s+print\s*\{(.*?)\}\s*</style>", flat)
|
||||
assert print_block is not None
|
||||
body = print_block.group(1)
|
||||
assert ".zone__popup-summary { display: none !important; }" in body
|
||||
assert "position: static !important" in body
|
||||
assert "max-height: none !important" in body
|
||||
assert "overflow: visible !important" in body
|
||||
|
||||
|
||||
# ─── P-5 ─ beforeprint hook is body-level (NOT inside <details>) ────
|
||||
|
||||
|
||||
def test_beforeprint_and_afterprint_listeners_present():
|
||||
html = _render()
|
||||
assert "addEventListener('beforeprint'" in html
|
||||
assert "addEventListener('afterprint'" in html
|
||||
|
||||
|
||||
def test_print_script_is_outside_any_details_block():
|
||||
"""The IMP-35 u8 popup render path is JS-free. Our print script
|
||||
sits at body level after the slide div, so no <script> appears
|
||||
inside a <details>...</details> popup block."""
|
||||
html = _render(
|
||||
)
|
||||
# No <details> in the no-popup baseline — but the assertion still
|
||||
# holds defensively: locate every <details>...</details> block (if
|
||||
# any) and confirm no <script> tag appears inside.
|
||||
for block in re.findall(r"<details[\s>].*?</details>", html, re.DOTALL):
|
||||
assert "<script" not in block
|
||||
assert "addEventListener" not in block
|
||||
|
||||
|
||||
# ─── P-6 ─ restore semantics ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_restore_uses_single_dataset_key():
|
||||
"""Restore strategy uses one dataset key
|
||||
(``dataset.imp90PrintRestore``) — no global Set/Map, no mutation
|
||||
of any other DOM attribute. Locks the minimal-surface contract."""
|
||||
html = _render()
|
||||
assert "imp90PrintRestore" in html
|
||||
# Restore branch only sets open=false when the prior state was '0'.
|
||||
assert "imp90PrintRestore === '0'" in html
|
||||
assert "d.open = true" in html
|
||||
216
tests/test_phase_z2_structure_overrides.py
Normal file
216
tests/test_phase_z2_structure_overrides.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""IMP-56 (#90) u7 — Step 12 ``structure_overrides`` apply unit tests.
|
||||
|
||||
Synthetic — exercises ``_apply_structure_overrides_to_zones`` directly
|
||||
without running the full Phase Z 22-step pipeline. The helper is
|
||||
decoupled from ``MdxSection`` / ``CompositionUnit`` graphs and only
|
||||
consumes a minimal ``[{position, slot_payload}, ...]`` zone list, so a
|
||||
synthetic fixture is sufficient to lock the contract.
|
||||
|
||||
Coverage axes (Stage 2 plan u7 + Stage 1 binding contract) :
|
||||
|
||||
- reorder happy path : ``slot_order`` partial reorder mutates
|
||||
``zone['slot_payload']`` key order in place (caller reference stays
|
||||
valid via clear+update rebuild contract documented at u6).
|
||||
- hide happy path : ``hidden_slots`` pops the listed keys.
|
||||
- stale slot_key : absent slot_keys silently no-op (count toward
|
||||
``skipped_zones`` if the whole override produces no mutation).
|
||||
- SCOPE LOCK : frame-swap-shaped inner keys (``frame_id``,
|
||||
``template_id``) are dropped by the u6 validate gate and therefore
|
||||
never reach the apply path here.
|
||||
- raw_content preservation : per-slot ``list[str]`` line content
|
||||
untouched; out-of-band sentinels (mirror of ``debug_zones`` graph)
|
||||
stay byte-identical.
|
||||
- audit shape : ``applied_zones`` / ``skipped_zones`` / ``per_zone``
|
||||
keys present and counts consistent with the input batch.
|
||||
- empty / ``None`` batch is a no-op (empty audit).
|
||||
|
||||
Fully synthetic per Codex generalization guardrail (MOCK_ prefix).
|
||||
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml``
|
||||
dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _apply_structure_overrides_to_zones
|
||||
|
||||
|
||||
# ─── Synthetic fixture helpers ──────────────────────────────────────
|
||||
|
||||
|
||||
def _zone(position: str, slot_payload: dict) -> dict:
|
||||
"""Minimal zone dict mirroring the Step 12 ``zones_data[i]`` shape."""
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": "MOCK_T_phase_z2_structure_overrides",
|
||||
"slot_payload": slot_payload,
|
||||
"content_weight": 1.0,
|
||||
"min_height_px": 200,
|
||||
}
|
||||
|
||||
|
||||
# ─── Case 1 : reorder happy path ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_apply_reorders_slot_payload_keys_in_place():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {"top": {"slot_order": ["slot_c", "slot_a"]}}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
# Caller reference (payload) stays valid via clear+update rebuild.
|
||||
assert zones[0]["slot_payload"] is payload
|
||||
assert list(payload.keys()) == ["slot_c", "slot_a", "slot_b"]
|
||||
# Per-slot list[str] content untouched (raw_content invariant).
|
||||
assert payload["slot_a"] == ["A"]
|
||||
assert payload["slot_b"] == ["B"]
|
||||
assert payload["slot_c"] == ["C"]
|
||||
assert audit["applied_zones"] == 1
|
||||
assert audit["skipped_zones"] == 0
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
|
||||
|
||||
|
||||
# ─── Case 2 : hide happy path ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_apply_hides_listed_slot_keys():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {"top": {"hidden_slots": ["slot_b"]}}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert "slot_b" not in payload
|
||||
assert list(payload.keys()) == ["slot_a", "slot_c"]
|
||||
assert audit["applied_zones"] == 1
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
|
||||
|
||||
|
||||
# ─── Case 3 : stale slot_key — frame swap / layout regression ─────────
|
||||
|
||||
|
||||
def test_stale_slot_key_silently_no_op():
|
||||
"""Absent slot_keys produce no mutation; the zone counts toward skipped_zones."""
|
||||
payload = {"slot_a": ["A"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {
|
||||
"top": {
|
||||
"hidden_slots": ["slot_missing"], # absent — no-op
|
||||
"slot_order": ["slot_also_missing"], # absent — no-op
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert list(payload.keys()) == ["slot_a"]
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 1
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": False}]
|
||||
|
||||
|
||||
# ─── Case 4 : SCOPE LOCK — frame swap shape dropped at validate ───────
|
||||
|
||||
|
||||
def test_frame_swap_keys_dropped_at_validate_no_mutation():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"]}
|
||||
zones = [_zone("top", payload)]
|
||||
# frame_id / template_id / slot_payload as inner keys are the canonical
|
||||
# frame-swap / DOM-rebuild shapes the SCOPE LOCK rejects.
|
||||
overrides = {
|
||||
"top": {
|
||||
"frame_id": "MOCK_OTHER_FRAME",
|
||||
"template_id": "MOCK_OTHER_TEMPLATE",
|
||||
"slot_payload": {"slot_a": ["overwritten"]},
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
# No mutation: validate gate drops the whole zone payload (no allowed
|
||||
# inner key remains), so the zone never reaches the apply loop.
|
||||
assert payload == {"slot_a": ["A"], "slot_b": ["B"]}
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 0
|
||||
assert audit["per_zone"] == []
|
||||
|
||||
|
||||
# ─── Case 5 : raw_content preservation invariant ──────────────────────
|
||||
|
||||
|
||||
def test_raw_content_sentinel_untouched():
|
||||
"""Helper must not mutate anything outside zone['slot_payload']
|
||||
AND must not mutate per-slot list[str] line content."""
|
||||
raw_sentinel = ["MOCK_S1", "MOCK_S2"]
|
||||
payload = {"slot_a": ["line 1", "line 2"], "slot_b": ["line 3"]}
|
||||
zones = [_zone("top", payload)]
|
||||
zones[0]["source_section_ids_sentinel"] = raw_sentinel # out-of-band
|
||||
zones[0]["raw_content_sentinel"] = "- line 1\n- line 2\n"
|
||||
|
||||
_apply_structure_overrides_to_zones(
|
||||
{"top": {"slot_order": ["slot_b", "slot_a"]}}, zones,
|
||||
)
|
||||
|
||||
# Out-of-band fields untouched.
|
||||
assert zones[0]["source_section_ids_sentinel"] is raw_sentinel
|
||||
assert zones[0]["source_section_ids_sentinel"] == ["MOCK_S1", "MOCK_S2"]
|
||||
assert zones[0]["raw_content_sentinel"] == "- line 1\n- line 2\n"
|
||||
# Per-slot list[str] line content byte-identical.
|
||||
assert payload["slot_a"] == ["line 1", "line 2"]
|
||||
assert payload["slot_b"] == ["line 3"]
|
||||
|
||||
|
||||
# ─── Case 6 : empty / None / irrelevant batch is no-op ────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"batch",
|
||||
[
|
||||
None,
|
||||
{},
|
||||
{"top": {}}, # empty per-zone
|
||||
{"missing_zone": {"slot_order": ["slot_a"]}}, # stale zone_id
|
||||
],
|
||||
)
|
||||
def test_empty_or_irrelevant_batch_is_noop(batch):
|
||||
payload = {"slot_a": ["A"]}
|
||||
zones = [_zone("top", payload)]
|
||||
audit = _apply_structure_overrides_to_zones(batch, zones)
|
||||
|
||||
assert list(payload.keys()) == ["slot_a"]
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 0
|
||||
assert audit["per_zone"] == []
|
||||
|
||||
|
||||
# ─── Case 7 : zone without slot_payload skipped (defensive) ───────────
|
||||
|
||||
|
||||
def test_zone_without_slot_payload_skipped():
|
||||
zones = [{"position": "top"}] # no slot_payload key (defensive contract)
|
||||
audit = _apply_structure_overrides_to_zones(
|
||||
{"top": {"slot_order": ["slot_a"]}}, zones,
|
||||
)
|
||||
assert audit["per_zone"] == []
|
||||
assert audit["applied_zones"] == 0
|
||||
assert audit["skipped_zones"] == 0
|
||||
|
||||
|
||||
# ─── Case 8 : combined reorder + hide in a single zone ────────────────
|
||||
|
||||
|
||||
def test_combined_reorder_and_hide_in_one_zone():
|
||||
payload = {"slot_a": ["A"], "slot_b": ["B"], "slot_c": ["C"]}
|
||||
zones = [_zone("top", payload)]
|
||||
overrides = {
|
||||
"top": {
|
||||
"hidden_slots": ["slot_b"],
|
||||
"slot_order": ["slot_c", "slot_a"],
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_structure_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert list(payload.keys()) == ["slot_c", "slot_a"]
|
||||
assert audit["applied_zones"] == 1
|
||||
assert audit["per_zone"] == [{"position": "top", "mutated": True}]
|
||||
158
tests/test_phase_z2_text_overrides.py
Normal file
158
tests/test_phase_z2_text_overrides.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""IMP-56 (#90) u5 — Step 12 ``text_overrides`` apply unit tests.
|
||||
|
||||
Synthetic — exercises ``_apply_text_overrides_to_zones`` directly without
|
||||
running the full Phase Z 22-step pipeline. The helper is decoupled from
|
||||
``MdxSection`` / ``CompositionUnit`` graphs and only consumes a minimal
|
||||
``[{position, slot_payload}, ...]`` zone list, so a synthetic fixture is
|
||||
sufficient to lock the contract.
|
||||
|
||||
Coverage axes (Stage 2 plan u5 + Stage 1 binding contract) :
|
||||
|
||||
- sanitized batch : malformed text_path / non-string value drops per-row
|
||||
(mirrors ``image_id_stamper.build_image_overrides_style`` u7 tolerance).
|
||||
- stale path : frame swap / layout regression → ``skipped``, not error.
|
||||
- raw_content preservation : helper never touches ``debug_zones`` / unit
|
||||
graph (asserted by zero-mutation on an out-of-band sentinel mapping).
|
||||
- audit shape : ``applied`` / ``skipped`` / ``per_zone`` keys present and
|
||||
counts consistent with the input batch.
|
||||
- empty / ``None`` override input is a no-op (empty audit).
|
||||
|
||||
Fully synthetic per Codex generalization guardrail (MOCK_ prefix).
|
||||
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml``
|
||||
dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _apply_text_overrides_to_zones
|
||||
|
||||
|
||||
# ─── Synthetic fixture helpers ──────────────────────────────────────
|
||||
|
||||
|
||||
def _zone(position: str, slot_payload: dict) -> dict:
|
||||
"""Minimal zone dict mirroring the Step 12 ``zones_data[i]`` shape."""
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": "MOCK_T_phase_z2_text_overrides",
|
||||
"slot_payload": slot_payload,
|
||||
"content_weight": 1.0,
|
||||
"min_height_px": 200,
|
||||
}
|
||||
|
||||
|
||||
# ─── Case 1 : happy path — list[str] slot mutation ────────────────────
|
||||
|
||||
|
||||
def test_apply_replaces_list_line_in_place():
|
||||
zones = [
|
||||
_zone("top", {"slot_title": ["original headline"], "slot_body": ["a", "b", "c"]}),
|
||||
_zone("bottom", {"slot_caption": ["caption A"]}),
|
||||
]
|
||||
overrides = {
|
||||
"top": {"slot_title.0": "edited headline", "slot_body.1": "edited B"},
|
||||
}
|
||||
|
||||
audit = _apply_text_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["edited headline"]
|
||||
assert zones[0]["slot_payload"]["slot_body"] == ["a", "edited B", "c"]
|
||||
# bottom zone untouched (no override entry)
|
||||
assert zones[1]["slot_payload"]["slot_caption"] == ["caption A"]
|
||||
assert audit["applied"] == 2
|
||||
assert audit["skipped"] == 0
|
||||
assert audit["per_zone"] == [{"position": "top", "applied": 2, "skipped": 0}]
|
||||
|
||||
|
||||
# ─── Case 2 : stale text_path — frame swap / layout regression ────────
|
||||
|
||||
|
||||
def test_stale_text_path_skipped_silently():
|
||||
"""Absent slot_key + out-of-range line_index both count as skipped, not errors."""
|
||||
zones = [_zone("top", {"slot_title": ["only one line"]})]
|
||||
overrides = {
|
||||
"top": {
|
||||
"slot_title.0": "ok", # applied
|
||||
"slot_title.99": "out of range", # skipped (idx > len)
|
||||
"slot_missing.0": "stale frame", # skipped (slot absent)
|
||||
},
|
||||
}
|
||||
|
||||
audit = _apply_text_overrides_to_zones(overrides, zones)
|
||||
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["ok"]
|
||||
assert "slot_missing" not in zones[0]["slot_payload"]
|
||||
assert audit["applied"] == 1
|
||||
assert audit["skipped"] == 2
|
||||
assert audit["per_zone"][0] == {"position": "top", "applied": 1, "skipped": 2}
|
||||
|
||||
|
||||
# ─── Case 3 : malformed input — per-entry tolerance ────────────────────
|
||||
|
||||
|
||||
def test_malformed_entries_dropped_in_validate():
|
||||
"""Non-string value / malformed text_path drop in ``validate_text_overrides``."""
|
||||
zones = [_zone("top", {"slot_title": ["original", "second"]})]
|
||||
overrides = {
|
||||
"top": {
|
||||
"slot_title.0": "good", # kept
|
||||
"slot_title.bad": "ignored", # dropped (non-int idx)
|
||||
"slot_title.1": 123, # dropped (non-str value)
|
||||
"no_dot": "ignored", # dropped (missing '.')
|
||||
},
|
||||
"": {"slot_title.0": "empty zone id dropped"}, # zone_id sanitization
|
||||
123: {"slot_title.0": "non-string zone id dropped"},
|
||||
}
|
||||
|
||||
audit = _apply_text_overrides_to_zones(overrides, zones)
|
||||
|
||||
# only the well-formed entry applied
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["good", "second"]
|
||||
assert audit["applied"] == 1
|
||||
assert audit["skipped"] == 0
|
||||
|
||||
|
||||
# ─── Case 4 : raw_content preservation invariant ──────────────────────
|
||||
|
||||
|
||||
def test_raw_content_sentinel_untouched():
|
||||
"""Helper must not mutate anything outside ``zone['slot_payload']``.
|
||||
|
||||
Out-of-band fields (mirror of ``debug_zones[i].source_section_ids`` /
|
||||
MdxSection graph) stay byte-identical — Stage 1 binding contract.
|
||||
"""
|
||||
raw_sentinel = ["MOCK_S1", "MOCK_S2"]
|
||||
zones = [_zone("top", {"slot_title": ["original"]})]
|
||||
zones[0]["source_section_ids_sentinel"] = raw_sentinel # out-of-band
|
||||
zones[0]["raw_content_sentinel"] = "- original bullet\n- second\n"
|
||||
|
||||
_apply_text_overrides_to_zones({"top": {"slot_title.0": "edited"}}, zones)
|
||||
|
||||
assert zones[0]["source_section_ids_sentinel"] is raw_sentinel # same object
|
||||
assert zones[0]["source_section_ids_sentinel"] == ["MOCK_S1", "MOCK_S2"]
|
||||
assert zones[0]["raw_content_sentinel"] == "- original bullet\n- second\n"
|
||||
|
||||
|
||||
# ─── Case 5 : empty / None batch is no-op ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [None, {}, {"top": {}}, {"missing_zone": {"slot.0": "x"}}])
|
||||
def test_empty_or_irrelevant_batch_is_noop(payload):
|
||||
zones = [_zone("top", {"slot_title": ["unchanged"]})]
|
||||
audit = _apply_text_overrides_to_zones(payload, zones)
|
||||
|
||||
assert zones[0]["slot_payload"]["slot_title"] == ["unchanged"]
|
||||
assert audit["applied"] == 0
|
||||
assert audit["skipped"] == 0
|
||||
|
||||
|
||||
# ─── Case 6 : zone without slot_payload skipped (defensive) ───────────
|
||||
|
||||
|
||||
def test_zone_without_slot_payload_skipped():
|
||||
zones = [{"position": "top"}] # no slot_payload key (defensive contract)
|
||||
audit = _apply_text_overrides_to_zones({"top": {"slot.0": "x"}}, zones)
|
||||
assert audit["per_zone"] == []
|
||||
assert audit["applied"] == 0
|
||||
assert audit["skipped"] == 0
|
||||
212
tests/test_phase_z2_text_path_stamper.py
Normal file
212
tests/test_phase_z2_text_path_stamper.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""IMP-56 (#90) u9 — Step 13 ``text_path_stamper`` wiring tests.
|
||||
|
||||
Verifies that :func:`src.phase_z2_pipeline.render_slide` stamps each
|
||||
rendered ``text-line`` opening tag with
|
||||
``data-text-path="{slot_key}.{line_index}"`` via the u8 stamper
|
||||
(``src.text_path_stamper.stamp_zone_html``). This is the wiring unit
|
||||
companion to the u8 module-level tests at
|
||||
``tests/test_text_path_stamper.py``.
|
||||
|
||||
Coverage axes (Stage 2 plan u9 + Stage 1 binding contract) :
|
||||
|
||||
- happy path : real ``bim_current_problems_paired`` template emits
|
||||
``text-line`` divs for list-valued slots; the stamper attaches
|
||||
``data-text-path`` with matching ``{slot_key}.{line_index}``.
|
||||
- non-list slots skipped : ``title`` / ``row_*_left_label`` (scalars)
|
||||
do NOT receive ``data-text-path`` attributes.
|
||||
- empty list slots emit no stamps : rows whose body list is empty
|
||||
contribute zero stamps.
|
||||
- deterministic : repeated calls produce byte-identical HTML
|
||||
(no nondeterministic mutation of ``slot_payload`` between renders).
|
||||
- empty zone : the ``__empty__`` template_id short-circuit emits no
|
||||
``data-text-path`` (the stamper short-circuits on empty stamps).
|
||||
|
||||
Fully synthetic slot_payload — no real Phase Z run, no
|
||||
``v4_full32_result.yaml`` dependency. Uses the real
|
||||
``bim_current_problems_paired`` family template only to exercise the
|
||||
genuine Jinja2 + slide_base render path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import render_slide
|
||||
|
||||
|
||||
# ─── Fixture helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
"""Minimal valid layout_css for a single-zone slide.
|
||||
|
||||
Mirrors tests/phase_z2/test_slide_base_embedded_mode.py shape.
|
||||
"""
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def _paired_slot_payload(
|
||||
*,
|
||||
left_lines: list[str] | None = None,
|
||||
right_lines: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Build a slot_payload for the bim_current_problems_paired family.
|
||||
|
||||
Only row_1 is populated by default; rows 2-4 stay empty so the
|
||||
template short-circuits to zero text-line divs for them.
|
||||
"""
|
||||
left_lines = left_lines if left_lines is not None else ["L1a", "L1b"]
|
||||
right_lines = right_lines if right_lines is not None else ["R1a"]
|
||||
payload: dict = {
|
||||
"title": "Synthetic Title",
|
||||
"row_1_left_label": "left pill 1",
|
||||
"row_1_left_body": [{"text": t, "indent": 0} for t in left_lines],
|
||||
"row_1_right_label": "right pill 1",
|
||||
"row_1_right_body": [{"text": t, "indent": 0} for t in right_lines],
|
||||
}
|
||||
for r in (2, 3, 4):
|
||||
payload[f"row_{r}_left_label"] = f"left pill {r}"
|
||||
payload[f"row_{r}_left_body"] = []
|
||||
payload[f"row_{r}_right_label"] = f"right pill {r}"
|
||||
payload[f"row_{r}_right_body"] = []
|
||||
return payload
|
||||
|
||||
|
||||
def _zone(template_id: str, slot_payload: dict) -> dict:
|
||||
return {
|
||||
"position": "primary",
|
||||
"template_id": template_id,
|
||||
"slot_payload": slot_payload,
|
||||
}
|
||||
|
||||
|
||||
def _render(zones: list[dict]) -> str:
|
||||
return render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=zones,
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
embedded_mode="embedded",
|
||||
)
|
||||
|
||||
|
||||
# ─── Case 1 : happy path — list-valued slots stamped ─────────────────
|
||||
|
||||
|
||||
def test_render_slide_stamps_text_path_per_line():
|
||||
"""Each list-valued slot line gets data-text-path={slot}.{index}."""
|
||||
payload = _paired_slot_payload(
|
||||
left_lines=["left line A", "left line B"],
|
||||
right_lines=["right line A"],
|
||||
)
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# left body 2 lines + right body 1 line = 3 stamps in row 1.
|
||||
assert 'data-text-path="row_1_left_body.0"' in html
|
||||
assert 'data-text-path="row_1_left_body.1"' in html
|
||||
assert 'data-text-path="row_1_right_body.0"' in html
|
||||
# row 2-4 are empty → no stamps for those slot_keys.
|
||||
assert "row_2_left_body" not in html
|
||||
assert "row_3_right_body" not in html
|
||||
|
||||
|
||||
def test_stamps_preserve_class_attribute():
|
||||
"""data-text-path injected before existing class attribute, both present."""
|
||||
payload = _paired_slot_payload(left_lines=["only left"], right_lines=[])
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# The original class="text-line..." must survive verbatim alongside
|
||||
# the injected data-text-path attribute on the same opening tag.
|
||||
assert re.search(
|
||||
r'<div\s+data-text-path="row_1_left_body\.0"\s+class="text-line[^"]*">',
|
||||
html,
|
||||
) is not None
|
||||
|
||||
|
||||
# ─── Case 2 : non-list slots are NOT stamped ─────────────────────────
|
||||
|
||||
|
||||
def test_non_list_slots_not_stamped():
|
||||
"""Scalar slot values (title, *_label) get no data-text-path."""
|
||||
payload = _paired_slot_payload(left_lines=["x"], right_lines=["y"])
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# Scalar slots present in slot_payload as strings — must not receive
|
||||
# data-text-path stamps (u8 contract: scalar slots skipped silently
|
||||
# because they render outside text-line divs).
|
||||
assert 'data-text-path="title' not in html
|
||||
assert 'data-text-path="row_1_left_label' not in html
|
||||
assert 'data-text-path="row_1_right_label' not in html
|
||||
|
||||
|
||||
# ─── Case 3 : empty list slots contribute no stamps ──────────────────
|
||||
|
||||
|
||||
def test_empty_list_slots_no_stamps():
|
||||
"""Empty list slot yields zero stamps; template emits zero text-line divs."""
|
||||
payload = _paired_slot_payload(left_lines=[], right_lines=[])
|
||||
html = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
# No row 1 lines at all (both bodies empty) → no row_1_*_body stamps.
|
||||
assert "data-text-path" not in html
|
||||
|
||||
|
||||
# ─── Case 4 : deterministic — repeated render produces same HTML ─────
|
||||
|
||||
|
||||
def test_render_with_stamp_is_deterministic():
|
||||
"""Same slot_payload → byte-identical HTML across two render_slide calls.
|
||||
|
||||
Guards against the wiring layer accidentally mutating slot_payload
|
||||
between renders (the stamper itself only reads slot_payload; it
|
||||
operates on rendered_partial). Also guards against double-stamping.
|
||||
"""
|
||||
payload_1 = _paired_slot_payload()
|
||||
payload_2 = _paired_slot_payload()
|
||||
html_1 = _render([_zone("bim_current_problems_paired", payload_1)])
|
||||
html_2 = _render([_zone("bim_current_problems_paired", payload_2)])
|
||||
|
||||
assert html_1 == html_2
|
||||
# Counts must match — no double-stamp side effect on shared module state.
|
||||
assert html_1.count("data-text-path=") == html_2.count("data-text-path=")
|
||||
# 2 left + 1 right = 3 stamps for the default fixture.
|
||||
assert html_1.count("data-text-path=") == 3
|
||||
|
||||
|
||||
# ─── Case 5 : __empty__ short-circuit emits no stamps ────────────────
|
||||
|
||||
|
||||
def test_empty_template_short_circuit_no_stamps():
|
||||
"""``template_id=__empty__`` short-circuits before stamping; no stamps."""
|
||||
html = _render([_zone("__empty__", {})])
|
||||
assert "data-text-path" not in html
|
||||
|
||||
|
||||
# ─── Case 6 : slot_payload preserved (raw_content invariant) ─────────
|
||||
|
||||
|
||||
def test_render_does_not_mutate_slot_payload():
|
||||
"""Stamping must not mutate slot_payload list/dict contents.
|
||||
|
||||
The stamper operates on rendered_partial HTML; the source
|
||||
slot_payload should be byte-identical before and after render_slide.
|
||||
Locks the raw_content preservation invariant at the wiring layer.
|
||||
"""
|
||||
payload = _paired_slot_payload(
|
||||
left_lines=["preserved A", "preserved B"],
|
||||
right_lines=["preserved C"],
|
||||
)
|
||||
# Snapshot key list/dict identities and content.
|
||||
snapshot_left = list(payload["row_1_left_body"])
|
||||
snapshot_left_text = [item["text"] for item in snapshot_left]
|
||||
snapshot_right_text = [item["text"] for item in payload["row_1_right_body"]]
|
||||
|
||||
_ = _render([_zone("bim_current_problems_paired", payload)])
|
||||
|
||||
assert [item["text"] for item in payload["row_1_left_body"]] == snapshot_left_text
|
||||
assert [item["text"] for item in payload["row_1_right_body"]] == snapshot_right_text
|
||||
# Scalar slots untouched too.
|
||||
assert payload["title"] == "Synthetic Title"
|
||||
392
tests/test_structure_override_resolver.py
Normal file
392
tests/test_structure_override_resolver.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""IMP-56 (#90) u6 — tests for ``src.structure_override_resolver``.
|
||||
|
||||
Covers the resolver contract called out in the Stage 2 plan :
|
||||
|
||||
1. ``validate_structure_overrides`` returns ``{}`` for non-mapping input.
|
||||
2. ``validate_structure_overrides`` preserves well-formed entries.
|
||||
3. ``validate_structure_overrides`` drops malformed per-entry rows without
|
||||
rejecting the whole batch (per-entry tolerance — mirrors u4
|
||||
text_override_resolver contract).
|
||||
4. ``validate_structure_overrides`` REJECTS frame swap (any inner key
|
||||
other than slot_order / hidden_slots is silently dropped — SCOPE LOCK).
|
||||
5. ``validate_structure_overrides`` drops non-list slot_order / hidden_slots
|
||||
values.
|
||||
6. ``validate_structure_overrides`` drops non-string or empty slot_keys
|
||||
inside slot_order / hidden_slots.
|
||||
7. ``validate_structure_overrides`` de-duplicates slot_key entries within
|
||||
each list.
|
||||
8. ``validate_structure_overrides`` returns fresh nested dicts AND lists
|
||||
(caller can mutate without aliasing the source).
|
||||
9. ``validate_structure_overrides`` drops per-zone payloads that contain
|
||||
neither a non-empty slot_order nor a non-empty hidden_slots after
|
||||
sanitization.
|
||||
10. ``apply_structure_override`` removes hidden_slots in-place and returns
|
||||
``True``.
|
||||
11. ``apply_structure_override`` reorders the slot-payload mapping per
|
||||
slot_order (partial reorder; unmentioned slots keep tail order).
|
||||
12. ``apply_structure_override`` combines hide + reorder atomically.
|
||||
13. ``apply_structure_override`` silently skips stale slot_keys (frame
|
||||
swap / layout regression) without raising.
|
||||
14. ``apply_structure_override`` returns ``False`` (no mutation) on a
|
||||
no-op override (empty, or all stale).
|
||||
15. ``apply_structure_override`` preserves the caller's reference identity
|
||||
on ``zone`` (in-place mutation via clear + update).
|
||||
16. ``apply_structure_override`` NEVER inspects or mutates per-slot
|
||||
payload values — only top-level key membership / ordering.
|
||||
17. ``apply_structure_override`` is defensive against non-list
|
||||
slot_order / hidden_slots leaking through (treats as empty, no raise).
|
||||
|
||||
All tests are pure-Python — no filesystem, no Selenium, no fixtures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.structure_override_resolver import (
|
||||
InvalidStructureOverride,
|
||||
apply_structure_override,
|
||||
validate_structure_overrides,
|
||||
)
|
||||
|
||||
|
||||
# -- module surface ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalid_structure_override_is_value_error_subclass():
|
||||
# Reserved future strict-mode exception — kept as a public surface so
|
||||
# u7 / strict callers can branch on source-malformation vs stale-DOM.
|
||||
assert issubclass(InvalidStructureOverride, ValueError)
|
||||
|
||||
|
||||
# -- validate_structure_overrides ------------------------------------------
|
||||
|
||||
|
||||
def test_validate_structure_overrides_non_mapping_returns_empty():
|
||||
for bad_input in [None, [], "string", 42, 1.5]:
|
||||
assert validate_structure_overrides(bad_input) == {}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_passes_well_formed():
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
},
|
||||
"zone-bottom": {"slot_order": ["slot_a", "slot_b"]},
|
||||
"zone-only-hide": {"hidden_slots": ["slot_x"]},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
},
|
||||
"zone-bottom": {"slot_order": ["slot_a", "slot_b"]},
|
||||
"zone-only-hide": {"hidden_slots": ["slot_x"]},
|
||||
}
|
||||
assert out is not payload # fresh dict
|
||||
|
||||
|
||||
def test_validate_structure_overrides_per_entry_tolerance():
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
},
|
||||
"": {"slot_order": ["x"]}, # empty zone_id dropped
|
||||
42: {"slot_order": ["y"]}, # non-string zone_id dropped
|
||||
"zone-non-mapping": "not a dict", # non-mapping payload dropped
|
||||
"zone-bottom": {"hidden_slots": ["slot_aux"]},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
},
|
||||
"zone-bottom": {"hidden_slots": ["slot_aux"]},
|
||||
}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_rejects_frame_swap_inner_keys():
|
||||
# SCOPE LOCK — frame swap attempts MUST be silently dropped. The only
|
||||
# mechanism for swapping a frame is the existing ``frames`` axis; this
|
||||
# resolver intentionally has no escape hatch so the Phase Z
|
||||
# no-AI-HTML-structure invariant stays intact.
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title"],
|
||||
# The following 4 keys are all frame-swap / DOM-rebuild
|
||||
# attempts and MUST be dropped by validate.
|
||||
"frame_id": "compare_v2",
|
||||
"template_id": "topic_left_right",
|
||||
"unit_id": "03-1+03-2",
|
||||
"slot_payload": {"injected_slot": ["unsafe"]},
|
||||
},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {"zone-top": {"slot_order": ["slot_title"]}}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_rejects_frame_swap_zone_with_no_lock_keys():
|
||||
# If a per-zone payload contains ONLY frame-swap attempts (no
|
||||
# slot_order / hidden_slots), the whole zone gets dropped after
|
||||
# sanitization (no signal remains).
|
||||
payload = {
|
||||
"zone-attempt-swap": {
|
||||
"frame_id": "compare_v2",
|
||||
"template_id": "topic_left_right",
|
||||
},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_drops_non_list_slot_arrays():
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": "not a list",
|
||||
"hidden_slots": {"also": "not a list"},
|
||||
},
|
||||
"zone-bottom": {
|
||||
"slot_order": 42,
|
||||
"hidden_slots": None,
|
||||
},
|
||||
"zone-good": {"slot_order": ["slot_title"]},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {"zone-good": {"slot_order": ["slot_title"]}}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_drops_bad_slot_key_entries():
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": [
|
||||
"good_slot",
|
||||
"", # empty string dropped
|
||||
42, # non-string dropped
|
||||
None, # non-string dropped
|
||||
{"nested": "obj"}, # non-string dropped
|
||||
"another_good",
|
||||
],
|
||||
"hidden_slots": ["", "valid_hide", 99, "valid_hide_2"],
|
||||
},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {
|
||||
"zone-top": {
|
||||
"slot_order": ["good_slot", "another_good"],
|
||||
"hidden_slots": ["valid_hide", "valid_hide_2"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_dedupes_slot_key_entries():
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": ["a", "b", "a", "c", "b"],
|
||||
"hidden_slots": ["x", "x", "y", "x"],
|
||||
},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {
|
||||
"zone-top": {
|
||||
"slot_order": ["a", "b", "c"],
|
||||
"hidden_slots": ["x", "y"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_drops_empty_payload_after_sanitization():
|
||||
# Per-zone payloads that have empty slot_order AND empty hidden_slots
|
||||
# after sanitization carry no signal → drop the zone entirely.
|
||||
payload = {
|
||||
"zone-empty-lists": {"slot_order": [], "hidden_slots": []},
|
||||
"zone-only-bad-entries": {"slot_order": ["", None, 99]},
|
||||
"zone-good": {"slot_order": ["slot_title"]},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
assert out == {"zone-good": {"slot_order": ["slot_title"]}}
|
||||
|
||||
|
||||
def test_validate_structure_overrides_returns_fresh_nested_dicts_and_lists():
|
||||
# Mutating the returned dict's per-zone payload (or any list inside)
|
||||
# must not leak back into the source.
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_order": ["a", "b"],
|
||||
"hidden_slots": ["x"],
|
||||
},
|
||||
}
|
||||
out = validate_structure_overrides(payload)
|
||||
out["zone-top"]["slot_order"].append("mutated")
|
||||
out["zone-top"]["hidden_slots"].append("mutated_hide")
|
||||
out["zone-top"]["new_key"] = "leaked?"
|
||||
assert payload["zone-top"]["slot_order"] == ["a", "b"]
|
||||
assert payload["zone-top"]["hidden_slots"] == ["x"]
|
||||
assert "new_key" not in payload["zone-top"]
|
||||
|
||||
|
||||
# -- apply_structure_override ----------------------------------------------
|
||||
|
||||
|
||||
def test_apply_structure_override_hide_only_mutates_in_place():
|
||||
zone: dict = {
|
||||
"slot_title": ["title line"],
|
||||
"slot_body": ["body line"],
|
||||
"slot_caption": ["caption"],
|
||||
}
|
||||
assert apply_structure_override(zone, {"hidden_slots": ["slot_caption"]}) is True
|
||||
assert list(zone.keys()) == ["slot_title", "slot_body"]
|
||||
assert zone == {
|
||||
"slot_title": ["title line"],
|
||||
"slot_body": ["body line"],
|
||||
}
|
||||
|
||||
|
||||
def test_apply_structure_override_reorder_only_partial():
|
||||
# Partial reorder — listed keys move to front in order; unmentioned
|
||||
# keys keep their original relative order at the tail.
|
||||
zone: dict = {
|
||||
"slot_title": ["t"],
|
||||
"slot_body": ["b"],
|
||||
"slot_caption": ["c"],
|
||||
"slot_aux": ["a"],
|
||||
}
|
||||
assert apply_structure_override(zone, {"slot_order": ["slot_aux", "slot_title"]}) is True
|
||||
assert list(zone.keys()) == ["slot_aux", "slot_title", "slot_body", "slot_caption"]
|
||||
|
||||
|
||||
def test_apply_structure_override_combines_hide_and_reorder():
|
||||
zone: dict = {
|
||||
"slot_title": ["t"],
|
||||
"slot_body": ["b"],
|
||||
"slot_caption": ["c"],
|
||||
"slot_aux": ["a"],
|
||||
}
|
||||
override = {
|
||||
"slot_order": ["slot_aux", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
}
|
||||
assert apply_structure_override(zone, override) is True
|
||||
# slot_caption hidden; slot_aux + slot_body moved to front; remaining
|
||||
# (slot_title) appended at tail in original order.
|
||||
assert list(zone.keys()) == ["slot_aux", "slot_body", "slot_title"]
|
||||
|
||||
|
||||
def test_apply_structure_override_silently_skips_stale_slot_keys():
|
||||
# Frame swap / layout regression — the prior render's override
|
||||
# references slot_keys that the new render's frame no longer emits.
|
||||
# The resolver must silently skip those without raising.
|
||||
zone: dict = {"slot_title": ["t"], "slot_body": ["b"]}
|
||||
override = {
|
||||
"slot_order": ["slot_phantom_1", "slot_body", "slot_phantom_2"],
|
||||
"hidden_slots": ["slot_does_not_exist"],
|
||||
}
|
||||
assert apply_structure_override(zone, override) is True
|
||||
# slot_body moves to front; slot_title appended at tail; phantoms
|
||||
# silently ignored; hidden_slots no-op.
|
||||
assert list(zone.keys()) == ["slot_body", "slot_title"]
|
||||
assert zone == {"slot_body": ["b"], "slot_title": ["t"]}
|
||||
|
||||
|
||||
def test_apply_structure_override_no_op_returns_false():
|
||||
# Empty override → no mutation.
|
||||
zone: dict = {"slot_title": ["t"], "slot_body": ["b"]}
|
||||
snapshot = dict(zone)
|
||||
assert apply_structure_override(zone, {}) is False
|
||||
assert zone == snapshot
|
||||
assert list(zone.keys()) == ["slot_title", "slot_body"]
|
||||
|
||||
|
||||
def test_apply_structure_override_all_stale_returns_false():
|
||||
# All slot_keys in the override are absent from zone → no mutation.
|
||||
zone: dict = {"slot_title": ["t"], "slot_body": ["b"]}
|
||||
snapshot = dict(zone)
|
||||
override = {
|
||||
"slot_order": ["phantom_a", "phantom_b"],
|
||||
"hidden_slots": ["phantom_c"],
|
||||
}
|
||||
assert apply_structure_override(zone, override) is False
|
||||
assert zone == snapshot
|
||||
assert list(zone.keys()) == ["slot_title", "slot_body"]
|
||||
|
||||
|
||||
def test_apply_structure_override_already_in_desired_order_returns_false():
|
||||
# slot_order matches the existing key order exactly → no mutation.
|
||||
zone: dict = {"slot_title": ["t"], "slot_body": ["b"]}
|
||||
override = {"slot_order": ["slot_title", "slot_body"]}
|
||||
assert apply_structure_override(zone, override) is False
|
||||
assert list(zone.keys()) == ["slot_title", "slot_body"]
|
||||
|
||||
|
||||
def test_apply_structure_override_preserves_zone_reference_identity():
|
||||
# In-place mutation via clear + update — caller's reference must
|
||||
# remain valid after reorder.
|
||||
zone: dict = {"slot_a": ["a"], "slot_b": ["b"], "slot_c": ["c"]}
|
||||
zone_ref = zone # capture reference
|
||||
apply_structure_override(zone, {"slot_order": ["slot_c", "slot_a"]})
|
||||
assert zone_ref is zone
|
||||
assert list(zone.keys()) == ["slot_c", "slot_a", "slot_b"]
|
||||
|
||||
|
||||
def test_apply_structure_override_never_inspects_per_slot_values():
|
||||
# The resolver MUST NOT inspect / mutate per-slot list[str] contents.
|
||||
# Use weird non-list values to confirm passthrough.
|
||||
zone: dict = {
|
||||
"slot_a": ["a1", "a2", "a3"],
|
||||
"slot_b": {"nested": "object"}, # non-list payload — passthrough
|
||||
"slot_c": None, # None payload — passthrough
|
||||
"slot_d": 42, # int payload — passthrough
|
||||
}
|
||||
snapshot = {k: zone[k] for k in zone}
|
||||
apply_structure_override(zone, {"slot_order": ["slot_d", "slot_a"]})
|
||||
assert list(zone.keys()) == ["slot_d", "slot_a", "slot_b", "slot_c"]
|
||||
# values are identity-preserved
|
||||
for key in zone:
|
||||
assert zone[key] is snapshot[key]
|
||||
|
||||
|
||||
def test_apply_structure_override_defensive_on_non_list_arrays():
|
||||
# If a non-validated override leaks through, non-list slot_order /
|
||||
# hidden_slots should be treated as empty rather than raising.
|
||||
zone: dict = {"slot_title": ["t"], "slot_body": ["b"]}
|
||||
snapshot = dict(zone)
|
||||
override = {
|
||||
"slot_order": "not a list",
|
||||
"hidden_slots": {"also": "not a list"},
|
||||
}
|
||||
assert apply_structure_override(zone, override) is False
|
||||
assert zone == snapshot
|
||||
assert list(zone.keys()) == ["slot_title", "slot_body"]
|
||||
|
||||
|
||||
def test_apply_structure_override_hide_wins_over_reorder():
|
||||
# Edge case: a slot_key appears in BOTH slot_order and hidden_slots.
|
||||
# hidden_slots is applied first, so the slot is gone by the time
|
||||
# reorder runs — the reorder entry silently no-ops.
|
||||
zone: dict = {"slot_a": ["a"], "slot_b": ["b"], "slot_c": ["c"]}
|
||||
override = {
|
||||
"slot_order": ["slot_b", "slot_a", "slot_c"],
|
||||
"hidden_slots": ["slot_b"],
|
||||
}
|
||||
assert apply_structure_override(zone, override) is True
|
||||
# slot_b removed first; slot_a + slot_c reordered to front (slot_b
|
||||
# silently skipped because it no longer exists).
|
||||
assert list(zone.keys()) == ["slot_a", "slot_c"]
|
||||
|
||||
|
||||
def test_apply_structure_override_returns_true_on_pure_reorder_only():
|
||||
# Pure reorder (no hide) — should still return True when key order
|
||||
# actually changes.
|
||||
zone: dict = {"slot_a": ["a"], "slot_b": ["b"]}
|
||||
override = {"slot_order": ["slot_b", "slot_a"]}
|
||||
assert apply_structure_override(zone, override) is True
|
||||
assert list(zone.keys()) == ["slot_b", "slot_a"]
|
||||
|
||||
|
||||
def test_apply_structure_override_returns_true_on_pure_hide_only():
|
||||
# Pure hide (no reorder) — should still return True when a key was
|
||||
# actually removed.
|
||||
zone: dict = {"slot_a": ["a"], "slot_b": ["b"]}
|
||||
override = {"hidden_slots": ["slot_a"]}
|
||||
assert apply_structure_override(zone, override) is True
|
||||
assert list(zone.keys()) == ["slot_b"]
|
||||
188
tests/test_text_override_resolver.py
Normal file
188
tests/test_text_override_resolver.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""IMP-56 (#90) u4 — tests for ``src.text_override_resolver``.
|
||||
|
||||
Covers the resolver contract called out in the Stage 2 plan :
|
||||
|
||||
1. ``parse_text_path`` happy path : ``slot_title.0`` → ``("slot_title", 0)``.
|
||||
2. ``parse_text_path`` rejects: empty / non-string / no '.' / negative
|
||||
index / non-int index / empty slot_key / empty line_index.
|
||||
3. ``parse_text_path`` ``rpartition`` semantics — compound slot_key with
|
||||
internal '.' is preserved (split is on LAST '.').
|
||||
4. ``validate_text_overrides`` returns ``{}`` for non-mapping input.
|
||||
5. ``validate_text_overrides`` drops malformed per-entry rows without
|
||||
rejecting the whole batch (mirrors image_id_stamper per-entry tolerance).
|
||||
6. ``validate_text_overrides`` preserves well-formed entries.
|
||||
7. ``validate_text_overrides`` returns a fresh dict (caller can mutate
|
||||
without aliasing the source).
|
||||
8. ``apply_text_override`` happy path mutates in-place and returns ``True``.
|
||||
9. ``apply_text_override`` returns ``False`` (no mutation) on absent slot.
|
||||
10. ``apply_text_override`` returns ``False`` (no mutation) on
|
||||
line_index >= len(lines) (forward-compat with frame swap / layout
|
||||
regression dropping the slot row).
|
||||
11. ``apply_text_override`` returns ``False`` (no mutation) on a
|
||||
non-list slot (defensive against malformed zone wrappers).
|
||||
12. ``apply_text_override`` preserves untouched lines / other slots.
|
||||
|
||||
All tests are pure-Python — no filesystem, no Selenium, no fixtures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.text_override_resolver import (
|
||||
InvalidTextOverride,
|
||||
apply_text_override,
|
||||
parse_text_path,
|
||||
validate_text_overrides,
|
||||
)
|
||||
|
||||
|
||||
# -- parse_text_path --------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_text_path_simple():
|
||||
assert parse_text_path("slot_title.0") == ("slot_title", 0)
|
||||
assert parse_text_path("slot_body.5") == ("slot_body", 5)
|
||||
|
||||
|
||||
def test_parse_text_path_compound_slot_key():
|
||||
# rpartition semantics — split on LAST '.' so compound keys survive.
|
||||
# Note: a numeric-looking compound suffix (e.g., 'slot.1.5') parses to
|
||||
# ('slot.1', 5) by design; callers that want a strict identifier-only
|
||||
# slot_key should enforce that in their own stamper (u8).
|
||||
assert parse_text_path("slot.compound.2") == ("slot.compound", 2)
|
||||
assert parse_text_path("slot_title.1.5") == ("slot_title.1", 5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
[
|
||||
"",
|
||||
"no_dot",
|
||||
".0",
|
||||
"slot_title.",
|
||||
"slot_title.-1",
|
||||
"slot_title.abc",
|
||||
],
|
||||
)
|
||||
def test_parse_text_path_rejects_malformed(bad_path):
|
||||
with pytest.raises(InvalidTextOverride):
|
||||
parse_text_path(bad_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_type", [None, 0, 1.5, [], {}, b"slot.0"])
|
||||
def test_parse_text_path_rejects_non_string(bad_type):
|
||||
with pytest.raises(InvalidTextOverride):
|
||||
parse_text_path(bad_type) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# -- validate_text_overrides ------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_input", [None, [], "string", 42, 1.5])
|
||||
def test_validate_text_overrides_non_mapping_returns_empty(bad_input):
|
||||
assert validate_text_overrides(bad_input) == {}
|
||||
|
||||
|
||||
def test_validate_text_overrides_passes_well_formed():
|
||||
payload = {
|
||||
"zone-top": {"slot_title.0": "edited headline"},
|
||||
"zone-bottom": {"slot_body.0": "line A", "slot_body.1": "line B"},
|
||||
}
|
||||
out = validate_text_overrides(payload)
|
||||
assert out == payload
|
||||
assert out is not payload # fresh dict
|
||||
|
||||
|
||||
def test_validate_text_overrides_per_entry_tolerance():
|
||||
# Mix of well-formed + every variety of malformed; only the well-formed
|
||||
# rows should survive.
|
||||
payload = {
|
||||
"zone-top": {
|
||||
"slot_title.0": "good",
|
||||
"no_dot": "bad path",
|
||||
"slot_x.abc": "bad index",
|
||||
"slot_y.-1": "negative",
|
||||
42: "non-string path",
|
||||
"slot_z.0": 99, # non-string value
|
||||
},
|
||||
"": {"slot_title.0": "bad zone id"}, # empty zone_id dropped
|
||||
42: {"slot_title.0": "non-string zone id"}, # non-string dropped
|
||||
"zone-empty-after-filter": {"no_dot": "x"}, # entire zone drops
|
||||
"zone-non-mapping": "not a dict", # non-mapping dropped
|
||||
"zone-bottom": {"slot_body.0": "kept"},
|
||||
}
|
||||
out = validate_text_overrides(payload)
|
||||
assert out == {
|
||||
"zone-top": {"slot_title.0": "good"},
|
||||
"zone-bottom": {"slot_body.0": "kept"},
|
||||
}
|
||||
|
||||
|
||||
def test_validate_text_overrides_returns_fresh_nested_dicts():
|
||||
# Mutating the returned dict's per-zone payload must not leak back
|
||||
# into the source (callers should be able to use the result as a
|
||||
# working buffer).
|
||||
payload = {"zone-top": {"slot_title.0": "v"}}
|
||||
out = validate_text_overrides(payload)
|
||||
out["zone-top"]["slot_title.0"] = "mutated"
|
||||
assert payload["zone-top"]["slot_title.0"] == "v"
|
||||
|
||||
|
||||
# -- apply_text_override ----------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_text_override_happy_path_mutates_in_place():
|
||||
zone: dict = {"slot_title": ["orig"]}
|
||||
assert apply_text_override(zone, "slot_title.0", "edited") is True
|
||||
assert zone == {"slot_title": ["edited"]}
|
||||
|
||||
|
||||
def test_apply_text_override_multi_line_slot():
|
||||
zone: dict = {"slot_body": ["line A", "line B", "line C"]}
|
||||
assert apply_text_override(zone, "slot_body.1", "REPLACED") is True
|
||||
assert zone == {"slot_body": ["line A", "REPLACED", "line C"]}
|
||||
|
||||
|
||||
def test_apply_text_override_absent_slot_returns_false_no_mutation():
|
||||
zone: dict = {"slot_title": ["orig"]}
|
||||
assert apply_text_override(zone, "slot_missing.0", "x") is False
|
||||
assert zone == {"slot_title": ["orig"]}
|
||||
|
||||
|
||||
def test_apply_text_override_out_of_range_returns_false_no_mutation():
|
||||
# Forward-compat: a prior render's text_path may target an index the
|
||||
# new render's slot no longer emits (frame swap / layout regression).
|
||||
# This is NOT an error — the override is silently skipped.
|
||||
zone: dict = {"slot_body": ["only one line"]}
|
||||
assert apply_text_override(zone, "slot_body.5", "x") is False
|
||||
assert zone == {"slot_body": ["only one line"]}
|
||||
|
||||
|
||||
def test_apply_text_override_non_list_slot_returns_false_no_mutation():
|
||||
# Defensive: if a wrapper passes a non-list slot value, do not crash.
|
||||
zone: dict = {"slot_title": "not a list"}
|
||||
assert apply_text_override(zone, "slot_title.0", "x") is False
|
||||
assert zone == {"slot_title": "not a list"}
|
||||
|
||||
|
||||
def test_apply_text_override_preserves_other_slots_and_lines():
|
||||
zone: dict = {
|
||||
"slot_title": ["title line"],
|
||||
"slot_body": ["body line A", "body line B"],
|
||||
"slot_caption": ["caption"],
|
||||
}
|
||||
assert apply_text_override(zone, "slot_body.0", "EDITED A") is True
|
||||
assert zone == {
|
||||
"slot_title": ["title line"], # untouched
|
||||
"slot_body": ["EDITED A", "body line B"], # only line 0 changed
|
||||
"slot_caption": ["caption"], # untouched
|
||||
}
|
||||
|
||||
|
||||
def test_apply_text_override_propagates_parse_errors():
|
||||
# apply delegates path parsing to parse_text_path; malformed paths
|
||||
# raise (NOT return False) so callers can distinguish "skipped because
|
||||
# path didn't match the live DOM" from "path was malformed at source".
|
||||
zone: dict = {"slot_title": ["x"]}
|
||||
with pytest.raises(InvalidTextOverride):
|
||||
apply_text_override(zone, "no_dot", "x")
|
||||
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
|
||||
@@ -3,9 +3,13 @@
|
||||
Covers the persisted axes called out in the Stage 2 plan
|
||||
(IMP-51 #79 u1 extended this to 5 axes by adding ``image_overrides``;
|
||||
IMP-45 #74 u1 extended to 6 axes by adding ``slide_css``;
|
||||
IMP-55 #93 u1 extended to 7 axes by adding ``manual_section_assignment``):
|
||||
IMP-55 #93 u1 extended to 7 axes by adding ``manual_section_assignment``;
|
||||
IMP-56 #90 u1 extended to 8 axes by adding ``text_overrides``;
|
||||
IMP-56 #90 u2 extended to 9 axes by adding ``structure_overrides`` —
|
||||
scope LOCKED to ``{slot_order, hidden_slots}``; frame swap stays on the
|
||||
existing ``frames`` axis):
|
||||
|
||||
1. Round-trip ``save`` → ``load`` (7 KNOWN_AXES + foreign top-level keys).
|
||||
1. Round-trip ``save`` → ``load`` (9 KNOWN_AXES + foreign top-level keys).
|
||||
2. Unknown-key passthrough (foreign axes preserved across partial merges).
|
||||
3. Missing / corrupt / non-object behavior (graceful ``{}`` + stderr warning).
|
||||
4. Invalid keys (``InvalidOverrideKey`` raised on traversal / separators /
|
||||
@@ -123,25 +127,50 @@ def _full_payload() -> dict:
|
||||
},
|
||||
"slide_css": "<style>.slide .frame-process-product .label { font-size: 14px; }</style>",
|
||||
"manual_section_assignment": True,
|
||||
"text_overrides": {"zone-top": {"slot_title.0": "edited headline"}},
|
||||
"structure_overrides": {
|
||||
"zone-top": {
|
||||
"slot_order": ["slot_title", "slot_body"],
|
||||
"hidden_slots": ["slot_caption"],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_known_axes_includes_image_overrides():
|
||||
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (now 7 total)."""
|
||||
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (now 9 total)."""
|
||||
assert "image_overrides" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 7
|
||||
assert len(KNOWN_AXES) == 9
|
||||
|
||||
|
||||
def test_known_axes_includes_slide_css():
|
||||
"""IMP-45 #74 u1 — ``slide_css`` is a known axis (7 total)."""
|
||||
"""IMP-45 #74 u1 — ``slide_css`` is a known axis (9 total)."""
|
||||
assert "slide_css" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 7
|
||||
assert len(KNOWN_AXES) == 9
|
||||
|
||||
|
||||
def test_known_axes_includes_manual_section_assignment():
|
||||
"""IMP-55 #93 u1 — bool intent marker is a known axis (7 total)."""
|
||||
"""IMP-55 #93 u1 — bool intent marker is a known axis (9 total)."""
|
||||
assert "manual_section_assignment" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 7
|
||||
assert len(KNOWN_AXES) == 9
|
||||
|
||||
|
||||
def test_known_axes_includes_text_overrides():
|
||||
"""IMP-56 #90 u1 — ``text_overrides`` is a known axis (9 total)."""
|
||||
assert "text_overrides" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 9
|
||||
|
||||
|
||||
def test_known_axes_includes_structure_overrides():
|
||||
"""IMP-56 #90 u2 — ``structure_overrides`` is a known axis (9 total).
|
||||
|
||||
Scope is locked to ``{slot_order, hidden_slots}`` only; frame swap
|
||||
stays on the existing ``frames`` axis (verified at resolver u6 + Step
|
||||
12 apply u7). The IO layer itself only enforces the axis name + the
|
||||
foreign-key preservation invariant.
|
||||
"""
|
||||
assert "structure_overrides" in KNOWN_AXES
|
||||
assert len(KNOWN_AXES) == 9
|
||||
|
||||
|
||||
def test_save_then_load_round_trip(tmp_path):
|
||||
@@ -170,6 +199,8 @@ def test_save_partial_payload_preserves_other_axes(tmp_path):
|
||||
assert loaded["image_overrides"] == _full_payload()["image_overrides"]
|
||||
assert loaded["slide_css"] == _full_payload()["slide_css"]
|
||||
assert loaded["manual_section_assignment"] is True
|
||||
assert loaded["text_overrides"] == _full_payload()["text_overrides"]
|
||||
assert loaded["structure_overrides"] == _full_payload()["structure_overrides"]
|
||||
|
||||
|
||||
def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
|
||||
@@ -193,6 +224,8 @@ def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
|
||||
assert loaded["frames"] == _full_payload()["frames"]
|
||||
assert loaded["slide_css"] == _full_payload()["slide_css"]
|
||||
assert loaded["manual_section_assignment"] is True
|
||||
assert loaded["text_overrides"] == _full_payload()["text_overrides"]
|
||||
assert loaded["structure_overrides"] == _full_payload()["structure_overrides"]
|
||||
|
||||
|
||||
def test_save_axis_replaces_not_deep_merges(tmp_path):
|
||||
@@ -265,12 +298,15 @@ def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
|
||||
key = "03"
|
||||
save(key, _full_payload(), root=tmp_path)
|
||||
raw = (tmp_path / "03.json").read_text(encoding="utf-8")
|
||||
# sort_keys=True → KNOWN_AXES come out alphabetically
|
||||
# sort_keys=True → KNOWN_AXES come out alphabetically. ``structure_overrides``
|
||||
# (IMP-56 #90 u2) sorts between ``slide_css`` and ``text_overrides``.
|
||||
pos_frames = raw.index('"frames"')
|
||||
pos_image_overrides = raw.index('"image_overrides"')
|
||||
pos_layout = raw.index('"layout"')
|
||||
pos_manual = raw.index('"manual_section_assignment"')
|
||||
pos_slide_css = raw.index('"slide_css"')
|
||||
pos_structure_overrides = raw.index('"structure_overrides"')
|
||||
pos_text_overrides = raw.index('"text_overrides"')
|
||||
pos_zg = raw.index('"zone_geometries"')
|
||||
pos_zs = raw.index('"zone_sections"')
|
||||
assert (
|
||||
@@ -279,6 +315,8 @@ def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
|
||||
< pos_layout
|
||||
< pos_manual
|
||||
< pos_slide_css
|
||||
< pos_structure_overrides
|
||||
< pos_text_overrides
|
||||
< pos_zg
|
||||
< pos_zs
|
||||
)
|
||||
|
||||
@@ -55,6 +55,13 @@ def _exec_main_block(
|
||||
# the section-assignment axis; the new kwargs are captured here so any
|
||||
# follow-up test can pin them without re-touching this harness.
|
||||
override_slide_css=None,
|
||||
# IMP-56 (#90) u16 — absorb the two new file-only axes added to
|
||||
# ``run_phase_z2_mvp1`` so the existing harness keeps working when
|
||||
# the CLI dispatch passes the new kwargs through. Both default to
|
||||
# ``None`` so the no-file / corrupt-file / invalid-stem tests can
|
||||
# continue asserting "all overrides None".
|
||||
override_text_overrides=None,
|
||||
override_structure_overrides=None,
|
||||
reuse_from=None,
|
||||
):
|
||||
captured["mdx_path"] = mdx_path
|
||||
@@ -65,6 +72,8 @@ def _exec_main_block(
|
||||
captured["override_section_assignments"] = override_section_assignments
|
||||
captured["override_image_overrides"] = override_image_overrides
|
||||
captured["override_slide_css"] = override_slide_css
|
||||
captured["override_text_overrides"] = override_text_overrides
|
||||
captured["override_structure_overrides"] = override_structure_overrides
|
||||
captured["reuse_from"] = reuse_from
|
||||
|
||||
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
|
||||
@@ -600,3 +609,69 @@ def test_cli_section_assignment_works_without_persisted_file(
|
||||
)
|
||||
|
||||
assert captured["override_section_assignments"] == {"top": ["cli-only"]}
|
||||
|
||||
|
||||
# -- 6. IMP-56 (#90) u16 — text_overrides + structure_overrides file fallback
|
||||
|
||||
|
||||
def test_file_text_overrides_flow_through_when_no_cli(tmp_path, monkeypatch):
|
||||
"""text_overrides axis is file-only — JSON payload reaches run kwarg."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
(tmp_path / "03.json").write_text(
|
||||
json.dumps({
|
||||
"text_overrides": {
|
||||
"top": {"title.0": "edited title", "body.1": "edited line"},
|
||||
"bottom_l": {"caption.0": "edited caption"},
|
||||
},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_text_overrides"] == {
|
||||
"top": {"title.0": "edited title", "body.1": "edited line"},
|
||||
"bottom_l": {"caption.0": "edited caption"},
|
||||
}
|
||||
# No structure payload on file → kwarg collapses to None via ``or None``.
|
||||
assert captured["override_structure_overrides"] is None
|
||||
|
||||
|
||||
def test_file_structure_overrides_flow_through_when_no_cli(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""structure_overrides axis is file-only; inner keys locked to
|
||||
slot_order + hidden_slots (any other inner key is dropped by the
|
||||
CLI gate). Non-string list elements are dropped too."""
|
||||
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||
(tmp_path / "03.json").write_text(
|
||||
json.dumps({
|
||||
"structure_overrides": {
|
||||
"top": {
|
||||
"slot_order": ["c", "a", "b"],
|
||||
"hidden_slots": ["d"],
|
||||
# foreign key — must be dropped by the CLI gate
|
||||
"frame_id": "swap_attempt",
|
||||
# non-string elements — must be dropped per-entry
|
||||
"slot_order_with_junk": ["x", 1, None, "y"],
|
||||
},
|
||||
"bottom": {"hidden_slots": ["e"]},
|
||||
},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_exec_main_block(
|
||||
captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch,
|
||||
)
|
||||
|
||||
assert captured["override_structure_overrides"] == {
|
||||
"top": {"slot_order": ["c", "a", "b"], "hidden_slots": ["d"]},
|
||||
"bottom": {"hidden_slots": ["e"]},
|
||||
}
|
||||
# No text payload on file → kwarg collapses to None.
|
||||
assert captured["override_text_overrides"] is None
|
||||
|
||||
Reference in New Issue
Block a user