import { mkdir, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR; const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR; const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`; const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default'; const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); type NavigationFixture = { officerLevel: number; permission: number; nationLevel: number; stage: number; npcMode: number; generalMeCalls: number; operations: string[]; generalName?: string; cityDefence?: number; cityState?: number; nationRate?: number; contextRevision?: string; contextOperations?: JsonPatchOperation[]; commandTableRevision?: string; commandTableOperations?: JsonPatchOperation[]; commandBlockedCount?: number; forceSnapshotCalls?: number; refreshDelayMs?: number; largeCommandTable?: boolean; reservedTurns?: Array<{ index: number; action: string; args: Record }>; messages?: unknown; messageContacts?: unknown; autorunLimit?: number | null; dashboardResponses?: Array<{ bytes: number; contextKind: string | null; commandTableKind: string | null; boardAccessKind: string | null; }>; }; type JsonPatchOperation = { op: 'add' | 'remove' | 'replace'; path: string; value?: unknown; }; type DashboardBundleInput = { include?: { context?: boolean; commandTable?: boolean; boardAccess?: boolean }; known?: { context?: string; commandTable?: string; boardAccess?: string }; forceSnapshot?: boolean; }; const operationInput = (route: Route, index: number): DashboardBundleInput => { const input = new URL(route.request().url()).searchParams.get('input'); if (!input) return {}; const parsed = JSON.parse(input) as Record; const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput }; return entry.json ?? (entry as DashboardBundleInput); }; const readModelChanges = ( overrides: Partial<{ generalIds: number[]; cityIds: number[]; nationIds: number[]; mapGeneralIds: number[]; mapCityIds: number[]; mapNationIds: number[]; frontStatusGeneralIds: number[]; frontStatusNationIds: number[]; frontStatusActorIds: number[]; frontStatusChanged: boolean; lobbyGeneralIds: number[]; lobbyChanged: boolean; reservedGeneralIds: number[]; recordGeneralIds: number[]; worldChanged: boolean; globalRecordsChanged: boolean; worldHistoryChanged: boolean; contactsChanged: boolean; }> ) => ({ generalIds: [], cityIds: [], nationIds: [], mapGeneralIds: [], mapCityIds: [], mapNationIds: [], frontStatusGeneralIds: [], frontStatusNationIds: [], frontStatusActorIds: [], frontStatusChanged: false, lobbyGeneralIds: [], lobbyChanged: false, reservedGeneralIds: [], recordGeneralIds: [], worldChanged: false, globalRecordsChanged: false, worldHistoryChanged: false, contactsChanged: false, ...overrides, }); const emitReadModelChanges = (page: Page, changes: ReturnType) => page.evaluate((payload) => { (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( 'readModelChanged', { at: new Date().toISOString(), revision: Date.now(), changes: payload, } ); }, changes); const commandTableFixture = (large: boolean, blockedCount = 0) => ({ general: large ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ category, values: Array.from({ length: 16 }, (_, localIndex) => { const index = categoryIndex * 16 + localIndex; return { key: `command-${index}`, name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, reqArg: index % 2 === 0, possible: index >= blockedCount, status: index >= blockedCount ? 'available' : 'blocked', inputFields: [ { key: 'amount', label: '수량', kind: 'number', required: true, min: 1, max: 10_000, }, ], }; }), })) : [], nation: [], inputOptions: { cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })), nations: [], generals: [], crewTypes: [], armTypes: [], nationTypes: [], colors: [], items: {}, }, }); const CONTEXT_INITIAL_REVISION = 'AAAAAAAAAAAAAAAAAAAAAA'; const COMMAND_TABLE_REVISION = 'CCCCCCCCCCCCCCCCCCCCCC'; const BOARD_ACCESS_REVISION = 'DDDDDDDDDDDDDDDDDDDDDD'; const contextRevision = (state: NavigationFixture) => { if (state.contextRevision) return state.contextRevision; const name = state.generalName ?? '메뉴검증장수'; if (name === '메뉴검증장수') return CONTEXT_INITIAL_REVISION; if (name === '부드럽게갱신된장수') return 'EEEEEEEEEEEEEEEEEEEEEE'; if (name === '탭공유갱신장수') return 'FFFFFFFFFFFFFFFFFFFFFF'; if (name === '리더만갱신장수') return 'GGGGGGGGGGGGGGGGGGGGGG'; return 'HHHHHHHHHHHHHHHHHHHHHH'; }; const deltaSlice = ( value: T, revision: string, known: string | undefined, forceSnapshot: boolean, operations?: JsonPatchOperation[] ) => { if (forceSnapshot || !known) return { kind: 'snapshot' as const, revision, data: value }; if (known === revision) return { kind: 'unchanged' as const, revision }; return { kind: 'patch' as const, baseRevision: known, revision, operations: operations ?? [ { op: 'replace' as const, path: '/general/name', value: (value as ReturnType).general.name, }, ], }; }; const emptyMessages = (permission: number) => ({ private: [], national: [], public: [], diplomacy: [], permission, sequence: -1, hasMore: { private: false, national: false, public: false, diplomacy: false }, latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 }, canRespondDiplomacy: false, }); const generalContext = (state: NavigationFixture) => ({ general: { id: 7, name: state.generalName ?? '메뉴검증장수', nationId: 1, cityId: 1, troopId: 0, npcState: 0, officerLevel: state.officerLevel, picture: null, imageServer: 0, 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, progression: { experienceLevel: 1, dedicationLevel: 2, statExperience: { leadership: 5, strength: 10, intelligence: 15 }, statUpgradeLimit: 20, dex: [350, 100_000, 500_000, 1_000_000, 1_275_975], }, items: { horse: null, weapon: null, book: null, item: null }, turnTime: '0185-01-01T00:00:00.000Z', }, city: { id: 1, name: '업', level: 8, nationId: 1, region: 1, population: 100_000, populationMax: 200_000, agriculture: 1_000, agricultureMax: 2_000, commerce: 1_000, commerceMax: 2_000, security: 1_000, securityMax: 2_000, trust: 80, trade: 100, defence: state.cityDefence ?? 1_000, defenceMax: 2_000, wall: 1_000, wallMax: 2_000, supplyState: 1, frontState: 0, state: state.cityState ?? 0, }, nation: { id: 1, name: '위', color: '#008000', level: state.nationLevel, gold: 10_000, rice: 20_000, tech: 100, rate: state.nationRate ?? 20, bill: 100, capitalCityId: 1, typeCode: 'che_유가', }, settings: {}, penalties: {}, }); const installFixture = async (page: Page, state: NavigationFixture) => { await page.addInitScript( ({ profile }) => { localStorage.setItem('sammo-game-token', 'ga_navigation'); localStorage.setItem('sammo-game-profile', profile); }, { profile: gameProfile } ); await page.route('**/image/**', async (route) => { await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }); }); await page.route('**/events**', async (route) => { await route.abort(); }); await page.route('**/gateway/api/trpc/**', async (route) => { const operations = operationNames(route); const results = operations.map((operation) => operation === 'me' ? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' }) : response({ ok: true }) ); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(operations.length === 1 ? results[0] : results), }); }); await page.route(`**${basePath}/api/trpc/**`, async (route) => { const operations = operationNames(route); state.operations.push(...operations); if ( operations.some((operation) => ['general.me', 'dashboard.getContextBundleDelta'].includes(operation)) && state.generalMeCalls > 0 && state.refreshDelayMs ) { await new Promise((resolve) => setTimeout(resolve, state.refreshDelayMs)); } const results = operations.map((operation, index) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') { return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, year: 185, month: 1, turnTerm: 10 }); } if (operation === 'dashboard.getContextBundleDelta') { state.generalMeCalls += 1; const input = operationInput(route, index); const include = input.include ?? {}; const forceSnapshot = input.forceSnapshot === true; if (forceSnapshot) state.forceSnapshotCalls = (state.forceSnapshotCalls ?? 0) + 1; const revision = contextRevision(state); const context = include.context ? deltaSlice( generalContext(state), revision, input.known?.context, forceSnapshot, state.contextOperations ) : undefined; const currentCommandTableRevision = state.commandTableRevision ?? COMMAND_TABLE_REVISION; const commandTable = include.commandTable ? forceSnapshot || !input.known?.commandTable ? { kind: 'snapshot' as const, revision: currentCommandTableRevision, data: commandTableFixture(state.largeCommandTable === true, state.commandBlockedCount), } : input.known.commandTable === currentCommandTableRevision ? { kind: 'unchanged' as const, revision: currentCommandTableRevision } : { kind: 'patch' as const, baseRevision: input.known.commandTable, revision: currentCommandTableRevision, operations: state.commandTableOperations ?? [], } : undefined; const boardAccess = include.boardAccess ? forceSnapshot || !input.known?.boardAccess ? { kind: 'snapshot' as const, revision: BOARD_ACCESS_REVISION, data: { permission: state.permission, canMeeting: state.officerLevel >= 1, canSecret: state.permission >= 2, }, } : { kind: 'unchanged' as const, revision: BOARD_ACCESS_REVISION } : undefined; return response({ context, commandTable, boardAccess }); } if (operation === 'general.me') { state.generalMeCalls += 1; return response(generalContext(state)); } if (operation === 'world.getState') { return response({ currentYear: 185, currentMonth: 1, tickSeconds: 600, config: { npcMode: state.npcMode, const: {}, environment: {} }, meta: {}, }); } if (operation === 'world.getMapLayout') { return response({ mapName: 'che', cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 200, y: 120, path: [] }], regionMap: { 1: '하북' }, levelMap: { 8: '특' }, }); } if (operation === 'world.getMap') { return response({ result: true, version: 0, startYear: 180, year: 185, month: 1, cityList: [[1, 8, state.cityState ?? 0, 1, 1, 1]], nationList: [[1, '위', '#008000', 1]], spyList: {}, shownByGeneralList: [], myCity: 1, myNation: 1, }); } if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { return response({ turns: state.reservedTurns ?? [], revision: 0, autorunLimit: state.autorunLimit ?? null, }); } if (operation === 'messages.getRecent') { return response(state.messages ?? emptyMessages(state.permission)); } if (operation === 'messages.getContacts') return response(state.messageContacts ?? { nation: [] }); if (operation === 'general.getRecentRecords') { return response({ global: [{ id: 3, text: '장수 동향 기록' }], general: [{ id: 2, text: '개인 기록' }], history: [{ id: 1, text: '중원 정세 기록' }], }); } if (operation === 'general.getFrontStatus') { return response({ onlineUserCount: 1, onlineNations: '위(1)', onlineGenerals: '메뉴검증장수', nationNotice: '

국가 방침

', lastExecuted: null, latestVote: { id: 9, title: '메뉴 설문', hasVoted: false }, }); } if (operation === 'board.getAccess') { return response({ permission: state.permission, canMeeting: state.officerLevel >= 1, canSecret: state.permission >= 2, }); } if (operation === 'tournament.getState') return response({ stage: state.stage }); return response({ ok: true }); }); operations.forEach((operation, index) => { if (operation !== 'dashboard.getContextBundleDelta') return; const item = results[index]; if (!item) return; const data = item.result.data as { context?: { kind: string }; commandTable?: { kind: string }; boardAccess?: { kind: string }; }; (state.dashboardResponses ??= []).push({ bytes: Buffer.byteLength(JSON.stringify(item)), contextKind: data.context?.kind ?? null, commandTableKind: data.commandTable?.kind ?? null, boardAccessKind: data.boardAccess?.kind ?? null, }); }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(operations.length === 1 ? results[0] : results), }); }); }; const installRealtimeHarness = async (page: Page) => { await page.addInitScript(() => { class TestEventSource extends EventTarget { static latest: TestEventSource | null = null; static created = 0; static closed = 0; readonly url: string; constructor(url: string | URL) { super(); this.url = url.toString(); TestEventSource.created += 1; TestEventSource.latest = this; queueMicrotask(() => this.dispatchEvent(new Event('open'))); } close() { if (TestEventSource.latest === this) { TestEventSource.latest = null; TestEventSource.closed += 1; } } } Object.defineProperty(window, 'EventSource', { configurable: true, value: TestEventSource }); Object.defineProperty(window, '__emitMainRealtime', { configurable: true, value: (type: string, payload: unknown) => { TestEventSource.latest?.dispatchEvent( new MessageEvent(type, { data: JSON.stringify({ type, ...((payload as object) ?? {}) }) }) ); }, }); Object.defineProperty(window, '__hasMainRealtime', { configurable: true, value: () => TestEventSource.latest !== null, }); Object.defineProperty(window, '__mainRealtimeStats', { configurable: true, value: () => ({ active: TestEventSource.latest !== null, created: TestEventSource.created, closed: TestEventSource.closed, }), }); }); }; const waitForMain = async (page: Page) => { await page.goto('./'); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); await expect(page.locator('.main-global-menu').first()).toBeVisible(); await expect(page.locator('.main-nation-menu')).toBeVisible(); await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3); }; const gridColumnCount = async (page: Page, selector: string) => page .locator(selector) .first() .evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length); const persistArtifact = async (page: Page, name: string) => { if (!artifactRoot) return; const target = resolve(artifactRoot); await mkdir(target, { recursive: true }); const geometry = await page.evaluate(() => { const describe = (selector: string) => { const element = document.querySelector(selector); if (!element) return null; const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, display: style.display, position: style.position, zIndex: style.zIndex, gridTemplateColumns: style.gridTemplateColumns, columnCount: style.columnCount, gap: style.gap, fontSize: style.fontSize, lineHeight: style.lineHeight, color: style.color, backgroundColor: style.backgroundColor, backgroundImage: style.backgroundImage, borderColor: style.borderColor, padding: style.padding, boxShadow: style.boxShadow, overflowY: style.overflowY, maxHeight: style.maxHeight, cursor: style.cursor, opacity: style.opacity, visibility: style.visibility, }; }; return { viewport: { width: innerWidth, height: innerHeight }, global: describe('.main-global-menu'), nation: describe('.main-nation-menu'), bottom: describe('.main-mobile-bottom'), globalPopup: describe('#mobile-global-menu'), nationPopup: describe('#mobile-nation-menu'), quickPopup: describe('#mobile-quick-menu'), }; }); await Promise.all([ page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }), writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`), ]); }; test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, nationLevel: 3, stage: 1, npcMode: 1, generalMeCalls: 0, operations: [], }; await installFixture(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); await expect(page.locator('.main-global-menu')).toHaveCount(3); expect(await gridColumnCount(page, '.main-global-menu')).toBe(8); expect(await gridColumnCount(page, '.main-nation-menu')).toBe(10); await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-mobile')).toHaveCount(0); const contentOrder = await page .locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]') .evaluateAll((elements) => elements.map((element) => { if (element.classList.contains('record-zone')) return 'records'; if (element.getAttribute('data-menu-position') === 'middle') return 'middle-menu'; if (element.classList.contains('desktop-message-panel')) return 'messages'; return 'bottom-menu'; }) ); expect(contentOrder).toEqual(['records', 'middle-menu', 'messages', 'bottom-menu']); const global = page.locator('.main-global-menu').first(); await expect(global.locator('[data-navigation-id="nation-betting"]')).toHaveAttribute( 'href', `${basePath}/nation-betting` ); await expect(global.locator('[data-navigation-id="nation-list"]')).toHaveAttribute( 'href', `${basePath}/nation-list` ); await expect(global.locator('[data-navigation-id="nation-list"]')).toHaveAttribute('target', '_blank'); await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community'); await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('aria-disabled', 'true'); await expect(global.locator('[data-navigation-id="survey"]')).toHaveClass(/highlight/); await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/); const gameInfoButton = global.locator('[data-menu-id="game-info"]'); const bettingButton = global.locator('[data-navigation-id="nation-betting"]'); await expect .poll(() => bettingButton.evaluate((element) => ({ backgroundColor: getComputedStyle(element).backgroundColor, backgroundImage: getComputedStyle(element).backgroundImage, })) ) .toEqual({ backgroundColor: 'rgb(0, 88, 44)', backgroundImage: 'none' }); await bettingButton.hover(); await expect .poll(() => bettingButton.evaluate((element) => getComputedStyle(element).backgroundColor)) .toBe('rgb(0, 88, 44)'); await gameInfoButton.focus(); await expect .poll(() => gameInfoButton.evaluate((element) => getComputedStyle(element).backgroundColor)) .toBe('rgb(0, 88, 44)'); await gameInfoButton.press('Enter'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); await expect(global.locator('#global-menu-game-info')).toBeVisible(); await page.keyboard.press('Escape'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); await expect(gameInfoButton).toBeFocused(); await gameInfoButton.click(); await page.getByRole('heading', { name: '전장 현황' }).click(); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); test('pure NPC message senders are not rendered as reply targets', async ({ page }) => { const target = (generalId: number, generalName: string) => ({ generalId, generalName, nationId: 1, nationName: '위', color: '#008000', icon: '', }); const messages = { ...emptyMessages(0), public: [ { id: 102, text: 'NPC 메시지', time: '2026-08-12 12:00:00', msgType: 'public', src: target(22, '순수NPC'), dest: null, option: {}, }, { id: 101, text: '유저 메시지', time: '2026-08-12 11:59:00', msgType: 'public', src: target(21, '유저장수'), dest: null, option: {}, }, ], }; const state: NavigationFixture = { officerLevel: 1, permission: 0, nationLevel: 1, stage: 0, npcMode: 1, generalMeCalls: 0, operations: [], messages, messageContacts: { nation: [ { nationId: 1, mailbox: 9001, name: '위', color: '#008000', general: [[21, '유저장수', 0]], }, ], }, }; await installFixture(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); const npcMessage = page.locator('.desktop-message-panel .msg-plate[data-id="102"]'); const userMessage = page.locator('.desktop-message-panel .msg-plate[data-id="101"]'); await expect(npcMessage.locator('.msg-header')).toContainText('순수NPC:위'); await expect(npcMessage.locator('.msg-header')).not.toContainText('↩'); await expect(npcMessage.getByRole('button', { name: /순수NPC/ })).toHaveCount(0); await expect(userMessage.getByRole('button', { name: /유저장수:위.*↩/ })).toBeVisible(); await userMessage.getByRole('button', { name: /유저장수:위.*↩/ }).click(); await expect(page.locator('.desktop-message-panel #mailbox_list')).toHaveValue('21'); await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`); }); test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, permission: 0, nationLevel: 1, stage: 0, npcMode: 1, generalMeCalls: 0, operations: [], largeCommandTable: true, reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: index === 0 ? '휴식' : `command-${index}`, args: {}, })), autorunLimit: 2224, }; await installFixture(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); const cityBars = page.locator('[data-main-target="city"] [role="progressbar"]'); const statBars = page.locator('[data-stat-progress] [role="progressbar"]'); const experienceBar = page.locator('[data-experience-progress] [role="progressbar"]'); await expect(cityBars).toHaveCount(8); await expect(statBars).toHaveCount(3); await expect(experienceBar).toHaveCount(1); await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0); await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4); expect(await cityBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(9); expect(await statBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(12); expect(await experienceBar.evaluate((element) => element.getBoundingClientRect().height)).toBe(12); const texture = await cityBars.first().evaluate((element) => getComputedStyle(element).backgroundImage); const fillTexture = await cityBars .first() .locator('.legacy-progress__fill') .evaluate((element) => getComputedStyle(element).backgroundImage); expect(texture).toContain('/game/pr5.gif'); expect(fillTexture).toContain('/game/pb5.gif'); const desktopGeometry = await page.evaluate(() => { const box = (element: Element) => { const rect = element.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom, }; }; const target = (name: string) => { const panel = document.querySelector(`[data-main-target="${name}"]`); if (!panel) throw new Error(`${name} panel missing`); return panel; }; const general = target('general'); const nation = target('nation'); const city = target('city'); const commands = target('commands'); const generalBody = general.querySelector('.general-body'); const editor = commands.querySelector('.reserved-command-editor'); const controlPad = commands.querySelector('.control-pad'); const bottomActions = commands.querySelector('.bottom-actions'); if (!generalBody || !editor || !controlPad || !bottomActions) throw new Error('main layout probe missing'); const visibleControlBoxes = [...controlPad.children] .filter((element) => getComputedStyle(element).display !== 'none') .map(box); const bottomActionBoxes = [...bottomActions.children].map(box); return { general: box(general), generalBody: box(generalBody), nation: box(nation), city: box(city), commands: box(commands), editor: box(editor), controlPad: box(controlPad), controlColumns: getComputedStyle(controlPad).gridTemplateColumns, visibleControlBoxes, bottomActionBoxes, commandHorizontalOverflow: commands.scrollWidth - commands.clientWidth, commandVerticalOverflow: commands.scrollHeight - commands.clientHeight, }; }); expect(desktopGeometry.general.y).toBe(desktopGeometry.nation.y); expect(desktopGeometry.general.bottom).toBe(desktopGeometry.nation.bottom); expect(desktopGeometry.commands.bottom).toBe(desktopGeometry.city.bottom); expect(desktopGeometry.generalBody.bottom).toBeLessThanOrEqual(desktopGeometry.general.bottom); expect(desktopGeometry.editor.right).toBeLessThanOrEqual(desktopGeometry.commands.right); expect(desktopGeometry.commandHorizontalOverflow).toBeLessThanOrEqual(0); expect(desktopGeometry.commandVerticalOverflow).toBeLessThanOrEqual(0); expect(desktopGeometry.controlColumns.split(' ')).toHaveLength(3); expect(desktopGeometry.visibleControlBoxes).toHaveLength(3); expect(new Set(desktopGeometry.visibleControlBoxes.map(({ y }) => y)).size).toBe(1); expect(desktopGeometry.bottomActionBoxes).toHaveLength(3); expect(new Set(desktopGeometry.bottomActionBoxes.map(({ y }) => y)).size).toBe(1); await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(15); const autonomousRest = page.locator('[data-main-target="commands"] .action-column > div').first(); await expect(autonomousRest).toContainText('휴식(자율 행동)'); expect(await autonomousRest.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)'); const tenthTurnButton = page.getByRole('button', { name: '10턴 명령 입력' }); await tenthTurnButton.click(); const quickPicker = page.getByTestId('command-picker'); await expect(quickPicker).toBeVisible(); const quickPickerAlignment = await quickPicker.evaluate((element) => { const row = element .closest('.reserved-command-editor') ?.querySelector('.action-column > div:nth-child(10)'); if (!row) throw new Error('10th command row missing'); return { pickerTop: element.getBoundingClientRect().top, rowTop: row.getBoundingClientRect().top, }; }); expect(quickPickerAlignment.pickerTop - quickPickerAlignment.rowTop).toBeCloseTo(30, 0); await quickPicker.getByRole('button', { name: '명령 입력 닫기' }).click(); const modeButton = page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '고급 모드', }); await modeButton.hover(); await modeButton.focus(); await expect(modeButton).toBeFocused(); await modeButton.click(); const advancedControlGeometry = await page.locator('[data-main-target="commands"] .reserved-command-editor').evaluate( (editor) => { const range = editor.querySelector('.range-menu'); const recent = [...editor.querySelectorAll('.control-pad summary')].find((element) => element.textContent?.includes('최근 실행') ); const advanced = editor.querySelector('.advanced-actions'); const queue = editor.querySelector('.queue-grid'); if (!range || !recent || !advanced || !queue) throw new Error('advanced command controls missing'); return { rangeTop: range.getBoundingClientRect().top, recentTop: recent.getBoundingClientRect().top, advancedTop: advanced.getBoundingClientRect().top, advancedBottom: advanced.getBoundingClientRect().bottom, queueTop: queue.getBoundingClientRect().top, }; } ); expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop); expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop); expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop); await page.locator('[data-main-target="commands"] .select-command').click(); const picker = page.getByTestId('command-picker'); await expect(picker).toBeVisible(); const pickerGeometry = await picker.evaluate((element) => { const rect = element.getBoundingClientRect(); const editor = element.closest('.reserved-command-editor')?.getBoundingClientRect(); const categories = element.querySelector('.category-list'); if (!editor || !categories) throw new Error('command picker geometry missing'); return { left: rect.left, right: rect.right, editorLeft: editor.left, editorRight: editor.right, categoryColumns: getComputedStyle(categories).gridTemplateColumns, categoryBoxes: [...categories.children].map((child) => { const childRect = child.getBoundingClientRect(); return { x: childRect.x, y: childRect.y, width: childRect.width, height: childRect.height }; }), horizontalOverflow: element.scrollWidth - element.clientWidth, }; }); expect(pickerGeometry.left).toBeGreaterThanOrEqual(pickerGeometry.editorLeft); expect(pickerGeometry.right).toBeLessThanOrEqual(pickerGeometry.editorRight); expect(pickerGeometry.categoryColumns.split(' ')).toHaveLength(3); expect(pickerGeometry.categoryBoxes).toHaveLength(3); expect(new Set(pickerGeometry.categoryBoxes.map(({ y }) => y)).size).toBe(1); expect(pickerGeometry.horizontalOverflow).toBeLessThanOrEqual(0); await picker.getByRole('button', { name: '명령 입력 닫기' }).click(); await page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '일반 모드' }).click(); const collapsedPanelHeight = await page .locator('[data-main-target="commands"]') .evaluate((element) => element.getBoundingClientRect().height); await page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }).click(); await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30); const expandedDesktopGeometry = await page.locator('.layout-desktop').evaluate((layout) => { const commands = layout.querySelector('[data-main-target="commands"]'); const city = layout.querySelector('[data-main-target="city"]'); const nation = layout.querySelector('[data-main-target="nation"]'); if (!commands || !city || !nation) throw new Error('expanded desktop panels missing'); return { commandHeight: commands.getBoundingClientRect().height, commandBottom: commands.getBoundingClientRect().bottom, cityBottom: city.getBoundingClientRect().bottom, nationTop: nation.getBoundingClientRect().top, verticalOverflow: commands.scrollHeight - commands.clientHeight, overflowY: getComputedStyle(commands).overflowY, }; }); expect(expandedDesktopGeometry.commandHeight).toBeGreaterThan(collapsedPanelHeight); expect(expandedDesktopGeometry.verticalOverflow).toBeLessThanOrEqual(0); expect(expandedDesktopGeometry.overflowY).toBe('visible'); expect(expandedDesktopGeometry.cityBottom).toBe(expandedDesktopGeometry.commandBottom); expect(expandedDesktopGeometry.nationTop).toBeGreaterThanOrEqual(expandedDesktopGeometry.commandBottom); const captureProgress = async (name: string) => { if (!artifactRoot) return; await mkdir(artifactRoot, { recursive: true }); const measurement = await page.locator('.legacy-progress').evaluateAll((elements) => elements.map((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); const fill = element.querySelector('.legacy-progress__fill'); return { label: element.getAttribute('aria-label'), rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, borderTop: style.borderTop, borderBottom: style.borderBottom, backgroundImage: style.backgroundImage, fillWidth: fill?.getBoundingClientRect().width ?? 0, fillBackgroundImage: fill ? getComputedStyle(fill).backgroundImage : '', }; }) ); const layout = await page.evaluate(() => { const describe = (selector: string) => { const element = document.querySelector(selector); if (!element) return null; const rect = element.getBoundingClientRect(); return { rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom, }, clientWidth: element.clientWidth, clientHeight: element.clientHeight, scrollWidth: element.scrollWidth, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY, boxSizing: getComputedStyle(element).boxSizing, cssWidth: getComputedStyle(element).width, minWidth: getComputedStyle(element).minWidth, paddingInline: getComputedStyle(element).paddingInline, borderInline: `${getComputedStyle(element).borderLeftWidth} ${getComputedStyle(element).borderRightWidth}`, }; }; return { viewport: { width: innerWidth, height: innerHeight }, documentWidth: document.documentElement.scrollWidth, roots: { html: describe('html'), body: describe('body'), app: describe('#app'), main: describe('main.main-page'), }, overflowingElements: [...document.body.querySelectorAll('*')] .map((element) => { const rect = element.getBoundingClientRect(); return { tag: element.tagName.toLowerCase(), className: element.className, testId: element.dataset.testid ?? null, left: rect.left, right: rect.right, width: rect.width, }; }) .filter(({ left, right }) => left < 0 || right > innerWidth) .slice(0, 20), city: describe('[data-main-target="city"]'), nation: describe('[data-main-target="nation"]'), general: describe('[data-main-target="general"]'), commands: describe('[data-main-target="commands"]'), commandEditor: describe('[data-main-target="commands"] .reserved-command-editor'), commandPicker: describe('[data-testid="command-picker"]'), }; }); await Promise.all([ page.screenshot({ path: resolve(artifactRoot, `progress-bars-${name}.png`), fullPage: true }), writeFile( resolve(artifactRoot, `progress-bars-${name}.json`), `${JSON.stringify({ layout, progress: measurement }, null, 2)}\n` ), ]); }; await captureProgress('desktop-1200'); await page.setViewportSize({ width: 500, height: 900 }); await expect(page.locator('.layout-mobile')).toBeVisible(); await page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }).click(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500); await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8); await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4); expect( await page .locator('[data-main-target="city"] [role="progressbar"]') .first() .evaluate((element) => element.getBoundingClientRect().height) ).toBe(9); const mobileGeometry = await page.locator('[data-main-target="commands"]').evaluate((commands) => { const editor = commands.querySelector('.reserved-command-editor'); const controls = commands.querySelector('.control-pad'); if (!editor || !controls) throw new Error('mobile command layout probe missing'); const panelRect = commands.getBoundingClientRect(); const editorRect = editor.getBoundingClientRect(); const controlBoxes = [...controls.children] .filter((element) => getComputedStyle(element).display !== 'none') .map((element) => { const rect = element.getBoundingClientRect(); return { y: rect.y, width: rect.width }; }); return { panelWidth: panelRect.width, panelHeight: panelRect.height, editorLeft: editorRect.left, editorRight: editorRect.right, panelLeft: panelRect.left, panelRight: panelRect.right, horizontalOverflow: commands.scrollWidth - commands.clientWidth, verticalOverflow: commands.scrollHeight - commands.clientHeight, controlColumns: getComputedStyle(controls).gridTemplateColumns, controlBoxes, }; }); expect(mobileGeometry.panelHeight).toBeGreaterThan(645); expect(mobileGeometry.editorLeft).toBeGreaterThanOrEqual(mobileGeometry.panelLeft); expect(mobileGeometry.editorRight).toBeLessThanOrEqual(mobileGeometry.panelRight); expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0); expect(mobileGeometry.verticalOverflow).toBeLessThanOrEqual(0); expect(mobileGeometry.controlColumns.split(' ')).toHaveLength(3); expect(mobileGeometry.controlBoxes).toHaveLength(3); expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1); await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30); await captureProgress('mobile-500'); }); test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, nationLevel: 3, stage: 6, npcMode: 1, generalMeCalls: 0, operations: [], }; await installFixture(page, state); await page.setViewportSize({ width: 940, height: 900 }); await waitForMain(page); expect(await gridColumnCount(page, '.main-global-menu')).toBe(8); expect(await gridColumnCount(page, '.main-nation-menu')).toBe(10); await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.layout-desktop')).toBeVisible(); await page.setViewportSize({ width: 939, height: 900 }); await expect(page.locator('.layout-mobile')).toBeVisible(); expect(await gridColumnCount(page, '.main-global-menu')).toBe(4); expect(await gridColumnCount(page, '.main-nation-menu')).toBe(5); await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await page.setViewportSize({ width: 500, height: 900 }); await expect .poll(() => page .locator('.main-global-menu') .first() .locator('[data-navigation-id="nation-betting"]') .evaluate((element) => getComputedStyle(element).backgroundColor) ) .toBe('rgb(0, 88, 44)'); const documentGeometry = await page.locator('.main-page').evaluate((element) => { const rect = element.getBoundingClientRect(); return { x: rect.x, width: rect.width, height: rect.height, scrollWidth: document.documentElement.scrollWidth, }; }); expect(documentGeometry).toMatchObject({ x: 0, width: 500, scrollWidth: 500, }); expect(documentGeometry.height).toBeGreaterThan(900); for (const selector of [ '[data-main-target="commands"]', '[data-main-target="general"]', '[data-main-target="map"]', '[data-main-target="world-history"]', '.mobile-message-panel', ]) { await expect(page.locator(selector)).toBeVisible(); } await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); }); test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, permission: 0, nationLevel: 1, stage: 0, npcMode: 1, generalMeCalls: 0, operations: [], }; await installFixture(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); const nationMenu = page.locator('.main-nation-menu'); await expect(nationMenu.locator('[data-navigation-id="meeting"]')).toHaveAttribute('href', `${basePath}/board`); await expect(nationMenu.locator('[data-navigation-id="secret-board"]')).toHaveAttribute('aria-disabled', 'true'); await expect(nationMenu.locator('[data-navigation-id="diplomacy"]')).toHaveAttribute('aria-disabled', 'true'); await expect(nationMenu.locator('[data-navigation-id="nation-info"]')).toHaveAttribute( 'href', `${basePath}/nation/info` ); state.officerLevel = 2; const callsBeforeRefresh = state.generalMeCalls; await page.getByRole('button', { name: '갱 신' }).click(); await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh); await expect(nationMenu.locator('[data-navigation-id="diplomacy"]')).toHaveAttribute( 'href', `${basePath}/diplomacy` ); await expect(nationMenu.locator('[data-navigation-id="nation-secret"]')).toHaveAttribute( 'href', `${basePath}/nation/secret` ); }); test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, nationLevel: 3, stage: 0, npcMode: 1, generalMeCalls: 0, operations: [], }; await installFixture(page, state); await page.setViewportSize({ width: 500, height: 900 }); await waitForMain(page); for (const selector of [ '[data-main-target="policy"]', '[data-main-target="commands"]', '[data-main-target="nation"]', '[data-main-target="general"]', '[data-main-target="city"]', '[data-main-target="map"]', '[data-main-target="global-records"]', '[data-main-target="general-records"]', '[data-main-target="world-history"]', '[data-message-type="public"]', '[data-message-type="national"]', '[data-message-type="private"]', '[data-message-type="diplomacy"]', ]) { await expect(page.locator(selector)).toBeVisible(); } const callsBeforeRefresh = state.generalMeCalls; await page.getByRole('button', { name: '갱 신' }).click(); await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh); await page.evaluate(() => { localStorage.setItem('sammo-session-token', 'session_navigation'); }); await page.route('**/gateway/', async (route) => { await route.fulfill({ status: 200, contentType: 'text/html', body: 'gateway' }); }); await Promise.all([page.waitForURL('**/gateway/'), page.getByRole('button', { name: '로비로' }).click()]); expect( await page.evaluate(() => ({ session: localStorage.getItem('sammo-session-token'), game: localStorage.getItem('sammo-game-token'), profile: localStorage.getItem('sammo-game-profile'), })) ).toEqual({ session: 'session_navigation', game: 'ga_navigation', profile: gameProfile, }); expect(state.operations).not.toContain('auth.logout'); }); test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({ page, }) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, nationLevel: 3, stage: 0, npcMode: 1, generalMeCalls: 0, operations: [], refreshDelayMs: 300, largeCommandTable: true, }; await installRealtimeHarness(page); await installFixture(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); await expect(page.locator('.general-title')).toContainText('메뉴검증장수'); await page.evaluate(() => { const general = document.querySelector('[data-main-target="general"]'); const city = document.querySelector('[data-main-target="city"]'); if (!general || !city) throw new Error('refresh probe targets missing'); const probe = { general, city, generalMutations: 0, cityMutations: 0, vueMeasures: [] as string[], }; new MutationObserver((records) => (probe.generalMutations += records.length)).observe(general, { childList: true, subtree: true, characterData: true, }); new MutationObserver((records) => (probe.cityMutations += records.length)).observe(city, { childList: true, subtree: true, characterData: true, }); Object.defineProperty(window, '__mainRefreshProbe', { configurable: true, value: probe }); performance.clearMarks(); performance.clearMeasures(); new PerformanceObserver((entries) => { probe.vueMeasures.push(...entries.getEntries().map((entry) => entry.name)); }).observe({ entryTypes: ['measure'] }); }); const callsBeforeRefresh = state.generalMeCalls; const operationsBeforeClockOnly = state.operations.length; await page.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( 'turnCompleted', { at: new Date().toISOString(), lastTurnTime: '0185-02-01T00:00:00.000Z', changes: { generalIds: [], cityIds: [], nationIds: [], reservedGeneralIds: [], recordGeneralIds: [], worldChanged: false, globalRecordsChanged: false, worldHistoryChanged: false, contactsChanged: false, }, } ); }); await new Promise((resolve) => setTimeout(resolve, 300)); expect(state.operations.slice(operationsBeforeClockOnly)).toEqual([]); const operationsBeforeChangedBurst = state.operations.length; state.generalName = '부드럽게갱신된장수'; await page.evaluate(() => { const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }) .__emitMainRealtime; for (let index = 0; index < 100; index += 1) { emit('turnCompleted', { at: new Date().toISOString(), lastTurnTime: '0185-02-01T00:00:00.000Z', changes: { generalIds: [7], cityIds: [], nationIds: [], mapGeneralIds: [], mapCityIds: [], mapNationIds: [], frontStatusGeneralIds: [], frontStatusNationIds: [], frontStatusActorIds: [], frontStatusChanged: false, lobbyGeneralIds: [], lobbyChanged: false, reservedGeneralIds: [], recordGeneralIds: [], worldChanged: false, globalRecordsChanged: false, worldHistoryChanged: false, contactsChanged: false, }, }); } }); await expect.poll(() => state.generalMeCalls, { timeout: 3_000 }).toBe(callsBeforeRefresh + 1); await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0); await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0); await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false'); expect(state.generalMeCalls).toBe(callsBeforeRefresh + 1); await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수'); const changedOperations = state.operations.slice(operationsBeforeChangedBurst); expect(changedOperations).toEqual(['dashboard.getContextBundleDelta']); expect(changedOperations).not.toEqual( expect.arrayContaining([ 'general.me', 'turns.getCommandTable', 'board.getAccess', 'lobby.info', 'world.getMap', 'messages.getRecent', 'messages.getContacts', 'general.getRecentRecords', 'general.getFrontStatus', 'turns.reserved.getGeneral', ]) ); const initialBundle = state.dashboardResponses?.find( (entry) => entry.contextKind === 'snapshot' && entry.commandTableKind === 'snapshot' ); const realtimeBundle = state.dashboardResponses?.find( (entry) => entry.contextKind === 'patch' && entry.commandTableKind === 'unchanged' ); expect(initialBundle?.bytes).toBeGreaterThan(5_000); expect(realtimeBundle).toMatchObject({ contextKind: 'patch', commandTableKind: 'unchanged', boardAccessKind: 'unchanged', }); expect(realtimeBundle?.bytes).toBeLessThan(1_000); const operationsBeforeSurvey = state.operations.length; await page.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( 'readModelChanged', { at: new Date().toISOString(), revision: 42, changes: { generalIds: [], cityIds: [], nationIds: [], mapGeneralIds: [], mapCityIds: [], mapNationIds: [], frontStatusGeneralIds: [], frontStatusNationIds: [], frontStatusActorIds: [], frontStatusChanged: true, lobbyGeneralIds: [], lobbyChanged: false, reservedGeneralIds: [], recordGeneralIds: [], worldChanged: false, globalRecordsChanged: false, worldHistoryChanged: false, contactsChanged: false, }, } ); }); await expect .poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 }) .toEqual(['general.getFrontStatus']); const profile = await page.evaluate(() => { const probe = ( window as unknown as { __mainRefreshProbe: { general: Element; city: Element; generalMutations: number; cityMutations: number; vueMeasures: string[]; }; } ).__mainRefreshProbe; return { generalMounted: probe.general === document.querySelector('[data-main-target="general"]'), cityMounted: probe.city === document.querySelector('[data-main-target="city"]'), generalMutations: probe.generalMutations, cityMutations: probe.cityMutations, vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)), }; }); expect(profile.generalMounted).toBe(true); expect(profile.cityMounted).toBe(true); expect(profile.generalMutations).toBeGreaterThan(0); expect(profile.cityMutations).toBe(0); if (!productionBundle) { expect(profile.vueMeasures.some((name) => name.includes('GeneralBasicCard'))).toBe(true); expect(profile.vueMeasures.some((name) => name.includes('CityBasicCard'))).toBe(false); } if (autoRefreshArtifactRoot) { await Promise.all([ page.screenshot({ path: resolve(autoRefreshArtifactRoot, 'auto-refresh-complete.png'), fullPage: true }), writeFile( resolve(autoRefreshArtifactRoot, 'profile.json'), `${JSON.stringify( { emittedTurnEvents: 100, selectiveGeneralRefreshes: state.generalMeCalls - callsBeforeRefresh, responseBytes: { initialSnapshot: initialBundle?.bytes ?? null, realtimeDelta: realtimeBundle?.bytes ?? null, reductionPercent: initialBundle && realtimeBundle ? Number(((1 - realtimeBundle.bytes / initialBundle.bytes) * 100).toFixed(2)) : null, }, responseKinds: realtimeBundle ?? null, inFlightSkeletons: { general: 0, city: 0 }, ...profile, }, null, 2 )}\n` ), ]); } const callsBeforeDefence = state.generalMeCalls; state.cityDefence = 900; state.contextRevision = 'I'.repeat(22); state.contextOperations = [{ op: 'replace', path: '/city/defence', value: 900 }]; state.commandTableRevision = 'J'.repeat(22); state.commandBlockedCount = 1; state.commandTableOperations = [ { op: 'replace', path: '/general/0/values/0/possible', value: false }, { op: 'replace', path: '/general/0/values/0/status', value: 'blocked' }, ]; await emitReadModelChanges(page, readModelChanges({ cityIds: [1], mapCityIds: [] })); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeDefence + 1); await expect(page.locator('[data-city-progress="수비"] .city-progress__text')).toHaveText('900 / 2,000'); const callsBeforeTax = state.generalMeCalls; state.nationRate = 25; state.contextRevision = 'K'.repeat(22); state.contextOperations = [{ op: 'replace', path: '/nation/rate', value: 25 }]; state.commandTableRevision = 'L'.repeat(22); state.commandBlockedCount = 2; state.commandTableOperations = [ { op: 'replace', path: '/general/0/values/1/possible', value: false }, { op: 'replace', path: '/general/0/values/1/status', value: 'blocked' }, ]; await emitReadModelChanges(page, readModelChanges({ nationIds: [1], mapNationIds: [], frontStatusNationIds: [] })); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1); const callsBeforeCityState = state.generalMeCalls; const operationsBeforeCityState = state.operations.length; state.cityState = 5; state.contextRevision = 'M'.repeat(22); state.contextOperations = [{ op: 'replace', path: '/city/state', value: 5 }]; state.commandTableRevision = 'N'.repeat(22); state.commandBlockedCount = 3; state.commandTableOperations = [ { op: 'replace', path: '/general/0/values/2/possible', value: false }, { op: 'replace', path: '/general/0/values/2/status', value: 'blocked' }, ]; await emitReadModelChanges(page, readModelChanges({ cityIds: [1], mapCityIds: [1] })); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeCityState + 1); await expect(page.locator('.city-base .city-state img')).toHaveAttribute('src', /event5\.gif$/u); expect(state.operations.slice(operationsBeforeCityState).sort()).toEqual( ['dashboard.getContextBundleDelta', 'world.getMap'].sort() ); const callsBeforeFallback = state.generalMeCalls; const forcedBeforeFallback = state.forceSnapshotCalls ?? 0; state.generalName = 'snapshot복구장수'; state.contextRevision = 'O'.repeat(22); state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }]; state.commandTableOperations = []; await emitReadModelChanges(page, readModelChanges({ generalIds: [7] })); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2); expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1); await expect(page.locator('.general-title')).toContainText('snapshot복구장수'); await expect(page.locator('.game-feedback')).toHaveCount(0); await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0); await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0); expect( await page.evaluate(() => { const probe = ( window as unknown as { __mainRefreshProbe: { general: Element; city: Element }; } ).__mainRefreshProbe; return { generalMounted: probe.general === document.querySelector('[data-main-target="general"]'), cityMounted: probe.city === document.querySelector('[data-main-target="city"]'), }; }) ).toEqual({ generalMounted: true, cityMounted: true }); await page.locator(`a[href="${basePath}/board"]`).first().click(); await page.waitForURL(`**${basePath}/board`); expect( await page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) ).toBe(false); const callsAfterLeavingMain = state.generalMeCalls; await page.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( 'turnCompleted', { at: new Date().toISOString(), lastTurnTime: '0185-02-01T00:00:00.000Z', changes: { generalIds: [7], cityIds: [], nationIds: [], mapGeneralIds: [], mapCityIds: [], mapNationIds: [], frontStatusGeneralIds: [], frontStatusNationIds: [], frontStatusActorIds: [], frontStatusChanged: false, lobbyGeneralIds: [], lobbyChanged: false, reservedGeneralIds: [], recordGeneralIds: [], worldChanged: false, globalRecordsChanged: false, worldHistoryChanged: false, contactsChanged: false, }, } ); }); await new Promise((resolve) => setTimeout(resolve, 300)); expect(state.generalMeCalls).toBe(callsAfterLeavingMain); }); test('same-account main tabs share one realtime diff and exclude a tab while sync is off', async ({ context, page, }) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, nationLevel: 3, stage: 0, npcMode: 1, generalMeCalls: 0, operations: [], }; const secondPage = await context.newPage(); const pages = [page, secondPage]; for (const currentPage of pages) { await currentPage.addInitScript(() => { Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); }); await installRealtimeHarness(currentPage); await installFixture(currentPage, state); await currentPage.setViewportSize({ width: 1200, height: 900 }); } await Promise.all(pages.map((currentPage) => waitForMain(currentPage))); await expect .poll(async () => { const stats = await Promise.all( pages.map((currentPage) => currentPage.evaluate(() => ( window as unknown as { __mainRealtimeStats: () => { active: boolean; created: number; closed: number }; } ).__mainRealtimeStats() ) ) ); return stats.filter((entry) => entry.active).length; }) .toBe(1); const activeFlags = await Promise.all( pages.map((currentPage) => currentPage.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) ) ); const leaderIndex = activeFlags.findIndex(Boolean); const followerIndex = leaderIndex === 0 ? 1 : 0; const leaderPage = pages[leaderIndex]; const followerPage = pages[followerIndex]; if (!leaderPage || !followerPage) throw new Error('realtime leader election failed'); const callsBeforeSharedRefresh = state.generalMeCalls; state.generalName = '탭공유갱신장수'; await leaderPage.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( 'readModelChanged', { at: new Date().toISOString(), revision: 100, changes: { generalIds: [7], cityIds: [], nationIds: [], mapGeneralIds: [], mapCityIds: [], mapNationIds: [], frontStatusGeneralIds: [], frontStatusNationIds: [], frontStatusActorIds: [], frontStatusChanged: false, lobbyGeneralIds: [], lobbyChanged: false, reservedGeneralIds: [], recordGeneralIds: [], worldChanged: false, globalRecordsChanged: false, worldHistoryChanged: false, contactsChanged: false, }, } ); }); await expect.poll(() => state.generalMeCalls).toBe(callsBeforeSharedRefresh + 1); await Promise.all( pages.map((currentPage) => expect(currentPage.locator('.general-title')).toContainText('탭공유갱신장수')) ); await followerPage.getByRole('button', { name: /실시간 동기화/u }).click(); await expect(followerPage.getByRole('button', { name: /실시간 동기화: 끔/u })).toBeVisible(); const callsBeforeExcludedRefresh = state.generalMeCalls; state.generalName = '리더만갱신장수'; await leaderPage.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( 'readModelChanged', { at: new Date().toISOString(), revision: 101, changes: { generalIds: [7], cityIds: [], nationIds: [], mapGeneralIds: [], mapCityIds: [], mapNationIds: [], frontStatusGeneralIds: [], frontStatusNationIds: [], frontStatusActorIds: [], frontStatusChanged: false, lobbyGeneralIds: [], lobbyChanged: false, reservedGeneralIds: [], recordGeneralIds: [], worldChanged: false, globalRecordsChanged: false, worldHistoryChanged: false, contactsChanged: false, }, } ); }); await expect.poll(() => state.generalMeCalls).toBe(callsBeforeExcludedRefresh + 1); await expect(leaderPage.locator('.general-title')).toContainText('리더만갱신장수'); await expect(followerPage.locator('.general-title')).toContainText('탭공유갱신장수'); await followerPage.getByRole('button', { name: /실시간 동기화: 끔/u }).click(); await expect(followerPage.locator('.general-title')).toContainText('리더만갱신장수'); await expect .poll(async () => { const flags = await Promise.all( pages.map((currentPage) => currentPage.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime() ) ) ); return flags.filter(Boolean).length; }) .toBe(1); });