test(frontend): verify restored public gap parity

This commit is contained in:
2026-07-26 02:04:08 +00:00
parent ba27b71e2f
commit 8eba0d4896
3 changed files with 373 additions and 1 deletions
+4
View File
@@ -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
@@ -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,
@@ -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<br>장수 수: 8<br>도시 수: 5', isHtml: true },
{ title: '위', info: '국력: 1100<br>장수 수: 7<br>도시 수: 4', isHtml: true },
{ title: '오', info: '국력: 900<br>장수 수: 6<br>도시 수: 3', isHtml: true },
{ title: '연', info: '국력: 700<br>장수 수: 5<br>도시 수: 2', isHtml: true },
{ title: '양', info: '국력: 650<br>장수 수: 4<br>도시 수: 2', isHtml: true },
{ title: '형', info: '국력: 600<br>장수 수: 3<br>도시 수: 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<HTMLElement>('.legacy-top-bar')!.getBoundingClientRect();
const cards = Array.from(container.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.npc-table')!.getBoundingClientRect();
const headers = Array.from(container.querySelectorAll<HTMLElement>('.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 });
}
});