244 lines
10 KiB
Go
244 lines
10 KiB
Go
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, `<progress`)
|
|
assert.Contains(t, b, `value="142"`)
|
|
assert.Contains(t, b, `max="282"`)
|
|
assert.Contains(t, b, "142 of 282")
|
|
|
|
// ...and a phase with no honest count never gets one: no percentage out of thin air.
|
|
app2, _ := newUploadApp(t, audioOK, 200<<20)
|
|
fixedClock(app2)
|
|
id2 := seedUpload(t, userID, "m2", "transcribing", "")
|
|
seedProgress(t, id2, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
|
|
assert.NotContains(t, statusBody(t, app2, id2), "<progress", "an engine call is one request: there is nothing to count")
|
|
}
|
|
|
|
func TestUploadProgress_ALongEngineCallWithAFreshHeartbeatIsStillJustRunning(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.StepRunning, 25*time.Minute, 0, 0, 0)
|
|
seedProgress(t, id, steps, progressNow.Add(-25*time.Minute), progressNow.Add(-3*time.Second))
|
|
|
|
b := statusBody(t, app, id)
|
|
|
|
assert.Contains(t, b, "25m 00s")
|
|
assert.NotContains(t, b, "step-health-lost")
|
|
assert.NotContains(t, b, "step-health-quiet",
|
|
"a single blocking request has no sub-progress to go quiet about; the heartbeat says the service is alive")
|
|
}
|
|
|
|
func TestUploadProgress_AHeartbeatThatStoppedMeansTheServiceIsLostAndTheJobIsNotCalledRunning(t *testing.T) {
|
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
|
fixedClock(app)
|
|
id := seedUpload(t, userID, "m", "transcribing", "")
|
|
seedProgress(t, id, twoEngineRun(), progressNow.Add(-3*time.Minute), progressNow.Add(-3*time.Minute))
|
|
|
|
b := statusBody(t, app, id)
|
|
|
|
assert.Contains(t, b, "step-health-lost")
|
|
assert.Contains(t, b, "stopped reporting")
|
|
assert.Contains(t, b, "3 min", "how long ago it was last heard from")
|
|
assert.NotContains(t, b, "spinner", "a lost job must not wear the running spinner")
|
|
assert.Contains(t, b, `hx-trigger="every`, "it keeps asking: a restarted service comes back, or the start-up sweep fails the row")
|
|
}
|
|
|
|
func TestUploadProgress_ACountedPhaseThatStopsAdvancingGoesQuietNotLost(t *testing.T) {
|
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
|
fixedClock(app)
|
|
id := seedUpload(t, userID, "m", "transcribing", "")
|
|
steps := twoEngineRun()
|
|
steps[5] = step("decide", "Deciding the differences", domain.StepRunning, 20*time.Minute, 0, 142, 282)
|
|
seedProgress(t, id, steps, progressNow.Add(-6*time.Minute), progressNow.Add(-3*time.Second))
|
|
|
|
b := statusBody(t, app, id)
|
|
|
|
assert.Contains(t, b, "step-health-quiet")
|
|
assert.Contains(t, b, "Nothing new for 6 min")
|
|
assert.NotContains(t, b, "stopped reporting", "the service is alive; it is the model that is slow")
|
|
}
|
|
|
|
func TestUploadProgress_BeforeTheFirstHeartbeatAJobIsRunningUntilItHasBeenSilentTooLong(t *testing.T) {
|
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
|
fixedClock(app)
|
|
fresh := seedUpload(t, userID, "fresh", "transcribing", "")
|
|
seedProgress(t, fresh, twoEngineRun(), progressNow.Add(-5*time.Second), time.Time{})
|
|
silent := seedUpload(t, userID, "silent", "transcribing", "")
|
|
seedProgress(t, silent, twoEngineRun(), progressNow.Add(-2*time.Minute), time.Time{})
|
|
|
|
assert.NotContains(t, statusBody(t, app, fresh), "step-health-lost", "the first beat has not been due yet")
|
|
assert.Contains(t, statusBody(t, app, silent), "step-health-lost", "no beat and no progress for two minutes is a lost service")
|
|
}
|
|
|
|
func TestUploadProgress_AQueuedJobShowsNoPhasesAndNoLostWarning(t *testing.T) {
|
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
|
fixedClock(app)
|
|
id := seedUpload(t, userID, "m", "queued", "")
|
|
|
|
b := statusBody(t, app, id)
|
|
|
|
assert.Contains(t, b, "Queued")
|
|
assert.NotContains(t, b, `class="steps"`)
|
|
assert.NotContains(t, b, "step-health")
|
|
assert.Contains(t, b, `hx-trigger="every 2s"`)
|
|
}
|
|
|
|
func TestUploadProgress_AFailedJobShowsWhichPhaseFailedAndWhatNeverStarted(t *testing.T) {
|
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
|
fixedClock(app)
|
|
id := seedUpload(t, userID, "m", "failed", "transcription_failed")
|
|
steps := twoEngineRun()
|
|
steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepFailed, 3*time.Minute, time.Minute, 0, 0)
|
|
seedProgress(t, id, steps, progressNow.Add(-2*time.Minute), progressNow.Add(-2*time.Minute))
|
|
|
|
b := statusBody(t, app, id)
|
|
|
|
assert.Contains(t, b, "Transcription failed")
|
|
assert.Contains(t, b, "step-failed")
|
|
assert.Contains(t, b, "step-pending", "what never started says where the job stopped")
|
|
assert.NotContains(t, b, "hx-trigger", "a failed job stops polling")
|
|
assert.NotContains(t, b, "step-health", "a finished job is not 'lost'")
|
|
}
|
|
|
|
func TestUploadProgress_ADoneJobKeepsItsRecordOfWhatWasDoneAndHowLongEachTook(t *testing.T) {
|
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
|
fixedClock(app)
|
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
|
|
steps := []domain.ProgressStep{
|
|
step("received", "Received", domain.StepDone, 20*time.Minute, 0, 0, 0),
|
|
step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0),
|
|
step("decide", "Deciding the differences", domain.StepDone, 15*time.Minute, 9*time.Minute+30*time.Second, 282, 282),
|
|
step("save", "Saving the transcript", domain.StepSkipped, 0, 0, 0, 0),
|
|
}
|
|
seedProgress(t, id, steps, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
|
|
|
|
b := statusBody(t, app, id)
|
|
|
|
assert.Contains(t, b, "Synthetic transcript text.")
|
|
assert.Contains(t, b, "What was done")
|
|
assert.Contains(t, b, "9m 30s")
|
|
assert.Contains(t, b, "step-skipped")
|
|
assert.NotContains(t, b, "hx-trigger", "a finished upload must not poll forever")
|
|
assert.NotContains(t, b, "step-health", "a finished job is never 'lost' however old its heartbeat")
|
|
assert.NotContains(t, b, "spinner")
|
|
}
|
|
|
|
func TestUploadProgress_TheFullPageRendersTheSameDisplayAsTheFragment(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))
|
|
|
|
rec := do(t, app, get("/uploads/"+id))
|
|
|
|
require.Equal(t, 200, rec.Code)
|
|
b := body(t, rec)
|
|
assert.Contains(t, b, "Transcribing with iguana/kb-whisper", "the page a user opens, not only the polled fragment, shows the phases")
|
|
assert.Contains(t, b, "3m 12s")
|
|
}
|