o2o-infinith-demo/supporters/scripts/gate/test.mjs
Haewon Kam 49c5e46f23 feat(supporters): 발행 게이트 확장 — v2 §3 검사 11종을 규칙 모듈·픽스처 테스트로 분리
- scripts/gate/rules.mjs: 운영자 어휘·구조 지문·화법·근거 type·규제 원문·결론 문장·수치 한정·확인일 일관성·검토자/검토일·영상 제목·표/체크리스트 (순수 함수)
- scripts/check.mjs: dist + 소스 frontmatter + 데이터 검사 러너, --update-layout, error/warn 분리
- scripts/gate/test.mjs + fixtures 9개: 실패 재현 + 18편 정답지 회귀 + 부정문 통과
- corrections.json 「답변엔진」 표현 고객 화면에서 제거, 반말 문장 1건 수정
- audit_aeo_geo.py: 표본 URL 5개 미만 경고(§3-10)
- 미결: 「채널 인기 전체」 탭은 경고만 (haewon 결정 대기)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:57:31 +09:00

66 lines
3.6 KiB
JavaScript

// 게이트 규칙 회귀 테스트. 빌드 없이 돈다.
// 1) fixtures/*.md : frontmatter `expect:` 에 적힌 error 코드가 실제로 나와야 통과
// 2) fixtures/*.html: 첫 줄 주석의 expect 코드 (none = 오류 0)
// 3) src/content/posts/*.md 전편이 소스 규칙 error 0 이어야 통과 (정답지 회귀)
// node scripts/gate/test.mjs
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import * as R from './rules.mjs';
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const FIX = here('./fixtures/');
const POSTS = here('../../src/content/posts/');
const authors = JSON.parse(readFileSync(here('../../src/data/authors.json'), 'utf8'));
const ctx = { physicians: authors.physicians, supporters: authors.supporters };
const base = yaml.load(readFileSync(join(FIX, '_base.yml'), 'utf8'));
let pass = 0, failCount = 0;
const ok = (name, msg = '') => { pass++; console.log(`${name}${msg ? ' · ' + msg : ''}`); };
const bad = (name, msg) => { failCount++; console.error(`${name}: ${msg}`); };
const codes = (list, level = R.E) => [...new Set(list.filter((x) => x.level === level).map((x) => x.code))];
// 1) md 픽스처
for (const f of readdirSync(FIX).filter((x) => x.endsWith('.md'))) {
const { data, body } = R.parseMarkdown(readFileSync(join(FIX, f), 'utf8'));
const { expect = [], ...fm } = data;
const merged = { ...base, ...fm };
const got = codes(R.checkPostSource({ data: merged, body }, ctx));
const missing = expect.filter((c) => !got.includes(c));
const extra = got.filter((c) => !expect.includes(c));
if (missing.length) bad(f, `기대한 오류가 안 나옴: ${missing.join(', ')} (실제: ${got.join(', ') || '없음'})`);
else if (extra.length) bad(f, `기대 밖 오류: ${extra.join(', ')}`);
else ok(f, got.join(', '));
}
// 2) html 픽스처 (dist 규칙)
for (const f of readdirSync(FIX).filter((x) => x.endsWith('.html'))) {
const html = readFileSync(join(FIX, f), 'utf8');
const expect = html.match(/expect:\s*([A-Z_]+|\(none\))/)?.[1];
const text = R.stripHtml(html);
const got = codes([...R.checkOperatorVocab(text, `/${f}`), ...R.checkBannedBody(text), ...R.checkHeadingEndings(html)]);
if (expect === '(none)') got.length ? bad(f, `오류 없어야 하는데: ${got.join(', ')}`) : ok(f, '오류 0');
else got.includes(expect) ? ok(f, got.join(', ')) : bad(f, `기대 ${expect}, 실제 ${got.join(', ') || '없음'}`);
}
// 3) 정답지 회귀: 실제 18편은 error 0
let postErrors = 0;
for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
const parsed = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
const errs = R.checkPostSource(parsed, ctx).filter((x) => x.level === R.E);
if (errs.length) { postErrors += errs.length; errs.forEach((e) => bad(`posts/${f}`, `[${e.code}] ${e.msg}`)); }
}
if (!postErrors) ok('src/content/posts 전편', '소스 규칙 error 0');
// 4) 부정문·임상 의견 표기는 통과해야 한다 (정답지에 실제로 있는 문장)
const negatives = [
'다만 답변만으로 운영 실태나 수술 안전이 보장되는 것은 아닙니다.',
'나노텍스처를 선호한다는 것이 손유성 원장의 임상 판단이며, 독립 연구로 비교우위가 확정됐다는 뜻은 아닙니다.',
];
const negGot = R.checkConclusions(negatives.map((text) => ({ field: 'body', text })));
negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) : ok('부정문·임상 의견 문장 통과');
console.log(`통과 ${pass} · 실패 ${failCount}`);
if (failCount) process.exit(1);