Independent FastAPI backend for the MaskanX agentic growth platform. Includes the agent runtime, MCP client integrations (Meta Ads, LinkedIn, HubSpot, Tavily, Exa, xAI, Citedy, image generation), PostgreSQL storage for chats and cron jobs, provider and secret management, and the CLI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1950 lines
52 KiB
Python
1950 lines
52 KiB
Python
import importlib.util
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from adclaw.agents.skill_scanner import SkillSecurityScanner
|
|
|
|
|
|
SKILL_DIR = Path(__file__).resolve().parents[1] / "src/adclaw/agents/skills/html-presentation-deck"
|
|
|
|
|
|
def _load_validate_html_deck_module():
|
|
spec = importlib.util.spec_from_file_location(
|
|
"validate_html_deck",
|
|
SKILL_DIR / "scripts" / "validate_html_deck.py",
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
IDEOGRAPH_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
|
|
|
|
|
|
def _term(*codes: int) -> str:
|
|
return "".join(chr(code) for code in codes)
|
|
|
|
|
|
BANNED_TERMS = [
|
|
_term(103, 117, 105, 122, 97, 110, 103),
|
|
_term(111, 112, 55, 52, 49, 56),
|
|
_term(112, 112, 116, 45, 115, 107, 105, 108, 108),
|
|
_term(122, 104, 45, 67, 78),
|
|
_term(78, 111, 116, 111, 32, 83, 97, 110, 115, 32, 83, 67),
|
|
_term(78, 111, 116, 111, 32, 83, 101, 114, 105, 102, 32, 83, 67),
|
|
]
|
|
BANNED_RE = re.compile(
|
|
"|".join(
|
|
rf"(?<![A-Za-z0-9]){re.escape(term)}(?![A-Za-z0-9])"
|
|
for term in BANNED_TERMS
|
|
),
|
|
re.IGNORECASE,
|
|
)
|
|
PRODUCT_GRID_TEST_CSS = (
|
|
".deck{display:flex}.slide{display:block}.stage{display:block}"
|
|
".progress{display:block}.nav{display:block}.index{display:block}"
|
|
)
|
|
|
|
|
|
def test_html_presentation_deck_has_no_non_english_markers_or_banned_terms():
|
|
for path in SKILL_DIR.rglob("*"):
|
|
if path.is_file() and path.suffix.lower() in {".md", ".html", ".py", ".json", ".txt"}:
|
|
text = path.read_text(encoding="utf-8")
|
|
assert not IDEOGRAPH_RE.search(text), f"Non-English ideograph found in {path.relative_to(SKILL_DIR)}"
|
|
assert not BANNED_RE.search(text), f"Banned term found in {path.relative_to(SKILL_DIR)}"
|
|
|
|
|
|
def test_html_presentation_deck_skill_docs_use_portable_validator_commands():
|
|
skill_md = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
|
|
checklist = (SKILL_DIR / "references" / "checklist.md").read_text(encoding="utf-8")
|
|
hardcoded = "src/adclaw/agents/skills/html-presentation-deck/scripts/validate"
|
|
for label, text in (("SKILL.md", skill_md), ("checklist.md", checklist)):
|
|
for line in text.splitlines():
|
|
if "python3" in line:
|
|
assert hardcoded not in line, f"{label} must not hardcode MaskanX repo validator path: {line}"
|
|
assert "<skill-dir>/scripts/validate_html_deck.py" in skill_md
|
|
assert "<skill-dir>/scripts/validate_deck_quality.py" in skill_md
|
|
assert ".codex/skills/html-presentation-deck" in skill_md
|
|
assert "active_skills/html-presentation-deck" in skill_md
|
|
|
|
|
|
def test_html_presentation_deck_assets_exist():
|
|
expected = [
|
|
"assets/screenshot-backgrounds/editorial/ink-paper.webp",
|
|
"assets/screenshot-backgrounds/editorial/indigo-porcelain.webp",
|
|
"assets/screenshot-backgrounds/editorial/forest-ledger.webp",
|
|
"assets/screenshot-backgrounds/editorial/warm-archive.webp",
|
|
"assets/screenshot-backgrounds/editorial/sand-gallery.webp",
|
|
"assets/screenshot-backgrounds/clean-grid/blue-anchor.webp",
|
|
"assets/screenshot-backgrounds/clean-grid/lemon-signal.webp",
|
|
"assets/screenshot-backgrounds/clean-grid/lime-circuit.webp",
|
|
"assets/screenshot-backgrounds/clean-grid/orange-marker.webp",
|
|
]
|
|
for rel_path in expected:
|
|
path = SKILL_DIR / rel_path
|
|
assert path.exists(), f"Missing generated asset: {rel_path}"
|
|
assert path.stat().st_size > 1024, f"Generated asset is unexpectedly small: {rel_path}"
|
|
|
|
|
|
def test_html_presentation_deck_referenced_assets_resolve():
|
|
reference = SKILL_DIR / "references/screenshot-framing.md"
|
|
text = reference.read_text(encoding="utf-8")
|
|
referenced_assets = sorted(set(re.findall(r"`(screenshot-backgrounds/[^`]+\.webp)`", text)))
|
|
|
|
assert len(referenced_assets) == 9
|
|
for rel_path in referenced_assets:
|
|
path = (reference.parent / "../assets" / rel_path).resolve()
|
|
assert path.is_file(), f"Broken screenshot background reference: {rel_path}"
|
|
|
|
|
|
def test_html_presentation_deck_static_scan_is_safe():
|
|
result = SkillSecurityScanner().scan_skill(SKILL_DIR)
|
|
assert result.safe is True
|
|
assert result.critical_count == 0
|
|
|
|
|
|
def test_html_deck_validator_accepts_single_quoted_html_and_generic_region_word(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
regional_word = "".join(chr(code) for code in [83, 119, 105, 115, 115])
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang='en'>
|
|
<head><title>Runtime test</title></head>
|
|
<body>
|
|
<section class='slide'><h1>{regional_word} market readout</h1></section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_validator_handles_invalid_path_cleanly(tmp_path):
|
|
directory = tmp_path / "deck.html"
|
|
directory.mkdir()
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(directory),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 2
|
|
assert "Invalid HTML deck path" in result.stderr
|
|
|
|
|
|
def test_html_deck_validator_merges_inline_tokens_with_root_for_contrast(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --muted: #777777; --paper: #ffffff; --panel: #f5f5f5; --accent: #ff5500; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<section class="slide" style="--panel:#151515">
|
|
<p class="muted">Low contrast body</p>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts" / "validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "muted text on panel" in result.stderr
|
|
|
|
|
|
def test_html_deck_contrast_helpers():
|
|
mod = _load_validate_html_deck_module()
|
|
assert mod._luminance("#ffffff") > mod._luminance("#000000")
|
|
assert mod._contrast("#000000", "#ffffff") == 21.0
|
|
|
|
|
|
def test_html_deck_css_variable_contexts_nested_root_and_shorthand_hex():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root {
|
|
--paper: #ffffff;
|
|
--muted: #888888;
|
|
--accent-text: #ffcc00;
|
|
@media (min-width: 1px) { --panel: #111111; }
|
|
}
|
|
</style>
|
|
<section class="slide" style="--panel:#222222"></section>
|
|
"""
|
|
contexts = mod._css_variable_contexts(html)
|
|
assert any(name.startswith(":root") for name, _ in contexts)
|
|
root_contexts = [vars for name, vars in contexts if name.startswith(":root")]
|
|
assert len(root_contexts) == 1
|
|
assert "--paper" in root_contexts[0]
|
|
inline_vars = next(vars for name, vars in contexts if name.startswith("inline"))
|
|
assert inline_vars["--muted"].rgb == "#888888"
|
|
assert inline_vars["--panel"].rgb == "#222222"
|
|
|
|
shorthand_only = mod._parse_css_variables(":root { --paper: #fff; }")
|
|
assert shorthand_only == {}
|
|
|
|
|
|
def test_html_deck_parses_slide_theme_rule_tokens():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root { --ink: #090b0f; --paper: #f7f7f1; --accent: #165cff; --muted: #59605a; --panel: #ecefe6; }
|
|
.slide.theme-dark {
|
|
--muted: rgba(247,247,241,180);
|
|
--panel: rgba(247,247,241,18);
|
|
}
|
|
.slide.theme-dark {
|
|
--muted: #cccccc;
|
|
--panel: #dddddd;
|
|
}
|
|
</style>
|
|
<section class="slide theme-dark"></section>
|
|
"""
|
|
contexts = mod._css_variable_contexts(html)
|
|
dark = [c for n, c in contexts if "theme-dark" in n][0]
|
|
assert dark["--ink"].rgb == "#090b0f"
|
|
assert dark["--muted"].rgb == "#f7f7f1"
|
|
assert dark["--muted"].alpha == pytest.approx(1.0)
|
|
|
|
bad = [c for n, c in contexts if "theme-dark" in n][1]
|
|
errors = mod._validate_contrast(
|
|
"slide theme rule 2 (.slide.theme-dark)",
|
|
bad,
|
|
)
|
|
assert any("muted text on panel" in error for error in errors)
|
|
|
|
|
|
def test_html_deck_validator_checks_every_grouped_theme_selector(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --ink: #000000; --paper: #ffffff; --yellow: #fff200; --accent: #165cff; --accent-on: #ffffff; --muted: #777777; --panel: #ffffff; }
|
|
.slide.theme-dark, .slide.theme-yellow { --muted: #000000; --panel: #000000; }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide theme-dark"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "theme-dark" in result.stderr
|
|
assert "muted text on slide background" in result.stderr
|
|
|
|
|
|
def test_html_deck_validator_accepts_theme_class_before_slide_class(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --ink: #000000; --paper: #ffffff; --accent: #165cff; --accent-on: #ffffff; --muted: #777777; --panel: #ffffff; }
|
|
.theme-dark.slide { --muted: #000000; --panel: #000000; }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide theme-dark"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "theme-dark" in result.stderr
|
|
assert "muted text on slide background" in result.stderr
|
|
|
|
|
|
def test_html_deck_validator_handles_grouped_root_selector_lists(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
html, :root { --paper: #ffffff; --muted: #ffffff; --panel: #eeeeee; }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "muted text on paper" in result.stderr
|
|
|
|
|
|
def test_html_deck_validator_ignores_descendant_root_scoped_tokens(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --ink: #111111; --paper: #ffffff; --muted: #555555; --panel: #eeeeee; }
|
|
:root .component { --muted: #ffffff; }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"><div class="component"></div></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_validator_checks_primary_text_on_paper(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --ink: #ffffff; --paper: #ffffff; --muted: #555555; --panel: #eeeeee; }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "primary text on paper" in result.stderr
|
|
|
|
|
|
def test_html_deck_validator_rechecks_themes_under_conditional_root(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --ink: #000000; --paper: #ffffff; --accent: #165cff; --accent-on: #ffffff; --muted: #777777; --panel: #ffffff; }
|
|
.slide.theme-dark { --muted: #dddddd; --panel: #222222; }
|
|
@media (max-width: 760px) { :root { --ink: #ffffff; } }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide theme-dark"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "conditional :root theme" in result.stderr
|
|
assert "primary text on theme background" in result.stderr
|
|
|
|
|
|
def test_html_deck_validator_ignores_descendant_theme_component_tokens(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --ink: #000000; --paper: #ffffff; --accent: #165cff; --accent-on: #ffffff; --muted: #555555; --panel: #eeeeee; }
|
|
.slide.theme-dark { --muted: #dddddd; --panel: #222222; }
|
|
.slide.theme-dark .panel { --muted: #000000; --panel: #ffffff; }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide theme-dark"><div class="panel"></div></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_root_parser_ignores_nested_quoted_declarations():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root {
|
|
@media (min-width: 1px) { .note::before { content: "--muted: #ffffff;"; } }
|
|
--paper: #ffffff;
|
|
--muted: #555555;
|
|
--panel: #eeeeee;
|
|
}
|
|
</style>
|
|
<section class="slide"></section>
|
|
"""
|
|
root_context = [vars for name, vars in mod._css_variable_contexts(html) if name.startswith(":root")][0]
|
|
assert root_context["--paper"].rgb == "#ffffff"
|
|
assert root_context["--muted"].rgb == "#555555"
|
|
assert root_context["--panel"].rgb == "#eeeeee"
|
|
|
|
|
|
def test_html_deck_parses_rgb_tokens_for_contrast(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root {
|
|
--paper: rgb(255,255,255);
|
|
--muted: rgb(250,250,250);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "muted text on paper" in result.stderr
|
|
|
|
|
|
def test_html_deck_contrast_composites_translucent_root_tokens(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root {
|
|
--paper: #ffffff;
|
|
--muted: rgba(0,0,0,.2);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "muted text on paper" in result.stderr
|
|
|
|
|
|
def test_html_deck_theme_rules_accumulate_and_composite_translucent_tokens():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root { --ink: #000000; --paper: #ffffff; --muted: #111111; --panel: #ffffff; }
|
|
.slide.theme-dark { --muted: rgba(255,255,255,.2); --panel: rgba(255,255,255,.08); }
|
|
.slide.theme-dark { --panel: rgba(255,255,255,.4); }
|
|
</style>
|
|
<section class="slide theme-dark"></section>
|
|
"""
|
|
contexts = mod._css_variable_contexts(html)
|
|
second = [c for n, c in contexts if "theme-dark" in n][1]
|
|
assert second["--muted"].alpha == pytest.approx(0.2)
|
|
assert second["--panel"].alpha == pytest.approx(0.4)
|
|
|
|
errors = mod._validate_contrast("slide theme rule 2 (.slide.theme-dark)", second)
|
|
assert any("muted text on slide background" in error for error in errors)
|
|
assert any("muted text on panel" in error for error in errors)
|
|
|
|
|
|
def test_html_deck_inline_tokens_inherit_slide_theme_context():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root { --ink: #000000; --paper: #ffffff; --muted: #111111; --panel: #ffffff; }
|
|
.slide.theme-dark { --muted: rgba(255,255,255,.2); --panel: rgba(255,255,255,.08); }
|
|
</style>
|
|
<section class="slide theme-dark" style="--panel: rgba(255,255,255,.4)"></section>
|
|
"""
|
|
contexts = mod._css_variable_contexts(html)
|
|
inline = [c for n, c in contexts if n.startswith("inline")][0]
|
|
assert inline["--muted"].alpha == pytest.approx(0.2)
|
|
assert inline["--panel"].alpha == pytest.approx(0.4)
|
|
|
|
errors = mod._validate_contrast("slide theme rule 1 (.slide.theme-dark)", inline)
|
|
assert any("muted text on slide background" in error for error in errors)
|
|
assert any("muted text on panel" in error for error in errors)
|
|
|
|
|
|
def test_html_deck_nested_inline_tokens_inherit_ancestor_slide_theme_context():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root { --ink: #000000; --paper: #ffffff; --muted: #111111; --panel: #ffffff; }
|
|
.slide.theme-dark { --muted: rgba(255,255,255,.2); --panel: rgba(255,255,255,.08); }
|
|
</style>
|
|
<section class="slide theme-dark"><div class="panel" style="--panel: #f7f7f1"></div></section>
|
|
"""
|
|
contexts = mod._css_variable_contexts(html)
|
|
inline = [c for n, c in contexts if n.startswith("inline")][0]
|
|
assert inline["--muted"].alpha == pytest.approx(0.2)
|
|
assert inline["--panel"].rgb == "#f7f7f1"
|
|
|
|
errors = mod._validate_contrast("inline style 1 (.theme-dark)", inline)
|
|
assert any("muted text on panel" in error for error in errors)
|
|
|
|
|
|
def test_html_deck_nested_inline_tokens_accept_ancestor_class_order():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root { --ink: #000000; --paper: #ffffff; --muted: #111111; --panel: #ffffff; }
|
|
.slide.theme-dark { --muted: rgba(255,255,255,.2); --panel: rgba(255,255,255,.08); }
|
|
</style>
|
|
<section class="theme-dark slide"><div class="panel" style="--panel: #f7f7f1"></div></section>
|
|
"""
|
|
contexts = mod._css_variable_contexts(html)
|
|
inline = [c for n, c in contexts if n.startswith("inline")][0]
|
|
assert inline["--muted"].alpha == pytest.approx(0.2)
|
|
assert inline["--panel"].rgb == "#f7f7f1"
|
|
|
|
errors = mod._validate_contrast("inline style 1 (.theme-dark)", inline)
|
|
assert any("muted text on panel" in error for error in errors)
|
|
|
|
|
|
def test_html_deck_root_parser_ignores_conditional_tokens_and_css_braces():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
@media (prefers-color-scheme: dark) { :root { --muted: #ffffff; --panel: #ffffff; } }
|
|
:root {
|
|
--paper: #ffffff;
|
|
--muted: #555555;
|
|
--asset: url("image{1}.png");
|
|
/* { ignored } */
|
|
@media (min-width: 1px) { --muted: #ffffff; --panel: #ffffff; }
|
|
--accent-text: #111111;
|
|
--panel: #eeeeee;
|
|
}
|
|
</style>
|
|
<section class="slide" style="--panel: 'decorative'; --muted: #222222"></section>
|
|
"""
|
|
contexts = mod._css_variable_contexts(html)
|
|
assert len([name for name, _ in contexts if name.startswith(":root")]) == 1
|
|
root_context = [vars for name, vars in contexts if name.startswith(":root")][0]
|
|
assert root_context["--muted"].rgb == "#555555"
|
|
assert root_context["--panel"].rgb == "#eeeeee"
|
|
inline_context = [vars for name, vars in contexts if name.startswith("inline")][0]
|
|
assert inline_context["--muted"].rgb == "#222222"
|
|
|
|
|
|
def test_html_deck_css_variable_contexts_ignore_visible_css_snippets():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<pre>:root { --paper: #000000; --muted: #000000; }</pre>
|
|
<style>
|
|
:root { --paper: #ffffff; --muted: #555555; --panel: #eeeeee; }
|
|
</style>
|
|
<section class="slide"></section>
|
|
"""
|
|
contexts = mod._css_variable_contexts(html)
|
|
root_contexts = [vars for name, vars in contexts if name.startswith(":root")]
|
|
assert len(root_contexts) == 1
|
|
assert root_contexts[0]["--paper"].rgb == "#ffffff"
|
|
assert root_contexts[0]["--muted"].rgb == "#555555"
|
|
|
|
|
|
def test_html_deck_css_variable_contexts_ignore_commented_style_blocks(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<!-- <style>:root { --paper: #ffffff; --muted: #ffffff; --panel: #ffffff; }</style> -->
|
|
<style>:root { --paper: #ffffff; --muted: #555555; --panel: #eeeeee; }</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_resolves_var_token_references_for_contrast():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root {
|
|
--paper: #ffffff;
|
|
--muted-base: #777777;
|
|
--muted: var(--muted-base);
|
|
--panel-base: #eeeeee;
|
|
--panel: var(--panel-base);
|
|
}
|
|
</style>
|
|
<section class="slide"></section>
|
|
"""
|
|
context = [vars for name, vars in mod._css_variable_contexts(html) if name.startswith(":root")][0]
|
|
|
|
errors = mod._validate_contrast(":root block 1", context)
|
|
|
|
assert any("muted text on paper" in error for error in errors)
|
|
assert any("muted text on panel" in error for error in errors)
|
|
|
|
|
|
def test_html_deck_contrast_composites_percentage_alpha_tokens(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root {
|
|
--paper: #ffffff;
|
|
--muted: rgba(0,0,0,20%);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "muted text on paper" in result.stderr
|
|
|
|
|
|
def test_html_deck_validator_composites_translucent_panel_over_paper(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root {
|
|
--paper: #ffffff;
|
|
--panel: rgba(0,0,0,.05);
|
|
--muted: #555555;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_validator_checks_muted_text_on_panel_2(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root {
|
|
--paper: #ffffff;
|
|
--panel: #eeeeee;
|
|
--panel-2: #555555;
|
|
--muted: #555555;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "muted text on panel-2" in result.stderr
|
|
|
|
|
|
def test_html_deck_validator_clamps_numeric_rgba_alpha_above_one(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root {
|
|
--paper: #ffffff;
|
|
--panel: #eeeeee;
|
|
--muted: rgba(0,0,0,2);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_validator_ignores_malformed_rgba_alpha_without_crashing(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root {
|
|
--paper: #ffffff;
|
|
--muted: rgba(0,0,0,1.2.3);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert "Traceback" not in result.stderr
|
|
|
|
|
|
def test_html_deck_validator_checks_only_final_root_cascade(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --paper: #ffffff; --panel: #eeeeee; --muted: #ffffff; }
|
|
:root { --muted: #555555; }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_validator_checks_conditional_root_cascade(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --paper: #ffffff; --panel: #eeeeee; --muted: #555555; }
|
|
@media (max-width: 760px) { :root { --muted: #ffffff; } }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "conditional :root" in result.stderr
|
|
assert "muted text on paper" in result.stderr
|
|
|
|
|
|
def test_html_deck_conditional_root_respects_later_base_cascade(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
@media (max-width: 760px) { :root { --muted: #ffffff; } }
|
|
:root { --paper: #ffffff; --panel: #eeeeee; --muted: #555555; }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_conditional_root_cascades_sibling_rules(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --paper: #ffffff; --panel: #eeeeee; --muted: #555555; }
|
|
@media (max-width: 760px) { :root { --paper: #000000; --panel: #000000; --muted: #ffffff; } }
|
|
@media (max-width: 760px) { :root { --muted: #333333; } }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "conditional :root" in result.stderr
|
|
assert "muted text on paper" in result.stderr
|
|
|
|
|
|
def test_html_deck_conditional_root_does_not_merge_distinct_media_rules(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --paper: #ffffff; --panel: #eeeeee; --muted: #555555; }
|
|
@media (max-width: 760px) { :root { --paper: #000000; --panel: #000000; --muted: #ffffff; } }
|
|
@media (min-width: 1200px) { :root { --muted: #333333; } }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_slide_theme_validates_primary_text_contrast():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root {
|
|
--accent: #165cff;
|
|
--accent-on: #ffffff;
|
|
--muted: #555555;
|
|
--panel: #eeeeee;
|
|
}
|
|
.slide.theme-accent {
|
|
--accent-on: #165cff;
|
|
--muted: #ffffff;
|
|
--panel: rgba(255,255,255,.08);
|
|
}
|
|
</style>
|
|
<section class="slide theme-accent"></section>
|
|
"""
|
|
theme_context = [
|
|
variables
|
|
for name, variables in mod._css_variable_contexts(html)
|
|
if "theme-accent" in name
|
|
][0]
|
|
|
|
errors = mod._validate_contrast("slide theme rule 1 (.slide.theme-accent)", theme_context)
|
|
|
|
assert any("primary text on theme background" in error for error in errors)
|
|
|
|
|
|
def test_html_deck_conditional_theme_respects_later_base_cascade(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --accent: #165cff; --accent-on: #ffffff; --muted: #555555; --panel: #eeeeee; }
|
|
@media (max-width: 760px) { .slide.theme-accent { --accent-on: #165cff; } }
|
|
.slide.theme-accent { --accent-on: #ffffff; --muted: #ffffff; --panel: rgba(255,255,255,.08); }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide theme-accent"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_conditional_theme_cascades_sibling_rules(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --accent: #ffffff; --accent-on: #333333; --accent-text: #333333; --muted: #555555; --panel: #eeeeee; }
|
|
.slide.theme-accent { --muted: #555555; --panel: rgba(0,0,0,.05); }
|
|
@media (max-width: 760px) { .slide.theme-accent { --accent: #000000; --accent-on: #ffffff; --muted: #ffffff; --panel: rgba(255,255,255,.08); } }
|
|
@media (max-width: 760px) { .slide.theme-accent { --accent-on: #333333; } }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide theme-accent"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "conditional slide theme" in result.stderr
|
|
assert "primary text on theme background" in result.stderr
|
|
|
|
|
|
def test_html_deck_conditional_theme_does_not_merge_distinct_media_rules(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root { --accent: #ffffff; --accent-on: #333333; --accent-text: #333333; --muted: #555555; --panel: #eeeeee; }
|
|
.slide.theme-accent { --muted: #555555; --panel: rgba(0,0,0,.05); }
|
|
@media (max-width: 760px) { .slide.theme-accent { --accent: #000000; --accent-on: #ffffff; --muted: #ffffff; --panel: rgba(255,255,255,.08); } }
|
|
@media (min-width: 1200px) { .slide.theme-accent { --accent-on: #333333; } }
|
|
</style>
|
|
</head>
|
|
<body><section class="slide theme-accent"></section></body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_html_deck.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_html_deck_slide_theme_validates_accent_component_contrast():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root {
|
|
--ink: #000000;
|
|
--paper: #ffffff;
|
|
--accent: #165cff;
|
|
--accent-on: #ffffff;
|
|
--muted: #555555;
|
|
--panel: #eeeeee;
|
|
}
|
|
.slide.theme-dark {
|
|
--accent: #ffffff;
|
|
--accent-on: #ffffff;
|
|
--muted: #ffffff;
|
|
--panel: rgba(255,255,255,.08);
|
|
}
|
|
</style>
|
|
<section class="slide theme-dark"></section>
|
|
"""
|
|
theme_context = [
|
|
variables
|
|
for name, variables in mod._css_variable_contexts(html)
|
|
if "theme-dark" in name
|
|
][0]
|
|
|
|
errors = mod._validate_contrast("slide theme rule 1 (.slide.theme-dark)", theme_context)
|
|
|
|
assert any("text on accent background" in error for error in errors)
|
|
|
|
|
|
def test_html_deck_product_grid_theme_tokens_pass_contrast():
|
|
mod = _load_validate_html_deck_module()
|
|
template = SKILL_DIR / "assets" / "template-product-grid.html"
|
|
html = template.read_text(encoding="utf-8")
|
|
theme_contexts = [
|
|
(name, variables)
|
|
for name, variables in mod._css_variable_contexts(html)
|
|
if name.startswith("slide theme rule")
|
|
]
|
|
assert len(theme_contexts) == 3
|
|
for name, variables in theme_contexts:
|
|
assert mod._validate_slide_theme_contrast(name, variables) == []
|
|
|
|
|
|
def test_html_deck_root_contexts_use_cumulative_aggregate():
|
|
mod = _load_validate_html_deck_module()
|
|
html = """
|
|
<style>
|
|
:root { --muted: #595959; --paper: #ffffff; }
|
|
:root { --panel: #f0f0f0; }
|
|
</style>
|
|
"""
|
|
contexts = mod._css_variable_contexts(html)
|
|
second_root = [vars for name, vars in contexts if name == ":root block 2"][0]
|
|
assert second_root["--muted"].rgb == "#595959"
|
|
assert second_root["--panel"].rgb == "#f0f0f0"
|
|
|
|
|
|
def test_html_deck_validate_contrast_flags_accent_text_on_paper():
|
|
mod = _load_validate_html_deck_module()
|
|
color = mod.CssColor
|
|
errors = mod._validate_contrast(
|
|
"test",
|
|
{
|
|
"--accent-text": color("#cccccc"),
|
|
"--paper": color("#ffffff"),
|
|
"--muted": color("#777777"),
|
|
"--panel": color("#f0f0f0"),
|
|
},
|
|
)
|
|
assert any("accent text on paper" in error for error in errors)
|
|
|
|
|
|
def test_html_deck_validator_parses_nested_root_block(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<style>
|
|
:root {
|
|
--muted: #595959;
|
|
--paper: #ffffff;
|
|
--panel: #f0f0f0;
|
|
--accent-text: #0b5d1e;
|
|
@supports (display: grid) { --panel: #e8e8e8; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<section class="slide"><p>ok</p></section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
result = subprocess.run(
|
|
[sys.executable, str(SKILL_DIR / "scripts/validate_html_deck.py"), str(deck)],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_ignores_xmlns_urls(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><svg xmlns="http://www.w3.org/2000/svg"></svg></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_external_and_unregistered_image_slots(tmp_path):
|
|
images = tmp_path / "images"
|
|
images.mkdir()
|
|
(images / "local.png").write_bytes(b"png")
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage">
|
|
<img src="https://example.com/remote.png" alt="Remote">
|
|
<img src="images/local.png" alt="Local" data-image-slot="pg04-main-16x10">
|
|
</div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "external http(s) references" in result.stderr
|
|
assert "image slot pg04-main-16x10 is not allowed for PG02" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_blank_image_src(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage">
|
|
<img src="" alt="Empty source" data-image-slot="pg02-media-16x10">
|
|
</div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1, result.stdout
|
|
assert "image 1 has blank src" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_does_not_warn_when_fewer_than_five_slides(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
slides = "\n".join(
|
|
f"""<section class="slide" data-system="product-grid" data-layout="{layout}">
|
|
<div class="stage"><p>Slide {idx}</p></div>
|
|
</section>"""
|
|
for idx, layout in enumerate(["PG02", "PG03", "PG05", "PG06"], start=1)
|
|
)
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
{slides}
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert "Decks under 8 slides should use at least 5 distinct layouts" not in result.stdout
|
|
|
|
|
|
def test_product_grid_quality_validator_allows_responsive_template_css(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}.title{{font-size:clamp(32px,5vw,72px)}}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><p>Responsive template CSS is allowed.</p></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_deck_local_css_classes(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>.stage{display:block}.one-off{color:red}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage one-off"><p>Custom classes are not allowed.</p></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "Deck defines unregistered CSS class(es): one-off" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_missing_copied_template_css(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>.stage{display:block}</style></head>
|
|
<body>
|
|
<main class="deck" id="deck">
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><p>Template CSS must be copied.</p></div>
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "missing copied Product Grid template CSS class(es)" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_uppercase_external_url_schemes(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><img src="HTTPS://example.com/remote.png" alt="Remote"></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "external http(s) references" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_nested_sections(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><section><p>Nested section</p></section></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "nested <section> elements are not allowed" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_remote_base_href(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<base href="https://cdn.example/">
|
|
<style>.stage{display:block}</style>
|
|
</head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><p>Remote base URLs are not allowed.</p></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "external http(s) references" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_protocol_relative_urls(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><img src="//cdn.example.com/remote.png" alt="Remote"></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "external http(s) references" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_svg_image_href(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><svg><image href="https://example.com/remote.png"></image></svg></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "external http(s) references" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_checks_dot_prefixed_local_images(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG01">
|
|
<div class="stage"><img src="./images/hero.png" alt="Hero"></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "missing local image images/hero.png" in result.stderr
|
|
assert "local image 1 missing data-image-slot" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_non_images_local_paths(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG01">
|
|
<div class="stage"><img src="assets/hero.png" alt="Hero" data-image-slot="pg01-hero-16x9"></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "image 1 must use a local images/ path" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_allows_font_size_in_style_blocks(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}.stage{{font-size:18px}}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><p>Template CSS owns type sizing.</p></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_allows_filter_as_body_copy(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><p class="body">Filter: tenant knowledge</p></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_slide_level_inline_style(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02" style="font-size:10px;height:100vh">
|
|
<div class="stage"><p>Slide-level inline style is not allowed.</p></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "inline font-size override" in result.stderr
|
|
assert "fixed vh height" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_rejects_yellow_section_blue_accent(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide theme-yellow" data-system="product-grid" data-layout="PG02" style="color:#165cff">
|
|
<div class="stage"><p>Yellow slides cannot use blue local accents.</p></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 1
|
|
assert "yellow theme contains slide-local blue accent styling" in result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_allows_yellow_section_var_accent(tmp_path):
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide theme-yellow" data-system="product-grid" data-layout="PG02" style="color:var(--accent)">
|
|
<div class="stage"><p>Yellow theme remaps accent to ink.</p></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(SKILL_DIR / "scripts/validate_deck_quality.py"),
|
|
str(deck),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_product_grid_quality_validator_fails_when_template_classes_are_unavailable(tmp_path, monkeypatch):
|
|
module_path = SKILL_DIR / "scripts/validate_deck_quality.py"
|
|
spec = importlib.util.spec_from_file_location("validate_deck_quality_for_test", module_path)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
deck = tmp_path / "deck.html"
|
|
deck.write_text(
|
|
f"""<!doctype html>
|
|
<html lang="en">
|
|
<head><style>{PRODUCT_GRID_TEST_CSS}</style></head>
|
|
<body>
|
|
<section class="slide" data-system="product-grid" data-layout="PG02">
|
|
<div class="stage"><p>Template class registry must load.</p></div>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(module, "TEMPLATE_PATH", tmp_path / "missing-template.html")
|
|
|
|
errors, warnings = module.check_file(deck)
|
|
|
|
assert not warnings
|
|
assert any("Template CSS source not found" in error for error in errors)
|
|
|
|
|
|
def test_product_grid_template_requires_horizontal_touch_intent():
|
|
template = (SKILL_DIR / "assets/template-product-grid.html").read_text(encoding="utf-8")
|
|
|
|
assert "event.touches[0].clientY" in template
|
|
assert "Math.abs(deltaX) > Math.abs(deltaY)" in template
|
|
|
|
|
|
def test_product_grid_template_uses_horizontal_wheel_direction_for_horizontal_intent():
|
|
template = (SKILL_DIR / "assets/template-product-grid.html").read_text(encoding="utf-8")
|
|
|
|
assert "const direction = horizontalIntent ? event.deltaX : event.deltaY;" in template
|
|
assert "event.deltaX > 0 || event.deltaY > 0" not in template
|