Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 22s
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>
486 lines
15 KiB
Python
486 lines
15 KiB
Python
"""IMP-51 (#79) u5 — focused tests for the ``--override-image`` CLI surface.
|
|
|
|
Stage 2 u5 scope (per the Exit Report):
|
|
|
|
- Successful parse: single flag + multiple flags accumulate.
|
|
- Forwarding: parsed mapping reaches ``run_phase_z2_mvp1`` as
|
|
``override_image_overrides={image_id: {"x", "y", "w", "h"}}``.
|
|
- Empty payload: omitting ``--override-image`` forwards ``None``
|
|
(CLI ``or None`` collapse, sibling pattern to other axes).
|
|
- Hard-error cases (each must ``sys.exit(2)`` with a stderr message):
|
|
* missing ``=``
|
|
* empty ``IMAGE_ID``
|
|
* duplicate ``IMAGE_ID``
|
|
* wrong float count (not 4)
|
|
* non-numeric float component
|
|
|
|
The harness mirrors ``tests/test_user_overrides_pipeline_fallback.py`` —
|
|
the ``if __name__ == "__main__"`` block of ``src.phase_z2_pipeline`` is
|
|
exec'd inside the module's namespace after monkeypatching
|
|
``run_phase_z2_mvp1`` with a recording stub. This exercises the actual
|
|
production parser without invoking the real pipeline.
|
|
|
|
The persistence fallback is silenced by redirecting
|
|
``src.user_overrides_io.DEFAULT_OVERRIDES_ROOT`` to a clean tmp directory
|
|
so persisted state from prior runs cannot bleed into the parser-only
|
|
assertions here.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
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,
|
|
override_slide_css=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["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:
|
|
"""Isolate the persistence fallback so file state never leaks in."""
|
|
monkeypatch.setattr(_io, "DEFAULT_OVERRIDES_ROOT", tmp_path)
|
|
|
|
|
|
# -- success paths --------------------------------------------------------
|
|
|
|
|
|
def test_no_image_override_forwards_none(tmp_path, monkeypatch):
|
|
"""When ``--override-image`` is omitted, the kwarg must be ``None``
|
|
(the parser's accumulator stays empty → ``overrides_images or None``)."""
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
|
|
|
|
assert captured["override_image_overrides"] is None
|
|
|
|
|
|
def test_single_image_override_parses_and_forwards(tmp_path, monkeypatch):
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
"img-abc=10,15,30.5,25",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert captured["override_image_overrides"] == {
|
|
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.5, "h": 25.0},
|
|
}
|
|
|
|
|
|
def test_multiple_image_overrides_accumulate(tmp_path, monkeypatch):
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
"img-abc=10,15,30,25",
|
|
"--override-image",
|
|
"img-def=50,15,40,40",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert captured["override_image_overrides"] == {
|
|
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
|
|
"img-def": {"x": 50.0, "y": 15.0, "w": 40.0, "h": 40.0},
|
|
}
|
|
|
|
|
|
def test_image_override_strips_whitespace_in_image_id(tmp_path, monkeypatch):
|
|
"""``iid.strip()`` is intentional — match sibling --override-frame and
|
|
--override-zone-geometry leniency on surrounding whitespace."""
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
" img-pad =5,5,10,10",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert captured["override_image_overrides"] == {
|
|
"img-pad": {"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0},
|
|
}
|
|
|
|
|
|
# -- hard-error paths -----------------------------------------------------
|
|
|
|
|
|
def test_image_override_missing_equals_exits(tmp_path, monkeypatch, capsys):
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
"img-abc10,15,30,25",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert excinfo.value.code == 2
|
|
err = capsys.readouterr().err
|
|
assert "--override-image must be IMAGE_ID=X,Y,W,H" in err
|
|
|
|
|
|
def test_image_override_empty_image_id_exits(tmp_path, monkeypatch, capsys):
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
"=10,15,30,25",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert excinfo.value.code == 2
|
|
err = capsys.readouterr().err
|
|
assert "IMAGE_ID must be non-empty" in err
|
|
|
|
|
|
def test_image_override_whitespace_only_image_id_exits(
|
|
tmp_path, monkeypatch, capsys
|
|
):
|
|
"""``iid.strip()`` must collapse whitespace-only IDs into the empty-ID
|
|
error path (otherwise a spurious key would land in the mapping)."""
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
" =10,15,30,25",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert excinfo.value.code == 2
|
|
err = capsys.readouterr().err
|
|
assert "IMAGE_ID must be non-empty" in err
|
|
|
|
|
|
def test_image_override_duplicate_image_id_exits(
|
|
tmp_path, monkeypatch, capsys
|
|
):
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
"img-abc=10,15,30,25",
|
|
"--override-image",
|
|
"img-abc=20,25,30,35",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert excinfo.value.code == 2
|
|
err = capsys.readouterr().err
|
|
assert "duplicate IMAGE_ID 'img-abc'" in err
|
|
|
|
|
|
def test_image_override_wrong_float_count_exits(
|
|
tmp_path, monkeypatch, capsys
|
|
):
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
"img-abc=10,15,30",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert excinfo.value.code == 2
|
|
err = capsys.readouterr().err
|
|
assert "expects 4 floats X,Y,W,H" in err
|
|
|
|
|
|
def test_image_override_too_many_floats_exits(
|
|
tmp_path, monkeypatch, capsys
|
|
):
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
"img-abc=10,15,30,25,99",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert excinfo.value.code == 2
|
|
err = capsys.readouterr().err
|
|
assert "expects 4 floats X,Y,W,H" in err
|
|
|
|
|
|
def test_image_override_non_numeric_value_exits(
|
|
tmp_path, monkeypatch, capsys
|
|
):
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
"img-abc=10,abc,30,25",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert excinfo.value.code == 2
|
|
err = capsys.readouterr().err
|
|
assert "floats parse fail" in err
|
|
|
|
|
|
# -- isolation guard ------------------------------------------------------
|
|
|
|
|
|
def test_image_override_does_not_leak_into_sibling_axes(tmp_path, monkeypatch):
|
|
"""A populated image override must not perturb the other four axes."""
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-image",
|
|
"img-abc=10,15,30,25",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert captured["override_image_overrides"] == {
|
|
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
|
|
}
|
|
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
|
|
|
|
|
|
# -- IMP-45 (#74) u5 — slide-level CSS override CLI surface ----------------
|
|
#
|
|
# Six focused cases mirror the --override-image pattern above:
|
|
# 1. neither flag → kwarg None (fall-back to MDX frontmatter at u4)
|
|
# 2. --override-slide-css inline TEXT → kwarg passes verbatim
|
|
# 3. --slide-css-file PATH UTF-8 read → kwarg = file contents
|
|
# 4. both flags set → sys.exit(2) with mutual-exclusion stderr
|
|
# 5. --slide-css-file missing path → sys.exit(2) with not-found stderr
|
|
# 6. --slide-css-file non-UTF-8 bytes → sys.exit(2) with utf-8 stderr
|
|
|
|
|
|
def test_no_slide_css_override_forwards_none(tmp_path, monkeypatch):
|
|
"""Neither --override-slide-css nor --slide-css-file → kwarg = None."""
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
_exec_main_block(
|
|
captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch,
|
|
)
|
|
|
|
assert captured["override_slide_css"] is None
|
|
|
|
|
|
def test_inline_slide_css_override_forwards_verbatim(tmp_path, monkeypatch):
|
|
"""--override-slide-css TEXT → kwarg = TEXT (verbatim, no `<style>` wrap)."""
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
captured: dict[str, Any] = {}
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-slide-css",
|
|
".slide { background: red; }",
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert captured["override_slide_css"] == ".slide { background: red; }"
|
|
|
|
|
|
def test_slide_css_file_override_reads_utf8(tmp_path, monkeypatch):
|
|
"""--slide-css-file PATH → kwarg = UTF-8 decoded file contents."""
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
css_payload = ".slide-body { color: #1e293b; } /* 한글 주석 */\n"
|
|
css_path = tmp_path / "slide_override.css"
|
|
css_path.write_text(css_payload, encoding="utf-8")
|
|
captured: dict[str, Any] = {}
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--slide-css-file",
|
|
str(css_path),
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert captured["override_slide_css"] == css_payload
|
|
|
|
|
|
def test_slide_css_both_flags_set_exits(tmp_path, monkeypatch, capsys):
|
|
"""--override-slide-css + --slide-css-file → sys.exit(2)."""
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
css_path = tmp_path / "slide_override.css"
|
|
css_path.write_text(".slide { color: red; }\n", encoding="utf-8")
|
|
captured: dict[str, Any] = {}
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--override-slide-css",
|
|
".slide { color: blue; }",
|
|
"--slide-css-file",
|
|
str(css_path),
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert excinfo.value.code == 2
|
|
err = capsys.readouterr().err
|
|
assert "--override-slide-css and --slide-css-file are mutually exclusive" in err
|
|
|
|
|
|
def test_slide_css_file_missing_path_exits(tmp_path, monkeypatch, capsys):
|
|
"""--slide-css-file with non-existent PATH → sys.exit(2)."""
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
missing_path = tmp_path / "does_not_exist.css"
|
|
captured: dict[str, Any] = {}
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--slide-css-file",
|
|
str(missing_path),
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert excinfo.value.code == 2
|
|
err = capsys.readouterr().err
|
|
assert "--slide-css-file path does not exist" in err
|
|
assert str(missing_path) in err
|
|
|
|
|
|
def test_slide_css_file_non_utf8_exits(tmp_path, monkeypatch, capsys):
|
|
"""--slide-css-file with non-UTF-8 bytes → sys.exit(2)."""
|
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
|
bad_path = tmp_path / "latin1.css"
|
|
# 0xff is a stand-alone invalid UTF-8 start byte; strict decode raises.
|
|
bad_path.write_bytes(b".slide { color: \xff; }\n")
|
|
captured: dict[str, Any] = {}
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_exec_main_block(
|
|
captured,
|
|
[
|
|
"src.phase_z2_pipeline",
|
|
"03.mdx",
|
|
"--slide-css-file",
|
|
str(bad_path),
|
|
],
|
|
monkeypatch,
|
|
)
|
|
|
|
assert excinfo.value.code == 2
|
|
err = capsys.readouterr().err
|
|
assert "--slide-css-file must be UTF-8 encoded" in err
|