Files
C.E.L_Slide_test2/tests/test_user_overrides_io.py
kyeongmin 9062931863
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 22s
feat(#74): IMP-45 u1~u8 slide-level CSS override (frontmatter slide_overrides.css + --override-slide-css/--slide-css-file + idempotent Step 13 injector)
u1 KNOWN_AXES tuple gains slide_css entry in src/user_overrides_io.py
(snake_case parity with image_overrides); round-trip test extends
to 6 axes.
u2 src/mdx_normalizer.py surfaces nested slide_overrides.css from the
MDX frontmatter into the normalize_mdx_content return dict; absent
key -> {}, non-string css drops. 4 unit cases in tests/test_mdx_normalizer.py
(present / absent / non-string / title-only).
u3 src/slide_css_injector.py NEW (88 lines) mirrors the
inject_image_overrides_style contract from src/image_id_stamper.py:
marker pair <!--IMP45-SLIDE-CSS:OPEN--> / <!--IMP45-SLIDE-CSS:CLOSE-->,
idempotent re-injection, </head> > <body> > document-start three-tier
fallback, empty/None -> unchanged. 8 fixtures in
tests/test_slide_css_injector.py mirror test_image_id_stamper.py.
u4 run_phase_z2_mvp1 accepts override_slide_css: Optional[str] = None;
None -> frontmatter slide_overrides.css fallback. Step 13 calls
inject_slide_css after image override injection and before the
final.html disk write, so CLI/CI/regression renders observe the same
backend artifact.
u5 argparse adds mutually-exclusive --override-slide-css TEXT (inline
CSS, <style> wrapper optional) and --slide-css-file PATH (UTF-8 read,
fail-closed sys.exit(2) on missing path / decode error / both flags
present). Resolved string is forwarded as override_slide_css kwarg.
6 cases in tests/test_phase_z2_cli_overrides.py (inline / file / both
/ missing / non-utf8 / neither).
u6 samples/mdx_batch/04.mdx frontmatter gains slide_overrides.css
block (verbatim of the former MDX04_DEFAULT_OVERRIDE_CSS constant,
no sample/frame gate). Subprocess smoke in
tests/test_phase_z2_slide_css_smoke.py verifies the marker pair and
CSS substring land in final.html.
u7 Front/client removes the sample/frame-gated frontend-only injection:
Home.tsx drops the MDX04_DEFAULT_OVERRIDE_CSS constant and the
sample==="04"+frame==="process_product_two_way" branch (-28 lines);
SlideCanvas.tsx drops the iframe contentDocument.head injection of
that prop (-14 lines). Live preview now reads backend final.html only.
u8 tests/regression/fixtures/89a_pre_baseline_sha.json 04.mdx entry
resyncs to the live SHA ddb6bf2f... / 28042 bytes (overwrites the
earlier 5-byte-drift d02c76fd... / 28047). Other entries untouched.
Note: 01.mdx baseline drift (ad6f16a3... / 29089 -> live f26a7fac...
/ 29084) predates this branch and is split to a follow-up issue per
the closed-issue fresh validation rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:26:03 +09:00

281 lines
9.1 KiB
Python

"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``.
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``):
1. Round-trip ``save`` → ``load`` (6 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 /
leading dot / empty).
All tests inject ``root=tmp_path`` so they never touch the real
``data/user_overrides/`` directory.
"""
from __future__ import annotations
import json
import pytest
from src.user_overrides_io import (
DEFAULT_OVERRIDES_ROOT,
InvalidOverrideKey,
KNOWN_AXES,
load,
override_path,
save,
validate_key,
)
# -- key validation ---------------------------------------------------------
def test_validate_key_accepts_typical_mdx_stems():
for key in ("01", "03", "03__DX_master", "sample.v2", "a-b_c.1"):
assert validate_key(key) == key
@pytest.mark.parametrize(
"bad_key",
[
"",
"..",
"../escape",
"sub/dir",
"sub\\dir",
".hidden",
"-leading-dash",
".",
"name with space",
"name?",
],
)
def test_validate_key_rejects_unsafe(bad_key):
with pytest.raises(InvalidOverrideKey):
validate_key(bad_key)
def test_validate_key_rejects_non_string():
for bad in (None, 123, b"bytes", ["list"], {"d": 1}):
with pytest.raises(InvalidOverrideKey):
validate_key(bad) # type: ignore[arg-type]
# -- override_path ----------------------------------------------------------
def test_override_path_uses_default_root_when_unspecified():
p = override_path("sample")
assert p.parent == DEFAULT_OVERRIDES_ROOT
assert p.name == "sample.json"
def test_override_path_honors_explicit_root(tmp_path):
p = override_path("sample", root=tmp_path)
assert p == tmp_path / "sample.json"
# -- load: missing / corrupt / non-object -----------------------------------
def test_load_missing_file_returns_empty_dict(tmp_path):
assert load("nope", root=tmp_path) == {}
def test_load_corrupt_json_warns_and_returns_empty(tmp_path, capsys):
path = override_path("corrupt", root=tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{ this is not valid json", encoding="utf-8")
result = load("corrupt", root=tmp_path)
assert result == {}
captured = capsys.readouterr()
assert "failed to read" in captured.err
assert str(path) in captured.err
def test_load_non_object_json_warns_and_returns_empty(tmp_path, capsys):
path = override_path("list_root", root=tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("[1, 2, 3]", encoding="utf-8")
result = load("list_root", root=tmp_path)
assert result == {}
captured = capsys.readouterr()
assert "not a JSON object" in captured.err
# -- save: round-trip + partial-merge + foreign-key preserve ----------------
def _full_payload() -> dict:
return {
"layout": "sidebar-right",
"zone_geometries": {
"zone-top": {"x": 40.0, "y": 50.0, "w": 1200.0, "h": 120.0},
},
"zone_sections": {"zone-top": ["03-1", "03-2"]},
"frames": {"03-1+03-2": "frame_two_way_compare"},
"image_overrides": {
"img-1": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 25.0},
},
"slide_css": "<style>.slide .frame-process-product .label { font-size: 14px; }</style>",
}
def test_known_axes_includes_image_overrides():
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (now 6 total)."""
assert "image_overrides" in KNOWN_AXES
assert len(KNOWN_AXES) == 6
def test_known_axes_includes_slide_css():
"""IMP-45 #74 u1 — ``slide_css`` is a known axis (6 total)."""
assert "slide_css" in KNOWN_AXES
assert len(KNOWN_AXES) == 6
def test_save_then_load_round_trip(tmp_path):
key = "03"
payload = _full_payload()
written = save(key, payload, root=tmp_path)
assert written.exists()
assert written == tmp_path / "03.json"
loaded = load(key, root=tmp_path)
for axis in KNOWN_AXES:
assert loaded[axis] == payload[axis], f"axis {axis!r} did not round-trip"
def test_save_partial_payload_preserves_other_axes(tmp_path):
key = "03"
save(key, _full_payload(), root=tmp_path)
save(key, {"layout": "two-column"}, root=tmp_path)
loaded = load(key, root=tmp_path)
assert loaded["layout"] == "two-column"
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
assert loaded["frames"] == _full_payload()["frames"]
assert loaded["image_overrides"] == _full_payload()["image_overrides"]
assert loaded["slide_css"] == _full_payload()["slide_css"]
def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
"""IMP-51 #79 u1 — partial ``image_overrides`` write preserves siblings."""
key = "03"
save(key, _full_payload(), root=tmp_path)
save(
key,
{"image_overrides": {"img-9": {"x": 5.0, "y": 5.0, "w": 50.0, "h": 50.0}}},
root=tmp_path,
)
loaded = load(key, root=tmp_path)
assert loaded["image_overrides"] == {
"img-9": {"x": 5.0, "y": 5.0, "w": 50.0, "h": 50.0}
}
assert loaded["layout"] == _full_payload()["layout"]
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
assert loaded["frames"] == _full_payload()["frames"]
assert loaded["slide_css"] == _full_payload()["slide_css"]
def test_save_axis_replaces_not_deep_merges(tmp_path):
key = "03"
save(key, {"frames": {"03-1": "frame_a", "03-2": "frame_b"}}, root=tmp_path)
save(key, {"frames": {"03-3": "frame_c"}}, root=tmp_path)
loaded = load(key, root=tmp_path)
assert loaded["frames"] == {"03-3": "frame_c"}
def test_save_none_clears_axis(tmp_path):
key = "03"
save(key, _full_payload(), root=tmp_path)
save(key, {"layout": None}, root=tmp_path)
loaded = load(key, root=tmp_path)
assert "layout" not in loaded
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
assert loaded["frames"] == _full_payload()["frames"]
def test_save_preserves_foreign_top_level_keys(tmp_path):
"""Forward-compat: axes outside KNOWN_AXES (zone_sizes, schema_version,
...) must survive a partial merge on a known axis. (IMP-51 #79 u1
promoted ``image_overrides`` to a known axis, so it is no longer
exercised here as a foreign key.)"""
key = "03"
path = override_path(key, root=tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
pre_seed = {
"layout": "single-column",
"zone_sizes": {"zone-top": "tall"},
"schema_version": "experimental-1",
}
path.write_text(json.dumps(pre_seed), encoding="utf-8")
save(key, {"layout": "sidebar-right"}, root=tmp_path)
loaded = load(key, root=tmp_path)
assert loaded["layout"] == "sidebar-right"
assert loaded["zone_sizes"] == pre_seed["zone_sizes"]
assert loaded["schema_version"] == pre_seed["schema_version"]
def test_save_creates_parent_directory(tmp_path):
nested = tmp_path / "deep" / "nest"
assert not nested.exists()
save("03", {"layout": "two-column"}, root=nested)
assert (nested / "03.json").exists()
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
pos_frames = raw.index('"frames"')
pos_image_overrides = raw.index('"image_overrides"')
pos_layout = raw.index('"layout"')
pos_slide_css = raw.index('"slide_css"')
pos_zg = raw.index('"zone_geometries"')
pos_zs = raw.index('"zone_sections"')
assert (
pos_frames
< pos_image_overrides
< pos_layout
< pos_slide_css
< pos_zg
< pos_zs
)
def test_save_leaves_no_tmp_file_on_success(tmp_path):
save("03", _full_payload(), root=tmp_path)
leftovers = [p for p in tmp_path.iterdir() if p.name != "03.json"]
assert leftovers == [], f"tmp files leaked: {leftovers!r}"
def test_save_rejects_non_dict_partial(tmp_path):
with pytest.raises(TypeError):
save("03", ["not", "a", "dict"], root=tmp_path) # type: ignore[arg-type]
# -- save / load propagate key validation -----------------------------------
@pytest.mark.parametrize("bad_key", ["", "..", "sub/dir", ".hidden"])
def test_save_rejects_invalid_key(tmp_path, bad_key):
with pytest.raises(InvalidOverrideKey):
save(bad_key, {"layout": "two-column"}, root=tmp_path)
@pytest.mark.parametrize("bad_key", ["", "..", "sub/dir", ".hidden"])
def test_load_rejects_invalid_key(tmp_path, bad_key):
with pytest.raises(InvalidOverrideKey):
load(bad_key, root=tmp_path)