Files
makelore/tests/unit/game-asset-review.test.ts
2026-07-29 17:22:35 +08:00

150 lines
5.7 KiB
TypeScript

import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it } from 'vitest';
import {
GameAssetReviewConflictError,
loadGameAssetReview,
recordGameAssetReviewActions,
recordGameAssetReviewAction,
} from '@electron/opencode/game-asset-review';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe('game asset review state', () => {
it('persists user decisions, resolves a review, and excludes terminal assets from later invocations', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-asset-review-'));
temporaryDirectories.push(projectPath);
const initial = await loadGameAssetReview(projectPath, 'review-1', ['hero', 'jump']);
expect(initial).toMatchObject({ invocationId: 'review-1', status: 'pending', pendingAssetIds: ['hero', 'jump'] });
const approved = await recordGameAssetReviewAction(projectPath, {
invocationId: 'review-1',
candidateIds: ['hero', 'jump'],
assetId: 'hero',
action: 'approve',
});
expect(approved).toMatchObject({ pendingAssetIds: ['jump'], approvedAssetIds: ['hero'] });
const discarded = await recordGameAssetReviewAction(projectPath, {
invocationId: 'review-1',
candidateIds: ['hero', 'jump'],
assetId: 'jump',
action: 'replace',
});
expect(discarded).toMatchObject({ status: 'resolved', pendingAssetIds: [], discardedAssetIds: ['jump'] });
const next = await loadGameAssetReview(projectPath, 'review-2', ['hero', 'jump', 'coin']);
expect(next).toMatchObject({ invocationId: 'review-2', candidateIds: ['hero', 'jump', 'coin'], pendingAssetIds: ['coin'] });
const state = JSON.parse(await readFile(join(projectPath, '.niancode', 'asset-review.json'), 'utf8')) as {
invocations: Record<string, { candidateIds: string[] }>;
};
expect(state.invocations['review-2']?.candidateIds).toEqual(['hero', 'jump', 'coin']);
});
it('makes repeated identical actions safe and rejects conflicting decisions', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-asset-review-'));
temporaryDirectories.push(projectPath);
await loadGameAssetReview(projectPath, 'review-1', ['hero']);
const first = await recordGameAssetReviewAction(projectPath, {
invocationId: 'review-1',
candidateIds: ['hero'],
assetId: 'hero',
action: 'approve',
});
const repeated = await recordGameAssetReviewAction(projectPath, {
invocationId: 'review-1',
candidateIds: ['hero'],
assetId: 'hero',
action: 'approve',
});
expect(repeated).toEqual(first);
await expect(recordGameAssetReviewAction(projectPath, {
invocationId: 'review-1',
candidateIds: ['hero'],
assetId: 'hero',
action: 'discard',
})).rejects.toBeInstanceOf(GameAssetReviewConflictError);
});
it('applies a complete review batch atomically and keeps repeated batches idempotent', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-asset-review-'));
temporaryDirectories.push(projectPath);
await loadGameAssetReview(projectPath, 'review-batch', ['hero', 'jump']);
const first = await recordGameAssetReviewActions(projectPath, {
invocationId: 'review-batch',
candidateIds: ['hero', 'jump'],
decisions: [
{ assetId: 'hero', action: 'approve' },
{ assetId: 'jump', action: 'replace' },
],
});
expect(first).toMatchObject({
status: 'resolved',
pendingAssetIds: [],
approvedAssetIds: ['hero'],
discardedAssetIds: ['jump'],
decisions: { hero: 'approved', jump: 'replace-requested' },
});
const repeated = await recordGameAssetReviewActions(projectPath, {
invocationId: 'review-batch',
candidateIds: ['hero', 'jump'],
decisions: [
{ assetId: 'hero', action: 'approve' },
{ assetId: 'jump', action: 'replace' },
],
});
expect(repeated).toEqual(first);
await expect(recordGameAssetReviewActions(projectPath, {
invocationId: 'review-batch',
candidateIds: ['hero', 'jump'],
decisions: [
{ assetId: 'hero', action: 'discard' },
{ assetId: 'jump', action: 'approve' },
],
})).rejects.toBeInstanceOf(GameAssetReviewConflictError);
expect(await loadGameAssetReview(projectPath, 'review-batch', ['hero', 'jump'])).toEqual(first);
});
it('repairs an untouched empty invocation from the current Agent submission', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-asset-review-'));
temporaryDirectories.push(projectPath);
await loadGameAssetReview(projectPath, 'legacy-empty', []);
const repaired = await loadGameAssetReview(projectPath, 'legacy-empty', ['hero', 'jump']);
expect(repaired).toMatchObject({
invocationId: 'legacy-empty',
candidateIds: ['hero', 'jump'],
pendingAssetIds: ['hero', 'jump'],
status: 'pending',
});
});
it('repairs an untouched invocation whose legacy ids no longer match the current plan', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-asset-review-'));
temporaryDirectories.push(projectPath);
await loadGameAssetReview(projectPath, 'legacy-unmatched', ['legacy-name']);
const repaired = await loadGameAssetReview(projectPath, 'legacy-unmatched', ['hero']);
expect(repaired).toMatchObject({
invocationId: 'legacy-unmatched',
candidateIds: ['hero'],
pendingAssetIds: ['hero'],
status: 'pending',
});
});
});