From 90629318636aba8bc979c76c2a3e6aea47ca42a8 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Mon, 25 May 2026 03:26:03 +0900 Subject: [PATCH] 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 / , idempotent re-injection, > > 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, \n" + f"{_IMP45_STYLE_MARKER_CLOSE}" + ) + if _IMP45_STYLE_MARKER_OPEN in html: + return _IMP45_STYLE_BLOCK_RE.sub(lambda _m: block, html, count=1) + head_close = _HEAD_CLOSE_RE.search(html) + if head_close is not None: + idx = head_close.start() + return html[:idx] + block + "\n" + html[idx:] + body_open = _BODY_OPEN_RE.search(html) + if body_open is not None: + idx = body_open.end() + return html[:idx] + "\n" + block + html[idx:] + return block + "\n" + html diff --git a/src/user_overrides_io.py b/src/user_overrides_io.py index df8ca6b..2795fa8 100644 --- a/src/user_overrides_io.py +++ b/src/user_overrides_io.py @@ -5,14 +5,16 @@ auto-restores user choices without re-clicking. Source of truth = MDX-keyed file (stem of the MDX path), NOT ``data/runs//`` which mints a fresh run_id per ``/api/run`` invocation. -Schema (5 axes; stable order; IMP-51 #79 u1 added ``image_overrides``): +Schema (6 axes; stable order; IMP-51 #79 u1 added ``image_overrides``; +IMP-45 #74 u1 added ``slide_css``): { "layout": , "zone_geometries": {: {"x": float, "y": float, "w": float, "h": float}}, "zone_sections": {: [, ...]}, "frames": {: }, - "image_overrides": {: {"x": float, "y": float, "w": float, "h": float}} + "image_overrides": {: {"x": float, "y": float, "w": float, "h": float}}, + "slide_css": } ``image_id`` is the stable identifier emitted by the user-content image @@ -53,16 +55,17 @@ from typing import Any, Optional _PKG_ROOT = Path(__file__).resolve().parent.parent DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides" -# The five in-scope axes (IMP-51 #79 u1 added ``image_overrides``). Any -# other top-level key in the file is preserved but ignored by callers — -# keeps the file forward-compatible with future axes (e.g., zone_sizes) -# without a schema bump here. +# The six in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45 +# #74 u1 added ``slide_css``). Any other top-level key in the file is +# preserved but ignored by callers — keeps the file forward-compatible +# with future axes (e.g., zone_sizes) without a schema bump here. KNOWN_AXES: tuple[str, ...] = ( "layout", "zone_geometries", "zone_sections", "frames", "image_overrides", + "slide_css", ) # Key validation — MDX stem must be safe for filesystem use. Allow diff --git a/tests/regression/fixtures/89a_pre_baseline_sha.json b/tests/regression/fixtures/89a_pre_baseline_sha.json index b7473f9..4aaf4cb 100644 --- a/tests/regression/fixtures/89a_pre_baseline_sha.json +++ b/tests/regression/fixtures/89a_pre_baseline_sha.json @@ -40,8 +40,8 @@ "04.mdx": { "mdx_file": "04.mdx", "run_id": "89a_baseline_04", - "final_html_size_bytes": 27707, - "sha256": "2bce45041cdcca6518cd92586c1be9e051a5c98f5a0ad61fdde02604618a1d80", + "final_html_size_bytes": 28042, + "sha256": "ddb6bf2f8d76ca1f56588a50dd4af5aeb5f45e0a83d5241b83b5932d0c66d41c", "pipeline_exit_code": null }, "05.mdx": { diff --git a/tests/test_mdx_normalizer.py b/tests/test_mdx_normalizer.py new file mode 100644 index 0000000..ed1529d --- /dev/null +++ b/tests/test_mdx_normalizer.py @@ -0,0 +1,107 @@ +"""IMP-45 (#74) u2 — frontmatter ``slide_overrides`` surfacing. + +Covers ``src.mdx_normalizer.normalize_mdx_content`` and the helper +``_extract_slide_overrides``. The Stage 2 plan enumerates four cases: + +1. Present — nested ``slide_overrides.css`` survives normalization verbatim. +2. Absent — return dict carries an empty ``slide_overrides`` mapping. +3. Non-string ``css`` — dropped (fail-closed against typo'd YAML shapes). +4. Title-only frontmatter — no ``slide_overrides`` key in metadata → ``{}``. + +Scope-lock: this unit only adds the new key to the return dict. The four +pre-existing return keys (``clean_text``/``title``/``images``/``popups``/ +``tables``/``sections``) are asserted unchanged at the structural level. +""" +from __future__ import annotations + +from src.mdx_normalizer import _extract_slide_overrides, normalize_mdx_content + + +_CSS_BLOCK = "" + + +def _mdx_with_frontmatter(fm_body: str, body: str = "# 제목\n\n본문 한 줄.\n") -> str: + return f"---\n{fm_body}---\n{body}" + + +# -- case 1: present (nested css string survives verbatim) ------------------ + + +def test_normalize_surfaces_nested_slide_overrides_css(): + raw = _mdx_with_frontmatter( + "title: 04 sample\n" + "slide_overrides:\n" + f" css: \"{_CSS_BLOCK}\"\n" + ) + + result = normalize_mdx_content(raw) + + assert result["slide_overrides"] == {"css": _CSS_BLOCK} + # Other axes unaffected by the new key. + assert result["title"] == "04 sample" + assert "본문" in result["clean_text"] + + +# -- case 2: absent (no slide_overrides key in frontmatter) ----------------- + + +def test_normalize_returns_empty_slide_overrides_when_key_absent(): + raw = _mdx_with_frontmatter("title: 03 sample\n") + + result = normalize_mdx_content(raw) + + assert result["slide_overrides"] == {} + # Confirm key is always present (callers can rely on .get without default). + assert "slide_overrides" in result + + +# -- case 3: non-string css (fail-closed drop) ------------------------------ + + +def test_normalize_drops_non_string_css_under_slide_overrides(): + # YAML list under .css should be dropped; sibling unknown keys survive + # so the future generalization path (e.g., slide_overrides.js) stays + # forward-compatible per the Stage 2 plan. + raw = _mdx_with_frontmatter( + "title: typo case\n" + "slide_overrides:\n" + " css:\n" + " - .f29b__col_right { width: 320px; }\n" + " note: experimental sibling\n" + ) + + result = normalize_mdx_content(raw) + + assert "css" not in result["slide_overrides"] + assert result["slide_overrides"].get("note") == "experimental sibling" + + +# -- case 4: title-only frontmatter (no slide_overrides at all) ------------- + + +def test_normalize_title_only_frontmatter_yields_empty_slide_overrides(): + raw = _mdx_with_frontmatter("title: title only\n") + + result = normalize_mdx_content(raw) + + assert result["title"] == "title only" + assert result["slide_overrides"] == {} + + +# -- direct helper coverage (defensive against future return-shape drift) --- + + +def test_extract_slide_overrides_non_mapping_returns_empty_dict(): + # Frontmatter parsers can yield odd types if the user writes + # ``slide_overrides: 42`` or ``slide_overrides: ".x{}"``. The helper + # must coerce to ``{}`` rather than raise. + for bad in (None, 42, "literal string", ["css"]): + assert _extract_slide_overrides({"slide_overrides": bad}) == {} + + +def test_extract_slide_overrides_passes_through_unknown_siblings(): + payload = {"slide_overrides": {"css": ".a{}", "js": "console.log(1)"}} + assert _extract_slide_overrides(payload) == { + "css": ".a{}", + "js": "console.log(1)", + } diff --git a/tests/test_phase_z2_cli_overrides.py b/tests/test_phase_z2_cli_overrides.py index 0653005..138d924 100644 --- a/tests/test_phase_z2_cli_overrides.py +++ b/tests/test_phase_z2_cli_overrides.py @@ -56,6 +56,8 @@ def _exec_main_block( 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 @@ -64,6 +66,8 @@ def _exec_main_block( 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) @@ -346,3 +350,136 @@ def test_image_override_does_not_leak_into_sibling_axes(tmp_path, monkeypatch): 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 `") < e diff --git a/tests/test_user_overrides_io.py b/tests/test_user_overrides_io.py index 6e30adc..c82edea 100644 --- a/tests/test_user_overrides_io.py +++ b/tests/test_user_overrides_io.py @@ -1,9 +1,10 @@ """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-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`` (5 KNOWN_AXES + foreign top-level keys). +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 / @@ -119,13 +120,20 @@ def _full_payload() -> dict: "image_overrides": { "img-1": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 25.0}, }, + "slide_css": "", } def test_known_axes_includes_image_overrides(): - """IMP-51 #79 u1 — ``image_overrides`` is a known axis (5 total).""" + """IMP-51 #79 u1 — ``image_overrides`` is a known axis (now 6 total).""" assert "image_overrides" in KNOWN_AXES - assert len(KNOWN_AXES) == 5 + 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): @@ -152,6 +160,7 @@ def test_save_partial_payload_preserves_other_axes(tmp_path): 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): @@ -173,6 +182,7 @@ def test_save_partial_image_overrides_preserves_other_axes(tmp_path): 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): @@ -231,9 +241,17 @@ def test_save_writes_pretty_sorted_json_for_diffability(tmp_path): 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_zg < pos_zs + 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):