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 => { 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: (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 => { 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) | 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('.select-pool-page')!; const cards = Array.from( document.querySelectorAll('.card-holder > .general-card') ); const images = Array.from( document.querySelectorAll('.card-holder .portrait img') ); const selectionBody = document.querySelector('.selection-body')!; const createSection = document.querySelector('.create-section')!; const createBody = document.querySelector('.create-body')!; const pageTitle = document.querySelector('.page-title')!; const serverInfoTable = document.querySelector('.server-info-table')!; const invitationTable = document.querySelector('.invitation-table')!; const footerBack = document.querySelector('.footer-back')!; const footerBanner = document.querySelector('.footer-banner')!; const firstButton = document.querySelector( '.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-general-icon-fallback-source]' ); 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-general-icon-fallback-source]') }) .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-general-icon-fallback-source]') ).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(page.getByTestId('game-toast')).toContainText( '장수 생성에 실패했습니다: 장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.' ); 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(); const createDialog = page.getByRole('alertdialog', { name: '완료' }); await expect(createDialog).toContainText('선택한 장수로 생성했습니다.'); await createDialog.getByRole('button', { name: '확인' }).click(); await expect(page).toHaveURL(/\/hwe\/$/); expect(dialogs.filter((message) => message === '이 장수로 생성할까요?')).toHaveLength(2); 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(page.getByTestId('game-toast')).toContainText( '장수 선택 정보를 불러오지 못했습니다: 아직 다시 고를 수 없습니다' ); 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(); const reselectDialog = page.getByRole('alertdialog', { name: '완료' }); await expect(reselectDialog).toContainText('선택한 장수로 변경했습니다.'); await reselectDialog.getByRole('button', { name: '확인' }).click(); await expect(page).toHaveURL(/\/hwe\/$/); await expect.poll(() => dialogs).toContain(`이 장수를 선택할까요? : ${targetName}`); 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(); }); });