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; }; 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', }); }); });