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