Files
makelore/tests/unit/game-audio-delivery.test.ts

128 lines
7.7 KiB
TypeScript

import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease';
import { GameAudioDeliveryCoordinator, GameAudioReceiptStore } from '../../electron/services/game-audio-delivery';
import { GameAudioClientError, type AudioCommand, type AudioGeneration } from '../../electron/services/game-audio-client';
const directories: string[] = [];
afterEach(async () => { await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); });
const execution = '00000000-0000-4000-8000-000000000601';
const project = '00000000-0000-4000-8000-000000000602';
const release = '00000000-0000-4000-8000-000000000505';
const command: AudioCommand = { release_id: release, release_admission_id: project,
project_id: project, logical_operation_id: 'request', pricing_version_id: project, prompt: 'coin',
confirmed: true, kind: 'sound', mode: 'pack', count: 2, duration: 2, loop: false };
function generation(active = false): AudioGeneration {
return { schema_version: 1, plugin_id: 'makelore.game-audio', execution_id: execution, release_id: release,
project_id: project, logical_operation_id: 'request', kind: 'sound', provider_status: active ? 'running' : 'succeeded',
output_ready: !active, outputs: active ? [] : [{ index: 0, name: 'sound-1' }, { index: 1, name: 'sound-2' }],
progress_percent: active ? 40 : 100, poll_interval_seconds: 2, error_code: null,
billing: { mode: 'platform_metered', status: active ? 'dispatched' : 'settled',
reserved_points: '0.08', actual_points: active ? null : '0.08', usage_amount: 8, unit: 'half_second' } };
}
async function setup() {
const root = await mkdtemp(path.join(os.tmpdir(), 'makelore-audio-'));
directories.push(root);
const receipts = new GameAudioReceiptStore(path.join(root, 'user-data', 'receipts.json'));
const context = { accountKey: 'owner', conversationId: 'conversation', runId: 'run',
localProjectId: 'local-project', durableProjectId: project, projectPath: root, logicalOperationId: 'request' };
const leases = new PiProjectWriteLeaseCoordinator();
const client = {
generate: vi.fn(async () => generation(true)), get: vi.fn(async () => generation()),
byOperation: vi.fn(async () => generation()),
download: vi.fn(async () => ({ bytes: new TextEncoder().encode('ID3audio'), extension: 'mp3', mimeType: 'audio/mpeg' })),
};
const recordTouchedPaths = vi.fn(async () => undefined);
const options = { client, receipts, leases, recordTouchedPaths, getAccountKey: () => 'owner',
sleep: vi.fn(async () => undefined) };
return { root, context, receipts, leases, client, options };
}
describe('Game Audio durable automatic delivery', () => {
it('persists intent before POST and saves all outputs under the frozen project without another confirmation', async () => {
const s = await setup();
s.client.generate.mockImplementation(async () => {
expect((await s.receipts.list())[0]?.context).toEqual(s.context);
expect(s.leases.activeCount).toBe(0);
return generation(true);
});
s.client.download.mockImplementation(async () => {
expect(s.leases.activeCount).toBe(0);
return { bytes: new TextEncoder().encode('ID3audio'), extension: 'mp3', mimeType: 'audio/mpeg' };
});
const delivery = new GameAudioDeliveryCoordinator(s.options);
const progress = vi.fn();
const result = await delivery.generateAndMaterialize(s.context, command, progress);
expect(result.deliveryStatus).toBe('saved');
expect(result.files).toHaveLength(2);
for (const file of result.files) expect(await readFile(path.join(s.root, file.path), 'utf8')).toBe('ID3audio');
expect(result.files[1]?.path).toBe('assets/generated/game-audio/' + execution + '/sound-2.mp3');
expect(s.options.recordTouchedPaths).toHaveBeenCalled();
expect(progress.mock.calls.some(([value]) => value.phase === 'generating')).toBe(true);
await delivery.generateAndMaterialize(s.context, command);
expect(s.client.generate).toHaveBeenCalledTimes(1);
expect(s.client.download).toHaveBeenCalledTimes(2);
expect(await delivery.readSavedOutput(execution, 1)).toEqual({
path: result.files[1]?.path, dataUrl: 'data:audio/mpeg;base64,' + Buffer.from('ID3audio').toString('base64'),
});
await expect(delivery.readSavedOutput(execution, 9)).rejects.toMatchObject({ status: 404 });
const other = new GameAudioDeliveryCoordinator({ ...s.options, getAccountKey: () => 'other' });
await expect(other.readSavedOutput(execution, 0)).rejects.toMatchObject({ status: 404 });
other.dispose();
delivery.dispose();
});
it('recovers a lost POST response with by-operation, never re-submits', async () => {
const s = await setup();
s.client.generate.mockRejectedValue(new Error('response lost'));
const delivery = new GameAudioDeliveryCoordinator(s.options);
expect((await delivery.generateAndMaterialize(s.context, command)).deliveryStatus).toBe('saved');
expect(s.client.byOperation).toHaveBeenCalledWith('request', 'owner');
expect(s.client.generate).toHaveBeenCalledTimes(1);
delivery.dispose();
});
it('reports a definite pre-dispatch rejection as not started and never resumes it', async () => {
const s = await setup();
s.client.generate.mockRejectedValue(new GameAudioClientError('plugin_pricing_changed', 409, 'Price changed'));
const delivery = new GameAudioDeliveryCoordinator(s.options);
expect(await delivery.generateAndMaterialize(s.context, command))
.toMatchObject({ providerStatus: 'failed', billing: { status: 'not_started' } });
await delivery.resumePending();
expect(s.client.byOperation).not.toHaveBeenCalled();
delivery.dispose();
});
it('automatically resumes a transient download failure without resubmitting', async () => {
const s = await setup();
s.client.download.mockRejectedValueOnce(new Error('offline'));
vi.useFakeTimers();
const delivery = new GameAudioDeliveryCoordinator(s.options);
try {
expect((await delivery.generateAndMaterialize(s.context, command)).deliveryStatus).toBe('delivery_failed');
await vi.advanceTimersByTimeAsync(30_000);
vi.useRealTimers();
await vi.waitFor(async () => expect((await s.receipts.list())[0]?.deliveryStatus).toBe('saved'));
expect(s.client.generate).toHaveBeenCalledTimes(1);
} finally { delivery.dispose(); vi.useRealTimers(); }
});
it('resumes only the missing output after restart and pauses for another account', async () => {
const s = await setup();
s.client.download.mockResolvedValueOnce({ bytes: new TextEncoder().encode('ID3one'), extension: 'mp3', mimeType: 'audio/mpeg' })
.mockRejectedValue(new Error('download offline'));
const first = new GameAudioDeliveryCoordinator(s.options);
expect((await first.generateAndMaterialize(s.context, command)).deliveryStatus).toBe('delivery_failed');
first.dispose();
s.client.download.mockClear().mockResolvedValue({ bytes: new TextEncoder().encode('ID3two'), extension: 'mp3', mimeType: 'audio/mpeg' });
const other = new GameAudioDeliveryCoordinator({ ...s.options, getAccountKey: () => 'other' });
await other.resumePending();
expect(s.client.download).not.toHaveBeenCalled();
other.dispose();
const restored = new GameAudioDeliveryCoordinator(s.options);
await restored.resumePending();
expect(s.client.generate).toHaveBeenCalledTimes(1);
expect(s.client.download).toHaveBeenCalledTimes(1);
expect(s.client.download).toHaveBeenCalledWith(execution, 1, 'owner');
expect((await s.receipts.list())[0]?.deliveryStatus).toBe('saved');
restored.dispose();
});
});