Files
AndyTranscribe/tests/Feature/TranscriptionBroadcastTest.php
T
ben 187b6b5d12 Stream Whisper verbose metadata live without overflowing Reverb.
Persist accumulated verbose_json on the recording and broadcast only transcript/whisper deltas so the show page can render language, segments, word timestamps, and confidence while a run is in progress.
2026-08-13 15:55:51 +02:00

352 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Events\RecordingTranscriptionUpdated;
use App\Jobs\TranscribeRecording;
use App\Models\Recording;
use App\Models\User;
use App\Services\TranscriptionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Transcription;
use Tests\TestCase;
class TranscriptionBroadcastTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_report_progress_broadcasts_transcription_updated(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Broadcast progress',
'original_filename' => 'progress.mp3',
'file_path' => 'recordings/progress.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->reportProgress('Transcribing locally…', 50);
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
return $event->recording->is($recording)
&& $event->broadcastWith()['percent'] === 50
&& $event->broadcastWith()['progress'] === 'Transcribing locally…';
});
}
public function test_successful_transcription_broadcasts_done_status(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
Storage::fake('local');
Storage::disk('local')->put('recordings/sample.mp3', 'fake-audio-bytes');
Transcription::fake(['Hello from the recorder.']);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Sample',
'original_filename' => 'sample.mp3',
'file_path' => 'recordings/sample.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
$payload = $event->broadcastWith();
return $event->recording->is($recording)
&& $payload['status'] === 'done'
&& $payload['percent'] === 100
&& $payload['has_transcript'] === true
&& ($payload['word_count'] ?? 0) > 0;
});
}
public function test_failed_transcription_broadcasts_failure(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
Storage::fake('local');
Storage::disk('local')->put('recordings/bad.mp3', 'fake-audio-bytes');
Transcription::fake(function () {
throw new \RuntimeException('Provider unavailable');
});
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Bad',
'original_filename' => 'bad.mp3',
'file_path' => 'recordings/bad.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
try {
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
$this->fail('Expected transcription to throw');
} catch (\RuntimeException $e) {
$this->assertSame('Provider unavailable', $e->getMessage());
}
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
$payload = $event->broadcastWith();
return $event->recording->is($recording)
&& $payload['status'] === 'failed'
&& $payload['error'] === 'Provider unavailable';
});
}
public function test_cancel_transcription_broadcasts_cancelled_status(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Cancel me',
'original_filename' => 'cancel.mp3',
'file_path' => 'recordings/cancel.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->cancelTranscription();
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool {
return $event->recording->is($recording)
&& $event->broadcastWith()['status'] === 'cancelled';
});
}
public function test_broadcast_event_uses_private_recording_channel(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Channel',
'original_filename' => 'channel.mp3',
'file_path' => 'recordings/channel.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
$event = new RecordingTranscriptionUpdated($recording);
$this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs());
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
$this->assertSame('private-user.'.$this->user->id.'.recordings', $event->broadcastOn()[1]->name);
}
public function test_broadcast_payload_omits_transcript_text(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Long text',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_percent' => 70,
'transcript' => str_repeat('word ', 5000),
]);
$payload = (new RecordingTranscriptionUpdated($recording))->broadcastWith();
$this->assertArrayNotHasKey('transcript', $payload);
$this->assertArrayNotHasKey('whisper', $payload);
$this->assertTrue($payload['has_transcript']);
$this->assertSame(70, $payload['percent']);
$this->assertLessThan(10_000, strlen((string) json_encode($payload)));
}
public function test_report_progress_broadcasts_transcript_deltas_for_append(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Deltas',
'original_filename' => 'delta.mp3',
'file_path' => 'recordings/delta.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
'transcript' => 'Previous finished transcript',
]);
$recording->reportProgress('Transcribing locally…', 55, partialTranscript: 'Hello ');
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
$payload = $event->broadcastWith();
return ($payload['transcript_delta'] ?? null) === 'Hello '
&& ($payload['transcript_replace'] ?? false) === true
&& ! array_key_exists('transcript', $payload);
});
$recording->refresh()->reportProgress('Transcribing locally…', 60, partialTranscript: 'Hello from whisper.');
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
$payload = $event->broadcastWith();
return ($payload['transcript_delta'] ?? null) === 'from whisper.'
&& ($payload['transcript_replace'] ?? true) === false;
});
}
public function test_report_progress_broadcasts_whisper_delta_without_full_verbose_snapshot(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Verbose live',
'original_filename' => 'delta.mp3',
'file_path' => 'recordings/delta.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->reportProgress('Transcribing locally…', 40, partialTranscript: 'Hello', whisperDelta: [
'language' => 'en',
'duration' => 12.5,
'segment' => [
'id' => 0,
'start' => 0.0,
'end' => 1.2,
'text' => 'Hello',
'avg_logprob' => -0.2,
'words' => [
['word' => 'Hello', 'start' => 0.0, 'end' => 1.2, 'probability' => 0.91],
],
],
]);
$recording->refresh();
$this->assertSame('en', $recording->transcription_verbose['language']);
$this->assertCount(1, $recording->transcription_verbose['segments']);
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
$payload = $event->broadcastWith();
return ($payload['whisper_delta']['language'] ?? null) === 'en'
&& ($payload['whisper_delta']['segment']['text'] ?? null) === 'Hello'
&& ! array_key_exists('whisper', $payload)
&& ! array_key_exists('transcript', $payload)
&& ! array_key_exists('segments', $payload['whisper_delta'] ?? []);
});
}
public function test_full_whisper_segments_snapshot_is_persisted_but_not_broadcast(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Snapshot',
'original_filename' => 'snap.mp3',
'file_path' => 'recordings/snap.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->reportProgress('Transcribing locally…', 90, partialTranscript: 'Hello world', whisperDelta: [
'language' => 'en',
'duration' => 4.2,
'segments' => [
['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
['id' => 1, 'text' => 'world', 'start' => 1.0, 'end' => 2.0],
],
]);
$recording->refresh();
$this->assertCount(2, $recording->transcription_verbose['segments']);
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
$payload = $event->broadcastWith();
return ($payload['whisper_delta']['language'] ?? null) === 'en'
&& ($payload['whisper_delta']['duration'] ?? null) === 4.2
&& ! array_key_exists('segments', $payload['whisper_delta'] ?? [])
&& ! array_key_exists('whisper', $payload);
});
}
public function test_report_progress_replaces_verbose_segments_when_snapshot_arrives(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Replace segments',
'original_filename' => 'replace.mp3',
'file_path' => 'recordings/replace.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->reportProgress('Transcribing locally…', 40, whisperDelta: [
'segment' => ['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
]);
$recording->refresh()->reportProgress('Transcribing locally…', 99, whisperDelta: [
'language' => 'en',
'segments' => [
['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0, 'avg_logprob' => -0.1],
],
]);
$this->assertCount(1, $recording->refresh()->transcription_verbose['segments']);
$this->assertSame(-0.1, $recording->transcription_verbose['segments'][0]['avg_logprob']);
}
public function test_oversized_transcript_delta_is_not_broadcast(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Huge delta',
'original_filename' => 'huge.mp3',
'file_path' => 'recordings/huge.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
]);
$huge = str_repeat('a', RecordingTranscriptionUpdated::MAX_DELTA_BYTES + 1);
$payload = (new RecordingTranscriptionUpdated($recording, $huge, true))->broadcastWith();
$this->assertArrayNotHasKey('transcript_delta', $payload);
$this->assertArrayNotHasKey('transcript', $payload);
$this->assertLessThan(10_000, strlen((string) json_encode($payload)));
}
}