feat: 서버 준비 전에 감사 초기 상태와 정책 기준을 저장

This commit is contained in:
2026-09-16 05:22:01 +00:00
parent 3d60e6122d
commit 8faab62866
20 changed files with 481 additions and 41 deletions
+30 -1
View File
@@ -11,6 +11,7 @@ const world = {
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 };
@@ -201,7 +202,12 @@ const install = async (page: Page, denied = false) => {
return result({
...world,
collected: true,
sample: { year: 190, month: 6, kind: 'FINAL', settlementsComplete: 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: '촉',
@@ -463,6 +469,7 @@ test('selected general reads detail on demand and separates current reservations
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);
});
@@ -682,3 +689,25 @@ test('policy filter drafts do not read until applied, including default dates',
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');
});
@@ -2,7 +2,10 @@
import { ref, watch } from 'vue';
import PanelCard from '../ui/PanelCard.vue';
import { trpc } from '../../utils/trpc';
const props = defineProps<{ cityId: number; at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' } }>();
const props = defineProps<{
cityId: number;
at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' | 'INITIAL' };
}>();
defineEmits<{ close: []; generals: [cityId: number] }>();
type Detail = Awaited<ReturnType<typeof trpc.playAudit.cityDetail.query>>;
const data = ref<Detail | null>(null);
@@ -37,7 +40,11 @@ watch(
<template>
<PanelCard
title="선택 도시 상세"
:subtitle="at ? `${at.year}년 ${at.month}월 ${at.kind === 'FINAL' ? '최종 표본' : '월말'}` : '현재 상태'"
:subtitle="
at
? `${at.year}년 ${at.month}월 ${at.kind === 'FINAL' ? '최종 표본' : at.kind === 'INITIAL' ? '수집 시작 기준' : '월말'}`
: '현재 상태'
"
>
<template #actions><button class="legacy-button" @click="$emit('close')">상세 닫기</button></template>
<p v-if="loading" role="status">도시 조회 </p>
@@ -3,7 +3,10 @@ import { ref, watch } from 'vue';
import PanelCard from '../ui/PanelCard.vue';
import AuditGeneralLogs from './AuditGeneralLogs.vue';
import { trpc } from '../../utils/trpc';
const props = defineProps<{ generalId: number; at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' } }>();
const props = defineProps<{
generalId: number;
at?: { year: number; month: number; kind: 'MONTH_END' | 'FINAL' | 'INITIAL' };
}>();
defineEmits<{ close: [] }>();
type Detail = Awaited<ReturnType<typeof trpc.playAudit.generalDetail.query>>;
type Turns = Awaited<ReturnType<typeof trpc.playAudit.generalTurns.query>>;
@@ -69,7 +72,11 @@ watch(
<template>
<PanelCard
title="선택 장수 상세"
:subtitle="at ? `${at.year}년 ${at.month}월 ${at.kind === 'FINAL' ? '최종 표본' : '월말'}` : '현재 상태'"
:subtitle="
at
? `${at.year}년 ${at.month}월 ${at.kind === 'FINAL' ? '최종 표본' : at.kind === 'INITIAL' ? '수집 시작 기준' : '월말'}`
: '현재 상태'
"
>
<template #actions><button class="legacy-button" @click="$emit('close')">상세 닫기</button></template>
<p v-if="loading" role="status">상세 조회 </p>
@@ -93,7 +100,7 @@ watch(
{{ format(data.general.dedication) }}
</p>
<p>숙련 ( / / / / ): {{ Object.values(data.general.dex).map(format).join(' / ') }}</p>
<p v-if="at">과거 예약 명령은 월말 표본에 포함되지 않습니다.</p>
<p v-if="at">과거 예약 명령은 상태 표본에 포함되지 않습니다.</p>
<button v-else class="legacy-button" :disabled="turnsLoading" @click="loadTurns()">
현재 예약 명령 조회
</button>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import { trpc } from '../../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>;
defineProps<{ data: Snapshot }>();
const props = defineProps<{ data: Snapshot }>();
const label = computed(() => (props.data.sample?.kind === 'INITIAL' ? '수집 시작 기준' : '최종 표본'));
const format = (value: number | null) =>
value === null ? '자료 없음' : value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const groups = [
@@ -12,13 +14,11 @@ const groups = [
</script>
<template>
<p v-if="!data.collected">선택한 시점의 최종 표본이 없습니다.</p>
<p v-else-if="!data.nation">최종 표본에 해당 국가가 없습니다.</p>
<p v-if="!data.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!data.nation">표본에 해당 국가가 없습니다.</p>
<template v-else>
<h3>{{ data.nation.name }} · {{ data.sample?.year }} {{ data.sample?.month }} 최종 표본</h3>
<p>
최종 표본은 월말 시계열과 별도로 표시합니다. 아래 정산은 표본을 수집할 때까지 해당 월에 관측한 값입니다.
</p>
<h3>{{ data.nation.name }} · {{ data.sample?.year }} {{ data.sample?.month }} {{ label }}</h3>
<p>월말 시계열과 구분되는 표본입니다. 아래 정산은 표본을 수집할 때까지 해당 월에 관측한 값입니다.</p>
<p v-if="!data.sample?.settlementsComplete">정산 수집이 불완전한 월입니다.</p>
<dl class="nation-values">
<div>
@@ -38,7 +38,7 @@ const groups = [
<dd>{{ format(data.nation.paidGold) }} / {{ format(data.nation.paidRice) }}</dd>
</div>
</dl>
<div class="table-scroll" tabindex="0" aria-label="최종 국가 장수 집계">
<div class="table-scroll" tabindex="0" :aria-label="`${label} 국가 장수 집계`">
<table>
<thead>
<tr>
+26 -9
View File
@@ -68,11 +68,16 @@ const selectedGeneral = computed(() =>
typeof route.query.general === 'string' && /^\d+$/.test(route.query.general) ? Number(route.query.general) : null
);
const selectedAt = computed(() =>
route.query.at === 'month' || route.query.at === 'final'
route.query.at === 'month' || route.query.at === 'final' || route.query.at === 'initial'
? {
year: numeric(route.query.year, coverage.value?.year ?? 0),
month: numeric(route.query.month, coverage.value?.month ?? 1),
kind: route.query.at === 'final' ? ('FINAL' as const) : ('MONTH_END' as const),
kind:
route.query.at === 'final'
? ('FINAL' as const)
: route.query.at === 'initial'
? ('INITIAL' as const)
: ('MONTH_END' as const),
}
: undefined
);
@@ -93,16 +98,21 @@ const at = computed(() =>
: {
year: year.value,
month: month.value,
kind: moment.value === 'final' ? ('FINAL' as const) : ('MONTH_END' as const),
kind:
moment.value === 'final'
? ('FINAL' as const)
: moment.value === 'initial'
? ('INITIAL' as const)
: ('MONTH_END' as const),
}
);
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const nationName = (id: number) =>
nations.value?.items.find((item) => item.id === id)?.name ?? (id === 0 ? '무소속' : `국가 #${id}`);
const scopeLabel = computed(() =>
route.query.at !== 'month' && route.query.at !== 'final'
route.query.at !== 'month' && route.query.at !== 'final' && route.query.at !== 'initial'
? '현재 상태'
: `${route.query.year}${route.query.month}${route.query.at === 'final' ? '최종 표본' : '월말'}`
: `${route.query.year}${route.query.month}${route.query.at === 'final' ? '최종 표본' : route.query.at === 'initial' ? '수집 시작 기준' : '월말'}`
);
const result = computed(() =>
tab.value === 'generals'
@@ -123,7 +133,7 @@ const readQuery = () => {
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
? String(route.query.population)
: '';
moment.value = ['month', 'final'].includes(String(route.query.at)) ? String(route.query.at) : 'current';
moment.value = ['month', 'final', 'initial'].includes(String(route.query.at)) ? String(route.query.at) : 'current';
year.value = numeric(route.query.year, coverage.value?.year ?? 0);
month.value = numeric(route.query.month, coverage.value?.month ?? 1);
const defaultStart = Math.max(
@@ -175,10 +185,10 @@ const load = async (append = false) => {
};
} else if (tab.value === 'policies') {
// 정책 목록/상세는 해당 component가 필요한 요청만 실행한다.
} else if (nationId.value !== '' && moment.value === 'final') {
} else if (nationId.value !== '' && (moment.value === 'final' || moment.value === 'initial')) {
const response = await trpc.playAudit.nationSnapshot.query({
nationId: Number(nationId.value),
at: { year: year.value, month: month.value, kind: 'FINAL' },
at: { year: year.value, month: month.value, kind: moment.value === 'initial' ? 'INITIAL' : 'FINAL' },
});
if (request === generation) nationSnapshot.value = response;
} else if (nationId.value !== '') {
@@ -310,6 +320,10 @@ onMounted(async () => {
<p v-else-if="coverage.status === 'NOT_COLLECTED'">
아직 수집된 월별 표본이 없습니다. 현재 상태는 조회할 있습니다.
</p>
<p v-if="coverage.collectionStart">
상태·정책 수집 시작: {{ coverage.collectionStart.year }} {{ coverage.collectionStart.month }} ·
{{ coverage.collectionStart.observedAt }}. 이전 상태를 소급 복원하지 않습니다.
</p>
<form class="filters" @submit.prevent="apply">
<label
>조회 대상<select class="legacy-sort-select" v-model="tab">
@@ -358,6 +372,7 @@ onMounted(async () => {
<option value="current">현재</option>
<option value="month">월말</option>
<option value="final">최종 표본</option>
<option value="initial">수집 시작 기준</option>
</select></label
>
<label
@@ -377,7 +392,9 @@ onMounted(async () => {
:max="year === coverage.year ? coverage.month : 12"
required
/></label>
<template v-if="(tab === 'nations' && moment !== 'final') || tab === 'policies'">
<template
v-if="(tab === 'nations' && moment !== 'final' && moment !== 'initial') || tab === 'policies'"
>
<label
>시작 연도<input
v-model.number="fromYear"