150 lines
5.7 KiB
TypeScript
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',
|
|
});
|
|
});
|
|
});
|