Files
C.E.L_Slide_test2/tests/test_text_override_resolver.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

189 lines
7.1 KiB
Python

"""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")