529 lines
22 KiB
TypeScript
529 lines
22 KiB
TypeScript
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 artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
|
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[];
|
|
};
|
|
|
|
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: '메뉴검증장수',
|
|
nationId: 1,
|
|
cityId: 1,
|
|
troopId: 0,
|
|
npcState: 0,
|
|
officerLevel: state.officerLevel,
|
|
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,
|
|
items: { horse: null, weapon: null, book: null, item: null },
|
|
},
|
|
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: 1_000,
|
|
defenceMax: 2_000,
|
|
wall: 1_000,
|
|
wallMax: 2_000,
|
|
supplyState: 1,
|
|
frontState: 0,
|
|
},
|
|
nation: {
|
|
id: 1,
|
|
name: '위',
|
|
color: '#008000',
|
|
level: state.nationLevel,
|
|
gold: 10_000,
|
|
rice: 20_000,
|
|
tech: 100,
|
|
rate: 20,
|
|
bill: 100,
|
|
capitalCityId: 1,
|
|
typeCode: 'che_유가',
|
|
},
|
|
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);
|
|
const results = operations.map((operation) => {
|
|
if (operation === 'lobby.info') {
|
|
return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, year: 185, month: 1, turnTerm: 10 });
|
|
}
|
|
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: 185,
|
|
month: 1,
|
|
cityList: [[1, 8, 0, 1, 1, 1]],
|
|
nationList: [[1, '위', '#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: [], revision: 0 });
|
|
}
|
|
if (operation === 'messages.getRecent') return response(emptyMessages(state.permission));
|
|
if (operation === 'messages.getContacts') return response({ nation: [] });
|
|
if (operation === 'general.getRecentRecords') {
|
|
return response({
|
|
global: [{ id: 3, text: '장수 동향 기록' }],
|
|
general: [{ id: 2, text: '개인 기록' }],
|
|
history: [{ id: 1, text: '중원 정세 기록' }],
|
|
});
|
|
}
|
|
if (operation === 'general.getFrontStatus') {
|
|
return response({
|
|
onlineUserCount: 1,
|
|
onlineNations: '위(1)',
|
|
onlineGenerals: '메뉴검증장수',
|
|
nationNotice: '<p>국가 방침</p>',
|
|
lastExecuted: null,
|
|
latestVote: { id: 9, title: '메뉴 설문', hasVoted: false },
|
|
});
|
|
}
|
|
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 });
|
|
});
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(operations.length === 1 ? results[0] : results),
|
|
});
|
|
});
|
|
};
|
|
|
|
const waitForMain = async (page: Page) => {
|
|
await page.goto('./');
|
|
await expect(page.getByRole('heading', { name: '전장 현황' })).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(4);
|
|
};
|
|
|
|
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'),
|
|
};
|
|
});
|
|
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,
|
|
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);
|
|
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"]');
|
|
await gameInfoButton.focus();
|
|
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('the 939/940 boundary and 500px bottom bar match the ref responsive contract', async ({ page }) => {
|
|
const state: NavigationFixture = {
|
|
officerLevel: 5,
|
|
permission: 2,
|
|
nationLevel: 3,
|
|
stage: 6,
|
|
npcMode: 1,
|
|
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 });
|
|
const bottomGeometry = await page.locator('.main-mobile-bottom').evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
x: rect.x,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
bottom: innerHeight - rect.bottom,
|
|
buttons: [...element.children].map((child) => child.getBoundingClientRect().width),
|
|
position: getComputedStyle(element).position,
|
|
zIndex: getComputedStyle(element).zIndex,
|
|
};
|
|
});
|
|
expect(bottomGeometry).toEqual({
|
|
x: 0,
|
|
width: 500,
|
|
height: 45,
|
|
bottom: 0,
|
|
buttons: [125, 125, 125, 125],
|
|
position: 'fixed',
|
|
zIndex: '99',
|
|
});
|
|
|
|
const externalTrigger = page.locator('[data-bottom-menu="global"]');
|
|
await externalTrigger.focus();
|
|
await externalTrigger.press('Enter');
|
|
await expect(externalTrigger).toHaveAttribute('aria-expanded', 'true');
|
|
const popupStyle = await page.locator('#mobile-global-menu').evaluate((element) => {
|
|
const style = getComputedStyle(element);
|
|
return {
|
|
columns: style.columnCount,
|
|
overflowY: style.overflowY,
|
|
maxHeight: style.maxHeight,
|
|
bottom: style.bottom,
|
|
};
|
|
});
|
|
expect(popupStyle.columns).toBe('3');
|
|
expect(popupStyle.overflowY).toBe('auto');
|
|
expect(popupStyle.maxHeight).toBe('850px');
|
|
expect(popupStyle.bottom).toBe('47px');
|
|
const globalPopup = page.locator('#mobile-global-menu');
|
|
await expect(globalPopup.getByText('게시판', { exact: true })).toHaveCount(1);
|
|
await expect(globalPopup.getByText('공식 오픈 톡', { exact: true })).toHaveCount(1);
|
|
await expect(globalPopup.getByText('게임정보', { exact: true })).toHaveCount(1);
|
|
await expect(globalPopup.getByText('기타 정보', { exact: true })).toHaveCount(1);
|
|
await persistArtifact(page, `${basePath.slice(1)}-mobile-global-open-500`);
|
|
await page.keyboard.press('Escape');
|
|
await expect(externalTrigger).toBeFocused();
|
|
|
|
await page.locator('[data-bottom-menu="nation"]').click();
|
|
const nationPopup = page.locator('#mobile-nation-menu');
|
|
await expect(nationPopup.getByText('금/쌀 경매장', { exact: true })).toHaveCount(1);
|
|
await expect(nationPopup.getByText('유니크 경매장', { exact: true })).toHaveCount(1);
|
|
await page.keyboard.press('Escape');
|
|
|
|
await page.locator('[data-bottom-menu="quick"]').click();
|
|
const quickPopup = page.locator('#mobile-quick-menu');
|
|
for (const heading of ['국가 정보', '동향 정보', '메시지']) {
|
|
await expect(quickPopup.locator('.bottom-heading').getByText(heading, { exact: true })).toHaveCount(1);
|
|
}
|
|
await expect(quickPopup.locator('.bottom-heading')).toHaveCount(3);
|
|
await expect(quickPopup.locator('.bottom-divider')).toHaveCount(3);
|
|
await persistArtifact(page, `${basePath.slice(1)}-mobile-quick-open-500`);
|
|
await page.keyboard.press('Escape');
|
|
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
|
|
});
|
|
|
|
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 quick navigation changes tabs before scrolling, 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 installFixture(page, state);
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
await waitForMain(page);
|
|
|
|
const cases = [
|
|
['policy', 'map', '[data-main-target="policy"]'],
|
|
['commands', 'commands', '[data-main-target="commands"]'],
|
|
['nation', 'status', '[data-main-target="nation"]'],
|
|
['general', 'status', '[data-main-target="general"]'],
|
|
['city', 'status', '[data-main-target="city"]'],
|
|
['map', 'map', '[data-main-target="map"]'],
|
|
['global-records', 'world', '[data-main-target="global-records"]'],
|
|
['general-records', 'world', '[data-main-target="general-records"]'],
|
|
['world-history', 'world', '[data-main-target="world-history"]'],
|
|
['public-message', 'messages', '[data-message-type="public"]'],
|
|
['national-message', 'messages', '[data-message-type="national"]'],
|
|
['private-message', 'messages', '[data-message-type="private"]'],
|
|
['diplomacy-message', 'messages', '[data-message-type="diplomacy"]'],
|
|
] as const;
|
|
|
|
for (const [id, tab, selector] of cases) {
|
|
await page.locator('[data-bottom-menu="quick"]').click();
|
|
await page.locator(`[data-quick-id="${id}"]`).click();
|
|
await expect(page.locator('.mobile-tabs button.active')).toHaveText(
|
|
{ map: '지도', commands: '명령', status: '상태', world: '동향', messages: '메시지' }[tab]
|
|
);
|
|
await expect(page.locator(selector)).toBeVisible();
|
|
const targetTop = await page.locator(selector).evaluate((element) => element.getBoundingClientRect().top);
|
|
expect(targetTop).toBeLessThan(855);
|
|
}
|
|
|
|
const callsBeforeRefresh = state.generalMeCalls;
|
|
await page.locator('[data-bottom-menu="refresh"]').click();
|
|
await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh);
|
|
|
|
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 page.locator('[data-bottom-menu="quick"]').click();
|
|
await Promise.all([page.waitForURL('**/gateway/'), page.getByRole('menuitem', { 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');
|
|
});
|