test(frontend): add legacy visual parity fixtures

This commit is contained in:
2026-07-25 11:44:49 +00:00
parent f438634b4e
commit 2f0eccb543
6 changed files with 518 additions and 1 deletions
+61
View File
@@ -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.
+2 -1
View File
@@ -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",
@@ -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;
@@ -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,
},
],
});
@@ -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"]
}
@@ -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<void> => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(operationNames(route).map((operation) => response(resolveOperation(operation)))),
});
};
const installImages = async (page: Page): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<HTMLElement>(selector)!.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
};
const titleStyle = getComputedStyle(document.querySelector<HTMLElement>('.gateway-home h2')!);
const mapStyle = getComputedStyle(document.querySelector<HTMLElement>('.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<HTMLElement>('#container')!;
const item = document.querySelector<HTMLElement>('.rankView li')!;
const itemStyle = getComputedStyle(item);
const titleStyle = getComputedStyle(document.querySelector<HTMLElement>('.rankType')!);
const image = document.querySelector<HTMLImageElement>('.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();
});