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, `") require.GreaterOrEqual(t, end, 0) return html[start : start+end+len("")] } func TestUploadProgress_TheFullPageCarriesTheVerySameProgressListAsTheFragment(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)) full := body(t, do(t, app, get("/uploads/"+id))) fragment := statusBody(t, app, id) assert.Equal(t, stepsBlock(t, fragment), stepsBlock(t, full), "the page a user opens and the fragment HTMX swaps in must show the same list, or the display jumps on the first poll") }