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 FixtureState = { permissionLevel: number; failNextMutation?: boolean; failLoad?: boolean; mutations: string[]; }; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const artifactRoot = process.env.NPC_POLICY_PARITY_ARTIFACT_DIR ? resolve(process.env.NPC_POLICY_PARITY_ARTIFACT_DIR) : null; 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 fulfillJson = (route: Route, body: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); const nationPriority = [ '불가침제의', '선전포고', '천도', '유저장긴급포상', '부대전방발령', '유저장구출발령', '유저장후방발령', '부대유저장후방발령', '유저장전방발령', '유저장포상', '부대구출발령', '부대후방발령', 'NPC긴급포상', 'NPC구출발령', 'NPC후방발령', 'NPC포상', 'NPC전방발령', '유저장내정발령', 'NPC내정발령', 'NPC몰수', ]; const generalPriority = [ 'NPC사망대비', '귀환', '금쌀구매', '출병', '긴급내정', '전투준비', '전방워프', 'NPC헌납', '징병', '후방워프', '전쟁내정', '소집해제', '일반내정', '내정워프', ]; const policy = { reqNationGold: 10_000, reqNationRice: 12_000, CombatForce: {}, SupportForce: [], DevelopForce: [], reqHumanWarUrgentGold: 0, reqHumanWarUrgentRice: 0, reqHumanWarRecommandGold: 0, reqHumanWarRecommandRice: 0, reqHumanDevelGold: 10_000, reqHumanDevelRice: 10_000, reqNPCWarGold: 0, reqNPCWarRice: 0, reqNPCDevelGold: 0, reqNPCDevelRice: 500, minimumResourceActionAmount: 1_000, maximumResourceActionAmount: 10_000, minNPCWarLeadership: 40, minWarCrew: 1_500, minNPCRecruitCityPopulation: 50_000, safeRecruitCityPopulationRatio: 0.5, properWarTrainAtmos: 90, cureThreshold: 10, }; const policyFixture = (state: FixtureState) => ({ nationId: 1, nationName: '위', nationLevel: 3, defaultNationPolicy: policy, currentNationPolicy: policy, zeroPolicy: { ...policy, reqHumanWarUrgentGold: 7_600, reqHumanWarUrgentRice: 7_600, reqHumanWarRecommandGold: 15_200, reqHumanWarRecommandRice: 15_200, reqNPCWarGold: 2_700, reqNPCWarRice: 2_700, reqNPCDevelGold: 540, }, defaultNationPriority: nationPriority, currentNationPriority: nationPriority, availableNationPriorityItems: nationPriority, defaultGeneralActionPriority: generalPriority, currentGeneralActionPriority: generalPriority, availableGeneralActionPriorityItems: generalPriority, lastSetters: { policy: { setter: null, date: null }, nation: { setter: null, date: null }, general: { setter: null, date: null }, }, defaultStatMax: 70, defaultStatNpcMax: 75, permissionLevel: state.permissionLevel, }); const installFixture = async (page: Page, state: FixtureState) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_npc_policy_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(gameTrpcRoute, async (route) => { const operations = operationName(route).split(','); const results = operations.map((operation) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: { id: 22, name: '정책담당' } }); if (operation === 'join.getConfig') return response({}); if (operation === 'npc.getPolicy') { return state.failLoad ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') : response(policyFixture(state)); } if ( operation === 'npc.setNationPolicy' || operation === 'npc.setNationPriority' || operation === 'npc.setGeneralPriority' ) { state.mutations.push(operation); if (state.failNextMutation) { state.failNextMutation = false; return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN'); } return response({ ok: true }); } return errorResponse(operation, `Unhandled fixture operation: ${operation}`); }); await fulfillJson(route, results); }); }; const gotoPolicy = async (page: Page) => { await page.goto('npc-control'); }; 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('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({ page, }) => { const state: FixtureState = { permissionLevel: 4, mutations: [] }; await installFixture(page, state); await page.setViewportSize({ width: 1000, height: 900 }); await gotoPolicy(page); await expect(page.locator('#container')).toBeVisible(); const computed = await page.evaluate(() => { const measure = (selector: string) => { const element = document.querySelector(selector)!; const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, display: style.display, gridTemplateColumns: style.gridTemplateColumns, fontFamily: style.fontFamily, fontSize: style.fontSize, lineHeight: style.lineHeight, color: style.color, backgroundImage: style.backgroundImage, backgroundColor: style.backgroundColor, }; }; return { body: measure('body'), container: measure('#container'), topBar: measure('.top-back-bar'), section: measure('.section_bar'), form: measure('.form_list'), field: measure('.policy-field'), input: measure('.field-row input'), control: measure('.control_bar'), reset: measure('.reset_btn'), submit: measure('.submit_btn'), priorityPanel: measure('.priority-panel'), priorityList: measure('.priority-list'), inactiveHeader: measure('.inactive-header'), activeItem: measure('.priority-column:nth-child(2) .priority-item'), help: measure('.help-button'), documentWidth: document.documentElement.scrollWidth, }; }); expect(computed.body).toMatchObject({ width: 1000, fontSize: '14px', lineHeight: '21px' }); expect(computed.body.fontFamily).toContain('Pretendard'); expect(computed.container).toMatchObject({ x: 0, y: 32, width: 1000 }); expect(computed.container.backgroundImage).toContain('back_walnut.jpg'); expect(computed.topBar).toMatchObject({ width: 1000, height: 32 }); expect(computed.section).toMatchObject({ x: 1, y: 33, width: 998, height: 23 }); expect(computed.section.backgroundImage).toContain('back_green.jpg'); expect(computed.form).toMatchObject({ x: 9, width: 982 }); expect(computed.form.gridTemplateColumns).toBe('491px 491px'); expect(computed.field.width).toBeCloseTo(491, 0); expect(computed.input).toMatchObject({ width: 224, height: 34 }); expect(computed.reset).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(48, 48, 48)' }); expect(computed.submit).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(55, 90, 127)' }); expect(computed.priorityPanel.width).toBeCloseTo(499, 0); expect(computed.priorityList.width).toBeCloseTo(229, 0); expect(computed.inactiveHeader).toMatchObject({ height: 37, backgroundColor: 'rgb(214, 214, 214)' }); expect(computed.activeItem.height).toBe(37); expect(computed.help).toMatchObject({ width: 24, height: 22.375 }); expect(computed.documentWidth).toBe(1000); await screenshot(page, 'core-npc-policy-desktop-baseline.png'); const goldInput = page.getByLabel('국가 권장 금'); await goldInput.focus(); await expect(goldInput).toBeFocused(); expect(await goldInput.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe('none'); const help = page.getByRole('button', { name: '불가침제의 설명' }); await help.hover(); await expect.poll(() => help.evaluate((element) => getComputedStyle(element, '::after').opacity)).toBe('1'); const active = page.locator('.priority-panel').first().locator('.priority-column').nth(1).getByText('불가침제의'); await active.dragTo( page.locator('.priority-panel').first().locator('.priority-column').first().locator('.priority-list') ); await expect( page.locator('.priority-panel').first().locator('.priority-column').first().getByText('불가침제의') ).toBeVisible(); await goldInput.fill('12345'); page.once('dialog', (dialog) => dialog.accept()); await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); await expect(page.getByRole('status')).toContainText('NPC 정책이 반영되었습니다.'); expect(state.mutations).toContain('npc.setNationPolicy'); await screenshot(page, 'core-npc-policy-desktop.png'); }); test('500px layout stacks policy fields and priority panels like the reference', async ({ page }) => { await installFixture(page, { permissionLevel: 4, mutations: [] }); await page.setViewportSize({ width: 500, height: 900 }); await gotoPolicy(page); await expect(page.locator('#container')).toBeVisible(); const geometry = await page.evaluate(() => { const rect = (selector: string) => { const value = document.querySelector(selector)!.getBoundingClientRect(); return { x: value.x, y: value.y, width: value.width, height: value.height }; }; return { container: rect('#container'), form: rect('.form_list'), firstField: rect('.policy-field'), panels: [...document.querySelectorAll('.priority-panel')].map((element) => { const value = element.getBoundingClientRect(); return { x: value.x, y: value.y, width: value.width }; }), documentWidth: document.documentElement.scrollWidth, }; }); expect(geometry.container).toMatchObject({ x: 0, y: 32, width: 500 }); expect(geometry.form).toMatchObject({ x: 9, width: 482 }); expect(geometry.firstField.width).toBeCloseTo(482, 0); expect(geometry.panels).toHaveLength(2); expect(geometry.panels[0]).toMatchObject({ x: 1, width: 498 }); expect(geometry.panels[1]?.x).toBe(1); expect(geometry.panels[1]?.y).toBeGreaterThan(geometry.panels[0]?.y ?? 0); expect(geometry.documentWidth).toBe(500); await screenshot(page, 'core-npc-policy-mobile.png'); }); test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => { const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] }; await installFixture(page, state); await gotoPolicy(page); const input = page.getByLabel('국가 권장 금'); await expect(input).toBeEnabled(); await input.fill('23456'); page.once('dialog', (dialog) => dialog.accept()); await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); await expect(input).toHaveValue('23456'); expect(state.mutations).toEqual(['npc.setNationPolicy']); }); test('a user below secret read permission receives a recoverable page error', async ({ page }) => { await installFixture(page, { permissionLevel: 0, failLoad: true, mutations: [] }); await gotoPolicy(page); await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); await expect(page.locator('#container')).toHaveCount(0); await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible(); });