190 lines
9.6 KiB
Python
190 lines
9.6 KiB
Python
#!/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(
|
|
"<!doctype html><main>already built</main>\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)
|