NPC 정책 적용을 검증하고 자국 변경 이력 조회를 제공한다

This commit is contained in:
2026-10-22 23:50:35 +09:00
parent 87f810a878
commit 039c5d2a59
9 changed files with 977 additions and 3 deletions
+141 -2
View File
@@ -1,6 +1,6 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { devices, expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile } from 'node:fs/promises';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
@@ -14,6 +14,8 @@ type FixtureState = {
mutations: string[];
nationPriorityInput?: string[];
currentNationPriority?: string[];
currentGeneralPriority?: string[];
historyVersion?: number;
};
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -131,7 +133,7 @@ const policyFixture = (state: FixtureState) => ({
currentNationPriority: state.currentNationPriority ?? nationPriority,
availableNationPriorityItems: nationPriority,
defaultGeneralActionPriority: generalPriority,
currentGeneralActionPriority: generalPriority,
currentGeneralActionPriority: state.currentGeneralPriority ?? generalPriority,
availableGeneralActionPriorityItems: generalPriority,
lastSetters: {
policy: { setter: null, date: null },
@@ -175,6 +177,41 @@ const installFixture = async (page: Page, state: FixtureState) => {
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
: response(policyFixture(state));
}
if (operation === 'npc.policyHistory') {
return response({
coverage: 'RECORDED_VERSIONS_ONLY',
nextCursor: null,
items: [
{
id: 'a'.repeat(64),
revision: state.historyVersion ?? 2,
year: 185,
month: 1,
source: 'CHANGE',
actor: { name: '정책담당', generalId: 22 },
},
],
});
}
if (operation === 'npc.policyVersion') {
return response({
id: 'a'.repeat(64),
revision: state.historyVersion ?? 2,
year: 185,
month: 1,
source: 'CHANGE',
createdAt: '2026-10-22T00:00:00Z',
actor: { name: '정책담당', generalId: 22 },
fields: [
{
key: 'priority',
beforeJson: '["NPC전방발령","NPC포상"]',
afterJson: '["NPC포상"]',
changed: true,
},
],
});
}
if (
operation === 'npc.setNationPolicy' ||
operation === 'npc.setNationPriority' ||
@@ -188,6 +225,11 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.failNextMutation = false;
return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN');
}
state.historyVersion = (state.historyVersion ?? 2) + 1;
if (operation === 'npc.setNationPriority' && Array.isArray(jsonInput))
state.currentNationPriority = jsonInput.map(String);
if (operation === 'npc.setGeneralPriority' && Array.isArray(jsonInput))
state.currentGeneralPriority = jsonInput.map(String);
return response({ ok: true });
}
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
@@ -206,6 +248,103 @@ const screenshot = async (page: Page, name: string) => {
await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true });
};
for (const width of [500, 1000]) {
test(`일반 사용자가 정책 변경자와 전후 이력을 조회하고 새로고침한다 ${width}px`, async ({ page }, testInfo) => {
const state: FixtureState = { permissionLevel: 1, mutations: [], currentNationPriority: ['NPC포상'] };
await installFixture(page, state);
await page.setViewportSize({ width, height: 900 });
await gotoPolicy(page);
await page.getByRole('button', { name: '정책 변경 이력 조회' }).click();
const history = page.getByRole('region', { name: 'NPC 정책 변경 이력', exact: true });
await expect(history).toContainText('정책담당 (#22)');
await history.getByLabel('이력 종류').selectOption('NPC_NATION_PRIORITY');
await history.getByRole('button', { name: '버전 2', exact: true }).click();
const detail = page.getByRole('region', { name: '정책 변경 내용', exact: true });
await expect(detail).toContainText('["NPC전방발령","NPC포상"]');
await expect(detail).toContainText('["NPC포상"]');
await history.getByLabel('이력 종류').focus();
await expect(history.getByLabel('이력 종류')).toBeFocused();
const measurements = await history.evaluate((element) => {
const rect = element.getBoundingClientRect();
const cell = element.querySelector('td')!;
return {
rect: rect.toJSON(),
style: {
verticalAlign: getComputedStyle(cell).verticalAlign,
whiteSpace: getComputedStyle(cell.querySelector('pre')!).whiteSpace,
},
html: element.outerHTML,
overflow: document.documentElement.scrollWidth > innerWidth,
};
});
expect(measurements.style).toEqual({ verticalAlign: 'middle', whiteSpace: 'pre-wrap' });
expect(measurements.overflow).toBe(false);
await testInfo.attach('history-dom-geometry-style', {
body: JSON.stringify(measurements, null, 2),
contentType: 'application/json',
});
await writeFile(testInfo.outputPath('history-dom-geometry-style.json'), JSON.stringify(measurements, null, 2));
await page.screenshot({ path: testInfo.outputPath(`npc-history-${width}.png`), fullPage: true });
state.historyVersion = 3;
await history.getByRole('button', { name: '이력 새로고침' }).click();
await expect(history.getByRole('button', { name: '버전 3', exact: true })).toBeVisible();
await page.reload();
const nationPanel = page.locator('.priority-panel').first();
await expect(
nationPanel.locator('.priority-column').nth(1).getByText('NPC전방발령', { exact: true })
).toHaveCount(0);
await expect(
nationPanel.locator('.priority-column').first().getByText('NPC전방발령', { exact: true })
).toBeVisible();
});
}
test('발령 비활성화와 징병 우선순위를 저장한 뒤 새로고침해도 유지되고 열린 이력이 갱신된다', async ({ page }) => {
const state: FixtureState = {
permissionLevel: 4,
mutations: [],
currentNationPriority: ['NPC전방발령', 'NPC포상'],
currentGeneralPriority: ['출병', '일반내정', '징병'],
};
await installFixture(page, state);
await page.setViewportSize({ width: 1000, height: 900 });
await gotoPolicy(page);
await page.getByRole('button', { name: '정책 변경 이력 조회' }).click();
const history = page.getByRole('region', { name: 'NPC 정책 변경 이력', exact: true });
await expect(history.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
const nationPanel = page.locator('.priority-panel').first();
await nationPanel
.locator('.priority-column')
.nth(1)
.getByText('NPC전방발령', { exact: true })
.dragTo(nationPanel.locator('.priority-column').first().locator('.priority-list'));
await nationPanel.locator('.submit_btn').click();
await acceptAppConfirmation(page, '저장할까요?');
await expect(history.getByRole('button', { name: '버전 3', exact: true })).toBeVisible();
const generalPanel = page.locator('.priority-panel').nth(1);
const active = generalPanel.locator('.priority-column').nth(1);
// 목록을 화면 중앙에 놓아 창 가장자리의 Sortable auto-scroll을 피한다.
await active
.locator('.priority-list')
.evaluate((element) => element.scrollIntoView({ block: 'center', behavior: 'instant' }));
await touchDrag(page, active.getByText('징병', { exact: true }), active.getByText('출병', { exact: true }), {
targetYRatio: 0.1,
});
await expect(active.locator('.priority_info > span:nth-child(2)').first()).toHaveText('징병');
await generalPanel.locator('.submit_btn').click();
await acceptAppConfirmation(page, '저장할까요?');
await expect(history.getByRole('button', { name: '버전 4', exact: true })).toBeVisible();
expect(state.currentNationPriority).toEqual(['NPC포상']);
expect(state.currentGeneralPriority).toEqual(['징병', '출병', '일반내정']);
await page.reload();
await expect(
nationPanel.locator('.priority-column').first().getByText('NPC전방발령', { exact: true })
).toBeVisible();
await expect(
generalPanel.locator('.priority-column').nth(1).locator('.priority_info > span:nth-child(2)').first()
).toHaveText('징병');
});
test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({
page,
}) => {
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
import { fieldLabels } from './playAudit/policyLabels';
const props = defineProps<{ revision: number }>();
type History = Awaited<ReturnType<typeof trpc.npc.policyHistory.query>>;
type Version = Awaited<ReturnType<typeof trpc.npc.policyVersion.query>>;
const open = ref(false);
const area = ref<'NPC_VALUES' | 'NPC_NATION_PRIORITY' | 'NPC_GENERAL_PRIORITY'>('NPC_VALUES');
const history = ref<History | null>(null);
const version = ref<Version | null>(null);
const loading = ref(false);
const error = ref('');
const labels = { BASELINE: '최초 관측', CHANGE: '변경', OBSERVED_GAP: '관측 누락 이후 기준' };
let generation = 0;
const load = async (append = false) => {
const request = ++generation;
loading.value = true;
error.value = '';
if (!append) {
history.value = null;
version.value = null;
}
try {
const response = await trpc.npc.policyHistory.query({
area: area.value,
cursor: append ? (history.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
history.value = {
...response,
items: append ? [...(history.value?.items ?? []), ...response.items] : response.items,
};
} catch (cause) {
if (request === generation)
error.value = cause instanceof Error ? cause.message : '이력을 조회하지 못했습니다.';
} finally {
if (request === generation) loading.value = false;
}
};
const select = async (id: string) => {
const request = ++generation;
loading.value = true;
error.value = '';
version.value = null;
try {
const response = await trpc.npc.policyVersion.query({ id });
if (request === generation) version.value = response;
} catch (cause) {
if (request === generation)
error.value = cause instanceof Error ? cause.message : '변경 내용을 조회하지 못했습니다.';
} finally {
if (request === generation) loading.value = false;
}
};
watch([open, area, () => props.revision], () => {
if (open.value) void load();
else {
++generation;
loading.value = false;
history.value = null;
version.value = null;
}
});
</script>
<template>
<section class="npc-policy-history" aria-label="NPC 정책 변경 이력">
<button class="legacy-button" :aria-expanded="open" @click="open = !open">
정책 변경 이력 {{ open ? '닫기' : '조회' }}
</button>
<div v-if="open">
<p>자국의 현재 기수 기록입니다. 최초 관측 이전의 변경은 복원하지 않습니다.</p>
<p>우선순위는 실행을 시도하는 순서입니다. 징병은 전쟁 상태·병력·인구·자원 조건을 만족해야 합니다.</p>
<p>0이면 자동 계산하는 항목은 저장값이 같아도 기술·내정 비용에 따라 기준이 달라질 수 있습니다.</p>
<label
>이력 종류
<select v-model="area">
<option value="NPC_VALUES">국가 정책</option>
<option value="NPC_NATION_PRIORITY">국가턴 우선순위</option>
<option value="NPC_GENERAL_PRIORITY">일반턴 우선순위</option>
</select>
</label>
<button class="legacy-button" :disabled="loading" @click="load()">이력 새로고침</button>
<p v-if="loading" role="status">조회 중…</p>
<p v-if="error" role="alert">{{ error }}</p>
<template v-if="history">
<p v-if="history.historyGap" role="status">
{{ history.historyGap.firstYear }}년 {{ history.historyGap.firstMonth }}월 이후 일부 기록에 누락이
있습니다. 이력이 없다는 사실만으로 변경이 없었다고 판단할 수 없습니다.
</p>
<p v-if="history.coverage === 'IDENTITY_MISSING'">기수 식별자가 없어 이력을 조회할 수 없습니다.</p>
<p v-else-if="!history.items.length">기록된 정책 이력이 없습니다.</p>
<ol v-else>
<li v-for="item in history.items" :key="item.id">
<button class="legacy-button" :disabled="loading" @click="select(item.id)">
버전 {{ item.revision }}
</button>
{{ item.year }}년 {{ item.month }}월 · {{ labels[item.source] }} ·
{{ item.actor ? `${item.actor.name} (#${item.actor.generalId})` : '변경 주체 미상' }}
</li>
</ol>
<button
v-if="history.nextCursor !== null"
class="legacy-button"
:disabled="loading"
@click="load(true)"
>
이전 기록 더 보기
</button>
</template>
<section v-if="version" aria-label="정책 변경 내용">
<h3>버전 {{ version.revision }} · {{ version.actor?.name ?? '변경 주체 미상' }}</h3>
<p>{{ version.year }}년 {{ version.month }}월 · 기록 시각 {{ version.createdAt }}</p>
<p v-if="version.source !== 'CHANGE'">관측 기준입니다. 이전 값과 변경 주체를 추정하지 않습니다.</p>
<p>null은 개별 설정 없이 기본값을 상속함을 뜻합니다.</p>
<div class="table-scroll" tabindex="0" aria-label="정책 변경 전후 값">
<table>
<thead>
<tr>
<th>설정</th>
<th>변경 전</th>
<th>변경 후</th>
</tr>
</thead>
<tbody>
<tr v-for="field in version.fields" :key="field.key" :class="{ changed: field.changed }">
<th scope="row">
{{ fieldLabels[field.key] ?? field.key }}{{ field.changed ? ' (변경)' : '' }}
</th>
<td>
<pre>{{ field.beforeJson ?? '관측하지 않음' }}</pre>
</td>
<td>
<pre>{{ field.afterJson }}</pre>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.npc-policy-history {
margin-top: 16px;
padding: 8px;
border: 1px solid gray;
}
select,
button {
margin: 4px;
vertical-align: middle;
}
li {
margin-block: 6px;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid gray;
padding: 6px;
text-align: left;
vertical-align: middle;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
min-width: 100px;
margin: 0;
font: inherit;
}
.changed {
background: #343425;
}
[role='alert'] {
color: #ffb9b9;
}
h3 {
font-size: var(--sammo-font-size-normal);
}
</style>
@@ -3,12 +3,14 @@ import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref, watch } from 'vue';
import NpcPolicyHistory from '../components/NpcPolicyHistory.vue';
import SortableStringList from '../components/ui/SortableStringList';
import { useGameFeedback } from '../composables/useGameFeedback';
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit();
const historyRevision = ref(0);
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
@@ -308,6 +310,7 @@ const submitPolicy = async () => {
try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
lastSavedPolicy.value = clonePolicy(policyDraft.value);
historyRevision.value += 1;
showSuccessToast('NPC 정책이 반영되었습니다.');
} catch (caught) {
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
@@ -357,6 +360,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
await trpc.npc.setGeneralPriority.mutate(state.active);
lastSavedGeneralPriority.value = [...state.active];
}
historyRevision.value += 1;
showSuccessToast('NPC 정책이 반영되었습니다.');
} catch (caught) {
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
@@ -547,6 +551,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
</section>
</div>
</section>
<NpcPolicyHistory v-if="data" :key="data.nationId" :revision="historyRevision" />
<div class="sortable-compat-controls" aria-hidden="true">
<button type="button" tabindex="-1" />
<input v-for="index in 20" :key="index" type="hidden" />