M9: [?] 도움 버튼을 차례 안내 옆으로, 튜토리얼 E2E, 기존 E2E는 요약 카드 본 것으로 처리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 03:10:14 +09:00
parent 57753a037c
commit 32e3e89d8d
6 changed files with 165 additions and 7 deletions

View File

@@ -87,7 +87,7 @@ export function QuickStartButton({ title, cards }: { title: string; cards: Quick
}
/**
* [?] button for the room bar while a game is on screen. Also opens the summary cards by itself
* [?] button next to the turn status while a game is on screen. Also opens the summary cards by itself
* the first time this device sees a running game of this kind.
*/
export function GameHelp({ room, ui, title, me, players }: { room: RoomView; ui: GameUI; title: string; me: string; players: Record<string, PublicUser> }) {
@@ -118,8 +118,9 @@ export function GameHelp({ room, ui, title, me, players }: { room: RoomView; ui:
return (
<>
<button className="btn btn-ghost btn-sm" onClick={() => (setRules(false), setOpen(true))} aria-label="도움말: 지금 할 수 있는 것" aria-haspopup="dialog">
❓ 도움
<button className="btn btn-secondary" style={{ flexShrink: 0, padding: '0 12px', flexDirection: 'column', gap: 0, lineHeight: 1.1 }} onClick={() => (setRules(false), setOpen(true))} aria-label="도움말: 지금 할 수 있는 것" aria-haspopup="dialog">
<span aria-hidden style={{ fontSize: '1.3rem' }}>?</span>
<span className="small">도움</span>
</button>
{open &&
createPortal(

View File

@@ -160,7 +160,6 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
</strong>
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
<span style={{ flex: 1 }} />
{inGame && game && <GameHelp room={room} ui={ui} title={g.nameKo} me={me.id} players={players} />}
{!roomChatClosed && (
<span className="phone-only">
<ChatButton onOpen={() => setChatOpen(true)} />
@@ -546,14 +545,21 @@ function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui
)}
</div>
);
// [?] 지금 할 수 있는 것 sits right next to the turn status (docs/07 §5).
const statusRow = (
<div className="row" style={{ flexWrap: 'nowrap', gap: 8, width: '100%', alignItems: 'stretch' }}>
{status}
<GameHelp room={room} ui={ui} title={gameName(room.gameId)} me={me.id} players={players} />
</div>
);
return (
<div className="game-layout">
<div className="game-main">
{!wide && status}
{!wide && statusRow}
<ui.Board {...props} />
</div>
<aside className="game-side" aria-label="게임 정보">
{wide && status}
{wide && statusRow}
<ui.Side {...props} />
{wide && !(ui.ownChat && (room.status === 'playing' || room.status === 'paused')) && <ChatPanel room={room} me={me} title={false} />}
</aside>

View File

@@ -8,7 +8,7 @@ import { join } from 'node:path';
import { loadConfig } from '../apps/server/src/config';
import { startServer } from '../apps/server/src/server';
const PORT = 4520;
const PORT = Number(process.env.E2E_PORT ?? 4520);
const ORIGIN = `http://localhost:${PORT}`;
const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
const app = startServer({

View File

@@ -2014,6 +2014,8 @@ for (const id of games) {
}
const guest = guests[0]!;
for (const p of [host, ...guests]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`));
// The first-game "30초 규칙 요약" cards are tested in e2e/tutorial.e2e.ts; mark them seen here.
for (const p of [host, ...guests]) await p.addInitScript((g) => localStorage.setItem(`bg:qs:${g}`, '1'), id);
try {
await host.goto(`${ORIGIN}/g/${id}`);
await host.getByRole('button', { name: /친구랑 하기/ }).click();

View File

@@ -61,6 +61,8 @@ try {
const b = await ctxB.newPage();
const errors: string[] = [];
for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message));
// The first-game "30초 규칙 요약" cards are tested in e2e/tutorial.e2e.ts.
for (const p of [a, b]) await p.addInitScript(() => localStorage.setItem('bg:qs:omok', '1'));
let code = '';
await step('A: 홈 → 방 만들기 → 닉네임 → 대기실', async () => {

147
e2e/tutorial.e2e.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* E2E (docs/07 §5 초보자 도움): the first game of a kind on a device shows the 3 "30초 규칙 요약" cards
* ([다음]/[건너뛰기]/[시작하기]), the next game doesn't, and the in-game [?] sheet explains the
* situation and opens the full rules and the cards again. Also runs axe on both sheets.
* Run: bun e2e/tutorial.e2e.ts (needs apps/web/dist built)
*/
import { chromium, type Page } from 'playwright-core';
import { mkdirSync, mkdtempSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { loadConfig } from '../apps/server/src/config';
import { startServer } from '../apps/server/src/server';
const PORT = Number(process.env.E2E_PORT ?? 4521);
const ORIGIN = `http://localhost:${PORT}`;
const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results');
mkdirSync(SHOTS, { recursive: true });
const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
const app = startServer({
config: loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')), 'app.db'), PUBLIC_ORIGIN: ORIGIN }),
log: () => {},
staticDir: join(import.meta.dir, '../apps/web/dist'),
});
const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true });
let failed = false;
const step = async (name: string, fn: () => Promise<void>) => {
const t0 = performance.now();
try {
await fn();
console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`);
} catch (e) {
failed = true;
console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`);
throw e;
}
};
/** axe on the open sheet only (the game board under it is covered by e2e/a11y.e2e.ts). */
async function axe(page: Page, name: string) {
await page.waitForTimeout(400); // sheet fade-in would lower measured contrast
await page.addScriptTag({ content: AXE });
const res = await page.evaluate(async () => (window as any).axe.run('.sheet', { resultTypes: ['violations'] }));
const serious = res.violations.filter((v: any) => v.impact === 'serious' || v.impact === 'critical');
if (serious.length) throw new Error(`axe ${name}: ${serious.map((v: any) => `${v.id} ${v.nodes[0]?.target?.join(' ')}`).join('; ')}`);
}
const cards = (p: Page) => p.getByRole('dialog', { name: '오목 30초 규칙 요약' });
const help = (p: Page) => p.getByRole('dialog', { name: '지금 할 수 있는 것' });
try {
const a = await (await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' })).newPage();
const b = await (await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' })).newPage();
const errors: string[] = [];
for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message));
await step('방 만들기 → 친구 입장 → 시작', async () => {
await a.goto(`${ORIGIN}/g/omok`);
await a.getByRole('button', { name: /친구랑 하기/ }).click();
await a.getByLabel('내 이름(닉네임)').fill('호랑이');
await a.getByRole('button', { name: '이 이름으로 시작하기' }).click();
await a.waitForURL(/\/r\/\d{6}$/);
if (await a.getByRole('button', { name: /도움/ }).count()) throw new Error('[?] shown in the lobby');
await b.goto(a.url());
await b.getByLabel('내 이름(닉네임)').fill('고양이');
await b.getByRole('button', { name: '이 이름으로 입장하기' }).click();
await a.getByLabel('자리').getByText('고양이').waitFor();
await a.getByRole('button', { name: '▶ 게임 시작' }).click();
await a.getByRole('dialog', { name: '준비 안 된 사람이 있어요' }).getByRole('button', { name: '시작하기' }).click();
});
await step('처음 하는 게임: 요약 카드 3장 (PC는 끝까지, 휴대폰은 건너뛰기)', async () => {
await cards(a).getByText('1/3').waitFor();
await cards(b).getByText('1/3').waitFor();
await b.waitForTimeout(400); // sheet animation
await b.screenshot({ path: join(SHOTS, 'tutorial-1-cards-phone.png') });
await axe(a, '요약 카드');
await cards(a).getByRole('button', { name: '다음 →' }).click();
await cards(a).getByText('2/3').waitFor();
await cards(a).getByRole('button', { name: '다음 →' }).click();
await cards(a).getByText('3/3').waitFor();
if (await cards(a).getByRole('button', { name: '건너뛰기' }).count()) throw new Error('last card still offers 건너뛰기');
await a.screenshot({ path: join(SHOTS, 'tutorial-2-cards-last-pc.png') });
await cards(a).getByRole('button', { name: '시작하기' }).click();
await cards(a).waitFor({ state: 'detached' });
await cards(b).getByRole('button', { name: '건너뛰기' }).click();
await cards(b).waitFor({ state: 'detached' });
for (const p of [a, b]) {
if ((await p.evaluate(() => localStorage.getItem('bg:qs:omok'))) !== '1') throw new Error('seen flag not stored');
}
});
await step('[?] → 지금 할 수 있는 것 + 전체 규칙 + 요약 다시 보기', async () => {
await a.getByRole('button', { name: '도움말: 지금 할 수 있는 것' }).click();
const text = await help(a).locator('.help-now').innerText();
if (!/차례예요/.test(text)) throw new Error(`no situation sentence: ${text}`);
await axe(a, '도움말');
await help(a).getByRole('button', { name: /전체 규칙 보기/ }).click();
await help(a).getByText('5개를 먼저 이으면 이겨요', { exact: false }).first().waitFor();
await a.screenshot({ path: join(SHOTS, 'tutorial-3-help-pc.png') });
await help(a).getByRole('button', { name: /요약 다시 보기/ }).click();
await cards(a).getByText('1/3').waitFor();
await cards(a).getByRole('button', { name: '건너뛰기' }).click();
await cards(a).waitFor({ state: 'detached' });
// Phone: the [?] next to the turn status doesn't widen the page.
const sw = await b.evaluate(() => document.documentElement.scrollWidth);
if (sw > 390) throw new Error(`phone page is ${sw}px wide`);
await b.getByRole('button', { name: '도움말: 지금 할 수 있는 것' }).click();
const phone = await help(b).locator('.help-now').innerText();
if (!/차례예요/.test(phone)) throw new Error(`no situation sentence on phone: ${phone}`);
await b.waitForTimeout(400);
await b.screenshot({ path: join(SHOTS, 'tutorial-4-help-phone.png') });
await help(b).getByRole('button', { name: '닫기' }).click();
await help(b).waitFor({ state: 'detached' });
});
await step('두 번째 판: 요약 카드가 다시 나오지 않아요', async () => {
await a.getByRole('button', { name: /기권/ }).click();
await a.getByRole('button', { name: '기권하기' }).click();
await a.getByRole('button', { name: /한 판 더/ }).click();
await b.getByRole('button', { name: /한 판 더/ }).click();
await a.getByText(/차례예요|생각 중이에요/).first().waitFor();
await a.waitForTimeout(800);
if ((await cards(a).count()) + (await cards(b).count())) throw new Error('cards shown again');
await a.reload();
await a.locator('.turn-status').waitFor();
await a.waitForTimeout(500);
if (await cards(a).count()) throw new Error('cards shown after reload');
});
await step('규칙 페이지: 30초 요약 보기', async () => {
await a.goto(`${ORIGIN}/rules/omok`);
await a.getByRole('button', { name: /30초 요약 보기/ }).click();
await cards(a).getByText('1/3').waitFor();
await cards(a).getByRole('button', { name: '건너뛰기' }).click();
await cards(a).waitFor({ state: 'detached' });
});
if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`);
} catch (e) {
failed = true;
console.error(e);
} finally {
await browser.close();
await app.stop();
}
console.log(failed ? 'E2E FAILED' : 'E2E PASSED');
process.exit(failed ? 1 : 0);