From 854a91c397a130c3e2e3aebbba6a283a0b41779f Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 14 Sep 2026 23:49:30 +0000 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20=ED=83=AD=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=EC=99=80=20=EB=B3=B5=EA=B7=80=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-frontend/e2e/board.spec.ts | 19 +- app/game-frontend/e2e/directoryLists.spec.ts | 2 +- .../e2e/legacyArchiveViews.spec.ts | 2 + app/game-frontend/e2e/mainNavigation.spec.ts | 254 ++++++++++++++++++ .../e2e/tournamentBracket.spec.ts | 28 +- .../tournament/TournamentPageHeader.vue | 10 +- .../src/composables/usePageExit.ts | 16 ++ app/game-frontend/src/main.ts | 2 + .../src/utils/auxiliaryNavigation.ts | 47 ++++ app/game-frontend/src/views/AuctionView.vue | 14 +- .../src/views/BattleCenterView.vue | 36 +-- .../src/views/BattleSimulatorView.vue | 6 + .../src/views/BestGeneralView.vue | 18 +- app/game-frontend/src/views/BettingView.vue | 12 +- app/game-frontend/src/views/BoardView.vue | 23 +- .../src/views/ChiefCenterView.vue | 14 +- .../src/views/CurrentCityView.vue | 12 +- app/game-frontend/src/views/DiplomacyView.vue | 14 +- .../src/views/DynastyDetailView.vue | 21 +- .../src/views/DynastyListView.vue | 21 +- .../src/views/GeneralListView.vue | 12 +- .../src/views/GlobalInfoView.vue | 15 +- .../src/views/HallOfFameView.vue | 18 +- app/game-frontend/src/views/InheritView.vue | 12 +- app/game-frontend/src/views/MyPageView.vue | 8 +- .../src/views/MySettingsView.vue | 8 +- .../src/views/NationBettingView.vue | 24 +- .../src/views/NationCitiesView.vue | 14 +- .../src/views/NationGeneralsView.vue | 10 +- .../src/views/NationInfoView.vue | 14 +- .../src/views/NationListView.vue | 13 +- .../src/views/NationPersonnelView.vue | 18 +- .../src/views/NationSecretView.vue | 13 +- .../src/views/NationStratFinanView.vue | 16 +- .../src/views/NpcControlView.vue | 12 +- app/game-frontend/src/views/NpcListView.vue | 11 +- app/game-frontend/src/views/PastPlaysView.vue | 6 +- .../src/views/SelectGeneralView.vue | 15 +- app/game-frontend/src/views/SurveyView.vue | 13 +- .../src/views/TournamentView.vue | 12 +- app/game-frontend/src/views/TrafficView.vue | 12 +- app/game-frontend/src/views/TroopView.vue | 14 +- app/game-frontend/src/views/YearbookView.vue | 23 +- .../test/legacyButtonFamily.test.ts | 6 +- docs/frontend-page-navigation.md | 67 +++++ 45 files changed, 720 insertions(+), 237 deletions(-) create mode 100644 app/game-frontend/src/composables/usePageExit.ts create mode 100644 app/game-frontend/src/utils/auxiliaryNavigation.ts create mode 100644 docs/frontend-page-navigation.md diff --git a/app/game-frontend/e2e/board.spec.ts b/app/game-frontend/e2e/board.spec.ts index 40319f20..f92b336f 100644 --- a/app/game-frontend/e2e/board.spec.ts +++ b/app/game-frontend/e2e/board.spec.ts @@ -209,6 +209,22 @@ const installApi = async (page: Page, state: BoardFixture) => { }); return response({ id: 22 }); } + if (operation === 'dashboard.getContextBundleDelta') { + const snapshot = (data: unknown, revision: string) => ({ + kind: 'snapshot', + revision, + sourceRevision: revision, + data, + }); + return response({ + context: snapshot(generalContext, 'board-context'), + commandTable: snapshot({ general: [], nation: [] }, 'board-commands'), + boardAccess: snapshot( + { permission: state.permission, canMeeting: state.canMeeting, canSecret: state.canSecret }, + 'board-access' + ), + }); + } if (operation === 'general.me') return response(generalContext); if (operation === 'world.getMapLayout') { return response({ mapName: 'che', cityList: [], regionMap: {}, levelMap: {} }); @@ -498,7 +514,8 @@ test('denies direct secret-room rendering and disables its in-game menu for an o expect(state.requests).not.toContain('board.getArticles'); state.requests.length = 0; - await page.goto(''); + await page.getByRole('button', { name: '돌아가기', exact: true }).click(); + await expect(page).toHaveURL(new URL(gamePath('/'), page.url()).href); const nationMenu = page.locator('.main-nation-menu').first(); await expect(nationMenu.locator('[data-navigation-id="meeting"]')).toHaveAttribute('href', gamePath('/board')); await expect(nationMenu.locator('[data-navigation-id="secret-board"]')).toHaveAttribute('aria-disabled', 'true'); diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index 1587e273..7983a16b 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -807,7 +807,7 @@ test('nation directory reuses only the public general-directory row on hover and expect(await foreignTrigger.getAttribute('aria-expanded')).toBe('true'); expect(await foreignTrigger.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('dashed'); - await page.getByRole('button', { name: '창 닫기' }).first().focus(); + await page.getByRole('button', { name: '돌아가기' }).first().focus(); await expect(preview).toHaveCount(0); if (artifactRoot) { diff --git a/app/game-frontend/e2e/legacyArchiveViews.spec.ts b/app/game-frontend/e2e/legacyArchiveViews.spec.ts index 5a0750f8..f7fbeb2a 100644 --- a/app/game-frontend/e2e/legacyArchiveViews.spec.ts +++ b/app/game-frontend/e2e/legacyArchiveViews.spec.ts @@ -273,6 +273,8 @@ test('왕조 일람과 상세는 현재 profile의 이전 서버 기록만 조 expect(state.dynastyRequests.some((request) => request.includes('legacy'))).toBe(true); expect(state.dynastyRequests.every((request) => !request.includes('sourceProfile'))).toBe(true); await page.screenshot({ path: testInfo.outputPath('dynasty-detail-profile-scope-desktop.png'), fullPage: true }); + await page.getByRole('button', { name: '돌아가기', exact: true }).last().click(); + await expect(page).toHaveURL(new URL('./', testInfo.project.use.baseURL as string).href); }); test('연감 국가 라벨은 밝은 배경에 검정, 어두운 배경에 흰 글자를 사용한다', async ({ page }, testInfo) => { diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index ffc350d0..c5ed1b44 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -6,6 +6,9 @@ import { buildEquipmentTradeItemOptions, loadEquipmentTradeItemOrder } from '../ import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js'; import { ITEM_KEYS, loadItemModules } from '@sammo-ts/logic/items/index.js'; +// 브라우저 보조키로 생성한 탭까지 검증하므로 경량 headless shell 대신 전체 Chromium을 사용한다. +test.use({ channel: 'chromium' }); + const response = (data: unknown) => ({ result: { data } }); const runtimeNavigation = JSON.parse( await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8') @@ -29,6 +32,7 @@ const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); type NavigationFixture = { + pageExitAudit?: boolean; decoratedNpcChiefs?: boolean; officerLevel: number; permission: number; @@ -567,6 +571,7 @@ const generalContext = (state: NavigationFixture) => ({ const installFixture = async (page: Page | BrowserContext, state: NavigationFixture) => { await page.addInitScript( ({ profile }) => { + if (!window.location.protocol.startsWith('http')) return; localStorage.setItem('sammo-game-token', 'ga_navigation'); localStorage.setItem('sammo-game-profile', profile); }, @@ -838,6 +843,12 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt winnerId: state.tournamentWinnerId, }); } + if (state.pageExitAudit) return errorResponse(operation, 'Navigation audit fixture: data unavailable'); + if (operation === 'tournament.getRankings') return response([]); + if (operation === 'tournament.getSnapshot') return response({ state: null, participants: [], matches: [] }); + if (operation === 'tournament.getBettingSummary') + return response({ totals: {}, myTotals: {}, totalAmount: 0, myAmount: 0 }); + if (operation === 'tournament.getAdminStatus') return response({ ok: false }); return response({ ok: true }); }); operations.forEach((operation, index) => { @@ -6881,3 +6892,246 @@ test('NPC prefixes stay single in the main nation card', async ({ page }, testIn await card.screenshot({ path: testInfo.outputPath(`npc-main-prefix-${width}.png`) }); } }); + +test.describe('page exit navigation', () => { + for (const width of [1000, 500]) { + test(`page exit policy distinguishes ordinary, modifier, copied and same-tab links at ${width}`, async ({ + page, + context, + }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + await installFixture(context, { + officerLevel: 12, + permission: 4, + nationLevel: 1, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }); + await waitForMain(page); + const mainUrl = page.url(); + const survey = page.locator('[data-menu-position="top"] [data-navigation-id="survey"]'); + const href = await survey.getAttribute('href'); + for (const method of ['control', 'middle', 'copy'] as const) { + const created = context.waitForEvent('page'); + if (method === 'copy') { + const copied = await context.newPage(); + await copied.goto(href!); + } else { + await survey.click(method === 'control' ? { modifiers: ['Control'] } : { button: 'middle' }); + } + const tab = await created; + await tab.bringToFront(); + await tab.waitForLoadState('domcontentloaded'); + await expect(tab).toHaveURL(`${basePath}/survey`); + await expect(tab.locator('.back_bar .back_btn')).toHaveText('돌아가기'); + await tab.reload(); + await expect(tab.locator('.bottom_bar .back_btn')).toHaveText('돌아가기'); + expect(context.pages()).toHaveLength(2); + await tab.locator('.bottom_bar .back_btn').click(); + await expect(tab).toHaveURL(mainUrl); + expect(context.pages()).toHaveLength(2); + await tab.close(); + } + // The status title intentionally uses the current tab, unlike its menu entry. + await page.locator('.tournament-status a').click(); + await expect(page).toHaveURL(`${basePath}/tournament`); + expect(context.pages()).toHaveLength(1); + await expect(page.getByRole('button', { name: '돌아가기', exact: true }).first()).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath(`same-tab-${width}.png`), fullPage: true }); + await page.getByRole('button', { name: '돌아가기', exact: true }).last().click(); + await expect(page).toHaveURL(mainUrl); + expect(context.pages()).toHaveLength(1); + + // Retain the auxiliary identity through SPA navigation and a reload. + const opened = page.waitForEvent('popup'); + await page.locator('[data-navigation-id="tournament"]').click(); + const tab = await opened; + await expect(tab.getByRole('button', { name: '창 닫기', exact: true }).first()).toBeVisible(); + await tab.getByRole('tab', { name: '베팅장', exact: true }).click(); + await expect(tab).toHaveURL(`${basePath}/betting`); + await tab.reload(); + await expect(tab.getByRole('button', { name: '창 닫기', exact: true }).last()).toBeVisible(); + const closed = tab.waitForEvent('close'); + await tab + .getByRole('button', { name: '창 닫기', exact: true }) + .last() + .click() + .catch((error: unknown) => { + if (!tab.isClosed()) throw error; + }); + await closed; + expect(context.pages()).toHaveLength(1); + expect(page.url()).toBe(mainUrl); + }); + } + + const exitAuditEntries = [ + 'nation-betting', + 'nation-list', + 'general-list', + 'best-general', + 'yearbook', + 'battle-simulator', + 'hall-of-fame', + 'dynasty', + 'traffic', + 'npc-list', + 'survey', + 'meeting', + 'secret-board', + 'troop', + 'diplomacy', + 'personnel', + 'finance', + 'chief-center', + 'npc-control', + 'nation-secret', + 'tournament', + 'tournament-menu', + 'betting', + 'nation-info', + 'nation-cities', + 'nation-generals', + 'global-info', + 'current-city', + 'battle-center', + 'inherit', + 'my-page', + 'auction-resource', + 'auction-resource-menu', + 'auction-unique', + 'my-settings', + ]; + for (const width of [1000, 500]) { + test(`all main page exits obey tab policy even when page data fails at ${width}`, async ({ + page, + context, + }, testInfo) => { + test.setTimeout(180_000); + await page.setViewportSize({ width, height: 900 }); + await installFixture(context, { + pageExitAudit: true, + officerLevel: 12, + permission: 4, + nationLevel: 1, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }); + await waitForMain(page); + const mainUrl = page.url(); + const evidence: unknown[] = []; + for (const id of exitAuditEntries) { + let link = page.locator(`[data-navigation-id="${id}"]`).first(); + if (!(await link.isVisible())) { + const group = ['tournament-menu', 'betting'].includes(id) + ? 'tournament-betting' + : ['auction-resource-menu', 'auction-unique'].includes(id) + ? 'auction' + : 'game-info'; + await page.locator(`[data-menu-id="${group}"]`).first().click(); + } + link = page.locator(`[data-navigation-id="${id}"]`).first(); + const href = await link.getAttribute('href'); + const newTab = (await link.getAttribute('target')) === '_blank'; + const opened = newTab ? page.waitForEvent('popup') : null; + await link.click(); + const target = opened ? await opened : page; + await target.setViewportSize({ width, height: 900 }); + await target.waitForURL(new URL(href!, mainUrl).href); + const exit = target.getByRole('button', { name: newTab ? '창 닫기' : '돌아가기', exact: true }).first(); + await expect(exit, id).toBeVisible(); + expect(context.pages(), id).toHaveLength(newTab ? 2 : 1); + await target.evaluate(() => document.fonts.ready); + await exit.focus(); + await exit.hover(); + evidence.push({ + id, + href, + newTab, + width, + ...(await exit.evaluate((el) => ({ + viewportWidth: window.innerWidth, + rect: el.getBoundingClientRect().toJSON(), + font: getComputedStyle(el).font, + border: getComputedStyle(el).border, + text: el.textContent, + }))), + }); + await target.screenshot({ path: testInfo.outputPath(`${id}-${width}.png`), fullPage: true }); + await writeFile( + testInfo.outputPath(`${id}-${width}.html`), + await target.locator('#app').evaluate((el) => el.outerHTML) + ); + if (newTab) { + const closed = target.waitForEvent('close'); + await exit.click().catch((error: unknown) => { + if (!target.isClosed()) throw error; + }); + await closed; + // Opening the original href from the browser does not inherit auxiliary identity. + const direct = await context.newPage(); + await direct.setViewportSize({ width, height: 900 }); + await direct.goto(href!); + const back = direct.getByRole('button', { name: '돌아가기', exact: true }).first(); + await expect(back, id).toBeVisible(); + await back.click(); + await expect(direct).toHaveURL(mainUrl); + expect(context.pages()).toHaveLength(2); + await direct.close(); + } else { + await exit.click(); + await expect(page).toHaveURL(mainUrl); + } + expect(context.pages(), id).toHaveLength(1); + expect(page.url(), id).toBe(mainUrl); + } + await writeFile(testInfo.outputPath(`exit-audit-${width}.json`), JSON.stringify(evidence, null, 2)); + }); + } + + test('keyboard activation opens a closable tab and blocked popups use the current tab', async ({ + page, + context, + }) => { + await installFixture(context, { + officerLevel: 0, + permission: 0, + nationLevel: 0, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }); + await waitForMain(page); + const mainUrl = page.url(); + const link = page.locator('[data-menu-position="top"] [data-navigation-id="survey"]'); + await link.focus(); + const opened = page.waitForEvent('popup'); + await link.press('Enter'); + const tab = await opened; + await expect(tab.getByRole('button', { name: '창 닫기', exact: true }).first()).toBeVisible(); + expect(await tab.evaluate(() => document.referrer)).toBe(''); + const closed = tab.waitForEvent('close'); + await tab + .getByRole('button', { name: '창 닫기', exact: true }) + .first() + .click() + .catch((error: unknown) => { + if (!tab.isClosed()) throw error; + }); + await closed; + expect(context.pages()).toHaveLength(1); + await page.evaluate(() => { + window.open = () => null; + }); + await link.click(); + await expect(page).toHaveURL(`${basePath}/survey`); + await page.getByRole('button', { name: '돌아가기', exact: true }).first().click(); + await expect(page).toHaveURL(mainUrl); + expect(context.pages()).toHaveLength(1); + }); +}); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index ac53b4f8..65c0c7b5 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -536,7 +536,7 @@ test('join refresh shows the assigned preliminary group immediately with accessi const refresh = page.getByRole('button', { name: '갱신' }); const join = page.getByRole('button', { name: '참가' }); - const close = page.getByRole('button', { name: '창 닫기' }).first(); + const close = page.getByRole('button', { name: '돌아가기' }).first(); await expect(join).toBeEnabled(); await expect(page.getByText('조별 예선 순위')).toBeVisible(); await expect(page.getByText('조별 본선 순위')).toHaveCount(0); @@ -797,7 +797,7 @@ test('tournament and betting pages expose same-row navigation tabs beside close' const navigation = page.getByRole('tablist', { name: '토너먼트와 베팅장 이동' }); const tournamentTab = navigation.getByRole('tab', { name: '토너먼트' }); const bettingTab = navigation.getByRole('tab', { name: '베팅장' }); - const close = page.getByRole('button', { name: '창 닫기' }).first(); + const close = page.getByRole('button', { name: '돌아가기' }).first(); await expect(tournamentTab).toHaveAttribute('aria-selected', 'true'); const headerCenters = await Promise.all( @@ -945,29 +945,7 @@ test('betting realtime refresh is shared across tabs and preserves local interac await expect(follower.locator('.mobile-bracket').getByLabel('관우 베팅 금액', { exact: true })).toHaveValue('50'); }); -test('tournament and betting close only their script-opened popup window', async ({ page }, testInfo) => { - const baseURL = testInfo.project.use.baseURL; - expect(typeof baseURL).toBe('string'); - await page.goto('about:blank'); - - for (const route of ['tournament', 'betting'] as const) { - const popupPromise = page.waitForEvent('popup'); - await page.evaluate(() => window.open('about:blank', '_blank', 'noopener')); - const popup = await popupPromise; - await installFixture(popup, { tournamentStage: route === 'betting' ? 6 : 1 }); - await popup.goto(new URL(route, baseURL as string).href); - - await expect(popup.getByRole('button', { name: '창 닫기' }).first()).toBeVisible(); - expect(await popup.evaluate(() => window.opener)).toBeNull(); - - const closed = popup.waitForEvent('close'); - await popup.getByRole('button', { name: '창 닫기' }).first().click(); - await closed; - - expect(page.isClosed()).toBe(false); - expect(page.url()).toBe('about:blank'); - } -}); +// Actual main-link popup closure and tab counts are covered in mainNavigation.spec.ts. test('betting bracket shows intelligence for debate tournament candidates', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); diff --git a/app/game-frontend/src/components/tournament/TournamentPageHeader.vue b/app/game-frontend/src/components/tournament/TournamentPageHeader.vue index 6700d052..f5bfaa0f 100644 --- a/app/game-frontend/src/components/tournament/TournamentPageHeader.vue +++ b/app/game-frontend/src/components/tournament/TournamentPageHeader.vue @@ -1,10 +1,12 @@