"""IMP-52 (#80) u9 — backend tests for the argparse persistence fallback. Stage 2 u9 scope (per the Exit Report): 1. Per-axis fill — file value flows through when CLI omits the axis. 2. CLI-wins — CLI value beats file value on the same axis. 3. No-file noop — missing file → ``run_phase_z2_mvp1`` gets all-None. 4. Corrupt-file warn — invalid JSON / non-object → stderr warning + skip. 5. Invalid stem warn — ``Path(args.mdx_path).stem`` rejected by validator → warning + fallback skipped wholesale. We exec the ``if __name__ == "__main__"`` block of ``src.phase_z2_pipeline`` directly inside the module's namespace, after (a) monkeypatching ``src.user_overrides_io.DEFAULT_OVERRIDES_ROOT`` to a tmp directory and (b) replacing ``run_phase_z2_mvp1`` with a recording stub. This exercises the production fallback verbatim without the cost of a real pipeline invocation. """ from __future__ import annotations import ast import json import sys from pathlib import Path from typing import Any import pytest import src.phase_z2_pipeline as _pz2 import src.user_overrides_io as _io # -- harness --------------------------------------------------------------- def _exec_main_block( captured: dict[str, Any], argv: list[str], monkeypatch ) -> None: """Run the ``__main__`` body of phase_z2_pipeline.py with a fake ``run_phase_z2_mvp1`` so its kwargs are observable.""" def _fake_run( mdx_path, run_id, *, override_layout=None, override_frames=None, override_zone_geometries=None, override_section_assignments=None, override_image_overrides=None, # IMP-55 (#93) u9 — mirror the live ``run_phase_z2_mvp1`` signature so # the __main__ dispatch in src/phase_z2_pipeline.py:8332 does not raise # TypeError on kwargs added by IMP-45 #74 (``override_slide_css``) and # IMP-43 #72 (``reuse_from``). The u9 truth-table assertions only read # the section-assignment axis; the new kwargs are captured here so any # follow-up test can pin them without re-touching this harness. override_slide_css=None, # IMP-56 (#90) u16 — absorb the two new file-only axes added to # ``run_phase_z2_mvp1`` so the existing harness keeps working when # the CLI dispatch passes the new kwargs through. Both default to # ``None`` so the no-file / corrupt-file / invalid-stem tests can # continue asserting "all overrides None". override_text_overrides=None, override_structure_overrides=None, reuse_from=None, ): captured["mdx_path"] = mdx_path captured["run_id"] = run_id captured["override_layout"] = override_layout captured["override_frames"] = override_frames captured["override_zone_geometries"] = override_zone_geometries captured["override_section_assignments"] = override_section_assignments captured["override_image_overrides"] = override_image_overrides captured["override_slide_css"] = override_slide_css captured["override_text_overrides"] = override_text_overrides captured["override_structure_overrides"] = override_structure_overrides captured["reuse_from"] = reuse_from monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run) monkeypatch.setattr(sys, "argv", argv) src_path = Path(_pz2.__file__) source = src_path.read_text(encoding="utf-8") tree = ast.parse(source) for node in tree.body: if ( isinstance(node, ast.If) and isinstance(node.test, ast.Compare) and isinstance(node.test.left, ast.Name) and node.test.left.id == "__name__" ): block = ast.Module(body=node.body, type_ignores=[]) exec(compile(block, str(src_path), "exec"), _pz2.__dict__) return raise AssertionError("no `if __name__ == '__main__'` block found") def _redirect_overrides_root(tmp_path: Path, monkeypatch) -> None: """Redirect the on-disk persistence root so tests never touch ``data/user_overrides/``.""" monkeypatch.setattr(_io, "DEFAULT_OVERRIDES_ROOT", tmp_path) def _write_full_payload(tmp_path: Path, stem: str = "03") -> Path: path = tmp_path / f"{stem}.json" path.write_text( json.dumps( { "layout": "sidebar-right", "frames": {"03-1": "frame_file_a", "03-1+03-2": "frame_file_b"}, "zone_geometries": { "top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.3}, "bottom": {"x": 0.0, "y": 0.3, "w": 1.0, "h": 0.7}, }, "zone_sections": { "top": ["03-1"], "bottom": ["03-2", "03-3"], }, # IMP-55 (#93) u9 — seed the new ``manual_section_assignment`` # marker True so the per-axis / CLI-wins / partial-merge tests # below continue to exercise the file→fallback path under the # gate added in src/phase_z2_pipeline.py (``is True`` identity # check). Truth-table coverage for False / absent / non-bool # belongs to u10 and lives in its own test section. "manual_section_assignment": True, "image_overrides": { "img-file-a": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0}, "img-file-b": {"x": 50.0, "y": 50.0, "w": 40.0, "h": 40.0}, }, } ), encoding="utf-8", ) return path # -- 1. no-file noop ------------------------------------------------------- def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch): _redirect_overrides_root(tmp_path, monkeypatch) captured: dict[str, Any] = {} _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) assert captured["override_layout"] is None assert captured["override_frames"] is None assert captured["override_zone_geometries"] is None assert captured["override_section_assignments"] is None assert captured["override_image_overrides"] is None # MDX path / run_id propagate untouched. assert captured["mdx_path"] == Path("03.mdx") assert captured["run_id"] is None # -- 2. file fills every axis when CLI is empty ---------------------------- def test_file_only_fills_all_five_axes_when_cli_empty(tmp_path, monkeypatch): _redirect_overrides_root(tmp_path, monkeypatch) _write_full_payload(tmp_path, "03") captured: dict[str, Any] = {} _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) assert captured["override_layout"] == "sidebar-right" assert captured["override_frames"] == { "03-1": "frame_file_a", "03-1+03-2": "frame_file_b", } assert captured["override_zone_geometries"] == { "top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.3}, "bottom": {"x": 0.0, "y": 0.3, "w": 1.0, "h": 0.7}, } assert captured["override_section_assignments"] == { "top": ["03-1"], "bottom": ["03-2", "03-3"], } assert captured["override_image_overrides"] == { "img-file-a": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0}, "img-file-b": {"x": 50.0, "y": 50.0, "w": 40.0, "h": 40.0}, } # -- 3. CLI beats file on the same axis ----------------------------------- def test_cli_layout_overrides_file_layout(tmp_path, monkeypatch): _redirect_overrides_root(tmp_path, monkeypatch) _write_full_payload(tmp_path, "03") captured: dict[str, Any] = {} _exec_main_block( captured, ["src.phase_z2_pipeline", "03.mdx", "--override-layout", "two-column"], monkeypatch, ) # layout from CLI; remaining axes still filled from file. assert captured["override_layout"] == "two-column" assert captured["override_frames"] == { "03-1": "frame_file_a", "03-1+03-2": "frame_file_b", } assert captured["override_zone_geometries"] is not None assert captured["override_section_assignments"] is not None def test_cli_frames_overrides_file_frames(tmp_path, monkeypatch): _redirect_overrides_root(tmp_path, monkeypatch) _write_full_payload(tmp_path, "03") captured: dict[str, Any] = {} _exec_main_block( captured, [ "src.phase_z2_pipeline", "03.mdx", "--override-frame", "03-1=cli_frame_x", ], monkeypatch, ) # CLI ``frames`` payload wholly replaces file ``frames`` (per-axis win). assert captured["override_frames"] == {"03-1": "cli_frame_x"} # Other axes still come from the file. assert captured["override_layout"] == "sidebar-right" assert captured["override_zone_geometries"] is not None assert captured["override_section_assignments"] is not None assert captured["override_image_overrides"] is not None # -- 3b. CLI image override beats file image override (IMP-51 #79 u6) ----- def test_cli_image_override_overrides_file_image_overrides(tmp_path, monkeypatch): _redirect_overrides_root(tmp_path, monkeypatch) _write_full_payload(tmp_path, "03") captured: dict[str, Any] = {} _exec_main_block( captured, [ "src.phase_z2_pipeline", "03.mdx", "--override-image", "img-cli=70,80,20,15", ], monkeypatch, ) # CLI ``image_overrides`` payload wholly replaces file payload (per-axis). assert captured["override_image_overrides"] == { "img-cli": {"x": 70.0, "y": 80.0, "w": 20.0, "h": 15.0}, } # Other axes still come from the file. assert captured["override_layout"] == "sidebar-right" assert captured["override_frames"] is not None assert captured["override_zone_geometries"] is not None assert captured["override_section_assignments"] is not None # -- 4. corrupt / non-object file warns and skips fallback ---------------- def test_corrupt_json_warns_and_skips_fallback(tmp_path, monkeypatch, capsys): _redirect_overrides_root(tmp_path, monkeypatch) (tmp_path / "03.json").write_text("{ not valid json", encoding="utf-8") captured: dict[str, Any] = {} _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) err = capsys.readouterr().err assert "failed to read" in err # ``or None`` collapses empty dicts back to None on the call site. assert captured["override_layout"] is None assert captured["override_frames"] is None assert captured["override_zone_geometries"] is None assert captured["override_section_assignments"] is None assert captured["override_image_overrides"] is None def test_non_object_top_level_warns_and_skips_fallback( tmp_path, monkeypatch, capsys ): _redirect_overrides_root(tmp_path, monkeypatch) (tmp_path / "03.json").write_text("[1, 2, 3]", encoding="utf-8") captured: dict[str, Any] = {} _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) err = capsys.readouterr().err assert "not a JSON object" in err assert captured["override_layout"] is None assert captured["override_frames"] is None assert captured["override_zone_geometries"] is None assert captured["override_section_assignments"] is None assert captured["override_image_overrides"] is None # -- 5. invalid MDX stem warns and skips fallback wholesale --------------- def test_invalid_mdx_stem_warns_and_skips_fallback( tmp_path, monkeypatch, capsys ): _redirect_overrides_root(tmp_path, monkeypatch) # Seed a file the loader would otherwise consume; the invalid stem must # short-circuit before any read happens. _write_full_payload(tmp_path, "03") captured: dict[str, Any] = {} # ``Path(".hidden.mdx").stem`` == ".hidden" → leading dot → InvalidOverrideKey. _exec_main_block( captured, ["src.phase_z2_pipeline", ".hidden.mdx"], monkeypatch ) err = capsys.readouterr().err assert "cannot derive persistence key" in err assert captured["override_layout"] is None assert captured["override_frames"] is None assert captured["override_zone_geometries"] is None assert captured["override_section_assignments"] is None assert captured["override_image_overrides"] is None # -- 6. per-axis partial fill (file fills only what CLI omits) ------------ def test_per_axis_partial_fill_mixes_cli_and_file(tmp_path, monkeypatch): """File carries frames + zone_geometries; CLI supplies layout only. Expected: ``override_layout`` = CLI value, ``override_frames`` and ``override_zone_geometries`` = file values, ``override_section_assignments`` = None (neither side provided it). """ _redirect_overrides_root(tmp_path, monkeypatch) (tmp_path / "03.json").write_text( json.dumps( { "frames": {"03-1": "frame_only_file"}, "zone_geometries": { "top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5}, }, } ), encoding="utf-8", ) captured: dict[str, Any] = {} _exec_main_block( captured, [ "src.phase_z2_pipeline", "03.mdx", "--override-layout", "sidebar-right", ], monkeypatch, ) assert captured["override_layout"] == "sidebar-right" assert captured["override_frames"] == {"03-1": "frame_only_file"} assert captured["override_zone_geometries"] == { "top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5}, } assert captured["override_section_assignments"] is None assert captured["override_image_overrides"] is None # -- 7. image_overrides fallback edge cases (IMP-51 #79 u6) --------------- def test_image_overrides_fallback_drops_malformed_entries(tmp_path, monkeypatch): """File carries a mix of valid + malformed image_overrides entries. Expected: valid entry survives; malformed entries (non-string id, empty id, non-dict value, missing key, non-numeric value) are silently dropped — no exception propagates. """ _redirect_overrides_root(tmp_path, monkeypatch) (tmp_path / "03.json").write_text( json.dumps( { "image_overrides": { "img-valid": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0}, "": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0}, "img-not-dict": "oops", "img-missing-h": {"x": 1.0, "y": 2.0, "w": 3.0}, "img-bad-value": {"x": "abc", "y": 2.0, "w": 3.0, "h": 4.0}, } } ), encoding="utf-8", ) captured: dict[str, Any] = {} _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) assert captured["override_image_overrides"] == { "img-valid": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0}, } def test_image_overrides_fallback_non_dict_axis_is_ignored(tmp_path, monkeypatch): """File ``image_overrides`` is a non-dict (list); fallback silently skips.""" _redirect_overrides_root(tmp_path, monkeypatch) (tmp_path / "03.json").write_text( json.dumps({"image_overrides": ["not", "a", "dict"]}), encoding="utf-8", ) captured: dict[str, Any] = {} _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) # ``overrides_images`` stays empty; ``or None`` collapses on call site. assert captured["override_image_overrides"] is None def test_image_overrides_fallback_coerces_int_values_to_float(tmp_path, monkeypatch): """JSON-loaded ints (e.g. ``10`` not ``10.0``) must coerce to float.""" _redirect_overrides_root(tmp_path, monkeypatch) (tmp_path / "03.json").write_text( json.dumps( {"image_overrides": {"img-int": {"x": 10, "y": 20, "w": 30, "h": 40}}} ), encoding="utf-8", ) captured: dict[str, Any] = {} _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) coerced = captured["override_image_overrides"] assert coerced == {"img-int": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 40.0}} for axis_value in coerced["img-int"].values(): assert isinstance(axis_value, float) # -- 8. IMP-55 (#93) u10 — manual_section_assignment marker truth-table ---- # # The backend file-fallback gate added in u9 # (``src/phase_z2_pipeline.py`` ``_persisted.get("manual_section_assignment") # is True``) is exercised here with the full truth-table of marker values a # stale or hand-edited overrides file could legally contain. The gate is # fail-closed by ``is True`` identity, so only literal Python ``True`` # (JSON ``true``) propagates ``zone_sections`` into # ``override_section_assignments``; everything else — absent, ``False``, # truthy non-bool (``"true"``, ``1``, ``[]``, ``{}``), and ``None`` — must # leave the axis as ``None`` (``or None`` collapses an empty dict on the # call site). No hardcoding of section IDs in the assertion logic; the # values ``03-1`` / ``03-2`` here are sample payload literals, not pinned # behavior. ``_write_marker_payload`` reuses the IO loader by way of the # ``__main__`` block (no direct import of the pipeline gate). _MARKER_ABSENT = object() def _write_marker_payload( tmp_path: Path, marker: Any, stem: str = "03" ) -> Path: """Write a minimal overrides file with ``zone_sections`` + optional marker. ``marker is _MARKER_ABSENT`` → omit ``manual_section_assignment`` key. Any other value (including ``None``) → write it verbatim as JSON. """ payload: dict[str, Any] = { "zone_sections": {"top": ["03-1"], "bottom": ["03-2"]}, } if marker is not _MARKER_ABSENT: payload["manual_section_assignment"] = marker path = tmp_path / f"{stem}.json" path.write_text(json.dumps(payload), encoding="utf-8") return path def test_marker_true_fills_section_assignments(tmp_path, monkeypatch): """marker=True + zone_sections in file + CLI empty → file value flows.""" _redirect_overrides_root(tmp_path, monkeypatch) _write_marker_payload(tmp_path, True, "03") captured: dict[str, Any] = {} _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) assert captured["override_section_assignments"] == { "top": ["03-1"], "bottom": ["03-2"], } @pytest.mark.parametrize( "marker", [ _MARKER_ABSENT, False, "true", # JSON-string truthy must NOT pass the ``is True`` gate. 1, # int truthy must NOT pass. [], {}, None, ], ids=["absent", "false", "string_true", "int_one", "empty_list", "empty_dict", "null"], ) def test_marker_non_true_skips_section_assignments( tmp_path, monkeypatch, marker ): """marker absent / False / non-bool → gate fail-closed → axis is None. Even though ``zone_sections`` is present in the file, the gate refuses to forward it because ``manual_section_assignment`` is not literal ``True``. ``or None`` on the call site collapses the empty dict back to ``None``. """ _redirect_overrides_root(tmp_path, monkeypatch) _write_marker_payload(tmp_path, marker, "03") captured: dict[str, Any] = {} _exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch) assert captured["override_section_assignments"] is None # -- 9. IMP-55 (#93) u11 — CLI ``--override-section-assignment`` wins over # persisted manual-marker fallback ---------------------------------------- # # The u9 gate only fires on the file→fallback branch # (``not overrides_section_assignments and _persisted.get(...) is True``). # When the CLI supplies ``--override-section-assignment`` the # ``overrides_section_assignments`` dict is truthy before the gate is # evaluated, so the persisted ``zone_sections`` axis (and the # ``manual_section_assignment`` marker that would otherwise unlock it) is # bypassed entirely — CLI is authoritative on the section-assignment # axis. The three cases below pin this contract: # # 1. CLI + persisted marker True (file value present) → CLI wins. # 2. CLI + persisted marker False (file value present) → CLI wins; the # marker is irrelevant on the CLI-wins path (no truth-value coupling). # 3. CLI with no overrides file at all → CLI value flows through; the # marker is a gate on file→fallback only, never a precondition for # any CLI ``cli_override`` to take effect. # # Cross-axis bystanders (layout / frames / geometries / images) are # intentionally not seeded here; this section locks CLI-vs-marker # semantics on the section axis only. Cross-axis CLI-wins behavior is # already covered by the IMP-52 #80 tests in section 3 / 3b above. def test_cli_section_assignment_wins_over_persisted_marker_true( tmp_path, monkeypatch ): """marker=True + file zone_sections + CLI value → CLI wins per-axis.""" _redirect_overrides_root(tmp_path, monkeypatch) _write_marker_payload(tmp_path, True, "03") captured: dict[str, Any] = {} _exec_main_block( captured, [ "src.phase_z2_pipeline", "03.mdx", "--override-section-assignment", "top=cli-section", ], monkeypatch, ) # CLI value wholly replaces the file zone_sections (per-axis win); the # gate's ``not overrides_section_assignments`` precondition is false. assert captured["override_section_assignments"] == {"top": ["cli-section"]} def test_cli_section_assignment_wins_with_persisted_marker_false( tmp_path, monkeypatch ): """marker=False + file zone_sections + CLI value → CLI wins; marker unread.""" _redirect_overrides_root(tmp_path, monkeypatch) _write_marker_payload(tmp_path, False, "03") captured: dict[str, Any] = {} _exec_main_block( captured, [ "src.phase_z2_pipeline", "03.mdx", "--override-section-assignment", "bottom=cli-section", ], monkeypatch, ) assert captured["override_section_assignments"] == {"bottom": ["cli-section"]} def test_cli_section_assignment_works_without_persisted_file( tmp_path, monkeypatch ): """No overrides file → CLI value flows; marker is not a CLI precondition.""" _redirect_overrides_root(tmp_path, monkeypatch) captured: dict[str, Any] = {} _exec_main_block( captured, [ "src.phase_z2_pipeline", "03.mdx", "--override-section-assignment", "top=cli-only", ], monkeypatch, ) assert captured["override_section_assignments"] == {"top": ["cli-only"]} # -- 6. IMP-56 (#90) u16 — text_overrides + structure_overrides file fallback def test_file_text_overrides_flow_through_when_no_cli(tmp_path, monkeypatch): """text_overrides axis is file-only — JSON payload reaches run kwarg.""" _redirect_overrides_root(tmp_path, monkeypatch) (tmp_path / "03.json").write_text( json.dumps({ "text_overrides": { "top": {"title.0": "edited title", "body.1": "edited line"}, "bottom_l": {"caption.0": "edited caption"}, }, }), encoding="utf-8", ) captured: dict[str, Any] = {} _exec_main_block( captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch, ) assert captured["override_text_overrides"] == { "top": {"title.0": "edited title", "body.1": "edited line"}, "bottom_l": {"caption.0": "edited caption"}, } # No structure payload on file → kwarg collapses to None via ``or None``. assert captured["override_structure_overrides"] is None def test_file_structure_overrides_flow_through_when_no_cli( tmp_path, monkeypatch ): """structure_overrides axis is file-only; inner keys locked to slot_order + hidden_slots (any other inner key is dropped by the CLI gate). Non-string list elements are dropped too.""" _redirect_overrides_root(tmp_path, monkeypatch) (tmp_path / "03.json").write_text( json.dumps({ "structure_overrides": { "top": { "slot_order": ["c", "a", "b"], "hidden_slots": ["d"], # foreign key — must be dropped by the CLI gate "frame_id": "swap_attempt", # non-string elements — must be dropped per-entry "slot_order_with_junk": ["x", 1, None, "y"], }, "bottom": {"hidden_slots": ["e"]}, }, }), encoding="utf-8", ) captured: dict[str, Any] = {} _exec_main_block( captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch, ) assert captured["override_structure_overrides"] == { "top": {"slot_order": ["c", "a", "b"], "hidden_slots": ["d"]}, "bottom": {"hidden_slots": ["e"]}, } # No text payload on file → kwarg collapses to None. assert captured["override_text_overrides"] is None