import { randomUUID } from 'node:crypto'; import { expect, test, type Page, type Route } from '@playwright/test'; import { createGamePostgresConnector, createRedisConnector, resolvePostgresConfigFromEnv, resolveRedisConfigFromEnv, type GamePrismaClient, type RedisConnector, } from '../../packages/infra/src/index.js'; const RECORD_MARKER = `main-records-live-${randomUUID()}`; const accessToken = `ga_${randomUUID()}`; let postgres: ReturnType; let redis: RedisConnector; let prisma: GamePrismaClient; let createdIds: number[] = []; const accessKey = (token: string) => `sammo:game:access:che:default:${token}`; const trpcResponse = (data: unknown) => ({ result: { data } }); const trpcError = (path: string, message: string) => ({ error: { message, code: -32000, data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path }, }, }); const operationNames = (route: Route): string[] => { const pathname = decodeURIComponent(new URL(route.request().url()).pathname); return pathname.slice(pathname.lastIndexOf('/trpc/') + 6).split(','); }; const inspectRecordLayout = async (page: Page) => page.evaluate(() => { const inspect = (selector: string) => { const element = document.querySelector(selector); if (!(element instanceof HTMLElement)) { throw new Error(`missing ${selector}`); } const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, }, padding: style.padding, color: style.color, backgroundImage: style.backgroundImage, borderTop: style.borderTop, borderBottom: style.borderBottom, fontFamily: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, lineHeight: style.lineHeight, textAlign: style.textAlign, }; }; const buckets = [...document.querySelectorAll('[data-record-bucket]')]; const mobileZone = document.querySelector('.record-zone-mobile'); const zoneSelector = mobileZone && getComputedStyle(mobileZone).display !== 'none' ? '.record-zone-mobile' : '.record-zone'; return { zone: inspect(zoneSelector), global: inspect('[data-record-bucket="global"]'), general: inspect('[data-record-bucket="general"]'), history: inspect('[data-record-bucket="history"]'), title: inspect('[data-record-bucket="global"]'), firstLine: inspect('[data-record-bucket="global"] .record-line'), panels: buckets.map((bucket) => { const panel = bucket.closest('.record-panel'); const title = panel?.querySelector('.record-title'); if (!panel || !title) throw new Error('record panel structure missing'); return { bucket: bucket.dataset.recordBucket, panel: panel.getBoundingClientRect().toJSON(), title: title.getBoundingClientRect().toJSON(), lineCount: bucket.querySelectorAll('.record-line').length, titleStyle: { fontSize: getComputedStyle(title).fontSize, fontWeight: getComputedStyle(title).fontWeight, lineHeight: getComputedStyle(title).lineHeight, textAlign: getComputedStyle(title).textAlign, borderTop: getComputedStyle(title).borderTop, borderBottom: getComputedStyle(title).borderBottom, }, }; }), }; }); test.beforeAll(async () => { postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: 'che' })); redis = createRedisConnector(resolveRedisConfigFromEnv()); await postgres.connect(); await redis.connect(); prisma = postgres.prisma; const general = await prisma.general.findFirst({ where: { userId: { not: null } }, orderBy: { id: 'asc' }, select: { id: true, userId: true, name: true }, }); if (!general?.userId) { throw new Error('The live che profile needs an owned general.'); } const world = await prisma.worldState.findFirst({ orderBy: { id: 'asc' }, select: { currentYear: true, currentMonth: true }, }); if (!world) { throw new Error('The live che profile needs a world state.'); } const drafts = [ ...Array.from({ length: 15 }, (_, index) => ({ scope: 'SYSTEM' as const, category: 'SUMMARY' as const, text: `●${RECORD_MARKER} 장수 동향 ${15 - index}`, })), ...Array.from({ length: 7 }, (_, index) => ({ scope: 'GENERAL' as const, category: 'ACTION' as const, generalId: general.id, text: `●${RECORD_MARKER} 개인 기록 ${7 - index}`, })), ...Array.from({ length: 15 }, (_, index) => ({ scope: 'SYSTEM' as const, category: 'HISTORY' as const, text: `●${RECORD_MARKER} 중원 정세 ${15 - index}`, })), ]; const rows = await Promise.all( drafts.map((draft) => prisma.logEntry.create({ data: { ...draft, year: world.currentYear, month: world.currentMonth, meta: { fixture: RECORD_MARKER }, }, select: { id: true }, }) ) ); createdIds = rows.map((row: { id: number }) => row.id); const issuedAt = new Date(); const expiresAt = new Date(issuedAt.getTime() + 30 * 60 * 1000); await redis.client.set( accessKey(accessToken), JSON.stringify({ version: 1, profile: 'che:default', issuedAt: issuedAt.toISOString(), expiresAt: expiresAt.toISOString(), sessionId: `main-records-${randomUUID()}`, user: { id: general.userId, username: 'main-records-live', displayName: general.name, roles: [], }, sanctions: {}, }), { EX: 1800 } ); }); test.afterAll(async () => { if (createdIds.length > 0) { await prisma.logEntry.deleteMany({ where: { id: { in: createdIds } } }); } await redis.client.del(accessKey(accessToken)); await redis.disconnect(); await postgres.disconnect(); }); test('renders all three real database record buckets with legacy computed geometry', async ({ page }) => { const liveResponse = await page.request.get( `http://127.0.0.1:15113/che/api/trpc/general.getRecentRecords?input=${encodeURIComponent( JSON.stringify({ json: { lastGeneralRecordId: 0, lastWorldHistoryId: 0, }, }) )}`, { headers: { authorization: `Bearer ${accessToken}` } } ); expect(liveResponse.ok()).toBe(true); const livePayload = (await liveResponse.json()) as { result: { data: { global: unknown[]; general: unknown[]; history: unknown[] } }; }; const liveRecords = livePayload.result.data; expect([liveRecords.global.length, liveRecords.general.length, liveRecords.history.length]).toEqual([15, 7, 15]); let failRecords = false; await page.addInitScript( ({ token }) => { window.localStorage.setItem('sammo-game-token', token); window.localStorage.setItem('sammo-game-profile', 'che:default'); }, { token: accessToken } ); await page.route('**/che/api/trpc/**', async (route) => { const results = operationNames(route).map((operation) => { if (operation === 'general.me') { return trpcResponse({ general: { id: 1, name: '관리자', npcState: 0, nationId: 0, cityId: 1, troopId: 0, picture: null, imageServer: 0, officerLevel: 0, stats: { leadership: 55, strength: 55, intelligence: 55 }, gold: 1000, rice: 1000, crew: 0, train: 0, atmos: 0, injury: 0, experience: 0, dedication: 0, items: { horse: null, weapon: null, book: null, item: null }, }, city: null, nation: null, settings: {}, penalties: {}, }); } if (operation === 'general.getRecentRecords') { return failRecords ? trpcError(operation, '동향 정보를 불러오지 못했습니다.') : trpcResponse(liveRecords); } if (operation === 'auth.status') return trpcResponse({ userId: 'frontend-parity-user' }); if (operation === 'lobby.info') { return trpcResponse({ year: 190, month: 1, userCnt: 1, npcCnt: 0, nationCnt: 0, maxUserCnt: 50, turnTerm: 60, fictionMode: '가상', otherTextInfo: '', starttime: '0190-01-01T00:00:00.000Z', myGeneral: { id: 1, name: '관리자', nationId: 0 }, }); } if (operation === 'world.getMapLayout') { return trpcResponse({ mapName: 'che', cityList: [{ id: 1, name: '낙양', level: 7, region: 1, x: 350, y: 245, path: [] }], regionMap: { 1: '사예' }, levelMap: { 7: '수도' }, }); } if (operation === 'world.getMap') { return trpcResponse({ year: 190, month: 1, startYear: 190, cityList: [[1, 7, 0, 0, 1, 1]], nationList: [], spyList: {}, shownByGeneralList: [], myCity: 1, myNation: 0, }); } if (operation === 'turns.getCommandTable') return trpcResponse({ general: [], nation: [] }); if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { return trpcResponse([]); } if (operation === 'messages.getRecent') { return trpcResponse({ private: [], public: [], national: [], diplomacy: [], permission: 0, latestRead: { private: 0, diplomacy: 0 }, canRespondDiplomacy: false, }); } if (operation === 'messages.getContacts') return trpcResponse([]); if (operation === 'board.getAccess') { return trpcResponse({ canMeeting: false, canSecret: false, permission: 0 }); } if (operation === 'tournament.getState') return trpcResponse({ stage: 0 }); if (operation === 'public.recordAccess') return trpcResponse({ recorded: true }); throw new Error(`Unhandled live main operation: ${operation}`); }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results), }); }); await page.route('**/image/**', async (route) => { const source = new URL(route.request().url()); const response = await route.fetch({ url: `https://dev-sam-ref.hided.net${source.pathname}`, }); await route.fulfill({ response }); }); await page.setViewportSize({ width: 1000, height: 900 }); await page.goto('./', { waitUntil: 'networkidle' }); await expect(page.locator('[data-record-bucket="global"] .record-line')).toHaveCount(15); await expect(page.locator('[data-record-bucket="general"] .record-line')).toHaveCount(7); await expect(page.locator('[data-record-bucket="history"] .record-line')).toHaveCount(15); await expect(page.locator('[data-record-bucket="global"]')).toContainText(RECORD_MARKER); const desktop = await inspectRecordLayout(page); expect(desktop.panels.map((panel) => panel.lineCount)).toEqual([15, 7, 15]); expect(desktop.panels[0]?.panel.width).toBeCloseTo(500, 0); expect(desktop.panels[1]?.panel.x).toBeCloseTo(500, 0); expect(desktop.panels[2]?.panel.width).toBeCloseTo(1000, 0); expect(desktop.panels[0]?.panel.height).toBeCloseTo(338, 0); expect(desktop.panels[2]?.panel.height).toBeCloseTo(338, 0); expect(desktop.panels[0]?.title.height).toBeCloseTo(23, 0); expect(desktop.panels[0]?.titleStyle).toMatchObject({ fontSize: '14px', fontWeight: '400', lineHeight: '21px', textAlign: 'center', borderTop: '1px solid rgb(128, 128, 128)', borderBottom: '1px solid rgb(128, 128, 128)', }); await page.setViewportSize({ width: 500, height: 900 }); await page.getByRole('button', { name: '동향', exact: true }).click(); await expect(page.locator('.record-zone-mobile')).toBeVisible(); const mobile = await inspectRecordLayout(page); expect(mobile.panels.map((panel) => panel.lineCount)).toEqual([15, 7, 15]); expect(mobile.panels.every((panel) => Math.round(panel.panel.width) === 500)).toBe(true); expect(mobile.panels[0]?.panel.height).toBeCloseTo(338, 0); expect(mobile.panels[1]?.panel.height).toBeCloseTo(170, 0); expect(mobile.panels[2]?.panel.height).toBeCloseTo(338, 0); failRecords = true; await page.getByRole('button', { name: '새로고침', exact: true }).click(); await expect(page.getByRole('alert').first()).toContainText('동향 정보를 불러오지 못했습니다.'); await expect(page.getByRole('heading', { name: '장수 동향', exact: true })).toBeVisible(); });