Merge branch 'main' into feature/nation-general-lists

# Conflicts:
#	app/game-frontend/e2e/playwright.config.mjs
This commit is contained in:
2026-07-26 05:18:49 +00:00
35 changed files with 3928 additions and 1007 deletions
+192 -11
View File
@@ -1,6 +1,28 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const response = (data: unknown) => ({ result: { data } });
const artifactRoot = process.env.CITY_PARITY_ARTIFACT_DIR;
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
const readImage = async (relativePath: string): Promise<Buffer> => {
if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`);
for (const root of imageRoots) {
try {
return await readFile(resolve(root, relativePath));
} catch {
// Product checkout and feature worktrees have different image-root parents.
}
}
throw new Error(`Fixture image not found: ${relativePath}`);
};
const imageContentType = (relativePath: string) => {
if (relativePath.endsWith('.png')) return 'image/png';
if (relativePath.endsWith('.gif')) return 'image/gif';
return 'image/jpeg';
};
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const city = {
@@ -46,19 +68,68 @@ const layout = {
regionMap: { 1: '하북' },
levelMap: { 8: '특' },
};
const generalContext = {
general: {
id: 1,
name: '장수',
nationId: 1,
cityId: 1,
officerLevel: 1,
npcState: 0,
troopId: 0,
picture: null,
imageServer: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
gold: 1000,
rice: 1000,
crew: 500,
train: 90,
atmos: 90,
injury: 0,
experience: 0,
dedication: 0,
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
},
city,
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
settings: {},
penalties: {},
};
const emptyMessages = {
private: [],
national: [],
public: [],
diplomacy: [],
sequence: -1,
hasMore: { private: false, national: false, public: false, diplomacy: false },
latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 },
canRespondDiplomacy: false,
};
const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_info');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/game/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
);
await page.route('**/image/**', async (route) => {
const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? '');
await route.fulfill({
status: 200,
contentType: imageContentType(relativePath),
body: await readImage(relativePath),
});
});
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMap') return response(map);
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral') return response([]);
if (operation === 'messages.getRecent') return response(emptyMessages);
if (operation === 'board.getAccess') return response({ canMeeting: false, canSecret: false });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'nation.getNationInfo')
return response({
nation: {
@@ -158,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
id: 1,
name: '업',
nationId: 1,
nationColor: '#008000',
level: 8,
region: 1,
population: mode === 'wanderer' ? null : 150000,
@@ -193,14 +265,30 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
intelligence: 50,
injury: 0,
officerLevel: 1,
leadershipBonus: 0,
defenceTrain: 80,
crewTypeId: 1,
crewTypeName: '보병',
crew: 500,
train: 90,
atmos: 90,
turns: ['징병'],
},
],
forceSummary: {
enemyCrew: 0,
enemyArmedGenerals: 0,
enemyGenerals: 0,
ownCrew: mode === 'wanderer' ? 0 : 500,
ownArmedGenerals: mode === 'wanderer' ? 0 : 1,
ownGenerals: mode === 'wanderer' ? 0 : 1,
ready90Crew: mode === 'wanderer' ? 0 : 500,
ready90Generals: mode === 'wanderer' ? 0 : 1,
ready60Crew: mode === 'wanderer' ? 0 : 500,
ready60Generals: mode === 'wanderer' ? 0 : 1,
defenceReadyCrew: mode === 'wanderer' ? 0 : 500,
defenceReadyGenerals: mode === 'wanderer' ? 0 : 1,
},
lastExecute: '2026-07-26',
});
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
@@ -215,11 +303,11 @@ const go = async (page: Page, path: string) => {
test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
for (const [path, selector] of [
['nation/info', '.legacy-info-page'],
['nation/cities', '.nation-cities-page'],
['global-info', '.global-page'],
['current-city', '.city-page'],
for (const [path, selector, fontSize, fontFamily, borderCollapse] of [
['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'],
['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'],
['global-info', '.global-page', '14px', 'Pretendard', 'collapse'],
['current-city', '.city-page', '16px', 'Times New Roman', 'separate'],
] as const) {
await go(page, path);
await expect(page.locator(selector)).toBeVisible();
@@ -230,14 +318,14 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p
});
expect(box.width).toBe(1000);
expect(box.x).toBe(100);
expect(box.fontSize).toBe('14px');
expect(box.fontFamily).toContain('Pretendard');
expect(box.fontSize).toBe(fontSize);
expect(box.fontFamily).toContain(fontFamily);
expect(
await page
.locator('table')
.first()
.evaluate((el) => getComputedStyle(el).borderCollapse)
).toBe('collapse');
).toBe(borderCollapse);
}
});
@@ -250,7 +338,100 @@ test('current-city hides values and general rows for a wandering user', async ({
test('current-city exposes own general details to a member and admin fixture', async ({ page }) => {
await install(page, 'admin');
await page.setViewportSize({ width: 1200, height: 900 });
await go(page, 'current-city');
await expect(page.locator('.generals')).toContainText('장수');
await expect(page.locator('.generals')).toContainText('90');
const legacyGeometry = await page.evaluate(() => {
const rect = (selector: string) => {
const box = document.querySelector(selector)?.getBoundingClientRect();
return box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null;
};
const icon = document.querySelector<HTMLImageElement>('.general-icon');
return {
selector: rect('#citySelector'),
stats: rect('.stats'),
generals: rect('.generals'),
titleAlign: getComputedStyle(document.querySelector('.city-page > table:first-child td')!).textAlign,
icon: icon
? {
...rect('.general-icon'),
naturalWidth: icon.naturalWidth,
naturalHeight: icon.naturalHeight,
}
: null,
};
});
expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 });
expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 });
expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 });
expect(legacyGeometry.titleAlign).toBe('start');
expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 });
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
const computedDom = await page.evaluate(() => {
const measure = (selector: string) => {
const element = document.querySelector(selector);
if (!element) return null;
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
style: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderCollapse: style.borderCollapse,
padding: style.padding,
textAlign: style.textAlign,
},
};
};
const icon = document.querySelector<HTMLImageElement>('.general-icon');
return {
body: measure('body'),
page: measure('.city-page'),
selector: measure('#citySelector'),
stats: measure('.stats'),
generals: measure('.generals'),
title: measure('.city-title'),
firstIcon: icon
? {
...measure('.general-icon'),
naturalWidth: icon.naturalWidth,
naturalHeight: icon.naturalHeight,
}
: null,
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
};
});
await writeFile(
resolve(artifactRoot, 'core-current-city-computed-dom.json'),
`${JSON.stringify(computedDom, null, 2)}\n`
);
await page.screenshot({
path: resolve(artifactRoot, 'core-current-city-desktop.png'),
fullPage: true,
animations: 'disabled',
});
}
});
test('a Chromium map click opens the clicked city route and keeps the legacy pointer interaction', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await go(page, '');
const cityLink = page.locator('.map-city').first();
await expect(cityLink).toBeVisible();
await cityLink.hover();
await expect(cityLink).toHaveCSS('cursor', 'pointer');
await cityLink.click();
await expect(page).toHaveURL(/\/che\/current-city\?cityId=1$/);
await expect(page.locator('.stats')).toContainText('업');
});
@@ -0,0 +1,150 @@
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const operations = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const general = {
id: 1,
name: '테스트장수',
npcState: 0,
officerLevel: 1,
cityId: 1,
cityName: null,
troopId: 0,
troopName: null,
officerCity: 0,
officerCityName: null,
stats: { leadership: 70, strength: 60, intelligence: 50 },
experienceLevel: 9,
dedicationLevel: 1,
injury: 0,
gold: 1000,
rice: 2000,
personality: null,
specialDomestic: null,
specialWar: null,
belong: 1,
refreshScoreTotal: 10,
permission: 'normal',
};
const install = async (page: Page, secretAllowed = true) => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_general');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/che/api/trpc/**', async (route) => {
const results = operations(route).map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '테스트장수' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'nation.getGeneralList')
return response({
nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 1, permission: 0 },
generals: [general],
});
if (operation === 'nation.getSecretGeneralList') {
if (!secretAllowed)
return {
error: {
message: '권한이 부족합니다.',
code: -32000,
data: { code: 'FORBIDDEN', httpStatus: 403, path: operation },
},
};
return response({
nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 1, permission: 1 },
summary: {
gold: 1000,
rice: 2000,
crew: 300,
generalCount: 1,
averageGold: 1000,
averageRice: 2000,
readiness: {
90: { crew: 300, generals: 1 },
80: { crew: 300, generals: 1 },
60: { crew: 300, generals: 1 },
},
},
generals: [
{
id: 1,
name: '테스트장수',
npcState: 0,
injury: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
leadershipBonus: 0,
experienceLevel: 9,
troopId: 0,
troopName: null,
gold: 1000,
rice: 2000,
cityId: 1,
cityName: '업',
defenceTrain: 90,
defenceTrainText: '☆',
crewTypeId: 1,
crew: 300,
train: 90,
atmos: 90,
killTurn: 7,
turnTime: '2026-01-01T01:02:00.000Z',
reservedCommands: ['징병', '훈련'],
},
],
});
}
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
};
test('nation generals keeps the 1000px legacy grid and redacted member columns', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/generals');
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
await expect(page.locator('#nation-general-list')).toContainText('?');
const computed = await page.locator('.general-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
});
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' });
expect(computed.fontFamily).toContain('Times New Roman');
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
'separate'
);
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030);
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66);
});
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 500, height: 900 });
for (const path of ['nation/generals', 'nation/secret']) {
await page.goto(path);
await expect(
page.locator(path.endsWith('secret') ? '#secret-general-list' : '#nation-general-list')
).toBeVisible();
expect(await page.locator('main').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
}
});
test('secret office renders summary, turns, and the forbidden error flow', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/secret');
await expect(page.locator('.summary')).toContainText('전체 금');
await expect(page.locator('#secret-general-list')).toContainText('1 : 징병');
expect(await page.locator('.secret-page').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
await page.unroute('**/che/api/trpc/**');
await install(page, false);
await page.goto('nation/secret');
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
await expect(page.locator('#secret-general-list')).toHaveCount(0);
});
@@ -14,6 +14,7 @@ export default defineConfig({
'inGameInfo.spec.ts',
'nationOffices.spec.ts',
'directoryLists.spec.ts',
'nationGeneralSecret.spec.ts',
],
fullyParallel: false,
workers: 1,