o2o-infinith-demo/supporters/scripts/gate/plan.test.mjs
Haewon Kam e40f9ceb94 feat(supporters): 회복 일정 플래너 /plan · /en/plan · 시술별 회복 규칙표, 큐레이션 장소, 예약 딥링크, 헤더 내비 연결
- src/pages/plan.astro, src/pages/en/plan.astro: 한국어·영어 페이지, hreflang 상호 연결
- src/components/Planner.astro: 5단계 흐름(언어·국적 → 시술·수술일 → 선호 → 일정표 → 예약·저장), 브라우저 안 계산, URL 해시 공유, ICS 저장
- src/lib/plan.ts: 입국 권장일·출국 가능 최소일·외출 등급·식사 단계·추천 점수·딥링크·ICS 순수 함수
- src/data/procedures.json: 시술 8종 규칙표. 값마다 clinic/provisional 표시, 병원 확인 전 잠정값은 화면 배지
- src/data/places.json: 신논현역 기준 큐레이션 31곳 (구글 지도·예약 사이트 검색 링크만)
- src/data/planStrings.json: UI 문자열 ko/en
- scripts/gate/plan.test.mjs: 계산·데이터 정합성 검증 11건
- Base.astro: lang·alternates prop, 내비 '회복 일정', 푸터 링크. 구조 기준선 갱신(haewon 승인)
- visit.astro: 플래너 진입 CTA 카드. sitemap에 /plan, /en/plan 추가
- 설계 근거: docs/INFINITH_Viewclinic_Supporters_Site_Design_v0.2.md §13

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:00:33 +09:00

99 lines
5.3 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';
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 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} 이름`); }
});
console.log(`plan.test: ${n}건 통과`);