designpaca/build/eval/interview/test/route.test.mjs
Yun Chan 9bcb45cbd3 feat(core,cli): harden install engine and extend skill lint and interview eval
- update only touches the recorded project root; project installs do not
  follow symlinks or junctions; repeated --force rotates .orig backups;
  uninstall never deletes outside the recorded root
- run npm/npx without a shell on Windows and pin optional tool versions
- skip the update check in CI and non-interactive output
- lint: cross-document section pointers, preflight row ids, gate rule
  numbers, per-document size budgets, gate copy equality
- eval: scenarios S19-S21, route and read-cost rejudging, interpretation
  card judging, temp-dir previews and table-form cards no longer fail
2026-10-06 16:55:38 +09:00

122 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 경로 판정(V-36) 단위 테스트. 모델을 부르지 않는다. 표기 예문은 실제 실행 기록
// (outputs/eval/interview/runs/redteam-2026-10-02·-r2, 2026-10-02)에서 옮긴 것이다.
//
// node --test build/eval/interview/test/route.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { detectRoute, detectRoutes, judgeRoute, ROUTES } from "../lib/route.mjs";
import { normalizeClaude } from "../lib/judge.mjs";
import { staticJudgements } from "../lib/static.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SCENARIOS = JSON.parse(fs.readFileSync(path.join(HERE, "..", "scenarios.json"), "utf8"));
const byId = Object.fromEntries(SCENARIOS.map((s) => [s.id, s]));
const textEvents = (...texts) => texts.map((text) => ({ kind: "text", text }));
// [원문, 기대 경로]
const STATED = [
["국소 — 새 화면 없음, 토큰 재정의 없음, 섹션 1개.", "국소"], // SKILL.md 0-2 의 예문
["경로는 **연장**(새 화면, design.md 있음 — 톤·토큰은 이미 확정됨)이고, 고유명사 슬롯이 비어 있어 인터뷰가 필요합니다.", "연장"],
["경로 판정: 새 화면(A) + design.md 없음(C 아니오) → **전체 경로**. 필수 슬롯은 모두 확정됐으니 1단계로 진행합니다.", "전체"],
["리뷰 경로(국소 수정 없음, 평가만) — 0단계만 거치고 코드 기반 5′ 감사로 진행합니다.", "리뷰"],
["- 국소 경로(섹션 1개, 새 토큰 체계 아님)로 판단해 인터뷰 없이 바로 진행했습니다.", "국소"],
["국소 수정으로 진행합니다. 현재 기본색과 hover 색이 모두 `#d97757`이라 색 변화가 없습니다.", "국소"],
["국소 수정입니다. 같은 녹색 계열의 `#32493b`로 hover 색을 한 단계 어둡게 하겠습니다.", "국소"],
["새 사이트를 만드는 전체 경로로 진행합니다. 가정은 한국어 고객 대상의 작은 꽃집입니다.", "전체"],
["전체 제작 경로로 진행합니다.", "전체"],
["기존 `design.md`가 있어 디자인을 이어가는 ‘연장’ 경로입니다.", "연장"],
["범위: 평가만(리뷰 경로) · 실제 브라우저 렌더 도구가 이 환경에 없어 코드 기반 정적 분석으로 진행했습니다.", "리뷰"],
["방향을 바꾸는 요청이라 전체(리디자인) 경로로 갑니다. 기존 design.md 는 보존 자산 목록의 입력으로 씁니다.", "전체(리디자인)"],
["Local route — no new screen, no token redefinition, one section.", "국소"],
["Route: extension. The existing tokens stay as they are.", "연장"],
];
for (const [text, route] of STATED) {
test(`경로 표기를 읽는다: ${route} ← "${text.slice(0, 40)}…"`, () => {
assert.equal(detectRoute(text)?.route, route);
});
}
// 경로 표기가 아닌 말. 경로 이름이 들어 있어도 잡으면 안 된다.
const NOT_STATED = [
"다음 단계로 사진을 채우고 싶다면, 그건 리뷰가 아니라 국소/연장 구현 경로로 넘어가는 일이라 별도로 알려주면 된다.", // 나열
"톤·업종·토큰 체계는 이미 확정돼 있고 국소 색상 값 조정이라 인터뷰 없이 바로 진행한다.", // 풀어 쓴 말
"페이지 전체를 리뷰하면 될까요, 아니면 시각 디자인만 중점적으로 볼까요?",
"“상담 신청”은 연락처 자리표시자로 이동합니다. 실제 신청 경로가 필요합니다.",
"새 사이트이므로 전체 디자인 과정을 진행하고 있습니다.",
"The full page was reviewed at 1440 and 390.",
];
for (const text of NOT_STATED) {
test(`경로 표기가 아니다: "${text.slice(0, 40)}…"`, () => {
assert.equal(detectRoute(text), null, JSON.stringify(detectRoutes(text)));
});
}
test("ROUTES 는 SKILL.md 0-2 표의 경로 이름이다", () => {
assert.deepEqual(ROUTES, ["전체", "전체(리디자인)", "연장", "국소", "리뷰", "피드백"]);
});
test("경로를 올렸으면 마지막에 말한 경로가 최종이다", () => {
const r = detectRoute("국소 경로로 시작했습니다.\n\n손대는 섹션이 3개로 늘어 연장 경로로 올립니다.");
assert.equal(r.route, "연장");
assert.deepEqual(r.routes, ["국소", "연장"]);
});
test("강한 표기가 있으면 약한 표기(‘국소 수정으로’)는 쓰지 않는다", () => {
const r = detectRoute("연장 경로입니다. 가격 표는 새 화면이고, 푸터 문구는 국소 수정으로 진행하면 됩니다.");
assert.equal(r.route, "연장");
assert.deepEqual(r.routes, ["연장"]);
});
test("judgeRoute: 기대와 같으면 PASS, 다르면 FAIL, 표기가 없으면 REVIEW, 기대가 없으면 null", () => {
assert.equal(judgeRoute(byId.S6, textEvents("국소 수정으로 진행합니다.")).verdict, "PASS");
const fail = judgeRoute(byId.S6, textEvents("토큰을 새로 만들어야 해서 연장 경로로 올립니다."));
assert.equal(fail.verdict, "FAIL");
assert.match(fail.note, /경로 연장 — 기대는 국소/);
const review = judgeRoute(byId.S6, textEvents("hover 색을 한 단계 진하게 바꿨습니다."));
assert.equal(review.verdict, "REVIEW");
assert.equal(review.route, null);
assert.equal(judgeRoute(byId.S1, textEvents("전체 경로로 진행합니다.")), null);
});
test("judgeRoute: 전체 ↔ 전체(리디자인)은 REVIEW, expectedRoute 배열은 어느 하나와 같으면 PASS", () => {
const r = judgeRoute({ expectedRoute: "전체(리디자인)" }, textEvents("전체 경로로 진행합니다."));
assert.equal(r.verdict, "REVIEW");
assert.equal(judgeRoute({ expectedRoute: ["연장", "국소"] }, textEvents("연장 경로입니다.")).verdict, "PASS");
});
test("과한 경로(J-01): design.md 없는 기존 제품에 화면 추가를 전체로 판정하면 S19 는 FAIL", () => {
assert.equal(byId.S19.expectedRoute, "연장");
assert.equal(judgeRoute(byId.S19, textEvents("design.md 가 없어 전체 경로로 진행합니다. 먼저 톤을 여쭙겠습니다.")).verdict, "FAIL");
assert.equal(judgeRoute(byId.S19, textEvents("현행 색·서체가 코드에 있어 연장 경로입니다. 플랜과 가격을 알려 주세요.")).verdict, "PASS");
});
test("과잉 승격(J-12): 파생 상태 토큰 하나로 연장에 올리면 S20 은 FAIL, 국소에 머물면 PASS", () => {
assert.equal(byId.S20.expectedRoute, "국소");
const up = judgeRoute(byId.S20, textEvents("국소 경로로 시작했지만 `--color-accent-hover` 토큰을 새로 만들어야 해서 연장 경로로 올립니다."));
assert.equal(up.verdict, "FAIL");
assert.match(up.note, /국소 → 연장/);
const stay = judgeRoute(byId.S20, textEvents("국소 — 새 화면 없음, 섹션 1개. hover 색은 기존 강조색에서 파생한 상태 토큰이라 국소에 머뭅니다."));
assert.equal(stay.verdict, "PASS");
});
test("staticJudgements: 스킬이 호출되지 않은 실행은 경로를 판정하지 않는다", () => {
const noSkill = [{ kind: "text", text: "국소 수정으로 진행합니다." }];
assert.equal(staticJudgements(byId.S6, noSkill).route, null);
const raw = fs.readFileSync(path.join(HERE, "transcripts", "claude-s21-interpretation-pass.ndjson"), "utf8");
const st = staticJudgements(byId.S21, normalizeClaude(raw));
assert.equal(st.route.verdict, "PASS");
assert.equal(st.route.route, "전체");
});
test("expectedRoute 는 아는 경로 이름만 쓴다", () => {
for (const s of SCENARIOS) {
if (s.expectedRoute === undefined) continue;
for (const r of [].concat(s.expectedRoute)) assert.ok(ROUTES.includes(r), `${s.id}: 모르는 경로 '${r}'`);
}
assert.ok(SCENARIOS.filter((s) => s.expectedRoute).length >= 9);
});