툴팁의 hover와 키보드 focus 동작은 유지하면서 게임 정보에 반복되던 점선·파선 밑줄을 제거한다. 실제 Chromium에서 내정보, 장수 일람, 국가 전략, NPC 선택 흐름을 검증한다.
393 lines
17 KiB
TypeScript
393 lines
17 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('https://sam-image.hided.net/icons/**', 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(tooltip).toHaveCSS('text-decoration-line', 'none');
|
|
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',
|
|
'https://sam-image.hided.net/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.setViewportSize({ width: 390, height: 844 });
|
|
const documentWidthBeforeDialog = await page.evaluate(() => document.documentElement.scrollWidth);
|
|
await page.locator('#btn-retry-possession').click();
|
|
const successDialog = page.getByRole('alertdialog', { name: '완료' });
|
|
await expect(successDialog).toContainText('빙의에 성공했습니다.');
|
|
await expect(successDialog.getByRole('button', { name: '확인' })).toBeFocused();
|
|
const dialogGeometry = await successDialog.evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
left: rect.left,
|
|
right: window.innerWidth - rect.right,
|
|
bottom: window.innerHeight - rect.bottom,
|
|
width: rect.width,
|
|
documentWidth: document.documentElement.scrollWidth,
|
|
};
|
|
});
|
|
expect(dialogGeometry.left).toBeGreaterThanOrEqual(0);
|
|
expect(dialogGeometry.right).toBeGreaterThanOrEqual(0);
|
|
expect(dialogGeometry.bottom).toBeGreaterThanOrEqual(0);
|
|
expect(dialogGeometry.documentWidth).toBe(documentWidthBeforeDialog);
|
|
await page.screenshot({ path: testInfo.outputPath('game-notice-dialog.png') });
|
|
await successDialog.getByRole('button', { name: '확인' }).click();
|
|
await expect(page).toHaveURL(new RegExp(`${basePath}/$`));
|
|
expect(state.possessInputs).toHaveLength(2);
|
|
expect(state.possessInputs[1]?.clientRequestId).toBe(firstRequestId);
|
|
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,
|
|
});
|
|
});
|