feat(web): immediate summarize + status poll + ASCII tapir spinner
Clicking "Summarize" now runs the summary in the background (when a Processor is
wired) instead of only queuing it. The handler flips the DB flag, kicks off the
work on a detached context, and returns an animated "processing" card that polls
GET /v/{id}/status every 2s via HTMX. Status returns the summary card once it
lands (no poll → polling stops), the animation while in-flight, or the Queued
card otherwise. Queue-only behaviour is unchanged when no Processor is set.
The spinner is a CSS-only cross-fade of three ASCII tapir frames — no JS, honours
prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
|
||||
// fakeProcessor records ProcessVideo calls. With block set it parks until the
|
||||
// channel is closed, so a test can observe the handler return before the
|
||||
// background work finishes (proving it ran in a goroutine).
|
||||
type fakeProcessor struct {
|
||||
block chan struct{}
|
||||
done chan struct{}
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeProcessor) ProcessVideo(_ context.Context, _, videoID string) error {
|
||||
if f.block != nil {
|
||||
<-f.block
|
||||
}
|
||||
f.calls = append(f.calls, videoID)
|
||||
if f.done != nil {
|
||||
close(f.done)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRequestSummarizeImmediateProcessing(t *testing.T) {
|
||||
app := newApp(t)
|
||||
p := rawPool(t)
|
||||
resetDB(t, p)
|
||||
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||
|
||||
fp := &fakeProcessor{block: make(chan struct{}), done: make(chan struct{})}
|
||||
app.Processor = fp
|
||||
|
||||
rec := postSummarize(t, app, videoX, true)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
html := body(t, rec)
|
||||
|
||||
// The processing card came back while ProcessVideo is still parked on block:
|
||||
// the work runs in a goroutine, the handler did not wait for it.
|
||||
require.Contains(t, html, "Summarizing", "processing card returned")
|
||||
require.Contains(t, html, "ω", "ascii tapir frame rendered")
|
||||
require.Contains(t, html, "∪∪", "all tapir frames rendered")
|
||||
require.Contains(t, html, "/v/"+videoX+"/status", "card polls the status endpoint")
|
||||
require.Contains(t, html, `hx-trigger="every 2s"`, "card auto-polls every 2s")
|
||||
require.NotContains(t, html, "Queued", "not the queue-only card")
|
||||
|
||||
close(fp.block)
|
||||
select {
|
||||
case <-fp.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("ProcessVideo was not called in the background")
|
||||
}
|
||||
require.Equal(t, []string{videoX}, fp.calls)
|
||||
}
|
||||
|
||||
func TestStatusProcessingThenDone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
app := newApp(t)
|
||||
p := rawPool(t)
|
||||
resetDB(t, p)
|
||||
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||
|
||||
// Park ProcessVideo so the video stays in-flight while we poll status.
|
||||
fp := &fakeProcessor{block: make(chan struct{})}
|
||||
app.Processor = fp
|
||||
require.Equal(t, http.StatusOK, postSummarize(t, app, videoX, true).Code)
|
||||
|
||||
// Processing: status returns the animation card, still polling.
|
||||
rec := getStatus(t, app, videoX)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
html := body(t, rec)
|
||||
require.Contains(t, html, "Summarizing", "in-flight → animation card")
|
||||
require.Contains(t, html, `hx-trigger="every 2s"`, "still polling")
|
||||
|
||||
close(fp.block)
|
||||
|
||||
// Done: once a summary exists, status returns the summary card with no poll.
|
||||
require.NoError(t, deliver(ctx, app, videoX, "the summary body"))
|
||||
rec = getStatus(t, app, videoX)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
html = body(t, rec)
|
||||
require.NotContains(t, html, "Summarizing", "done → no animation")
|
||||
require.NotContains(t, html, "every 2s", "done card does not poll (polling stops)")
|
||||
require.Contains(t, html, "/v/"+videoX+"\"", "links to the detail page")
|
||||
}
|
||||
|
||||
func TestStatusQueuedWhenNotInFlight(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
app := newApp(t)
|
||||
p := rawPool(t)
|
||||
resetDB(t, p)
|
||||
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||
|
||||
// Flag set but nothing in-flight (e.g. queue-only, or after a restart).
|
||||
require.NoError(t, app.Store.RequestSummarize(ctx, userID, videoX))
|
||||
|
||||
rec := getStatus(t, app, videoX)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
html := body(t, rec)
|
||||
require.Contains(t, html, "Queued", "queued chip card")
|
||||
require.NotContains(t, html, "Summarizing", "not processing")
|
||||
require.NotContains(t, html, "every 2s", "queued card does not poll")
|
||||
}
|
||||
|
||||
func TestStatusNotFound(t *testing.T) {
|
||||
app := newApp(t)
|
||||
resetDB(t, rawPool(t))
|
||||
rec := getStatus(t, app, videoX)
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/v/"+videoID+"/status", nil)
|
||||
req.Header.Set("HX-Request", "true")
|
||||
rec := httptest.NewRecorder()
|
||||
app.Router().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
Reference in New Issue
Block a user