- 템플릿: Planner.astro, lib/plan.ts·tour.ts, styles/plan.css, planStrings, pages plan·en/plan·404. Base 내비 회복 일정 → /plan, 언어 짝 /plan↔/en/plan, 옛 주소 리다이렉트(vercel.json), 사이트맵 - 워커 planner 단계(recovery 다음): scripts/build_planner_data.mjs 가 업종별 기본 규칙표(scripts/template/planner/procedures.plastic|derm.json)에 병원 시술 페이지 원문(recoveryNotes)을 matchKeywords 로 붙이고, 장소는 briefs/<clinic>/planner.places.json(큐레이션) 또는 범용 기본표(관광공사 기준 좌표)로 만든다 - 브리프: viewclinic·oracle 큐레이션 장소. 빈 템플릿(관광 데이터 없음)도 빌드·검증 통과(plan.test 14건) - 이전 세션의 미커밋 작업(피부과 수집·OCR·게이트·언어 스위치, stay 페이지 제거)도 이 커밋에 함께 들어감. docs/prd 변경은 제외 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
153 lines
12 KiB
JavaScript
153 lines
12 KiB
JavaScript
// 게이트 규칙 회귀 테스트. 빌드 없이 돈다.
|
|
// 1) fixtures/*.md : frontmatter `expect:` 에 적힌 error 코드가 실제로 나와야 통과 (`expectWarn:`·`expectNoWarn:` 으로 warn 코드도 본다)
|
|
// 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 fx = JSON.parse(readFileSync(join(FIX, '_authors.json'), 'utf8'));
|
|
const fxCtx = { physicians: fx.physicians, supporters: fx.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 픽스처. expect = 나와야 하는 error 코드(정확히 일치), expectWarn = 나와야 하는 warn 코드, expectNoWarn = 나오면 안 되는 warn 코드
|
|
for (const f of readdirSync(FIX).filter((x) => x.endsWith('.md'))) {
|
|
const { data, body } = R.parseMarkdown(readFileSync(join(FIX, f), 'utf8'));
|
|
const { expect = [], expectWarn = [], expectNoWarn = [], ...fm } = data;
|
|
const merged = { ...base, ...fm };
|
|
const all = R.checkPostSource({ data: merged, body }, fxCtx);
|
|
const got = codes(all);
|
|
const gotWarn = codes(all, R.W);
|
|
const missing = expect.filter((c) => !got.includes(c));
|
|
const extra = got.filter((c) => !expect.includes(c));
|
|
const missingWarn = expectWarn.filter((c) => !gotWarn.includes(c));
|
|
const badWarn = expectNoWarn.filter((c) => gotWarn.includes(c));
|
|
if (missing.length) bad(f, `기대한 오류가 안 나옴: ${missing.join(', ')} (실제: ${got.join(', ') || '없음'})`);
|
|
else if (extra.length) bad(f, `기대 밖 오류: ${extra.join(', ')}`);
|
|
else if (missingWarn.length) bad(f, `기대한 경고가 안 나옴: ${missingWarn.join(', ')} (실제 경고: ${gotWarn.join(', ') || '없음'})`);
|
|
else if (badWarn.length) bad(f, `나오면 안 되는 경고: ${badWarn.join(', ')}`);
|
|
else ok(f, [...got, ...expectWarn.map((c) => `warn:${c}`)].join(', ') || '오류 0');
|
|
}
|
|
|
|
// 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 (${readdirSync(POSTS).filter((x) => x.endsWith('.md')).length}편)`);
|
|
|
|
// 4) 부정문·임상 의견 표기는 통과해야 한다 (정답지에 실제로 있는 문장)
|
|
const negatives = [
|
|
'다만 답변만으로 운영 실태나 수술 안전이 보장되는 것은 아닙니다.',
|
|
'나노텍스처를 선호한다는 것이 손유성 원장의 임상 판단이며, 독립 연구로 비교우위가 확정됐다는 뜻은 아닙니다.',
|
|
];
|
|
const negGot = R.checkConclusions(negatives.map((text) => ({ field: 'body', text })));
|
|
negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) : ok('부정문·임상 의견 문장 통과');
|
|
|
|
// 5) 생성기 직렬화 왕복: 정답지 frontmatter → js-yaml dump → 다시 파싱해도 값이 같고, 소스 게이트(금칙어·비교 수치·홈페이지 중복 포함) error 0
|
|
{
|
|
const { toMarkdown, gatePost } = await import('../gen/serialize.mjs');
|
|
const homeText = readFileSync(here('../home_text.txt'), 'utf8');
|
|
const KEYS = ['title', 'summary', 'description', 'faq', 'sources', 'videos', 'qbIds', 'tags', 'hero', 'thumbnail', 'reviewer', 'reviewedAt', 'datePublished'];
|
|
let rt = 0;
|
|
for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
|
|
const g = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
|
|
const md = toMarkdown(g.data, g.body);
|
|
const m = R.parseMarkdown(md);
|
|
for (const k of KEYS) if (JSON.stringify(g.data[k] ?? null) !== JSON.stringify(m.data[k] ?? null)) { rt++; bad(`왕복 ${f}`, `${k} 값이 바뀜`); }
|
|
if (typeof m.data.datePublished !== 'string') { rt++; bad(`왕복 ${f}`, 'datePublished 가 문자열이 아님 (YAML 날짜 자동 변환)'); }
|
|
const errs = gatePost(md, { ...ctx, homeText }).errors;
|
|
if (errs.length) { rt++; errs.forEach((e) => bad(`생성기 게이트 ${f}`, `[${e.code}] ${e.msg}`)); }
|
|
}
|
|
if (!rt) ok('생성기 직렬화 왕복·소스 게이트', '18편 값 동일, error 0');
|
|
}
|
|
|
|
// 6) 수치 대조·비교 수치 (생성기 전용 규칙)
|
|
{
|
|
const cmp = R.checkComparison('마취과 전문의 상주 비율은 병원급 이상 평균 23.1%인데 뷰성형외과는 43%입니다.');
|
|
cmp.length ? ok('비교 수치 검출', cmp[0].code) : bad('비교 수치 검출', '병원급 평균 23.1% vs 43% 를 못 잡음');
|
|
const okCmp = R.checkComparison('다른 병원에서 한 수술도 재수술 상담이 되나요? 상담에서 확인하세요.');
|
|
okCmp.length ? bad('비교 수치 오탐', okCmp[0].msg) : ok('"다른 병원에서 한 수술" 서술 통과');
|
|
}
|
|
|
|
// 7) 뉴스룸 항목 (news.json): 후기·전후 비교·변신 제목과 규제·사건 보도는 error, 시기 표현("수술 전후 검진")·"무사고"는 통과
|
|
{
|
|
const flagged = R.checkNewsItems([
|
|
{ title: '조여정 닮은꼴 강은수, 성형 전후 비교해보니', date: '2014-08-15', kind: 'column', url: 'a' },
|
|
{ title: '주걱턱 태국소녀, 인형 같은 변신 성공', date: '2014-09-06', kind: 'column', url: 'b' },
|
|
{ title: '○○성형외과 공정위 제재… 뒷광고 적발', date: '2026-01-01', kind: 'mention', url: 'c' },
|
|
{ title: '날짜 형식이 다른 기사', date: '2026.01.01', kind: 'mention', url: 'd' },
|
|
]);
|
|
const c = codes(flagged);
|
|
['BANNED_NEWS_TITLE', 'NEGATIVE_NEWS', 'NEWS_DATE'].every((x) => c.includes(x)) && flagged.filter((x) => x.code === 'BANNED_NEWS_TITLE').length === 2 ? ok('뉴스 제목 금칙·사건 보도·날짜 검출', c.join(', ')) : bad('뉴스 항목 검출', JSON.stringify(flagged.map((x) => x.code)));
|
|
const passed = R.checkNewsItems([
|
|
{ title: '가슴수술 전후, 유방검진 왜 중요할까?', date: '2020-03-11', kind: 'column', url: 'e' },
|
|
{ title: "'환자안전 제일'… 뷰성형외과, 개원 16년동안 무사고 행보", date: '2020-01-17', kind: 'release', url: 'f' },
|
|
]);
|
|
passed.length ? bad('뉴스 정상 제목 오탐', passed.map((x) => x.msg).join(' | ')) : ok('"수술 전후 검진"·"무사고" 제목 통과');
|
|
const real = JSON.parse(readFileSync(here('../../src/data/news.json'), 'utf8'));
|
|
const re = R.checkNewsItems(real.items);
|
|
re.length ? bad('news.json 회귀', `${re.length}건: ` + re.slice(0, 3).map((x) => x.msg).join(' | ')) : ok('news.json 전 항목 규칙 통과', `${real.items.length}건`);
|
|
}
|
|
|
|
// 8) 피부과 규칙 단위 검사 (PRD §9). 금액·허가·효과 보장·시술자
|
|
{
|
|
const F = (text) => [{ field: 'body', text }];
|
|
const price = R.checkPriceMention(F('울쎄라 300샷 이벤트가 99만원. 리쥬란 15만 원부터. ₩150,000. 1+1 행사.'));
|
|
price.length >= 3 ? ok('금액 표기 검출', `${price.length}건`) : bad('금액 표기 검출', JSON.stringify(price));
|
|
const priceOk = R.checkPriceMention(F('수술 후 5~7일이면 일상생활이 가능합니다. 원장 3명이 진료합니다. 2026년 9월 기준입니다. 전화는 02-539-1177입니다. 금액은 비급여 진료비용 고지 페이지에서 확인하세요.'));
|
|
priceOk.length ? bad('금액 오탐', priceOk.map((x) => x.msg).join(' | ')) : ok('일수·인원·연도·전화번호·고지 안내 문장 통과');
|
|
const dev = R.checkDeviceClaim({ sources: [] }, F('이 장비는 FDA 승인과 식약처 허가를 받았습니다.'));
|
|
dev.some((x) => x.code === 'DEVICE_CLAIM') ? ok('허가 표현 + regulation 소스 없음 검출') : bad('허가 표현 검출', JSON.stringify(dev));
|
|
const devOk = R.checkDeviceClaim({ sources: [{ type: 'regulation' }] }, F('이 장비는 식약처 허가를 받았습니다. 허가가 위험이 없다는 뜻은 아닙니다. 영구 제모라는 표현은 쓰지 않습니다.'));
|
|
devOk.length ? bad('허가 표현 오탐', devOk.map((x) => x.msg).join(' | ')) : ok('regulation 소스 있는 허가 표현·부정문 통과');
|
|
const guar = R.checkDeviceClaim({ sources: [] }, F('한 번이면 평생 유지됩니다. 기미는 완치됩니다.'));
|
|
guar.length >= 2 ? ok('효과 보장 표현 검출', `${guar.length}건`) : bad('효과 보장 검출', JSON.stringify(guar));
|
|
const semi = R.checkDeviceClaim({ sources: [] }, F('반영구 화장은 별도 시술입니다. 유지기간은 개인차가 있습니다.'));
|
|
semi.length ? bad('반영구 오탐', semi[0].msg) : ok('"반영구" 시술명 통과');
|
|
const pr = R.checkPractitioner({ category: 'D' }, F('시술시간은 확인입니다.'), 'derm');
|
|
pr.length === 1 && pr[0].code === 'PRACTITIONER_MISSING' && pr[0].level === R.W ? ok('시술자 표기 없음 경고 (derm·D)') : bad('시술자 경고', JSON.stringify(pr));
|
|
const prSkip = [...R.checkPractitioner({ category: 'D' }, F('시술시간은 확인입니다.'), 'plastic'), ...R.checkPractitioner({ category: 'A' }, F('시술시간은 확인입니다.'), 'derm'), ...R.checkPractitioner({ category: 'D' }, F('원장이 직접 시술합니다.'), 'derm')];
|
|
prSkip.length ? bad('시술자 경고 오탐', JSON.stringify(prSkip)) : ok('성형외과·비시술 글·"원장이 직접" 표기는 경고 없음');
|
|
const banned = R.checkBannedBody('정품 정량 인증 병원입니다. 비포애프터 사진을 보세요.');
|
|
banned.length >= 2 ? ok('피부과 금칙어(정품 정량 인증·비포애프터) 검출', `${banned.length}건`) : bad('피부과 금칙어', JSON.stringify(banned));
|
|
const bannedOk = R.checkBannedBody('병원은 정품·정량 사용을 밝힙니다. 이 매체는 전후 사진을 싣지 않습니다. 영수증 인증 후기입니다.');
|
|
bannedOk.length ? bad('피부과 금칙어 오탐', bannedOk.map((x) => x.msg).join(' | ')) : ok('정품 정량 서약 사실·전후 사진 원칙 서술 통과');
|
|
// 정답지 18편: 피부과 규칙을 derm 으로 강제해도 error 0 (PRICE_MENTION·DEVICE_CLAIM 은 업종 무관, PRACTITIONER 는 경고라 error 에 안 잡힘)
|
|
let dermErr = 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, industry: 'derm' }).filter((x) => x.level === R.E && ['PRICE_MENTION', 'DEVICE_CLAIM'].includes(x.code));
|
|
if (errs.length) { dermErr += errs.length; errs.forEach((e) => bad(`posts/${f} (derm)`, `[${e.code}] ${e.msg}`)); }
|
|
}
|
|
if (!dermErr) ok('정답지 전편 PRICE_MENTION·DEVICE_CLAIM 0건');
|
|
}
|
|
|
|
console.log(`통과 ${pass} · 실패 ${failCount}`);
|
|
if (failCount) process.exit(1);
|