Files
core2026/app/game-frontend/e2e/auction.spec.ts
T

372 lines
15 KiB
TypeScript

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')];
type AuctionFixture = {
failResourceBid?: boolean;
resourceBidCount: number;
uniqueBidCount: number;
};
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 url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const readReferenceImage = async (filename: string): Promise<Buffer> => {
for (const imageRoot of imageRoots) {
try {
return await readFile(resolve(imageRoot, filename));
} catch {
// The main checkout and nested feature worktrees have different parents.
}
}
throw new Error(`Reference image not found: ${filename}`);
};
const overview = {
resourceAuctions: [
{
id: 1,
type: 'BUY_RICE',
targetCode: '1000',
status: 'OPEN',
hostGeneralId: 11,
hostName: '조조',
isCallerHost: false,
closeAt: '2026-07-27T02:30:00.000Z',
detail: {
title: '쌀 1000 경매',
amount: 1000,
isReverse: false,
startBidAmount: 500,
finishBidAmount: 1800,
},
highestBid: {
amount: 750,
bidderName: '관우',
isCaller: false,
eventAt: '2026-07-26T01:00:00.000Z',
},
},
{
id: 2,
type: 'SELL_RICE',
targetCode: '900',
status: 'OPEN',
hostGeneralId: 7,
hostName: '유비',
isCallerHost: true,
closeAt: '2026-07-27T03:00:00.000Z',
detail: {
title: '금 900 경매',
amount: 900,
isReverse: false,
startBidAmount: 600,
finishBidAmount: 1700,
},
highestBid: null,
},
],
uniqueAuctions: [
{
id: 10,
type: 'UNIQUE_ITEM',
targetCode: 'che_무기_12_칠성검',
status: 'OPEN',
hostGeneralId: null,
hostName: '청룡',
isCallerHost: false,
closeAt: '2026-07-27T04:00:00.000Z',
detail: {
title: '칠성검 경매',
startBidAmount: 5000,
remainCloseDateExtensionCnt: 1,
availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z',
},
highestBid: {
amount: 5500,
bidderName: '백호',
isCaller: false,
eventAt: '2026-07-26T02:00:00.000Z',
},
},
{
id: 9,
type: 'UNIQUE_ITEM',
targetCode: 'che_서적_15_손자병법',
status: 'FINISHED',
hostGeneralId: null,
hostName: '현무',
isCallerHost: true,
closeAt: '2026-07-25T04:00:00.000Z',
detail: {
title: '손자병법 경매',
startBidAmount: 5000,
remainCloseDateExtensionCnt: 0,
availableLatestBidCloseDate: '2026-07-25T04:30:00.000Z',
},
highestBid: {
amount: 6000,
bidderName: '현무',
isCaller: true,
eventAt: '2026-07-25T03:00:00.000Z',
},
},
],
callerAlias: '현무',
remainPoint: 9000,
recentLogs: [
{
id: 1,
text: '<C>●</>경매 1번 거래가 성사되었습니다.',
createdAt: '2026-07-25T00:00:00.000Z',
},
],
};
const uniqueDetail = {
auction: {
id: 10,
targetCode: 'che_무기_12_칠성검',
status: 'OPEN',
hostName: '청룡',
isCallerHost: false,
closeAt: '2026-07-27T04:00:00.000Z',
detail: {
title: '칠성검 경매',
startBidAmount: 5000,
remainCloseDateExtensionCnt: 1,
availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z',
},
},
bids: [
{
id: 101,
amount: 5500,
bidderName: '백호',
isCaller: false,
eventAt: '2026-07-26T02:00:00.000Z',
},
{
id: 100,
amount: 5000,
bidderName: '현무',
isCaller: true,
eventAt: '2026-07-26T01:00:00.000Z',
},
],
callerAlias: '현무',
remainPoint: 9000,
};
const installFixture = async (page: Page, state: AuctionFixture) => {
await page.addInitScript(() => {
window.localStorage.setItem('sammo-game-token', 'ga_auction_playwright');
window.localStorage.setItem('sammo-game-profile', 'che:default');
});
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
await page.route(`**/image/game/${filename}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/jpeg',
body: await readReferenceImage(filename),
});
});
}
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') {
return response({ myGeneral: { id: 7, name: '유비' } });
}
if (operation === 'join.getConfig') {
return response({});
}
if (operation === 'auction.getOverview') {
return response(overview);
}
if (operation === 'auction.getUniqueDetail') {
return response(uniqueDetail);
}
if (operation === 'auction.bidBuyRice') {
if (state.failResourceBid) {
state.failResourceBid = false;
return errorResponse(operation, '금이 부족합니다.');
}
state.resourceBidCount += 1;
return response({ ok: true });
}
if (operation === 'auction.bidUnique') {
state.uniqueBidCount += 1;
return response({ ok: true });
}
if (operation === 'auction.openBuyRice' || operation === 'auction.openSellRice') {
return response({ auctionId: 20, closeAt: '2026-07-28T00:00:00.000Z' });
}
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
};
const gotoAuction = async (page: Page, suffix = 'auction') => {
const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info'));
await page.goto(suffix);
await lobbyResponse;
await expect(page.locator('#container')).toBeVisible();
};
test('resource auction preserves the legacy desktop structure, geometry, and interaction states', async ({ page }) => {
const state = { failResourceBid: true, resourceBidCount: 0, uniqueBidCount: 0 };
await installFixture(page, state);
await page.setViewportSize({ width: 1000, height: 800 });
await gotoAuction(page);
await expect(page.getByRole('heading', { name: '경매장', exact: true })).toBeVisible();
await expect(page.getByText('쌀 구매', { exact: true })).toBeVisible();
await expect(page.getByText('쌀 판매', { exact: true })).toBeVisible();
await expect(page.getByText('단가', { exact: true }).first()).toBeVisible();
const geometry = await page.locator('#container').evaluate((container) => {
const box = (selector: string) => {
const rect = container.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
};
const containerRect = container.getBoundingClientRect();
const row = container.querySelector<HTMLElement>('.resource-row')!;
const rowRect = row.getBoundingClientRect();
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
const button = container.querySelector<HTMLElement>('.tab-button')!;
const buttonStyle = getComputedStyle(button);
return {
container: { x: containerRect.x, width: containerRect.width },
topBar: box('.top-back-bar'),
row: { width: rowRect.width, height: rowRect.height },
cells,
button: {
height: button.getBoundingClientRect().height,
borderRadius: buttonStyle.borderRadius,
cursor: buttonStyle.cursor,
fontSize: buttonStyle.fontSize,
},
};
});
expect(geometry.container).toEqual({ x: 0, width: 1000 });
expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 });
expect(geometry.row).toEqual({ width: 1000, height: 22 });
expect(geometry.cells[0]).toBeCloseTo(66.66, 1);
expect(geometry.cells[1]).toBeCloseTo(133.34, 1);
expect(geometry.cells[6]).toBeCloseTo(200, 1);
expect(geometry.button).toEqual({
height: 35.5,
borderRadius: '5.25px',
cursor: 'pointer',
fontSize: '14px',
});
await page.screenshot({ path: 'test-results/auction/resource-desktop-initial.png', fullPage: true });
const firstRow = page.locator('.resource-row.clickable-row').first();
await firstRow.click();
const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' });
await bidInput.fill('800');
await page.getByRole('button', { name: '입찰', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('금이 부족합니다.');
await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true });
expect(state.resourceBidCount).toBe(0);
await page.getByRole('button', { name: '입찰', exact: true }).click();
await expect(page.getByRole('status')).toContainText('입찰했습니다.');
expect(state.resourceBidCount).toBe(1);
await firstRow.hover();
expect(await firstRow.evaluate((row) => getComputedStyle(row).cursor)).toBe('pointer');
await page.screenshot({ path: 'test-results/auction/resource-desktop.png', fullPage: true });
});
test('resource auction keeps the legacy 500px two-row grid', async ({ page }) => {
await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 });
await page.setViewportSize({ width: 500, height: 800 });
await gotoAuction(page);
const geometry = await page
.locator('.resource-row')
.first()
.evaluate((row) => {
const origin = row.getBoundingClientRect();
const relative = (selector: string) => {
const rect = row.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height };
};
return {
row: { width: origin.width, height: origin.height },
idx: relative('.idx'),
host: relative('.host'),
amount: relative('.amount'),
close: relative('.close-date'),
};
});
expect(geometry.row).toEqual({ width: 500, height: 43 });
expect(geometry.idx).toEqual({ x: 0, y: 10.5, width: 41.65625, height: 21 });
expect(geometry.host).toEqual({ x: 41.65625, y: 0, width: 125, height: 21 });
expect(geometry.amount).toEqual({ x: 41.65625, y: 21, width: 125, height: 21 });
expect(geometry.close).toEqual({ x: 416.65625, y: 10.5, width: 83.34375, height: 21 });
await page.screenshot({ path: 'test-results/auction/resource-mobile.png', fullPage: true });
});
test('unique auction separates ongoing and finished lists and auto-loads the legacy detail', async ({ page }) => {
const state = { resourceBidCount: 0, uniqueBidCount: 0 };
await installFixture(page, state);
await page.setViewportSize({ width: 1000, height: 800 });
await gotoAuction(page, 'auction?type=unique');
await expect(page.getByRole('heading', { name: '유니크 경매장', exact: true })).toBeVisible();
await expect(page.locator('.caller-alias')).toContainText('내 가명: 현무');
await expect(page.getByRole('heading', { name: '경매 10번 상세' })).toBeVisible();
await expect(page.getByText('최대지연', { exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: '진행중인 경매 목록' })).toBeVisible();
await expect(page.getByRole('heading', { name: '종료된 경매 목록' })).toBeVisible();
await expect(page.getByText('남음', { exact: true })).toBeVisible();
await expect(page.getByText('소진', { exact: true })).toBeVisible();
const aliasStyle = await page.locator('.caller-alias strong').evaluate((element) => {
const style = getComputedStyle(element);
return { color: style.color, fontWeight: style.fontWeight };
});
expect(aliasStyle).toEqual({ color: 'rgb(0, 255, 255)', fontWeight: '700' });
const input = page.getByRole('spinbutton', { name: '유산포인트' });
await input.fill('5600');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('칠성검 경매에 5600유산포인트를 입찰하시겠습니까?');
await dialog.accept();
});
await page.getByRole('button', { name: '입찰', exact: true }).click();
await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.');
expect(state.uniqueBidCount).toBe(1);
await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true });
});
test('resource host cannot bid on the auction opened by its own general', async ({ page }) => {
await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 });
await gotoAuction(page);
await page.locator('.resource-row.clickable-row').filter({ hasText: '유비' }).click();
await expect(page.getByRole('button', { name: '입찰', exact: true })).toBeDisabled();
});