diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index a94dac9..2865916 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -15,6 +15,8 @@ authenticated path as a real session without contacting a live database. The test suite intercepts only the tRPC operations required by each screen and fails on unknown operations. It also serves the checked-out reference image tree instead of replacing images with layout-neutral placeholders. +`public-gaps.spec.ts` adds bounded fixtures for nation betting and the public +NPC list, including mutations and recoverable API failures. Run the suite from the core2026 repository root: @@ -43,6 +45,8 @@ storage, route guards, and image loading. | troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | | hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus | | yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | +| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | +| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | The global game baseline is black, white, Pretendard 14px. Legacy texture helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is diff --git a/tools/frontend-legacy-parity/playwright.config.mjs b/tools/frontend-legacy-parity/playwright.config.mjs index 5184d11..b3bc4f7 100644 --- a/tools/frontend-legacy-parity/playwright.config.mjs +++ b/tools/frontend-legacy-parity/playwright.config.mjs @@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') export default defineConfig({ testDir: '.', - testMatch: 'visual-parity.spec.ts', + testMatch: ['visual-parity.spec.ts', 'public-gaps.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/tools/frontend-legacy-parity/public-gaps.spec.ts b/tools/frontend-legacy-parity/public-gaps.spec.ts new file mode 100644 index 0000000..0977f8d --- /dev/null +++ b/tools/frontend-legacy-parity/public-gaps.spec.ts @@ -0,0 +1,368 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const imageRoots = [ + resolve(repositoryRoot, '../image/game'), + resolve(repositoryRoot, '../../image/game'), +]; +const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const pathname = new URL(route.request().url()).pathname; + return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const fulfill = async (route: Route, results: unknown[]) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); +}; + +const installImages = async (page: Page) => { + await page.route('**/image/game/**', async (route) => { + const filename = new URL(route.request().url()).pathname.split('/').at(-1) ?? ''; + for (const root of imageRoots) { + try { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readFile(resolve(root, filename)), + }); + return; + } catch { + // Feature worktrees and the main checkout have different image-relative roots. + } + } + await route.abort('failed'); + }); +}; + +const installSession = async (page: Page) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_public_gaps'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); +}; + +const bettingList = { + result: true, + bettingList: { + 7: { + id: 7, + type: 'bettingNation', + name: '천통국 예상', + finished: false, + selectCnt: 2, + isExclusive: false, + reqInheritancePoint: true, + openYearMonth: 2316, + closeYearMonth: 2340, + winner: null, + totalAmount: 800, + }, + }, + year: 193, + month: 1, +}; + +const bettingDetail = { + result: true, + bettingInfo: { + id: 7, + type: 'bettingNation', + name: '천통국 예상', + finished: false, + selectCnt: 2, + isExclusive: false, + reqInheritancePoint: true, + openYearMonth: 2316, + closeYearMonth: 2340, + candidates: [ + { title: '촉', info: '국력: 1200
장수 수: 8
도시 수: 5', isHtml: true }, + { title: '위', info: '국력: 1100
장수 수: 7
도시 수: 4', isHtml: true }, + { title: '오', info: '국력: 900
장수 수: 6
도시 수: 3', isHtml: true }, + { title: '연', info: '국력: 700
장수 수: 5
도시 수: 2', isHtml: true }, + { title: '양', info: '국력: 650
장수 수: 4
도시 수: 2', isHtml: true }, + { title: '형', info: '국력: 600
장수 수: 3
도시 수: 1', isHtml: true }, + ], + winner: null, + }, + bettingDetail: [ + ['[-1]', 500], + ['[0,1]', 200], + ['[1,2]', 100], + ], + myBetting: [['[0,1]', 50]], + remainPoint: 1200, + year: 193, + month: 1, +}; + +const installBettingFixture = async (page: Page) => { + await installImages(page); + await installSession(page); + let failBet = true; + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') { + return response({ myGeneral: { id: 1, name: '테스트 장수' } }); + } + if (operation === 'join.getConfig') { + return response({}); + } + if (operation === 'betting.getList') { + return response(bettingList); + } + if (operation === 'betting.getDetail') { + return response(bettingDetail); + } + if (operation === 'betting.bet') { + if (failBet) { + failBet = false; + return errorResponse(operation, '유산포인트가 충분하지 않습니다.'); + } + return response({ result: true }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await fulfill(route, results); + }); +}; + +const npcNameRows = [ + { + id: 1, + name: '관우', + npcState: 1, + ownerName: '악령 관우', + level: 4, + nationId: 1, + nationName: '촉', + personality: { key: 'che_의리', name: '의리', info: '의리를 중시합니다.' }, + specialDomestic: { key: 'che_상재', name: '상재', info: '상업에 능합니다.' }, + specialWar: { key: 'che_돌격', name: '돌격', info: '돌격에 능합니다.' }, + statTotal: 260, + leadership: 90, + strength: 95, + intelligence: 75, + experience: 800, + dedication: 700, + }, + { + id: 2, + name: '조운', + npcState: 2, + ownerName: '', + level: 5, + nationId: 0, + nationName: '-', + personality: { key: 'che_안전', name: '안전', info: '안전을 중시합니다.' }, + specialDomestic: null, + specialWar: { key: 'che_견고', name: '견고', info: '방어에 능합니다.' }, + statTotal: 270, + leadership: 95, + strength: 90, + intelligence: 85, + experience: 900, + dedication: 900, + }, +]; + +const installNpcFixture = async (page: Page) => { + await installImages(page); + let npcCalls = 0; + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'public.getNpcList') { + npcCalls += 1; + if (npcCalls === 3) { + return errorResponse(operation, '빙의 일람을 불러오지 못했습니다.'); + } + return response({ + sort: npcCalls === 1 ? 1 : 8, + generals: npcCalls === 1 ? npcNameRows : [...npcNameRows].reverse(), + }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await fulfill(route, results); + }); +}; + +test('nation betting matches the legacy desktop geometry and preserves a failed form', async ({ page }) => { + await installBettingFixture(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('http://127.0.0.1:15102/che/nation-betting'); + await expect(page.getByRole('button', { name: /천통국 예상/ })).toBeVisible(); + await page.getByRole('button', { name: /천통국 예상/ }).click(); + await expect(page.locator('.betting-candidate')).toHaveCount(6); + + const geometry = await page.locator('#nation-betting-container').evaluate((container) => { + const containerRect = container.getBoundingClientRect(); + const bar = container.querySelector('.legacy-top-bar')!.getBoundingClientRect(); + const cards = Array.from(container.querySelectorAll('.betting-candidate')); + const cardStyle = getComputedStyle(cards[0]!); + return { + container: { x: containerRect.x, width: containerRect.width }, + bar: { width: bar.width, height: bar.height }, + cardWidths: cards.map((card) => card.getBoundingClientRect().width), + cardStyle: { + borderWidth: cardStyle.borderWidth, + borderRadius: cardStyle.borderRadius, + cursor: cardStyle.cursor, + fontSize: cardStyle.fontSize, + lineHeight: cardStyle.lineHeight, + }, + }; + }); + + expect(geometry.container).toEqual({ x: 140, width: 1000 }); + expect(geometry.bar).toEqual({ width: 1000, height: 32 }); + expect(geometry.cardWidths.every((width) => Math.abs(width - 162) < 1)).toBe(true); + expect(geometry.cardStyle).toEqual({ + borderWidth: '1px', + borderRadius: '7px', + cursor: 'pointer', + fontSize: '14px', + lineHeight: '18.2px', + }); + + await page.locator('.betting-candidate').nth(0).click(); + await page.locator('.betting-candidate').nth(1).click(); + const pickedStyle = await page.locator('.betting-candidate').first().evaluate((candidate) => { + const style = getComputedStyle(candidate); + return { borderColor: style.borderColor, outlineWidth: style.outlineWidth, titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight }; + }); + expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)'); + // Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1. + expect(pickedStyle.outlineWidth).toBe('1px'); + expect(Number(pickedStyle.titleWeight)).toBeGreaterThanOrEqual(700); + + const amountInput = page.getByLabel('베팅 금액'); + await amountInput.fill('300'); + await page.getByRole('button', { name: '베팅', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('유산포인트가 충분하지 않습니다.'); + await expect(amountInput).toHaveValue('300'); + await expect(page.locator('.betting-candidate.picked')).toHaveCount(2); + + await page.getByRole('button', { name: '베팅', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('베팅했습니다'); + await expect(amountInput).toHaveValue('0'); + + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'nation-betting-core-desktop.png'), fullPage: true }); + } +}); + +test('nation betting keeps the legacy 500px three-column mobile contract', async ({ page }) => { + await installBettingFixture(page); + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto('http://127.0.0.1:15102/che/nation-betting'); + await page.getByRole('button', { name: /천통국 예상/ }).click(); + await expect(page.locator('.betting-candidate')).toHaveCount(6); + + const geometry = await page.locator('#nation-betting-container').evaluate((container) => { + const rect = container.getBoundingClientRect(); + const cards = Array.from(container.querySelectorAll('.betting-candidate')); + return { + x: rect.x, + width: rect.width, + firstWidth: cards[0]!.getBoundingClientRect().width, + fourthY: cards[3]!.getBoundingClientRect().y, + firstY: cards[0]!.getBoundingClientRect().y, + }; + }); + expect(geometry.x).toBe(0); + expect(geometry.width).toBe(500); + expect(geometry.firstWidth).toBeCloseTo(161.328125, 3); + expect(geometry.fourthY).toBeGreaterThan(geometry.firstY); + + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'nation-betting-core-mobile.png'), fullPage: true }); + } +}); + +test('NPC list matches the legacy table geometry, sorting and error retention', async ({ page }) => { + await installNpcFixture(page); + await page.setViewportSize({ width: 1024, height: 800 }); + await page.goto('http://127.0.0.1:15102/che/npc-list'); + await expect(page.locator('.npc-table tbody tr')).toHaveCount(2); + + const geometry = await page.locator('#npc-list-container').evaluate((container) => { + const containerRect = container.getBoundingClientRect(); + const tableRect = container.querySelector('.npc-table')!.getBoundingClientRect(); + const headers = Array.from(container.querySelectorAll('.npc-table th')); + const style = getComputedStyle(headers[0]!); + return { + container: { x: containerRect.x, width: containerRect.width }, + tableWidth: tableRect.width, + headerWidths: headers.map((header) => header.getBoundingClientRect().width), + headerStyle: { + height: headers[0]!.getBoundingClientRect().height, + borderWidth: style.borderWidth, + padding: style.padding, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }, + }; + }); + + expect(geometry.container).toEqual({ x: 12, width: 1000 }); + expect(geometry.tableWidth).toBe(1000); + // The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table. + expect(geometry.headerWidths).toEqual([ + 104.609375, + 104.609375, + 69.734375, + 121.015625, + 69.734375, + 90.25, + 69.734375, + 69.734375, + 69.734375, + 69.734375, + 80, + 80.109375, + ]); + expect(geometry.headerStyle).toEqual({ + height: 20, + borderWidth: '1px', + padding: '0px', + fontSize: '14px', + lineHeight: '18.2px', + }); + await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우'); + await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS('color', 'rgb(135, 206, 235)'); + const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first(); + await personality.hover(); + await expect(personality.getByRole('tooltip')).toBeVisible(); + expect(await personality.evaluate((element) => getComputedStyle(element).cursor)).toBe('help'); + + await page.locator('#npc-list-sort').selectOption('8'); + await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(page.locator('.npc-table tbody tr').first()).toContainText('조운'); + + await page.locator('#npc-list-sort').selectOption('7'); + await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(page.getByRole('alert')).toContainText('빙의 일람을 불러오지 못했습니다.'); + await expect(page.locator('#npc-list-sort')).toHaveValue('7'); + await expect(page.locator('.npc-table tbody tr').first()).toContainText('조운'); + + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'npc-list-core-desktop.png'), fullPage: true }); + } +});