관리 daemon의 실행 commit SHA를 NPC 감사 결정에 기록
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { normalizeAuditCodeVersion } from '../src/playAudit/decision.js';
|
||||
const runtimeFactory = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../src/turn/turnDaemon.js', () => ({ createTurnDaemonRuntime: runtimeFactory }));
|
||||
vi.mock('../src/turn/turnDaemonMemoryReporter.js', () => ({
|
||||
createTurnDaemonMemoryReporter: () => ({ report: vi.fn(), stop: vi.fn() }),
|
||||
}));
|
||||
import { runTurnDaemonCli } from '../src/turn/cli.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
runtimeFactory.mockReset();
|
||||
});
|
||||
describe('audit runtime build identity', () => {
|
||||
it.each([undefined, '', 'main', 'abcd1234', '0'.repeat(40), 'a'.repeat(41), 'g'.repeat(40)])(
|
||||
'leaves unconfirmed SHA %s unknown',
|
||||
(value) => {
|
||||
expect(normalizeAuditCodeVersion(value)).toBeUndefined();
|
||||
}
|
||||
);
|
||||
it.each([40, 64])('normalizes an exact %s digit SHA', (length) => {
|
||||
expect(normalizeAuditCodeVersion(` ${'A'.repeat(length)} `)).toBe('a'.repeat(length));
|
||||
});
|
||||
it.each([undefined, 'a'.repeat(40)])('passes the startup build identity to the runtime', async (sha) => {
|
||||
vi.spyOn(process, 'on').mockReturnValue(process);
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
const close = vi.fn();
|
||||
runtimeFactory.mockResolvedValue({
|
||||
lifecycle: { start: vi.fn(), stop: vi.fn() },
|
||||
close,
|
||||
});
|
||||
await runTurnDaemonCli({
|
||||
profile: 'che',
|
||||
profileName: 'che:fixture',
|
||||
tickMinutes: 10,
|
||||
databaseUrl: 'postgresql://fixture/game',
|
||||
gatewayDatabaseUrl: 'postgresql://fixture/gateway',
|
||||
env: sha ? { TURN_BUILD_COMMIT_SHA: sha } : {},
|
||||
});
|
||||
expect(runtimeFactory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auditCodeVersion: sha, profileName: 'che:fixture' })
|
||||
);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,7 @@ export type TurnTestHarnessOptions = {
|
||||
};
|
||||
worldRef?: { current: InMemoryTurnWorld | null };
|
||||
onDecisionTrace?: Parameters<typeof createReservedTurnHandler>[0]['onDecisionTrace'];
|
||||
auditCodeVersion?: string;
|
||||
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
|
||||
onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled'];
|
||||
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory'];
|
||||
@@ -115,6 +116,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
|
||||
unitSet: options.snapshot.unitSet,
|
||||
getWorld: () => worldRef.current,
|
||||
onDecisionTrace: options.onDecisionTrace,
|
||||
auditCodeVersion: options.auditCodeVersion,
|
||||
onActionResolved: options.onActionResolved,
|
||||
onActionProfiled: options.onActionProfiled,
|
||||
commandRngFactory: options.commandRngFactory,
|
||||
|
||||
@@ -250,6 +250,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
|
||||
const decisionTrace: AiDecisionTraceEvent[] = [];
|
||||
const savedDecisions: PendingAuditDecision[] = [];
|
||||
const { runUntil } = await createTurnTestHarness({
|
||||
auditCodeVersion: 'a'.repeat(40),
|
||||
onDecisionTrace: auditEnabled ? (event) => decisionTrace.push(event) : undefined,
|
||||
wrapGeneralTurnHandler: (handler) => ({ execute: (context) => {
|
||||
const result = handler.execute(context);
|
||||
@@ -447,6 +448,7 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
|
||||
expect(dispatchCount).toBeGreaterThan(0);
|
||||
if (auditEnabled) {
|
||||
expect(savedDecisions.length).toBeGreaterThan(0);
|
||||
expect(savedDecisions.every((row) => row.summary.codeVersion === 'a'.repeat(40))).toBe(true);
|
||||
expect(new Set(savedDecisions.map((row) => row.id)).size).toBe(savedDecisions.length);
|
||||
expect(savedDecisions.some((row) => row.phase === 'nation' && row.summary.executedAction === 'che_선전포고')).toBe(true);
|
||||
expect(savedDecisions.some((row) => row.phase === 'general' && row.summary.executedAction === 'che_출병')).toBe(true);
|
||||
|
||||
@@ -36,6 +36,7 @@ integration('decision persistence and bounded retention', () => {
|
||||
});
|
||||
it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => {
|
||||
const decision = draft('decision-one');
|
||||
decision.summary.codeVersion = 'a'.repeat(40);
|
||||
await db.$executeRawUnsafe(
|
||||
`CREATE OR REPLACE FUNCTION decision_fixture_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.ordinal=1 THEN RAISE EXCEPTION 'decision chunk failure'; END IF; RETURN NEW; END $$`
|
||||
);
|
||||
@@ -61,7 +62,18 @@ integration('decision persistence and bounded retention', () => {
|
||||
});
|
||||
await db.$transaction((tx) => persistAuditDecisions(tx, [decision]));
|
||||
const header = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } });
|
||||
expect(header).toMatchObject({ tick: 4_320_000_000n, stepCount: 302 });
|
||||
expect(header).toMatchObject({
|
||||
tick: 4_320_000_000n,
|
||||
stepCount: 302,
|
||||
summary: { codeVersion: 'a'.repeat(40) },
|
||||
});
|
||||
await expect(
|
||||
db.$transaction((tx) =>
|
||||
persistAuditDecisions(tx, [
|
||||
{ ...decision, summary: { ...decision.summary, codeVersion: 'b'.repeat(40) } },
|
||||
])
|
||||
)
|
||||
).rejects.toThrow('replay conflict');
|
||||
const chunks = await db.playAuditDecisionChunk.findMany({
|
||||
where: { decisionId: decision.id },
|
||||
orderBy: { ordinal: 'asc' },
|
||||
|
||||
Reference in New Issue
Block a user