test(frontend): verify restored public gap parity
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user