NPC 정책 적용을 검증하고 자국 변경 이력 조회를 제공한다
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user