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:
@@ -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": {
|
||||
|
||||
107
tests/test_mdx_normalizer.py
Normal file
107
tests/test_mdx_normalizer.py
Normal file
@@ -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 = "<style>.f29b__col_right { width: 320px; }</style>"
|
||||
|
||||
|
||||
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)",
|
||||
}
|
||||
@@ -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 `<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
|
||||
|
||||
@@ -62,6 +62,7 @@ def _exec_main_block(
|
||||
override_zone_geometries=None,
|
||||
override_section_assignments=None,
|
||||
override_image_overrides=None,
|
||||
override_slide_css=None,
|
||||
reuse_from=None,
|
||||
):
|
||||
captured["called"] = True
|
||||
@@ -72,6 +73,7 @@ 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)
|
||||
|
||||
101
tests/test_phase_z2_slide_css_smoke.py
Normal file
101
tests/test_phase_z2_slide_css_smoke.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""IMP-45 (#74) u6 — subprocess smoke for the slide-level CSS override axis.
|
||||
|
||||
End-to-end guard that the ``slide_overrides.css`` frontmatter axis added
|
||||
in u2 propagates through the pipeline (u4) and lands in ``final.html``
|
||||
via :func:`src.slide_css_injector.inject_slide_css` (u3).
|
||||
|
||||
The fixture is the new ``slide_overrides.css`` frontmatter block in
|
||||
``samples/mdx_batch/04.mdx`` (u6 migration of the legacy frontend-only
|
||||
``MDX04_DEFAULT_OVERRIDE_CSS`` constant). The pipeline is spawned via
|
||||
``python -m src.phase_z2_pipeline 04.mdx <run_id>`` so the assertion
|
||||
applies to the on-disk artifact CI / CLI / regression all observe, not
|
||||
to a live iframe view.
|
||||
|
||||
The subprocess returncode is intentionally NOT asserted: mdx04 has known
|
||||
downstream issues (see ``test_pipeline_smoke_imp85.py`` —
|
||||
``test_mdx04_no_longer_emits_imp85_crash_signature``) that are tracked
|
||||
on a separate axis. Step 13 runs before the downstream failure surface,
|
||||
so ``final.html`` is written with the injected slide CSS marker
|
||||
regardless. The test asserts ``final.html`` exists and contains both
|
||||
the IMP-45 sentinel marker and a distinctive CSS substring from the
|
||||
migrated frontmatter block.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SAMPLES_DIR = REPO_ROOT / "samples" / "mdx_batch"
|
||||
RUNS_DIR = REPO_ROOT / "data" / "runs"
|
||||
|
||||
# IMP-45 (#74) u3 marker sentinel emitted by
|
||||
# :func:`src.slide_css_injector.inject_slide_css` around the injected
|
||||
# ``<style>`` block. Match the ``_IMP45_STYLE_MARKER_OPEN`` constant in
|
||||
# ``src/slide_css_injector.py`` byte-for-byte.
|
||||
IMP45_OPEN_MARKER = "<!--IMP45-SLIDE-CSS:OPEN-->"
|
||||
IMP45_CLOSE_MARKER = "<!--IMP45-SLIDE-CSS:CLOSE-->"
|
||||
|
||||
# Distinctive substring from the migrated frontmatter block in
|
||||
# ``samples/mdx_batch/04.mdx``. ``f29b__cell:nth-child(n+3)`` is unique
|
||||
# to the MDX04 slide-level override CSS and does not appear elsewhere in
|
||||
# the slide_base / partial templates, so its presence in ``final.html``
|
||||
# is sufficient evidence that the frontmatter axis fed the injector.
|
||||
MDX04_DISTINCTIVE_CSS_SUBSTRING = ".f29b__cell:nth-child(n+3)"
|
||||
|
||||
|
||||
def _run_pipeline(mdx_name: str, run_id: str, timeout: int = 240) -> subprocess.CompletedProcess:
|
||||
"""Spawn ``python -m src.phase_z2_pipeline <mdx> <run_id>``."""
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"src.phase_z2_pipeline",
|
||||
str(SAMPLES_DIR / mdx_name),
|
||||
run_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
|
||||
|
||||
def _unique_run_id(prefix: str) -> str:
|
||||
return f"{prefix}_imp45_slide_css_smoke_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def test_mdx04_slide_overrides_css_lands_in_final_html() -> None:
|
||||
"""mdx04 ``slide_overrides.css`` frontmatter must reach ``final.html``.
|
||||
|
||||
Contract pins both the IMP-45 marker sentinel and a distinctive CSS
|
||||
substring from the migrated frontmatter block so a regression in
|
||||
either the frontmatter extractor (u2), the kwarg forwarding (u4),
|
||||
or the injector (u3) is caught by this single smoke.
|
||||
"""
|
||||
run_id = _unique_run_id("mdx04")
|
||||
cp = _run_pipeline("04.mdx", run_id)
|
||||
|
||||
final_html_path = RUNS_DIR / run_id / "phase_z2" / "final.html"
|
||||
assert final_html_path.is_file(), (
|
||||
f"final.html not found at {final_html_path}\n"
|
||||
f"--- stderr tail ---\n{cp.stderr[-1500:]}\n"
|
||||
f"--- stdout tail ---\n{cp.stdout[-1500:]}"
|
||||
)
|
||||
html = final_html_path.read_text(encoding="utf-8")
|
||||
|
||||
assert IMP45_OPEN_MARKER in html, (
|
||||
f"IMP-45 open marker missing from mdx04 final.html ({final_html_path}).\n"
|
||||
f"slide_overrides.css frontmatter axis did not reach the injector."
|
||||
)
|
||||
assert IMP45_CLOSE_MARKER in html, (
|
||||
f"IMP-45 close marker missing from mdx04 final.html ({final_html_path}).\n"
|
||||
f"Marker wrap appears unbalanced — check inject_slide_css() output."
|
||||
)
|
||||
assert MDX04_DISTINCTIVE_CSS_SUBSTRING in html, (
|
||||
f"Migrated frontmatter CSS substring "
|
||||
f"{MDX04_DISTINCTIVE_CSS_SUBSTRING!r} missing from mdx04 final.html "
|
||||
f"({final_html_path}). The slide_overrides.css block did not propagate."
|
||||
)
|
||||
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
|
||||
@@ -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": "<style>.slide .frame-process-product .label { font-size: 14px; }</style>",
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user