merge: add secure troop management
# Conflicts: # app/game-engine/src/turn/types.ts # app/game-engine/src/turn/worldLoader.ts
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: 'troop.spec.ts',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
},
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report') }]],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/troop'),
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:15120/che/',
|
||||
...devices['Desktop Chrome'],
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port 15120',
|
||||
cwd: repositoryRoot,
|
||||
url: 'http://127.0.0.1:15120/che/',
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import { 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';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
|
||||
|
||||
const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
||||
for (const imageRoot of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(imageRoot, filename));
|
||||
} catch {
|
||||
// The main checkout and nested feature worktrees have different parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Reference image not found: ${filename}`);
|
||||
};
|
||||
|
||||
type Member = { id: number; name: string; cityId: number; cityName: string };
|
||||
type TroopFixture = {
|
||||
id: number;
|
||||
name: string;
|
||||
nationId: number;
|
||||
turnTime: string;
|
||||
reservedCommands: string[];
|
||||
leader: {
|
||||
id: number;
|
||||
name: string;
|
||||
cityId: number;
|
||||
cityName: string;
|
||||
picture: string | null;
|
||||
imageServer: number;
|
||||
};
|
||||
members: Member[];
|
||||
};
|
||||
type FixtureState = {
|
||||
me: { id: number; troopId: number };
|
||||
permission: number;
|
||||
troops: TroopFixture[];
|
||||
failCreate?: boolean;
|
||||
};
|
||||
|
||||
const baseTroops = (): TroopFixture[] => [
|
||||
{
|
||||
id: 1,
|
||||
name: '백마대',
|
||||
nationId: 1,
|
||||
turnTime: '2026-07-25T08:20:30.000Z',
|
||||
reservedCommands: ['che_집합', 'che_이동'],
|
||||
leader: {
|
||||
id: 1,
|
||||
name: '공손찬',
|
||||
cityId: 1,
|
||||
cityName: '북평',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [
|
||||
{ id: 1, name: '공손찬', cityId: 1, cityName: '북평' },
|
||||
{ id: 3, name: '조운', cityId: 1, cityName: '북평' },
|
||||
{ id: 4, name: '전예', cityId: 2, cityName: '계' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '청룡대',
|
||||
nationId: 1,
|
||||
turnTime: '2026-07-25T08:30:30.000Z',
|
||||
reservedCommands: ['che_징병'],
|
||||
leader: {
|
||||
id: 2,
|
||||
name: '관우',
|
||||
cityId: 2,
|
||||
cityName: '계',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [{ id: 2, name: '관우', cityId: 2, cityName: '계' }],
|
||||
},
|
||||
];
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32000,
|
||||
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
|
||||
},
|
||||
});
|
||||
const operationName = (route: Route): string => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6));
|
||||
};
|
||||
|
||||
const fulfillJson = async (route: Route, body: unknown) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
const gotoTroop = async (page: Page) => {
|
||||
const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info'));
|
||||
await page.goto('troop');
|
||||
await lobbyResponse;
|
||||
};
|
||||
|
||||
const installApiFixture = async (page: Page, state: FixtureState) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_playwright');
|
||||
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
for (const filename of ['back_walnut.jpg', 'back_green.jpg']) {
|
||||
await page.route(`**/image/game/${filename}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
body: await readReferenceImage(filename),
|
||||
});
|
||||
});
|
||||
}
|
||||
await page.route('**/image/icons/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const operations = operationName(route).split(',');
|
||||
const results = operations.map((operation) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ myGeneral: { id: state.me.id, name: '테스트 장수' } });
|
||||
}
|
||||
if (operation === 'join.getConfig') {
|
||||
return response({});
|
||||
}
|
||||
if (operation === 'troop.getList') {
|
||||
return response({
|
||||
nation: { id: 1, name: '테스트국' },
|
||||
me: state.me,
|
||||
permission: state.permission,
|
||||
troops: state.troops,
|
||||
});
|
||||
}
|
||||
if (operation === 'troop.create') {
|
||||
if (state.failCreate) {
|
||||
state.failCreate = false;
|
||||
return errorResponse(operation, '부대 이름이 없습니다.');
|
||||
}
|
||||
const createdId = state.me.id;
|
||||
state.me.troopId = createdId;
|
||||
state.troops.push({
|
||||
id: createdId,
|
||||
name: '신규대',
|
||||
nationId: 1,
|
||||
turnTime: '2026-07-25T08:40:30.000Z',
|
||||
reservedCommands: [],
|
||||
leader: {
|
||||
id: createdId,
|
||||
name: '유비',
|
||||
cityId: 1,
|
||||
cityName: '북평',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [{ id: createdId, name: '유비', cityId: 1, cityName: '북평' }],
|
||||
});
|
||||
return response({ ok: true, troopId: createdId, troopName: '신규대' });
|
||||
}
|
||||
if (operation === 'troop.rename') {
|
||||
state.troops[0]!.name = '백마의종';
|
||||
return response({ ok: true, troopName: '백마의종' });
|
||||
}
|
||||
if (operation === 'troop.kick') {
|
||||
state.troops[0]!.members = state.troops[0]!.members.filter((member) => member.id !== 3);
|
||||
return response({ ok: true });
|
||||
}
|
||||
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||
});
|
||||
await fulfillJson(route, results);
|
||||
});
|
||||
};
|
||||
|
||||
test('renders the legacy desktop grid with matching computed geometry and states', async ({ page }) => {
|
||||
await installApiFixture(page, {
|
||||
me: { id: 1, troopId: 1 },
|
||||
permission: 4,
|
||||
troops: baseTroops(),
|
||||
});
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await gotoTroop(page);
|
||||
await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible();
|
||||
|
||||
const geometry = await page
|
||||
.locator('.troopItem')
|
||||
.first()
|
||||
.evaluate((item) => {
|
||||
const origin = item.getBoundingClientRect();
|
||||
const box = (selector: string) => {
|
||||
const rect = item.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
};
|
||||
const members = item.querySelector<HTMLElement>('.troopMembers')!;
|
||||
const style = getComputedStyle(members);
|
||||
return {
|
||||
item: { x: origin.x, y: origin.y, width: origin.width, height: origin.height },
|
||||
info: box('.troopInfo'),
|
||||
icon: box('.troopLeaderIcon'),
|
||||
reserved: box('.troopReservedCommand'),
|
||||
members: box('.troopMembers'),
|
||||
action: box('.troopAction'),
|
||||
membersStyle: {
|
||||
paddingTop: style.paddingTop,
|
||||
paddingLeft: style.paddingLeft,
|
||||
textAlign: style.textAlign,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
expect(geometry.item).toEqual({ x: 0, y: 32, width: 1000, height: 127.5 });
|
||||
expect(geometry.info.width).toBeCloseTo(130, 0);
|
||||
expect(geometry.info.height).toBeCloseTo(65, 0);
|
||||
expect(geometry.icon.x - geometry.info.x).toBeCloseTo(130, 0);
|
||||
expect(geometry.reserved.x - geometry.info.x).toBeCloseTo(260, 0);
|
||||
expect(geometry.members.x - geometry.info.x).toBeCloseTo(360, 0);
|
||||
expect(geometry.members.width).toBeCloseTo(639, 0);
|
||||
expect(geometry.members.height).toBeCloseTo(93, 0);
|
||||
expect(geometry.action.x - geometry.info.x).toBeCloseTo(65, 0);
|
||||
expect(geometry.action.y - geometry.info.y).toBeCloseTo(93, 0);
|
||||
expect(geometry.action.width).toBeCloseTo(934, 0);
|
||||
expect(geometry.membersStyle).toEqual({
|
||||
paddingTop: '7px',
|
||||
paddingLeft: '9.8px',
|
||||
textAlign: 'left',
|
||||
fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"',
|
||||
fontSize: '14px',
|
||||
lineHeight: '21px',
|
||||
});
|
||||
|
||||
const kickButton = page.getByRole('button', { name: '부대원 추방...' }).first();
|
||||
await kickButton.hover();
|
||||
const hoverStyle = await kickButton.evaluate((button) => ({
|
||||
cursor: getComputedStyle(button).cursor,
|
||||
filter: getComputedStyle(button).filter,
|
||||
}));
|
||||
expect(hoverStyle.cursor).toBe('pointer');
|
||||
expect(hoverStyle.filter).not.toBe('none');
|
||||
|
||||
await page.locator('.troopMember').nth(1).hover();
|
||||
await expect(page.getByRole('tooltip')).toContainText('조운');
|
||||
expect(await page.getByRole('tooltip').evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo(
|
||||
500,
|
||||
0
|
||||
);
|
||||
await page.screenshot({ path: 'test-results/troop/desktop-leader.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('matches the legacy 500px responsive placement', async ({ page }) => {
|
||||
await installApiFixture(page, {
|
||||
me: { id: 1, troopId: 1 },
|
||||
permission: 4,
|
||||
troops: baseTroops(),
|
||||
});
|
||||
await page.setViewportSize({ width: 500, height: 800 });
|
||||
await gotoTroop(page);
|
||||
await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible();
|
||||
|
||||
const geometry = await page
|
||||
.locator('.troopItem')
|
||||
.first()
|
||||
.evaluate((item) => {
|
||||
const origin = item.getBoundingClientRect();
|
||||
const relative = (selector: string) => {
|
||||
const rect = item.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width };
|
||||
};
|
||||
return {
|
||||
item: { width: origin.width, height: origin.height },
|
||||
info: relative('.troopInfo'),
|
||||
icon: relative('.troopLeaderIcon'),
|
||||
reserved: relative('.troopReservedCommand'),
|
||||
action: relative('.troopAction'),
|
||||
members: relative('.troopMembers'),
|
||||
};
|
||||
});
|
||||
expect(geometry.item).toEqual({ width: 500, height: 129 });
|
||||
expect(geometry.info).toMatchObject({ x: 0, y: 0, width: 130 });
|
||||
expect(geometry.icon).toMatchObject({ x: 130, y: 0, width: 130 });
|
||||
expect(geometry.reserved).toMatchObject({ x: 260, y: 0, width: 100 });
|
||||
expect(geometry.action).toMatchObject({ x: 360, y: 0, width: 140 });
|
||||
expect(geometry.members).toMatchObject({ x: 130, y: 93, width: 370 });
|
||||
await page.screenshot({ path: 'test-results/troop/mobile-leader.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('shows API failure then creates a troop successfully', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
me: { id: 7, troopId: 0 },
|
||||
permission: 0,
|
||||
troops: baseTroops(),
|
||||
failCreate: true,
|
||||
};
|
||||
await installApiFixture(page, state);
|
||||
await gotoTroop(page);
|
||||
|
||||
const input = page.getByRole('textbox', { name: '부대명' });
|
||||
await input.fill('실패대');
|
||||
await page.getByRole('button', { name: '부대 창설', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('부대 이름이 없습니다.');
|
||||
expect(state.me.troopId).toBe(0);
|
||||
|
||||
await input.fill('신규대');
|
||||
await page.getByRole('button', { name: '부대 창설', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('신규대 부대가 생성되었습니다.');
|
||||
await expect(page.locator('.troopInfo').filter({ hasText: '신규대' })).toBeVisible();
|
||||
await expect(input).toBeHidden();
|
||||
});
|
||||
|
||||
test('renames and kicks through confirm 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 expect(page.getByRole('status')).toContainText('부대명을 변경했습니다.');
|
||||
await expect(page.locator('.troopInfo').filter({ hasText: '백마의종' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '부대원 추방...' }).click();
|
||||
await page.getByRole('combobox', { name: '추방할 부대원' }).selectOption('3');
|
||||
await page.getByRole('button', { name: '추방', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('조운을 추방했습니다.');
|
||||
await expect(page.locator('.troopMembers').first()).not.toContainText('조운');
|
||||
});
|
||||
|
||||
test('does not render management controls for an unauthorized member', async ({ page }) => {
|
||||
await installApiFixture(page, {
|
||||
me: { id: 3, troopId: 1 },
|
||||
permission: 1,
|
||||
troops: baseTroops(),
|
||||
});
|
||||
await gotoTroop(page);
|
||||
await expect(page.getByRole('button', { name: '부대 탈퇴' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '부대원 추방...' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '부대명 변경...' })).toHaveCount(0);
|
||||
});
|
||||
Reference in New Issue
Block a user