textmachine/backend/internal/pipeline/resume_updatedat_test.go

185 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"context"
"fmt"
"strings"
"testing"
"time"
"textmachine/backend/internal/store"
)
// TestOrdinaryResumeMovesOnlyTheSkippedRow pins which rows a $0 resume writes. store.ChunkStatus.UpdatedAt
// is read as the book's modification time, and a resume that changes nothing still writes — but only
// where a stage was SKIPPED: a skip is not a stored verdict a resume can serve, so the flagged upstream
// row is served from its checkpoint and the skip is re-derived and re-upserted (waverun.go —
// recordSkippedStages for a flagged edit unit, the flagged branch of runStageSequence for a multi-stage
// wave). Both halves are asserted: a run where EVERY row moved would be a clock, not a write.
func TestOrdinaryResumeMovesOnlyTheSkippedRow(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if strings.Contains(body, "ОТКАЗНАЯГЛАВА") && !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop" // soft refusal → the edit stage is skipped
}
return draftEdit(body)
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗНАЯГЛАВА\fНОРМАЛЬНАЯГЛАВА"})
ctx := context.Background()
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
before := statusStamps(t, r1)
callsAfterFirstRun := rec.count()
r1.Close()
// datetime('now') has one-second resolution: a rewrite inside the same second is indistinguishable from
// no rewrite, and the test would pass for the wrong reason.
time.Sleep(1100 * time.Millisecond)
r2 := newRunner(t, bookPath)
defer r2.Close()
if _, err := r2.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
after := statusStamps(t, r2)
if rec.count() != callsAfterFirstRun {
t.Fatalf("premise broken: the resume must buy nothing, calls went %d → %d", callsAfterFirstRun, rec.count())
}
if len(before) != 4 {
t.Fatalf("premise broken: want 4 chunk_status rows (2 units × 2 stages), got %d: %v", len(before), before)
}
const skipped = "ch1/chunk0/edit"
if before[skipped].disposition != string(DispSkipped) {
t.Fatalf("premise broken: %s must be the skipped row, got %q", skipped, before[skipped].disposition)
}
if after[skipped].updatedAt == before[skipped].updatedAt {
t.Errorf("a SKIPPED row is re-derived and re-written by every run past it: %s stayed at %q",
skipped, before[skipped].updatedAt)
}
for key, b := range before {
if key == skipped {
continue
}
if after[key].updatedAt != b.updatedAt {
t.Errorf("an ordinary resume must not write an %s row: %s moved %q → %q",
b.disposition, key, b.updatedAt, after[key].updatedAt)
}
}
}
type statusStamp struct{ disposition, updatedAt string }
// statusStamps maps "chapter/chunk/stage" to each row's disposition and write time.
func statusStamps(t *testing.T, r *Runner) map[string]statusStamp {
t.Helper()
rows, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
out := make(map[string]statusStamp, len(rows))
for _, cs := range rows {
key := chunkStageKey(cs.Chapter, cs.ChunkIdx, cs.Stage)
out[key] = statusStamp{disposition: cs.Disposition, updatedAt: cs.UpdatedAt}
}
return out
}
func chunkStageKey(chapter, chunkIdx int, stage string) string {
return fmt.Sprintf("ch%d/chunk%d/%s", chapter, chunkIdx, stage)
}
// TestASkippedRowSaysWHICHWriterWroteIt pins the half of store.ChunkStatus.UpdatedAt's doc that could
// only be READ until now. It names two writers of a `skipped` row and says which condition selects
// each — and reading was not enough: the author of the pack that wrote that sentence named the wrong
// mechanism three times running, and each time it was a mutation that caught him.
//
// ⚠ MEASURED BEFORE THIS TEST EXISTED, and it is why the second case is here at all: breaking
// recordSkippedStages reddened six tests, while breaking the flagged branch of runStageSequence survived
// the WHOLE package. One of the two writers the doc names was exercised by nothing, so no mutation could
// have told a reader which one fires — the question the doc answers had no witness on either side.
//
// The two writers are told apart by the DETAIL they write, which is the only durable trace saying which
// code path produced the row. Swap the two sentences and this goes red naming both.
func TestASkippedRowSaysWHICHWriterWroteIt(t *testing.T) {
t.Run("a flagged member draft skips the unit's edit", func(t *testing.T) {
srv := newJSONProvider(&reqRec{}, func(body string) (string, string) {
if !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop" // the draft soft-refuses → the edit is pre-decided skipped
}
return draftEdit(body)
})
defer srv.Close()
r := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗНАЯГЛАВА"}))
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
got := statusDetail(t, r, "edit")
if !strings.Contains(got, "a member draft chunk of this edit unit was flagged") {
t.Errorf("a unit whose member draft flagged is skipped by recordSkippedStages, and the row must say so: %q", got)
}
if strings.Contains(got, "an upstream stage was flagged") {
t.Errorf("this row is not the multi-stage skip; the two writers' rows are indistinguishable: %q", got)
}
})
t.Run("a flagged stage skips its own wave's later stages", func(t *testing.T) {
srv := newJSONProvider(&reqRec{}, func(body string) (string, string) {
if isEditBody(body) {
return "Извините, я не могу продолжить.", "stop" // the FIRST edit stage flags on the refusal blacklist
}
return draftEdit(body)
})
defer srv.Close()
r := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{source: "ОБЫЧНАЯГЛАВА", secondEditStage: true}))
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
// PREMISE: the first edit stage really did flag, so what follows is the skip of a LATER stage in
// the same wave and not a unit that was never edited.
if d := statusDisposition(t, r, "edit"); d != string(DispFlagged) {
t.Fatalf("premise broken: the first edit stage must be flagged, got %q", d)
}
got := statusDetail(t, r, "polish")
if !strings.Contains(got, "an upstream stage was flagged") {
t.Errorf("a later stage of the same wave is skipped by runStageSequence, and the row must say so: %q", got)
}
if strings.Contains(got, "a member draft chunk") {
t.Errorf("this row is not the pre-decided edit skip; the two writers' rows are indistinguishable: %q", got)
}
})
}
// statusDetail returns the stored chunk_status detail of one stage of the book's first chunk.
func statusDetail(t *testing.T, r *Runner, stage string) string {
t.Helper()
return statusRow(t, r, stage).Detail
}
// statusDisposition returns the stored disposition of one stage of the book's first chunk.
func statusDisposition(t *testing.T, r *Runner, stage string) string {
t.Helper()
return statusRow(t, r, stage).Disposition
}
func statusRow(t *testing.T, r *Runner, stage string) store.ChunkStatus {
t.Helper()
rows, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
t.Fatalf("read chunk_status: %v", err)
}
for _, cs := range rows {
if cs.Stage == stage && cs.Chapter == 1 && cs.ChunkIdx == 0 {
return cs
}
}
t.Fatalf("no chunk_status row for stage %q; rows were %+v", stage, rows)
return store.ChunkStatus{}
}