diff --git a/backend/internal/latest/acquire_test.go b/backend/internal/latest/acquire_test.go
index 9ff6def..4a6cd0c 100644
--- a/backend/internal/latest/acquire_test.go
+++ b/backend/internal/latest/acquire_test.go
@@ -135,6 +135,33 @@ func TestAcquireFillsChapterAndCoverFromOneFetch(t *testing.T) {
}
}
+// A pause governs the Lane only: a Reader's first bookmark of a Series on a
+// paused Site still reads the page, because acquisition is the creation-time
+// fetch, not the poll queue (issue #147).
+func TestAcquireIgnoresLanePause(t *testing.T) {
+ s, _ := newTestStore(t)
+ if err := s.PauseLane("asura", time.Now().Add(6*time.Hour).UnixMilli()); err != nil {
+ t.Fatalf("PauseLane: %v", err)
+ }
+ page := &fakeFetcher{body: asuraSeriesAndCoverFixture, status: 200}
+ covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}
+ acq := newAcquirer(s, page, covers)
+
+ bookmarkNewSeries(t, s, acquireSeriesURL)
+ acq.Wait()
+
+ if got := page.callCount(); got != 1 {
+ t.Fatalf("series page fetches on a paused Site = %d, want 1", got)
+ }
+ if got := covers.callCount(); got != 1 {
+ t.Fatalf("cover fetches = %d, want 1", got)
+ }
+ got := readBookmark(t, s, acquireKey)
+ if got.LatestChapterNum == nil || *got.LatestChapterNum != 181 {
+ t.Fatalf("LatestChapterNum = %v, want 181", got.LatestChapterNum)
+ }
+}
+
// A Series that already exists is not re-acquired: no fetch, and the Cover it
// already has is left alone.
func TestAcquireSkipsAnExistingSeries(t *testing.T) {
diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go
index 70da921..bf8d7c8 100644
--- a/backend/internal/latest/poller_test.go
+++ b/backend/internal/latest/poller_test.go
@@ -2058,6 +2058,107 @@ func TestDurableRefusalSurvivesFreshPoller(t *testing.T) {
}
}
+// The pause is read ahead of the refusal check: a Lane that is both paused
+// and inside a refusal backoff records the paused skip value, not the
+// refusing one. The pause is the owner's order and outranks the Site's mood
+// (issue #147).
+func TestPauseGatePrecedesRefusalGate(t *testing.T) {
+ s, _ := newTestStore(t)
+ now := time.UnixMilli(5_000_000)
+ seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
+ if err := s.SetLaneRefusal("asura", now.Add(10*time.Minute).UnixMilli()); err != nil {
+ t.Fatalf("SetLaneRefusal: %v", err)
+ }
+ if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
+ t.Fatalf("PauseLane: %v", err)
+ }
+
+ f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
+ p := newTestPoller(t, s, f, now)
+ if pace := p.runLanePass(context.Background(), "asura", false); pace != 30*time.Minute {
+ t.Fatalf("paused-while-refusing pace = %s, want 30m (the pause's expiry)", pace)
+ }
+ if f.callCount() != 0 {
+ t.Fatalf("fetches while paused and refusing = %d, want 0", f.callCount())
+ }
+ if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
+ t.Fatalf("skip = %q, want %q (the pause outranks the refusal)", pass.Skip, SkipPaused)
+ }
+}
+
+// A pause is a fact about the Site, not about the process: a freshly
+// constructed poller against a store holding a pause row stays paused until
+// the expiry, then runs the Lane normally. The restart criterion is the
+// whole point of writing a row instead of commanding a poller (issue #147).
+func TestDurablePauseSurvivesFreshPoller(t *testing.T) {
+ s, _ := newTestStore(t)
+ now := time.UnixMilli(5_000_000)
+ seedForCheck(t, s, "asura:x", "https://asurascans.com/series/x", 0)
+ if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
+ t.Fatalf("PauseLane: %v", err)
+ }
+
+ // A restart: a brand-new poller, no in-memory state, same store.
+ fresh := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
+ if pace := fresh.runLanePass(context.Background(), "asura", false); pace != 30*time.Minute {
+ t.Fatalf("fresh poller's paused pace = %s, want 30m", pace)
+ }
+ if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused {
+ t.Fatalf("fresh poller's pass skip = %q, want %q", pass.Skip, SkipPaused)
+ }
+
+ // Past the expiry the same fresh poller runs the Lane normally.
+ fresh.Now = func() time.Time { return now.Add(31 * time.Minute) }
+ fresh.runLanePass(context.Background(), "asura", false)
+ if pass := latestPassFor(t, s, "asura"); pass.Skip != "" {
+ t.Fatalf("pass after the expiry skip = %q, want the loop reached", pass.Skip)
+ }
+ if got := readLatestCheckedAt(t, s, "asura:x"); got == 0 {
+ t.Fatal("the Series was not checked after the pause lifted")
+ }
+}
+
+// ResumeLane zeroes the pause and the Lane's next pass finds its full queue
+// waiting: a pause delays work rather than discarding it, so the due Series
+// sit unstamped while paused and are all fetched once the pause lifts
+// (issue #147).
+func TestResumeLaneRestoresTheQueue(t *testing.T) {
+ s, _ := newTestStore(t)
+ now := time.UnixMilli(5_000_000)
+ for i := 0; i < 3; i++ {
+ seedForCheck(t, s, fmt.Sprintf("asura:s%d", i), "https://asurascans.com/series/x", 0)
+ }
+ if err := s.PauseLane("asura", now.Add(30*time.Minute).UnixMilli()); err != nil {
+ t.Fatalf("PauseLane: %v", err)
+ }
+
+ f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
+ p := newTestPoller(t, s, f, now)
+ p.runLanePass(context.Background(), "asura", false)
+ if f.callCount() != 0 {
+ t.Fatalf("fetches while paused = %d, want 0", f.callCount())
+ }
+ for i := 0; i < 3; i++ {
+ if got := readLatestCheckedAt(t, s, fmt.Sprintf("asura:s%d", i)); got != 0 {
+ t.Fatalf("asura:s%d stamp while paused = %d, want 0 (due and unstamped)", i, got)
+ }
+ }
+
+ if err := s.ResumeLane("asura"); err != nil {
+ t.Fatalf("ResumeLane: %v", err)
+ }
+ before := f.callCount()
+ p.runLanePass(context.Background(), "asura", false)
+ if got := f.callCount() - before; got != 3 {
+ t.Fatalf("fetches after resume = %d, want 3 (the full due queue)", got)
+ }
+ for i := 0; i < 3; i++ {
+ if got := readLatestCheckedAt(t, s, fmt.Sprintf("asura:s%d", i)); got == 0 {
+ t.Fatalf("asura:s%d still untried after resume", i)
+ }
+ }
+}
+
// RecordLanePass prunes in the same call that inserts, so the retention
// cutoff the recorder passes is observable in what survives: a row just inside
// 14 days behind the poller's clock is kept, one just outside is pruned
diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go
index 0a34c7a..ca39e8c 100644
--- a/backend/internal/web/admin.go
+++ b/backend/internal/web/admin.go
@@ -47,6 +47,8 @@ func (h *Handler) adminRoutes() []adminRoute {
{"GET /admin/series", h.adminSeries},
{"GET /admin/series/{key}", h.adminSeriesDetail},
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
+ {"POST /admin/lanes/{site}/pause", h.adminLanePause},
+ {"POST /admin/lanes/{site}/resume", h.adminLaneResume},
{"GET /ui/admin/lanes", h.uiLanes},
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
diff --git a/backend/internal/web/admin_lanes.go b/backend/internal/web/admin_lanes.go
index 7cc5bd1..599bb64 100644
--- a/backend/internal/web/admin_lanes.go
+++ b/backend/internal/web/admin_lanes.go
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
+ "slices"
"time"
"bookmarkmanager/backend/internal/latest"
@@ -50,6 +51,11 @@ type laneRow struct {
// Attention is the one flag the template colours on, so a Lane that
// needs the owner is found at a glance rather than read for.
Attention bool
+ // Paused is the live pause state — the poll_lanes stamp the pass row
+ // joins on, still in the future — not the pass's skip: the control must
+ // offer Resume from the moment the owner presses Pause, with no pass
+ // having run to record it (issue #147).
+ Paused bool
}
// chip is one named outcome count over the owner's window.
@@ -69,6 +75,79 @@ func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "lanes", h.lanesView())
}
+// pauseDurations are the offered pause lengths, by their wire value. A fixed
+// allow-list rather than time.ParseDuration: the unoffered value must be
+// refused, and a permissive parser turns the offered set into "anything Go
+// can read" (issue #147).
+var pauseDurations = map[string]time.Duration{
+ "1h": time.Hour,
+ "6h": 6 * time.Hour,
+ "24h": 24 * time.Hour,
+}
+
+// laneSite reads the Site a lane route names, answering the request itself
+// when it is not a registry Site. The path value is client-supplied, so it
+// is checked against the registry before it reaches the store.
+func laneSite(w http.ResponseWriter, r *http.Request) (string, bool) {
+ site := r.PathValue("site")
+ if !slices.Contains(latest.SiteNames(), site) {
+ http.Error(w, "unknown site", http.StatusBadRequest)
+ return "", false
+ }
+ return site, true
+}
+
+// adminLanePause writes a bounded pause for one Site and answers with the
+// freshly rendered Lanes block, so the figures describe the state after the
+// press. The pause is a fact about the Site — the Lane's next pass reads it
+// from the durable row, never from this process — so it survives a restart.
+// The owner gate is the route's, not this handler's; the body is capped like
+// the API path caps its bodies; the Site and the duration are validated
+// here, before the store sees them (issue #147).
+func (h *Handler) adminLanePause(w http.ResponseWriter, r *http.Request) {
+ site, ok := laneSite(w, r)
+ if !ok {
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form", http.StatusBadRequest)
+ return
+ }
+ d, ok := pauseDurations[r.PostFormValue("duration")]
+ if !ok {
+ http.Error(w, "unknown pause duration", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.PauseLane(site, time.Now().Add(d).UnixMilli()); err != nil {
+ log.Printf("pause lane %s: %v", site, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.render(w, http.StatusOK, "lanes", h.lanesView())
+}
+
+// adminLaneResume zeroes one Site's pause and answers with the freshly
+// rendered Lanes block. Resume is the reversal of a bounded pause, so it
+// fires instantly with no confirm row (issue #147).
+func (h *Handler) adminLaneResume(w http.ResponseWriter, r *http.Request) {
+ site, ok := laneSite(w, r)
+ if !ok {
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.ResumeLane(site); err != nil {
+ log.Printf("resume lane %s: %v", site, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.render(w, http.StatusOK, "lanes", h.lanesView())
+}
+
// lanesView builds the Lane status block from the durable pass log. Both
// reads are the store's latest-per-Site projection, so the page's seam is a
// seeded row rather than a fake poller; errors degrade to the empty state and
@@ -147,6 +226,7 @@ func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow
row.Chips = outcomeChips(o)
row.HasChips = len(row.Chips) > 0
row.StatePhrase, row.StateGood, row.Attention = laneState(p, now)
+ row.Paused = time.UnixMilli(p.PausedUntil).After(now)
return row
}
@@ -179,8 +259,19 @@ func outcomeChips(o store.SiteOutcomes) []chip {
// eligible — carry no Attention: the mark must stay spendable on the faults
// that actually need the owner.
func laneState(p store.LanePass, now time.Time) (phrase string, good, attention bool) {
+ // The pause phrase reads the live poll_lanes stamp the pass row joins
+ // on, not the pass's skip: the owner's press must render as paused on
+ // the very answer it gets, with no pass having run to record it. The
+ // pause is a fact about the Site, and the join delivers it (issue #147).
+ if pausedUntil := time.UnixMilli(p.PausedUntil); pausedUntil.After(now) {
+ phrase = "paused · resumes in " + humanDuration(pausedUntil.Sub(now))
+ good = true
+ return phrase, good, attention
+ }
switch p.Skip {
case latest.SkipPaused:
+ // A paused pass whose stamp has since lapsed: the Lane still
+ // declined with a reason, so it is never the one true stall.
phrase = "paused · resumes in " + humanDuration(time.UnixMilli(p.PausedUntil).Sub(now))
good = true
case latest.SkipRefusing:
diff --git a/backend/internal/web/templates/lanes.html b/backend/internal/web/templates/lanes.html
index 5ac35d4..c4a359e 100644
--- a/backend/internal/web/templates/lanes.html
+++ b/backend/internal/web/templates/lanes.html
@@ -32,7 +32,24 @@
{{.Gap}}
ran {{.Ran}}
{{if .HasChips}}{{range $i, $c := .Chips}}{{if $i}} · {{end}}{{$c.Name}} {{$c.Count}}{{end}}{{else}}none observed{{end}}{{if .StatePhrase}} · {{.StatePhrase}}{{end}}
-
+ {{/* The pause control lives in the one slot the design leaves for it:
+ a running Lane offers the three durations and Pause; a paused Lane
+ offers Resume in the same place. Pause is not destruction — it
+ takes nothing away and reverses in one press — so neither wears a
+ confirm row or the danger accent. The form wraps the select so the
+ offered duration travels with the press. */}}
+ {{if .Paused}}
+
+ {{else}}
+
+ {{end}}
{{end}}
diff --git a/backend/web_test.go b/backend/web_test.go
index 363d293..3d701f5 100644
--- a/backend/web_test.go
+++ b/backend/web_test.go
@@ -763,6 +763,7 @@ func TestAdminRoutesAreOwnerOnly(t *testing.T) {
t.Fatalf("route pattern %q has no method", pattern)
}
path = strings.Replace(path, "{id}", target, 1)
+ path = strings.Replace(path, "{site}", "asura", 1)
for _, tc := range []struct {
name string
@@ -1027,6 +1028,207 @@ func TestBrowserConfigAndReachabilityDerived(t *testing.T) {
})
}
+// Pressing Pause writes a future expiry at the offered length and answers
+// with the freshly rendered Lanes block, so the row the owner just pressed
+// reads as paused with its remaining time and offers Resume — with no poller
+// having run at all. The pause is a fact about the Site, never a command to
+// a process (issue #147).
+func TestLanePauseRoundTrip(t *testing.T) {
+ router, st := newWebTestServer(t, lanesConfig())
+ // A stall-shaped pass — due but none checked, no skip — so the test
+ // proves the pause renders over it as paused, never as the one true
+ // stall.
+ seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 3, Checked: 0, GapMS: 10_000})
+
+ for _, tc := range []struct{ duration, phrase string }{
+ {"1h", "paused · resumes in 1h"},
+ {"6h", "paused · resumes in 6h"},
+ {"24h", "paused · resumes in 24h"},
+ } {
+ t.Run(tc.duration, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/pause",
+ strings.NewReader("duration="+tc.duration))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("POST pause status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, `id="lanes"`) {
+ t.Errorf("pause response is not the Lanes block:\n%s", body)
+ }
+ if !strings.Contains(body, tc.phrase) {
+ t.Errorf("pause response does not render %q:\n%s", tc.phrase, body)
+ }
+ if !strings.Contains(body, `hx-post="/admin/lanes/asura/resume"`) {
+ t.Errorf("pause response does not offer Resume for asura:\n%s", body)
+ }
+ if strings.Contains(body, `class="trow attention"`) {
+ t.Errorf("a paused Lane is marked for attention:\n%s", body)
+ }
+ if strings.Contains(body, "not checking") {
+ t.Errorf("a paused Lane reads as the one true stall:\n%s", body)
+ }
+ // The expiry is a future fact about the Site, at the offered length.
+ paused, _, err := st.LaneGates("asura")
+ if err != nil {
+ t.Fatalf("LaneGates: %v", err)
+ }
+ d, _ := time.ParseDuration(tc.duration)
+ want := time.Now().Add(d).UnixMilli()
+ if paused < want-time.Minute.Milliseconds() || paused > want+time.Minute.Milliseconds() {
+ t.Errorf("pause expiry = %d, want now+%s (%d, within a minute)", paused, tc.duration, want)
+ }
+ })
+ }
+}
+
+// A missing duration and any value outside the offered set are refused with
+// 400 and nothing is written: a permissive parser would turn the offered set
+// into "anything Go can read", and an unoffered pause is a silent outage the
+// owner left behind (issue #147).
+func TestLanePauseRejectsBadDurations(t *testing.T) {
+ router, st := newWebTestServer(t, lanesConfig())
+ seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli()})
+
+ for _, tc := range []struct{ name, body string }{
+ {"missing", ""},
+ {"empty value", "duration="},
+ {"unoffered", "duration=2h"},
+ {"zero", "duration=0h"},
+ {"absurd", "duration=999h"},
+ {"not a duration", "duration=six"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/pause", strings.NewReader(tc.body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusBadRequest {
+ t.Errorf("pause body %q status = %d, want 400", tc.body, rr.Code)
+ }
+ })
+ }
+ paused, _, err := st.LaneGates("asura")
+ if err != nil {
+ t.Fatalf("LaneGates: %v", err)
+ }
+ if paused != 0 {
+ t.Errorf("a rejected pause still wrote expiry %d", paused)
+ }
+}
+
+// The Site in the path is client-supplied, so it is checked against the
+// registry before the store sees it: an unknown Site is a 400 on both action
+// routes, not a write (issue #147).
+func TestLaneActionsRejectUnknownSite(t *testing.T) {
+ router, st := newWebTestServer(t, lanesConfig())
+ for _, tc := range []struct{ path, body string }{
+ {"/admin/lanes/notasite/pause", "duration=6h"},
+ {"/admin/lanes/notasite/resume", ""},
+ } {
+ req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusBadRequest {
+ t.Errorf("POST %s status = %d, want 400", tc.path, rr.Code)
+ }
+ }
+}
+
+// Pressing Resume zeroes the pause and answers with the freshly rendered
+// block: the same slot now offers the duration select and Pause, and the
+// paused phrase is gone. The queue half of resume lives in the poller tests
+// (issue #147).
+func TestLaneResumeRoundTrip(t *testing.T) {
+ router, st := newWebTestServer(t, lanesConfig())
+ seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 2, Checked: 2, GapMS: 10_000})
+ if err := st.PauseLane("asura", time.Now().Add(6*time.Hour).UnixMilli()); err != nil {
+ t.Fatalf("PauseLane: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/resume", nil)
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("POST resume status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, `hx-post="/admin/lanes/asura/pause"`) || !strings.Contains(body, ">Pause") {
+ t.Errorf("resume response does not offer Pause again:\n%s", body)
+ }
+ if strings.Contains(body, "Resume") || strings.Contains(body, "paused") {
+ t.Errorf("resume response still reads as paused:\n%s", body)
+ }
+ paused, _, err := st.LaneGates("asura")
+ if err != nil {
+ t.Fatalf("LaneGates: %v", err)
+ }
+ if paused != 0 {
+ t.Errorf("resume left pause expiry %d, want 0", paused)
+ }
+}
+
+// Form bodies on both action routes are capped the way the API path caps
+// them: an oversized body is a 400, not a memory grant, and nothing is
+// written (issue #147).
+func TestLaneActionsCapBody(t *testing.T) {
+ router, st := newWebTestServer(t, lanesConfig())
+ seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli()})
+ big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap
+ for _, path := range []string{"/admin/lanes/asura/pause", "/admin/lanes/asura/resume"} {
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(big))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusBadRequest {
+ t.Errorf("oversized body on %s status = %d, want 400", path, rr.Code)
+ }
+ }
+ paused, _, err := st.LaneGates("asura")
+ if err != nil {
+ t.Fatalf("LaneGates: %v", err)
+ }
+ if paused != 0 {
+ t.Errorf("an oversized body still paused the Lane (expiry %d)", paused)
+ }
+}
+
+// A pause renders as paused, never as stalled: the owner's own act must not
+// be reported back as a fault. The stamp lives on poll_lanes and the pass
+// rows join it, so a pause seeded straight into the store — a restart, no
+// poller anywhere — renders its patina phrase with no attention flag and no
+// stall mark (issue #147).
+func TestPausedLaneRendersAsPausedNotStalled(t *testing.T) {
+ router, st := newWebTestServer(t, lanesConfig())
+ now := time.Now()
+ seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, GapMS: 10_000})
+ if err := st.PauseLane("asura", now.Add(6*time.Hour).UnixMilli()); err != nil {
+ t.Fatalf("PauseLane: %v", err)
+ }
+
+ body := lanesBody(t, router, st)
+ if !strings.Contains(body, `class="ok">paused · resumes in 6h<`) {
+ t.Errorf("a paused Lane does not render the patina phrase:\n%s", body)
+ }
+ if strings.Contains(body, `class="trow attention"`) {
+ t.Errorf("a paused Lane is marked for attention:\n%s", body)
+ }
+ if strings.Contains(body, "not checking") {
+ t.Errorf("a paused Lane reads as the one true stall:\n%s", body)
+ }
+ if strings.Contains(body, `class="bad"`) {
+ t.Errorf("a paused Lane wears the fault accent:\n%s", body)
+ }
+}
+
// A Reader past the disagreement threshold is rendered as blocked, and
// clearing their marks both zeroes the counters and lifts the block in the
// roster the response carries back.