diff --git a/app/game-frontend/e2e/appConfirmation.ts b/app/game-frontend/e2e/appConfirmation.ts new file mode 100644 index 00000000..abad2ba0 --- /dev/null +++ b/app/game-frontend/e2e/appConfirmation.ts @@ -0,0 +1,9 @@ +import { expect, type Page } from '@playwright/test'; + +export const acceptAppConfirmation = async (page: Page, message: string): Promise => { + const dialog = page.getByTestId('game-notice-dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText(message); + await dialog.getByRole('button', { name: '확인', exact: true }).click(); + await expect(dialog).toBeHidden(); +}; diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 6b4d5e3d..faa014f0 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -1,9 +1,10 @@ -import { expect, test, type Page, type Route } from '@playwright/test'; +import { devices, expect, test, type Page, type Route } from '@playwright/test'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { expectLumenButtonStates } from './lumenButton.js'; +import { acceptAppConfirmation } from './appConfirmation.js'; type Role = 'leader' | 'head' | 'member'; type FixtureState = { @@ -394,11 +395,8 @@ test('leader can grant two ambassador and auditor permissions by click or touch expect(ambassadorGeometry.width).toBeGreaterThanOrEqual(100); await screenshot(page, 'core-personnel-mobile-permission-picker-open.png'); - page.once('dialog', async (dialog) => { - expect(dialog.message()).toBe('외교권자를 변경할까요?'); - await dialog.accept(); - }); await page.getByRole('button', { name: '외교권자 임명 반영' }).click(); + await acceptAppConfirmation(page, '외교권자를 변경할까요?'); await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible(); await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: true, targetGeneralIds: [7, 6] }); @@ -409,11 +407,8 @@ test('leader can grant two ambassador and auditor permissions by click or touch await expect(auditorOptions.getByRole('option', { name: '허저' })).toHaveCount(0); await auditorOptions.getByRole('option', { name: '장료' }).click(); await expect(auditorTrigger).toHaveAccessibleName('조언자 선택, 현재 2명'); - page.once('dialog', async (dialog) => { - expect(dialog.message()).toBe('조언자를 변경할까요?'); - await dialog.accept(); - }); await page.getByRole('button', { name: '조언자 임명 반영' }).click(); + await acceptAppConfirmation(page, '조언자를 변경할까요?'); await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible(); await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: false, targetGeneralIds: [8, 6] }); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); @@ -441,11 +436,8 @@ test('personnel selects an informed general and reports the JosaUtil-composed re await candidate.focus(); expect(await candidate.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none'); await screenshot(page, 'core-personnel-desktop-general-picker.png'); - page.once('dialog', async (dialog) => { - expect(dialog.message()).toBe('장료를 주부직에 임명하시겠습니까?'); - await dialog.accept(); - }); await candidate.click(); + await acceptAppConfirmation(page, '장료를 주부직에 임명하시겠습니까?'); await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.'); expect(state.appointedGeneralId).toBe(6); @@ -531,11 +523,8 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie await picker.getByRole('button', { name: /장료/ }).evaluate((button) => getComputedStyle(button).outlineStyle) ).not.toBe('none'); await screenshot(page, 'core-personnel-mobile-city-picker.png'); - page.once('dialog', async (dialog) => { - expect(dialog.message()).toBe('장료를 허창 태수직에 임명하시겠습니까?'); - await dialog.accept(); - }); await picker.getByRole('button', { name: /장료/ }).click(); + await acceptAppConfirmation(page, '장료를 허창 태수직에 임명하시겠습니까?'); await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.'); expect(state.appointedGeneralId).toBe(6); expect(state.appointedCityId).toBe(1); @@ -572,6 +561,29 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie await screenshot(page, 'core-personnel-mobile-rows.png'); }); +test('@ios-webkit iPhone touch appoints a city officer after the picker closes', async ({ browser }, testInfo) => { + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') throw new Error('Playwright baseURL is required'); + const context = await browser.newContext({ ...devices['iPhone 15'], baseURL: configuredBaseUrl }); + const page = await context.newPage(); + const state: FixtureState = { role: 'head', rate: 20 }; + try { + await installFixture(page, state); + await gotoOffice(page, 'nation/personnel'); + await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click(); + const picker = page.getByTestId('personnel-selection-dialog'); + await picker.getByRole('button', { name: /장료/ }).click(); + await expect(picker).toBeHidden(); + await acceptAppConfirmation(page, '장료를 허창 태수직에 임명하시겠습니까?'); + await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.'); + expect(state.appointedGeneralId).toBe(6); + expect(state.appointedCityId).toBe(1); + expect(state.appointedOfficerLevel).toBe(4); + } finally { + await context.close(); + } +}); + test('personnel hides every mutation control for an ordinary member and exposes load errors', async ({ page }) => { await installFixture(page, { role: 'member', rate: 20 }); await gotoOffice(page, 'nation/personnel'); diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index 60bbe314..7e6d275b 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -1,15 +1,18 @@ -import { expect, test, type Page, type Route } from '@playwright/test'; +import { devices, expect, test, type Page, type Route } from '@playwright/test'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { touchDrag } from './touchDrag.js'; +import { acceptAppConfirmation } from './appConfirmation.js'; type FixtureState = { permissionLevel: number; failNextMutation?: boolean; failLoad?: boolean; mutations: string[]; + nationPriorityInput?: string[]; + currentNationPriority?: string[]; }; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); @@ -124,7 +127,7 @@ const policyFixture = (state: FixtureState) => ({ reqNPCDevelGold: 540, }, defaultNationPriority: nationPriority, - currentNationPriority: nationPriority, + currentNationPriority: state.currentNationPriority ?? nationPriority, availableNationPriorityItems: nationPriority, defaultGeneralActionPriority: generalPriority, currentGeneralActionPriority: generalPriority, @@ -155,7 +158,14 @@ const installFixture = async (page: Page, state: FixtureState) => { } await page.route(gameTrpcRoute, async (route) => { const operations = operationName(route).split(','); - const results = operations.map((operation) => { + const rawBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; + const requestBody = rawBody && typeof rawBody === 'object' ? (rawBody as Record) : {}; + const results = operations.map((operation, operationIndex) => { + const rawPayload = requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : {}); + const payload = rawPayload && typeof rawPayload === 'object' ? (rawPayload as Record) : {}; + const jsonInput = Array.isArray(rawPayload) + ? rawPayload + : (payload.json ?? (payload.input as { json?: unknown } | undefined)?.json); if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: { id: 22, name: '정책담당' } }); if (operation === 'join.getConfig') return response({}); @@ -170,6 +180,9 @@ const installFixture = async (page: Page, state: FixtureState) => { operation === 'npc.setGeneralPriority' ) { state.mutations.push(operation); + if (operation === 'npc.setNationPriority' && Array.isArray(jsonInput)) { + state.nationPriorityInput = jsonInput.map(String); + } if (state.failNextMutation) { state.failNextMutation = false; return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN'); @@ -280,8 +293,8 @@ test('desktop geometry, typography, textures, drag, focus, tooltip, and successf ).toBeVisible(); await goldInput.fill('12345'); - page.once('dialog', (dialog) => dialog.accept()); await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); + await acceptAppConfirmation(page, '저장할까요?'); await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( 'NPC 정책이 반영되었습니다.' ); @@ -289,6 +302,29 @@ test('desktop geometry, typography, textures, drag, focus, tooltip, and successf await screenshot(page, 'core-npc-policy-desktop.png'); }); +test('@ios-webkit iPhone touch saves the reordered NPC priority', async ({ browser }, testInfo) => { + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') throw new Error('Playwright baseURL is required'); + const context = await browser.newContext({ ...devices['iPhone 15'], baseURL: configuredBaseUrl }); + const page = await context.newPage(); + const reorderedPriority = [nationPriority[1]!, nationPriority[0]!, ...nationPriority.slice(2)]; + const state: FixtureState = { permissionLevel: 4, mutations: [], currentNationPriority: reorderedPriority }; + try { + await installFixture(page, state); + await gotoPolicy(page); + const panel = page.locator('.priority-panel').first(); + const activeList = panel.locator('.priority-column').nth(1).locator('.priority-list'); + await expect(activeList.locator('.priority_info > span:nth-child(2)').first()).toHaveText('선전포고'); + await panel.getByRole('button', { name: '설정' }).click(); + await acceptAppConfirmation(page, '저장할까요?'); + await expect(page.getByTestId('game-toast')).toContainText('NPC 정책이 반영되었습니다.'); + expect(state.nationPriorityInput?.[0]).toBe('선전포고'); + expect(state.nationPriorityInput).toContain('불가침제의'); + } finally { + await context.close(); + } +}); + test('500px layout stacks policy fields and priority panels like the reference', async ({ page }) => { await installFixture(page, { permissionLevel: 4, mutations: [] }); await page.setViewportSize({ width: 500, height: 900 }); @@ -346,19 +382,9 @@ test('physical mobile touch reorders NPC priority across active and inactive lis const nationPanel = mobilePage.locator('.priority-panel').first(); const activeList = nationPanel.locator('.priority-column').nth(1).locator('.priority-list'); const activeRows = activeList.locator('.priority-item'); - await touchDrag( - mobilePage, - activeRows.nth(0), - activeRows.nth(3), - { targetYRatio: 0.9 } - ); + await touchDrag(mobilePage, activeRows.nth(0), activeRows.nth(3), { targetYRatio: 0.9 }); await expect - .poll(() => - activeList - .locator('.priority-item .priority_info > span:nth-child(2)') - .first() - .textContent() - ) + .poll(() => activeList.locator('.priority-item .priority_info > span:nth-child(2)').first().textContent()) .toBe('선전포고'); const activeItem = activeList.getByText('불가침제의', { exact: true }); @@ -366,9 +392,7 @@ test('physical mobile touch reorders NPC priority across active and inactive lis await touchDrag(mobilePage, activeItem, inactiveList.locator('.inactive-header')); await expect(inactiveList.getByText('불가침제의', { exact: true })).toBeVisible(); - await expect( - activeList.getByText('불가침제의', { exact: true }) - ).toHaveCount(0); + await expect(activeList.getByText('불가침제의', { exact: true })).toHaveCount(0); await mobilePage.screenshot({ path: testInfo.outputPath('npc-priority-mobile-touch.png'), fullPage: true }); } finally { await context.close(); diff --git a/app/game-frontend/e2e/troop.spec.ts b/app/game-frontend/e2e/troop.spec.ts index 6a1ff378..96ac987b 100644 --- a/app/game-frontend/e2e/troop.spec.ts +++ b/app/game-frontend/e2e/troop.spec.ts @@ -1,8 +1,9 @@ -import { expect, test, type Page, type Route } from '@playwright/test'; +import { devices, expect, test, type Page, type Route } from '@playwright/test'; import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; +import { acceptAppConfirmation } from './appConfirmation.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const imageRoots = [ @@ -348,6 +349,11 @@ const installApiFixture = async (page: Page, state: FixtureState) => { state.troops[0]!.members = state.troops[0]!.members.filter((member) => member.id !== 3); return response({ ok: true }); } + if (operation === 'troop.exit') { + state.me.troopId = 0; + state.troops = state.troops.filter((troop) => troop.id !== state.me.id); + return response({ ok: true }); + } return errorResponse(operation, `Unhandled fixture operation: ${operation}`); }); await fulfillJson(route, results); @@ -555,19 +561,19 @@ test('shows API failure then creates a troop successfully', async ({ page }) => await expect(input).toBeHidden(); }); -test('renames and kicks through confirm dialogs, then refreshes state', async ({ page }) => { +test('renames and kicks through app confirmation dialogs, then refreshes state', async ({ page }) => { const state: FixtureState = { me: { id: 1, troopId: 1 }, permission: 4, troops: baseTroops(), }; await installApiFixture(page, state); - page.on('dialog', (dialog) => dialog.accept()); await gotoTroop(page); await page.getByRole('button', { name: '부대명 변경...' }).first().click(); await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종'); await page.getByRole('button', { name: '변경', exact: true }).click(); + await acceptAppConfirmation(page, '백마대 부대의 이름을 백마의종으로 바꾸시겠습니까?'); await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText( '부대명을 변경했습니다.' ); @@ -576,12 +582,44 @@ test('renames and kicks through confirm dialogs, then refreshes state', async ({ await page.getByRole('button', { name: '부대원 추방...' }).click(); await page.getByRole('combobox', { name: '추방할 부대원' }).selectOption('3'); await page.getByRole('button', { name: '추방', exact: true }).click(); + await acceptAppConfirmation(page, '백마의종 부대에서 조운을 추방하시겠습니까?'); await expect( page.locator('[data-testid="game-toast"][data-feedback-kind="success"]').filter({ hasText: '조운' }) ).toContainText('조운을 추방했습니다.'); await expect(page.locator('.troopMembers').first()).not.toContainText('조운'); }); +test('@ios-webkit iPhone touch renames and disbands a troop through app confirmations', async ({ + browser, +}, testInfo) => { + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') throw new Error('Playwright baseURL is required'); + const context = await browser.newContext({ ...devices['iPhone 15'], baseURL: configuredBaseUrl }); + const page = await context.newPage(); + const state: FixtureState = { + me: { id: 1, troopId: 1 }, + permission: 4, + troops: baseTroops(), + }; + try { + await installApiFixture(page, state); + await gotoTroop(page); + await page.getByRole('button', { name: '부대명 변경...' }).first().click(); + await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종'); + await page.getByRole('button', { name: '변경', exact: true }).click(); + await acceptAppConfirmation(page, '백마대 부대의 이름을 백마의종으로 바꾸시겠습니까?'); + await expect(page.locator('.troopInfo').filter({ hasText: '백마의종' })).toBeVisible(); + + await page.getByRole('button', { name: '부대 해산' }).click(); + await acceptAppConfirmation(page, '백마의종 부대를 해산하겠습니까?'); + await expect(page.getByTestId('game-toast').filter({ hasText: '부대를 해산했습니다.' })).toBeVisible(); + expect(state.me.troopId).toBe(0); + await expect(page.locator('[data-troop-id="1"]')).toHaveCount(0); + } finally { + await context.close(); + } +}); + test('does not render management controls for an unauthorized member', async ({ page }) => { await installApiFixture(page, { me: { id: 3, troopId: 1 }, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index f8913320..1e912344 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -14,6 +14,7 @@ "test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs", "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:ios-webkit": "playwright test nationOffices.spec.ts troop.spec.ts npcPolicy.spec.ts --config e2e/playwright.config.mjs --browser=webkit --grep @ios-webkit", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", "test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs", "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", diff --git a/app/game-frontend/src/components/ui/GameFeedbackLayer.vue b/app/game-frontend/src/components/ui/GameFeedbackLayer.vue index ade8a88c..bc8e3248 100644 --- a/app/game-frontend/src/components/ui/GameFeedbackLayer.vue +++ b/app/game-frontend/src/components/ui/GameFeedbackLayer.vue @@ -2,9 +2,10 @@ import { nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { useGameFeedback, type GameFeedbackKind } from '../../composables/useGameFeedback'; -const { toasts, dialog, dismissToast, acknowledgeDialog } = useGameFeedback(); +const { toasts, dialog, dismissToast, acknowledgeDialog, cancelDialog } = useGameFeedback(); const dialogPanel = ref(null); const acknowledgeButton = ref(null); +const cancelButton = ref(null); let returnFocus: HTMLElement | null = null; let previousBodyOverflow = ''; @@ -37,7 +38,7 @@ watch( } if (next) { await nextTick(); - acknowledgeButton.value?.focus(); + (next.cancelLabel ? cancelButton.value : acknowledgeButton.value)?.focus(); return; } if (previous) restorePage(); @@ -48,7 +49,8 @@ watch( const handleDialogKeydown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { event.preventDefault(); - acknowledgeDialog(); + if (dialog.value?.cancelLabel) cancelDialog(); + else acknowledgeDialog(); return; } if (event.key !== 'Tab' || !dialogPanel.value) return; @@ -119,6 +121,15 @@ onBeforeUnmount(() => {

{{ dialog.message }}