Files
core2026/app/game-frontend/e2e/npcPossession.spec.ts
T

362 lines
15 KiB
TypeScript

import { expect, test, type Page, type Route } from '@playwright/test';
import { gameBasePath, gameProfile } from './gameTestPaths.js';
const response = (data: unknown) => ({ result: { data } });
const basePath = gameBasePath;
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
type FixtureState = {
reservationCalls: number;
reservationInputs: Array<Record<string, unknown>>;
rawBodies: unknown[];
possessInputs: Array<Record<string, unknown>>;
hasGeneral: boolean;
injectTimeout: boolean;
};
const candidates = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
name: `빙의후보${index + 1}`,
nation: { id: 0, name: '재야', color: '#aaaaaa' },
stats: {
leadership: 40 + index,
strength: 50 + index,
intelligence: 60 + index,
},
picture: 'default.jpg',
imageServer: index === 0 ? 1 : 0,
personality: { code: 'che_안전', name: '안전', info: '안전을 중시합니다.' },
specialDomestic: { code: 'che_인덕', name: '인덕', info: '인덕 설명' },
specialWar: { code: 'che_무쌍', name: '무쌍', info: '무쌍 설명' },
keepCount: 3,
}));
const generalList = Array.from({ length: 55 }, (_, index) => {
const candidate = candidates[index % candidates.length]!;
return {
id: index + 1,
name: `전체장수${String(index + 1).padStart(2, '0')}`,
picture: candidate.picture,
imageServer: candidate.imageServer,
npcState: index === 54 ? 1 : 2,
ownerName: index === 54 ? '빙의자' : '',
age: 25 + (index % 20),
level: 3 + (index % 5),
officerLevel: index % 5,
killturn: index % 10,
nationId: 0,
nationName: '재야',
nationLevel: 0,
personality: { key: 'che_안전', name: '안전', info: '안전을 중시합니다.' },
specialDomestic: { key: 'che_인덕', name: '인덕', info: '인덕 설명' },
specialWar: { key: 'che_무쌍', name: '무쌍', info: '무쌍 설명' },
statTotal: 150 + index,
leadership: 40 + (index % 30),
strength: 50 + (index % 20),
intelligence: 60 + (index % 10),
experience: 800 + index,
experienceText: '무명',
dedication: 700 + index,
dedicationText: '28품관',
};
});
generalList.push({
...generalList[0]!,
id: 56,
name: '사용자장수',
npcState: 0,
ownerName: '',
statTotal: 230,
leadership: 80,
strength: 70,
intelligence: 80,
experience: 16_000,
experienceText: '지역적',
dedication: 10_000,
dedicationText: '21품관',
});
const findInput = (value: unknown): Record<string, unknown> => {
if (!value || typeof value !== 'object') return {};
if ('json' in value && value.json && typeof value.json === 'object') {
return value.json as Record<string, unknown>;
}
const record = value as Record<string, unknown>;
if (
['refresh', 'keepIds', 'generalId', 'tokenNonce', 'clientRequestId'].some((key) => Object.hasOwn(record, key))
) {
return record;
}
for (const nested of Object.values(value)) {
const input = findInput(nested);
if (Object.keys(input).length > 0) return input;
}
return {};
};
const installFixture = async (page: Page, state: FixtureState): Promise<void> => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_npc_possession');
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
await page.route('**/image/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
});
});
await page.route('**/gateway/api/user-icons/default.jpg', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#8855aa"/></svg>',
});
});
await page.route('**/events**', async (route) => route.abort());
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
const operations = operationNames(route);
const rawBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
state.rawBodies.push(rawBody);
const body = rawBody && typeof rawBody === 'object' ? (rawBody as Record<string, unknown>) : {};
const results = operations.map((operation, index) => {
const input = findInput(body[String(index)] ?? body);
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') {
return response({
myGeneral: state.hasGeneral ? { id: 1, name: '빙의후보1' } : null,
year: 180,
month: 1,
turnTerm: 5,
});
}
if (operation === 'join.getConfig') {
return response({
rules: {
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
allowCustomName: true,
},
user: { id: 'npc-user', displayName: '빙의사용자', canCreateGeneral: true },
personalities: [{ key: 'Random', name: '???', info: '' }],
warSpecials: [],
nations: [],
serverInfo: {
currentYear: 180,
currentMonth: 1,
tickMinutes: 5,
maxGeneral: 500,
userGeneralCount: 0,
npcGeneralCount: 12,
},
inherit: {
totalPoint: 0,
costs: {
inheritBornSpecialPoint: 0,
inheritBornTurntimePoint: 0,
inheritBornCityPoint: 0,
inheritBornStatPoint: 0,
},
availableCities: [],
turnTimeZones: [],
availableSpecialWar: [],
},
selectionPool: { enabled: false },
npcPossession: { enabled: true },
});
}
if (operation === 'join.listPossessCandidates') {
state.reservationCalls += 1;
state.reservationInputs.push(input);
const refresh = input.refresh === true;
const now = Date.now();
const keepIds = Array.isArray(input.keepIds) ? input.keepIds : [];
return response({
tokenNonce: refresh ? 202 : 101,
validUntil: new Date(now + 90_000).toISOString(),
pickMoreFrom: new Date(refresh ? now + 1_200 : now - 1_000).toISOString(),
pickMoreSeconds: refresh ? 2 : 0,
candidates: candidates.map((candidate) => ({
...candidate,
keepCount: refresh && keepIds.includes(candidate.id) ? 2 : 3,
})),
});
}
if (operation === 'public.getNpcList') {
return response({
sort: 1,
generals: generalList,
tokenKeepCounts: { 1: 2, 2: 1 },
});
}
if (operation === 'join.possessGeneral') {
state.possessInputs.push(input);
if (state.injectTimeout) {
state.injectTimeout = false;
return {
error: {
message:
'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
code: -32008,
data: {
code: 'TIMEOUT',
httpStatus: 408,
path: 'join.possessGeneral',
},
},
};
}
state.hasGeneral = true;
return response({ ok: true, generalId: Number(input.generalId) });
}
return response({});
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(operations.length === 1 ? results[0] : results),
});
});
};
test('renders Ref-shaped token cards, preserves keep cooldown and retries possession with one ID', async ({
page,
}, testInfo) => {
const state: FixtureState = {
reservationCalls: 0,
reservationInputs: [],
rawBodies: [],
possessInputs: [],
hasGeneral: false,
injectTimeout: true,
};
await installFixture(page, state);
await page.setViewportSize({ width: 1024, height: 900 });
await page.goto('join?tab=possess');
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
await expect(page.locator('.npc-card')).toHaveCount(5);
await expect(page.getByText(/까지 유효/)).toBeVisible();
const geometry = await page.locator('.npc-possession-section').evaluate((section) => {
const sectionRect = section.getBoundingClientRect();
const cards = [...section.querySelectorAll<HTMLElement>('.npc-card')];
const image = section.querySelector<HTMLImageElement>('.npc-card-image');
return {
sectionWidth: sectionRect.width,
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
imageWidth: image?.getBoundingClientRect().width,
imageHeight: image?.getBoundingClientRect().height,
imageNaturalWidth: image?.naturalWidth,
imageNaturalHeight: image?.naturalHeight,
};
});
expect(geometry).toEqual({
sectionWidth: 1000,
cardWidths: [125, 125, 125, 125, 125],
imageWidth: 64,
imageHeight: 64,
imageNaturalWidth: 64,
imageNaturalHeight: 64,
});
const tooltip = page.locator('.npc-tooltip').first();
const tooltipPopup = tooltip.getByRole('tooltip');
await expect(tooltipPopup).toBeHidden();
await tooltip.hover();
await expect(tooltipPopup).toBeVisible();
await tooltip.focus();
await expect(tooltip).toBeFocused();
await expect(tooltipPopup).toHaveText('안전을 중시합니다.');
await expect(page.locator('.npc-card-image').first()).toHaveAttribute('src', '/gateway/api/user-icons/default.jpg');
await page.locator('#btn-load-general-list').click();
await expect(page.locator('#tb-general-list')).toBeVisible();
await expect(page.locator('#tb-general-list tbody tr')).toHaveCount(50);
await expect(page.locator('#tb-general-list tbody tr').first()).toHaveAttribute('data-general-id', '56');
await expect(page.locator('#tb-general-list tbody tr').first()).toHaveAttribute('data-reservation-state', '2');
const selectedRow = page.locator('#tb-general-list tbody tr[data-general-id="1"]');
await expect(selectedRow).toHaveAttribute('data-reservation-state', '1');
await expect(selectedRow.locator('.npc-general-name')).toHaveCSS('color', 'rgb(238, 130, 238)');
await expect(selectedRow.locator('.npc-general-name')).toContainText('(2회)');
const listGeometry = await page.locator('#tb-general-list').evaluate((table) => {
const rect = table.getBoundingClientRect();
const icon = table.querySelector<HTMLImageElement>('.npc-general-icon');
return {
width: rect.width,
iconWidth: icon?.getBoundingClientRect().width,
iconHeight: icon?.getBoundingClientRect().height,
iconNaturalWidth: icon?.naturalWidth,
iconNaturalHeight: icon?.naturalHeight,
};
});
expect(listGeometry).toEqual({
width: 970,
iconWidth: 64,
iconHeight: 64,
iconNaturalWidth: 64,
iconNaturalHeight: 64,
});
await page.locator('#btn-print-more-generals').click();
await expect(page.locator('#tb-general-list tbody tr')).toHaveCount(56);
await expect(page.locator('#btn-print-more-generals')).toHaveCount(0);
await page.screenshot({
path: testInfo.outputPath('npc-possession-candidates-and-list.png'),
fullPage: true,
});
await page.locator('.npc-keep input').first().check();
await page.getByRole('button', { name: '다른 장수 보기' }).click();
await expect.poll(() => state.reservationCalls).toBe(2);
expect(state.reservationInputs.at(-1), JSON.stringify(state.rawBodies.at(-1))).toMatchObject({
refresh: true,
keepIds: [1],
});
await expect(page.locator('.npc-keep').first()).toContainText('보관(2회)');
const refreshButton = page.locator('#btn-pick-more');
await expect(refreshButton).toBeDisabled();
await expect(refreshButton).toContainText(/다른 장수 보기\([12]초\)/);
await expect(refreshButton).toBeEnabled({ timeout: 3_000 });
const dialogs: string[] = [];
page.on('dialog', async (dialog) => {
dialogs.push(dialog.message());
if (
dialog.type() === 'confirm' &&
dialogs.filter((message) => message.startsWith('빙의할까요?')).length === 1
) {
await dialog.dismiss();
return;
}
await dialog.accept();
});
const possessButton = page.locator('.npc-action').first();
await possessButton.click();
expect(state.possessInputs).toHaveLength(0);
await possessButton.click();
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
expect(state.possessInputs).toHaveLength(1);
const firstRequestId = state.possessInputs[0]?.clientRequestId;
expect(firstRequestId).toMatch(/^[0-9a-f-]{36}$/);
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toContain(
firstRequestId as string
);
await page.evaluate(() => {
const expiredNow = Date.now() + 120_000;
Date.now = () => expiredNow;
});
await expect(page.locator('.npc-token-expired')).toBeVisible();
await expect(possessButton).toBeEnabled();
await expect(refreshButton).toBeDisabled();
await page.locator('#btn-retry-possession').click();
await expect(page).toHaveURL(new RegExp(`${basePath}/$`));
expect(state.possessInputs).toHaveLength(2);
expect(state.possessInputs[1]?.clientRequestId).toBe(firstRequestId);
expect(dialogs).toContain('빙의에 성공했습니다.');
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull();
await page.screenshot({
path: testInfo.outputPath('npc-possession-success.png'),
fullPage: true,
});
});