From d0f2dcf838a97bf44185880ed5c6be330114c603 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 2 Oct 2026 08:57:58 +0200 Subject: [PATCH] test(web): the page shows real phases, tells running from quiet from lost, and never invents a percentage (red) Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads_progress_handler_test.go | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 internal/web/uploads_progress_handler_test.go diff --git a/internal/web/uploads_progress_handler_test.go b/internal/web/uploads_progress_handler_test.go new file mode 100644 index 0000000..fcce617 --- /dev/null +++ b/internal/web/uploads_progress_handler_test.go @@ -0,0 +1,243 @@ +package web_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.d-ma.be/mathias/tapir/internal/domain" + "git.d-ma.be/mathias/tapir/internal/web" +) + +// The page's progress display (ADR-035, #42): real phases from the row, and the three +// honest states the page can tell apart -- running, quiet (counted phase not advancing) +// and lost (the service stopped reporting). Real router, real Postgres, a fixed clock. + +var progressNow = time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC) + +func fixedClock(app *web.App) { app.Now = func() time.Time { return progressNow } } + +// seedProgress writes the job's phases and the two timestamps onto an existing row. +func seedProgress(t *testing.T, id string, steps []domain.ProgressStep, progressAt, aliveAt time.Time) { + t.Helper() + b, err := json.Marshal(steps) + require.NoError(t, err) + var alive any + if !aliveAt.IsZero() { + alive = aliveAt + } + _, err = rawPool(t).Exec(context.Background(), + `UPDATE uploaded_transcripts SET progress = $2, progress_at = $3, alive_at = $4 WHERE id = $1`, + id, b, progressAt, alive) + require.NoError(t, err) +} + +func step(key, label string, state domain.StepState, startedAgo, ranFor time.Duration, done, total int) domain.ProgressStep { + s := domain.ProgressStep{Key: key, Label: label, State: state, Done: done, Total: total} + if state != domain.StepPending { + s.StartedAt = progressNow.Add(-startedAgo) + } + if state == domain.StepDone || state == domain.StepFailed { + s.FinishedAt = s.StartedAt.Add(ranFor) + } + return s +} + +// twoEngineRun is a job in its second engine: three steps done, one running, three to come. +func twoEngineRun() []domain.ProgressStep { + return []domain.ProgressStep{ + step("received", "Received", domain.StepDone, 5*time.Minute, 0, 0, 0), + step("audio", "Preparing the audio", domain.StepDone, 5*time.Minute, 40*time.Second, 0, 0), + step("engine_a", "Transcribing with iguana/pianissimo", domain.StepDone, 4*time.Minute, 38*time.Second, 0, 0), + step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepRunning, 3*time.Minute+12*time.Second, 0, 0, 0), + step("compare", "Comparing the two transcripts", domain.StepPending, 0, 0, 0, 0), + step("decide", "Deciding the differences", domain.StepPending, 0, 0, 0, 0), + step("save", "Saving the transcript", domain.StepPending, 0, 0, 0, 0), + } +} + +func statusBody(t *testing.T, app *web.App, id string) string { + t.Helper() + rec := do(t, app, get("/uploads/"+id+"/status")) + require.Equal(t, 200, rec.Code) + return body(t, rec) +} + +func TestUploadProgress_ARunningJobShowsEveryPhaseInOrderWithTheRunningOneMarked(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + fixedClock(app) + id := seedUpload(t, userID, "m", "transcribing", "") + seedProgress(t, id, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second)) + + b := statusBody(t, app, id) + + last := -1 + for _, label := range []string{ + "Received", "Preparing the audio", "Transcribing with iguana/pianissimo", "Transcribing with iguana/kb-whisper", + "Comparing the two transcripts", "Deciding the differences", "Saving the transcript", + } { + i := strings.Index(b, label) + require.Greater(t, i, last, "phase %q missing or out of order:\n%s", label, b) + last = i + } + assert.Equal(t, 3, strings.Count(b, "step-done"), "three finished phases collapse to a done line") + assert.Equal(t, 1, strings.Count(b, `aria-current="step"`), "exactly one phase is the current one") + assert.Contains(t, b, "3m 12s", "the running phase shows how long it has run") + assert.Contains(t, b, "38s", "a finished phase shows how long it took") + assert.Contains(t, b, `hx-trigger="every 2s"`, "an unfinished job re-asks every two seconds") +} + +func TestUploadProgress_OnlyACountedPhaseGetsABarAndItShowsRealCounts(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + fixedClock(app) + id := seedUpload(t, userID, "m", "transcribing", "") + steps := twoEngineRun() + steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 3*time.Minute, 2*time.Minute, 0, 0) + steps[4] = step("compare", "Comparing the two transcripts", domain.StepDone, 1*time.Minute, time.Second, 0, 0) + steps[5] = step("decide", "Deciding the differences", domain.StepRunning, 50*time.Second, 0, 142, 282) + seedProgress(t, id, steps, progressNow.Add(-5*time.Second), progressNow.Add(-3*time.Second)) + + b := statusBody(t, app, id) + + assert.Contains(t, b, `