128 lines
7.7 KiB
TypeScript
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();
|
|
});
|
|
});
|