`. The render pipeline now produces a dual-PPTX
+> output (MARP image-of-slide + python-pptx structured) and base64-inlines
+> all images into the committed HTML.
\ No newline at end of file
diff --git a/tests/test_pptx_generator.py b/tests/test_pptx_generator.py
new file mode 100644
index 0000000..c1fc776
--- /dev/null
+++ b/tests/test_pptx_generator.py
@@ -0,0 +1,145 @@
+"""REQ-274: tests for `scripts/render_pptx.py` — the structured, editable
+python-pptx deck produced alongside the MARP-rendered PPTX.
+
+The python-pptx deck is a native OOXML presentation: real text boxes,
+native tables, embedded pictures, and italic benefit callouts. These
+tests are offline (no AWS, no network) and assert the structural
+properties of the committed `*-python.pptx` artifact.
+"""
+from pathlib import Path
+from typing import cast
+
+import pytest
+from pptx import Presentation
+from pptx.enum.shapes import MSO_SHAPE_TYPE
+from pptx.presentation import Presentation as PresentationT
+from pptx.shapes.autoshape import Shape
+
+ROOT = Path(__file__).resolve().parent.parent
+PRESENTATIONS = ROOT / "docs" / "presentations"
+MARPT_DECK = PRESENTATIONS / "nova-autonomous-cloud-delivery-marp.md"
+PYTHON_PPTX = PRESENTATIONS / "nova-autonomous-cloud-delivery-python.pptx"
+
+# Title slide + 20 main slides + 1 appendix slide.
+EXPECTED_SLIDE_COUNT = 22
+
+
+@pytest.fixture(scope="module")
+def prs() -> PresentationT:
+ """Load the committed python-pptx deck once for the whole module."""
+ assert PYTHON_PPTX.is_file(), f"python-pptx PPTX not found: {PYTHON_PPTX}"
+ return Presentation(str(PYTHON_PPTX))
+
+
+def _slide_titles(prs: PresentationT) -> list[str]:
+ """Return the first non-empty text-frame line per slide (the title)."""
+ titles: list[str] = []
+ for slide in prs.slides:
+ for shape in slide.shapes:
+ if not shape.has_text_frame:
+ continue
+ text = cast(Shape, shape).text_frame.text.strip()
+ if not text:
+ continue
+ # The title is the first non-empty line of the first non-empty
+ # text frame we find on the slide.
+ first_line = text.split("\n")[0].strip()
+ if first_line:
+ titles.append(first_line)
+ break
+ else:
+ titles.append("")
+ return titles
+
+
+def test_slide_count(prs: PresentationT):
+ """REQ-269/274: the python-pptx deck has 22 slides
+ (title + 20 main + 1 appendix)."""
+ assert len(prs.slides) == EXPECTED_SLIDE_COUNT, \
+ f"expected {EXPECTED_SLIDE_COUNT} slides, got {len(prs.slides)}"
+
+
+def test_title_slide_colors(prs: PresentationT):
+ """REQ-269: the title slide (slide 0) has a solid-filled background
+ shape carrying the S&P Red (#D6002A) brand color (the title slide is
+ a red-bar-on-black layout)."""
+ title_slide = prs.slides[0]
+ red_found = False
+ black_found = False
+ for shape in title_slide.shapes:
+ fill = getattr(shape, "fill", None)
+ if fill is None:
+ continue
+ try:
+ if fill.type != 1: # MSO_FILL.SOLID
+ continue
+ except Exception:
+ continue
+ rgb = str(fill.fore_color.rgb).upper()
+ if rgb == "D6002A":
+ red_found = True
+ if rgb == "1B1B1B":
+ black_found = True
+ assert red_found, \
+ "title slide has no solid-fill shape with S&P Red (#D6002A)"
+
+
+def test_expected_slide_titles(prs: PresentationT):
+ """REQ-269/274: spot-check that key slide titles match the markdown
+ deck (The Problem, Nova's Vision, Recap + Ask)."""
+ titles = _slide_titles(prs)
+ # Build a flat lowercase concatenation for substring checks.
+ flat = " | ".join(titles).lower()
+ expected = [
+ "the problem",
+ "nova's vision",
+ "recap + ask",
+ ]
+ missing = [t for t in expected if t not in flat]
+ assert not missing, \
+ f"missing expected slide titles in python-pptx deck: {missing}; " \
+ f"found titles: {titles}"
+
+
+def test_table_rendering(prs: PresentationT):
+ """REQ-269: a slide with a table (the RACI slide) has a native PPTX
+ table shape (GraphicFrame with has_table=True)."""
+ table_slides = []
+ for idx, slide in enumerate(prs.slides):
+ for shape in slide.shapes:
+ if shape.shape_type == MSO_SHAPE_TYPE.TABLE or getattr(
+ shape, "has_table", False
+ ):
+ table_slides.append(idx)
+ break
+ assert table_slides, \
+ "no slide in the python-pptx deck has a native PPTX table shape"
+
+
+def test_image_embedding(prs: PresentationT):
+ """REQ-269: a slide with an image (the Platform Pipeline slide)
+ has a native PPTX picture shape."""
+ picture_slides = []
+ for idx, slide in enumerate(prs.slides):
+ for shape in slide.shapes:
+ if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
+ picture_slides.append(idx)
+ break
+ assert picture_slides, \
+ "no slide in the python-pptx deck has a native PPTX picture shape"
+
+
+def test_benefit_callout_present(prs: PresentationT):
+ """REQ-269/274: at least one slide has an italic text run (the
+ benefit callout, rendered as italic body text by render_pptx.py)."""
+ italic_runs = 0
+ for slide in prs.slides:
+ for shape in slide.shapes:
+ if not shape.has_text_frame:
+ continue
+ for paragraph in cast(Shape, shape).text_frame.paragraphs:
+ for run in paragraph.runs:
+ if run.font.italic and run.text.strip():
+ italic_runs += 1
+ assert italic_runs > 0, \
+ "no italic text runs found in the python-pptx deck (benefit callout)"
\ No newline at end of file
diff --git a/tests/test_slides_pipeline.py b/tests/test_slides_pipeline.py
index cda8202..34fdc8d 100644
--- a/tests/test_slides_pipeline.py
+++ b/tests/test_slides_pipeline.py
@@ -1,21 +1,28 @@
-"""REQ-239..243 (v1.20) + REQ-245,251,252 (v1.21): S&P theme + slide render
-pipeline + deck-refinement tests.
+"""REQ-239..243 (v1.20) + REQ-245,251,252 (v1.21/22) + REQ-273..275 (v1.23):
+S&P theme + slide render pipeline + deck-refinement tests.
v1.20 validates:
- - The Marp deck frontmatter references nova-sp-theme.css
- - The CSS file contains the S&P colors (#D6002A, #1B1B1B)
- The mermaid theme JSON contains the S&P colors
- Every .mmd has a corresponding .png
- The render_slides.sh script exists and is executable
- The CI workflow file exists
-v1.21 adds (REQ-245,251,252):
+v1.21/22 adds (REQ-245,251,252):
- Deck renamed to nova-autonomous-cloud-delivery*
- No maturity badges in the Marp deck
- No version in the Marp footer/title slide
- - 20 main + 1 appendix slides (v1.22 split slides 3+8 to relieve overflow)
+ - 20 main + 1 appendix slides
- No D-###/REQ-###/internal .py paths in audience-facing slides
- - Title is "Nova — The Autonomous Cloud Delivery Platform"
+
+v1.23 (REQ-273,274,275) — single-document + dual-PPTX + image-inlining pipeline:
+ - The plain `.md` is gone; `*-marp.md` is the sole source of truth.
+ - Marp deck uses `theme: default` + an inline `style:` block (S&P colors).
+ - `nova-sp-theme.css` is RETAINED AS REFERENCE (not loaded at render).
+ - HTML has base64-inlined images (zero `src="assets/` references).
+ - A second PPTX (`*-python.pptx`) is produced by `scripts/render_pptx.py`.
+ - Speaker notes live as `` HTML comments.
+ - Benefit callouts use `
` (no `**Benefit:**` prefixes).
+ - The purged term "penetrate" is absent repo-wide.
"""
import re
from pathlib import Path
@@ -29,50 +36,216 @@ THEME_CSS = ASSETS / "nova-sp-theme.css"
THEME_JSON = ASSETS / "mmd" / "sp-theme.json"
MARP_DECK = PRESENTATIONS / "nova-autonomous-cloud-delivery-marp.md"
SOURCE_MD = PRESENTATIONS / "nova-autonomous-cloud-delivery.md"
+HTML = PRESENTATIONS / "nova-autonomous-cloud-delivery.html"
+PYTHON_PPTX = PRESENTATIONS / "nova-autonomous-cloud-delivery-python.pptx"
+MARP_PPTX = PRESENTATIONS / "nova-autonomous-cloud-delivery.pptx"
RENDER_SCRIPT = ROOT / "scripts" / "render_slides.sh"
SLIDES_WORKFLOW = ROOT / ".github" / "workflows" / "slides.yml"
+def _frontmatter(text: str) -> str:
+ """Return the Marp frontmatter block (between the first two `---`)."""
+ fm_match = re.match(r'^---\n(.*?)\n---', text, re.DOTALL)
+ assert fm_match, "Marp frontmatter not found"
+ return fm_match.group(1)
+
+
+# --- S&P theme reference + mermaid theme -------------------------------
+
def test_sp_theme_css_exists():
- """REQ-239: nova-sp-theme.css exists."""
+ """REQ-239: nova-sp-theme.css exists (retained as a reference)."""
assert THEME_CSS.is_file(), f"theme CSS not found: {THEME_CSS}"
+def test_nova_sp_theme_css_retained_as_reference():
+ """REQ-274: nova-sp-theme.css is retained as a REFERENCE only and is
+ explicitly NOT loaded at render time (the live styling is the inline
+ `style:` block in the -marp.md frontmatter)."""
+ assert THEME_CSS.is_file(), f"theme CSS not found: {THEME_CSS}"
+ css = THEME_CSS.read_text()
+ assert "not loaded at render" in css.lower(), \
+ "nova-sp-theme.css does not document itself as 'not loaded at render'"
+
+
def test_sp_theme_css_has_snp_colors():
- """REQ-239: CSS contains S&P Red and Black."""
+ """REQ-239: the reference CSS still carries S&P Red and Black."""
css = THEME_CSS.read_text()
assert "#D6002A" in css, "S&P Red (#D6002A) missing from theme CSS"
assert "#1B1B1B" in css, "S&P Black (#1B1B1B) missing from theme CSS"
def test_sp_theme_json_has_snp_colors():
- """The mermaid theme JSON also has S&P colors."""
+ """The mermaid theme JSON has S&P colors (mermaid PNGs are S&P-themed)."""
json_text = THEME_JSON.read_text()
assert "#D6002A" in json_text, "S&P Red missing from mermaid theme"
assert "#1B1B1B" in json_text, "S&P Black missing from mermaid theme"
-def test_marp_deck_uses_sp_theme():
- """REQ-239: Marp deck frontmatter references nova-sp-theme.css."""
+# --- Marp deck: theme + inline style ----------------------------------
+
+def test_marp_deck_uses_default_theme():
+ """REQ-274: the Marp deck frontmatter uses `theme: default` (not the
+ retired `theme: nova-sp`). S&P styling is delivered by the inline
+ `style:` block, not the standalone CSS."""
+ frontmatter = _frontmatter(MARP_DECK.read_text())
+ assert re.search(r"^theme:\s*default\s*$", frontmatter, re.MULTILINE), \
+ "Marp deck does not set `theme: default` in the frontmatter"
+ assert "nova-sp" not in frontmatter, \
+ "Marp deck still references the retired `nova-sp` theme"
+
+
+def test_marp_deck_has_sp_inline_style():
+ """REQ-274: the inline `style:` block carries the S&P properties
+ (#D6002A, #1B1B1B, and the `section.title` rule)."""
+ frontmatter = _frontmatter(MARP_DECK.read_text())
+ assert "style:" in frontmatter, "frontmatter has no inline `style:` block"
+ # The inline style block extends past the frontmatter close in Marp
+ # (the `style:` value is a multi-line YAML literal). Read the whole
+ # deck so we capture the full style block.
+ deck = MARP_DECK.read_text()
+ assert "#D6002A" in deck, "inline style: block missing #D6002A"
+ assert "#1B1B1B" in deck, "inline style: block missing #1B1B1B"
+ assert "section.title" in deck, \
+ "inline style: block missing the `section.title` rule"
+
+
+def test_marp_deck_no_badges():
+ """REQ-252: no maturity badges in the Marp deck."""
text = MARP_DECK.read_text()
- # The frontmatter is between the first two ---
- fm_match = re.match(r'^---\n(.*?)\n---', text, re.DOTALL)
- assert fm_match, "Marp frontmatter not found"
- frontmatter = fm_match.group(1)
- assert "nova-sp" in frontmatter, \
- "Marp deck does not reference nova-sp theme"
+ assert "badge" not in text, "Marp deck still contains badge spans"
-def test_marp_deck_not_using_default_theme():
- """The Marp deck must not use 'theme: default'."""
+def test_marp_deck_no_version_in_footer():
+ """REQ-251: no version (v1.x) in the Marp frontmatter footer/header."""
+ frontmatter = _frontmatter(MARP_DECK.read_text())
+ assert not re.search(r"v1\.\d+", frontmatter), \
+ f"Marp frontmatter still contains a version: {frontmatter}"
+ assert "Act %" not in frontmatter, \
+ "Marp frontmatter still contains 'Act %{page}' artifact"
+
+
+def test_marp_deck_title_slide_no_version_subtitle():
+ """REQ-251: the title slide does not carry a version subtitle."""
text = MARP_DECK.read_text()
- fm_match = re.match(r'^---\n(.*?)\n---', text, re.DOTALL)
- assert fm_match, "Marp frontmatter not found"
- frontmatter = fm_match.group(1)
- assert "theme: default" not in frontmatter, \
- "Marp deck still uses 'theme: default' — should use nova-sp-theme.css"
+ after_fm = text.split("---\n", 2)[2] if text.startswith("---") else text
+ first_slide = after_fm.split("\n---\n")[0]
+ assert "v1.18" not in first_slide, \
+ "Title slide still contains 'v1.18' subtitle"
+ assert "Citizen Developer & Production-Grade Guidance" not in first_slide, \
+ "Title slide still contains the old version subtitle"
+def test_marp_deck_title_is_autonomous_cloud_delivery():
+ """REQ-245: the deck title is 'Nova — The Autonomous Cloud Delivery Platform'."""
+ text = MARP_DECK.read_text()
+ assert "Autonomous Cloud Delivery Platform" in text, \
+ "Deck title is not 'Autonomous Cloud Delivery Platform'"
+ assert "No-Humans Infrastructure Platform" not in text, \
+ "Deck still carries the old 'No-Humans Infrastructure Platform' title"
+
+
+def test_marp_deck_slide_count():
+ """REQ-245/261: 20 main slides + 1 appendix = 21 slide sections
+ (22 rendered sections incl. the H1 title slide)."""
+ text = MARP_DECK.read_text()
+ main_slides = re.findall(r"^## Slide ", text, re.MULTILINE)
+ appendix_slides = re.findall(r"^## Appendix ", text, re.MULTILINE)
+ assert len(main_slides) == 20, \
+ f"expected 20 main slides, found {len(main_slides)}"
+ assert len(appendix_slides) == 1, \
+ f"expected 1 appendix slide, found {len(appendix_slides)}"
+
+
+def test_marp_deck_no_internal_citations():
+ """REQ-252: no D-### decision IDs, REQ-### requirement IDs, or internal
+ .py file paths in the audience-facing Marp deck SLIDE BODIES. Internal
+ provenance is allowed inside `` HTML comments (speaker
+ notes / talking points), which Marp excludes from the rendered slide."""
+ text = MARP_DECK.read_text()
+ # Strip HTML comments (speaker notes + talking points) before checking.
+ body = re.sub(r"", "", text, flags=re.DOTALL)
+ assert not re.search(r"\bD-\d{3}\b", body), \
+ "Marp deck slide body contains D-### decision IDs"
+ assert not re.search(r"\bREQ-\d{3}\b", body), \
+ "Marp deck slide body contains REQ-### requirement IDs"
+ assert not re.search(r"\b(outbox_writer|confidence_signal|hitl_gates|"
+ r"attestation_matrix|checkov_adapter|infracost_adapter|"
+ r"contract_resolver|run_platform)\.py\b", body), \
+ "Marp deck slide body contains internal .py file paths"
+
+
+# --- Speaker notes + benefit callouts (REQ-274) ----------------------
+
+def test_speaker_notes_as_html_comments():
+ """REQ-274: speaker notes are embedded as ``
+ HTML comments (Marp excludes HTML comments from the rendered slide;
+ the comments are for authors/presenters). Expect >= 20 (one per main
+ slide) + the appendix slide."""
+ text = MARP_DECK.read_text()
+ count = len(re.findall(r"