import { expect, test, type Page, type Route } from '@playwright/test'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; type Role = 'leader' | 'head' | 'member'; type FixtureState = { role: Role; failNextRate?: boolean; failPersonnelLoad?: boolean; rate: number; appointedGeneralId?: number; noticeMutationInput?: string; scoutMutationInput?: string; }; type TrpcRequestPayload = { json?: Record; input?: { json?: Record }; }; const purifiedNoticeResponse = '

서버 정화 방침

default.jpg'; const purifiedScoutResponse = '서버 정화 임관문위험 링크'; const artifactRoot = process.env.OFFICE_PARITY_ARTIFACT_DIR ? resolve(process.env.OFFICE_PARITY_ARTIFACT_DIR) : null; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const imageRoots = [ ...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT)] : []), resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image'), ]; const referenceAsset = async (relativePath: string): Promise => { for (const root of imageRoots) { try { return await readFile(resolve(root, relativePath)); } catch { // Nested worktrees and the primary checkout have different image parents. } } throw new Error(`Reference image not found: ${relativePath}`); }; const response = (data: unknown) => ({ result: { data } }); const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({ error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 400, path } }, }); const operationName = (route: Route): string => { const url = new URL(route.request().url()); return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)); }; const responseHasOperation = (url: string, operation: string): boolean => { const pathname = decodeURIComponent(new URL(url).pathname); return pathname .slice(pathname.lastIndexOf('/trpc/') + 6) .split(',') .includes(operation); }; const fulfillJson = (route: Route, body: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); const general = (id: number, name: string, officerLevel: number, overrides: Record = {}) => ({ id, name, npcState: 0, officerLevel, cityId: 1, cityName: '허창', troopId: 0, troopName: null, picture: 'default.jpg', imageServer: 0, officerCity: officerLevel >= 2 && officerLevel <= 4 ? 1 : 0, officerCityName: officerLevel >= 2 && officerLevel <= 4 ? '허창' : null, stats: { leadership: 70, strength: 70, intelligence: 70 }, experience: 100, dedication: 200, injury: 0, gold: 1000, rice: 1000, crew: 100, personality: null, specialDomestic: null, specialWar: null, belong: 10, permission: 'normal', ...overrides, }); const personnelFixture = (state: FixtureState) => { const officerLevel = state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1; const fullGenerals = [ general(1, '조조', 12), general(2, '순욱', 11), general(3, '하후돈', 4), general(4, '곽가', 3), general(5, '정욱', 2), general(6, '장료', 1), general(7, '허저', 1, { permission: 'ambassador' }), ]; const visibleGenerals = state.role === 'member' ? fullGenerals.filter((entry) => entry.officerLevel >= 2) : fullGenerals; return { me: { id: state.role === 'leader' ? 1 : state.role === 'head' ? 2 : 6, officerLevel, canManage: state.role !== 'member', canChangePermissions: state.role === 'leader', canKick: state.role !== 'member', }, nation: { id: 1, name: '작위검증국', color: '#777777', level: 3, typeCode: 'che_법가', capitalCityId: 1, chiefSet: 0, }, chiefStatMin: 65, generals: visibleGenerals, chiefAssignments: { 12: fullGenerals[0], 11: fullGenerals[1] }, cityAssignments: [ { id: 1, name: '허창', level: 7, region: 2, officerSet: 1 << 3, officers: { 4: fullGenerals[2], 3: fullGenerals[3], 2: fullGenerals[4] }, }, { id: 2, name: '낙양', level: 6, region: 2, officerSet: 0, officers: { 4: null, 3: null, 2: null } }, ], awards: { tigers: [{ id: 3, name: '하후돈', value: 12 }], eagles: [{ id: 4, name: '곽가', value: 9 }], }, permissionCandidates: state.role === 'leader' ? { ambassadors: [ { id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }, { id: 7, name: '허저', npcState: 0, permission: 'ambassador', maxPermission: 4 }, ], auditors: [{ id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }], } : { ambassadors: [], auditors: [] }, }; }; const financeFixture = (state: FixtureState) => ({ editable: state.role === 'leader' || state.role === 'head', nationMsg: '

백성을 편안하게 한다.

', scoutMsg: '

천하의 인재를 구합니다.

', nationId: 1, officerLevel: state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1, year: 185, month: 1, nationsList: [ { id: 1, name: '작위검증국', color: '#777777', level: 3, power: 1234, generalCount: 7, cityCount: 2, diplomacy: { state: 7, term: null }, }, { id: 2, name: '촉', color: '#008000', level: 3, power: 987, generalCount: 5, cityCount: 1, diplomacy: { state: 2, term: 6 }, }, ], gold: 10000, rice: 20000, income: { gold: { city: 2000, war: 300 }, rice: { city: 1800, wall: 200 } }, outcome: 1000, policy: { rate: state.rate, bill: 100, secretLimit: 3, blockScout: false, blockWar: false }, warSettingCnt: { remain: 5, inc: 2, max: 10 }, }); const installFixture = async (page: Page, state: FixtureState) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_office_playwright'); localStorage.setItem('sammo-game-profile', profile); }, gameProfile); for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { await page.route(`**/image/game/${filename}`, async (route) => route.fulfill({ status: 200, contentType: 'image/jpeg', body: await referenceAsset(`game/${filename}`), }) ); } await page.route('**/image/icons/**', async (route) => route.fulfill({ status: 200, contentType: 'image/jpeg', body: await referenceAsset('icons/default.jpg'), }) ); await page.route(gameTrpcRoute, async (route) => { const operations = operationName(route).split(','); const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; const requestBody = rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record) : {}; const results = operations.map((operation, operationIndex) => { const rawPayload = requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined); const payload = rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined; const jsonInput = payload?.json ?? payload?.input?.json ?? (payload as Record | undefined) ?? {}; if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조조' } }); if (operation === 'join.getConfig') return response({}); if (operation === 'nation.getPersonnelInfo') { return state.failPersonnelLoad ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') : response(personnelFixture(state)); } if (operation === 'nation.getStratFinan') return response(financeFixture(state)); if (operation === 'nation.appoint') { state.appointedGeneralId = 6; return response({ ok: true }); } if (operation === 'nation.kick' || operation === 'nation.changePermission') return response({ ok: true }); if (operation === 'nation.setRate') { if (state.failNextRate) { state.failNextRate = false; return errorResponse(operation, '세율을 변경할 수 없습니다.'); } state.rate = 25; return response({ ok: true }); } if (operation === 'nation.setNotice') { state.noticeMutationInput = typeof jsonInput.msg === 'string' ? jsonInput.msg : undefined; return response({ ok: true, msg: purifiedNoticeResponse }); } if (operation === 'nation.setScoutMsg') { state.scoutMutationInput = typeof jsonInput.msg === 'string' ? jsonInput.msg : undefined; return response({ ok: true, msg: purifiedScoutResponse }); } if (['nation.setBill', 'nation.setSecretLimit', 'nation.setBlockScout'].includes(operation)) return response({ ok: true }); if (operation === 'nation.setBlockWar') return response({ availableCnt: 4 }); return errorResponse(operation, `Unhandled fixture operation: ${operation}`); }); await fulfillJson(route, results); }); }; const gotoOffice = async (page: Page, path: 'nation/personnel' | 'nation/finance') => { const lobbyResponse = page.waitForResponse((response) => responseHasOperation(response.url(), 'lobby.info')); await page.goto(path); await lobbyResponse; }; const screenshot = async (page: Page, name: string) => { if (!artifactRoot) return; await mkdir(artifactRoot, { recursive: true }); await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true }); }; test('personnel matches the 1000px legacy table geometry, textures, image, and interaction states', async ({ page, }) => { await installFixture(page, { role: 'leader', rate: 20 }); await page.setViewportSize({ width: 1000, height: 900 }); await gotoOffice(page, 'nation/personnel'); await expect(page.getByText('작위검증국')).toBeVisible(); const computed = await page.locator('#personnel-container').evaluate((container) => { const box = (selector?: string) => { const element = selector ? container.querySelector(selector)! : container; const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { width: rect.width, height: rect.height, fontFamily: style.fontFamily, fontSize: style.fontSize, lineHeight: style.lineHeight, backgroundImage: style.backgroundImage, color: style.color, }; }; return { container: box(), heading: box('.heading-table'), status: box('.chief-status'), icon: box('.general-icon'), documentWidth: document.documentElement.scrollWidth, }; }); expect(computed.container.width).toBe(1000); expect(computed.heading.width).toBe(1000); expect(computed.heading.height).toBeCloseTo(56, 0); expect(computed.status.width).toBe(1000); expect(computed.icon.width).toBeCloseTo(64.7, 0); expect(computed.icon.height).toBeCloseTo(64, 0); expect(computed.container.fontFamily).toContain('Pretendard'); expect(computed.container.fontSize).toBe('14px'); expect(computed.container.lineHeight).toBe('18.2px'); expect(computed.status.backgroundImage).toContain('back_walnut.jpg'); expect(computed.documentWidth).toBe(1000); const appointButton = page.getByRole('button', { name: '임명' }).first(); expect(await appointButton.evaluate((button) => getComputedStyle(button).backgroundColor)).toBe( 'rgb(108, 117, 125)' ); await appointButton.hover(); expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); await appointButton.focus(); expect(await appointButton.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none'); await screenshot(page, 'core-personnel-desktop-leader.png'); }); test('personnel preserves the legacy fixed 1000px document on a 500px viewport', async ({ page }) => { await installFixture(page, { role: 'head', rate: 20 }); await page.setViewportSize({ width: 500, height: 900 }); await gotoOffice(page, 'nation/personnel'); await expect(page.getByText('작위검증국')).toBeVisible(); expect( await page.locator('#personnel-container').evaluate((element) => element.getBoundingClientRect().width) ).toBe(1000); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000); await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible(); await screenshot(page, 'core-personnel-mobile-head.png'); }); test('personnel hides every mutation control for an ordinary member and exposes load errors', async ({ page }) => { await installFixture(page, { role: 'member', rate: 20 }); await gotoOffice(page, 'nation/personnel'); await expect(page.getByText('도 시 관 직 임 명')).toHaveCount(0); await expect(page.getByText('외 교 권 자 임 명')).toHaveCount(0); await expect(page.getByText('추 방', { exact: true })).toHaveCount(0); await expect(page.getByText(/곽가\(10년\).*허창/)).toBeVisible(); const failed = await page.context().newPage(); await installFixture(failed, { role: 'member', rate: 20, failPersonnelLoad: true }); await gotoOffice(failed, 'nation/personnel'); await expect(failed.getByRole('alert')).toContainText('권한이 부족합니다.'); }); test('finance matches the reference 1000px and 500px computed DOM contracts', async ({ page }) => { await installFixture(page, { role: 'head', rate: 20 }); await page.setViewportSize({ width: 1000, height: 900 }); await gotoOffice(page, 'nation/finance'); await expect(page.getByText('외교관계')).toBeVisible(); const desktop = await page.locator('#finance-container').evaluate((container) => { const geometry = (selector?: string) => { const element = selector ? container.querySelector(selector)! : container; const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { width: rect.width, height: rect.height, gridTemplateColumns: style.gridTemplateColumns, fontFamily: style.fontFamily, fontSize: style.fontSize, lineHeight: style.lineHeight, backgroundColor: style.backgroundColor, backgroundImage: style.backgroundImage, }; }; return { container: geometry(), top: geometry('.top-back-bar'), title: geometry('.diplomacy-title'), row: geometry('.diplomacy-row'), notice: geometry('.notice-title'), finance: geometry('.finance-title'), input: geometry('input[type="number"]'), }; }); expect(desktop.container.width).toBe(1000); expect(desktop.container.fontFamily).toContain('Pretendard'); expect(desktop.container.fontSize).toBe('14px'); expect(desktop.container.backgroundImage).toContain('back_walnut.jpg'); expect(desktop.top.height).toBe(32); expect(desktop.top.gridTemplateColumns).toBe('90px 90px 640px 90px 90px'); expect(desktop.title.height).toBeCloseTo(25.47, 1); expect(desktop.title.backgroundColor).toBe('rgb(55, 90, 127)'); expect(desktop.row.gridTemplateColumns).toBe( '260.859px 130.438px 86.9531px 86.9531px 173.922px 86.9531px 173.922px' ); expect(desktop.notice.backgroundColor).toBe('rgb(255, 255, 255)'); expect(desktop.finance.backgroundColor).toBe('rgb(0, 188, 140)'); expect(desktop.input.width).toBeCloseTo(58.66, 1); expect(desktop.input.height).toBe(30); expect( await page .locator('.policy-submit') .first() .evaluate((element) => getComputedStyle(element).backgroundColor) ).toBe('rgb(55, 90, 127)'); expect( await page .locator('.policy-cancel') .first() .evaluate((element) => getComputedStyle(element).backgroundColor) ).toBe('rgb(108, 117, 125)'); expect( await page .getByRole('checkbox', { name: '전쟁 금지' }) .evaluate((element) => getComputedStyle(element).borderRadius) ).toBe('16px'); await screenshot(page, 'core-finance-desktop-head.png'); await page.setViewportSize({ width: 500, height: 900 }); await page.reload(); await expect(page.getByText('외교관계')).toBeVisible(); expect(await page.locator('#finance-container').evaluate((element) => element.getBoundingClientRect().width)).toBe( 500 ); expect( await page.locator('.top-back-bar').evaluate((element) => getComputedStyle(element).gridTemplateColumns) ).toBe('90px 90px 140px 90px 90px'); expect( await page .locator('.diplomacy-row') .first() .evaluate((element) => getComputedStyle(element).gridTemplateColumns) ).toBe('130.422px 65.2188px 43.4844px 43.4688px 86.9688px 43.4688px 86.9688px'); await screenshot(page, 'core-finance-mobile-head.png'); }); test('finance enforces edit permissions and preserves the old value across an API failure', async ({ page }) => { const state: FixtureState = { role: 'head', rate: 20, failNextRate: true }; await installFixture(page, state); await gotoOffice(page, 'nation/finance'); const input = page.getByRole('spinbutton', { name: '세율' }); await input.fill('25'); await page.locator('.policy-cell').filter({ hasText: '세율' }).getByRole('button', { name: '변경' }).click(); await expect(page.getByRole('alert')).toContainText('세율을 변경할 수 없습니다.'); await expect(input).toHaveValue('20'); await input.fill('25'); await page.locator('.policy-cell').filter({ hasText: '세율' }).getByRole('button', { name: '변경' }).click(); await expect(page.getByRole('status')).toContainText('세율을 변경했습니다.'); expect(state.rate).toBe(25); const readOnly = await page.context().newPage(); await installFixture(readOnly, { role: 'member', rate: 20 }); await gotoOffice(readOnly, 'nation/finance'); await expect(readOnly.getByRole('button', { name: '국가방침 수정' })).toHaveCount(0); await expect(readOnly.locator('.policy-cell').getByRole('button', { name: '변경' })).toHaveCount(0); await expect(readOnly.getByRole('checkbox', { name: '전쟁 금지' })).toBeDisabled(); }); test('finance adopts the server-purified notice and scout message before rendering the saved preview', async ({ page, }) => { const state: FixtureState = { role: 'head', rate: 20 }; await installFixture(page, state); await gotoOffice(page, 'nation/finance'); const dirtyNotice = '

원문

'; await page.getByRole('button', { name: '국가방침 수정' }).click(); await page.getByRole('textbox', { name: '국가 방침' }).fill(dirtyNotice); await page.locator('#notice-form').getByRole('button', { name: '저장' }).click(); const noticePreview = page.locator('#notice-form .message-preview'); await expect(noticePreview).toContainText('서버 정화 방침'); expect(state.noticeMutationInput).toBe(dirtyNotice); await expect(noticePreview.locator('[data-flip="horizontal"]')).toHaveCSS('color', 'rgb(0, 255, 255)'); await expect(noticePreview.locator('script, svg, [onerror], [onload], [onclick]')).toHaveCount(0); await expect .poll(() => page.evaluate(() => (globalThis as typeof globalThis & { __nationNoticeXss?: number }).__nationNoticeXss) ) .toBeUndefined(); const dirtyScout = '원문'; await page.getByRole('button', { name: '임관 권유문 수정' }).click(); await page.getByRole('textbox', { name: '임관 권유' }).fill(dirtyScout); await page.locator('#scout-message-form').getByRole('button', { name: '저장' }).click(); const scoutPreview = page.locator('#scout-message-form .message-preview'); await expect(scoutPreview).toContainText('서버 정화 임관문'); expect(state.scoutMutationInput).toBe(dirtyScout); await expect(scoutPreview.locator('a', { hasText: '위험 링크' })).not.toHaveAttribute('href'); await expect(scoutPreview.locator('script, svg, [onerror], [onload], [onclick]')).toHaveCount(0); await expect .poll(() => page.evaluate(() => (globalThis as typeof globalThis & { __nationScoutXss?: number }).__nationScoutXss) ) .toBeUndefined(); });