// 회복기 스케줄러 계산 로직 검증. 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}건 통과`);