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>
189 lines
7.1 KiB
Python
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")
|