메인 연결 페이지의 탭 종료와 복귀 동작 통일

This commit is contained in:
2026-09-14 23:49:30 +00:00
parent 62f4ca3058
commit 854a91c397
45 changed files with 720 additions and 237 deletions
+18 -1
View File
@@ -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');
+1 -1
View File
@@ -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) {
@@ -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) => {
@@ -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);
});
});
@@ -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 });