/* /s/stay 시연본 전수 점검 — 사장님이 지시한 항목을 하나씩 화면에서 센다. */ import {chromium} from 'playwright'; const URL = process.argv[2] || 'http://localhost/s/stay'; const b = await chromium.launch({channel: 'chrome'}); const out = []; const ok = (name, pass, detail) => out.push({name, pass, detail}); // ── 데스크톱 ────────────────────────────────────────────────────────── const page = await b.newPage({viewport: {width: 1280, height: 900}}); const failed = []; page.on('response', (r) => { if (r.status() >= 400 && r.url().includes('/s/stay')) failed.push(r.status() + ' ' + decodeURIComponent(r.url().slice(-36))); }); const errs = []; page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text().slice(0, 90)); }); await page.goto(URL, {waitUntil: 'domcontentloaded'}); await page.waitForTimeout(6500); const d = await page.evaluate(() => { const P = window.__SITE_PAYLOAD__; const sec = (id) => { const s = P.theme.sections.find((x) => x.id === id); try { return JSON.parse(s.data); } catch { return {items: []}; } }; const itin = sec('itinerary').items; const stops = itin.flatMap((i) => i.days.flatMap((day) => day.stops)); const days = itin.flatMap((i) => i.days); return { catch: (P.narrative.catchphrases?.items || []).length, catchKinds: ['general', 'season', 'month', 'weather'].map((k) => (P.narrative.catchphrases?.items || []).filter((x) => x.kind === k).length), themes: itin.length, firstIsStay: days.every((day) => day.stops[0].name.endsWith('스테이 머뭄')), // 떠나는 날은 갈린다 — 차로 온 손님은 짐을 싣고 나서므로 복귀 칸이 없다(2026-09-14 대표 의견). lastIsStay: itin.every((i) => i.days.slice(0, -1) .every((day) => day.stops[day.stops.length - 1].name.endsWith('스테이 머뭄'))), depart: (() => { const tail = (i) => i.days[i.days.length - 1]; const car = itin.filter((i) => !tail(i).stops[tail(i).stops.length - 1].name.endsWith('스테이 머뭄')); const walk = itin.filter((i) => !car.includes(i)); return { car: car.length, walk: walk.length, car1: car.filter((i) => i.duration === '1박 2일').length, car2: car.filter((i) => i.duration === '2박 3일').length, // 퇴실 칸의 문구가 동선과 같은 말을 하는가 — 차인데 "맡겨 두고" 면 손님이 한 번 더 들른다 carNote: car.every((i) => (tail(i).stops[0].note || '').includes('차에 싣고')), walkNote: walk.every((i) => (tail(i).stops[0].note || '').includes('맡겨 두고')), walkEnd: walk.every((i) => tail(i).stops[tail(i).stops.length - 1].name.startsWith('마무리')), }; })(), consecutive: days.filter((day) => day.stops.some((s, i) => i && s.name.split(' · ').pop() === day.stops[i - 1].name.split(' · ').pop())).length, hasNote: stops.every((s) => s.name.endsWith('스테이 머뭄') || s.note), stopImages: stops.filter((s) => s.imageUrl).length, restDays: days.filter((day) => day.stops.some((s) => s.name.startsWith('머뭄에서 쉬는 날'))).length, endTimes: (() => { const t = days.map((day) => { let c = +day.startTime.slice(0, 2) * 60 + +day.startTime.slice(3); day.stops.forEach((s) => { c += s.moveMinutes + s.minutes; }); return c; }); return [Math.min(...t), Math.max(...t)]; })(), daily: sec('daily').items.length, reading: (() => { const e = sec('reading'); return { items: e.items.length, groups: (e.groups || []).length, sourced: e.items.filter((x) => x.source && x.source.url).length, }; })(), domReadCards: document.querySelectorAll('#w4d-reading .w4d-read-card').length, domReadGroups: document.querySelectorAll('#w4d-reading .w4d-read-group').length, domReadSrc: document.querySelectorAll('#w4d-reading .w4d-read-src a').length, // 레일이 아니어야 한다 — 가로로 밀어야 보이면 '펼쳐 둔다' 가 아니다. // 신문 조판이라 단(column)으로 흐른다(2026-09-14 대표: "신문처럼"). readCols: (() => { const el = document.querySelector('#w4d-reading .w4d-read-grid'); if (!el) return null; return {cols: getComputedStyle(el).columnCount, overflow: el.scrollWidth - el.clientWidth}; })(), readPaper: !!document.querySelector('#w4d-reading .w4d-read-head h2') && !!document.querySelector('#w4d-reading .w4d-read-dateline'), dailyCats: [...new Set(sec('daily').items.map((x) => x.category))].sort(), dailySourced: sec('daily').items.filter((x) => x.source && x.source.url).length, domDailyPages: document.querySelectorAll('#daily article').length, storyTabs: [...document.querySelectorAll('#story [role="tab"]')].map((t) => t.textContent.trim()), songs: sec('songs').items.length, ownInSongs: sec('songs').items.filter((s) => s.own).length, songLinks: sec('songs').items.filter((s) => s.listenUrl).length, people: sec('people').items.length, peoplePhotos: sec('people').items.filter((p) => p.imageUrl).length, peopleCredits: sec('people').items.filter((p) => p.imageCredit).length, // 사진 있는 사람이 앞에 모여 있는가 — 레일 앞머리가 글자만 남으면 안 된다 photoFirst: (() => { const a = sec('people').items.map((p) => !!p.imageUrl); const n = a.filter(Boolean).length; return a.slice(0, n).every(Boolean); })(), ownSongs: (P.narrative.ownSongs?.items || []).length, // 화면 domPeopleImg: document.querySelectorAll('#people img').length, domCredit: document.querySelectorAll('#people .w4d-credit').length, domItinLinks: document.querySelectorAll('#itinerary a.w4d-link').length, domPostcardLinks: document.querySelectorAll('#postcard a.w4d-link').length, domItinImg: document.querySelectorAll('#itinerary img:not([src*="openstreetmap"])').length, miniBtns: [...document.querySelectorAll('#w4d-mini button')].map((x) => x.id), cassette: !!document.getElementById('w4d-tape'), redRule: document.querySelectorAll('.w4d-rule').length, bbqRow: !!document.body.textContent.match(/바비큐 이용/), divider: (() => { const el = document.querySelector('#root div.border-line.flex.flex-col.gap-3.border-t.pt-6'); return el ? getComputedStyle(el).borderTopWidth : 'none'; })(), heroImg: (P.media.find((m) => m.isPrimary) || {}).url, roomA: P.units[0].mediaIds.length, roomB: P.units[1].mediaIds.length, roomMirror: P.units.flatMap((u) => u.mediaIds).length, tagline: document.querySelector('#root h1')?.nextElementSibling?.textContent.trim(), sub: document.getElementById('w4d-sub')?.textContent.trim(), subIsCatch: (() => { const t = document.getElementById('w4d-sub')?.textContent.trim(); return !!t && (P.narrative.catchphrases?.items || []).some((x) => x.text.replace(/\s+/g, ' ').trim() === t); })(), restaurants: P.local.restaurants.length, // 걸어서 10분(800m) 넘는 맛집 — 없으면 '10분 이상' 탭에서 맛집 칸이 빈다(2026-09-11 사장님 지적) restPhotos: P.local.restaurants.filter((r) => r.imageUrl).length, restFar: P.local.restaurants.filter((r) => { const m = /^([\d.]+)\s*(km|m)$/i.exec(r.distanceText || ''); return m && Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1) > 800; }).length, }; }); ok('캐치프레이즈 100개', d.catch === 100, `${d.catch}개 · 일반${d.catchKinds[0]}/계절${d.catchKinds[1]}/월${d.catchKinds[2]}/날씨${d.catchKinds[3]}`); // 대표 문구는 고정, 순환은 그 아래 줄 (2026-09-11 사장님 지시) ok('대표 문구 고정', d.tagline === '히로쓰 가옥 담 너머, 백 년 된 집 한 채', `"${d.tagline}"`); ok('아래 줄에 캐치프레이즈', d.subIsCatch, `"${d.sub}"`); const sub2 = await page.waitForTimeout(7500).then(() => page.evaluate(() => document.getElementById('w4d-sub')?.textContent.trim())); ok('아래 줄 순환', !!sub2 && sub2 !== d.sub, `→ "${sub2}"`); /* 자동재생 (2026-09-14 대표 지시). 브라우저는 조작 없는 소리 재생을 막는다 — 정책이라 못 넘는다. 그래서 **첫 손길에 켜지는가**를 센다. 막지 않는 브라우저면 그 전에 이미 켜져 있다. */ const autoAtLoad = await page.evaluate(() => document.getElementById('w4d-play')?.dataset.playing); await page.mouse.wheel(0, 300); await page.waitForTimeout(2500); const autoAfter = await page.evaluate(() => ({ btn: document.getElementById('w4d-play')?.dataset.playing, time: document.querySelector('.w4d-item[data-playing="1"] .w4d-time')?.textContent, })); ok('자동재생', autoAfter.btn === '1', `불러온 직후 ${autoAtLoad === '1' ? '바로 재생' : '브라우저가 막음'} → 첫 손길에 ${autoAfter.time || '안 켜짐'}`); ok('주변 맛집', d.restaurants >= 30 && d.restFar > 0, `${d.restaurants}곳 · 걸어서 10분 넘는 곳 ${d.restFar}`); ok('맛집 사진', d.restPhotos === d.restaurants, `${d.restPhotos}/${d.restaurants}`); ok('일정 테마', d.themes === 21, `${d.themes}개`); ok('1번 = 스테이 머뭄', d.firstIsStay, d.firstIsStay ? '전 일자' : '어긋남'); ok('마지막 = 머뭄 (떠나는 날 빼고)', d.lastIsStay, d.lastIsStay ? '전 일자' : '어긋남'); ok('떠나는 날 복귀 없음 = 차 테마', d.depart.car === 14 && d.depart.car1 === 7 && d.depart.car2 === 7, `차 ${d.depart.car}(1박${d.depart.car1}·2박${d.depart.car2}) · 도보 ${d.depart.walk}`); ok('떠나는 날 퇴실 문구', d.depart.carNote && d.depart.walkNote && d.depart.walkEnd, d.depart.carNote && d.depart.walkNote && d.depart.walkEnd ? '차=싣고 · 도보=맡기고 찾으러' : '어긋남'); ok('같은 자리 연속 없음', d.consecutive === 0, `${d.consecutive}건`); ok('정거장마다 할 일 문구', d.hasNote, d.hasNote ? '전 정거장' : '빠짐'); ok('일정 사진 없음', d.stopImages === 0 && d.domItinImg === 0, `payload ${d.stopImages} · 화면 ${d.domItinImg}`); ok('숙소에서 쉬는 날', d.restDays >= 3, `${d.restDays}일`); ok('종료 시각 분산', d.endTimes[1] - d.endTimes[0] > 240, `${(d.endTimes[0] / 60 | 0)}:${String(d.endTimes[0] % 60).padStart(2, '0')} ~ ${(d.endTimes[1] / 60 | 0)}:${String(d.endTimes[1] % 60).padStart(2, '0')}`); ok('군산 읽기 — 한 화면에 다 편다', d.reading.items >= 30 && d.domReadCards === d.reading.items && d.readCols && d.readCols.overflow <= 2 && Number(d.readCols.cols) >= 2, `payload ${d.reading.items} · 화면 ${d.domReadCards}꼭지 · ${d.domReadGroups}면 · ${d.readCols?.cols}단 · 가로넘침 ${d.readCols?.overflow}px`); ok('군산 읽기 신문 조판', d.readPaper, d.readPaper ? '제호 · 발행일 줄 있음' : '없음'); ok('군산 읽기 출처', d.reading.sourced === d.reading.items && d.domReadSrc === d.reading.items, `payload ${d.reading.sourced}/${d.reading.items} · 화면 ${d.domReadSrc}`); ok('오늘의 한 장', d.daily >= 39 && d.domDailyPages === d.daily, `payload ${d.daily}장 · 화면 ${d.domDailyPages}장 · ${d.dailyCats.join('/')}`); ok('오늘의 한 장 출처', d.dailySourced === d.daily, `${d.dailySourced}/${d.daily}`); ok('이야기 탭에 일력', d.storyTabs.includes('오늘의 한 장'), d.storyTabs.join(',')); ok('가요 다방 곡', d.songs >= 25, `${d.songs}곡`); ok('가요 다방에 자작곡 없음', d.ownInSongs === 0, `${d.ownInSongs}곡`); ok('노래마다 링크', d.songLinks === d.songs, `${d.songLinks}/${d.songs}`); ok('인물 50명 이상', d.people >= 50, `${d.people}명`); ok('인물 사진', d.peoplePhotos >= 18 && d.domPeopleImg === d.peoplePhotos, `payload ${d.peoplePhotos} · 화면 ${d.domPeopleImg}`); // 저작자 표시는 **위키에서 받은 사진에만** 붙는다(라이선스 조건). 원본 사이트가 원래 쓰던 // 미러 사진(채만식)은 이 사이트 것이라 표시 대상이 아니다 — 그래서 두 수가 다르다. ok('사진 있는 사람이 앞', d.photoFirst, d.photoFirst ? `앞 ${d.peoplePhotos}명` : '흩어져 있다'); ok('사진 저작자 표시', d.domCredit === d.peopleCredits, `${d.domCredit}/${d.peopleCredits} (미러 ${d.peoplePhotos - d.peopleCredits}장 제외)`); ok('일정 지도 링크', d.domItinLinks > 150, `${d.domItinLinks}개`); ok('엽서 지도 링크', d.domPostcardLinks === 4, `${d.domPostcardLinks}개`); ok('플레이어 단추 2개', d.miniBtns.length === 2 && !d.miniBtns.includes('w4d-next'), d.miniBtns.join(',')); ok('카세트 아이콘', d.cassette, d.cassette ? '있음' : '없음'); ok('자작곡 5곡', d.ownSongs === 5, `${d.ownSongs}곡`); ok('빨간 줄 없음', d.redRule === 0, `${d.redRule}개`); ok('바비큐 항목 유지', d.bbqRow, d.bbqRow ? '있음' : '사라짐'); ok('맨 아래 가로선 제거', d.divider === '0px', d.divider); ok('히어로 원본 사진', /\/img\/mirror\//.test(d.heroImg || ''), d.heroImg); ok('객실 사진 A12/B10', d.roomA === 12 && d.roomB === 10, `A ${d.roomA} · B ${d.roomB}`); ok('404 없음', failed.length === 0, failed.slice(0, 3).join(' / ') || '없음'); ok('콘솔 오류 없음', errs.length === 0, errs.slice(0, 2).join(' / ') || '없음'); // 플레이어 동작 await page.click('#w4d-toggle'); await page.waitForTimeout(350); await page.click('.w4d-item'); await page.waitForTimeout(2200); const play = await page.evaluate(() => { const row = document.querySelector('.w4d-item[data-playing="1"]'); return {playing: !!row, time: row?.querySelector('.w4d-time')?.textContent, btn: document.getElementById('w4d-play')?.dataset.playing}; }); ok('음원 재생', play.playing && /\d:\d\d \/ \d:\d\d/.test(play.time || ''), `${play.time}`); await page.close(); // ── 모바일 ──────────────────────────────────────────────────────────── const m = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true}); await m.goto(URL, {waitUntil: 'domcontentloaded'}); await m.waitForTimeout(5500); await m.click('#w4d-toggle'); await m.waitForTimeout(400); const mob = await m.evaluate(() => { const p = document.getElementById('w4d-panel').getBoundingClientRect(); const nav = [...document.querySelectorAll('#root nav')].find((n) => getComputedStyle(n).position === 'fixed'); const mini = document.getElementById('w4d-mini').getBoundingClientRect(); const rows = [...document.querySelectorAll('.w4d-item')].map((el) => Math.round(el.getBoundingClientRect().height)); return {panelIn: p.left >= 0 && p.right <= window.innerWidth, panelW: Math.round(p.width), rowMin: Math.min(...rows), tabbarOverlap: nav ? mini.bottom > nav.getBoundingClientRect().top : false, bodyScrollX: document.documentElement.scrollWidth > window.innerWidth}; }); ok('모바일 패널 화면 안', mob.panelIn, `폭 ${mob.panelW}px`); ok('모바일 줄 높이 ≥48px', mob.rowMin >= 48, `${mob.rowMin}px`); ok('모바일 탭바 안 겹침', !mob.tabbarOverlap, mob.tabbarOverlap ? '겹침' : '안 겹침'); ok('가로 스크롤 없음', !mob.bodyScrollX, mob.bodyScrollX ? '생김' : '없음'); await m.close(); await b.close(); const bad = out.filter((x) => !x.pass); out.forEach((x) => console.log(`${x.pass ? ' OK ' : ' ✗✗ '} ${x.name.padEnd(22)} ${x.detail}`)); console.log(`\n${out.length}개 중 통과 ${out.length - bad.length} · 실패 ${bad.length}`); process.exit(bad.length ? 1 : 0);