#!/usr/bin/env python3 """test_p1_design.py — P1-C (#16) 디자인 파이프라인 discovery-first + 실질 품질검증. 검증 대상: 1. design-system.md — discovery 단계 + reuse/adapt/create 판단 + greenfield-react preset framing (스택이 고정 기본값이 아니라 preset/선택지로 격하됨). 2. preview_ui.py — 신규 품질 체크(반응형/대비/포커스/상태/렌더검증)를 노출하고, 깨진/degraded 빌드를 성공으로 위장하지 않는다(fail-loud). 브라우저 없이 로직/플래그를 검증. 3. design-brief-spec.yaml — discovery 지원 필드(existing-system, stack-decision) additive. 브라우저(npm/vite/chrome)를 요구하지 않는다 — fail-loud 경로와 정적 로직만 친다. 실행: CLAUDE_PROJECT_DIR="$PWD" python3 .claude/tests/test_p1_design.py """ import importlib.util import os import subprocess import sys import tempfile from unittest import mock ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) HOOKS = os.path.join(ROOT, ".claude", "hooks") PY = sys.executable passed = failed = 0 def check(name, ok): global passed, failed if ok: passed += 1 print(f" PASS {name}") else: failed += 1 print(f" FAIL {name}") def run_pv(args, **kw): e = dict(os.environ) e["CLAUDE_PROJECT_DIR"] = ROOT return subprocess.run([PY, os.path.join(HOOKS, "preview_ui.py")] + args, capture_output=True, text=True, env=e, **kw) # ── 1) design-system.md: discovery-first + preset framing ────────────── print("== design-system.md discovery-first + preset ==") DSM = os.path.join(ROOT, ".claude", "commands", "design-system.md") _dsm = open(DSM, encoding="utf-8").read() _low = _dsm.lower() check("design-system.md has a DISCOVERY step", "discovery" in _low) check("design-system.md has reuse/adapt/create decision", all(k in _low for k in ("reuse", "adapt", "create"))) check("design-system.md frames React+Vite as a preset (not fixed default)", "preset" in _low and "greenfield-react" in _low) check("design-system.md says stack is not hard-fixed (discovery decides)", ("고정" in _dsm and "discovery" in _low) or "고정 아님" in _dsm or "고정값이 아니라" in _dsm) check("design-system.md prefers existing project stack/tokens/components", ("기존" in _dsm) and ("재사용" in _dsm or "reuse" in _low)) # discovery는 구현/preset 선택보다 먼저 와야 한다(순서 강제). _i_disc = _low.find("discovery") _i_impl = _low.find("greenfield-react") check("DISCOVERY appears before greenfield-react preset (order)", _i_disc != -1 and _i_impl != -1 and _i_disc < _i_impl) # 스크린샷 존재 != 품질을 문서로 명시. check("design-system.md states 'screenshot exists != quality'", "스크린샷 존재 ≠ 품질" in _dsm or "스크린샷 존재만으로 품질" in _dsm) # ── 2) preview_ui.py: new checks exposed + fail-loud ─────────────────── print("== preview_ui.py new checks + fail-loud ==") _help = run_pv(["--help"]) check("preview_ui.py --help works (rc0, mentions preview_ui)", _help.returncode == 0 and "preview_ui" in (_help.stdout + _help.stderr)) _h = _help.stdout + _help.stderr for flag in ("--viewports", "--check-css", "--contrast-only", "--states", "--allow-degraded"): check(f"preview_ui.py exposes {flag}", flag in _h) # 소스에 'screenshot exists != quality' 취지가 코드/주석으로 명시돼야 한다. _pvsrc = open(os.path.join(HOOKS, "preview_ui.py"), encoding="utf-8").read() check("preview_ui.py comment: screenshot existence != quality", "스크린샷이 존재한다 ≠ 품질" in _pvsrc or "스크린샷 존재 ≠ 품질" in _pvsrc or "존재≠품질" in _pvsrc) # fail-loud: 존재하지 않는 디렉터리 → 비영점, 'OK' 출력 금지. _bad = run_pv(["/no/such/dir/xyz123"]) check("nonexistent dir -> non-zero exit", _bad.returncode != 0) check("nonexistent dir -> no fake 'OK' success", "OK preview_ui" not in _bad.stdout) # fail-loud: package.json 없는 디렉터리 → 비영점(빌드 도달 전에 정직하게 실패). with tempfile.TemporaryDirectory() as td: _nopkg = run_pv([td]) check("dir without package.json -> non-zero exit", _nopkg.returncode != 0) check("dir without package.json -> no fake 'OK'", "OK preview_ui" not in _nopkg.stdout) # fail-loud (브라우저 없이 실제 품질 체크): --contrast-only 로 정적 대비/포커스. with tempfile.TemporaryDirectory() as td: bad_css = os.path.join(td, "bad.css") with open(bad_css, "w") as f: f.write(":root{ --accent:#dddddd; --on-accent:#cccccc; --ink:#eeeeee; --surface:#ffffff; }") _cbad = run_pv(["--contrast-only", bad_css]) check("contrast-only on low-contrast CSS -> non-zero (fail-loud)", _cbad.returncode != 0) check("contrast-only failure -> no fake 'OK'", "OK preview_ui" not in _cbad.stdout) good_css = os.path.join(td, "good.css") with open(good_css, "w") as f: f.write(":root{ --accent:#1b4dff; --on-accent:#ffffff; --ink:#111111; --surface:#ffffff; }") _cgood = run_pv(["--contrast-only", good_css]) check("contrast-only on accessible CSS -> rc0 pass", _cgood.returncode == 0) # --contrast-only on nonexistent path -> non-zero (no fake pass). _cmiss = run_pv(["--contrast-only", "/no/such/file.css"]) check("contrast-only missing path -> non-zero", _cmiss.returncode != 0) # no project_dir and no --contrast-only -> error (usage), non-zero. _none = run_pv([]) check("no project_dir and no --contrast-only -> non-zero", _none.returncode != 0) # ── 2b) preview_ui internal logic (import, no browser) ───────────────── print("== preview_ui.py logic (import) ==") _spec = importlib.util.spec_from_file_location("preview_ui", os.path.join(HOOKS, "preview_ui.py")) pv = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(pv) for fn in ("contrast_ratio", "check_contrast", "check_focus", "dom_is_empty", "build_project", "install_deps", "detect_pm", "detect_out_dir", "screenshot", "report_css_quality"): check(f"preview_ui exposes {fn}()", hasattr(pv, fn)) check("contrast_ratio(black,white) ~= 21", abs(pv.contrast_ratio((0, 0, 0), (255, 255, 255)) - 21.0) < 0.1) check("contrast_ratio(white,white) == 1", abs(pv.contrast_ratio((255, 255, 255), (255, 255, 255)) - 1.0) < 0.01) # check_contrast flags a critical pair _con = pv.check_contrast(":root{ --on-accent:#cccccc; --accent:#dddddd; }") check("check_contrast flags critical low-contrast pair", any(f["level"] == "critical" for f in _con)) _con2 = pv.check_contrast(":root{ --on-accent:#ffffff; --accent:#1b4dff; }") check("check_contrast passes accessible pair", _con2 and all(f["level"] == "ok" for f in _con2)) # check_focus: killed outline w/o replacement -> not ok; with box-shadow -> ok _fk, _ = pv.check_focus(".b:focus-visible{ outline:none; }") check("check_focus: outline killed w/o replacement -> not ok", _fk is False) _fo, _ = pv.check_focus(".b:focus-visible{ outline:none; box-shadow:0 0 0 3px var(--r); }") check("check_focus: outline killed but box-shadow present -> ok", _fo is True) # --no-build is a render-only path. A missing node_modules directory must not # trigger package installation or a build before the existing dist is rendered. with tempfile.TemporaryDirectory() as td: open(os.path.join(td, "package.json"), "w", encoding="utf-8").write("{}\n") os.makedirs(os.path.join(td, "dist")) open(os.path.join(td, "dist", "index.html"), "w", encoding="utf-8").write( "
already built
\n") old_argv = sys.argv sys.argv = [os.path.join(HOOKS, "preview_ui.py"), td, "--no-build"] no_build_exit = None try: with mock.patch.object(pv, "install_deps", side_effect=AssertionError("install called")), \ mock.patch.object(pv, "build_project", side_effect=AssertionError("build called")), \ mock.patch.object(pv, "find_chrome", return_value=None): try: pv.main() except SystemExit as exc: no_build_exit = exc.code except AssertionError: no_build_exit = "unexpected install/build" finally: sys.argv = old_argv check("--no-build skips dependency installation and build", no_build_exit == 1) # ── 3) design-brief-spec.yaml: additive discovery fields ─────────────── print("== design-brief-spec.yaml discovery fields (additive) ==") import yaml # noqa: E402 _dbs = yaml.safe_load(open(os.path.join(ROOT, "org-os/06-agent-work/design-brief-spec.yaml"), encoding="utf-8")) _root = _dbs["design-brief-spec"] _schema = _root["schema"] check("design-brief-spec.yaml is valid + has schema", isinstance(_schema, dict)) check("schema has existing-system block", "existing-system" in _schema) check("existing-system has stack + tokens-source + data-density", all(k in (_schema.get("existing-system") or {}) for k in ("stack", "tokens-source", "data-density"))) check("schema has stack-decision block", "stack-decision" in _schema) check("stack-decision has choice(reuse/adapt/create) + preset", all(k in (_schema.get("stack-decision") or {}) for k in ("choice", "preset", "rationale"))) # backward compatible: required-anchors unchanged (no new required anchor). check("required-anchors unchanged (backward compatible)", _root.get("required-anchors") == ["brief", "references", "tokens", "decisions", "donts"]) print(f"\n{passed} passed, {failed} failed") sys.exit(1 if failed else 0)