feat(#74): IMP-45 u1~u8 slide-level CSS override (frontmatter slide_overrides.css + --override-slide-css/--slide-css-file + idempotent Step 13 injector)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 22s
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>
This commit is contained in:
100
tests/test_slide_css_injector.py
Normal file
100
tests/test_slide_css_injector.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""IMP-45 (#74) u3 — tests for :mod:`src.slide_css_injector`.
|
||||
|
||||
Mirrors the inject_image_overrides_style test pattern in
|
||||
``tests/test_image_id_stamper.py`` (lines 306-377) for the new
|
||||
``slide_css`` axis. Eight cases :
|
||||
|
||||
1. empty css returns html unchanged
|
||||
2. None css returns html unchanged (mirror of empty-css guard for the
|
||||
typed ``str | None`` signature)
|
||||
3. inserts before </head> (precedence path 1)
|
||||
4. case-insensitive head close (precedence path 1, uppercase variant)
|
||||
5. body-open fallback when no </head> (precedence path 2)
|
||||
6. document-start fallback when neither head nor body present (path 3)
|
||||
7. idempotent on second call with identical css
|
||||
8. replaces existing block when re-called with different css (latest-wins)
|
||||
|
||||
Plus one additional guard :
|
||||
|
||||
9. marker comments wrap the injected <style> block (open precedes close,
|
||||
<style> sits between them)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.slide_css_injector import inject_slide_css
|
||||
|
||||
|
||||
_OPEN_MARKER = "<!--IMP45-SLIDE-CSS:OPEN-->"
|
||||
_CLOSE_MARKER = "<!--IMP45-SLIDE-CSS:CLOSE-->"
|
||||
|
||||
|
||||
def test_inject_slide_css_empty_string_returns_html_unchanged():
|
||||
html = "<html><head></head><body>x</body></html>"
|
||||
assert inject_slide_css(html, "") == html
|
||||
|
||||
|
||||
def test_inject_slide_css_none_returns_html_unchanged():
|
||||
html = "<html><head></head><body>x</body></html>"
|
||||
assert inject_slide_css(html, None) == html
|
||||
|
||||
|
||||
def test_inject_slide_css_inserts_before_head_close():
|
||||
html = "<html><head><title>t</title></head><body>x</body></html>"
|
||||
out = inject_slide_css(html, ".x { color: red; }")
|
||||
assert "<style>" in out
|
||||
head_idx = out.lower().index("</head>")
|
||||
body_idx = out.lower().index("<body")
|
||||
style_idx = out.index("<style>")
|
||||
assert style_idx < head_idx < body_idx
|
||||
|
||||
|
||||
def test_inject_slide_css_case_insensitive_head_close():
|
||||
html = "<HTML><HEAD></HEAD><BODY>x</BODY></HTML>"
|
||||
out = inject_slide_css(html, ".x { color: red; }")
|
||||
assert "<style>" in out
|
||||
assert out.index("<style>") < out.upper().index("</HEAD>")
|
||||
|
||||
|
||||
def test_inject_slide_css_falls_back_to_body_open_when_no_head():
|
||||
html = "<body>x</body>"
|
||||
out = inject_slide_css(html, ".x { color: red; }")
|
||||
assert "<style>" in out
|
||||
body_open_end = out.index(">", out.index("<body")) + 1
|
||||
assert out[body_open_end:].lstrip().startswith(_OPEN_MARKER)
|
||||
|
||||
|
||||
def test_inject_slide_css_falls_back_to_document_start_when_no_head_or_body():
|
||||
html = "<div>fragment</div>"
|
||||
out = inject_slide_css(html, ".x { color: red; }")
|
||||
assert out.startswith(_OPEN_MARKER)
|
||||
assert "<style>" in out
|
||||
assert out.rstrip().endswith("</div>")
|
||||
|
||||
|
||||
def test_inject_slide_css_is_idempotent_on_second_call():
|
||||
html = "<html><head></head><body>x</body></html>"
|
||||
css = ".x { color: red; }"
|
||||
once = inject_slide_css(html, css)
|
||||
twice = inject_slide_css(once, css)
|
||||
assert twice == once
|
||||
assert once.count(_OPEN_MARKER) == 1
|
||||
assert twice.count(_OPEN_MARKER) == 1
|
||||
|
||||
|
||||
def test_inject_slide_css_replaces_existing_block_with_new_css():
|
||||
html = "<html><head></head><body>x</body></html>"
|
||||
first = inject_slide_css(html, ".old { color: red; }")
|
||||
second = inject_slide_css(first, ".new { color: blue; }")
|
||||
assert ".old" not in second
|
||||
assert ".new" in second
|
||||
assert second.count(_OPEN_MARKER) == 1
|
||||
|
||||
|
||||
def test_inject_slide_css_wraps_block_with_marker_comments():
|
||||
html = "<html><head></head><body>x</body></html>"
|
||||
out = inject_slide_css(html, ".x { color: red; }")
|
||||
assert _OPEN_MARKER in out
|
||||
assert _CLOSE_MARKER in out
|
||||
s = out.index(_OPEN_MARKER)
|
||||
e = out.index(_CLOSE_MARKER)
|
||||
assert s < out.index("<style>") < out.index("</style>") < e
|
||||
Reference in New Issue
Block a user