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 errorResponse = (path: string, message: string) => ({
    error: {
        message,
        code: -32029,
        data: { code: 'TOO_MANY_REQUESTS', httpStatus: 429, path },
    },
});
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;
    generalTurnTime?: string;
    serverTime?: string;
    clockMode?: 'realtime' | 'manual';
    cityDefence?: number;
    cityState?: number;
    nationRate?: number;
    contextRevision?: string;
    contextOperations?: JsonPatchOperation[];
    commandTableRevision?: string;
    commandTableOperations?: JsonPatchOperation[];
    commandBlockedCount?: number;
    forceSnapshotCalls?: number;
    refreshDelayMs?: number;
    accessLimitAfterCalls?: number;
    largeCommandTable?: boolean;
    refCommandCategories?: boolean;
    currentYear?: number;
    currentMonth?: number;
    scenarioTitle?: string;
    nationColor?: string;
    lastExecuted?: string | null;
    latestVote?: { id: number; title: string; hasVoted: boolean } | null;
    globalRecords?: Array<{ id: number; text: string }>;
    generalRecords?: Array<{ id: number; text: string }>;
    worldHistory?: Array<{ id: number; text: string }>;
    reservedTurns?: Array<{ index: number; action: string; args: Record<string, unknown> }>;
    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<string, unknown>;
    const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput };
    return entry.json ?? (entry as DashboardBundleInput);
};

const readModelInvalidation = (
    overrides: Partial<{
        context: boolean;
        lobby: boolean;
        map: boolean;
        commands: boolean;
        contacts: boolean;
        boardAccess: boolean;
        reservedTurns: boolean;
        records: boolean;
        frontStatus: boolean;
    }>
) => ({
    context: false,
    lobby: false,
    map: false,
    commands: false,
    contacts: false,
    boardAccess: false,
    reservedTurns: false,
    records: false,
    frontStatus: false,
    ...overrides,
});

const emitReadModelInvalidation = (page: Page, invalidation: ReturnType<typeof readModelInvalidation>) =>
    page.evaluate((payload) => {
        (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime(
            'readModelInvalidated',
            {
                invalidation: payload,
            }
        );
    }, invalidation);

const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계략', '국가'].map((category, index) => ({
    category,
    values: [
        {
            key: `ref-command-${index}`,
            name: category === '계략' ? '화계' : `${category} 명령`,
            reqArg: false,
            possible: true,
            status: 'available' as const,
            inputFields: [],
        },
    ],
}));

const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false) => ({
    general: refCategories
        ? refCommandCategoryFixture
        : 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 = <T>(
    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<typeof generalContext>).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: 7,
        npcState: 0,
        officerLevel: state.officerLevel,
        officerLevelText: state.officerLevel === 0 ? '재야' : '군주',
        officerCityName: state.officerLevel >= 2 && state.officerLevel <= 4 ? '업' : null,
        generalType: '용장',
        leadershipBonus: state.officerLevel === 12 ? state.nationLevel * 2 : 0,
        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,
        age: 25,
        retirementYear: 70,
        defenceTrain: 80,
        killTurn: 5,
        remainingMinutes: 7,
        troop: { name: '백마대', status: 'present', leaderCityName: '업' },
        refreshScore: { current: 3, total: 120, text: '보통' },
        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 },
        itemNames: { horse: '적토마', weapon: '청룡언월도', book: '육도', item: '옥벽' },
        crewTypeId: 1,
        crewTypeName: '보병',
        traits: { personal: '대담', specialDomestic: '상재', specialWar: '무쌍' },
        turnTime: state.generalTurnTime ?? '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: state.nationColor ?? '#008000',
        level: state.nationLevel,
        gold: 10_000,
        rice: 20_000,
        tech: 100,
        rate: state.nationRate ?? 20,
        bill: 100,
        capitalCityId: 1,
        typeCode: 'che_유가',
        typeName: '유가',
        typePros: '농상↑ 민심↑',
        typeCons: '쌀수입↓',
        population: { cityCount: 2, current: 150_000, max: 620_500 },
        crew: { generalCount: 2, current: 500, max: 7_000 },
        power: 1_234,
        taxRate: state.nationRate ?? 20,
        strategicCommandLimit: 2,
        diplomaticLimit: 0,
        prohibitScout: false,
        prohibitWar: true,
        techLevel: 0,
        techLimited: false,
        topChiefs: {
            12: { id: 1, name: '군주', npcState: 0 },
            11: { id: 2, name: '참모', npcState: 1 },
        },
        impossibleStrategicCommands: [{ name: '수몰', remainingTurns: 2, availableYear: 190, availableMonth: 5 }],
    },
    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: state.currentYear ?? 185,
                    month: state.currentMonth ?? 1,
                    turnTerm: 10,
                    serverTime: state.serverTime ?? '2026-08-13T00:00:00.000Z',
                    clockMode: state.clockMode ?? 'realtime',
                    scenarioTitle: state.scenarioTitle ?? '',
                });
            }
            if (operation === 'dashboard.getContextBundleDelta') {
                state.generalMeCalls += 1;
                if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) {
                    return errorResponse(
                        operation,
                        '접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
                            '(다음 접속 가능 시각: 2026-08-15 12:34:56) ' +
                            '자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.'
                    );
                }
                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,
                                  state.refCommandCategories === true
                              ),
                          }
                        : 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: state.currentYear ?? 185,
                    month: state.currentMonth ?? 1,
                    cityList: [[1, 8, state.cityState ?? 0, 1, 1, 1]],
                    nationList: [[1, '위', state.nationColor ?? '#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: state.globalRecords ?? [{ id: 3, text: '장수 동향 기록' }],
                    general: state.generalRecords ?? [{ id: 2, text: '개인 기록' }],
                    history: state.worldHistory ?? [{ id: 1, text: '중원 정세 기록' }],
                });
            }
            if (operation === 'general.getFrontStatus') {
                return response({
                    onlineUserCount: 1,
                    onlineNations: '위(1)',
                    onlineGenerals: '메뉴검증장수',
                    nationNotice: '<p>국가 방침</p>',
                    lastExecuted: state.lastExecuted === undefined ? '2026-08-13T00:05:06.000Z' : state.lastExecuted,
                    latestVote:
                        state.latestVote === undefined
                            ? { id: 9, title: '메뉴 설문', hasVoted: false }
                            : state.latestVote,
                });
            }
            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 || !('result' in 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.locator('.game-shell__title')).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<HTMLElement>(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'),
            gameHeader: describe('.game-shell__header'),
            gameTitle: describe('.game-shell__title'),
            gameHeaderActions: describe('.desktop-action-controls'),
            legacyGameInfo: describe('.legacy-game-info'),
            activityStatus: describe('.activity-status'),
            executionStatus: describe('.execution-status'),
            tournamentStatus: describe('.tournament-status'),
            voteStatus: describe('.vote-status'),
            autoRefresh: describe('[data-bottom-menu="auto-refresh"]'),
            manualRefresh: describe('[data-bottom-menu="manual-refresh"]'),
            commandPicker: describe('[data-testid="command-picker"]'),
            commandCategoryButton: describe('[data-testid="command-picker"] .category-btn'),
            commandItem: describe('[data-testid="command-picker"] .command-item'),
            commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
            commandDividers: [
                ...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider'),
            ].map((element) => {
                const rect = element.getBoundingClientRect();
                const style = getComputedStyle(element);
                return {
                    rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
                    borderTop: style.borderTop,
                    margin: style.margin,
                };
            }),
        };
    });
    const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first();
    if (await commandMenu.isVisible()) {
        await commandMenu.screenshot({ path: resolve(target, `${name}-menu.png`) });
    }
    const commandPicker = page.getByTestId('command-picker');
    if (await commandPicker.isVisible()) {
        await commandPicker.screenshot({ path: resolve(target, `${name}-command-picker.png`) });
    }
    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,
        scenarioTitle: '메인 화면 검증 시나리오',
        lastExecuted: '2026-08-13T00:05:06.000Z',
        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);
    await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1);
    await expect(page.locator('.game-shell__subtitle')).toHaveCount(0);
    await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월');
    await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분');
    await expect(page.locator('.legacy-game-info')).not.toContainText('최근 턴:');
    await expect(page.locator('.execution-status')).toHaveText('동작 시각: 08-13 09:05');
    await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
    await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
    const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
        const header = element.querySelector<HTMLElement>('.game-shell__header');
        const title = element.querySelector<HTMLElement>('.game-shell__title');
        const gameInfo = element.querySelector<HTMLElement>('.legacy-game-info');
        const activity = element.querySelector<HTMLElement>('.activity-status');
        const execution = element.querySelector<HTMLElement>('.execution-status');
        const tournament = element.querySelector<HTMLElement>('.tournament-status');
        const survey = element.querySelector<HTMLElement>('.vote-status');
        if (!header || !title || !gameInfo || !activity || !execution || !tournament || !survey) {
            throw new Error('main header status geometry is incomplete');
        }
        return {
            header: header.getBoundingClientRect().toJSON(),
            title: title.getBoundingClientRect().toJSON(),
            gameInfo: gameInfo.getBoundingClientRect().toJSON(),
            activity: activity.getBoundingClientRect().toJSON(),
            execution: execution.getBoundingClientRect().toJSON(),
            tournament: tournament.getBoundingClientRect().toJSON(),
            survey: survey.getBoundingClientRect().toJSON(),
            activityColumns: getComputedStyle(activity).gridTemplateColumns,
        };
    });
    expect(headerStatusGeometry.header.height).toBeGreaterThanOrEqual(headerStatusGeometry.title.height);
    expect(headerStatusGeometry.header.height).toBeLessThan(60);
    expect(headerStatusGeometry.gameInfo.y).toBeGreaterThanOrEqual(headerStatusGeometry.header.bottom);
    expect(headerStatusGeometry.activity.y).toBeGreaterThanOrEqual(headerStatusGeometry.gameInfo.bottom);
    expect(headerStatusGeometry.activity.width).toBeCloseTo(1000, 0);
    expect(headerStatusGeometry.execution.width).toBeCloseTo(333.33, 0);
    expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0);
    expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0);
    expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(3);
    const tournamentStatusLink = page.locator('.tournament-status a');
    const surveyStatusLink = page.locator('.vote-status a');
    await tournamentStatusLink.hover();
    await expect
        .poll(() => tournamentStatusLink.evaluate((element) => getComputedStyle(element).cursor))
        .toBe('pointer');
    await surveyStatusLink.focus();
    await expect(surveyStatusLink).toBeFocused();
    await expect
        .poll(() => surveyStatusLink.evaluate((element) => getComputedStyle(element).textDecorationLine))
        .toContain('underline');
    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('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => {
    const state: NavigationFixture = {
        officerLevel: 0,
        permission: 0,
        nationLevel: 0,
        stage: 0,
        npcMode: 1,
        generalMeCalls: 0,
        operations: [],
        generalName: 'Administrator',
        generalTurnTime: '2026-08-13T00:09:10.713Z',
        serverTime: '2026-08-13T00:07:06.250Z',
        clockMode: 'realtime',
        currentYear: 179,
        currentMonth: 8,
    };
    await installFixture(page, state);
    await page.clock.install({ time: new Date('2026-08-13T00:00:00.000Z') });
    const cdp = await page.context().newCDPSession(page);
    await cdp.send('Emulation.setTimezoneOverride', { timezoneId: 'Asia/Seoul' });
    await page.setViewportSize({ width: 1200, height: 900 });
    await waitForMain(page);

    const title = page.locator('[data-main-target="general"] .general-title').first();
    await expect(title).toContainText('Administrator');
    await expect(title).toContainText('용장');
    await expect(title).toContainText('09:09:10');
    await expect(title).not.toContainText('00:09');
    const commandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
    await expect(commandClock).toHaveText('09:07:06');
    await expect(commandClock).not.toHaveText('00:07');
    await page.clock.runFor(1_000);
    await expect(commandClock).toHaveText('09:07:07');
    const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first();
    await expect(generalCard).toContainText('수비 함(훈사80)');
    await expect(generalCard).toContainText('5 턴');
    await expect(generalCard).toContainText('7분 남음');
    await expect(generalCard).toContainText('백마대');
    await expect(generalCard).toContainText('보통 120점(3)');

    const desktopGeometry = await title.evaluate((element) => {
        const rect = element.getBoundingClientRect();
        const style = getComputedStyle(element);
        return {
            width: rect.width,
            height: rect.height,
            scrollWidth: element.scrollWidth,
            clientWidth: element.clientWidth,
            fontSize: style.fontSize,
            lineHeight: style.lineHeight,
            overflow: style.overflow,
        };
    });
    const desktopClockGeometry = await commandClock.evaluate((element) => {
        const rect = element.getBoundingClientRect();
        const style = getComputedStyle(element);
        return {
            width: rect.width,
            height: rect.height,
            scrollWidth: element.scrollWidth,
            clientWidth: element.clientWidth,
            fontSize: style.fontSize,
            lineHeight: style.lineHeight,
            display: style.display,
            placeItems: style.placeItems,
        };
    });
    expect(desktopGeometry.width).toBeGreaterThan(0);
    expect(desktopGeometry.height).toBeGreaterThan(0);
    expect(desktopGeometry.scrollWidth - desktopGeometry.clientWidth).toBeLessThanOrEqual(0);
    expect(desktopClockGeometry.scrollWidth - desktopClockGeometry.clientWidth).toBeLessThanOrEqual(0);
    expect(desktopClockGeometry.placeItems).toBe('center');
    expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
    if (artifactRoot) {
        const target = resolve(artifactRoot);
        await mkdir(target, { recursive: true });
        await Promise.all([
            page.screenshot({ path: resolve(target, 'main-turn-time-local-desktop-1200.png'), fullPage: true }),
            writeFile(
                resolve(target, 'main-turn-time-local-desktop-1200.json'),
                `${JSON.stringify({ title: desktopGeometry, commandClock: desktopClockGeometry }, null, 2)}\n`
            ),
        ]);
    }

    await page.setViewportSize({ width: 500, height: 900 });
    const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
    await expect(mobileTitle).toContainText('09:09:10');
    const mobileCommandClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
    await expect(mobileCommandClock).toHaveText('09:07:07');
    const mobileGeometry = {
        title: await mobileTitle.evaluate((element) => ({
            width: element.getBoundingClientRect().width,
            height: element.getBoundingClientRect().height,
            overflow: element.scrollWidth - element.clientWidth,
            fontSize: getComputedStyle(element).fontSize,
            lineHeight: getComputedStyle(element).lineHeight,
        })),
        commandClock: await mobileCommandClock.evaluate((element) => ({
            width: element.getBoundingClientRect().width,
            height: element.getBoundingClientRect().height,
            overflow: element.scrollWidth - element.clientWidth,
            fontSize: getComputedStyle(element).fontSize,
            lineHeight: getComputedStyle(element).lineHeight,
            display: getComputedStyle(element).display,
            placeItems: getComputedStyle(element).placeItems,
        })),
    };
    expect(mobileGeometry.title.overflow).toBeLessThanOrEqual(0);
    expect(mobileGeometry.commandClock.overflow).toBeLessThanOrEqual(0);
    expect(mobileGeometry.commandClock.placeItems).toBe('center');
    expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
    if (artifactRoot) {
        await Promise.all([
            page.screenshot({
                path: resolve(artifactRoot, 'main-turn-time-local-mobile-500.png'),
                fullPage: true,
            }),
            writeFile(
                resolve(artifactRoot, 'main-turn-time-local-mobile-500.json'),
                `${JSON.stringify(mobileGeometry, null, 2)}\n`
            ),
        ]);
    }

    state.clockMode = 'manual';
    state.serverTime = '2026-08-13T00:08:30.000Z';
    await page.reload();
    const frozenClock = page.locator('[data-main-target="commands"] [data-command-current-time]').first();
    await expect(frozenClock).toHaveText('09:08:30');
    await page.clock.runFor(2_000);
    await expect(frozenClock).toHaveText('09:08:30');
});

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 reserved-turn picker renders the Ref general category order', async ({ page }) => {
    const state: NavigationFixture = {
        officerLevel: 1,
        permission: 0,
        nationLevel: 1,
        stage: 0,
        npcMode: 1,
        generalMeCalls: 0,
        operations: [],
        refCommandCategories: true,
        reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
    };
    await installFixture(page, state);
    await page.setViewportSize({ width: 1200, height: 900 });
    await waitForMain(page);

    await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
    const picker = page.getByTestId('command-picker');
    const categoryButtons = picker.locator('.category-btn');
    await expect(categoryButtons).toHaveText(['개인', '내정', '군사', '인사', '계략', '국가']);

    const desktopGeometry = await picker.evaluate((element) => {
        const categories = element.querySelector<HTMLElement>('.category-list');
        const categoryButton = categories?.querySelector<HTMLElement>('.category-btn');
        const commandButton = element.querySelector<HTMLElement>('.command-item');
        if (!categories || !categoryButton || !commandButton) throw new Error('command choice geometry is missing');
        const buttons = [...categories.querySelectorAll<HTMLElement>('.category-btn')];
        const categoryStyle = getComputedStyle(categoryButton);
        const commandStyle = getComputedStyle(commandButton);
        return {
            columns: getComputedStyle(categories).gridTemplateColumns,
            rows: new Set(buttons.map((button) => button.getBoundingClientRect().y)).size,
            horizontalOverflow: element.scrollWidth - element.clientWidth,
            categoryButton: {
                height: categoryButton.getBoundingClientRect().height,
                paddingTop: categoryStyle.paddingTop,
                paddingBottom: categoryStyle.paddingBottom,
            },
            commandButton: {
                height: commandButton.getBoundingClientRect().height,
                paddingTop: commandStyle.paddingTop,
                paddingBottom: commandStyle.paddingBottom,
            },
        };
    });
    expect(desktopGeometry.columns.split(' ')).toHaveLength(3);
    expect(desktopGeometry.rows).toBe(2);
    expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
    expect(desktopGeometry.categoryButton).toEqual({ height: 32, paddingTop: '6px', paddingBottom: '6px' });
    expect(desktopGeometry.commandButton).toEqual(desktopGeometry.categoryButton);

    const strategyCategory = picker.getByRole('button', { name: '계략', exact: true });
    await strategyCategory.hover();
    await strategyCategory.focus();
    await expect(strategyCategory).toBeFocused();
    await strategyCategory.click();
    await expect(strategyCategory).toHaveClass(/active/);
    await expect(picker.locator('.command-item')).toHaveText(['화계']);
    await persistArtifact(page, `${basePath.slice(1)}-main-reserved-ref-categories-desktop-1200`);

    await page.setViewportSize({ width: 500, height: 900 });
    await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
    const mobilePicker = page.getByTestId('command-picker');
    await expect(mobilePicker.locator('.category-btn')).toHaveText(['개인', '내정', '군사', '인사', '계략', '국가']);
    const mobileGeometry = await mobilePicker.evaluate((element) => {
        const categoryButton = element.querySelector<HTMLElement>('.category-btn');
        const commandButton = element.querySelector<HTMLElement>('.command-item');
        if (!categoryButton || !commandButton) throw new Error('mobile command choice geometry is missing');
        const categoryStyle = getComputedStyle(categoryButton);
        const commandStyle = getComputedStyle(commandButton);
        return {
            horizontalOverflow: element.scrollWidth - element.clientWidth,
            categoryButton: {
                height: categoryButton.getBoundingClientRect().height,
                paddingTop: categoryStyle.paddingTop,
                paddingBottom: categoryStyle.paddingBottom,
            },
            commandButton: {
                height: commandButton.getBoundingClientRect().height,
                paddingTop: commandStyle.paddingTop,
                paddingBottom: commandStyle.paddingBottom,
            },
        };
    });
    expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
    expect(mobileGeometry.categoryButton).toEqual({ height: 32, paddingTop: '6px', paddingBottom: '6px' });
    expect(mobileGeometry.commandButton).toEqual(mobileGeometry.categoryButton);
    await persistArtifact(page, `${basePath.slice(1)}-main-reserved-ref-categories-mobile-500`);
});

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);

    const nationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]');
    await expect(nationCard.locator('.head')).toHaveCount(17);
    await expect(nationCard).toContainText('유가 (농상↑ 민심↑쌀수입↓)');
    await expect(nationCard).toContainText('영주군주참모ⓝ참모');
    await expect(nationCard).toContainText('총 주민150,000 / 620,500');
    await expect(nationCard).toContainText('총 병사500 / 7,000');
    await expect(nationCard).toContainText('지급률100%');
    await expect(nationCard).toContainText('전략2턴');
    await expect(nationCard).toContainText('임관허가');
    await expect(nationCard).toContainText('전쟁금지');
    expect(await nationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193);
    const nationRowHeights = await nationCard
        .locator('.nation-grid')
        .evaluate((element) => [...element.children].map((child) => child.getBoundingClientRect().height));
    expect(Math.max(...nationRowHeights) - Math.min(...nationRowHeights)).toBeLessThanOrEqual(0.01);
    const strategicCell = nationCard.locator('.strategic');
    const strategicTooltip = strategicCell.getByRole('tooltip');
    await expect(strategicTooltip).toBeHidden();
    await strategicCell.hover();
    await expect(strategicTooltip).toBeVisible();
    await expect(strategicTooltip).toContainText('수몰: 2턴 뒤(190년 5월부터)');
    await strategicCell.focus();
    await expect(strategicCell).toBeFocused();
    await expect(strategicTooltip).toBeVisible();

    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<HTMLElement>(`[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<HTMLElement>('.general-body');
        const editor = commands.querySelector<HTMLElement>('.reserved-command-editor');
        const controlPad = commands.querySelector<HTMLElement>('.control-pad');
        const bottomActions = commands.querySelector<HTMLElement>('.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();
    await tenthTurnButton.click();
    await expect(quickPicker).toBeHidden();
    await tenthTurnButton.click();
    await expect(quickPicker).toBeVisible();
    const quickPickerAlignment = await quickPicker.evaluate((element) => {
        const row = element
            .closest('.reserved-command-editor')
            ?.querySelector<HTMLElement>('.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<HTMLElement>('.range-menu');
            const recent = [...editor.querySelectorAll<HTMLElement>('.control-pad summary')].find((element) =>
                element.textContent?.includes('최근 실행')
            );
            const advanced = editor.querySelector<HTMLElement>('.advanced-actions');
            const queue = editor.querySelector<HTMLElement>('.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);
    const rangeMenu = page.locator('[data-main-target="commands"] .range-menu');
    await rangeMenu.locator('summary').click();
    const rangeDividers = rangeMenu.locator('.menu-divider');
    await expect(rangeDividers).toHaveCount(1);
    await expect(rangeDividers.first()).toBeVisible();
    expect(await rangeDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
        '1px solid rgb(68, 68, 68)'
    );
    await persistArtifact(page, `${basePath.slice(1)}-command-range-divider-desktop-1200`);
    await rangeMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
    await expect(rangeMenu).not.toHaveAttribute('open', '');

    const selectedMenu = page.locator('[data-main-target="commands"] .selected-menu');
    await selectedMenu.locator('summary').click();
    const selectedMenuDividers = selectedMenu.locator('.menu-divider');
    await expect(selectedMenuDividers).toHaveCount(3);
    await expect(selectedMenuDividers.first()).toBeVisible();
    expect(await selectedMenuDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
        '1px solid rgb(68, 68, 68)'
    );
    await persistArtifact(page, `${basePath.slice(1)}-command-selected-dividers-desktop-1200`);
    await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
    await expect(selectedMenu).not.toHaveAttribute('open', '');

    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<HTMLElement>('.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<HTMLElement>('[data-main-target="commands"]');
        const city = layout.querySelector<HTMLElement>('[data-main-target="city"]');
        const nation = layout.querySelector<HTMLElement>('[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<HTMLElement>('.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<HTMLElement>(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<HTMLElement>('*')]
                    .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);
    const mobileNationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]');
    expect(await mobileNationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193);
    expect(await mobileNationCard.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(
        0
    );
    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<HTMLElement>('.reserved-command-editor');
        const controls = commands.querySelector<HTMLElement>('.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);
    const mobileTurnButton = page.getByRole('button', { name: '10턴 명령 입력' });
    await mobileTurnButton.click();
    await expect(page.getByTestId('command-picker')).toBeVisible();
    await mobileTurnButton.click();
    await expect(page.getByTestId('command-picker')).toBeHidden();
    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,
        scenarioTitle: '모바일 검증 시나리오',
        lastExecuted: null,
        latestVote: null,
        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(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1);
    await expect(page.locator('.game-shell__subtitle')).toHaveCount(0);
    await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월');
    await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분');
    await expect(page.locator('.legacy-game-info')).not.toContainText('최근 턴:');
    await expect(page.locator('.execution-status')).toHaveText('동작 시각: 기록 없음');
    await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중');
    await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음');
    const activityGeometry = await page.locator('.activity-status').evaluate((element) => {
        const main = element.closest<HTMLElement>('.main-page');
        const header = main?.querySelector<HTMLElement>('.game-shell__header');
        const headerActions = header?.querySelector<HTMLElement>('.desktop-action-controls');
        const execution = element.querySelector<HTMLElement>('.execution-status');
        const tournament = element.querySelector<HTMLElement>('.tournament-status');
        const survey = element.querySelector<HTMLElement>('.vote-status');
        if (!header || !headerActions || !execution || !tournament || !survey) {
            throw new Error('mobile header or activity status is incomplete');
        }
        const headerRect = header.getBoundingClientRect();
        const headerActionsRect = headerActions.getBoundingClientRect();
        return {
            headerHeight: headerRect.height,
            headerRight: headerRect.right,
            headerActionsRight: headerActionsRect.right,
            width: element.getBoundingClientRect().width,
            executionWidth: execution.getBoundingClientRect().width,
            tournamentWidth: tournament.getBoundingClientRect().width,
            surveyWidth: survey.getBoundingClientRect().width,
            columns: getComputedStyle(element).gridTemplateColumns,
        };
    });
    expect(activityGeometry.headerHeight).toBeLessThan(60);
    expect(activityGeometry.headerActionsRight).toBeLessThanOrEqual(activityGeometry.headerRight);
    expect(activityGeometry.width).toBe(500);
    expect(activityGeometry.executionWidth).toBeCloseTo(166.67, 0);
    expect(activityGeometry.tournamentWidth).toBeCloseTo(166.67, 0);
    expect(activityGeometry.surveyWidth).toBeCloseTo(166.67, 0);
    expect(activityGeometry.columns.split(' ')).toHaveLength(3);
    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('mobile bottom controls share Ref pressed geometry while their color bases vary', async ({ page }, testInfo) => {
    const state: NavigationFixture = {
        officerLevel: 5,
        permission: 2,
        nationLevel: 3,
        stage: 0,
        npcMode: 1,
        nationColor: '#FFFF00',
        generalMeCalls: 0,
        operations: [],
        refreshDelayMs: 300,
    };
    await installFixture(page, state);
    await page.setViewportSize({ width: 500, height: 900 });
    await waitForMain(page);

    const buttonStyle = (selector: string) =>
        page.locator(selector).evaluate((element) => {
            const style = getComputedStyle(element);
            const rect = element.getBoundingClientRect();
            return {
                backgroundColor: style.backgroundColor,
                color: style.color,
                borderTopWidth: style.borderTopWidth,
                borderLeftWidth: style.borderLeftWidth,
                borderBottomWidth: style.borderBottomWidth,
                borderBottomColor: style.borderBottomColor,
                marginTop: style.marginTop,
                fontWeight: style.fontWeight,
                height: rect.height,
                classNames: [...element.classList],
            };
        });
    const pointerDownStyle = async (selector: string, screenshotName?: string) => {
        const target = page.locator(selector);
        const box = await target.boundingBox();
        if (!box) throw new Error(`mobile bottom control is not measurable: ${selector}`);
        await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
        await page.mouse.down();
        const activeStyle = await buttonStyle(selector);
        if (screenshotName) {
            await page.screenshot({ path: testInfo.outputPath(screenshotName), fullPage: true });
        }
        await page.mouse.move(1, 1);
        await page.mouse.up();
        return activeStyle;
    };

    const globalSelector = '[data-bottom-menu="global"]';
    await expect(page.locator(globalSelector)).toBeVisible();
    expect(await buttonStyle(globalSelector)).toMatchObject({
        backgroundColor: 'rgb(0, 88, 44)',
        color: 'rgb(255, 255, 255)',
        borderTopWidth: '0px',
        borderLeftWidth: '1px',
        borderBottomWidth: '4px',
        borderBottomColor: 'rgb(0, 79, 40)',
        marginTop: '0px',
        fontWeight: '700',
        height: 45,
        classNames: expect.arrayContaining(['legacy-button', 'legacy-button--navigation']),
    });
    await page.locator(globalSelector).hover();
    expect(await buttonStyle(globalSelector)).toMatchObject({
        backgroundColor: 'rgb(0, 88, 44)',
        borderBottomWidth: '3px',
        marginTop: '1px',
        height: 44,
    });
    expect(await pointerDownStyle(globalSelector)).toMatchObject({
        backgroundColor: 'rgb(0, 88, 44)',
        borderBottomWidth: '2px',
        marginTop: '2px',
        height: 43,
    });

    const nationTrigger = page.locator('[data-bottom-menu="nation"]');
    expect(await buttonStyle('[data-bottom-menu="nation"]')).toMatchObject({
        backgroundColor: 'rgb(255, 255, 0)',
        color: 'rgb(0, 0, 0)',
        borderTopWidth: '0px',
        borderLeftWidth: '1px',
        borderBottomWidth: '4px',
        borderBottomColor: 'color(srgb 0.9 0.9 0)',
        marginTop: '0px',
        fontWeight: '700',
        height: 45,
        classNames: expect.arrayContaining(['legacy-button', 'legacy-button--lumen']),
    });
    await nationTrigger.hover();
    expect(await buttonStyle('[data-bottom-menu="nation"]')).toMatchObject({
        backgroundColor: 'rgb(255, 255, 0)',
        borderBottomWidth: '3px',
        marginTop: '1px',
        height: 44,
    });
    expect(await pointerDownStyle('[data-bottom-menu="nation"]', 'mobile-bottom-nation-active.png')).toMatchObject({
        backgroundColor: 'rgb(255, 255, 0)',
        borderBottomWidth: '2px',
        marginTop: '2px',
        height: 43,
    });

    const quickSelector = '[data-bottom-menu="quick"]';
    expect(await buttonStyle(quickSelector)).toMatchObject({
        backgroundColor: 'rgb(33, 37, 41)',
        color: 'rgb(255, 255, 255)',
        borderTopWidth: '0px',
        borderLeftWidth: '1px',
        borderBottomWidth: '4px',
        borderBottomColor: 'rgb(30, 33, 37)',
        marginTop: '0px',
        fontWeight: '700',
        height: 45,
        classNames: expect.arrayContaining(['legacy-button', 'legacy-button--dark']),
    });
    await page.locator(quickSelector).hover();
    expect(await buttonStyle(quickSelector)).toMatchObject({
        backgroundColor: 'rgb(33, 37, 41)',
        borderBottomWidth: '3px',
        marginTop: '1px',
        height: 44,
    });
    expect(await pointerDownStyle(quickSelector)).toMatchObject({
        backgroundColor: 'rgb(33, 37, 41)',
        borderBottomWidth: '2px',
        marginTop: '2px',
        height: 43,
    });

    await page.locator(quickSelector).click();
    const lobbySelector = '#mobile-quick-menu .lobby-link';
    await expect(page.locator(lobbySelector)).toBeVisible();
    expect(await buttonStyle(lobbySelector)).toMatchObject({
        backgroundColor: 'rgb(0, 88, 44)',
        color: 'rgb(255, 255, 255)',
        borderBottomWidth: '4px',
        borderBottomColor: 'rgb(0, 79, 40)',
        marginTop: '0px',
        fontWeight: '700',
        height: 40,
        classNames: expect.arrayContaining(['legacy-button', 'legacy-button--navigation']),
    });
    await page.locator(lobbySelector).hover();
    expect(await buttonStyle(lobbySelector)).toMatchObject({
        backgroundColor: 'rgb(0, 88, 44)',
        borderBottomWidth: '3px',
        marginTop: '1px',
        height: 39,
    });
    expect(await pointerDownStyle(lobbySelector)).toMatchObject({
        backgroundColor: 'rgb(0, 88, 44)',
        borderBottomWidth: '2px',
        marginTop: '2px',
        height: 38,
    });

    const autoRefreshSelector = '[data-bottom-menu="auto-refresh"]';
    expect(await buttonStyle(autoRefreshSelector)).toMatchObject({
        backgroundColor: 'rgb(0, 88, 44)',
        color: 'rgb(255, 255, 255)',
        borderBottomWidth: '4px',
        borderBottomColor: 'rgb(0, 79, 40)',
        marginTop: '0px',
        fontWeight: '700',
        height: 45,
        classNames: expect.arrayContaining(['legacy-button', 'legacy-button--navigation']),
    });
    await page.locator(autoRefreshSelector).hover();
    expect(await buttonStyle(autoRefreshSelector)).toMatchObject({
        backgroundColor: 'rgb(0, 88, 44)',
        borderBottomWidth: '3px',
        marginTop: '1px',
        height: 44,
    });
    expect(await pointerDownStyle(autoRefreshSelector)).toMatchObject({
        backgroundColor: 'rgb(0, 88, 44)',
        borderBottomWidth: '2px',
        marginTop: '2px',
        height: 43,
    });

    const manualRefreshSelector = '[data-bottom-menu="manual-refresh"]';
    expect(await buttonStyle(manualRefreshSelector)).toMatchObject({
        backgroundColor: 'rgb(33, 37, 41)',
        color: 'rgb(255, 255, 255)',
        borderTopWidth: '0px',
        borderLeftWidth: '1px',
        borderBottomWidth: '4px',
        borderBottomColor: 'rgb(30, 33, 37)',
        marginTop: '0px',
        fontWeight: '700',
        height: 45,
        classNames: expect.arrayContaining(['legacy-button', 'legacy-button--dark']),
    });
    await page.locator(manualRefreshSelector).hover();
    expect(await buttonStyle(manualRefreshSelector)).toMatchObject({
        backgroundColor: 'rgb(33, 37, 41)',
        borderBottomWidth: '3px',
        marginTop: '1px',
        height: 44,
    });
    expect(await pointerDownStyle(manualRefreshSelector)).toMatchObject({
        backgroundColor: 'rgb(33, 37, 41)',
        borderBottomWidth: '2px',
        marginTop: '2px',
        height: 43,
    });
    await page.keyboard.press('Tab');
    await page.locator(manualRefreshSelector).focus();
    await expect
        .poll(() => page.locator(manualRefreshSelector).evaluate((element) => element.matches(':focus-visible')))
        .toBe(true);
    await expect
        .poll(() => page.locator(manualRefreshSelector).evaluate((element) => getComputedStyle(element).boxShadow))
        .not.toBe('none');
    await page.locator(manualRefreshSelector).click();
    await expect(page.locator(manualRefreshSelector)).toBeDisabled();
    expect(await buttonStyle(manualRefreshSelector)).toMatchObject({
        backgroundColor: 'rgb(33, 37, 41)',
        borderBottomWidth: '4px',
        marginTop: '0px',
        height: 45,
    });
    await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '0.55');
    await expect(page.locator(manualRefreshSelector)).toBeEnabled();

    await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-ref-buttons`);
});

test('real mobile devices initially fit the complete 500px game canvas', async ({ browser }, testInfo) => {
    test.setTimeout(60_000);
    const configuredBaseUrl = testInfo.project.use.baseURL;
    if (typeof configuredBaseUrl !== 'string') {
        throw new Error('Playwright baseURL is required for the mobile viewport contract');
    }

    const deviceWidths = [360, 390, 480];
    const measurements: Record<string, unknown> = {};

    for (const deviceWidth of deviceWidths) {
        const context = await browser.newContext({
            baseURL: configuredBaseUrl,
            viewport: { width: deviceWidth, height: 844 },
            screen: { width: deviceWidth, height: 844 },
            deviceScaleFactor: 1,
            isMobile: true,
            hasTouch: true,
            colorScheme: 'dark',
        });
        const mobilePage = await context.newPage();
        const state: NavigationFixture = {
            officerLevel: 5,
            permission: 2,
            nationLevel: 3,
            stage: 6,
            npcMode: 1,
            generalMeCalls: 0,
            operations: [],
        };
        await installFixture(mobilePage, state);
        await waitForMain(mobilePage);

        const mainGeometry = await mobilePage.locator('.main-page').evaluate((element) => {
            const rect = element.getBoundingClientRect();
            return {
                viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
                screenWidth: screen.availWidth,
                innerWidth: window.innerWidth,
                layoutViewportWidth: document.documentElement.clientWidth,
                visualViewportWidth: window.visualViewport?.width ?? null,
                visualViewportScale: window.visualViewport?.scale ?? null,
                documentScrollWidth: document.documentElement.scrollWidth,
                canvas: {
                    left: rect.left,
                    right: rect.right,
                    width: rect.width,
                },
            };
        });

        expect(mainGeometry.viewportMeta).toBe('width=500');
        expect(mainGeometry.screenWidth).toBe(deviceWidth);
        expect(mainGeometry.layoutViewportWidth).toBe(500);
        expect(mainGeometry.visualViewportWidth).toBeCloseTo(500, 2);
        expect(mainGeometry.visualViewportScale).toBeCloseTo(deviceWidth / 500, 2);
        expect(mainGeometry.documentScrollWidth).toBeLessThanOrEqual(mainGeometry.innerWidth);
        expect(mainGeometry.canvas).toEqual({ left: 0, right: 500, width: 500 });
        expect(mainGeometry.canvas.right).toBeLessThanOrEqual((mainGeometry.visualViewportWidth ?? 0) + 0.01);
        if (artifactRoot) {
            await mkdir(artifactRoot, { recursive: true });
            await mobilePage.screenshot({
                path: resolve(artifactRoot, `initial-mobile-fit-${deviceWidth}.png`),
                fullPage: true,
            });
        }

        const routeGeometry: Record<string, unknown> = {};
        if (deviceWidth === 390) {
            for (const target of ['chief-center', 'battle-center', 'inherit', 'nation-betting']) {
                await mobilePage.goto(target);
                await expect
                    .poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth))
                    .toBe('500px');
                routeGeometry[target] = await mobilePage.locator('#app').evaluate((element) => {
                    const rect = element.getBoundingClientRect();
                    return {
                        viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
                        layoutViewportWidth: document.documentElement.clientWidth,
                        visualViewportWidth: window.visualViewport?.width ?? null,
                        left: rect.left,
                        right: rect.right,
                        width: rect.width,
                    };
                });
                const geometry = routeGeometry[target] as {
                    viewportMeta: string;
                    layoutViewportWidth: number;
                    visualViewportWidth: number;
                    left: number;
                    right: number;
                    width: number;
                };
                expect(geometry.viewportMeta).toBe('width=500');
                expect(geometry.layoutViewportWidth).toBe(500);
                expect(geometry.visualViewportWidth).toBeCloseTo(500, 2);
                expect(geometry.left).toBeCloseTo(0, 2);
                expect(geometry.right).toBeCloseTo(500, 2);
                expect(geometry.width).toBeCloseTo(500, 2);
            }
        }

        measurements[String(deviceWidth)] = { main: mainGeometry, routes: routeGeometry };
        await context.close();
    }

    if (artifactRoot) {
        await writeFile(
            resolve(artifactRoot, 'initial-mobile-fit-computed-dom.json'),
            `${JSON.stringify(measurements, null, 2)}\n`
        );
    }
});

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 installRealtimeHarness(page);
    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 autoRefresh = page.getByRole('button', { name: '자동 갱신 ON' });
    const manualRefresh = page.getByRole('button', { name: '직접 갱신' });
    await expect(autoRefresh).toHaveAttribute('aria-pressed', 'true');
    await expect(autoRefresh.locator('strong')).toHaveCSS('color', 'rgb(158, 240, 184)');
    await expect(manualRefresh).toHaveAttribute('aria-busy', 'false');
    await expect
        .poll(() =>
            page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
        )
        .toBe(true);

    const refreshGeometry = await page.locator('.bottom-refresh-controls').evaluate((controls) => {
        const auto = controls.querySelector<HTMLElement>('[data-bottom-menu="auto-refresh"]');
        const manual = controls.querySelector<HTMLElement>('[data-bottom-menu="manual-refresh"]');
        if (!auto || !manual) throw new Error('mobile refresh controls are incomplete');
        const controlsRect = controls.getBoundingClientRect();
        const autoRect = auto.getBoundingClientRect();
        const manualRect = manual.getBoundingClientRect();
        return {
            controls: { left: controlsRect.left, right: controlsRect.right, width: controlsRect.width },
            auto: { left: autoRect.left, right: autoRect.right, width: autoRect.width },
            manual: { left: manualRect.left, right: manualRect.right, width: manualRect.width },
            overflow: controls.scrollWidth - controls.clientWidth,
        };
    });
    expect(refreshGeometry.controls.width).toBe(125);
    expect(refreshGeometry.auto.width).toBe(85);
    expect(refreshGeometry.manual.width).toBe(40);
    expect(refreshGeometry.auto.left).toBe(refreshGeometry.controls.left);
    expect(refreshGeometry.auto.right).toBe(refreshGeometry.manual.left);
    expect(refreshGeometry.manual.right).toBe(refreshGeometry.controls.right);
    expect(refreshGeometry.overflow).toBeLessThanOrEqual(0);

    await autoRefresh.focus();
    await expect(autoRefresh).toBeFocused();
    await autoRefresh.hover();
    await expect(autoRefresh).toHaveCSS('filter', 'none');
    await expect(autoRefresh).toHaveCSS('background-color', 'rgb(0, 88, 44)');
    await expect(autoRefresh).toHaveCSS('border-bottom-width', '3px');
    await expect(autoRefresh).toHaveCSS('margin-top', '1px');
    await autoRefresh.click();
    const disabledAutoRefresh = page.getByRole('button', { name: '자동 갱신 OFF' });
    await expect(disabledAutoRefresh).toHaveAttribute('aria-pressed', 'false');
    await expect(disabledAutoRefresh.locator('strong')).toHaveCSS('color', 'rgb(187, 187, 187)');
    await expect
        .poll(() =>
            page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
        )
        .toBe(false);
    await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls-off`);

    state.generalName = '직접갱신된장수';
    const callsBeforeRefresh = state.generalMeCalls;
    await manualRefresh.click();
    await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh);
    await expect(page.locator('.general-title')).toContainText('직접갱신된장수');

    const callsBeforeEnable = state.generalMeCalls;
    await page.getByRole('button', { name: '자동 갱신 OFF' }).click();
    await expect(page.getByRole('button', { name: '자동 갱신 ON' })).toHaveAttribute('aria-pressed', 'true');
    await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeEnable);
    await expect
        .poll(() =>
            page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
        )
        .toBe(true);

    await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls`);

    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: '<!doctype html><title>gateway</title>' });
    });
    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 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('readModelInvalidated', {
                invalidation: {
                    context: true,
                    lobby: false,
                    map: false,
                    commands: true,
                    contacts: false,
                    boardAccess: true,
                    reservedTurns: false,
                    records: false,
                    frontStatus: 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(
            'readModelInvalidated',
            {
                invalidation: {
                    context: false,
                    lobby: false,
                    map: false,
                    commands: false,
                    contacts: false,
                    boardAccess: false,
                    reservedTurns: false,
                    records: false,
                    frontStatus: true,
                },
            }
        );
    });
    await expect
        .poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 })
        .toEqual(['dashboard.getContextBundleDelta', '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 emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true }));
    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 emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true, boardAccess: true }));
    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 emitReadModelInvalidation(page, readModelInvalidation({ context: true, map: true, commands: true }));
    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 emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true, boardAccess: true }));
    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(
            'readModelInvalidated',
            {
                invalidation: {
                    context: true,
                    lobby: false,
                    map: false,
                    commands: true,
                    contacts: false,
                    boardAccess: true,
                    reservedTurns: false,
                    records: false,
                    frontStatus: false,
                },
            }
        );
    });
    await new Promise((resolve) => setTimeout(resolve, 300));
    expect(state.generalMeCalls).toBe(callsAfterLeavingMain);
});

test('access limit stops automatic main refresh and closes realtime until a manual retry can pass', async ({
    page,
}) => {
    const state: NavigationFixture = {
        officerLevel: 5,
        permission: 2,
        nationLevel: 3,
        stage: 0,
        npcMode: 1,
        generalMeCalls: 0,
        operations: [],
        accessLimitAfterCalls: 1,
    };
    await installRealtimeHarness(page);
    await installFixture(page, state);
    await page.setViewportSize({ width: 1200, height: 900 });
    await waitForMain(page);
    await expect
        .poll(() =>
            page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
        )
        .toBe(true);

    const operationsBeforeLimit = state.operations.length;
    await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true }));

    await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.');
    await expect
        .poll(() =>
            page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
        )
        .toBe(false);
    expect(state.operations.slice(operationsBeforeLimit)).toEqual(['dashboard.getContextBundleDelta']);

    const operationsAfterLimit = state.operations.length;
    await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true }));
    await new Promise((resolve) => setTimeout(resolve, 300));
    expect(state.operations).toHaveLength(operationsAfterLimit);

    state.accessLimitAfterCalls = undefined;
    await page.getByRole('button', { name: '갱 신' }).click();
    await expect(page.getByRole('alert')).toHaveCount(0);
    await expect
        .poll(() =>
            page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
        )
        .toBe(true);
});

test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
    const state: NavigationFixture = {
        officerLevel: 5,
        permission: 2,
        nationLevel: 3,
        stage: 0,
        npcMode: 1,
        generalMeCalls: 0,
        operations: [],
    };
    await installRealtimeHarness(page);
    await installFixture(page, state);
    await page.setViewportSize({ width: 1200, height: 900 });
    await waitForMain(page);
    await expect
        .poll(() =>
            page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
        )
        .toBe(true);

    state.globalRecords = [
        { id: 4, text: '자동 갱신된 장수 동향' },
        { id: 3, text: '장수 동향 기록' },
    ];
    const operationsBeforeGlobal = state.operations.length;
    await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
    await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향');
    expect(state.operations.slice(operationsBeforeGlobal)).toEqual([
        'dashboard.getContextBundleDelta',
        'general.getRecentRecords',
    ]);

    state.worldHistory = [
        { id: 5, text: '자동 갱신된 중원 정세' },
        { id: 1, text: '중원 정세 기록' },
    ];
    const operationsBeforeHistory = state.operations.length;
    await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
    await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세');
    expect(state.operations.slice(operationsBeforeHistory)).toEqual([
        'dashboard.getContextBundleDelta',
        'general.getRecentRecords',
    ]);

    state.currentMonth = 2;
    const operationsBeforeMonth = state.operations.length;
    await page.evaluate(
        (invalidation) => {
            (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
                'readModelInvalidated',
                {
                    invalidation,
                }
            );
        },
        readModelInvalidation({ lobby: true, map: true, commands: true })
    );
    await expect(page.getByText('현재: 185년 2월')).toBeVisible();
    await expect(page.locator('.map-viewer')).toContainText('185年 2月');
    expect(state.operations.slice(operationsBeforeMonth).sort()).toEqual(
        ['dashboard.getContextBundleDelta', 'lobby.info', 'world.getMap'].sort()
    );
});

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(
            'readModelInvalidated',
            {
                invalidation: {
                    context: true,
                    lobby: false,
                    map: false,
                    commands: true,
                    contacts: false,
                    boardAccess: true,
                    reservedTurns: false,
                    records: false,
                    frontStatus: 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(
            'readModelInvalidated',
            {
                invalidation: {
                    context: true,
                    lobby: false,
                    map: false,
                    commands: true,
                    contacts: false,
                    boardAccess: true,
                    reservedTurns: false,
                    records: false,
                    frontStatus: 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);
});
