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

1184 lines
63 KiB
TypeScript

import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page } from '@playwright/test';
import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const world = {
year: 190,
month: 7,
startYear: 190,
startMonth: 1,
serverId: 'audit-fixture',
tick: '100',
asOf: '2026-09-16T00:00:00.000Z',
collectionStart: { year: 190, month: 1, tick: '0', observedAt: '2026-09-16T00:00:00.000Z' },
};
const dex = { dex1: 100, dex2: 200, dex3: 300, dex4: 400, dex5: 500 };
const population = { count: 2, gold: 200, rice: 400, dex, averageGold: 100, averageRice: 200, averageDex: dex };
const general = {
id: 1,
name: '감사장수',
userId: 'fixture',
nationId: 2,
cityId: 3,
troopId: 0,
npcState: 2,
gold: 1200,
rice: 2400,
stats: { leadership: 80, strength: 70, intelligence: 90 },
experience: 100,
dedication: 200,
officerLevel: 2,
injury: 0,
age: 30,
crew: 5000,
crewTypeId: 1,
train: 80,
atmos: 90,
dex,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
};
const decision = {
id: 'd'.repeat(64),
executionId: 'e'.repeat(64),
phase: 'general',
generalId: 1,
nationId: 2,
cityId: 3,
npcState: 2,
year: 190,
month: 6,
tick: '100',
stepCount: 129,
createdAt: '2026-09-16T00:00:00.000Z',
summary: {
schemaVersion: 1,
coverage: 'PROCEDURES',
clockRevision: 1,
codeVersion: null,
policyRefs: { DEFENCE: 'a'.repeat(64) },
requestedAction: '휴식',
selectedAction: 'che_징병',
selectedReason: '징병 선택',
executedAction: '휴식',
completed: false,
usedFallback: true,
blockedReason: '자원 부족',
},
};
const install = async (page: Page, denied = false, baseline: boolean | 'document' | 'created' | 'removed' = false) => {
const requests: { operation: string; input: Record<string, unknown> }[] = [];
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_audit');
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
await page.route(gameTrpcRoute, async (route) => {
const url = new URL(route.request().url());
const inputs = JSON.parse(url.searchParams.get('input') ?? route.request().postData() ?? '{}');
const results = decodeURIComponent(url.pathname.split('/trpc/')[1] ?? '')
.split(',')
.map((operation, index) => {
const input = inputs[index] ?? {};
requests.push({ operation, input });
const result = (data: unknown) => ({ result: { data } });
switch (operation) {
case 'auth.status':
return result({ ok: true });
case 'lobby.info':
return result({ myGeneral: null });
case 'playAudit.capabilities':
return denied
? {
error: {
message: '이 프로필의 플레이 감사 권한이 필요합니다.',
code: -32003,
data: { code: 'FORBIDDEN', httpStatus: 403 },
},
}
: result({ profileName: gameProfile, read: true, accounts: false });
case 'playAudit.decisionHistory':
return result({
...world,
month: input.month ?? { year: 190, month: 7 },
coverage: 'PROCEDURES_ONLY',
items: [
{
...decision,
id: input.cursor ? 'c'.repeat(64) : decision.id,
phase: input.cursor ? 'nation' : 'general',
},
],
nextCursor: input.cursor ? null : { tick: '100', id: decision.id },
});
case 'playAudit.decisionDetail':
return result({
...world,
decision,
chunks: [
{
ordinal: input.cursor === undefined ? 0 : 1,
steps: [
{
phase: 'general',
generalId: 1,
nationId: 2,
cityId: 3,
npcState: 2,
year: 190,
month: 6,
tick: 100,
sequence: input.cursor === undefined ? 0 : 128,
...(input.cursor === undefined
? { kind: 'PROCEDURE_START', procedure: '<b>징병판정</b>' }
: { kind: 'DECISION_END', action: 'che_징병', reason: '징병 선택' }),
},
],
},
],
nextCursor: input.cursor === undefined ? 0 : null,
});
case 'playAudit.generalLogs':
return result({
...world,
type: input.type,
coverage: 'IDENTIFIED_LOGS_ONLY',
items: [
{
id: 1,
year: 190,
month: 1,
text: `<script>window.auditInjected=true</script>${input.type} 감사 로그`,
createdAt: '0190-01-01T00:00:00.000Z',
},
],
nextCursor: null,
});
case 'playAudit.diplomacyHistory':
if (baseline)
return result({
...world,
coverage: 'RECORDED_EVENTS_ONLY',
nextCursor: null,
items: [
{
id: 'c'.repeat(64),
sequence: '1',
srcNationId: 2,
destNationId: 3,
category: baseline === 'document' ? 'DOCUMENT' : 'RELATION',
source: 'BASELINE',
eventType:
baseline === 'document'
? 'LETTER_BASELINE'
: baseline === 'created'
? 'NATION_RELATION_CREATED'
: baseline === 'removed'
? 'NATION_RELATION_REMOVED'
: 'RELATION_BASELINE',
documentId: baseline === 'document' ? 8 : null,
previousDocumentId: null,
year: 190,
month: 1,
actor: null,
createdAt: world.asOf,
},
],
});
return result({
...world,
coverage: 'RECORDED_EVENTS_ONLY',
nextCursor: input.cursor ? null : '9007199254741001',
items: [
{
id: (input.cursor ? 'e' : 'd').repeat(64),
sequence: input.cursor ? '9007199254741000' : '9007199254741001',
srcNationId: 2,
destNationId: 3,
category: 'DOCUMENT',
source: 'API',
eventType: input.cursor ? 'LETTER_DESTROYED' : 'LETTER_ACCEPTED',
documentId: 8,
previousDocumentId: 7,
year: 190,
month: 6,
actor: {
generalId: 1,
name: '당시 외교권자',
nationId: 2,
officerLevel: 12,
npcState: 0,
},
createdAt: world.asOf,
},
],
});
case 'playAudit.diplomacyEvent':
if (baseline)
return result({
...world,
event: {
id: input.id,
sequence: '1',
srcNationId: 2,
destNationId: 3,
category: baseline === 'document' ? 'DOCUMENT' : 'RELATION',
source: 'BASELINE',
eventType:
baseline === 'document'
? 'LETTER_BASELINE'
: baseline === 'created'
? 'NATION_RELATION_CREATED'
: baseline === 'removed'
? 'NATION_RELATION_REMOVED'
: 'RELATION_BASELINE',
documentId: baseline === 'document' ? 8 : null,
previousDocumentId: null,
year: 190,
month: 1,
actor: null,
createdAt: world.asOf,
before: baseline === 'removed' ? { state: 7, term: 12, dead: 0 } : null,
after:
baseline === 'document'
? { state: 'ACTIVATED' }
: baseline === 'removed'
? null
: { state: 2, term: 0, dead: 0 },
tick: '0',
clockRevision: '1',
ordinal: 1,
executionId: 'relation-baseline',
requestId: null,
inputSequence: null,
documentStatus: baseline === 'document' ? 'AVAILABLE' : 'NOT_APPLICABLE',
document:
baseline === 'document'
? {
id: 8,
writtenAt: '2026-09-01T00:00:00.000Z',
brief: '보유 협정',
briefHtml: '<p>보유 협정</p>',
detail: '<p>도입 전 본문</p>',
detailHtml: '<p>도입 전 본문</p>',
}
: null,
},
});
return result({
...world,
event: {
id: input.id,
sequence: '9007199254741001',
srcNationId: 2,
destNationId: 3,
category: 'DOCUMENT',
source: 'API',
eventType: 'LETTER_ACCEPTED',
documentId: 8,
previousDocumentId: 7,
year: 190,
month: 6,
actor: {
generalId: 1,
name: '당시 외교권자',
nationId: 2,
officerLevel: 12,
npcState: 0,
},
createdAt: world.asOf,
before: { state: 'PROPOSED' },
after: { state: 'ACTIVATED' },
tick: '100',
clockRevision: '1',
ordinal: 1,
executionId: 'fixture',
requestId: 'fixture-request',
inputSequence: '9007199254740993',
documentStatus: String(input.id).startsWith('e') ? 'HASH_MISMATCH' : 'AVAILABLE',
document: String(input.id).startsWith('e')
? null
: {
id: 8,
writtenAt: world.asOf,
brief: '협정',
briefHtml: '<p>협정</p>',
detail: '<script>window.auditInjected=true</script><p>서명된 내용</p>',
detailHtml: '<p><b>서명된 내용</b></p>',
},
},
});
case 'playAudit.policyHistory':
return result({
...world,
coverage: 'RECORDED_VERSIONS_ONLY',
items: [
{
id: String(input.cursor ? 'a' : 'b').repeat(64),
nationId: 2,
area: input.area,
revision: input.cursor ? 1 : 2,
source: input.cursor ? 'BASELINE' : 'CHANGE',
year: 190,
month: 6,
previousId: input.cursor ? null : 'a'.repeat(64),
actor: input.cursor
? null
: {
generalId: 1,
name: '당시군주',
nationId: 2,
officerLevel: 12,
npcState: 0,
},
createdAt: world.asOf,
},
],
nextCursor: input.cursor ? null : 2,
});
case 'playAudit.policyVersion': {
const baseline = input.id === 'a'.repeat(64);
return result({
...world,
version: {
id: input.id,
nationId: 2,
area: 'DEFENCE',
revision: baseline ? 1 : 2,
source: baseline ? 'BASELINE' : 'CHANGE',
year: 190,
month: 6,
previousId: baseline ? null : 'a'.repeat(64),
actor: baseline
? null
: { generalId: 1, name: '당시군주', nationId: 2, officerLevel: 12, npcState: 0 },
createdAt: world.asOf,
tick: '100',
ordinal: baseline ? 1 : 2,
requestId: baseline ? null : 'policy-request-fixture',
inputSequence: baseline ? null : '9007199254740993',
fields: [
{
key: 'scout',
beforeJson: baseline ? null : '0',
afterJson: '1',
changed: !baseline,
},
{
key: 'priority',
beforeJson: baseline ? null : 'null',
afterJson: '["<script>window.auditInjected=true</script>"]',
changed: !baseline,
},
],
},
});
}
case 'playAudit.coverage':
return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null });
case 'playAudit.nations':
return result({
...world,
collected: true,
items: [{ id: 2, name: '촉', color: '#ff0000' }],
nextCursor: null,
});
case 'playAudit.nationSeries':
return result({
...world,
nextCursor: null,
items: [
{
year: 190,
month: 1,
periodMonths: 6,
complete: true,
from: { year: 190, month: 1 },
to: { year: 190, month: 6 },
stockAsOf: { year: 190, month: 6 },
stock: {
id: 2,
name: '촉',
color: '#ff0000',
gold: 600,
rice: 1200,
tech: 100,
appliedRate: 20,
populations: { human: population, npc: population, troopNpc: population },
},
flows: { incomeGold: 21, incomeRice: null, paidGold: 10, paidRice: 0 },
months: [1, 2, 3, 4, 5, 6].map((month) => ({
year: 190,
month,
collected: true,
nationPresent: true,
settlementsComplete: true,
})),
},
],
});
case 'playAudit.nationSnapshot':
return result({
...world,
collected: true,
sample: {
year: 190,
month: 6,
kind: (input.at as { kind: string }).kind,
settlementsComplete: (input.at as { kind: string }).kind !== 'INITIAL',
},
nation: {
id: 2,
name: '촉',
color: '#ff0000',
gold: 999,
rice: 222,
tech: 100,
appliedRate: 20,
incomeGold: 99,
incomeRice: 0,
paidGold: 0,
paidRice: 0,
populations: { human: population, npc: population, troopNpc: population },
},
});
case 'playAudit.generals':
return result({
...world,
collected: !input.at || (input.at as { month: number }).month !== 7,
sample: input.at ?? null,
nextCursor: input.cursor ? null : 1,
items:
input.at && (input.at as { month: number }).month === 7
? []
: [
{
...general,
id: input.cursor ? 2 : 1,
name: input.cursor ? '다음장수' : '감사장수',
},
],
});
case 'playAudit.generalDetail':
return result({
...world,
sample: input.at ?? null,
collected: true,
general: { ...general, name: input.at ? '과거감사장수' : general.name },
nation: { id: 2, name: '촉' },
city: { id: 3, name: '성도' },
});
case 'playAudit.cityDetail':
return result({
...world,
collected: true,
sample: input.at ?? null,
nation: { id: 2, name: '촉' },
city: {
id: 3,
name: '성도',
nationId: 2,
level: 4,
state: 0,
population: 10000,
populationMax: 20000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
wall: 100,
wallMax: 200,
defence: 100,
defenceMax: 200,
supplyState: 1,
frontState: 0,
trust: 80,
},
});
case 'playAudit.generalTurns':
return result({
...world,
currentOnly: true,
generalExists: true,
items: [{ turnIdx: 0, actionCode: '휴식', argumentJson: '{}' }],
nextCursor: null,
});
case 'playAudit.cities':
return result({
...world,
collected: true,
sample: null,
nextCursor: null,
items: [
{
id: 3,
name: '성도',
nationId: 2,
level: 4,
state: 0,
population: 10000,
populationMax: 20000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
wall: 100,
wallMax: 200,
defence: 100,
defenceMax: 200,
supplyState: 1,
frontState: 0,
trust: 80,
},
],
});
default:
throw new Error(`Unexpected operation ${operation}`);
}
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
return requests;
};
const capture = async (page: Page, name: string) => {
await page.evaluate(() => document.fonts.ready);
const directory = resolve('/tmp/play-audit-browser', gameProfile.replace(':', '-'));
await mkdir(directory, { recursive: true });
const geometry = await page.evaluate(() => ({
viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
width: document.documentElement.scrollWidth,
nodes: [...document.querySelectorAll('.audit-page, .panel-card, select, input, button, table')].map((node) => {
const rect = node.getBoundingClientRect();
const style = getComputedStyle(node);
return {
tag: node.tagName,
width: rect.width,
height: rect.height,
x: rect.x,
y: rect.y,
font: style.fontSize,
background: style.backgroundColor,
};
}),
}));
expect(geometry.width).toBeLessThanOrEqual(geometry.viewport.width);
await writeFile(resolve(directory, `${name}.json`), JSON.stringify(geometry, null, 2));
await writeFile(resolve(directory, `${name}.html`), await page.content());
await page.screenshot({ path: resolve(directory, `${name}.png`), fullPage: true });
};
test('profile audit without a general: chart controls, lazy reads, direct reload', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=nations&nation=2&fromYear=190&fromMonth=1&year=190&month=6'));
await expect(page.getByRole('cell', { name: '600', exact: true })).toBeVisible();
expect(requests.some((request) => ['playAudit.generals', 'playAudit.cities'].includes(request.operation))).toBe(
false
);
const count = requests.length;
await page.getByLabel('지표', { exact: true }).selectOption('incomeGold');
await expect(page.getByRole('cell', { name: '21', exact: true })).toBeVisible();
await page.getByLabel('지표', { exact: true }).selectOption('incomeRice');
await expect(page.getByRole('cell', { name: '자료 없음', exact: true })).toBeVisible();
expect(requests.length).toBe(count);
await page.getByText('월별 표본 수집됨', { exact: true }).click();
await expect(page.getByText('190년 1월: 수집됨')).toBeVisible();
await capture(page, 'desktop-series');
await page.reload();
await expect(page.getByRole('cell', { name: '600', exact: true })).toBeVisible();
expect(new URL(page.url()).pathname).toBe(gamePath('/play-audit'));
});
test('mobile city drilldown preserves month and includes foreign stationed generals', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=cities&nation=2&at=month&year=190&month=6'));
await page.getByText('내정 보기', { exact: true }).click();
await expect(page.getByText(/농업 100 \/ 200/)).toBeVisible();
await capture(page, 'mobile-cities');
await page.getByRole('button', { name: '모든 국가의 주둔 장수' }).click();
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
const input = requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input;
expect(input).toMatchObject({ cityId: 3, at: { year: 190, month: 6, kind: 'MONTH_END' } });
expect(input).not.toHaveProperty('nationId');
await page.getByText('상세 보기', { exact: true }).click();
await expect(page.getByText(/통솔 80/)).toBeVisible();
await capture(page, 'mobile-generals');
await page.getByRole('button', { name: '다음 50개 불러오기' }).click();
await expect(page.getByRole('rowheader', { name: /다음장수/ })).toBeVisible();
await page.goBack();
await expect(page.getByRole('rowheader', { name: /성도/ })).toBeVisible();
await page.goto(gamePath('/play-audit?tab=generals&at=month&year=190&month=7'));
await expect(page.getByText('선택한 시점의 표본이 없습니다.')).toBeVisible();
});
test('denied capability does not request game audit data', async ({ page }) => {
const requests = await install(page, true);
await page.goto(gamePath('/play-audit'));
await expect(page.getByRole('alert')).toContainText('플레이 감사 권한');
expect(
requests.filter((request) => request.operation.startsWith('playAudit.')).map((request) => request.operation)
).toEqual(['playAudit.capabilities']);
await expect(page.getByLabel('조회 대상')).toHaveCount(0);
});
test('back navigation during a slow read keeps the newer city view', async ({ page }) => {
await install(page);
let release = () => {};
let markStarted = () => {};
const held = new Promise<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
await page.route(gameTrpcRoute, async (route) => {
if (route.request().url().includes('playAudit.generals')) {
markStarted();
await held;
}
await route.fallback();
});
await page.goto(gamePath('/play-audit?tab=cities'));
await page.getByRole('button', { name: '모든 국가의 주둔 장수' }).click();
await started;
await expect(page.getByRole('button', { name: '조회', exact: true })).toBeDisabled();
await page.goBack();
await expect(page.getByRole('rowheader', { name: /성도/ })).toBeVisible();
const response = page.waitForResponse((response) => response.url().includes('playAudit.generals'));
release();
await response;
await expect(page.getByRole('rowheader', { name: /성도/ })).toBeVisible();
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toHaveCount(0);
await page.getByRole('button', { name: '조회', exact: true }).focus();
await expect(page.getByRole('button', { name: '조회', exact: true })).toBeFocused();
});
test('final nation snapshot is separate from the monthly series', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=nations&nation=2&at=final&year=190&month=6'));
await expect(page.getByRole('heading', { name: '촉 · 190년 6월 최종 표본' })).toBeVisible();
await expect(page.getByText('999 / 222', { exact: true })).toBeVisible();
expect(requests.some((request) => request.operation === 'playAudit.nationSeries')).toBe(false);
expect(requests.find((request) => request.operation === 'playAudit.nationSnapshot')?.input).toEqual({
nationId: 2,
at: { year: 190, month: 6, kind: 'FINAL' },
});
await capture(page, 'final-nation');
});
test('selected general reads detail on demand and separates current reservations from history', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=generals'));
await expect(page.getByRole('button', { name: '감사장수 (#1)', exact: true })).toBeVisible();
const before = requests.filter((request) => request.operation === 'playAudit.generals').length;
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toBeVisible();
await expect(page.getByText('국가 촉 · 도시 성도 · 부대 #0', { exact: true })).toBeVisible();
expect(requests.filter((request) => request.operation === 'playAudit.generals')).toHaveLength(before);
expect(requests.some((request) => request.operation === 'playAudit.generalTurns')).toBe(false);
await page.getByRole('button', { name: '현재 예약 명령 조회', exact: true }).click();
await expect(page.getByText(/위치 0: 휴식/)).toBeVisible();
await capture(page, 'general-detail');
await page.getByRole('button', { name: '상세 닫기', exact: true }).click();
await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toHaveCount(0);
await page.goto(gamePath('/play-audit?tab=generals&general=1&at=month&year=190&month=6'));
await expect(page.getByRole('heading', { name: '과거감사장수 (#1)' })).toBeVisible();
await expect(page.getByText('과거 예약 명령은 상태 표본에 포함되지 않습니다.', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '현재 예약 명령 조회', exact: true })).toHaveCount(0);
expect(requests.filter((request) => request.operation === 'playAudit.generalTurns')).toHaveLength(1);
});
test('city detail is addressable without reloading the list and retains month for stationed generals', async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=cities&at=month&year=190&month=6'));
const button = page.getByRole('button', { name: '성도 (#3)', exact: true });
await expect(button).toBeVisible();
const before = requests.filter((request) => request.operation === 'playAudit.cities').length;
await button.click();
await expect(page.getByRole('heading', { name: '성도 (#3) · 촉' })).toBeVisible();
expect(requests.filter((request) => request.operation === 'playAudit.cities')).toHaveLength(before);
await capture(page, 'city-detail-mobile');
await page.getByRole('button', { name: '이 시점의 모든 국가 주둔 장수', exact: true }).click();
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
expect(requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input).toMatchObject({
cityId: 3,
at: { year: 190, month: 6, kind: 'MONTH_END' },
});
});
test('general logs load explicitly and cache each category without reloading entity lists', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
await expect(page.getByRole('button', { name: '장수 기록 조회', exact: true })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')).toHaveLength(0);
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
const listCount = requests.filter((r) => r.operation === 'playAudit.generals').length;
await page.getByRole('button', { name: '장수 기록 조회', exact: true }).click();
await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible();
await page.getByLabel('기록 종류').selectOption('generalAction');
await expect(page.getByText('generalAction 감사 로그', { exact: false })).toBeVisible();
await page.getByLabel('기록 종류').selectOption('generalHistory');
await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')).toHaveLength(2);
expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(listCount);
expect(await page.evaluate(() => Reflect.get(window, 'auditInjected'))).toBeUndefined();
await expect(page.locator('.audit-logs script')).toHaveCount(0);
await capture(page, 'general-logs');
});
test('historical log failure retries independently and sends only the selected month', async ({ page }) => {
const requests = await install(page);
let fail = true;
await page.route(gameTrpcRoute, async (route) => {
if (decodeURIComponent(route.request().url()).includes('playAudit.generalLogs') && fail) {
fail = false;
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message: '기록 조회 재시도',
code: -32603,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
},
},
]),
});
} else await route.fallback();
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(gamePath('/play-audit?tab=generals&general=1&at=month&year=190&month=6'));
await page.getByRole('button', { name: '장수 기록 조회', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('기록 조회 재시도');
await page.getByRole('button', { name: '다시 조회', exact: true }).click();
await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')[0]?.input).toMatchObject({
generalId: 1,
month: { year: 190, month: 6 },
});
expect(requests.filter((r) => r.operation === 'playAudit.generalTurns')).toHaveLength(0);
await capture(page, 'historical-general-logs-mobile');
});
test('initial calendar before the scenario year bounds default periods and month controls', async ({ page }) => {
const requests = await install(page);
await page.route(gameTrpcRoute, async (route) => {
if (decodeURIComponent(route.request().url()).includes('playAudit.coverage')) {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
{
result: {
data: {
...world,
year: 189,
month: 10,
startYear: 189,
startMonth: 10,
status: 'COLLECTED',
samples: [],
nextCursor: null,
},
},
},
]),
});
} else await route.fallback();
});
await page.goto(gamePath('/play-audit?tab=nations&nation=2'));
await expect(page.getByLabel('시작 연도', { exact: true })).toHaveValue('189');
await expect(page.getByLabel('시작 월', { exact: true })).toHaveValue('10');
await expect(page.getByLabel('시작 월', { exact: true })).toHaveAttribute('min', '10');
await expect(page.getByLabel('월', { exact: true })).toHaveAttribute('min', '10');
await expect
.poll(() => requests.find((r) => r.operation === 'playAudit.nationSeries')?.input)
.toMatchObject({ from: { year: 189, month: 10 }, to: { year: 189, month: 10 } });
});
test('policy history reads summaries and selected versions only, preserving deep links and pagination', async ({
page,
}) => {
const requests = await install(page);
const path = '/play-audit?tab=policies&nation=2&policyArea=DEFENCE&fromYear=190&fromMonth=1&year=190&month=6';
await page.goto(gamePath(path));
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
expect(
requests.some(({ operation }) =>
['playAudit.policyVersion', 'playAudit.nationSeries', 'playAudit.generals'].includes(operation)
)
).toBe(false);
const listReads = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
const nationReads = requests.filter(({ operation }) => operation === 'playAudit.nations').length;
await page.getByRole('button', { name: '버전 2', exact: true }).click();
await expect(page.getByText('임관 권유 설정 (변경)', { exact: true })).toBeVisible();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(listReads);
expect(requests.filter(({ operation }) => operation === 'playAudit.nations')).toHaveLength(nationReads);
await page.getByText('요청 연결', { exact: true }).click();
await expect(page.getByText('입력 순번 9007199254740993', { exact: true })).toBeVisible();
expect(await page.evaluate(() => Object.hasOwn(window, 'auditInjected'))).toBe(false);
await capture(page, 'desktop-policy-detail');
await page.reload();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
await page.getByRole('button', { name: '이전 정책 버전', exact: true }).click();
await expect(page.getByText(/국가 #2 · 버전 1/)).toBeVisible();
await expect(page.getByRole('cell', { name: '관측하지 않음', exact: true })).toHaveCount(2);
await page.goBack();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await capture(page, 'mobile-policy-detail');
await page.getByRole('button', { name: '정책 상세 닫기' }).click();
await expect(page.getByRole('heading', { name: /선택 정책 버전/ })).toHaveCount(0);
await page.getByRole('button', { name: '다음 정책 50개' }).click();
await expect(page.getByRole('button', { name: '버전 1', exact: true })).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input).toMatchObject({
area: 'DEFENCE',
cursor: 2,
nationId: 2,
});
await page.getByLabel('정책 영역').selectOption('NPC_GENERAL_PRIORITY');
const before = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length)
.toBeGreaterThan(before);
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area).toBe(
'NPC_GENERAL_PRIORITY'
);
});
test('policy detail failure retries independently without reloading its history', async ({ page }) => {
const requests = await install(page);
let fail = true;
await page.route(gameTrpcRoute, async (route) => {
if (fail && route.request().url().includes('playAudit.policyVersion')) {
fail = false;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message: '정책 버전 일시 오류',
code: -32603,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
},
},
]),
});
return;
}
await route.fallback();
});
await page.goto(
gamePath('/play-audit?tab=policies&nation=2&policyArea=DEFENCE&fromYear=190&fromMonth=1&year=190&month=6')
);
await page.getByRole('button', { name: '버전 2', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('정책 버전 일시 오류');
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByRole('button', { name: '버전 다시 조회', exact: true }).click();
await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count);
});
test('policy filter drafts do not read until applied, including default dates', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=policies&nation=2'));
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByLabel('시작 월', { exact: true }).fill('3');
await page.getByLabel('정책 영역').selectOption('DEFENCE');
await page.getByRole('button', { name: '조회', exact: true }).focus();
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count);
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length)
.toBe(count + 1);
expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input).toMatchObject({
area: 'DEFENCE',
from: { year: 190, month: 3 },
});
});
test('initial observation is separate from month-end and final snapshots', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=nations&nation=2&at=initial&year=190&month=6'));
await expect(page.getByRole('heading', { name: '촉 · 190년 6월 수집 시작 기준' })).toBeVisible();
await expect(page.getByText(/상태·정책 수집 시작: 190년 1월/)).toBeVisible();
expect(requests.some(({ operation }) => operation === 'playAudit.nationSeries')).toBe(false);
expect(requests.find(({ operation }) => operation === 'playAudit.nationSnapshot')?.input).toMatchObject({
at: { kind: 'INITIAL' },
});
await page.getByLabel('조회 대상').selectOption('generals');
await page.getByRole('button', { name: '조회', exact: true }).click();
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
await expect(page.getByText('190년 6월 수집 시작 기준', { exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: '과거감사장수 (#1)' })).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.generalDetail').at(-1)?.input).toMatchObject({
at: { kind: 'INITIAL' },
});
await capture(page, 'initial-observation');
await page.setViewportSize({ width: 390, height: 844 });
await capture(page, 'mobile-initial-observation');
});
test('diplomacy history loads selected safe document details without reloading the list', async ({ page }) => {
const requests = await install(page);
await page.goto(
gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6')
);
await expect(page.getByRole('button', { name: '문서 승인', exact: true })).toBeVisible();
expect(requests.some(({ operation }) => operation === 'playAudit.diplomacyEvent')).toBe(false);
const count = requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length;
const nations = requests.filter(({ operation }) => operation === 'playAudit.nations').length;
await page.getByRole('button', { name: '문서 승인', exact: true }).click();
await expect(
page.getByRole('region', { name: '당시 외교 문서' }).getByText('서명된 내용', { exact: true })
).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(count);
expect(requests.filter(({ operation }) => operation === 'playAudit.nations')).toHaveLength(nations);
await page.getByText('원문 HTML 확인', { exact: true }).click();
await expect(page.locator('pre').filter({ hasText: '<script>window.auditInjected=true</script>' })).toBeVisible();
expect(await page.evaluate(() => Reflect.get(window, 'auditInjected'))).toBeUndefined();
await capture(page, 'desktop-diplomacy-detail');
await page.reload();
await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await capture(page, 'mobile-diplomacy-detail');
const comparison = page.getByLabel('외교 전후 값', { exact: true });
await comparison.hover();
await page.mouse.wheel(650, 0);
await expect.poll(() => comparison.evaluate((node) => node.scrollLeft)).toBeGreaterThan(0);
await page.getByRole('button', { name: '외교 상세 닫기' }).click();
await page.getByRole('button', { name: '다음 사건 50개' }).click();
await page.getByRole('button', { name: '문서 파기', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('해시와 달라');
await expect(page.getByRole('region', { name: '당시 외교 문서' })).toHaveCount(0);
const before = requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length;
await page.getByLabel('시작 월', { exact: true }).fill('2');
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(before);
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length)
.toBeGreaterThan(before);
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').at(-1)?.input.from).toEqual({
year: 190,
month: 2,
});
});
test('diplomacy detail failure retries independently', async ({ page }) => {
const requests = await install(page);
let fail = true;
await page.route(gameTrpcRoute, async (route) => {
if (fail && route.request().url().includes('playAudit.diplomacyEvent')) {
fail = false;
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message: '외교 상세 재시도',
code: -32603,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
},
},
]),
});
} else await route.fallback();
});
await page.goto(
gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6')
);
await expect(page.getByRole('button', { name: '문서 승인', exact: true })).toBeVisible();
await page.getByRole('button', { name: '문서 승인', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('외교 상세 재시도');
const count = requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length;
await page.getByRole('button', { name: '상세 다시 조회' }).click();
await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible();
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(count);
});
test('diplomacy baseline displays observed state without a fictional document or actor', async ({ page }) => {
const requests = await install(page, false, true);
await page.goto(
gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6')
);
await page.getByRole('button', { name: '관계 최초 관측', exact: true }).click();
await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('교역');
await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('미관측 / 없음');
await expect(page.getByRole('region', { name: '당시 외교 문서' })).toHaveCount(0);
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(1);
});
test('existing diplomacy document is an initial observation with its preserved source', async ({ page }) => {
await install(page, false, 'document');
await page.goto(
gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6')
);
await page.getByRole('button', { name: '문서 최초 관측', exact: true }).click();
await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('미관측 / 없음');
await expect(page.getByRole('region', { name: '당시 외교 문서' })).toContainText('도입 전 본문');
await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible();
});
for (const [kind, label, value] of [
['created', '신생국 관계 생성', '교역'],
['removed', '멸망국 관계 종료', '불가침'],
] as const) {
test(`nation relation lifecycle displays ${kind} with a missing side`, async ({ page }) => {
await install(page, false, kind);
await page.goto(
gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6')
);
await page.getByRole('button', { name: label, exact: true }).click();
await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText(value);
await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('미관측 / 없음');
});
}
test('general search is explicit and persists across pagination and reload', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=generals&at=month&year=190&month=6'));
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
const count = requests.filter((r) => r.operation === 'playAudit.generals').length;
await page.getByLabel('장수 이름', { exact: true }).fill('감사');
await page.getByLabel('장수 번호 정렬', { exact: true }).selectOption('desc');
expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(count);
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect(page).toHaveURL(/name=/);
await expect
.poll(() => requests.filter((r) => r.operation === 'playAudit.generals').at(-1)?.input)
.toMatchObject({
name: '감사',
order: 'desc',
at: { year: 190, month: 6, kind: 'MONTH_END' },
});
await page.getByRole('button', { name: '다음 50개 불러오기' }).click();
await expect
.poll(() => requests.filter((r) => r.operation === 'playAudit.generals').at(-1)?.input)
.toMatchObject({
name: '감사',
order: 'desc',
cursor: 1,
});
await page.reload();
await expect(page.getByLabel('장수 이름', { exact: true })).toHaveValue('감사');
await expect(page.getByLabel('장수 번호 정렬', { exact: true })).toHaveValue('desc');
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
await capture(page, 'mobile-general-search');
});
test('NPC decisions are explicit, paginated, independently addressable and escaped', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=generals&at=month&year=190&month=6&general=1'));
await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toBeVisible();
expect(requests.some((r) => r.operation.startsWith('playAudit.decision'))).toBe(false);
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
await expect(page.getByRole('button', { name: '개인 판단 · tick 100', exact: true })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input).toMatchObject({
generalId: 1,
month: { year: 190, month: 6 },
});
expect(requests.some((r) => r.operation === 'playAudit.decisionDetail')).toBe(false);
const counts = {
list: requests.filter((r) => r.operation === 'playAudit.generals').length,
history: requests.filter((r) => r.operation === 'playAudit.decisionHistory').length,
};
await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('<b>징병판정</b>');
await expect(page.getByRole('list', { name: '판단 절차' }).locator('b')).toHaveCount(0);
await page.getByRole('button', { name: '판단 절차 더 불러오기', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('최종 선택');
expect(requests.filter((r) => r.operation === 'playAudit.decisionDetail').at(-1)?.input).toMatchObject({
id: decision.id,
generalId: 1,
cursor: 0,
limit: 1,
});
expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(counts.list);
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory')).toHaveLength(counts.history);
await capture(page, 'mobile-npc-decision');
await page.reload();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정');
await page.getByRole('button', { name: '결정 목록 더 불러오기', exact: true }).click();
await expect(page.getByRole('button', { name: '수뇌 판단 · tick 100', exact: true })).toBeVisible();
});
test('NPC decision detail retry preserves history and other general information', async ({ page }) => {
const requests = await install(page);
let fail = true;
await page.route(gameTrpcRoute, async (route) => {
if (fail && route.request().url().includes('playAudit.decisionDetail')) {
fail = false;
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message: '결정 상세 재시도',
code: -32603,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
},
},
]),
});
} else await route.fallback();
});
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click();
await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('결정 상세 재시도');
await expect(page.getByRole('button', { name: '개인 판단 · tick 100', exact: true })).toBeVisible();
const count = requests.filter((r) => r.operation === 'playAudit.decisionHistory').length;
await page.getByRole('button', { name: '결정 상세 다시 조회', exact: true }).click();
await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정');
expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory')).toHaveLength(count);
await capture(page, 'desktop-npc-decision');
});
test('NPC decision opens its immutable policy without querying policy history', async ({ page }) => {
const requests = await install(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(gamePath(`/play-audit?tab=generals&general=1&decision=${decision.id}`));
await expect(page.getByRole('list', { name: '판단 절차' })).toBeVisible();
expect(requests.some((r) => r.operation === 'playAudit.policyVersion')).toBe(false);
const before = requests.length;
await page.getByText('당시 정책 참조', { exact: true }).click();
await page.getByRole('button', { name: '국방 설정 당시 버전 조회', exact: true }).click();
await expect(page.getByText(/국가 #2 · 버전 1/)).toBeVisible();
expect(requests.slice(before).map((r) => r.operation)).toEqual(['playAudit.policyVersion']);
expect(requests.at(-1)?.input).toEqual({ id: 'a'.repeat(64) });
await expect(page.getByRole('button', { name: '이전 정책 버전', exact: true })).toHaveCount(0);
expect(await page.evaluate(() => Object.hasOwn(window, 'auditInjected'))).toBe(false);
await capture(page, 'mobile-decision-policy');
await page.reload();
await expect(page.getByText(/국가 #2 · 버전 1/)).toBeVisible();
expect(requests.some((r) => r.operation === 'playAudit.policyHistory')).toBe(false);
await page.getByRole('button', { name: '정책 상세 닫기', exact: true }).click();
await expect(page.getByRole('heading', { name: /선택 정책 버전/ })).toHaveCount(0);
const policyReads = requests.filter((r) => r.operation === 'playAudit.policyVersion').length;
await page.goto(gamePath(`/play-audit?tab=generals&general=1&decision=${decision.id}&policy=${'b'.repeat(64)}`));
await expect(page.getByRole('list', { name: '판단 절차' })).toBeVisible();
expect(requests.filter((r) => r.operation === 'playAudit.policyVersion')).toHaveLength(policyReads);
await expect(page.getByRole('heading', { name: /선택 정책 버전/ })).toHaveCount(0);
});