Files
core2026/tools/integration-tests/test/turnSnapshotCanonicalCoverage.test.ts
T
Hide_D 8d966d3152 test: 실제 transport 권한과 커맨드 내구 행렬을 검증한다
실제 Fastify HTTP, PostgreSQL과 Redis에서 예약 mutation의 인증·sanction·소유권·수뇌 경계, 감사 원장과 무부작용을 검증한다.

장수 55개와 수뇌 35개를 R1/R2/R3으로 전수 분류하고 대표 9개를 Ref/Core 의미 비교부터 lease, 단일 flush와 fresh reload까지 실행한다. 비행위자 revision/lease와 관직 5 수뇌 partition도 exact 보존한다.
2026-08-24 07:41:12 +00:00

529 lines
19 KiB
TypeScript

import { GameClock, MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
import { describe, expect, it } from 'vitest';
import {
closeTurnSnapshotSelectorOverCreatedEntities,
projectCoreDatabaseSnapshot,
type CanonicalTurnSnapshot,
} from '../src/turn-differential/canonical.js';
import { compareTurnSnapshotDeltas, compareTurnSnapshots } from '../src/turn-differential/compare.js';
import { projectCoreMessageDrafts, projectCoreMessageReadState } from '../src/turn-differential/coreCommandTrace.js';
import { projectEffectiveCoreMessageValidUntil } from '../src/turn-differential/databaseSnapshot.js';
import { projectFullLifecycleSnapshotGraph } from '../src/turn-differential/fullLifecycleFixture.js';
interface CommandStateFixture {
generalMeta: Record<string, unknown>;
generalFields?: Record<string, unknown>;
nationMeta: Record<string, unknown>;
nationFields?: Record<string, unknown>;
}
const databaseSnapshot = (
latestReadPrivateMessage = 0,
commandStateFixture?: CommandStateFixture
): CanonicalTurnSnapshot =>
projectCoreDatabaseSnapshot({
world: {
currentYear: 183,
currentMonth: 1,
tickSeconds: 600,
meta: { lastTurnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
gameNow: new Date('0183-01-01T00:10:00.000Z'),
lastTurnTick: 0,
},
generals: [
{
id: 1,
name: '조조',
nationId: 1,
cityId: 1,
troopId: 1,
userId: 'owner-a',
personalCode: 'che_안전',
specialCode: 'che_농업',
special2Code: 'che_신산',
horseCode: 'che_명마_01_적토마',
weaponCode: 'che_무기_07_맥궁',
bookCode: 'che_서적_01_손자병법',
itemCode: 'che_도구_01_옥새',
meta: commandStateFixture?.generalMeta ?? {},
penalty: {},
...commandStateFixture?.generalFields,
},
],
rankData: [],
cities: [],
nations: commandStateFixture
? [
{
id: 1,
name: '위',
color: '#111111',
capitalCityId: 1,
gold: 1_000,
rice: 1_000,
tech: 100,
level: 1,
typeCode: 'che_명가',
meta: commandStateFixture.nationMeta,
...commandStateFixture.nationFields,
},
]
: [],
troops: [{ troopLeaderId: 1, nationId: 1, name: '조조군' }],
diplomacy: [],
generalTurns: [],
nationTurns: [],
logs: [],
messages: [
{
id: 71,
mailbox: 1,
type: 'private',
src: 2,
dest: 1,
time: new Date('0183-01-01T00:10:00.000Z'),
validUntil: new Date('0183-04-01T00:10:00.000Z'),
message: { text: '등용 서신' },
},
],
messageReadStates: [
{
generalId: 1,
latestPrivateMessage: latestReadPrivateMessage,
latestDiplomacyMessage: 0,
},
],
messageInboxRows: [{ id: 71, mailbox: 1, type: 'private', src: 2 }],
messageWatermark: 71,
});
describe('turn snapshot canonical blind-spot coverage', () => {
it('projects Prisma general role and item column names into canonical fields', () => {
expect(databaseSnapshot().generals[0]).toMatchObject({
personality: 'che_안전',
specialDomestic: 'che_농업',
specialWar: 'che_신산',
itemHorse: 'che_명마_01_적토마',
itemWeapon: 'che_무기_07_맥궁',
itemBook: 'che_서적_01_손자병법',
itemExtra: 'che_도구_01_옥새',
});
});
it('projects troop rows and detects a troop mutant', () => {
const reference = databaseSnapshot();
const core = {
...databaseSnapshot(),
troops: [{ id: 1, nationId: 1, name: '변조된 부대' }],
};
expect(reference.troops).toEqual([{ id: 1, nationId: 1, name: '조조군' }]);
expect(reference.world.gameNow).toBe('0183-01-01T00:10:00.000Z');
expect(compareTurnSnapshots(reference, core)).toContainEqual({
path: 'troops[1].name',
reference: '조조군',
core: '변조된 부대',
});
});
it('projects persisted message rows and detects a message mutant', () => {
const reference = databaseSnapshot();
const core = {
...databaseSnapshot(),
messages: [{ ...databaseSnapshot().messages[0], sourceId: 9 }],
};
expect(reference.messages).toEqual([
{
id: 71,
mailbox: 1,
type: 'private',
sourceId: 2,
destinationId: 1,
createdAt: '0183-01-01T00:10:00.000Z',
validUntil: '0183-04-01T00:10:00.000Z',
payload: { text: '등용 서신' },
},
]);
expect(reference.watermarks.messageId).toBe(71);
expect(compareTurnSnapshots(reference, core)).toContainEqual({
path: 'messages[0].sourceId',
reference: 2,
core: 9,
});
});
it('expands a Core message draft into the persisted receiver and sender rows', async () => {
const messages = await projectCoreMessageDrafts(
[
{
msgType: 'private',
src: {
generalId: 1,
generalName: '조조',
nationId: 1,
nationName: '위',
color: '#111111',
icon: '1.webp',
},
dest: {
generalId: 2,
generalName: '유비',
nationId: 2,
nationName: '촉',
color: '#222222',
icon: '2.webp',
},
text: '등용 서신',
time: new Date('0183-01-01T00:10:00.000Z'),
validUntil: new Date('0183-04-01T00:10:00.000Z'),
},
],
70
);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
id: 71,
mailbox: 2,
type: 'private',
sourceId: 1,
destinationId: 2,
validUntil: '0183-04-01T00:10:00.000Z',
payload: { text: '등용 서신' },
});
expect(messages[1]).toMatchObject({
id: 72,
mailbox: 1,
validUntil: '0183-04-01T00:10:00.000Z',
payload: { option: { receiverMessageID: 71 } },
});
expect(projectCoreMessageReadState(2, 2, messages)).toEqual({
unreadPrivateCount: 1,
unreadDiplomacyCount: 0,
hasUnreadMessage: true,
});
});
it('uses a persisted validity tick ahead of the Date fallback and keeps infinity explicit', () => {
const clock = new GameClock({
baseTime: new Date('0183-01-01T00:00:00.000Z'),
tick: 0,
mode: 'manual',
wallAnchor: new Date('0183-01-01T00:00:00.000Z'),
turnSeconds: 600,
});
const oneMinuteTick = clock.dateToTick(new Date('0183-01-01T00:01:00.000Z'));
expect(
projectEffectiveCoreMessageValidUntil(
{
validUntil: new Date('0183-01-02T00:00:00.000Z'),
validUntilTick: BigInt(oneMinuteTick),
},
clock
)
).toBe('0183-01-01T00:01:00.000Z');
expect(
projectEffectiveCoreMessageValidUntil(
{
validUntil: new Date('0183-01-02T00:00:00.000Z'),
validUntilTick: BigInt(MAX_SAFE_GAME_TICK),
},
clock
)
).toBe('infinite');
expect(
projectEffectiveCoreMessageValidUntil(
{ validUntil: new Date('0183-01-02T00:00:00.000Z'), validUntilTick: null },
clock
)
).toBe('0183-01-02T00:00:00.000Z');
});
it('compares stable owner identity instead of only owner presence', () => {
const reference = databaseSnapshot();
const core = {
...databaseSnapshot(),
generals: [{ ...databaseSnapshot().generals[0], ownerIdentity: 'owner-b' }],
};
expect(reference.generals[0]).toMatchObject({ hasOwner: true, ownerIdentity: 'owner-a' });
expect(compareTurnSnapshots(reference, core)).toContainEqual({
path: 'generals[1].ownerIdentity',
reference: 'owner-a',
core: 'owner-b',
});
});
it('detects a mutant that marks a generated incoming message as already read', () => {
const reference = databaseSnapshot();
const core = databaseSnapshot(71);
expect(reference.generals[0]?.messageReadState).toEqual({
unreadPrivateCount: 1,
unreadDiplomacyCount: 0,
hasUnreadMessage: true,
});
expect(compareTurnSnapshots(reference, core)).toEqual(
expect.arrayContaining([
{
path: 'generals[1].messageReadState.hasUnreadMessage',
reference: true,
core: false,
},
{
path: 'generals[1].messageReadState.unreadPrivateCount',
reference: 1,
core: 0,
},
])
);
});
it('closes the after selector over created entities and exposes an omission mutant', () => {
const selector = closeTurnSnapshotSelectorOverCreatedEntities(
{ generalIds: [1], cityIds: [1], nationIds: [1], troopIds: [] },
{ generalIds: [1], cityIds: [1], nationIds: [1], troopIds: [] },
{ generalIds: [1, 2], cityIds: [1], nationIds: [1, 2], troopIds: [2] }
);
expect(selector).toMatchObject({ generalIds: [1, 2], nationIds: [1, 2], troopIds: [2] });
const beforeReference = databaseSnapshot();
const afterReference = {
...databaseSnapshot(),
generals: [...databaseSnapshot().generals, { id: 2, ownerIdentity: null }],
};
const beforeCore = databaseSnapshot();
const afterCore = databaseSnapshot();
expect(compareTurnSnapshotDeltas(beforeReference, afterReference, beforeCore, afterCore)).not.toEqual([]);
});
it('keeps persisted actor rank rows in the full-lifecycle graph and catches an upsert omission', () => {
const snapshot = {
...databaseSnapshot(),
rankData: [
{ generalId: 1, nationId: 1, type: 'dedication', value: 1_015 },
{ generalId: 1, nationId: 1, type: 'experience', value: 1_015 },
],
};
const omitted = {
...snapshot,
rankData: snapshot.rankData.filter((row) => row.type !== 'experience'),
};
expect(projectFullLifecycleSnapshotGraph(snapshot).actorRankData).toEqual([
{ nationId: 1, type: 'dedication', value: 1_015 },
{ nationId: 1, type: 'experience', value: 1_015 },
]);
expect(projectFullLifecycleSnapshotGraph(omitted)).not.toEqual(projectFullLifecycleSnapshotGraph(snapshot));
});
it('projects command-semantic meta outside the ignored raw meta graph and catches omission mutants', () => {
const generalMeta = {
armType: 3,
explevel: 4,
dedlevel: 2,
npc_org: 4,
text: '의병 소개',
};
const generalFields = {
affinity: 37,
bornYear: 170,
deadYear: 210,
npcState: 4,
turnTick: 1_027_407n,
};
const nationMeta = {
can_국기변경: 1,
can_무작위수도이전: 1,
spy: { 7: 3 },
collapsed: true,
rate: 20,
bill: 100,
secretlimit: 3,
};
const expectedFixture = { generalMeta, generalFields, nationMeta };
const before = databaseSnapshot(0, { generalMeta: {}, nationMeta: {} });
const expectedAfter = databaseSnapshot(0, expectedFixture);
expect(expectedAfter.generals[0]).toMatchObject({
expLevel: 4,
dedLevel: 2,
affinity: 37,
bornYear: 170,
deadYear: 210,
npcState: 4,
npcOriginalState: 4,
npcMessage: '의병 소개',
turnTick: 1_027_407,
turnSecond: 17,
turnFraction: 123_450,
});
expect(expectedAfter.generals[0]?.commandState).toEqual({ recruitmentArmType: 3 });
expect(expectedAfter.nations[0]?.commandState).toEqual({
flagChangesRemaining: 1,
randomCapitalMovesRemaining: 1,
spy: [{ cityId: 7, remainingTurns: 3 }],
collapsed: true,
rate: 20,
bill: 100,
secretLimit: 3,
});
const ignoredRawMeta = [/^generals\[[^\]]+\]\.meta(?:\.|$)/, /^nations\[[^\]]+\]\.meta(?:\.|$)/];
const mutants: Array<{ path: string; snapshot: CanonicalTurnSnapshot }> = [
{
path: 'generals[1].commandState.recruitmentArmType',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalMeta: { ...generalMeta, armType: undefined },
}),
},
{
path: 'generals[1].expLevel',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalMeta: { ...generalMeta, explevel: 0 },
}),
},
{
path: 'generals[1].dedLevel',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalMeta: { ...generalMeta, dedlevel: 0 },
}),
},
{
path: 'generals[1].affinity',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, affinity: null },
}),
},
{
path: 'generals[1].bornYear',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, bornYear: 171 },
}),
},
{
path: 'generals[1].deadYear',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, deadYear: 211 },
}),
},
{
path: 'generals[1].npcState',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, npcState: 3 },
}),
},
{
path: 'generals[1].npcOriginalState',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalMeta: { ...generalMeta, npc_org: undefined },
}),
},
{
path: 'generals[1].npcMessage',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalMeta: { ...generalMeta, text: null },
}),
},
{
path: 'generals[1].turnSecond',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, turnTick: 1_087_407n },
}),
},
{
path: 'generals[1].turnFraction',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, turnTick: 1_027_408n },
}),
},
{
path: 'generals[1].turnTick',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, turnTick: 1_027_408n },
}),
},
{
path: 'nations[1].commandState.flagChangesRemaining',
snapshot: databaseSnapshot(0, {
generalMeta,
generalFields,
nationMeta: { ...nationMeta, can_국기변경: 0 },
}),
},
{
path: 'nations[1].commandState.randomCapitalMovesRemaining',
snapshot: databaseSnapshot(0, {
generalMeta,
generalFields,
nationMeta: { ...nationMeta, can_무작위수도이전: 0 },
}),
},
{
path: 'nations[1].commandState.spy',
snapshot: databaseSnapshot(0, {
generalMeta,
generalFields,
nationMeta: { ...nationMeta, spy: {} },
}),
},
{
path: 'nations[1].commandState.collapsed',
snapshot: databaseSnapshot(0, {
generalMeta,
generalFields,
nationMeta: { ...nationMeta, collapsed: false },
}),
},
{
path: 'nations[1].commandState.rate',
snapshot: databaseSnapshot(0, {
...expectedFixture,
nationMeta: { ...nationMeta, rate: 0 },
}),
},
{
path: 'nations[1].commandState.bill',
snapshot: databaseSnapshot(0, {
...expectedFixture,
nationMeta: { ...nationMeta, bill: 0 },
}),
},
{
path: 'nations[1].commandState.secretLimit',
snapshot: databaseSnapshot(0, {
...expectedFixture,
nationMeta: { ...nationMeta, secretlimit: 2 },
}),
},
];
for (const mutant of mutants) {
const differences = compareTurnSnapshotDeltas(before, expectedAfter, before, mutant.snapshot, {
ignoredPathPatterns: ignoredRawMeta,
});
expect(
differences.some(
(difference) => difference.path === mutant.path || difference.path.startsWith(`${mutant.path}[`)
),
mutant.path
).toBe(true);
}
});
});