test: add turn command state differential harness
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findTurnDifferentialWorkspaceRoot,
|
||||
runReferenceTurnCommandTrace,
|
||||
} from '../src/turn-differential/referenceSnapshot.js';
|
||||
|
||||
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||
|
||||
integration('legacy command trace runner', () => {
|
||||
it('runs a nation declaration against a disposable cloned database', () => {
|
||||
const trace = runReferenceTurnCommandTrace(
|
||||
workspaceRoot!,
|
||||
'fixtures/turn-differential/nation-declaration.json'
|
||||
);
|
||||
|
||||
expect(trace.execution).toMatchObject({
|
||||
kind: 'nation',
|
||||
action: 'che_선전포고',
|
||||
seedDomain: 'nationCommand',
|
||||
});
|
||||
expect(trace.rng).toEqual([]);
|
||||
expect(trace.after.diplomacy).toEqual([
|
||||
expect.objectContaining({ fromNationId: 1, toNationId: 2, state: 1, term: 24 }),
|
||||
expect.objectContaining({ fromNationId: 2, toNationId: 1, state: 1, term: 24 }),
|
||||
]);
|
||||
expect(trace.after.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('runs live sortie through conquest and nation collapse', () => {
|
||||
const trace = runReferenceTurnCommandTrace(
|
||||
workspaceRoot!,
|
||||
'fixtures/turn-differential/live-sortie-conquest.json'
|
||||
);
|
||||
|
||||
expect(trace.execution).toMatchObject({
|
||||
kind: 'general',
|
||||
action: 'che_출병',
|
||||
seedDomain: 'generalCommand',
|
||||
});
|
||||
expect(trace.rng).toEqual([
|
||||
{
|
||||
seq: 0,
|
||||
operation: 'nextInt',
|
||||
arguments: { maxInclusive: 0 },
|
||||
result: 0,
|
||||
},
|
||||
]);
|
||||
expect(trace.after.cities).toContainEqual(expect.objectContaining({ id: 70, nationId: 1 }));
|
||||
expect(trace.after.nations).not.toContainEqual(expect.objectContaining({ id: 2 }));
|
||||
expect(trace.after.generals).toContainEqual(expect.objectContaining({ id: 2, nationId: 0 }));
|
||||
expect(trace.after.logs.some((log) => String(log.text).includes('점령'))).toBe(true);
|
||||
expect(trace.after.logs.some((log) => String(log.text).includes('멸망'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { canonicalizeTurnCommandArgs, type CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js';
|
||||
import {
|
||||
buildTurnSnapshotDelta,
|
||||
compareTurnSnapshotDeltas,
|
||||
compareTurnSnapshots,
|
||||
} from '../src/turn-differential/compare.js';
|
||||
|
||||
const snapshot = (
|
||||
engine: 'ref' | 'core2026',
|
||||
overrides: Partial<CanonicalTurnSnapshot> = {}
|
||||
): CanonicalTurnSnapshot => ({
|
||||
schemaVersion: 1,
|
||||
engine,
|
||||
world: { year: 183, month: 1, tickMinutes: 10, turnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
|
||||
generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
|
||||
cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }],
|
||||
nations: [{ id: 1, gold: 0, rice: 0 }],
|
||||
diplomacy: [],
|
||||
generalTurns: [{ generalId: 1, turnIndex: 0, action: 'che_농지개간', args: null }],
|
||||
nationTurns: [],
|
||||
logs: [],
|
||||
messages: [],
|
||||
watermarks: { logId: 0, messageId: 0 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('turn snapshot differential comparator', () => {
|
||||
it('compares entity arrays by semantic identity instead of database row order', () => {
|
||||
const reference = snapshot('ref', {
|
||||
cities: [
|
||||
{ id: 2, nationId: 2, agriculture: 900 },
|
||||
{ id: 1, nationId: 1, agriculture: 1000 },
|
||||
],
|
||||
});
|
||||
const core = snapshot('core2026', {
|
||||
cities: [
|
||||
{ id: 1, nationId: 1, agriculture: 1000 },
|
||||
{ id: 2, nationId: 2, agriculture: 900 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(compareTurnSnapshots(reference, core)).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes legacy ID argument spelling at the trace boundary', () => {
|
||||
expect(
|
||||
canonicalizeTurnCommandArgs({
|
||||
destCityID: 70,
|
||||
nested: [{ destNationID: 2 }],
|
||||
})
|
||||
).toEqual({
|
||||
destCityId: 70,
|
||||
nested: [{ destNationId: 2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('compares ordered log content independently from database primary keys', () => {
|
||||
const reference = snapshot('ref', {
|
||||
logs: [{ id: 10, category: 'action', text: '동일 로그' }],
|
||||
});
|
||||
const core = snapshot('core2026', {
|
||||
logs: [{ id: 900, category: 'action', text: '동일 로그' }],
|
||||
});
|
||||
|
||||
expect(
|
||||
compareTurnSnapshots(reference, core, {
|
||||
ignoredPathPatterns: [/^logs\[[^\]]+\]\.id$/],
|
||||
})
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports exact changed paths for general and nation command state', () => {
|
||||
const reference = snapshot('ref', {
|
||||
diplomacy: [{ fromNationId: 1, toNationId: 2, state: 1, term: 24 }],
|
||||
});
|
||||
const core = snapshot('core2026', {
|
||||
diplomacy: [{ fromNationId: 1, toNationId: 2, state: 1, term: 23 }],
|
||||
});
|
||||
|
||||
expect(compareTurnSnapshots(reference, core)).toEqual([
|
||||
{
|
||||
path: 'diplomacy[1->2].term',
|
||||
reference: 24,
|
||||
core: 23,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('compares before/after deltas when database layouts or initial values differ', () => {
|
||||
const refBefore = snapshot('ref');
|
||||
const refAfter = snapshot('ref', {
|
||||
generals: [{ id: 1, gold: 990, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
|
||||
cities: [{ id: 1, nationId: 1, agriculture: 1042, defence: 500 }],
|
||||
});
|
||||
const coreBefore = snapshot('core2026', {
|
||||
generals: [{ id: 1, gold: 2000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
|
||||
cities: [{ id: 1, nationId: 1, agriculture: 3000, defence: 500 }],
|
||||
});
|
||||
const coreAfter = snapshot('core2026', {
|
||||
generals: [{ id: 1, gold: 1990, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
|
||||
cities: [{ id: 1, nationId: 1, agriculture: 3042, defence: 500 }],
|
||||
});
|
||||
|
||||
expect(buildTurnSnapshotDelta(refBefore, refAfter).get('cities[1].agriculture')).toBe(42);
|
||||
expect(compareTurnSnapshotDeltas(refBefore, refAfter, coreBefore, coreAfter)).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects live sortie persistence differences including conquest and nation collapse', () => {
|
||||
const beforeRef = snapshot('ref', {
|
||||
cities: [{ id: 2, nationId: 2, agriculture: 1000, defence: 1 }],
|
||||
nations: [
|
||||
{ id: 1, gold: 0, rice: 0 },
|
||||
{ id: 2, gold: 0, rice: 0 },
|
||||
],
|
||||
});
|
||||
const afterRef = snapshot('ref', {
|
||||
cities: [{ id: 2, nationId: 1, agriculture: 1000, defence: 0 }],
|
||||
nations: [{ id: 1, gold: 0, rice: 0 }],
|
||||
});
|
||||
const beforeCore = snapshot('core2026', {
|
||||
cities: [{ id: 2, nationId: 2, agriculture: 1000, defence: 1 }],
|
||||
nations: [
|
||||
{ id: 1, gold: 0, rice: 0 },
|
||||
{ id: 2, gold: 0, rice: 0 },
|
||||
],
|
||||
});
|
||||
const afterCore = snapshot('core2026', {
|
||||
cities: [{ id: 2, nationId: 1, agriculture: 1000, defence: 0 }],
|
||||
nations: [
|
||||
{ id: 1, gold: 0, rice: 0 },
|
||||
{ id: 2, gold: 0, rice: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(compareTurnSnapshotDeltas(beforeRef, afterRef, beforeCore, afterCore)).toContainEqual({
|
||||
path: 'nations[2].gold',
|
||||
reference: { before: 0, after: undefined },
|
||||
core: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { readCoreDatabaseSnapshot } from '../src/turn-differential/databaseSnapshot.js';
|
||||
import { captureCoreDatabaseTurnTrace } from '../src/turn-differential/trace.js';
|
||||
|
||||
const databaseUrl = process.env.TURN_DIFFERENTIAL_DATABASE_URL ?? process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const ids = {
|
||||
general: 2_147_000_101,
|
||||
city: 2_147_000_102,
|
||||
nation: 2_147_000_103,
|
||||
};
|
||||
|
||||
integration('core2026 turn state database snapshot adapter', () => {
|
||||
let db: GamePrismaClient;
|
||||
let disconnect: (() => Promise<void>) | undefined;
|
||||
let createdWorldId: number | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
disconnect = () => connector.disconnect();
|
||||
|
||||
const world = await db.worldState.findFirst({ orderBy: { id: 'asc' } });
|
||||
if (!world) {
|
||||
createdWorldId = (
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'turn-differential-adapter',
|
||||
currentYear: 183,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
meta: { lastTurnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
|
||||
},
|
||||
})
|
||||
).id;
|
||||
}
|
||||
await db.nation.create({
|
||||
data: {
|
||||
id: ids.nation,
|
||||
name: '비교국',
|
||||
color: '#123456',
|
||||
capitalCityId: ids.city,
|
||||
gold: 100,
|
||||
rice: 200,
|
||||
tech: 10,
|
||||
level: 1,
|
||||
typeCode: 'che_중립',
|
||||
meta: { gennum: 1, power: 300, war: 0 },
|
||||
},
|
||||
});
|
||||
await db.city.create({
|
||||
data: {
|
||||
id: ids.city,
|
||||
name: '비교도시',
|
||||
level: 5,
|
||||
nationId: ids.nation,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
trust: 80,
|
||||
trade: 100,
|
||||
defence: 500,
|
||||
defenceMax: 1_000,
|
||||
wall: 500,
|
||||
wallMax: 1_000,
|
||||
region: 1,
|
||||
meta: { state: 0, term: 0 },
|
||||
},
|
||||
});
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: ids.general,
|
||||
name: '비교장수',
|
||||
nationId: ids.nation,
|
||||
cityId: ids.city,
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 80,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 500,
|
||||
crewTypeId: 1100,
|
||||
train: 90,
|
||||
atmos: 90,
|
||||
turnTime: new Date('0183-01-01T00:00:00.000Z'),
|
||||
lastTurn: { command: '휴식' },
|
||||
meta: { killturn: 24, myset: 6, intel_exp: 3 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.general.deleteMany({ where: { id: ids.general } });
|
||||
await db.city.deleteMany({ where: { id: ids.city } });
|
||||
await db.nation.deleteMany({ where: { id: ids.nation } });
|
||||
if (createdWorldId !== null) {
|
||||
await db.worldState.deleteMany({ where: { id: createdWorldId } });
|
||||
}
|
||||
await disconnect?.();
|
||||
});
|
||||
|
||||
it('projects selected PostgreSQL rows into the canonical comparison schema', async () => {
|
||||
const result = await readCoreDatabaseSnapshot(databaseUrl!, {
|
||||
generalIds: [ids.general],
|
||||
cityIds: [ids.city],
|
||||
nationIds: [ids.nation],
|
||||
});
|
||||
|
||||
expect(result.engine).toBe('core2026');
|
||||
expect(result.generals).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: ids.general,
|
||||
nationId: ids.nation,
|
||||
cityId: ids.city,
|
||||
intelligence: 80,
|
||||
killTurn: 24,
|
||||
mySet: 6,
|
||||
})
|
||||
);
|
||||
expect(result.cities).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: ids.city,
|
||||
agriculture: 1_000,
|
||||
defence: 500,
|
||||
state: 0,
|
||||
term: 0,
|
||||
})
|
||||
);
|
||||
expect(result.nations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: ids.nation,
|
||||
generalCount: 1,
|
||||
power: 300,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('captures before/after state around a real database execution boundary', async () => {
|
||||
const trace = await captureCoreDatabaseTurnTrace(
|
||||
databaseUrl!,
|
||||
{
|
||||
kind: 'general',
|
||||
actorGeneralId: ids.general,
|
||||
action: 'che_농지개간',
|
||||
args: null,
|
||||
observe: {
|
||||
generalIds: [ids.general],
|
||||
cityIds: [ids.city],
|
||||
nationIds: [ids.nation],
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
await db.$transaction([
|
||||
db.general.update({
|
||||
where: { id: ids.general },
|
||||
data: { gold: { decrement: 10 } },
|
||||
}),
|
||||
db.city.update({
|
||||
where: { id: ids.city },
|
||||
data: { agriculture: { increment: 42 } },
|
||||
}),
|
||||
]);
|
||||
return { outcome: { ok: true } };
|
||||
}
|
||||
);
|
||||
|
||||
expect(trace.before.generals[0]?.gold).toBe(1_000);
|
||||
expect(trace.after.generals[0]?.gold).toBe(990);
|
||||
expect(trace.before.cities[0]?.agriculture).toBe(1_000);
|
||||
expect(trace.after.cities[0]?.agriculture).toBe(1_042);
|
||||
expect(trace.execution).toMatchObject({
|
||||
kind: 'general',
|
||||
action: 'che_농지개간',
|
||||
seedDomain: 'generalCommand',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findTurnDifferentialWorkspaceRoot,
|
||||
readReferenceDatabaseSnapshot,
|
||||
} from '../src/turn-differential/referenceSnapshot.js';
|
||||
|
||||
const workspaceRoot = findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||
|
||||
integration('legacy turn state snapshot adapter', () => {
|
||||
it('exports a read-only canonical snapshot from the isolated reference service', () => {
|
||||
const snapshot = readReferenceDatabaseSnapshot(workspaceRoot!, {
|
||||
generalIds: [],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
});
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
engine: 'ref',
|
||||
world: {
|
||||
year: expect.any(Number),
|
||||
month: expect.any(Number),
|
||||
tickMinutes: expect.any(Number),
|
||||
},
|
||||
generals: [],
|
||||
cities: [],
|
||||
nations: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { canonicalizeTurnCommandArgs, type CanonicalTurnCommandTrace } from '../src/turn-differential/canonical.js';
|
||||
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
|
||||
|
||||
const referencePath = process.env.TURN_REFERENCE_TRACE;
|
||||
const corePath = process.env.TURN_CORE_TRACE;
|
||||
const integration = describe.skipIf(!referencePath || !corePath);
|
||||
|
||||
const readTrace = (filePath: string): CanonicalTurnCommandTrace =>
|
||||
JSON.parse(fs.readFileSync(filePath, 'utf8')) as CanonicalTurnCommandTrace;
|
||||
|
||||
integration('saved ref and core turn command traces', () => {
|
||||
it('has the same command identity, RNG calls, and semantic state delta', () => {
|
||||
const reference = readTrace(referencePath!);
|
||||
const core = readTrace(corePath!);
|
||||
|
||||
expect(core.execution).toMatchObject({
|
||||
kind: reference.execution.kind,
|
||||
actorGeneralId: reference.execution.actorGeneralId,
|
||||
action: reference.execution.action,
|
||||
seedDomain: reference.execution.seedDomain,
|
||||
});
|
||||
expect(canonicalizeTurnCommandArgs(core.execution.args)).toEqual(
|
||||
canonicalizeTurnCommandArgs(reference.execution.args)
|
||||
);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
const differences = compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: [
|
||||
/^logs\[[^\]]+\]\.id$/,
|
||||
/^logs\[[^\]]+\]\.scope$/,
|
||||
/^logs\[[^\]]+\]\.nationId$/,
|
||||
/^messages(?:\[|$)/,
|
||||
/\.turnTime$/,
|
||||
/^world\.turnTime$/,
|
||||
],
|
||||
});
|
||||
expect(differences, JSON.stringify(differences, null, 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user