o2o-infinith-demo/supporters/scripts/gate/plan.test.mjs
Haewon Kam aa5b05227f feat(supporters): 회복 일정 플래너를 템플릿·워커에 통합 (/plan·/en/plan), /recovery·/stay 를 /plan 으로 정리
- 템플릿: 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>
2026-09-11 11:27:59 +09:00

131 lines
7.6 KiB
JavaScript

// 회복기 스케줄러 계산 로직 검증. node --experimental-strip-types scripts/gate/plan.test.mjs
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { buildPlan, addDays, decodeHash, encodeHash, bookingUrl, googleFlightsUrl, buildIcs } from '../../src/lib/plan.ts';
import { tourToPlaces, festivalsDuring, levelByDistance, bookingName } from '../../src/lib/tour.ts';
const here = (p) => new URL(p, import.meta.url);
const P = JSON.parse(readFileSync(here('../../src/data/procedures.json'), 'utf8'));
const PL = JSON.parse(readFileSync(here('../../src/data/places.json'), 'utf8'));
const TKO = JSON.parse(readFileSync(here('../../src/data/medicalTourismKo.json'), 'utf8'));
const byId = (id) => P.procedures.find((x) => x.id === id);
const nat = (code) => P.nationalities.list.find((x) => x.code === code);
let n = 0;
const ok = (name, fn) => { fn(); n++; console.log(` ✓ ${name}`); };
ok('addDays: 월 경계', () => { assert.equal(addDays('2026-10-30', 3), '2026-11-02'); assert.equal(addDays('2026-11-01', -2), '2026-10-30'); });
ok('가슴: 입국일·실밥·출국·체류', () => {
const plan = buildPlan({ procedure: byId('breast'), surgeryDate: '2026-11-03', nationality: nat('JP'), prefs: ['shopping'] }, PL.places);
assert.equal(plan.arriveBy, '2026-11-01');
assert.equal(plan.stitchDate, '2026-11-10');
assert.equal(plan.earliestDeparture, '2026-11-13');
assert.equal(plan.stayNights, 12);
assert.equal(plan.days[0].isArrival, true);
assert.equal(plan.days.at(-1).isDeparture, true);
});
ok('장거리 국적은 준비일 +1', () => {
const plan = buildPlan({ procedure: byId('breast'), surgeryDate: '2026-11-03', nationality: nat('US'), prefs: [] }, PL.places);
assert.equal(plan.arriveBy, '2026-10-31');
assert.equal(plan.preOpDays, 3);
});
ok('윤곽: 유동식 단계에는 죽·스무디만 추천', () => {
const plan = buildPlan({ procedure: byId('contour'), surgeryDate: '2026-11-03', nationality: nat('JP'), prefs: ['food', 'shopping'] }, PL.places);
const day5 = plan.days.find((d) => d.offset === 5);
assert.equal(day5.level, 1);
assert.equal(day5.diet, 'liquid');
for (const pick of day5.picks) {
assert.ok(pick.minLevel <= 1, `${pick.id} 등급 초과`);
if (pick.category === 'food') assert.ok(pick.dietOk.includes('liquid'), `${pick.id} 유동식 불가`);
}
assert.equal(plan.stitchDate, null); // 녹는 실
});
ok('수술 당일·입원일·L0 은 추천 없음', () => {
const plan = buildPlan({ procedure: byId('breast'), surgeryDate: '2026-11-03', nationality: null, prefs: [] }, PL.places);
const d0 = plan.days.find((d) => d.offset === 0), d1 = plan.days.find((d) => d.offset === 1);
assert.deepEqual(d0.picks, []); assert.equal(d0.isSurgery, true);
assert.equal(d1.level, 0); assert.deepEqual(d1.picks, []);
});
ok('호텔은 일자 추천에 나오지 않음', () => {
const plan = buildPlan({ procedure: byId('skin'), surgeryDate: '2026-11-03', nationality: null, prefs: ['care'] }, PL.places);
for (const d of plan.days) for (const p of d.picks) assert.notEqual(p.category, 'hotel');
});
ok('연속 이틀 같은 곳 반복 억제', () => {
const plan = buildPlan({ procedure: byId('eye'), surgeryDate: '2026-11-03', nationality: null, prefs: ['sightseeing'] }, PL.places);
const out = plan.days.filter((d) => d.picks.length && d.offset > 0);
let repeats = 0;
for (let i = 1; i < out.length; i++) for (const p of out[i].picks) if (out[i - 1].picks.some((q) => q.id === p.id)) repeats++;
assert.ok(repeats <= out.length, `연속 반복 ${repeats}`);
});
ok('해시 왕복', () => {
const s = { p: 'nose', d: '2026-12-01', l: 'en', n: 'US', pref: ['kpop', 'food'] };
assert.deepEqual(decodeHash('#' + encodeHash(s)), s);
assert.equal(decodeHash('#d=notadate').d, undefined);
});
ok('예약 링크 형식', () => {
const u = new URL(bookingUrl('Hotel Cappuccino Seoul', '2026-11-01', '2026-11-13', 'en'));
assert.equal(u.hostname, 'www.booking.com');
assert.equal(u.searchParams.get('checkin'), '2026-11-01');
assert.ok(googleFlightsUrl('2026-11-01', '2026-11-13', 'en').startsWith('https://www.google.com/travel/flights'));
});
ok('ICS 에 일정 4개', () => {
const plan = buildPlan({ procedure: byId('nose'), surgeryDate: '2026-11-03', nationality: null, prefs: [] }, PL.places);
const ics = buildIcs(plan, { arrive: 'Arrive', surgery: 'Surgery', stitch: 'Stitch', departure: 'Depart' }, 'Test');
assert.equal((ics.match(/BEGIN:VEVENT/g) || []).length, 4);
assert.ok(ics.includes('DTSTART;VALUE=DATE:20261103'));
});
ok('규칙표 정합성: 숫자마다 status, provisional 은 source 없어도 됨, clinic 은 source 필수', () => {
for (const p of P.procedures) {
for (const k of ['preOpDays', 'hospitalNights', 'stitchRemovalDay', 'earliestDepartureDay']) {
assert.ok(['clinic', 'provisional'].includes(p[k].status), `${p.id}.${k} status`);
if (p[k].status === 'clinic') assert.ok(p[k].source, `${p.id}.${k} clinic 인데 source 없음`);
}
for (const s of [...p.outingLevels, ...p.diet]) if (s.status === 'clinic') assert.ok(s.source, `${p.id} 단계 clinic 인데 source 없음`);
for (const c of p.constraints) assert.ok(P.constraints[c], `${p.id} 미정의 제약 ${c}`);
for (const s of p.sources) assert.ok(P.clinicSources.some((x) => x.id === s), `${p.id} 미정의 출처 ${s}`);
}
for (const pl of PL.places) { assert.ok(PL.categories[pl.category], `${pl.id} 카테고리`); assert.ok(pl.name.ko && pl.name.en, `${pl.id} 이름`); }
});
const HAS_TOUR = Object.values(TKO.places ?? {}).some((v) => Array.isArray(v) && v.length); // 템플릿 빈 데이터면 관광공사 검사는 건너뛴다
ok('TourAPI 어댑터: 카테고리 매핑, 사우나 제외, 음식점은 일반식만, 거리 등급', () => {
assert.equal(levelByDistance(400), 1); assert.equal(levelByDistance(2500), 2); assert.equal(levelByDistance(9000), 3);
assert.equal(bookingName('K-그랜드 호스텔 강남 1(K-GRAND HOSTEL GANGNAM1)'), 'K-GRAND HOSTEL GANGNAM1');
if (!HAS_TOUR) { console.log(' (관광공사 데이터 없음: 변환 검사 건너뜀)'); return; }
const tp = tourToPlaces(TKO, 'ko');
assert.ok(tp.length > 50, `변환 ${tp.length}건`);
assert.ok(tp.every((p) => p.source === 'tourapi'));
assert.ok(!tp.some((p) => p.id.startsWith('tour-wellness-')), '사우나·스파가 후보에 들어감');
assert.ok(tp.filter((p) => p.category === 'food').every((p) => p.dietOk.length === 1 && p.dietOk[0] === 'normal'));
});
ok('큐레이션이 TourAPI 보다 먼저 나오고 유동식 단계에는 TourAPI 음식점이 없음', () => {
if (!HAS_TOUR) return;
const tp = tourToPlaces(TKO, 'ko');
const all = [...PL.places.map((x) => ({ ...x, source: 'curated' })), ...tp];
const plan = buildPlan({ procedure: byId('contour'), surgeryDate: '2026-11-03', nationality: null, prefs: ['food'] }, all);
for (const d of plan.days.filter((d) => d.diet === 'liquid')) for (const p of d.picks) assert.ok(!(p.source === 'tourapi' && p.category === 'food'), `${d.date} ${p.id}`);
const first = plan.days.find((d) => d.picks.length);
assert.equal(first.picks[0].source, 'curated');
});
ok('축제: 체류 기간과 겹치는 것만, L3 날에만 붙음', () => {
if (!HAS_TOUR) return;
const fest = festivalsDuring(TKO, '2026-11-01', '2026-11-17');
for (const f of fest) assert.ok(f.start <= '2026-11-17' && f.end >= '2026-11-01');
const plan = buildPlan({ procedure: byId('skin'), surgeryDate: '2026-11-03', nationality: null, prefs: [], festivals: fest }, PL.places);
for (const d of plan.days) if (d.festivals.length) assert.ok(d.level >= 3, `${d.date} L${d.level}`);
});
console.log(`plan.test: ${n}건 통과`);