import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR; const legacyImageRoot = process.env.LEGACY_IMAGE_ROOT; const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); const persistParityArtifact = async (page: Page, name: string, geometry: unknown) => { if (!parityArtifactDir) { return; } await mkdir(parityArtifactDir, { recursive: true }); await Promise.all([ page.screenshot({ path: resolve(parityArtifactDir, `${name}.png`), fullPage: true }), writeFile(resolve(parityArtifactDir, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`), ]); }; type FixtureState = { permission: 'head' | 'member'; myset: number; settingMutations: Array>; }; const myGeneral = (state: FixtureState) => ({ general: { id: 7, name: '검증장수', npcState: 0, nationId: 1, cityId: 1, troopId: 0, picture: null, imageServer: 0, officerLevel: state.permission === 'head' ? 5 : 1, stats: { leadership: 70, strength: 60, intelligence: 50 }, gold: 1_000, rice: 2_000, crew: 300, train: 80, atmos: 90, injury: 0, experience: 100, dedication: 200, items: { horse: 'che_명마', weapon: null, book: null, item: null }, }, city: { id: 1, name: '업', level: 8, nationId: 1 }, nation: { id: 1, name: '위', color: '#777777', level: 3 }, settings: { tnmt: 0, defence_train: 80, use_treatment: 21, use_auto_nation_turn: 1, myset: state.myset, }, penalties: {}, }); const battleCenter = (state: FixtureState) => ({ me: { id: 7, officerLevel: state.permission === 'head' ? 5 : 1, permissionLevel: state.permission === 'head' ? 2 : 0, }, nation: { id: 1, name: '위', color: '#777777', level: 3 }, currentYear: 185, currentMonth: 1, turnTermMinutes: 10, generals: [ { id: 7, name: '검증장수', npcState: 0, officerLevel: state.permission === 'head' ? 5 : 1, cityId: 1, turnTime: '2026-01-01 00:10:00', recentWar: '2026-01-01 00:00:00', warnum: 3, stats: { leadership: 70, strength: 60, intelligence: 50 }, experience: 100, dedication: 200, injury: 0, gold: 1_000, rice: 2_000, crew: 300, train: 80, atmos: 90, }, { id: 8, name: '다른장수', npcState: 2, officerLevel: 1, cityId: 1, turnTime: '2026-01-01 00:20:00', recentWar: null, warnum: 0, stats: { leadership: 50, strength: 50, intelligence: 50 }, experience: 0, dedication: 0, injury: 0, gold: 500, rice: 500, crew: 100, train: 60, atmos: 60, }, ], }); const install = async (page: Page, state: FixtureState) => { await page.addInitScript(() => { localStorage.setItem('sammo-game-token', 'menu-token'); localStorage.setItem('sammo-game-profile', 'che:default'); }); await page.route('**/image/game/**', async (route) => { const filename = basename(new URL(route.request().url()).pathname); if (legacyImageRoot && ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg'].includes(filename)) { await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readFile(resolve(legacyImageRoot, filename)), }); return; } await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }); }); await page.route('**/che/api/trpc/**', async (route) => { const operations = operationNames(route); const results = operations.map((operation) => { if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } }); if (operation === 'join.getConfig') return response({}); if (operation === 'general.me') return response(myGeneral(state)); if (operation === 'world.getState') return response({ currentYear: 185, currentMonth: 1, tickSeconds: 600, config: { npcMode: 0, const: { availableInstantAction: {} } }, meta: { turntime: '2026-01-01T00:00:00.000Z', opentime: '2025-12-01T00:00:00.000Z', autorun_user: {}, }, }); if (operation === 'general.getMyLog') return response({ type: 'generalAction', logs: [{ id: 1, text: '기록' }] }); if (operation === 'general.setMySetting') { const raw = route.request().postDataJSON() as { input?: { json?: Record } }; state.settingMutations.push(raw.input?.json ?? {}); state.myset = Math.max(0, state.myset - 1); return response({ ok: true }); } if (operation === 'nation.getBattleCenter') { if (state.permission === 'member') { return { error: { message: '권한이 부족합니다.', code: -32000, data: { code: 'FORBIDDEN', httpStatus: 403, path: operation }, }, }; } return response(battleCenter(state)); } if (operation === 'nation.getGeneralLog') { const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction') ? 'generalAction' : operation; return response({ type, generalId: 7, logs: [{ id: 1, text: '감찰 기록' }] }); } return response({ ok: true }); }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(operations.length === 1 ? results[0] : results), }); }); }; test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; await install(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('my-page'); await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('#set_my_setting')).toBeVisible(); const desktop = await page.locator('#container').evaluate((element) => { const rect = element.getBoundingClientRect(); const title = element.querySelector('.title-row')!.getBoundingClientRect(); const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); const saveButton = element.querySelector('#set_my_setting')!; const save = saveButton.getBoundingClientRect(); const customCss = element.querySelector('#custom_css')!.getBoundingClientRect(); const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns; return { width: rect.width, minWidth: getComputedStyle(element).minWidth, fontSize: getComputedStyle(element).fontSize, columns, titleHeight: title.height, settingsOffset: settings.x - rect.x, saveWidth: save.width, saveHeight: save.height, saveBackground: getComputedStyle(saveButton).backgroundColor, customCssWidth: customCss.width, customCssHeight: customCss.height, backgroundImage: getComputedStyle(element).backgroundImage, sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage, }; }); expect(desktop.width).toBe(1000); expect(desktop.minWidth).toBe('500px'); expect(desktop.fontSize).toBe('14px'); expect(desktop.columns.split(' ')).toHaveLength(2); expect(desktop.titleHeight).toBeCloseTo(54, 0); expect(desktop.settingsOffset).toBeCloseTo(500, 0); expect(desktop.saveWidth).toBe(160); expect(desktop.saveHeight).toBe(30); expect(desktop.saveBackground).toBe('rgb(34, 85, 0)'); expect(desktop.customCssWidth).toBe(420); expect(desktop.customCssHeight).toBe(150); expect(desktop.backgroundImage).toContain('back_walnut.jpg'); expect(desktop.sectionBackgroundImage).toContain('back_green.jpg'); await persistParityArtifact(page, 'core-my-page-desktop', desktop); await page .locator('select') .filter({ has: page.locator('option[value="999"]') }) .selectOption('999'); await page.locator('#set_my_setting').click(); await expect.poll(() => state.settingMutations.length).toBe(1); expect(state.settingMutations[0]).not.toHaveProperty('generalId'); await page.setViewportSize({ width: 500, height: 900 }); await page.reload(); const mobile = await page.locator('#container').evaluate((element) => { const rect = element.getBoundingClientRect(); const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); return { width: rect.width, scrollWidth: document.documentElement.scrollWidth, columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns, settingsOffset: settings.x - rect.x, settingsWidth: settings.width, }; }); expect(mobile).toMatchObject({ width: 500, scrollWidth: 500, columns: '500px', settingsOffset: 0, settingsWidth: 500, }); await persistParityArtifact(page, 'core-my-page-mobile', mobile); }); test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => { const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; await install(page, head); await page.setViewportSize({ width: 1000, height: 900 }); await page.goto('battle-center'); await expect(page.getByRole('heading', { name: '감찰부' })).toBeVisible(); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); await page.getByRole('button', { name: '다음 ▶' }).click(); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); const geometry = await page.locator('.battle-page').evaluate((element) => { const selector = element.querySelector('.selector-row')!; const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect()); const logBlock = element.querySelector('.log-block')!.getBoundingClientRect(); return { width: element.getBoundingClientRect().width, fontSize: getComputedStyle(element).fontSize, selectorColumns: getComputedStyle(selector).gridTemplateColumns, selectorHeight: selector.getBoundingClientRect().height, controlWidths: controls.map((control) => control.width), logBlockWidth: logBlock.width, backgroundImage: getComputedStyle(element).backgroundImage, generalBackgroundImage: getComputedStyle(element.querySelector('.battle-general-card')!) .backgroundImage, }; }); expect(geometry.width).toBe(1000); expect(geometry.fontSize).toBe('14px'); expect(geometry.selectorColumns.split(' ')).toHaveLength(4); expect(geometry.selectorHeight).toBeCloseTo(36, 0); expect(geometry.controlWidths[0]).toBeCloseTo(83.33, 0); expect(geometry.controlWidths[1]).toBeCloseTo(333.33, 0); expect(geometry.logBlockWidth).toBeCloseTo(500, 0); expect(geometry.backgroundImage).toContain('back_walnut.jpg'); expect(geometry.generalBackgroundImage).toContain('back_blue.jpg'); await persistParityArtifact(page, 'core-battle-center-desktop', geometry); await page.setViewportSize({ width: 500, height: 900 }); const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({ columns: getComputedStyle(element).gridTemplateColumns, controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width), })); expect(mobileGeometry.columns.split(' ')).toHaveLength(4); expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0); expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0); await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry); await page.unrouteAll({ behavior: 'wait' }); const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] }; await install(page, member); await page.reload(); await expect(page.locator('.error')).toContainText('권한이 부족합니다.'); });