diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 10c6264c..39043bad 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -13,6 +13,7 @@ import { } from '../../turns/commandTable.js'; import { loadMapDefinitionByName } from '../../maps/mapDefinition.js'; import { + buildEquipmentTradeItemOptions, parseReservedTurnArgs, TURN_COMMAND_NATION_COLORS, type TurnCommandInputOptions, @@ -283,29 +284,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number cityNames: new Map(cities.map((entry) => [entry.id, entry.name])), troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])), }); - const items: TurnCommandInputOptions['items'] = { - horse: [{ value: 'None', label: '판매/해제' }], - weapon: [{ value: 'None', label: '판매/해제' }], - book: [{ value: 'None', label: '판매/해제' }], - item: [{ value: 'None', label: '판매/해제' }], - }; - for (const item of moduleBundle.itemModules) { - if (item.buyable) { - const cost = item.cost ?? 0; - const currentSecurity = city?.security ?? 0; - const availability = - currentSecurity < item.reqSecu - ? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요` - : general.gold < cost - ? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요` - : '현재 구입 가능'; - items[item.slot].push({ - value: item.key, - label: item.name, - description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`, - }); - } - } + const items = buildEquipmentTradeItemOptions({ + configConst: asRecord(asRecord(worldState.config).const), + itemModules: moduleBundle.itemModules, + currentSecurity: city?.security ?? 0, + generalGold: general.gold, + }); const inputOptions: TurnCommandInputOptions = { cities: cities.map((entry) => ({ value: entry.id, diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 70661544..51c446c2 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -5,6 +5,8 @@ import { type NationTurnCommandSpec, } from '@sammo-ts/logic'; import { asRecord, isRecord } from '@sammo-ts/common'; +import type { ItemModule } from '@sammo-ts/logic/items/types.js'; +import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { z } from 'zod'; import { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js'; @@ -103,6 +105,49 @@ export interface TurnCommandInputOptions { }; } +type EquipmentTradeItemModule = Pick; + +const plainLegacyInfo = (value: string): string => + value + .replace(//giu, ' · ') + .replace(/<[^>]+>/gu, '') + .replace(/\s+/gu, ' ') + .trim(); + +export const buildEquipmentTradeItemOptions = (options: { + configConst: Record; + itemModules: readonly EquipmentTradeItemModule[]; + currentSecurity: number; + generalGold: number; +}): TurnCommandInputOptions['items'] => { + const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst); + const items: TurnCommandInputOptions['items'] = { + horse: [{ value: 'None', label: '판매/해제' }], + weapon: [{ value: 'None', label: '판매/해제' }], + book: [{ value: 'None', label: '판매/해제' }], + item: [{ value: 'None', label: '판매/해제' }], + }; + + for (const item of options.itemModules) { + if (!item.buyable || !purchasableItemKeys.has(item.key)) { + continue; + } + const cost = item.cost ?? 0; + const availability = + options.currentSecurity < item.reqSecu + ? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요` + : options.generalGold < cost + ? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요` + : '현재 구입 가능'; + items[item.slot].push({ + value: item.key, + label: item.name, + description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`, + }); + } + return items; +}; + // 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다. export const TURN_COMMAND_NATION_COLORS = [ '#FF0000', diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index ad464973..1a8216e3 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -6,7 +6,24 @@ import { } from '@sammo-ts/logic'; import { describe, expect, it } from 'vitest'; -import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js'; +import { + buildEquipmentTradeItemOptions, + buildTurnCommandInputFields, + parseReservedTurnArgs, +} from '../src/turns/commandInput.js'; + +const buildShopItem = (key: string, name: string) => ({ + key, + rawName: name, + name, + info: `${name}
설명`, + slot: 'item' as const, + cost: 100, + buyable: true, + consumable: false, + reqSecu: 3000, + unique: false, +}); describe('turn command argument input', () => { it('builds supported fields for every argument-bearing command module', async () => { @@ -80,4 +97,35 @@ describe('turn command argument input', () => { }); await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command'); }); + + it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => { + const items = buildEquipmentTradeItemOptions({ + configConst: {}, + itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')], + currentSecurity: 5000, + generalGold: 1000, + }); + + expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']); + expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명'); + }); + + it('shows only zero-count buyable items selected by an explicit scenario pool', () => { + const items = buildEquipmentTradeItemOptions({ + configConst: { + allItems: { + item: { + che_치료_환약: 1, + event_전투특기_격노: 0, + }, + }, + }, + itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')], + currentSecurity: 2000, + generalGold: 50, + }); + + expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']); + expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요'); + }); }); diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index f9b1c907..5a89fae0 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -15,6 +15,7 @@ import { loadActionModuleBundle, } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; +import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { createRuntimeTrace } from './runtimeTrace.js'; // legacy GameConstBase 기본값 @@ -146,6 +147,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit ['maxResourceActionAmount'], DEFAULT_MAX_RESOURCE_ACTION_AMOUNT ), + purchasableItemKeys: resolveLegacyPurchasableItemKeys(constValues), }; }; diff --git a/app/game-engine/test/scenarioLoader.test.ts b/app/game-engine/test/scenarioLoader.test.ts index 53013df5..71fc9388 100644 --- a/app/game-engine/test/scenarioLoader.test.ts +++ b/app/game-engine/test/scenarioLoader.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; type LoadedScenario = Awaited>; @@ -77,4 +78,18 @@ describe('tracked scenario resources', () => { expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4); expect(readItemSlot(composedAddon, 'horse').che_명마_07_백마).toBe(2); }); + + it('projects ordinary and explicit secret-item scenario pools into command execution', async () => { + const [ordinaryBlank, secretScenario] = await Promise.all( + [1, 2701].map((scenarioId) => loadScenarioDefinitionById(scenarioId)) + ); + const ordinaryKeys = buildCommandEnv(ordinaryBlank.config).purchasableItemKeys; + const secretScenarioKeys = buildCommandEnv(secretScenario.config).purchasableItemKeys; + + expect(ordinaryKeys?.size).toBe(24); + expect(ordinaryKeys?.has('che_치료_환약')).toBe(true); + expect([...ordinaryKeys!].filter((key) => key.startsWith('event_전투특기_'))).toEqual([]); + expect([...secretScenarioKeys!].filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20); + expect(secretScenarioKeys?.has('event_전투특기_격노')).toBe(true); + }); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 81412299..6921b970 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -48,6 +48,7 @@ type NavigationFixture = { accessLimitAfterCalls?: number; largeCommandTable?: boolean; draftCommandTable?: boolean; + equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>; refCommandCategories?: boolean; currentYear?: number; currentMonth?: number; @@ -235,6 +236,7 @@ const draftCommandGroups = [ options: [ { value: 'horse', label: '명마' }, { value: 'weapon', label: '무기' }, + { value: 'item', label: '도구' }, ], }, { @@ -250,7 +252,13 @@ const draftCommandGroups = [ }, ]; -const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false, draftCommands = false) => ({ +const commandTableFixture = ( + large: boolean, + blockedCount = 0, + refCategories = false, + draftCommands = false, + equipmentItemOptions?: Array<{ value: string; label: string; description?: string }> +) => ({ general: draftCommands ? draftCommandGroups : refCategories @@ -309,6 +317,7 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f { value: 'None', label: '없음' }, { value: '청룡언월도', label: '청룡언월도' }, ], + item: equipmentItemOptions ?? [{ value: 'None', label: '없음' }], } : {}, context: draftCommands @@ -595,7 +604,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => { state.largeCommandTable === true, state.commandBlockedCount, state.refCommandCategories === true, - state.draftCommandTable === true + state.draftCommandTable === true, + state.equipmentItemOptions ), } : input.known.commandTable === currentCommandTableRevision @@ -3455,6 +3465,52 @@ for (const viewport of [ }); } +for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, +] as const) { + test(`renders only scenario-scoped equipment items on ${viewport.name}`, async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + draftCommandTable: true, + equipmentItemOptions: [ + { value: 'None', label: '판매/해제' }, + { + value: 'che_치료_환약', + label: '환약', + description: '현재 구입 가능 · 가격 100 · 부상 회복', + }, + ], + reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })), + }; + await installFixture(page, state); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await waitForMain(page); + + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '국가', exact: true }).click(); + await picker.getByRole('button', { name: '장비 매매', exact: true }).click(); + await picker.getByLabel('장비 종류', { exact: true }).selectOption('item'); + + const itemSelect = picker.getByLabel('장비', { exact: true }); + await expect(itemSelect.locator('option')).toHaveText(['판매/해제', '환약']); + await expect(itemSelect.locator('option', { hasText: '비급' })).toHaveCount(0); + await itemSelect.selectOption('che_치료_환약'); + await expect(picker).toContainText('현재 구입 가능 · 가격 100 · 부상 회복'); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth)) + .toBeLessThanOrEqual(viewport.width); + await picker.screenshot({ path: test.info().outputPath(`scenario-item-shop-${viewport.name}.png`) }); + }); +} + test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({ page, }) => { diff --git a/app/gateway-api/src/orchestrator/pm2ProcessManager.ts b/app/gateway-api/src/orchestrator/pm2ProcessManager.ts index f94edcdb..0f45ac2e 100644 --- a/app/gateway-api/src/orchestrator/pm2ProcessManager.ts +++ b/app/gateway-api/src/orchestrator/pm2ProcessManager.ts @@ -8,29 +8,44 @@ import { type ProcessDefinition, } from './processManager.js'; -type Pm2Module = typeof Pm2; +export interface Pm2Client { + connect(callback: (error?: Error) => void): void; + disconnect(): void; + list(callback: (error: Error | null, list?: Pm2.ProcessDescription[]) => void): void; + start(options: Pm2.StartOptions, callback: (error?: Error) => void): void; + stop(name: string, callback: (error?: Error) => void): void; + delete(name: string, callback: (error?: Error) => void): void; +} + +export interface Pm2ProcessManagerOptions { + loadPm2?: () => Pm2Client; + connectTimeoutMs?: number; + listTimeoutMs?: number; + mutationTimeoutMs?: number; +} const require = createRequire(import.meta.url); -const loadPm2 = (): Pm2Module => require('pm2') as Pm2Module; +const loadPm2 = (): Pm2Client => require('pm2') as Pm2Client; +const DEFAULT_PM2_CONNECT_TIMEOUT_MS = 5_000; +const DEFAULT_PM2_LIST_TIMEOUT_MS = 5_000; +const DEFAULT_PM2_MUTATION_TIMEOUT_MS = 30_000; -const withPm2 = async (handler: (pm2: Pm2Module) => Promise): Promise => { - const pm2 = loadPm2(); - await new Promise((resolve, reject) => { - pm2.connect((error) => { - if (error) { +const withTimeout = (promise: Promise, timeoutMs: number, label: string): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs); + timer.unref(); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); reject(error); - return; } - resolve(); - }); + ); }); - try { - return await handler(pm2); - } finally { - pm2.disconnect(); - } -}; export const buildPm2StartOptions = (definition: ProcessDefinition) => ({ name: definition.name, @@ -47,8 +62,52 @@ export const buildPm2StartOptions = (definition: ProcessDefinition) => ({ }); export class Pm2ProcessManager implements ProcessManager { + private readonly loadPm2: () => Pm2Client; + private readonly connectTimeoutMs: number; + private readonly listTimeoutMs: number; + private readonly mutationTimeoutMs: number; + private sessionTail: Promise = Promise.resolve(); + + constructor(options: Pm2ProcessManagerOptions = {}) { + this.loadPm2 = options.loadPm2 ?? loadPm2; + this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_PM2_CONNECT_TIMEOUT_MS; + this.listTimeoutMs = options.listTimeoutMs ?? DEFAULT_PM2_LIST_TIMEOUT_MS; + this.mutationTimeoutMs = options.mutationTimeoutMs ?? DEFAULT_PM2_MUTATION_TIMEOUT_MS; + } + + private withPm2(label: string, timeoutMs: number, handler: (pm2: Pm2Client) => Promise): Promise { + const task = this.sessionTail.then(async () => { + const pm2 = this.loadPm2(); + try { + await withTimeout( + new Promise((resolve, reject) => { + pm2.connect((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }), + this.connectTimeoutMs, + 'PM2 connect' + ); + return await withTimeout(handler(pm2), timeoutMs, label); + } finally { + pm2.disconnect(); + } + }); + this.sessionTail = task.then( + () => undefined, + () => undefined + ); + return task; + } + async list(): Promise { - return withPm2( + return this.withPm2( + 'PM2 list', + this.listTimeoutMs, (pm2) => new Promise((resolve, reject) => { pm2.list((error, list) => { @@ -72,7 +131,9 @@ export class Pm2ProcessManager implements ProcessManager { } async start(definition: ProcessDefinition): Promise { - await withPm2( + await this.withPm2( + `PM2 start ${definition.name}`, + this.mutationTimeoutMs, (pm2) => new Promise((resolve, reject) => { pm2.list((listError, list) => { @@ -100,7 +161,9 @@ export class Pm2ProcessManager implements ProcessManager { } async stop(name: string): Promise { - await withPm2( + await this.withPm2( + `PM2 stop ${name}`, + this.mutationTimeoutMs, (pm2) => new Promise((resolve, reject) => { pm2.stop(name, (error) => { @@ -115,7 +178,9 @@ export class Pm2ProcessManager implements ProcessManager { } async delete(name: string): Promise { - await withPm2( + await this.withPm2( + `PM2 delete ${name}`, + this.mutationTimeoutMs, (pm2) => new Promise((resolve, reject) => { pm2.delete(name, (error) => { diff --git a/app/gateway-api/test/pm2ProcessManager.test.ts b/app/gateway-api/test/pm2ProcessManager.test.ts index b6074766..ca47eb24 100644 --- a/app/gateway-api/test/pm2ProcessManager.test.ts +++ b/app/gateway-api/test/pm2ProcessManager.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { buildPm2StartOptions } from '../src/orchestrator/pm2ProcessManager.js'; +import { + buildPm2StartOptions, + Pm2ProcessManager, + type Pm2Client, +} from '../src/orchestrator/pm2ProcessManager.js'; describe('buildPm2StartOptions', () => { it('enforces bounded restart policy and strips inherited PM2 identity at the PM2 boundary', () => { @@ -56,3 +60,118 @@ describe('buildPm2StartOptions', () => { expect(options.env).not.toHaveProperty('args'); }); }); + +describe('Pm2ProcessManager session recovery', () => { + it('serializes concurrent PM2 sessions so one disconnect cannot interrupt another request', async () => { + const events: string[] = []; + let listCall = 0; + let releaseFirstList: (() => void) | undefined; + const pm2 = { + connect(callback: Parameters[0]) { + events.push('connect'); + callback(); + }, + disconnect() { + events.push('disconnect'); + }, + list(callback: Parameters[0]) { + listCall += 1; + const currentCall = listCall; + events.push(`list:${currentCall}`); + if (currentCall === 1) { + releaseFirstList = () => callback(null, []); + return; + } + callback(null, []); + }, + start() { + throw new Error('unused'); + }, + stop() { + throw new Error('unused'); + }, + delete() { + throw new Error('unused'); + }, + } satisfies Pm2Client; + const manager = new Pm2ProcessManager({ loadPm2: () => pm2 }); + + const first = manager.list(); + const second = manager.list(); + await vi.waitFor(() => expect(events).toEqual(['connect', 'list:1'])); + + releaseFirstList?.(); + await expect(Promise.all([first, second])).resolves.toEqual([[], []]); + expect(events).toEqual(['connect', 'list:1', 'disconnect', 'connect', 'list:2', 'disconnect']); + }); + + it('times out a lost PM2 callback and lets the next queued session proceed', async () => { + let listCall = 0; + const pm2 = { + connect(callback: Parameters[0]) { + callback(); + }, + disconnect() {}, + list(callback: Parameters[0]) { + listCall += 1; + if (listCall === 1) { + return; + } + callback(null, []); + }, + start() { + throw new Error('unused'); + }, + stop() { + throw new Error('unused'); + }, + delete() { + throw new Error('unused'); + }, + } satisfies Pm2Client; + const manager = new Pm2ProcessManager({ + loadPm2: () => pm2, + listTimeoutMs: 10, + }); + + await expect(manager.list()).rejects.toThrow('PM2 list timed out after 10ms.'); + await expect(manager.list()).resolves.toEqual([]); + }); + + it('disconnects a timed-out PM2 connection before releasing the serialized session', async () => { + let connectCall = 0; + let disconnectCall = 0; + const pm2 = { + connect(callback: Parameters[0]) { + connectCall += 1; + if (connectCall > 1) { + callback(); + } + }, + disconnect() { + disconnectCall += 1; + }, + list(callback: Parameters[0]) { + callback(null, []); + }, + start() { + throw new Error('unused'); + }, + stop() { + throw new Error('unused'); + }, + delete() { + throw new Error('unused'); + }, + } satisfies Pm2Client; + const manager = new Pm2ProcessManager({ + loadPm2: () => pm2, + connectTimeoutMs: 10, + }); + + await expect(manager.list()).rejects.toThrow('PM2 connect timed out after 10ms.'); + expect(disconnectCall).toBe(1); + await expect(manager.list()).resolves.toEqual([]); + expect(disconnectCall).toBe(2); + }); +}); diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index e5721eb6..d3ae7b0c 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -240,6 +240,112 @@ test('desktop administrator sidebar follows the navbar away and then sticks to t await writeFile(testInfo.outputPath('admin-sidebar-scroll-geometry.json'), JSON.stringify(measurements, null, 2)); }); +test('keeps mobile account actions on clean rows for users and administrators', async ({ browser }, testInfo) => { + const measureAccountActions = async (page: Page) => { + const actions = page.locator('.account-actions'); + await expect(actions).toBeVisible(); + return actions.evaluate((element) => { + const rect = (target: Element) => { + const value = target.getBoundingClientRect(); + return { + top: value.top, + right: value.right, + bottom: value.bottom, + left: value.left, + width: value.width, + height: value.height, + }; + }; + const items = Array.from(element.children).map((child) => ({ + ...rect(child), + clientWidth: (child as HTMLElement).clientWidth, + scrollWidth: (child as HTMLElement).scrollWidth, + whiteSpace: getComputedStyle(child).whiteSpace, + })); + return { + container: rect(element), + documentWidth: document.documentElement.scrollWidth, + viewportWidth: window.innerWidth, + items, + }; + }); + }; + + const userContext = await browser.newContext({ viewport: { width: 500, height: 900 } }); + const userPage = await userContext.newPage(); + await installGatewayFixture(userPage, []); + await userPage.goto('lobby'); + const userGeometry = await measureAccountActions(userPage); + expect(userGeometry.documentWidth).toBe(userGeometry.viewportWidth); + expect(userGeometry.items).toHaveLength(2); + expect(userGeometry.items[0]?.top).toBeCloseTo(userGeometry.items[1]?.top ?? 0, 0); + expect(userGeometry.items[0]?.bottom).toBeCloseTo(userGeometry.items[1]?.bottom ?? 0, 0); + expect(userGeometry.items[0]?.width).toBeCloseTo(userGeometry.items[1]?.width ?? 0, 0); + expect(userGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true); + await userPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-user-500px.png'), fullPage: true }); + await userContext.close(); + + const adminContext = await browser.newContext({ viewport: { width: 500, height: 900 } }); + const adminPage = await adminContext.newPage(); + await installGatewayFixture(adminPage, ['superuser']); + await adminPage.goto('lobby'); + const adminGeometry = await measureAccountActions(adminPage); + expect(adminGeometry.documentWidth).toBe(adminGeometry.viewportWidth); + expect(adminGeometry.items).toHaveLength(3); + const [account, logout, admin] = adminGeometry.items; + expect(account?.top).toBeCloseTo(logout?.top ?? 0, 0); + expect(account?.bottom).toBeCloseTo(logout?.bottom ?? 0, 0); + expect(account?.width).toBeCloseTo(logout?.width ?? 0, 0); + expect(admin?.top).toBeCloseTo((logout?.bottom ?? 0) + 16, 0); + expect(admin?.left).toBeCloseTo(account?.left ?? 0, 0); + expect(admin?.right).toBeCloseTo(logout?.right ?? 0, 0); + expect(adminGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true); + expect(account?.whiteSpace).toBe('nowrap'); + expect(admin?.whiteSpace).toBe('nowrap'); + + const adminLink = adminPage.getByRole('link', { name: '관리자 페이지' }); + const baseBackground = await adminLink.evaluate((element) => getComputedStyle(element).backgroundColor); + await adminLink.hover(); + await expect.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe( + baseBackground + ); + await adminLink.focus(); + await expect(adminLink).toBeFocused(); + await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-500px.png'), fullPage: true }); + + await adminPage.setViewportSize({ width: 390, height: 844 }); + const adminNarrowGeometry = await measureAccountActions(adminPage); + expect(adminNarrowGeometry.documentWidth).toBe(adminNarrowGeometry.viewportWidth); + expect(adminNarrowGeometry.items[0]?.top).toBeCloseTo(adminNarrowGeometry.items[1]?.top ?? 0, 0); + expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo( + (adminNarrowGeometry.items[1]?.bottom ?? 0) + 16, + 0 + ); + expect(adminNarrowGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true); + await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-390px.png'), fullPage: true }); + + await adminPage.setViewportSize({ width: 360, height: 800 }); + const adminSmallGeometry = await measureAccountActions(adminPage); + expect(adminSmallGeometry.documentWidth).toBe(adminSmallGeometry.viewportWidth); + expect(adminSmallGeometry.items[1]?.top).toBeCloseTo((adminSmallGeometry.items[0]?.bottom ?? 0) + 16, 0); + expect(adminSmallGeometry.items[2]?.top).toBeCloseTo((adminSmallGeometry.items[1]?.bottom ?? 0) + 16, 0); + expect(adminSmallGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true); + await writeFile( + testInfo.outputPath('gateway-account-actions-geometry.json'), + JSON.stringify( + { + user: userGeometry, + admin: adminGeometry, + adminNarrow: adminNarrowGeometry, + adminSmall: adminSmallGeometry, + }, + null, + 2 + ) + ); + await adminContext.close(); +}); + test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => { await installGatewayFixture(page, ['superuser']); diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 19f95771..a57fa64d 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -826,10 +826,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { > 계 정 관 리 -
+