#!/usr/bin/env python3 """Lightweight tests for scripts/build.py and scripts/shared.py. Run with: python3 scripts/tests/test_build.py The harness uses plain assertions and a tiny runner so it has no third-party dependency (matching the rest of the repo's lean tooling). """ from __future__ import annotations import contextlib import builtins import importlib.util import inspect import io import json import os import re import shutil import subprocess import sys import tempfile import zipfile from pathlib import Path # Make scripts/ importable when running this file directly. ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from build import ( # noqa: E402 DIAGRAM_TARGETS, HTML_TARGETS, PPTX_TARGETS, SCREEN_TARGETS, _BG_B, _BG_G, _BG_R, _density_bucket, _extract_root_vars, _last_content_y, _markdown_residue_issues, _off_palette_findings, _orphan_last_line, _pair_names, _parse_slide_sequence, _resume_balance_issues, _rhythm_issues, _root_token_findings, check_all, check_cross_template_consistency, check_markdown_residue, check_off_palette, check_placeholders, main as build_main, scan_file, ) from shared import ( # noqa: E402 DIAGRAM_TEMPLATES, HTML_TEMPLATES, PARCHMENT_RGB, ROOT as REPO_ROOT, SCREEN_TEMPLATES, TEMPLATES, build_targets, diagram_targets, load_checks_thresholds, screen_targets, ) import highlight as highlight_mod # noqa: E402 from highlight import highlight_code_blocks # noqa: E402 from site_facts import ( # noqa: E402 FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE, check_site_facts, site_fact_issues, site_structure_issues, ) from tokens import _mermaid_theme_drift # noqa: E402 from verify import RECOGNIZABLE_FALLBACK_FONT_MARKERS # noqa: E402 # --------------------------- helpers --------------------------- _PASS = 0 _FAIL = 0 def check(name: str, predicate: bool, detail: str = "") -> None: global _PASS, _FAIL if predicate: _PASS += 1 print(f"OK: {name}") else: _FAIL += 1 print(f"ERROR: {name}{(' - ' + detail) if detail else ''}") def write_temp_html(body: str, suffix: str = "-en.html") -> Path: f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8") f.write(body) f.close() return Path(f.name) def silently(callable_, *args, **kwargs): """Run a function with stdout suppressed, return its result.""" sink = io.StringIO() with contextlib.redirect_stdout(sink): return callable_(*args, **kwargs) def run_build_args(args: list[str]) -> tuple[int, str]: sink = io.StringIO() with contextlib.redirect_stdout(sink): rc = build_main(["build.py", *args]) return rc, sink.getvalue() def site_fact_file_map() -> dict[str, str]: rels = (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE) return { rel: (REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace") for rel in rels } # --------------------------- package archive --------------------------- PACKAGE_MAX_BYTES = 6_000_000 PACKAGE_ROOT_NAME = "kami" PACKAGE_FORBIDDEN_EXACT = { ".claude-plugin/marketplace.json", ".gitignore", "AGENTS.md", "CLAUDE.md", "README.md", "assets/images/1.png", "assets/images/2.png", "assets/images/3.png", "assets/fonts/TsangerJinKai02-W04.ttf", "assets/fonts/TsangerJinKai02-W05.ttf", "assets/fonts/SourceHanSerifKR-Regular.otf", "assets/fonts/SourceHanSerifKR-Medium.otf", "index.html", "index-en.html", "index-ja.html", "index-ko.html", "index-tw.html", "index-zh.html", "llms.txt", "robots.txt", "scripts/build_metadata.py", "scripts/draft-release-notes.py", "scripts/package-skill.sh", "sitemap.xml", "styles.css", "vercel.json", } PACKAGE_FORBIDDEN_PREFIXES = ( ".agents/", ".claude/", ".github/", "assets/demos/", "assets/examples/", "assets/illustrations/", "assets/showcase/", "plugins/", "scripts/tests/", ) PACKAGE_REQUIRED_ENTRIES = { "SKILL.md", "CHEATSHEET.md", "VERSION", "LICENSE", "assets/images/logo.svg", "assets/fonts/JetBrainsMono.woff2", "assets/templates/resume.html", "assets/templates/landing-page.html", "assets/diagrams/sequence.html", "references/design.md", "scripts/build.py", "scripts/ensure-fonts.sh", "scripts/site_facts.py", } def test_dist_package_contents() -> None: archive = REPO_ROOT / "dist" / "kami.zip" check("dist/kami.zip exists", archive.exists(), f"missing {archive}") if not archive.exists(): return size_bytes = archive.stat().st_size check("dist/kami.zip stays below 6MB", size_bytes <= PACKAGE_MAX_BYTES, f"{size_bytes} bytes > {PACKAGE_MAX_BYTES} bytes") with zipfile.ZipFile(archive) as zf: names = set(zf.namelist()) bad_root = sorted(name for name in names if not name.startswith(f"{PACKAGE_ROOT_NAME}/")) check("dist/kami.zip uses a Claude-friendly top-level skill folder", not bad_root, f"entries outside {PACKAGE_ROOT_NAME}/: {', '.join(bad_root)}") payload_names = { name.removeprefix(f"{PACKAGE_ROOT_NAME}/") for name in names if name.startswith(f"{PACKAGE_ROOT_NAME}/") } forbidden = sorted( name for name in payload_names if name.startswith(PACKAGE_FORBIDDEN_PREFIXES) or name in PACKAGE_FORBIDDEN_EXACT ) check("dist/kami.zip excludes site, CI, tests, demos, generated mirrors, and large bundled fonts", not forbidden, f"forbidden entries: {', '.join(forbidden)}") missing_required = sorted(PACKAGE_REQUIRED_ENTRIES - payload_names) check("dist/kami.zip keeps required runtime skill files", not missing_required, f"missing entries: {', '.join(missing_required)}") def test_plugin_metadata_generated() -> None: """Claude Code / Codex marketplaces and plugin mirrors must stay generated.""" script = REPO_ROOT / "scripts" / "build_metadata.py" check("build_metadata.py exists", script.exists(), f"missing {script}") if not script.exists(): return result = subprocess.run( [sys.executable, str(script), "--check"], cwd=REPO_ROOT, capture_output=True, text=True, ) detail = (result.stdout + result.stderr).strip() check("plugin metadata matches generator", result.returncode == 0, detail) def test_claude_plugin_marketplace_version_matches_version_file() -> None: """Claude Code uses this version instead of falling back to a commit hash.""" version = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() marketplace_file = REPO_ROOT / ".claude-plugin" / "marketplace.json" check("Claude plugin marketplace metadata exists", marketplace_file.exists()) if not marketplace_file.exists(): return marketplace = json.loads(marketplace_file.read_text(encoding="utf-8")) plugins = marketplace.get("plugins", []) kami_plugin = next((plugin for plugin in plugins if plugin.get("name") == "kami"), None) check("Claude plugin marketplace includes kami", kami_plugin is not None) if not kami_plugin: return check("Claude plugin marketplace version matches VERSION", kami_plugin.get("version") == version, f"marketplace={kami_plugin.get('version')!r}, VERSION={version!r}") check("Claude plugin marketplace installs the lightweight plugin directory", kami_plugin.get("source") == "./plugins/kami", f"source={kami_plugin.get('source')!r}") plugin_file = REPO_ROOT / "plugins" / "kami" / ".claude-plugin" / "plugin.json" check("Claude plugin manifest exists in generated plugin tree", plugin_file.exists()) if not plugin_file.exists(): return plugin = json.loads(plugin_file.read_text(encoding="utf-8")) check("Claude plugin manifest version matches VERSION", plugin.get("version") == version, f"plugin={plugin.get('version')!r}, VERSION={version!r}") check("Claude plugin manifest exposes skills directory", plugin.get("skills") == "./skills/", f"skills={plugin.get('skills')!r}") def test_build_metadata_reads_tokens_from_root_argument() -> None: from build_metadata import build_codex_plugin, read_token_value with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "references").mkdir() (root / "references" / "tokens.json").write_text('{"--brand":"#123456"}\n', encoding="utf-8") brand_color = read_token_value(root, "brand") plugin = build_codex_plugin("9.9.9", brand_color) check("build_metadata reads brand token from provided root", plugin["interface"]["brandColor"] == "#123456", f"brandColor={plugin['interface']['brandColor']}") # --------------------------- shared registry --------------------------- def test_registry_consistency() -> None: check("HTML_TEMPLATES has 24 entries", len(HTML_TEMPLATES) == 24, f"got {len(HTML_TEMPLATES)}") check("SCREEN_TARGETS has 3 entries", len(SCREEN_TARGETS) == 3, f"got {len(SCREEN_TARGETS)}") check("build_targets matches HTML_TEMPLATES key set", set(build_targets()) == set(HTML_TEMPLATES)) check("screen_targets matches SCREEN_TARGETS key set", set(screen_targets()) == set(SCREEN_TARGETS)) check("HTML_TARGETS in build.py matches build_targets()", dict(HTML_TARGETS) == build_targets()) check("DIAGRAM_TARGETS has 18 entries", len(DIAGRAM_TARGETS) == 18, f"got {len(DIAGRAM_TARGETS)}") check("DIAGRAM_TARGETS in build.py matches shared.diagram_targets()", dict(DIAGRAM_TARGETS) == diagram_targets() == dict(DIAGRAM_TEMPLATES)) check("PPTX_TARGETS has 2 entries", len(PPTX_TARGETS) == 2, f"got {len(PPTX_TARGETS)}") check("PARCHMENT_RGB is canonical", PARCHMENT_RGB == (0xF5, 0xF4, 0xED)) def test_runner_auto_discovers_tests() -> None: names = [name for name, _ in _test_functions()] check("test runner auto-discovers Codex update command test", "test_check_update_uses_codex_plugin_update_command" in names) check("test runner auto-discovers this test", "test_runner_auto_discovers_tests" in names) def test_build_cli_rejects_unexpected_flags() -> None: rc, out = run_build_args(["resume", "--verify"]) check("build.py rejects flags after target", rc == 2 and "ERROR: unexpected argument: --verify" in out, out.strip()) rc, out = run_build_args(["--check-density", "-v"]) check("build.py rejects unknown flags for path-based checks", rc == 2 and "ERROR: unexpected argument: -v" in out, out.strip()) rc, out = run_build_args(["--verify", "-v"]) check("build.py rejects unknown --verify flags", rc == 2 and "ERROR: unexpected argument: -v" in out, out.strip()) rc, out = run_build_args(["--check-markdown", "-v"]) check("build.py rejects unknown --check-markdown flags", rc == 2 and "ERROR: unexpected argument: -v" in out, out.strip()) def test_long_doc_templates_use_rendered_toc_pages_and_chapter_headers() -> None: """Long-doc TOCs must use WeasyPrint target-counter, and running headers must follow chapter h1 titles instead of getting stuck on the TOC h2. """ sources = ("long-doc.html", "long-doc-en.html", "long-doc-ko.html") required_ids = { "#ch-executive-summary", "#ch-background", "#ch-methodology", "#ch-conclusions", "#ch-appendix", } offenders: list[str] = [] for source in sources: text = (TEMPLATES / source).read_text(encoding="utf-8") if "target-counter(attr(href), page)" not in text: offenders.append(f"{source}: missing target-counter") if ".toc-page" in text: offenders.append(f"{source}: still has obsolete toc-page wiring") missing_ids = sorted(href for href in required_ids if f'href="{href}"' not in text or f'id="{href[1:]}"' not in text) if missing_ids: offenders.append(f"{source}: missing TOC href/id pairs {missing_ids}") h1_block = re.search(r"(?m)^ h1\s*\{(?P.*?)^ \}", text, re.S) if not h1_block or "string-set: section-title content();" not in h1_block.group("body"): offenders.append(f"{source}: h1 does not set running header") h2_block = re.search(r"(?m)^ h2\s*\{(?P.*?)^ \}", text, re.S) if h2_block and "string-set:" in h2_block.group("body"): offenders.append(f"{source}: h2 still sets running header") check("long-doc templates use rendered TOC pages and chapter headers", not offenders, "; ".join(offenders)) def test_site_facts_repo_clean() -> None: rc = silently(check_site_facts, False) check("public site facts match shared constants and registries", rc == 0, f"check_site_facts returned {rc}") def test_site_facts_flags_bad_diagram_count() -> None: files = site_fact_file_map() bad = files["index.html"] bad = bad.replace("18 inline SVG diagram types", "17 inline SVG diagram types") bad = bad.replace("Eighteen inline SVG diagram types", "Seventeen inline SVG diagram types") files["index.html"] = bad issues = site_fact_issues(files) check("public site facts flag stale diagram counts", any("index.html: missing diagram count 18" in issue for issue in issues), f"issues: {issues}") def test_site_structure_repo_clean() -> None: """Locale pages match index.html's DOM skeleton (redirect script exempt).""" issues = site_structure_issues() check("locale page structure matches index.html", not issues, f"issues: {issues}") def test_site_structure_flags_locale_drift() -> None: files = site_fact_file_map() files["index-zh.html"] = files["index-zh.html"].replace( '

', '

', 1) issues = site_structure_issues(files) check("locale structure check flags a drifted heading", any("index-zh.html: DOM skeleton drifted" in issue for issue in issues), f"issues: {issues}") def test_chinese_html_templates_keep_single_serif_stack() -> None: """Chinese templates must keep --sans pinned to --serif for PDF glyph safety.""" offenders: list[str] = [] for name, spec in HTML_TEMPLATES.items(): source = spec.source if name.endswith("-en"): continue text = (TEMPLATES / source).read_text(encoding="utf-8") if "--sans: var(--serif)" not in text and "--sans: var(--serif)" not in text: offenders.append(source) check("Chinese HTML templates keep --sans: var(--serif)", not offenders, f"offenders: {', '.join(offenders)}") def _ko_stack_offenders(text: str) -> list[str]: """Return CSS declarations that reference the bare `"Source Han Serif K"` family inside a multi-name fallback stack but omit the real OTF family name `"Source Han Serif KR"`. The bare name `"Source Han Serif K"` is legitimate on its own only as the `@font-face` declared alias (a single-name `font-family: "Source Han Serif K";` with no comma, which loads via the file/CDN `src`). Anywhere it appears as a fallback item in a comma-separated stack (`--serif`, `--mono`, `@page` margin boxes, `code`/`pre`, ...), `"Source Han Serif KR"` MUST sit alongside it, or an offline Linux skill install cannot resolve the ensure-fonts.sh-downloaded font by name. Detection: scan only `font-family` / `--serif` / `--sans` / `--mono` declaration values (up to the next `;`, never crossing `{`/`}`). The token `"Source Han Serif K"` (closing quote after `K`) never matches `"Source Han Serif KR"`, so a value that contains the bare token AND a comma (i.e. a fallback stack, not a bare `@font-face` alias) must also contain KR. """ bare = '"Source Han Serif K"' kr = '"Source Han Serif KR"' decl_re = re.compile(r"(?:font-family|--serif|--sans|--mono)\s*:\s*([^;{}]*)", re.IGNORECASE) offenders: list[str] = [] for m in decl_re.finditer(text): value = m.group(1) if bare in value and "," in value and kr not in value: offenders.append(" ".join(value.split())) return offenders def test_korean_templates_carry_resolvable_serif_name() -> None: """Every KO fallback stack that names `Source Han Serif K` must also name `Source Han Serif KR` (the actual family of the bundled OTFs), so the font resolves by name on an offline Linux skill install. Checks per-declaration, not just per-file, so a complete `--serif` cannot mask an incomplete local stack (page-margin header/footer, code/pre, mono). """ offenders: list[str] = [] ko_sources = [spec.source for name, spec in HTML_TEMPLATES.items() if name.endswith("-ko")] ko_sources += [source for name, source in SCREEN_TEMPLATES.items() if name.endswith("-ko")] # Guard against vacuous green: with zero -ko templates the offender loop # never runs and the check below would pass while enforcing nothing. check("Korean template set is non-empty", bool(ko_sources), "no -ko templates found in the registries") for source in ko_sources: text = (TEMPLATES / source).read_text(encoding="utf-8") for bad in _ko_stack_offenders(text): offenders.append(f"{source}: {bad}") check("Korean fallback stacks all carry Source Han Serif KR", not offenders, f"offenders: {'; '.join(offenders)}") # ---------- sibling placeholder parity (issue #38 class) ---------- # Repeated template structures whose placeholder hints must repeat the first # block verbatim. Hint richness degrading from block 1 to later siblings makes # fillers (human or agent) produce degraded copy; see issue #38. Cycle length N # means placeholders repeat in groups of N (e.g. Role/Actions/Impact rows). _SIBLING_PARITY_SPECS = ( ("resume*.html", r'class="proj-text">(\{\{.*?\}\})', 3), ("resume*.html", r'class="proj-role">(\{\{.*?\}\})', 1), ("resume*.html", r'class="conv-body">\s*(\{\{.*?\}\})', 1), ("resume*.html", r'class="os-desc">(\{\{.*?\}\})', 1), ("resume*.html", r'class="art-stats">(\{\{.*?\}\})', 1), ("portfolio*.html", r'class="project-block">\s*

[^<]*

\s*

(\{\{.*?\}\})

', 3), ("portfolio*.html", r'class="project-type">(\{\{.*?\}\})', 1), ("portfolio*.html", r'class="project-date">(\{\{.*?\}\})', 1), ("one-pager*.html", r'
  • (\{\{(?:短 bullet|Short bullet|짧은 bullet).*?\}\})
  • ', 3), ("long-doc*.html", r'(\{\{(?:一段论述|A paragraph|한 단락 논술).*?\}\})', 1), ) def test_sibling_placeholder_hints_stay_in_parity() -> None: """Same-structure sibling blocks must carry identical placeholder hints.""" matched = 0 offenders: list[str] = [] for glob_pattern, regex, cycle in _SIBLING_PARITY_SPECS: rx = re.compile(regex, re.DOTALL) for path in sorted(TEMPLATES.glob(glob_pattern)): hits = rx.findall(path.read_text(encoding="utf-8")) if not hits: offenders.append(f"{path.name}: no match for {regex[:40]!r} (stale spec?)") continue matched += 1 if len(hits) % cycle != 0: offenders.append(f"{path.name}: {len(hits)} hint(s) not divisible by cycle {cycle}") continue first = hits[:cycle] for start in range(cycle, len(hits), cycle): block = hits[start:start + cycle] if block != first: offenders.append( f"{path.name}: block {start // cycle + 1} diverges from block 1: " f"{block} != {first}") check("sibling parity specs matched across template families", matched >= 30, f"only {matched} template/spec matches; spec table may be stale") check("repeated blocks carry identical placeholder hints", not offenders, "; ".join(offenders[:6])) def test_font_fallback_markers_recognize_pt_serif() -> None: """macOS without Charter may render English fallbacks as PT Serif.""" embedded = {"DROIWJ+PT-Serif", "ZBEAAE+JetBrains-Mono"} fallback_present = any( marker in font for font in embedded for marker in RECOGNIZABLE_FALLBACK_FONT_MARKERS ) check("font fallback markers recognize PT-Serif", fallback_present, f"markers: {RECOGNIZABLE_FALLBACK_FONT_MARKERS}") def test_chinese_slides_mono_has_cjk_fallback() -> None: """Slide labels may mix mono Latin and CJK; the mono stack needs CJK fallback.""" text = (TEMPLATES / "slides-weasy.html").read_text(encoding="utf-8") check("slides-weasy mono stack includes TsangerJinKai02 fallback", '"TsangerJinKai02"' in text and '"Source Han Serif SC"' in text) # --------------------------- scan_file --------------------------- def test_scan_file_skip_bug() -> None: """Lines starting with '#' (CSS id selectors) must NOT be skipped.""" fixture = """ """ p = write_temp_html(fixture) try: findings = scan_file(p) rules = {f.rule for f in findings} check("scan_file flags rgba on #id-prefixed CSS line", "rgba-background" in rules, f"rules found: {rules or '(none)'}") finally: p.unlink(missing_ok=True) def test_scan_file_arrow_in_en() -> None: """`→` in -en.html body should trigger arrow-unicode-in-en.""" fixture = """

    Step 1 → Step 2

    """ p = write_temp_html(fixture, suffix="-en.html") try: findings = scan_file(p) rules = {f.rule for f in findings} check("scan_file flags U+2192 arrow in -en.html", "arrow-unicode-in-en" in rules, f"rules found: {rules or '(none)'}") finally: p.unlink(missing_ok=True) def test_scan_file_clean_template() -> None: """A clean template should produce zero findings.""" fixture = """ """ p = write_temp_html(fixture) try: findings = scan_file(p) check("scan_file produces no findings on clean template", len(findings) == 0, f"got {len(findings)} finding(s): {[f.rule for f in findings]}") finally: p.unlink(missing_ok=True) # --------------------------- slide sequence --------------------------- def test_parse_slide_sequence_empty() -> None: fixture = """def main(): pass """ p = write_temp_html(fixture, suffix=".py") try: seq = _parse_slide_sequence(p) check("_parse_slide_sequence returns [] for empty main()", seq == [], f"got {seq}") finally: p.unlink(missing_ok=True) def test_parse_slide_sequence_basic() -> None: fixture = """def main(): cover_slide() content_slide() content_slide() chapter_slide() metrics_slide() def helper(): other_call() """ p = write_temp_html(fixture, suffix=".py") try: seq = _parse_slide_sequence(p) expected = ["cover_slide", "content_slide", "content_slide", "chapter_slide", "metrics_slide"] check("_parse_slide_sequence parses ordered slide calls", seq == expected, f"got {seq}") finally: p.unlink(missing_ok=True) # --------------------------- scan_file extra rules --------------------------- def test_scan_file_line_height_too_loose() -> None: """line-height >= 1.6 should trigger line-height-too-loose.""" fixture = """ """ p = write_temp_html(fixture) try: findings = scan_file(p) rules = {f.rule for f in findings} check("scan_file flags line-height 1.8 (too loose)", "line-height-too-loose" in rules, f"rules found: {rules or '(none)'}") finally: p.unlink(missing_ok=True) def test_scan_file_cool_gray() -> None: """Cool-gray hex literals should be flagged.""" fixture = """ """ p = write_temp_html(fixture) try: findings = scan_file(p) rules = {f.rule for f in findings} check("scan_file flags cool gray #888", "cool-gray" in rules, f"rules found: {rules or '(none)'}") finally: p.unlink(missing_ok=True) def test_off_palette_flags_non_token_hex() -> None: """A non-token, non-cool-gray hex in a component rule is off-palette.""" fixture = """ """ p = write_temp_html(fixture) try: findings = _off_palette_findings(p, {"#1b365d"}) rules = {f.rule for f in findings} check("_off_palette_findings flags non-token hex #ff00aa", "off-palette" in rules, f"rules found: {rules or '(none)'}") finally: p.unlink(missing_ok=True) def test_off_palette_ignores_root_and_svg() -> None: """Hex inside :root token defs and inside blocks must be skipped.""" fixture = """ """ p = write_temp_html(fixture) try: findings = _off_palette_findings(p, {"#1b365d"}) check("_off_palette_findings skips :root defs and fills", findings == [], f"unexpected findings: {[(f.line, f.excerpt) for f in findings]}") finally: p.unlink(missing_ok=True) def test_root_token_findings_flags_off_palette_definition() -> None: """An off-palette hex *defined* in :root (never used as a literal property hex) escapes _off_palette_findings, which blanks :root. _root_token_findings closes that gap: a stray `--brand-deep: #a64f33` second accent is flagged, while a registered token and cool-gray (reported elsewhere) are not.""" fixture = """ """ p = write_temp_html(fixture) try: findings = _root_token_findings(p, {"#1b365d"}) rules = {f.rule for f in findings} excerpts = " ".join(f.excerpt for f in findings) check("_root_token_findings flags off-palette :root token #a64f33", "off-palette-token" in rules and "#a64f33" in excerpts, f"rules={rules or '(none)'} excerpts={excerpts or '(none)'}") check("_root_token_findings does not flag the registered --brand token", "#1b365d" not in excerpts, f"unexpectedly flagged brand token: {excerpts}") finally: p.unlink(missing_ok=True) def test_off_palette_repo_clean() -> None: """The real editorial templates must carry no off-palette colors.""" rc = silently(check_off_palette) check("check_off_palette passes on the real templates", rc == 0, f"check_off_palette returned {rc}") def test_check_update_script() -> None: """check-update.sh notifies on a newer remote, stays silent when current, throttles to once per day, and fails silently offline. It only reads a version file and sends no data; KAMI_UPDATE_URL points it at a fixture.""" script = REPO_ROOT / "scripts" / "check-update.sh" check("check-update.sh exists", script.exists()) if not script.exists(): return if shutil.which("bash") is None or shutil.which("curl") is None: check("check-update.sh behavior (skipped: bash/curl unavailable)", True) return local_ver = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() def run(cache: str, url: str) -> tuple[int, str]: env = dict(os.environ, XDG_CACHE_HOME=cache, KAMI_UPDATE_URL=url) r = subprocess.run(["bash", str(script)], capture_output=True, text=True, env=env) return r.returncode, r.stdout.strip() with tempfile.TemporaryDirectory() as d: dp = Path(d) newer = dp / "newer"; newer.write_text("9.9.9\n") same = dp / "same"; same.write_text(local_ver + "\n") rc, out = run(str(dp / "c1"), newer.as_uri()) check("check-update notifies on a newer remote", rc == 0 and "9.9.9" in out, out) check("check-update default command uses plugin bundle path", "npx skills add tw93/kami/plugins/kami -a universal -g -y" in out and "skills update" not in out, out) rc, out = run(str(dp / "c2"), same.as_uri()) check("check-update is silent when current", rc == 0 and out == "", out) c3 = str(dp / "c3") run(c3, newer.as_uri()) _, out2 = run(c3, newer.as_uri()) check("check-update throttles to once per day", out2 == "", out2) rc, out = run(str(dp / "c4"), (dp / "nope").as_uri()) check("check-update fails silently when offline", rc == 0 and out == "", out) def test_check_update_uses_codex_plugin_update_command() -> None: """When installed through Codex plugin cache, the update hint should use plugin marketplace refresh commands instead of the legacy npx skill update. """ script = REPO_ROOT / "scripts" / "check-update.sh" check("check-update.sh exists for Codex command test", script.exists()) if not script.exists(): return if shutil.which("bash") is None or shutil.which("curl") is None: check("check-update Codex command (skipped: bash/curl unavailable)", True) return with tempfile.TemporaryDirectory() as d: dp = Path(d) newer = dp / "newer" newer.write_text("9.9.9\n") roots = [ dp / ".codex" / "plugins" / "cache" / "kami" / "kami" / "1.7.4" / "skills" / "kami", dp / "custom-codex-home" / "plugins" / "cache" / "kami" / "kami" / "1.7.4" / "skills" / "kami", ] for index, install_root in enumerate(roots, start=1): (install_root / "scripts").mkdir(parents=True) shutil.copy2(script, install_root / "scripts" / "check-update.sh") (install_root / "VERSION").write_text("1.7.4\n") env = dict(os.environ, XDG_CACHE_HOME=str(dp / f"cache-{index}"), KAMI_UPDATE_URL=newer.as_uri()) result = subprocess.run( ["bash", str(install_root / "scripts" / "check-update.sh")], capture_output=True, text=True, env=env, ) out = result.stdout.strip() check(f"check-update uses Codex plugin update command ({install_root.parent.parent.parent.name})", result.returncode == 0 and "codex plugin marketplace upgrade kami" in out, out) def test_check_update_uses_claude_plugin_update_command() -> None: """When installed through Claude Code's plugin cache, the update hint should use Claude's plugin updater instead of generic npx skill install. """ script = REPO_ROOT / "scripts" / "check-update.sh" check("check-update.sh exists for Claude command test", script.exists()) if not script.exists(): return if shutil.which("bash") is None or shutil.which("curl") is None: check("check-update Claude command (skipped: bash/curl unavailable)", True) return with tempfile.TemporaryDirectory() as d: dp = Path(d) newer = dp / "newer" newer.write_text("9.9.9\n") install_root = dp / ".claude" / "plugins" / "cache" / "kami" / "kami" / "1.9.1" / "skills" / "kami" (install_root / "scripts").mkdir(parents=True) shutil.copy2(script, install_root / "scripts" / "check-update.sh") (install_root / "VERSION").write_text("1.9.1\n") env = dict(os.environ, XDG_CACHE_HOME=str(dp / "cache"), KAMI_UPDATE_URL=newer.as_uri()) result = subprocess.run( ["bash", str(install_root / "scripts" / "check-update.sh")], capture_output=True, text=True, env=env, ) out = result.stdout.strip() check("check-update uses Claude plugin update command", result.returncode == 0 and "claude plugin update kami" in out and "npx skills" not in out, out) def test_lint_repo_clean() -> None: """The full CSS lint (scan_file across every template) must pass. This is what `build.py --check` runs; covering it here means a rule violation such as thin-border-radius cannot reach main behind an otherwise green suite.""" rc = silently(check_all, False) check("check_all (full CSS lint) passes on the real templates", rc == 0, f"check_all returned {rc}") def test_scan_file_ignores_block_comment_rgba() -> None: """rgba() inside a /* ... */ CSS block comment must not trigger findings.""" fixture = """ """ p = write_temp_html(fixture) try: findings = scan_file(p) rules = {f.rule for f in findings} check("scan_file ignores rgba inside /* */ comment", "rgba-background" not in rules, f"rules found: {rules or '(none)'}") finally: p.unlink(missing_ok=True) def test_scan_file_thin_border_with_radius() -> None: """Sub-1pt closed border in a block with border-radius should fire pitfall #2.""" fixture = """ """ p = write_temp_html(fixture) try: findings = scan_file(p) rules = {f.rule for f in findings} check("scan_file flags thin border with border-radius", "thin-border-radius" in rules, f"rules found: {rules or '(none)'}") finally: p.unlink(missing_ok=True) # --------------------------- check_placeholders --------------------------- def test_check_placeholders_flags_unfilled() -> None: """A doc with `{{ name }}` left over should fail the check.""" p = write_temp_html("

    {{ name }}

    {{ role }}

    ") try: rc = silently(check_placeholders, [str(p)]) check("check_placeholders fails on {{ name }}", rc == 1, f"rc={rc}") finally: p.unlink(missing_ok=True) def test_check_placeholders_passes_clean() -> None: """A doc with no placeholder syntax should pass.""" p = write_temp_html("

    Real Name

    Real role

    ") try: rc = silently(check_placeholders, [str(p)]) check("check_placeholders passes clean file", rc == 0, f"rc={rc}") finally: p.unlink(missing_ok=True) def test_markdown_residue_flags_raw_markers() -> None: issues = _markdown_residue_issues("Intro\n---\nThis has **raw bold** and `raw code`.") check("markdown residue flags thematic breaks", any("thematic break" in issue for issue in issues), f"issues={issues}") check("markdown residue flags raw bold markers", any("bold marker" in issue for issue in issues), f"issues={issues}") check("markdown residue flags raw inline-code markers", any("inline-code marker" in issue for issue in issues), f"issues={issues}") check("markdown residue ignores clean text", _markdown_residue_issues("Clean paragraph with converted emphasis.") == []) def test_check_markdown_residue_skips_html_code_blocks() -> None: dirty = write_temp_html("

    Visible **raw bold**

    ", suffix=".html") clean_code = write_temp_html( "

    Visible text

    **example** `cmd`
    ", suffix=".html", ) try: rc = silently(check_markdown_residue, [str(dirty)]) check("check_markdown_residue fails visible raw markdown", rc == 1, f"rc={rc}") rc = silently(check_markdown_residue, [str(clean_code)]) check("check_markdown_residue skips code/pre blocks", rc == 0, f"rc={rc}") finally: dirty.unlink(missing_ok=True) clean_code.unlink(missing_ok=True) # --------------------------- cross-template consistency --------------------------- def test_pair_names_includes_known_pairs() -> None: captured = list(_pair_names()) check("pair_names includes one-pager", ("one-pager", "one-pager-en") in captured, f"got {[v for b, v in captured if b == 'one-pager']!r}") check("pair_names includes landing-page (CN/EN)", ("landing-page", "landing-page-en") in captured, f"got {[v for b, v in captured if b == 'landing-page']!r}") check("pair_names omits lone -en entries", not any(name.endswith("-en") for name, _ in _pair_names())) def test_pair_names_includes_ko_variants_when_present() -> None: """`_pair_names` must yield (base, base-ko) pairs in addition to (base, base-en).""" captured = list(_pair_names()) # Sanity: existing CN/EN and CN/KO pairs still detected, so the sweep # below cannot pass vacuously on an empty or EN-only pair list. check("CN/EN pair still detected", ("one-pager", "one-pager-en") in captured) check("CN/KO pair still detected", ("one-pager", "one-pager-ko") in captured) # Any base whose `-ko` sibling is registered must appear as a (base, base-ko) pair. bases = {base for base, _ in captured} seen = set(HTML_TEMPLATES) | set(SCREEN_TEMPLATES) missing = [ base for base in bases if f"{base}-ko" in seen and (base, f"{base}-ko") not in captured ] check("pair_names includes ko variants when present", not missing, f"unpaired KO bases: {missing}") def test_cross_template_consistency_clean() -> None: """The current repo should pass cross-template consistency.""" rc = silently(check_cross_template_consistency, False) check("cross-template returns 0 on current repo", rc == 0, f"rc={rc}") def test_extract_root_vars_picks_up_definitions() -> None: fixture = """ """ p = write_temp_html(fixture) try: vars_ = _extract_root_vars(p) check("extract_root_vars finds --brand", vars_.get("--brand") == "#1B365D", f"got {vars_.get('--brand')!r}") check("extract_root_vars finds --parchment", vars_.get("--parchment") == "#F5F4ED", f"got {vars_.get('--parchment')!r}") finally: p.unlink(missing_ok=True) # --------------------------- _last_content_y --------------------------- def _make_samples(rows_with_content: int, w: int, h: int, n: int = 3) -> bytes: """Build a flat RGB buffer: parchment everywhere, ink in the top N rows. Returns bytes matching the layout PyMuPDF's Pixmap uses, so we can drive _last_content_y without depending on a real PDF or numpy. """ parchment_row = bytes((_BG_R, _BG_G, _BG_B)) * w ink_row = bytes((27, 54, 93)) * w out = bytearray() for y in range(h): out.extend(ink_row if y < rows_with_content else parchment_row) return bytes(out) def test_last_content_y_dense_page() -> None: """Page with content all the way to the bottom: returns h-1.""" w, h, n = 80, 100, 3 samples = _make_samples(rows_with_content=h, w=w, h=h, n=n) y = _last_content_y(samples, w, h, w * n, n) check("_last_content_y dense page returns last row", y == h - 1, f"got {y}") def test_last_content_y_sparse_page() -> None: """Page with content only in top 10 rows: returns 9.""" w, h, n = 80, 100, 3 samples = _make_samples(rows_with_content=10, w=w, h=h, n=n) y = _last_content_y(samples, w, h, w * n, n) check("_last_content_y sparse page returns last content row", y == 9, f"got {y}") def test_last_content_y_blank_page() -> None: """Page with no content at all: returns 0.""" w, h, n = 80, 100, 3 samples = _make_samples(rows_with_content=0, w=w, h=h, n=n) y = _last_content_y(samples, w, h, w * n, n) check("_last_content_y blank page returns 0", y == 0, f"got {y}") def test_density_threshold_buckets() -> None: """Drive the real `_density_bucket` seam so the SPARSE (>50%) / WARN (>25%) / OK categorization is asserted against production logic, not a reimplemented copy. A `>`->`>=` slip or a warn/sparse swap in checks.py fails here.""" density_cfg = load_checks_thresholds()["density"] warn_pct = float(density_cfg["warn_pct"]) sparse_pct = float(density_cfg["sparse_pct"]) cases = [ (0.0, "OK"), # full page (warn_pct, "OK"), # exactly at warn threshold -> not yet WARN (strict >) (0.30, "WARN"), # 30% trailing (sparse_pct, "WARN"), # exactly at sparse threshold -> still WARN (strict >) (0.51, "SPARSE"), # 51% trailing (1.0, "SPARSE"), # blank page ] for empty, expected_bucket in cases: bucket = _density_bucket(empty, warn_pct, sparse_pct) check( f"_density_bucket empty={empty:.2f} -> {expected_bucket}", bucket == expected_bucket, f"got {bucket}", ) def test_rhythm_issues_rules() -> None: """Drive the three monotony rules in `_rhythm_issues` directly, without rendering a deck. Covers content-run limit, missing divider, and missing density-variation slide, plus the clean case.""" max_run, min_deck = 4, 8 healthy = [ "title_slide", "content_slide", "content_slide", "quote_slide", "chapter_slide", "content_slide", "metrics_slide", "closing_slide", ] check("rhythm: balanced deck has no issues", _rhythm_issues(healthy, max_run, min_deck) == [], f"got {_rhythm_issues(healthy, max_run, min_deck)}") long_run = ["quote_slide"] + ["content_slide"] * (max_run + 1) issues = _rhythm_issues(long_run, max_run, min_deck) check("rhythm: over-long content run flagged", any("content_slide run" in i for i in issues), f"got {issues}") no_divider = ["title_slide"] + ["content_slide", "quote_slide"] * 5 issues = _rhythm_issues(no_divider, max_run, min_deck) check("rhythm: large deck without divider flagged", any("no chapter_slide divider" in i for i in issues), f"got {issues}") no_variation = ["title_slide", "content_slide", "chapter_slide", "content_slide"] issues = _rhythm_issues(no_variation, max_run, min_deck) check("rhythm: deck without quote/metrics flagged", any("density variation" in i for i in issues), f"got {issues}") def test_orphan_last_line_predicate() -> None: """Drive `_orphan_last_line`: a short trailing line on a multi-line block is an orphan; single-line blocks and long trailing lines are not.""" max_words, max_chars = 3, 30 orphan = "This is a full sentence that wraps\nword" check("orphan: short trailing line detected", _orphan_last_line(orphan, max_words, max_chars) == "word", f"got {_orphan_last_line(orphan, max_words, max_chars)!r}") single = "Only one line here" check("orphan: single-line block is not an orphan", _orphan_last_line(single, max_words, max_chars) is None, "single line flagged") long_tail = "First line of the block\n" + "x" * (max_chars + 5) check("orphan: long trailing line is not an orphan", _orphan_last_line(long_tail, max_words, max_chars) is None, "long tail flagged") many_words = "First line here\none two three four five" check("orphan: wordy trailing line is not an orphan", _orphan_last_line(many_words, max_words, max_chars) is None, "wordy tail flagged") def test_resume_balance_issues() -> None: min_fill, max_fill, max_gap = 0.83, 0.95, 0.12 check("resume balance accepts two filled pages", _resume_balance_issues([0.88, 0.92], 2, min_fill, max_fill, max_gap) == []) issues = _resume_balance_issues([0.92, 0.74], 2, min_fill, max_fill, max_gap) check("resume balance flags low second page", any("p2 fill" in issue for issue in issues), f"issues={issues}") check("resume balance flags page gap", any("gap" in issue for issue in issues), f"issues={issues}") issues = _resume_balance_issues([0.90, 0.89, 0.50], 3, min_fill, max_fill, max_gap) check("resume balance requires two pages", any("expected 2" in issue for issue in issues), f"issues={issues}") # --------------------------- runner --------------------------- def test_highlight_with_language() -> None: html = '
    def foo():\n    pass
    ' out = highlight_code_blocks(html) if importlib.util.find_spec("pygments") is None: check("highlight skips styled output when Pygments is absent", out == html, f"out differs: {out[:200]}") return check("highlight adds style spans to language-tagged block", "" in out) def test_highlight_without_language() -> None: html = '
    def foo():\n    pass
    ' out = highlight_code_blocks(html) check("highlight does not modify plain code block", out == html, f"out differs: {out[:200]}") def test_highlight_without_pygments_dependency() -> None: html = '
    def foo():\n    pass
    ' original_import = builtins.__import__ original_warned = highlight_mod._WARNED_MISSING_PYGMENTS def fake_import(name, *args, **kwargs): if name == "pygments" or name.startswith("pygments."): raise ImportError("blocked for fallback test") return original_import(name, *args, **kwargs) try: highlight_mod._WARNED_MISSING_PYGMENTS = False builtins.__import__ = fake_import warning = io.StringIO() with contextlib.redirect_stderr(warning): out = highlight_code_blocks(html) finally: builtins.__import__ = original_import highlight_mod._WARNED_MISSING_PYGMENTS = original_warned check("highlight falls back unchanged without Pygments", out == html, f"out differs: {out[:200]}") check("highlight warns when Pygments is missing", "WARN: Pygments is not installed" in warning.getvalue(), f"warning: {warning.getvalue()}") def test_marp_themes_token_synced() -> None: """Marp theme CSS keeps its :root tokens in sync with tokens.json. Locks the invariant AGENTS.md documents (tokens.py globs marp/*.css), so the Marp decks cannot silently drift even if that glob is later refactored away. """ from shared import TOKENS_FILE from tokens import CSS_VAR, ROOT_BLOCK canonical = {k.lstrip("-"): v.strip().lower() for k, v in json.loads(TOKENS_FILE.read_text(encoding="utf-8")).items()} marp_files = sorted((TEMPLATES / "marp").glob("*.css")) check("marp theme CSS present", len(marp_files) >= 1, f"found {len(marp_files)} file(s)") drift: list[str] = [] checked = 0 for path in marp_files: block = ROOT_BLOCK.search(path.read_text(encoding="utf-8", errors="replace")) if not block: continue checked += 1 found = {m.group(1): m.group(2).strip().lower() for m in CSS_VAR.finditer(block.group(1))} for name, expected in canonical.items(): actual = found.get(name) if actual is not None and actual != expected: drift.append(f"{path.name}: --{name} expected {expected}, got {actual}") check("marp theme :root tokens match tokens.json", checked >= 1 and not drift, "; ".join(drift) if drift else f"checked {checked}, no :root block found") def test_mermaid_theme_matches_tokens() -> None: from shared import TOKENS_FILE canonical = json.loads(TOKENS_FILE.read_text(encoding="utf-8")) issues = _mermaid_theme_drift(canonical) check("mermaid theme colors and role docs match tokens.json", issues == [], f"issues: {issues}") def test_mermaid_theme_drift_flags_token_mismatch() -> None: from shared import TOKENS_FILE canonical = json.loads(TOKENS_FILE.read_text(encoding="utf-8")) canonical["--brand"] = "#000000" issues = _mermaid_theme_drift(canonical) check("mermaid theme drift flags accent token mismatch", any("accent" in issue and "--brand" in issue for issue in issues), f"issues: {issues}") def test_mermaid_normalize_defaults_match_theme() -> None: import mermaid_normalize as mermaid_mod theme = json.loads((REPO_ROOT / "references" / "mermaid-theme.json").read_text(encoding="utf-8")) expected_colors = {f"--{key}": value for key, value in theme["colors"].items()} check("mermaid normalizer fallback colors mirror mermaid-theme.json", mermaid_mod._DEFAULT_COLORS == expected_colors, f"default={mermaid_mod._DEFAULT_COLORS}, theme={expected_colors}") check("mermaid normalizer fallback font mirrors mermaid-theme.json", mermaid_mod._DEFAULT_FONT_STACK == theme["cssFontStack"], f"default={mermaid_mod._DEFAULT_FONT_STACK}, theme={theme['cssFontStack']}") # --------------------------- mermaid normalize --------------------------- def test_mermaid_color_mix_srgb_single_pct() -> None: from mermaid_normalize import _Resolver r = _Resolver({"--fg": "#141413", "--bg": "#f5f4ed"}) # color-mix(in srgb, fg 12%, bg) == 0.12*fg + 0.88*bg got = r.hex_of("color-mix(in srgb, var(--fg) 12%, var(--bg))") check("color-mix(in srgb, fg 12%, bg) resolves to warm gray", got == "#dad9d3", f"got {got}") def test_mermaid_color_mix_both_pct() -> None: from mermaid_normalize import _Resolver r = _Resolver({"--bg": "#ffffff", "--c": "#000000"}) got = r.hex_of("color-mix(in srgb, var(--bg) 75%, var(--c) 25%)") check("color-mix honors both explicit percentages", got == "#bfbfbf", f"got {got}") def test_mermaid_normalize_strips_unsafe_features() -> None: from mermaid_normalize import normalize # Root carries a deliberately NON-Kami theme (red accent, white bg) to prove # the normalizer re-themes to the Kami palette regardless of source theme. raw = ( '' "" '' ) out = normalize(raw) check("normalize removes color-mix()", "color-mix(" not in out, out) check("normalize removes var()", "var(" not in out, out) check("normalize removes google-fonts import", "googleapis" not in out, out) check("normalize drops the quoted single-family bug", "'Charter'" not in out, out) check("normalize keeps the Kami CJK serif stack", "TsangerJinKai02" in out, out) check("normalize resolves fill to a static hex", 'fill="#' in out, out) check("normalize re-themes accent to Kami ink-blue", "#1b365d" in out.lower(), out) check("normalize drops the source theme's red accent", "#ff0000" not in out.lower(), out) def test_mermaid_lint_flags_unnormalized_svg() -> None: body = '' path = write_temp_html(body, suffix=".html") # not a screen-template name try: rules = {f.rule for f in scan_file(path)} check("scan_file flags un-normalized mermaid color-mix", "mermaid-color-mix" in rules, f"rules={rules}") finally: path.unlink(missing_ok=True) def test_mermaid_diagram_templates_normalized() -> None: for name in ("sequence.html", "class.html", "er.html"): path = REPO_ROOT / "assets" / "diagrams" / name check(f"diagram {name} exists", path.exists(), f"missing {path}") if not path.exists(): continue text = path.read_text(encoding="utf-8") check(f"{name} carries no color-mix()", "color-mix(" not in text) check(f"{name} carries no ", " None: """The committed diagram HTML must still carry every node/participant/entity label from its .mmd source. No Node regenerates these, so this guards against a .mmd edit that silently leaves the committed SVG stale.""" src_dir = REPO_ROOT / "assets" / "diagrams" / "src" sources = sorted(src_dir.glob("*.mmd")) check("diagram .mmd sources present", len(sources) >= 1, f"found {len(sources)}") for mmd in sources: html_path = REPO_ROOT / "assets" / "diagrams" / f"{mmd.stem}.html" check(f"{mmd.stem}.html exists for {mmd.name}", html_path.exists()) if not html_path.exists(): continue labels: set[str] = set() for line in mmd.read_text(encoding="utf-8").splitlines(): line = line.strip() m = re.match(r"participant\s+\S+\s+as\s+(.+)", line) if m: labels.add(m.group(1).strip()) m = re.match(r"class\s+(\w+)", line) if m: labels.add(m.group(1)) labels.update(re.findall(r"\b([A-Z][A-Z_]{2,})\b", line)) # ER entities body = html_path.read_text(encoding="utf-8") missing = sorted(label for label in labels if label not in body) check(f"{mmd.stem}.html carries all {mmd.name} labels", not missing, f"missing labels (regenerate the diagram): {missing}") def test_mermaid_normalize_rejects_non_beautiful_mermaid() -> None: """A non-beautiful-mermaid SVG (no --bg/--fg roles) must raise, not silently emit unresolved colors.""" from mermaid_normalize import normalize raised = False try: normalize('') except ValueError: raised = True check("normalize rejects input lacking --bg/--fg color roles", raised) def test_mermaid_normalize_cli_accepts_output_before_input() -> None: """CLI parsing should accept both `input -o out` and `-o out input`.""" script = REPO_ROOT / "scripts" / "mermaid_normalize.py" raw = ( '' '' ) with tempfile.TemporaryDirectory() as d: dp = Path(d) src = dp / "raw.svg" out = dp / "clean.svg" src.write_text(raw, encoding="utf-8") result = subprocess.run( [sys.executable, str(script), "-o", str(out), str(src)], cwd=REPO_ROOT, capture_output=True, text=True, ) body = out.read_text(encoding="utf-8") if out.exists() else "" check("mermaid_normalize CLI supports -o before input", result.returncode == 0 and out.exists() and "color-mix(" not in body and "var(" not in body, (result.stdout + result.stderr).strip()) def test_mermaid_normalize_cli_reports_missing_input() -> None: """Missing input should be a concise ERROR, not a Python traceback.""" script = REPO_ROOT / "scripts" / "mermaid_normalize.py" with tempfile.TemporaryDirectory() as d: result = subprocess.run( [sys.executable, str(script), str(Path(d) / "missing.svg")], cwd=REPO_ROOT, capture_output=True, text=True, ) combined = result.stdout + result.stderr check("mermaid_normalize CLI reports missing input without traceback", result.returncode == 1 and "ERROR:" in combined and "Traceback" not in combined, combined.strip()) def _test_functions(): tests = [] for name, func in globals().items(): if not name.startswith("test_") or not callable(func): continue if getattr(func, "__module__", None) != __name__: continue code = getattr(func, "__code__", None) if code is None: continue tests.append((code.co_firstlineno, name, func)) return [(name, func) for _, name, func in sorted(tests)] def main() -> int: for name, func in _test_functions(): signature = inspect.signature(func) if signature.parameters: params = ", ".join(signature.parameters) check(f"{name} has no parameters", False, f"parameters: {params}") continue func() print() print(f"Passed: {_PASS} | Failed: {_FAIL}") return 0 if _FAIL == 0 else 1 if __name__ == "__main__": sys.exit(main())