package capture import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/media" ) // A frame for a session that already ended must not land in the next one. The // recorder used to be addressed as "whatever is running now", so a client whose // session was reaped went on appending its microphone into a meeting somebody // else had started. func TestAppendWithTheWrongTokenIsRefused(t *testing.T) { r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{}) s, err := r.Start("первая") if err != nil { t.Fatal(err) } if err := r.Append("someone-elses-token", frame(1)); !errors.Is(err, ErrWrongSession) { t.Fatalf("append = %v, want ErrWrongSession", err) } if _, err := r.Stop(context.Background(), "someone-elses-token"); !errors.Is(err, ErrWrongSession) { t.Fatalf("stop = %v, want ErrWrongSession", err) } if r.Abort("someone-elses-token") { t.Fatal("Abort discarded a session it does not own") } if err := r.Append(s.Token, frame(1)); err != nil { t.Fatalf("the owner is still refused: %v", err) } } // A client that simply stops sending — a phone whose tab was closed — used to // hold the single session slot forever, and every later Start answered ErrBusy // with a meeting from last week. func TestStaleSessionIsReapedByTheWallClock(t *testing.T) { r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{MaxDuration: time.Minute}) now := time.Now().UTC() r.now = func() time.Time { return now } s, err := r.Start("брошенная") if err != nil { t.Fatal(err) } if _, err := r.Start("вторая"); !errors.Is(err, ErrBusy) { t.Fatalf("start = %v, want ErrBusy", err) } now = now.Add(time.Minute + StaleGrace + time.Second) next, err := r.Start("вторая") if err != nil { t.Fatalf("a stale session was not reaped: %v", err) } if next.Token == s.Token { t.Fatal("the new session reused the stale token") } if err := r.Append(s.Token, frame(1)); !errors.Is(err, ErrWrongSession) { t.Fatalf("the reaped client can still write: %v", err) } // The abandoned recording is not kept: nobody is waiting for it, and storing // it would mean keeping a meeting on the strength of a dropped connection. if _, err := os.Stat(s.path); !os.IsNotExist(err) { t.Fatalf("the reaped spool file survived: %v", err) } } // One window failing used to fail the whole transcription, which threw away // every other window of an hour-long meeting. The hole is marked instead, so the // summary cannot silently read as if nothing was missing. func TestOneFailedWindowIsMarkedNotFatal(t *testing.T) { r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{STTWindow: time.Second}) r.tr = &windowTranscriber{failOn: 2} s, err := r.Start("встреча") if err != nil { t.Fatal(err) } if err := r.Append(s.Token, frame(3)); err != nil { t.Fatal(err) } res, err := r.Stop(context.Background(), s.Token) if err != nil { t.Fatalf("stop: %v", err) } if !strings.Contains(res.Transcript, gapMarker) { t.Errorf("no gap marker in %q", res.Transcript) } if !strings.Contains(res.Transcript, "окно1") || !strings.Contains(res.Transcript, "окно3") { t.Errorf("the surviving windows were dropped: %q", res.Transcript) } } // The audio not fitting the store is not a reason to lose the words. Stop used // to return early on a store failure, so a recording over the blob cap produced // neither a blob nor a transcript. func TestStoreFailureStillTranscribes(t *testing.T) { blobs, err := media.OpenWithBudget(t.TempDir(), 512, 1024, 0) if err != nil { t.Fatal(err) } tr := &fakeTranscriber{} r, err := New(blobs, tr, nil, Config{}) if err != nil { t.Fatal(err) } s, err := r.Start("длинная встреча") if err != nil { t.Fatal(err) } if err := r.Append(s.Token, frame(2)); err != nil { t.Fatal(err) } // The store failure is reported, but as a degraded success: the Result is // filled in, and the caller keeps it rather than treating the error as // nothing having happened. res, err := r.Stop(context.Background(), s.Token) if err == nil { t.Fatal("the store failure was not reported") } if res.BlobID != "" { t.Errorf("blob id = %q, want none", res.BlobID) } if !errors.Is(res.StoreErr, media.ErrTooLarge) { t.Errorf("StoreErr = %v, want ErrTooLarge", res.StoreErr) } if res.Transcript == "" { t.Fatal("the words were lost with the audio") } // The spool file is cleaned up even on the failure path. glob, _ := filepath.Glob(filepath.Join(blobs.Dir(), "spool", "*")) if len(glob) != 0 { t.Errorf("spool leaked: %v", glob) } } // windowTranscriber answers per window and fails a chosen one, which is what a // whisper timeout in the middle of a meeting looks like. type windowTranscriber struct { calls int failOn int } func (w *windowTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) { w.calls++ if w.calls == w.failOn { return "", 0, errors.New("whisper timed out") } return fmt.Sprintf("окно%d", w.calls), 1.0, nil }