4-axis MDX-stem keyed persistence so layout / zone_geometries / zone_sections / frames
survive across `/api/run` sessions. Auto-restore on MDX reopen; CLI > file precedence
on backend pipeline entry; 300ms-debounced PUT flushed before Generate.
u1 src/user_overrides_io.py — load/save/validate_key (MDX-stem regex), 4-axis schema,
miss={}, corrupt warning+{}, atomic tmp+rename, foreign-key preserve.
u2 src/phase_z2_pipeline.py — post-argparse fallback fills only missing axes.
u3 Front/vite.config.ts — GET /api/user-overrides/:key (200 {} on miss, 400 traversal).
u4 Front/vite.config.ts — PUT /api/user-overrides/:key, 4-axis allowlist, partial merge.
u5 Front/client/src/services/userOverridesApi.ts — typed get/save + flushUserOverrides
with 300ms debounce and mutated-axis partial payloads.
u6 Front/client/src/pages/Home.tsx + slidePlanUtils.ts — restore on MDX upload (non-frame
axes immediately, frames remapped post-loadRun unit_id → region.id).
u7 Home.tsx — persist on 4 mutation handlers (section drop, layout select, zone resize,
frame select); zone_sizes and Generate excluded.
u8 tests/test_user_overrides_io.py — round-trip, unknown-key passthrough, missing/corrupt,
invalid keys (26 tests).
u9 tests/test_user_overrides_pipeline_fallback.py — per-axis fill, CLI-wins, no-file noop,
corrupt warning+skip (16 tests).
u10 Home.tsx + user_overrides_write.test.ts — await flushUserOverrides() before runPipeline
in handleGenerate try-block head; source-pattern regression assertions (20 → 22 tests).
Backend pytest 42/42 green. Frontend vitest 113/113 green (endpoint 42 / restore 21 /
service 28 / write 22). HEAD baseline ee97f4f; no spillover to phase_z2 templates /
families / frames / pipeline orchestration outside the IMP-52 surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
7.3 KiB
Python
230 lines
7.3 KiB
Python
"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``.
|
|
|
|
Covers the four axes called out in the Stage 2 plan:
|
|
|
|
1. Round-trip ``save`` → ``load`` (4 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"},
|
|
}
|
|
|
|
|
|
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"]
|
|
|
|
|
|
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, image_overrides,
|
|
schema_version, ...) must survive a partial merge on a known axis."""
|
|
key = "03"
|
|
path = override_path(key, root=tmp_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
pre_seed = {
|
|
"layout": "single-column",
|
|
"image_overrides": {"img-1": {"position": "right", "size": "small"}},
|
|
"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["image_overrides"] == pre_seed["image_overrides"]
|
|
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_layout = raw.index('"layout"')
|
|
pos_zg = raw.index('"zone_geometries"')
|
|
pos_zs = raw.index('"zone_sections"')
|
|
assert pos_frames < pos_layout < 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)
|