185 lines
7.5 KiB
Go
185 lines
7.5 KiB
Go
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{}
|
||
}
|