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

520 lines
23 KiB
TypeScript

import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { expect, test, type Page } from '@playwright/test';
import { encryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
import {
createGamePostgresConnector,
type GamePrisma,
type GamePrismaClient,
} from '@sammo-ts/infra';
const gameTokenSecret = process.env.SELECT_POOL_LIVE_GAME_SECRET;
const databaseUrl = process.env.SELECT_POOL_LIVE_DATABASE_URL;
const userId = process.env.SELECT_POOL_LIVE_USER_ID;
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:903';
const hasLiveFixture = Boolean(gameTokenSecret && databaseUrl && userId);
const workspaceRoot =
process.env.SAMMO_WORKSPACE_ROOT ??
path.resolve(import.meta.dirname, '../../../../../sam_rebuild');
const defaultIcon = path.resolve(
process.env.SELECT_POOL_LIVE_DEFAULT_ICON ??
path.join(workspaceRoot, 'image/icons/default.jpg')
);
const walnutTexture = path.join(workspaceRoot, 'image/game/back_walnut.jpg');
const greenTexture = path.join(workspaceRoot, 'image/game/back_green.jpg');
const fixtureNationIds = [990_901, 990_902, 990_903];
interface AssetTracker {
userIconRequests: number;
}
const installSession = async (page: Page, tracker?: AssetTracker): Promise<void> => {
const now = new Date();
const gameToken = encryptGameSessionToken(
{
version: 1,
profile,
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
sessionId: `select-pool-live-${randomUUID()}`,
user: {
id: userId!,
username: 'select-pool-live',
displayName: '선택실사용자',
roles: ['user'],
legacyMemberNo: 42,
},
sanctions: {},
identity: {
kakaoVerified: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
graceEndsAt: null,
},
},
gameTokenSecret!
);
await page.addInitScript(
({ token, gameProfile }) => {
if (!window.localStorage.getItem('sammo-game-token')) {
window.localStorage.setItem('sammo-game-token', token);
}
window.localStorage.setItem('sammo-game-profile', gameProfile);
},
{ token: gameToken, gameProfile: profile }
);
await page.addInitScript(() => {
const values = [1, 0];
Object.defineProperty(window.crypto, 'getRandomValues', {
configurable: true,
value: <T extends ArrayBufferView | null>(array: T): T => {
if (array && (array as Uint32Array).length > 0) {
(array as Uint32Array)[0] = values.shift() ?? 0;
}
return array;
},
});
});
await page.route('**/image/icons/**', (route) =>
route.fulfill({ path: defaultIcon, contentType: 'image/jpeg' })
);
await page.route('**/gateway/api/user-icons/**', (route) => {
if (tracker) {
tracker.userIconRequests += 1;
}
return route.fulfill({ status: 404, body: '' });
});
await page.route('**/image/game/back_walnut.jpg', (route) =>
route.fulfill({ path: walnutTexture, contentType: 'image/jpeg' })
);
await page.route('**/image/game/back_green.jpg', (route) =>
route.fulfill({ path: greenTexture, contentType: 'image/jpeg' })
);
};
const waitForPool = async (page: Page): Promise<void> => {
await expect(page.locator('.card-holder > .general-card')).toHaveCount(14);
await page.evaluate(async () => {
await document.fonts.ready;
});
};
test.describe('scenario 903 live selection pool', () => {
test.skip(!hasLiveFixture, 'live selection-pool token and database are required');
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
test.beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
});
test.afterAll(async () => {
await closeDb?.();
});
test('renders Ref-width desktop/mobile cards, tooltip, focus, and expiration states', async ({
page,
}, testInfo) => {
await page.clock.install({ time: new Date() });
const assetTracker: AssetTracker = { userIconRequests: 0 };
await installSession(page, assetTracker);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('select-general');
await waitForPool(page);
await expect(page.locator('.server-info-table')).toContainText(
'현재 : 180年 1月 (5분 턴 서버)'
);
await expect(page.locator('.server-info-table')).toContainText(
'등록 장수 : 유저 0 / 500 명'
);
await expect(page.locator('.invitation-table')).toContainText('임관 권유 메시지');
const geometry = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>('.select-pool-page')!;
const cards = Array.from(
document.querySelectorAll<HTMLElement>('.card-holder > .general-card')
);
const images = Array.from(
document.querySelectorAll<HTMLImageElement>('.card-holder .portrait img')
);
const selectionBody = document.querySelector<HTMLElement>('.selection-body')!;
const createSection = document.querySelector<HTMLElement>('.create-section')!;
const createBody = document.querySelector<HTMLElement>('.create-body')!;
const pageTitle = document.querySelector<HTMLElement>('.page-title')!;
const serverInfoTable =
document.querySelector<HTMLElement>('.server-info-table')!;
const invitationTable =
document.querySelector<HTMLElement>('.invitation-table')!;
const footerBack = document.querySelector<HTMLElement>('.footer-back')!;
const footerBanner = document.querySelector<HTMLElement>('.footer-banner')!;
const firstButton = document.querySelector<HTMLElement>(
'.card-holder .select-button'
)!;
return {
root: root.getBoundingClientRect().toJSON(),
pageTitle: pageTitle.getBoundingClientRect().toJSON(),
serverInfoTable: serverInfoTable.getBoundingClientRect().toJSON(),
invitationTable: invitationTable.getBoundingClientRect().toJSON(),
cards: cards.map((card) => card.getBoundingClientRect().toJSON()),
images: images.map((image) => ({
rect: image.getBoundingClientRect().toJSON(),
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
objectFit: getComputedStyle(image).objectFit,
})),
selectionBody: selectionBody.getBoundingClientRect().toJSON(),
createSection: createSection.getBoundingClientRect().toJSON(),
createBody: createBody.getBoundingClientRect().toJSON(),
footerBack: footerBack.getBoundingClientRect().toJSON(),
footerBanner: footerBanner.getBoundingClientRect().toJSON(),
firstButton: firstButton.getBoundingClientRect().toJSON(),
rootStyle: {
fontFamily: getComputedStyle(root).fontFamily,
fontSize: getComputedStyle(root).fontSize,
lineHeight: getComputedStyle(root).lineHeight,
backgroundImage: getComputedStyle(root).backgroundImage,
},
scrollWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.root).toMatchObject({ x: 100, y: 8, width: 1000 });
expect(geometry.pageTitle).toMatchObject({ x: 100, y: 8, width: 1000 });
expect(Math.abs(geometry.pageTitle.height - 42.1875)).toBeLessThan(0.6);
expect(Math.abs(geometry.serverInfoTable.y - 50.1875)).toBeLessThan(0.6);
expect(Math.abs(geometry.serverInfoTable.height - 40.375)).toBeLessThan(0.6);
expect(Math.abs(geometry.invitationTable.x - 553)).toBeLessThan(0.6);
expect(Math.abs(geometry.invitationTable.y - 90.5625)).toBeLessThan(0.6);
expect(geometry.invitationTable.width).toBe(94);
expect(Math.abs(geometry.invitationTable.height - 20.1875)).toBeLessThan(0.1);
expect(geometry.rootStyle).toMatchObject({
fontSize: '14px',
lineHeight: '18.2px',
});
expect(geometry.rootStyle.fontFamily).toContain('Pretendard');
expect(geometry.rootStyle.backgroundImage).toContain('back_walnut.jpg');
expect(geometry.cards.every((card) => card.width === 127)).toBe(true);
expect(new Set(geometry.cards.map((card) => card.y)).size).toBe(2);
const shortestCard = Math.min(...geometry.cards.map((card) => card.height));
expect(Math.abs(shortestCard - 254.875)).toBeLessThan(0.6);
expect(
geometry.images.every(
(image, index) =>
image.rect.width === 64 &&
image.rect.height === 64 &&
Math.abs(
image.rect.x -
(geometry.cards[index]!.x +
(geometry.cards[index]!.width - image.rect.width) / 2)
) < 0.1
)
).toBe(true);
expect(
geometry.images.every(
(image) => image.naturalWidth > 0 && image.naturalHeight > 0
)
).toBe(true);
expect(geometry.images.every((image) => image.objectFit === 'fill')).toBe(true);
const fallbackImages = page.locator(
'.card-holder .portrait img[data-fallback-applied="true"]'
);
await expect(fallbackImages).not.toHaveCount(0);
expect(assetTracker.userIconRequests).toBe(await fallbackImages.count());
expect(Math.abs(geometry.selectionBody.y - 130.9375)).toBeLessThan(0.6);
expect(Math.abs(geometry.createSection.height - 87.375)).toBeLessThan(0.6);
expect(geometry.firstButton.height).toBe(19);
expect(geometry.footerBanner.height).toBeCloseTo(20.1875, 3);
await expect(page.locator('.invitation-table tbody tr')).toHaveCount(0);
await expect(page.locator('.footer-banner')).toContainText(
'삼국지 모의전투 HiDCHe core2026'
);
await expect(page.locator('.footer-banner a')).toHaveText('Credit');
const firstTrait = page.locator('.card-holder .trait-tooltip').first();
await firstTrait.hover();
await expect(firstTrait.getByRole('tooltip')).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('select-general-desktop-hover.png'),
fullPage: true,
});
const firstButton = page.locator('.card-holder .select-button').first();
await firstButton.focus();
await expect(firstButton).toHaveCSS('outline-style', 'auto');
await expect(firstButton).toHaveCSS('outline-width', '1px');
await firstButton.hover();
await expect(firstButton).toHaveCSS('background-color', 'rgb(25, 25, 25)');
await page.setViewportSize({ width: 500, height: 900 });
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeGreaterThanOrEqual(1008);
await page.screenshot({
path: testInfo.outputPath('select-general-mobile.png'),
fullPage: true,
});
const validText = await page.locator('.selection-body small span').textContent();
expect(validText).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
const displayedExpiry = new Date(`${validText!.replace(' ', 'T')}+09:00`).getTime();
const delta = Math.max(displayedExpiry - Date.now(), 0);
await page.clock.fastForward(delta);
await expect(page.locator('.expired-text')).toHaveCount(0);
await page.clock.fastForward(2_000);
await expect(page.locator('.expired-text')).toHaveText('- 만료 -');
});
test('shuffles non-neutral invitation nations with Ref-style row content and colors', async ({
page,
}) => {
await db.nation.deleteMany({ where: { id: { in: fixtureNationIds } } });
await db.nation.createMany({
data: [
{
id: fixtureNationIds[0]!,
name: '테스트국A',
color: '#330000',
level: 1,
meta: { infoText: 'A 권유' },
},
{
id: fixtureNationIds[1]!,
name: '테스트국B',
color: '#FFFF00',
level: 1,
meta: { infoText: 'B 권유' },
},
{
id: fixtureNationIds[2]!,
name: '테스트국C',
color: '#000080',
level: 1,
meta: { infoText: 'C 권유' },
},
],
});
try {
await installSession(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('select-general');
await waitForPool(page);
const rows = page.locator('.invitation-table tbody tr');
await expect(rows.locator('.invitation-nation')).toHaveText([
'테스트국C',
'테스트국A',
'테스트국B',
]);
await expect(rows.locator('.invitation-message')).toHaveText([
'C 권유',
'A 권유',
'B 권유',
]);
await expect(rows.nth(0)).toHaveCSS('background-color', 'rgb(0, 0, 128)');
await expect(rows.nth(1)).toHaveCSS('background-color', 'rgb(51, 0, 0)');
await expect(rows.nth(2)).toHaveCSS('background-color', 'rgb(255, 255, 0)');
await expect(rows.nth(0)).toHaveCSS('color', 'rgb(255, 255, 255)');
await expect(rows.nth(2)).toHaveCSS('color', 'rgb(0, 0, 0)');
const invitationGeometry = await page.locator('.invitation-table').evaluate((table) => {
const rect = table.getBoundingClientRect();
return { x: rect.x, width: rect.width };
});
expect(invitationGeometry).toEqual({ x: 100, width: 1000 });
} finally {
await db.nation.deleteMany({ where: { id: { in: fixtureNationIds } } });
}
});
test('creates, rejects cooldown, exposes MyPage action, and reselects through the live API', async ({
page,
}) => {
const dialogs: string[] = [];
const createClientRequestIds: string[] = [];
let injectCreateTimeout = true;
page.on('dialog', async (dialog) => {
dialogs.push(dialog.message());
await dialog.accept();
});
await page.route('**/trpc/join.selectPoolGeneral?batch=1', async (route) => {
const findClientRequestId = (value: unknown): string | undefined => {
if (!value || typeof value !== 'object') return undefined;
if (
'clientRequestId' in value &&
typeof value.clientRequestId === 'string'
) {
return value.clientRequestId;
}
for (const nested of Object.values(value)) {
const found = findClientRequestId(nested);
if (found) return found;
}
return undefined;
};
const clientRequestId = findClientRequestId(route.request().postDataJSON());
expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/);
createClientRequestIds.push(clientRequestId!);
if (!injectCreateTimeout) {
await route.continue();
return;
}
injectCreateTimeout = false;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message:
'장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
code: -32008,
data: {
code: 'TIMEOUT',
httpStatus: 408,
path: 'join.selectPoolGeneral',
},
},
},
]),
});
});
const assetTracker: AssetTracker = { userIconRequests: 0 };
await installSession(page, assetTracker);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('select-general');
await waitForPool(page);
const candidateCards = page.locator('.card-holder > .general-card');
const fallbackCard = candidateCards
.filter({ has: page.locator('img[data-fallback-applied="true"]') })
.first();
await expect(fallbackCard).toBeVisible();
const initialName = await fallbackCard.locator('h4').first().textContent();
const userIconRequestsBeforePreview = assetTracker.userIconRequests;
await fallbackCard.locator('.select-button').click();
await expect(page.locator('.selected-card')).toHaveCount(1);
await expect(
page.locator('.selected-card img[data-fallback-applied="true"]')
).toBeVisible();
expect(assetTracker.userIconRequests).toBeGreaterThan(userIconRequestsBeforePreview);
await page.locator('.custom-form select').selectOption('che_안전');
await page.getByRole('button', { name: '다시입력' }).click();
await expect(page.locator('.custom-form select')).toHaveValue('Random');
await expect(page.locator('.selected-card')).toHaveCount(1);
await page.locator('.custom-form select').selectOption('che_안전');
await page.locator('#build-general').click();
await expect.poll(() => dialogs).toContain(
'실패했습니다: 장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.'
);
await waitForPool(page);
const retryCard = page
.locator('.card-holder > .general-card')
.filter({ has: page.locator('h4', { hasText: initialName?.trim() ?? '' }) })
.first();
await retryCard.locator('.select-button').click();
await page.locator('.custom-form select').selectOption('che_안전');
await page.locator('#build-general').click();
await expect(page).toHaveURL(/\/hwe\/$/);
expect(dialogs.filter((message) => message === '이 장수로 생성할까요?')).toHaveLength(2);
await expect.poll(() => dialogs).toContain('선택한 장수로 생성했습니다.');
expect(createClientRequestIds).toHaveLength(2);
expect(createClientRequestIds[1]).toBe(createClientRequestIds[0]);
const created = await db.general.findFirstOrThrow({ where: { userId } });
expect(created.name).toBe(initialName?.trim());
expect(created.personalCode).toBe('che_안전');
expect(created.specialCode).toMatch(/^che_event_/);
const createEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolCreate' },
orderBy: { sequence: 'desc' },
});
expect(createEvent).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
expect(createEvent.requestId).toMatch(
new RegExp(`^select-pool:${userId}:[0-9a-f-]{36}:create$`)
);
expect(
await page.evaluate(() =>
window.sessionStorage.getItem('sammo-select-pool-pending-action')
)
).toBeNull();
await page.goto('my-page');
const actionLink = page.locator('.select-general-link');
await expect(actionLink).toBeVisible();
await expect(actionLink.locator('..')).toContainText(
/다른 장수 선택\s*\(\d{4}-\d{2}-\d{2}/
);
await expect(actionLink).toHaveCSS('width', '160px');
await expect(actionLink).toHaveCSS('height', '30px');
dialogs.length = 0;
await page.goto('select-general');
await expect.poll(() => dialogs).toContain('실패했습니다: 아직 다시 고를 수 없습니다');
await expect(page.locator('.error-text')).toHaveText('아직 다시 고를 수 없습니다');
const availableAt = '2026-07-29T00:00:00.000Z';
const cooldownRequestId = `select-pool-live-cooldown-${randomUUID()}`;
await db.inputEvent.create({
data: {
requestId: cooldownRequestId,
target: 'ENGINE',
eventType: 'patchGeneral',
payload: {
type: 'patchGeneral',
requestId: cooldownRequestId,
generalId: created.id,
patch: {
meta: {
next_change: availableAt,
nextChangeAt: availableAt,
},
},
} as GamePrisma.InputJsonValue,
},
});
await expect
.poll(
async () =>
(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: cooldownRequestId },
})
).status
)
.toBe('SUCCEEDED');
dialogs.length = 0;
await page.reload();
await waitForPool(page);
const cards = page.locator('.card-holder > .general-card');
const names = await cards.locator('h4').allTextContents();
const targetIndex = names.findIndex((name) => name.trim() !== created.name);
expect(targetIndex).toBeGreaterThanOrEqual(0);
const targetName = names[targetIndex]!.trim();
await cards.nth(targetIndex).locator('.select-button').click();
await expect(page).toHaveURL(/\/hwe\/$/);
await expect.poll(() => dialogs).toContain(`이 장수를 선택할까요? : ${targetName}`);
await expect.poll(() => dialogs).toContain('선택한 장수로 변경했습니다.');
await expect
.poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name)
.toBe(targetName);
const reselectEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolReselect' },
orderBy: { sequence: 'desc' },
});
expect(reselectEvent).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
expect(reselectEvent.requestId).toMatch(
new RegExp(`^select-pool:${userId}:[0-9a-f-]{36}:reselect$`)
);
expect(
await page.evaluate(() =>
window.sessionStorage.getItem('sammo-select-pool-pending-action')
)
).toBeNull();
});
});