diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md new file mode 100644 index 0000000..b905900 --- /dev/null +++ b/docs/frontend-legacy-parity.md @@ -0,0 +1,61 @@ +# Frontend legacy parity + +This document records the reusable browser fixture and the visual contracts that +are enforced while the PHP frontend is moved to the Vue applications. The +workspace-level `docs/ref-core2026-mapping.md` remains the source of truth for +the end-to-end PHP-to-core2026 mapping. + +## Canonical browser fixture + +`tools/frontend-legacy-parity/fixtures/canonical.ts` contains deterministic, +non-secret gateway, session, map, and hall-of-fame data. The fixture uses a +synthetic `ga_` game access token so the game router guard follows the same +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. + +Run the suite from the core2026 repository root: + +```sh +pnpm test:e2e:frontend-legacy +``` + +The suite starts both applications at their public prefixes: + +- gateway: `http://127.0.0.1:15100/gateway/` +- game: `http://127.0.0.1:15102/che/` + +Chromium uses the same locale, timezone, device scale factor, and color scheme +for desktop and mobile checks. Tests assert geometry, computed typography and +texture URLs before checking hover, focus, menu, login mutation, session +storage, route guards, and image loading. + +## Enforced contracts + +| Screen | Ref entry point | Current automated contract | +| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | +| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | +| 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 | + +The global game baseline is black, white, Pretendard 14px. Legacy texture +helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is +green, and `bg2` is blue. Shared `PanelCard` uses the same walnut body and green +header so screens that still use the common component no longer inherit the +discarded Galmuri/parchment visual system. + +## Route coverage rule + +Adding or changing a frontend route requires: + +1. a verified ref entry-point mapping in the workspace mapping document; +2. deterministic tRPC fixture data for the visible state; +3. desktop and 500px/mobile geometry and computed-style assertions; +4. actual Chromium interaction checks for every visible interactive state; +5. natural image dimensions and `object-fit` checks where images affect layout. + +Pixel snapshots may be added after these structural assertions pass. Dynamic +regions must not be hidden merely to make a pixel threshold pass. diff --git a/package.json b/package.json index 6c30b5f..5a721af 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "generate:resource-schemas": "pnpm --filter @sammo-ts/tools-scripts generate:resource-schemas", "validate:resources": "pnpm --filter @sammo-ts/tools-scripts validate:resources", "check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check", - "check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check" + "check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check", + "test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/tools/frontend-legacy-parity/fixtures/canonical.ts b/tools/frontend-legacy-parity/fixtures/canonical.ts new file mode 100644 index 0000000..50d28d0 --- /dev/null +++ b/tools/frontend-legacy-parity/fixtures/canonical.ts @@ -0,0 +1,123 @@ +export const canonicalFrontendFixture = { + gateway: { + user: { + id: 'visual-user-1', + username: 'visual-user', + displayName: '시각검증', + roles: [], + }, + sessionToken: 'session_visual_fixture', + profile: { + profileName: 'che', + profile: 'che', + apiPort: 15003, + status: 'RUNNING', + korName: '체', + color: '#00bc8c', + scenario: '가상모드22', + }, + }, + game: { + session: { + profile: 'che', + gameToken: 'ga_frontend_legacy_fixture', + general: { + id: 1, + name: '유비', + troopId: 0, + }, + }, + lobby: { + year: 197, + month: 7, + userCnt: 17, + npcCnt: 31, + nationCnt: 5, + maxUserCnt: 50, + turnTerm: 60, + fictionMode: '가상', + otherTextInfo: '표준', + starttime: '0190-01-01T00:00:00.000Z', + myGeneral: null, + }, + mapLayout: { + mapName: 'che', + cityList: [ + { id: 1, name: '낙양', level: 7, region: 1, x: 350, y: 245, path: [2] }, + { id: 2, name: '장안', level: 7, region: 1, x: 260, y: 240, path: [1] }, + { id: 3, name: '업', level: 6, region: 2, x: 390, y: 160, path: [1] }, + ], + regionMap: { 1: '사예', 2: '기주' }, + levelMap: { 6: '주', 7: '수도' }, + }, + map: { + year: 197, + month: 7, + startYear: 190, + cityList: [ + [1, 7, 0, 1, 1, 1], + [2, 7, 0, 2, 1, 1], + [3, 6, 0, 1, 2, 1], + ], + nationList: [ + [1, '한', '#d32f2f', 1], + [2, '진', '#1976d2', 2], + ], + }, + hallOptions: [ + { + season: 1, + scenarios: [{ id: 22, name: '【가상모드22】 찐유저무쌍!', count: 1 }], + }, + ], + hall: { + sections: [ + { + title: '명 성', + valueType: 'int', + entries: [ + { + generalId: 1, + name: '유비', + ownerName: '시각검증', + nationName: '촉', + bgColor: '#006400', + fgColor: '#ffffff', + picture: 'default.jpg', + imageServer: 0, + value: 12000, + printValue: '12,000', + serverName: '체', + serverIdx: 1, + scenarioName: '가상모드22', + startTime: '2026-01-01', + unitedTime: '2026-02-01', + }, + { + generalId: 2, + name: '조조', + ownerName: '검증계정', + nationName: '위', + bgColor: '#8b0000', + fgColor: '#ffffff', + picture: 'default.jpg', + imageServer: 0, + value: 11000, + printValue: '11,000', + serverName: '체', + serverIdx: 1, + scenarioName: '가상모드22', + startTime: '2026-01-01', + unitedTime: '2026-02-01', + }, + ], + }, + { + title: '계 급', + valueType: 'int', + entries: [], + }, + ], + }, + }, +} as const; diff --git a/tools/frontend-legacy-parity/playwright.config.mjs b/tools/frontend-legacy-parity/playwright.config.mjs new file mode 100644 index 0000000..5184d11 --- /dev/null +++ b/tools/frontend-legacy-parity/playwright.config.mjs @@ -0,0 +1,48 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +export default defineConfig({ + testDir: '.', + testMatch: 'visual-parity.spec.ts', + fullyParallel: false, + workers: 1, + timeout: 30_000, + expect: { + timeout: 5_000, + }, + reporter: [ + ['list'], + ['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report/frontend-legacy') }], + ], + outputDir: resolve(repositoryRoot, 'test-results/frontend-legacy'), + use: { + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + colorScheme: 'dark', + locale: 'ko-KR', + timezoneId: 'UTC', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: [ + { + command: + 'VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port 15100', + cwd: repositoryRoot, + url: 'http://127.0.0.1:15100/gateway/', + reuseExistingServer: false, + timeout: 120_000, + }, + { + command: + 'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port 15102', + cwd: repositoryRoot, + url: 'http://127.0.0.1:15102/che/', + reuseExistingServer: false, + timeout: 120_000, + }, + ], +}); diff --git a/tools/frontend-legacy-parity/tsconfig.json b/tools/frontend-legacy-parity/tsconfig.json new file mode 100644 index 0000000..6778313 --- /dev/null +++ b/tools/frontend-legacy-parity/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": true, + "esModuleInterop": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "strict": true, + "target": "ES2022", + "types": ["node", "@playwright/test"] + }, + "include": ["./**/*.ts", "./**/*.mjs"] +} diff --git a/tools/frontend-legacy-parity/visual-parity.spec.ts b/tools/frontend-legacy-parity/visual-parity.spec.ts new file mode 100644 index 0000000..823b550 --- /dev/null +++ b/tools/frontend-legacy-parity/visual-parity.spec.ts @@ -0,0 +1,271 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, extname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { canonicalFrontendFixture as fixture } from './fixtures/canonical'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const imageRoot = resolve(repositoryRoot, '../../image'); + +const response = (data: unknown) => ({ result: { data } }); + +const operationNames = (route: Route): string[] => { + const pathname = new URL(route.request().url()).pathname; + return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const fulfillOperations = async (route: Route, resolveOperation: (operation: string) => unknown): Promise => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(operationNames(route).map((operation) => response(resolveOperation(operation)))), + }); +}; + +const installImages = async (page: Page): Promise => { + await page.route('**/image/**', async (route) => { + const pathname = decodeURIComponent(new URL(route.request().url()).pathname); + const relative = pathname.replace(/^\/image\//, ''); + const candidates = [ + resolve(imageRoot, relative), + resolve(imageRoot, 'game', relative), + resolve(imageRoot, 'icons', '22.jpg'), + ]; + for (const candidate of candidates) { + try { + const body = await readFile(candidate); + const extension = extname(candidate).toLowerCase(); + const contentType = extension === '.png' ? 'image/png' : 'image/jpeg'; + await route.fulfill({ status: 200, contentType, body }); + return; + } catch { + // 다음 공개 image root 후보를 확인한다. + } + } + await route.abort('failed'); + }); +}; + +const installGatewayFixture = async (page: Page): Promise => { + let loggedIn = false; + await installImages(page); + await page.route('**/gateway/api/trpc/**', async (route) => { + await fulfillOperations(route, (operation) => { + if (operation === 'me') return loggedIn ? fixture.gateway.user : null; + if (operation === 'lobby.profiles') return [fixture.gateway.profile]; + if (operation === 'lobby.notice') return []; + if (operation === 'auth.login') { + loggedIn = true; + return { + user: fixture.gateway.user, + sessionToken: fixture.gateway.sessionToken, + issuedAt: '2026-07-25T00:00:00.000Z', + }; + } + if (operation === 'auth.kakaoStart') { + return { mode: 'login', state: 'visual-state', authUrl: '/gateway/oauth-started' }; + } + throw new Error(`Unhandled gateway fixture operation: ${operation}`); + }); + }); + await page.route('**/che/api/trpc/**', async (route) => { + await fulfillOperations(route, (operation) => { + if (operation === 'lobby.info') return fixture.game.lobby; + if (operation === 'public.getMapLayout') return fixture.game.mapLayout; + if (operation === 'public.getCachedMap') return fixture.game.map; + throw new Error(`Unhandled gateway game fixture operation: ${operation}`); + }); + }); +}; + +const installHallFixture = async (page: Page): Promise => { + await installImages(page); + await page.route('**/che/api/trpc/**', async (route) => { + await fulfillOperations(route, (operation) => { + if (operation === 'ranking.getHallOfFameOptions') return fixture.game.hallOptions; + if (operation === 'ranking.getHallOfFame') return fixture.game.hall; + throw new Error(`Unhandled hall fixture operation: ${operation}`); + }); + }); +}; + +const installAuthenticatedGameFixture = async (page: Page): Promise => { + await installImages(page); + await page.addInitScript( + ({ gameToken, profile }) => { + window.localStorage.setItem('sammo-game-token', gameToken); + window.localStorage.setItem('sammo-game-profile', profile); + }, + { + gameToken: fixture.game.session.gameToken, + profile: fixture.game.session.profile, + } + ); + await page.route('**/che/api/trpc/**', async (route) => { + await fulfillOperations(route, (operation) => { + if (operation === 'lobby.info') { + return { ...fixture.game.lobby, myGeneral: fixture.game.session.general }; + } + if (operation === 'join.getConfig') return {}; + if (operation === 'troop.getList') { + return { + nation: { id: 1, name: '촉' }, + me: fixture.game.session.general, + permission: 0, + troops: [], + }; + } + throw new Error(`Unhandled authenticated game fixture operation: ${operation}`); + }); + }); +}; + +test.describe('gateway legacy parity', () => { + test.beforeEach(async ({ page }) => { + await installGatewayFixture(page); + }); + + for (const viewport of [ + { name: 'desktop', width: 1365, height: 768 }, + { name: 'mobile', width: 390, height: 844 }, + ]) { + test(`matches the ref login and status geometry on ${viewport.name}`, async ({ page }) => { + await page.setViewportSize(viewport); + const mapImage = page.waitForResponse((response) => + response.url().endsWith('/image/game/map/che/bg_fall.jpg') + ); + await page.goto('http://127.0.0.1:15100/gateway/'); + expect((await mapImage).ok()).toBe(true); + await expect(page.locator('#login_card')).toBeVisible(); + await expect(page.locator('.map-preview-body')).toBeVisible(); + await expect(page.getByText('지도 이미지 및 현황 데이터 영역')).toHaveCount(0); + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => { + const box = document.querySelector(selector)!.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + }; + const titleStyle = getComputedStyle(document.querySelector('.gateway-home h2')!); + const mapStyle = getComputedStyle(document.querySelector('.map-preview-body')!); + return { + title: rect('.gateway-home h2'), + login: rect('#login_card'), + status: rect('#map-subframe'), + titleStyle: { + fontFamily: titleStyle.fontFamily, + fontSize: titleStyle.fontSize, + fontWeight: titleStyle.fontWeight, + }, + mapBackgroundImage: mapStyle.backgroundImage, + }; + }); + + expect(geometry.titleStyle.fontFamily).toContain('Pretendard'); + expect(geometry.titleStyle.fontSize).toBe('20px'); + expect(geometry.titleStyle.fontWeight).toBe('400'); + expect(geometry.login.width).toBeLessThanOrEqual(450); + expect(geometry.status.width).toBeLessThanOrEqual(700); + expect(geometry.mapBackgroundImage).toContain('bg_fall.jpg'); + if (viewport.name === 'desktop') { + expect(geometry.login.width).toBeCloseTo(450, 0); + expect(geometry.status.width).toBeCloseTo(700, 0); + } else { + expect(geometry.login.width).toBeCloseTo(374, 0); + await expect(page.locator('.navbar-toggler')).toBeVisible(); + await page.locator('.navbar-toggler').click(); + await expect(page.locator('#gateway-navigation')).toHaveClass(/open/); + } + + const loginButton = page.locator('.login-button'); + const before = await loginButton.evaluate((element) => getComputedStyle(element).backgroundColor); + await loginButton.hover(); + const hover = await loginButton.evaluate((element) => getComputedStyle(element).backgroundColor); + await loginButton.focus(); + await expect(loginButton).toBeFocused(); + expect(hover).not.toBe(before); + }); + } + + test('submits the real login mutation and stores the session', async ({ page }) => { + await page.goto('http://127.0.0.1:15100/gateway/'); + await page.locator('#username').fill('visual-user'); + await page.locator('#password').fill('visual-password'); + await page.locator('.login-button').click(); + await expect(page).toHaveURL(/\/gateway\/lobby$/); + await expect + .poll(() => page.evaluate(() => window.localStorage.getItem('sammo-session-token'))) + .toBe(fixture.gateway.sessionToken); + }); +}); + +test.describe('hall of fame legacy parity', () => { + test.beforeEach(async ({ page }) => { + await installHallFixture(page); + }); + + for (const viewport of [ + { name: 'desktop', width: 1365, height: 768, expectedWidth: 1000 }, + { name: 'mobile', width: 390, height: 844, expectedWidth: 500 }, + ]) { + test(`matches the ref fixed grid on ${viewport.name}`, async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto('http://127.0.0.1:15102/che/hall-of-fame'); + await expect(page.getByText('유비')).toBeVisible(); + await expect(page.locator('.rankView')).toHaveCount(2); + + const geometry = await page.evaluate(() => { + const container = document.querySelector('#container')!; + const item = document.querySelector('.rankView li')!; + const itemStyle = getComputedStyle(item); + const titleStyle = getComputedStyle(document.querySelector('.rankType')!); + const image = document.querySelector('.generalIcon')!; + return { + container: container.getBoundingClientRect().width, + containerBackgroundImage: getComputedStyle(container).backgroundImage, + item: { + width: item.getBoundingClientRect().width, + minHeight: itemStyle.minHeight, + }, + title: { + fontFamily: titleStyle.fontFamily, + fontSize: titleStyle.fontSize, + backgroundImage: titleStyle.backgroundImage, + }, + image: { + width: image.getBoundingClientRect().width, + height: image.getBoundingClientRect().height, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + objectFit: getComputedStyle(image).objectFit, + }, + }; + }); + + expect(geometry.container).toBe(viewport.expectedWidth); + expect(geometry.containerBackgroundImage).toContain('back_walnut.jpg'); + expect(geometry.item.width).toBe(100); + expect(geometry.title.fontFamily).toContain('Pretendard'); + expect(geometry.title.backgroundImage).toContain('back_green.jpg'); + expect(geometry.image).toMatchObject({ width: 64, height: 64, objectFit: 'cover' }); + expect(geometry.image.naturalWidth).toBeGreaterThan(0); + + const close = page.getByRole('button', { name: '창 닫기' }).first(); + await close.hover(); + await close.focus(); + await expect(close).toBeFocused(); + }); + } +}); + +test('game login delegates to the gateway like the ref entry point', async ({ page }) => { + await page.goto('http://127.0.0.1:15102/che/login'); + await expect(page).toHaveURL('http://127.0.0.1:15102/gateway/'); +}); + +test('canonical logged-in fixture passes the game route guard', async ({ page }) => { + await installAuthenticatedGameFixture(page); + await page.goto('http://127.0.0.1:15102/che/troop'); + await expect(page).toHaveURL(/\/che\/troop$/); + await expect(page.getByText('부대 편성')).toBeVisible(); +});