// scenarios.json 의 정적 검증. 시나리오 정의만 더하고 실행은 하지 않은 항목(S19~S21)이 깨진 채 남지 않게 한다. // // node --test build/eval/interview/test/scenarios.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"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(HERE, ".."); const SCENARIOS = JSON.parse(fs.readFileSync(path.join(ROOT, "scenarios.json"), "utf8")); const byId = Object.fromEntries(SCENARIOS.map((s) => [s.id, s])); function filesUnder(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? filesUnder(path.join(dir, e.name)) : [path.join(dir, e.name)])); } test("시나리오 ID 는 겹치지 않고 S1 부터 이어진다", () => { assert.deepEqual(SCENARIOS.map((s) => s.id), SCENARIOS.map((_, i) => `S${i + 1}`)); }); test("필수 필드와 픽스처 디렉터리가 있다", () => { for (const s of SCENARIOS) { assert.ok(["ask", "no-interview", "no-trigger", "direction-checkpoint"].includes(s.expect), `${s.id}: expect ${s.expect}`); assert.equal(typeof s.prompt, "string", `${s.id}: prompt`); if (s.fixture) assert.ok(fs.statSync(path.join(ROOT, "fixtures", s.fixture)).isDirectory(), `${s.id}: fixtures/${s.fixture}`); if (s.cardMode !== undefined) { assert.equal(s.cardMode, "interpretation", `${s.id}: cardMode`); assert.equal(s.expect, "direction-checkpoint", `${s.id}: cardMode 는 direction-checkpoint 에만 쓴다`); } if (s.maxSkillReadTokens !== undefined) assert.ok(Number.isInteger(s.maxSkillReadTokens) && s.maxSkillReadTokens > 0, `${s.id}: maxSkillReadTokens`); } }); test("새 시나리오(S19~S21)의 픽스처는 시나리오의 전제를 실제로 만든다", () => { const fx = (s) => path.join(ROOT, "fixtures", byId[s].fixture); const names = (s) => filesUnder(fx(s)).map((p) => path.relative(fx(s), p).split(path.sep).join("/")).sort(); // S19(J-01): design.md 가 없고, 현행 시각 언어가 코드에 있다(토큰 파일 + 같은 스타일을 쓰는 화면 둘). assert.deepEqual(names("S19"), ["features.html", "index.html", "styles/site.css", "styles/tokens.css"]); for (const page of ["index.html", "features.html"]) assert.match(fs.readFileSync(path.join(fx("S19"), page), "utf8"), /styles\/site\.css/); assert.match(fs.readFileSync(path.join(fx("S19"), "styles/tokens.css"), "utf8"), /--color-accent:[\s\S]*--font-display:/); // S20(J-12): design.md 가 있고 hover 용 토큰이 없다 — 고치려면 파생 상태 토큰을 더해야 한다. assert.deepEqual(names("S20"), ["design.md", "index.html"]); for (const f of names("S20")) assert.doesNotMatch(fs.readFileSync(path.join(fx("S20"), f), "utf8"), /--[a-z-]*hover/); assert.match(fs.readFileSync(path.join(fx("S20"), "design.md"), "utf8"), /색은 토큰으로만 쓴다/); assert.equal(byId.S20.prompt, byId.S6.prompt); // S21(J-08): 프롬프트가 가리키는 시안 파일이 있다. const mock = /refs\/[\w.-]+\.svg/.exec(byId.S21.prompt)?.[0]; assert.ok(mock && names("S21").includes(mock), `S21 프롬프트의 시안 파일이 픽스처에 없다: ${mock}`); }); test("픽스처는 작고(시나리오당 8KB 이하) LF 줄끝이다", () => { for (const s of ["S19", "S20", "S21"]) { const files = filesUnder(path.join(ROOT, "fixtures", byId[s].fixture)); let total = 0; for (const f of files) { const buf = fs.readFileSync(f); total += buf.length; assert.equal(buf.indexOf(0x0d), -1, `${f}: CR 줄끝`); } assert.ok(total <= 8 * 1024, `${s}: 픽스처 ${total}바이트`); } });