987 lines
43 KiB
Go
987 lines
43 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/chunk/chunktest"
|
||
"textmachine/backend/internal/obs"
|
||
)
|
||
|
||
// volumeslot_test.go: unified backlog row 232, second half — a unit interrupted between the waves used to
|
||
// take a slot of the volume grant from EVERY run that advanced it: four bought units became two
|
||
// chapters. A unit takes a slot once, in the run that starts it; the run that finishes it does so outside
|
||
// its own grant (volume.go, unitCarried), and says so.
|
||
|
||
// interruptibleEditor is a provider whose EDITOR fails until released — the store state a run cut off
|
||
// between the draft wave and the edit wave leaves behind: draft rows on file, no edit row anywhere.
|
||
func interruptibleEditor(rec *reqRec) (*httptest.Server, func()) {
|
||
var mu sync.Mutex
|
||
editWorks := false
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||
body, _ := io.ReadAll(req.Body)
|
||
rec.record(string(body))
|
||
if isEditBody(string(body)) {
|
||
mu.Lock()
|
||
ok := editWorks
|
||
mu.Unlock()
|
||
if !ok {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
return
|
||
}
|
||
writeFakeCompletion(w, "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop")
|
||
return
|
||
}
|
||
writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop")
|
||
}))
|
||
return srv, func() { mu.Lock(); editWorks = true; mu.Unlock() }
|
||
}
|
||
|
||
// TestAUnitInterruptedBetweenWavesTakesNoSecondSlot is row 232's measurement turned around: two
|
||
// purchases of two units on a five-unit book, the first cut off between its waves. It used to leave the
|
||
// buyer with two chapters for four bought units; now the first purchase's two units are finished OUTSIDE
|
||
// the second grant and the second grant buys two NEW units — four bought, four delivered.
|
||
//
|
||
// Mutation this catches: classify a started-but-unshipped unit as unitFresh again (delete the
|
||
// unitStarted branch in classifyUnits) — the second run reports Delivered=2 with chapters 3–4 never
|
||
// started, and the money assertion below goes red on the exact number the row names.
|
||
func TestAUnitInterruptedBetweenWavesTakesNoSecondSlot(t *testing.T) {
|
||
const chapters = 5
|
||
rec := &reqRec{}
|
||
srv, releaseEditor := interruptibleEditor(rec)
|
||
defer srv.Close()
|
||
bookPath := volumeBook(t, srv.URL, chapters)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
// Purchase 1: two units granted, both drafted, the editor dies → cut off between the waves.
|
||
r1 := newRunner(t, bookPath)
|
||
r1.MaxUnits = 2
|
||
if _, err := r1.TranslateBook(ctx); err == nil {
|
||
t.Fatal("precondition: the editor was supposed to fail this run")
|
||
}
|
||
rows, err := r1.Store.ChunkStatusesForBook("test-book")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
drafted, edits := map[int]bool{}, 0
|
||
for _, cs := range rows {
|
||
if cs.Stage == "edit" {
|
||
edits++
|
||
}
|
||
drafted[cs.Chapter] = true
|
||
}
|
||
if edits != 0 || !drafted[1] || !drafted[2] || len(drafted) != 2 {
|
||
t.Fatalf("precondition: chapters 1–2 drafted and NO edit row anywhere, got drafted=%v edit rows=%d", drafted, edits)
|
||
}
|
||
|
||
// Purchase 2: two more units. The two cut-off units are finished outside this grant; the grant buys
|
||
// chapters 3 and 4; chapter 5 is held back, so the run reports the stop.
|
||
releaseEditor()
|
||
callsBefore := rec.count()
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.MaxUnits = 2
|
||
res, err := r2.TranslateBook(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
v := res.Volume
|
||
if v == nil {
|
||
t.Fatal("chapter 5 was held back, so the run must report a volume stop")
|
||
}
|
||
if v.Delivered != 4 || v.Carried != 2 || v.Reworked != 0 || v.Free != 0 || v.Flagged != 0 {
|
||
t.Fatalf("four bought units must be four delivered — two carried from the first grant, two new: %+v", *v)
|
||
}
|
||
if v.LeftFresh != 1 || v.LeftRework != 0 {
|
||
t.Fatalf("one unit never delivered remains, nothing awaits a re-make: %+v", *v)
|
||
}
|
||
if len(res.Chunks) != 4 {
|
||
t.Fatalf("the run shipped %d unit(s), want the 4 the two purchases paid for", len(res.Chunks))
|
||
}
|
||
started := chaptersWithRows(t, r2.Store, "test-book")
|
||
for ch := 1; ch <= 4; ch++ {
|
||
if !started[ch] {
|
||
t.Fatalf("chapter %d was bought and must exist: %v", ch, started)
|
||
}
|
||
}
|
||
if started[5] {
|
||
t.Fatal("chapter 5 is past the second grant and must not have run")
|
||
}
|
||
// The money: two editor calls finish the carried units, two full chains buy the new ones.
|
||
if fresh := rec.count() - callsBefore; fresh != 2+2*2 {
|
||
t.Fatalf("the second purchase made %d provider call(s), want 6 (2 edits for the carried units + draft+edit for two new ones)", fresh)
|
||
}
|
||
// And the sentence says which of the new units this grant did not pay a slot for.
|
||
if line := v.String(); !strings.Contains(line, "4 NEW unit(s) delivered") || !strings.Contains(line, "2 of the new ones had been started by an earlier run") {
|
||
t.Fatalf("the stop line must name the carried units: %s", line)
|
||
}
|
||
}
|
||
|
||
// TestAUnitCutOffInsideTheDraftWaveIsCarriedNotRestarted is the same slot on a unit interrupted INSIDE
|
||
// a wave rather than between them: some members drafted, the rest not. The evidence of a start is any
|
||
// row at a position this run executes, so the half-drafted unit is finished outside the grant too.
|
||
func TestAUnitCutOffInsideTheDraftWaveIsCarriedNotRestarted(t *testing.T) {
|
||
var mu sync.Mutex
|
||
broken := true
|
||
rec := &reqRec{}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||
body, _ := io.ReadAll(req.Body)
|
||
rec.record(string(body))
|
||
s := string(body)
|
||
if isEditBody(s) {
|
||
writeFakeCompletion(w, "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop")
|
||
return
|
||
}
|
||
mu.Lock()
|
||
dead := broken && strings.Contains(s, "壊れた時計")
|
||
mu.Unlock()
|
||
if dead {
|
||
w.WriteHeader(http.StatusInternalServerError) // the wave dies on this member; the earlier one is on file
|
||
return
|
||
}
|
||
writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop")
|
||
}))
|
||
defer srv.Close()
|
||
const seg = "\nsegmentation:\n draft_budget_out: 24\n edit_ceiling_out: 8000\n fertility: { cjk: 1.1978, other: 0.3852 }\n"
|
||
split := "<p>静かな図書館の朝。鈴木は本を読んだ。壊れた時計があった。外では雨が降っていた。彼は窓を見た。</p>"
|
||
clean := "<p>朝%d。鈴木は歩いた。</p>"
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||
epub: []chunktest.Chapter{
|
||
{ID: "c1", Href: "c1.xhtml", Body: split},
|
||
{ID: "c2", Href: "c2.xhtml", Body: fmt.Sprintf(clean, 2)},
|
||
{ID: "c3", Href: "c3.xhtml", Body: fmt.Sprintf(clean, 3)},
|
||
},
|
||
spine: []string{"c1", "c2", "c3"}, regenerate: 0, waveWorkers: 1, gatesYAML: seg,
|
||
})
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
r1.MaxUnits = 1
|
||
if _, err := r1.TranslateBook(ctx); err == nil {
|
||
t.Fatal("precondition: the draft wave was supposed to die on the second member")
|
||
}
|
||
rows, err := r1.Store.ChunkStatusesForBook("test-book")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
members := 0
|
||
for _, cs := range rows {
|
||
if cs.Chapter != 1 || cs.Stage != "draft" {
|
||
t.Fatalf("precondition: only chapter 1 draft rows may exist, got %+v", cs)
|
||
}
|
||
members++
|
||
}
|
||
if members == 0 {
|
||
t.Fatal("precondition: at least one member of chapter 1 must have been drafted before the wave died")
|
||
}
|
||
|
||
mu.Lock()
|
||
broken = false
|
||
mu.Unlock()
|
||
callsBefore := rec.count()
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.MaxUnits = 1
|
||
res, err := r2.TranslateBook(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
v := res.Volume
|
||
if v == nil {
|
||
t.Fatal("chapter 3 was held back, so the run must report a volume stop")
|
||
}
|
||
if v.Delivered != 2 || v.Carried != 1 || v.LeftFresh != 1 || v.Free != 0 || v.Reworked != 0 {
|
||
t.Fatalf("the half-drafted unit is carried, the grant buys chapter 2, chapter 3 waits: %+v", *v)
|
||
}
|
||
started := chaptersWithRows(t, r2.Store, "test-book")
|
||
if !started[1] || !started[2] || started[3] {
|
||
t.Fatalf("chapters 1–2 must be complete and chapter 3 untouched: %v", started)
|
||
}
|
||
// The carried unit paid only for what it lacked: its remaining draft members and its edit. Nothing
|
||
// already on file was bought again.
|
||
unitCalls := len(chunksOfChapter(t, r2, 1)) - members + 1
|
||
if fresh := rec.count() - callsBefore; fresh != unitCalls+2 {
|
||
t.Fatalf("the second run made %d provider call(s), want %d (the carried unit's %d missing draft(s) + its edit, plus draft+edit for chapter 2)",
|
||
fresh, unitCalls+2, unitCalls-1)
|
||
}
|
||
}
|
||
|
||
// chunksOfChapter is the manifest's chunks of one chapter — how many DRAFT positions a unit there has.
|
||
func chunksOfChapter(t *testing.T, r *Runner, chapter int) []int {
|
||
t.Helper()
|
||
var idx []int
|
||
for _, ch := range chunksOf(t, r) {
|
||
if ch.Chapter == chapter {
|
||
idx = append(idx, ch.ChunkIdx)
|
||
}
|
||
}
|
||
return idx
|
||
}
|
||
|
||
// renameStages gives both fixture stages new names, so every stored row belongs to a stage the pipeline no
|
||
// longer runs while the book itself is unchanged.
|
||
func renameStages(t *testing.T, bookPath string) {
|
||
t.Helper()
|
||
p := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
|
||
b, err := os.ReadFile(p)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s := strings.Replace(string(b), "name: draft,", "name: draftx,", 1)
|
||
s = strings.Replace(s, "name: edit,", "name: editx,", 1)
|
||
if s == string(b) {
|
||
t.Fatalf("fixture drifted: no stage names to rename in:\n%s", b)
|
||
}
|
||
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// TestRowsOfARetiredStageAreNotAStart pins the evidence rule of unitCarried at its edge: a row for a
|
||
// stage the current pipeline does not run is neither a position this run would execute nor proof that a
|
||
// run under THIS pipeline began the unit. A book whose stages were all renamed has rows for none of its
|
||
// current positions; its units are started afresh and take a slot each, exactly as unitFullyRecorded
|
||
// already judges them incomplete by the same enumeration of positions.
|
||
//
|
||
// Mutation this catches: judge «started» by `len(rows) > 0` instead of by rows at current positions, and
|
||
// every unit of the renamed book rides outside the grant — Carried=3 / LeftFresh=0 under --max-units 1.
|
||
func TestRowsOfARetiredStageAreNotAStart(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := volumeBook(t, srv.URL, 3)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
|
||
renameStages(t, bookPath)
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.Resnapshot = true
|
||
r2.AcceptRebill = RebillConsent{Given: true}
|
||
r2.MaxUnits = 1
|
||
res, err := r2.TranslateBook(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
v := res.Volume
|
||
if v == nil {
|
||
t.Fatal("two units were held back, so the run must report a volume stop")
|
||
}
|
||
if v.Carried != 0 || v.Delivered != 1 || v.LeftFresh != 2 || v.Free != 0 {
|
||
t.Fatalf("rows of retired stages are not a start: one unit takes the slot, two wait — got %+v", *v)
|
||
}
|
||
if len(res.Chunks) != 1 {
|
||
t.Fatalf("OVERSPEND: %d unit(s) ran under --max-units 1", len(res.Chunks))
|
||
}
|
||
}
|
||
|
||
// TestTheRePlanCountsSlotsNotCarriedUnits pins the grant arithmetic at the edit-wave re-plan
|
||
// (rescopeEditWave): a free unit that the mid-run bank move makes paying takes a slot if one is FREE —
|
||
// and a slot is free when the units already paying were carried from an earlier grant. Compared against
|
||
// every paying unit instead (Paid), a run with carried units on the book would refuse the slot it still
|
||
// has and hold the unit back as «not re-made», delivering less than was bought.
|
||
//
|
||
// The scope is built by hand in the shape planVolume leaves — one unit carried, the rest free — because
|
||
// the defect is arithmetic, and the fixture that reaches it (a bounded run interrupted between waves on a
|
||
// book that mines its bank and then moves it mid-run) is the composition of three others.
|
||
func TestTheRePlanCountsSlotsNotCarriedUnits(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := volumeBook(t, srv.URL, 4)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
if _, err := r.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, withText, err := r.readModelChunks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
full, err := withText()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sel := precomputeSticky(full, r.baseMemory, r.Pipeline.Context.GlossaryTokenBudget)
|
||
units := r.outputUnits(full)
|
||
if len(units) != 4 {
|
||
t.Fatalf("fixture needs four units, got %d", len(units))
|
||
}
|
||
|
||
// One unit carried from an earlier grant (paying, no slot), three judged free; a grant of ONE, none of
|
||
// it taken yet.
|
||
s := &volumeScope{
|
||
admitted: map[chunkKey]bool{}, leader: map[chunkKey]bool{},
|
||
class: map[chunkKey]unitClass{}, editBlocked: map[chunkKey]bool{},
|
||
stop: VolumeStop{MaxUnits: 1, Delivered: 1, Carried: 1, Free: 3},
|
||
editSnapshot: "SNAPSHOT-AT-PLAN-TIME",
|
||
}
|
||
for i, u := range units {
|
||
key := chunkKey{u.Chapter, u.FirstChunkIdx}
|
||
s.admitted[key] = true
|
||
s.class[key] = unitFree
|
||
if i == 0 {
|
||
s.class[key] = unitCarried
|
||
}
|
||
for _, m := range u.Members {
|
||
s.leader[chunkKey{m.Chapter, m.ChunkIdx}] = true
|
||
}
|
||
}
|
||
if err := r.rescopeEditWave(ctx, s, units, full, sel, "A-DIFFERENT-SNAPSHOT"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// The three free units are paying now. The grant's one slot is still free — the carried unit took
|
||
// none — so exactly one of them takes it and two are held back.
|
||
if s.stop.Reworked != 1 || s.stop.LeftRework != 2 || s.stop.Free != 0 {
|
||
t.Fatalf("one slot was free and must go to a re-judged unit: got Reworked=%d LeftRework=%d Free=%d (a re-plan that counts the carried unit as a slot refuses all three)",
|
||
s.stop.Reworked, s.stop.LeftRework, s.stop.Free)
|
||
}
|
||
if s.stop.Delivered != 1 || s.stop.Carried != 1 {
|
||
t.Fatalf("the carried unit is untouched by the re-plan: %+v", s.stop)
|
||
}
|
||
}
|
||
|
||
// TestACarriedUnitOnADraftOnlyPipeline is the prompt's review axis 3, executed rather than reasoned: on a
|
||
// draft-only pipeline the SHIPPING wave is the draft one (finalStageWave), and the slot rule has to work
|
||
// there too — a unit whose second draft stage never ran is started, never shipped, and finished outside the
|
||
// grant, exactly as on an editor pipeline. Nothing in the rule branches on the wave, and this is what says
|
||
// so out loud instead of inferring it.
|
||
//
|
||
// It is also the negative half of the axis: the delivered test next door proves a draft-only book's
|
||
// already-shipped units are NOT re-sold; this proves the axis has not gone the other way either, calling
|
||
// an unfinished unit delivered.
|
||
func TestACarriedUnitOnADraftOnlyPipeline(t *testing.T) {
|
||
var mu sync.Mutex
|
||
firstDone, released := false, false
|
||
rec := &reqRec{}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||
body, _ := io.ReadAll(req.Body)
|
||
rec.record(string(body))
|
||
mu.Lock()
|
||
dead := firstDone && !released
|
||
firstDone = true
|
||
mu.Unlock()
|
||
if dead {
|
||
// Every call after the first fails until released: the first unit keeps its stage-1 row and
|
||
// never reaches stage 2 — the store state an interrupted draft wave leaves on this shape.
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
return
|
||
}
|
||
writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop")
|
||
}))
|
||
defer srv.Close()
|
||
|
||
var eps []chunktest.Chapter
|
||
var spine []string
|
||
for i := 1; i <= 3; i++ {
|
||
id := fmt.Sprintf("c%d", i)
|
||
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml", Body: fmt.Sprintf("<p>静かな図書館の朝%d。</p>", i)})
|
||
spine = append(spine, id)
|
||
}
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{epub: eps, spine: spine, draftOnly: true, waveWorkers: 1})
|
||
addSecondDraftStage(t, bookPath) // both stages exist from the first run, so no snapshot moves between them
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err == nil {
|
||
t.Fatal("precondition: the second draft stage was supposed to fail this run")
|
||
}
|
||
rows, err := r1.Store.ChunkStatusesForBook("test-book")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
announced, err := r1.Store.AnnouncedOnceKeys()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
if len(rows) != 1 || rows[0].Stage != "draft" || rows[0].Chapter != 1 {
|
||
t.Fatalf("precondition: exactly one row — chapter 1's first draft stage — must be on file, got %+v", rows)
|
||
}
|
||
if len(announced) != 0 {
|
||
t.Fatalf("precondition: nothing shipped, so nothing may be announced; got %v", announced)
|
||
}
|
||
|
||
mu.Lock()
|
||
released = true
|
||
mu.Unlock()
|
||
callsBefore := rec.count()
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.MaxUnits = 1
|
||
res, err := r2.TranslateBook(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
v := res.Volume
|
||
if v == nil {
|
||
t.Fatal("one unit was held back, so the run must report a volume stop")
|
||
}
|
||
// Chapter 1 was started and never shipped: carried, no slot. The slot buys chapter 2; chapter 3 waits.
|
||
if v.Delivered != 2 || v.Carried != 1 || v.Reworked != 0 || v.Free != 0 {
|
||
t.Fatalf("on a draft-only pipeline a started-but-unshipped unit is carried, and the grant buys a new one: %+v", *v)
|
||
}
|
||
if v.LeftFresh != 1 || v.LeftRework != 0 {
|
||
t.Fatalf("one unit never delivered remains, and nothing is awaiting a re-make: %+v", *v)
|
||
}
|
||
started := chaptersWithRows(t, r2.Store, "test-book")
|
||
if !started[1] || !started[2] || started[3] {
|
||
t.Fatalf("chapters 1-2 must be complete and chapter 3 untouched: %v", started)
|
||
}
|
||
// The carried unit paid only for the stage it lacked; the new unit paid for both of its stages.
|
||
if fresh := rec.count() - callsBefore; fresh != 1+2 {
|
||
t.Fatalf("the run made %d provider call(s), want 3 (the carried unit's missing stage + both stages of the new unit)", fresh)
|
||
}
|
||
}
|
||
|
||
// TestTheCarryIsBoundedByTheGrant is the ceiling's own survival: units an earlier run started ride outside
|
||
// the grant, and if that carry were unbounded the flag would stop bounding anything. The earlier run here
|
||
// carries NO ceiling at all — it drafts the whole book and dies before the edit wave, so nothing was ever
|
||
// charged a slot for those units — and the next purchase is granted one. It must finish ONE of them, not
|
||
// all five.
|
||
//
|
||
// Measured before the bound existed: `--max-units 1` paid for five units and made five provider calls, and
|
||
// because nothing was left over the run reported no volume stop at all. Found by a fresh-context review of
|
||
// this pack's own work.
|
||
func TestTheCarryIsBoundedByTheGrant(t *testing.T) {
|
||
const chapters = 5
|
||
rec := &reqRec{}
|
||
srv, releaseEditor := interruptibleEditor(rec)
|
||
defer srv.Close()
|
||
bookPath := volumeBook(t, srv.URL, chapters)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
// Run 1: NO grant. Every chapter is drafted, the editor dies — five started, never shipped units.
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err == nil {
|
||
t.Fatal("precondition: the editor was supposed to fail this run")
|
||
}
|
||
drafted := chaptersWithRows(t, r1.Store, "test-book")
|
||
r1.Close()
|
||
if len(drafted) != chapters {
|
||
t.Fatalf("precondition: the unbounded run must draft the whole book, got %v", drafted)
|
||
}
|
||
|
||
releaseEditor()
|
||
callsBefore := rec.count()
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.MaxUnits = 1
|
||
res, err := r2.TranslateBook(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
v := res.Volume
|
||
if v == nil {
|
||
t.Fatal("four units were held back, so the run must report a volume stop")
|
||
}
|
||
if v.Carried != 1 || v.Delivered != 1 {
|
||
t.Fatalf("a grant of one may finish ONE interrupted unit, not the whole book: %+v", *v)
|
||
}
|
||
if v.LeftFresh != chapters-1 {
|
||
t.Fatalf("the other four units are undelivered and must be reported as such: %+v", *v)
|
||
}
|
||
if len(res.Chunks) != 1 {
|
||
t.Fatalf("OVERSPEND: %d output unit(s) were paid for under --max-units 1", len(res.Chunks))
|
||
}
|
||
// One editor call, and not one more: the drafts are on file and the four held-back units never begin.
|
||
if fresh := rec.count() - callsBefore; fresh != 1 {
|
||
t.Fatalf("the run made %d provider call(s), want exactly 1 — the carry must not spend past the grant", fresh)
|
||
}
|
||
}
|
||
|
||
// TestARunThatWorkedOutsideItsGrantSaysSo is the disclosure half of the carry: a run whose whole remainder
|
||
// was carried holds nothing back, so a stop judged by the remainder alone would print nothing — in exactly
|
||
// the case where the run delivered most beyond its grant. The stop line is the only channel that explains
|
||
// why a purchase of two delivered two units it did not pay a slot for.
|
||
func TestARunThatWorkedOutsideItsGrantSaysSo(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv, releaseEditor := interruptibleEditor(rec)
|
||
defer srv.Close()
|
||
bookPath := volumeBook(t, srv.URL, 2)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err == nil {
|
||
t.Fatal("precondition: the editor was supposed to fail this run")
|
||
}
|
||
r1.Close()
|
||
|
||
releaseEditor()
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.MaxUnits = 2
|
||
res, err := r2.TranslateBook(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
v := res.Volume
|
||
if v == nil {
|
||
t.Fatal("the run finished two units outside its grant and must say so; nothing was held back, so a report judged by the remainder alone would be silent here")
|
||
}
|
||
if v.Carried != 2 || v.Delivered != 2 || v.Left() != 0 {
|
||
t.Fatalf("both units were carried and nothing remains: %+v", *v)
|
||
}
|
||
line := v.String()
|
||
if !strings.Contains(line, "2 of the new ones had been started by an earlier run") {
|
||
t.Fatalf("the line must name the units finished outside the grant: %s", line)
|
||
}
|
||
// ⚠ AND IT MUST NOT SAY THE RUN STOPPED SHORT. This run reached the end of the book; the report is
|
||
// attached because work happened outside the grant, not because the grant cut anything off. One
|
||
// opening for both cases printed «not at the end of the book» in the same sentence as «0 unit(s)
|
||
// NEVER delivered» — an operator told at once that the run was cut short and that nothing remains.
|
||
if strings.Contains(line, "not at the end of the book") {
|
||
t.Fatalf("the run reached the END of the book, and the line claims it stopped short of it: %s", line)
|
||
}
|
||
if !strings.Contains(line, "reached the END of the book") || !strings.Contains(line, "Nothing is left in the book") {
|
||
t.Fatalf("the end-of-book branch must say so in both halves of the sentence: %s", line)
|
||
}
|
||
}
|
||
|
||
// TestReconcileKeepsCarriedInsideDelivered pins the containment granted() depends on. Carried is documented
|
||
// as a PART of Delivered, and granted() = Delivered − Carried + Reworked; a fall-through that moves
|
||
// Delivered past Carried makes the grant arithmetic negative and the stop line say «0 NEW unit(s)
|
||
// delivered … (1 of the new ones had been started by an earlier run)» — a sentence that cannot be true.
|
||
//
|
||
// The state is reachable: a book with one carried unit and one unit that resumes FREE and then flags, which
|
||
// is the case reconcile's own default branch is written for.
|
||
func TestReconcileKeepsCarriedInsideDelivered(t *testing.T) {
|
||
s := &volumeScope{
|
||
admitted: map[chunkKey]bool{{1, 0}: true, {2, 0}: true},
|
||
leader: map[chunkKey]bool{{1, 0}: true, {2, 0}: true},
|
||
class: map[chunkKey]unitClass{{1, 0}: unitCarried, {2, 0}: unitFree},
|
||
stop: VolumeStop{MaxUnits: 1, Delivered: 1, Carried: 1, Free: 1},
|
||
}
|
||
s.reconcile([]ChunkOutcome{{Chapter: 2, ChunkIdx: 0, Disposition: DispFlagged, FinalText: ""}})
|
||
if s.stop.Carried > s.stop.Delivered {
|
||
t.Fatalf("Carried is a part of Delivered and must stay one: %+v", s.stop)
|
||
}
|
||
if g := s.stop.granted(); g < 0 {
|
||
t.Fatalf("the grant arithmetic went negative: granted()=%d from %+v", g, s.stop)
|
||
}
|
||
if line := s.stop.String(); strings.Contains(line, "0 NEW unit(s) delivered") && strings.Contains(line, "had been started by an earlier run") {
|
||
t.Fatalf("the line attributes carried units to a delivered count of zero: %s", line)
|
||
}
|
||
}
|
||
|
||
// TestReconcileKeepsContainmentOnTheFreshBranch is the same invariant on the OTHER branch. reconcile's
|
||
// docstring claims «every branch here keeps it one», and the fresh branch is the one the test above never
|
||
// reaches: it asks whether a delivered unit that is NOT one of the carried ones is still there to move.
|
||
//
|
||
// ⚠ THE STATE IS CONSTRUCTED, NOT OBSERVED, and that is deliberate. Reaching `Delivered == Carried` with a
|
||
// unitFresh outcome takes a run whose every admitted paying unit was carried while a fresh unit still
|
||
// resolved — a shape no fixture produces today. The invariant is asserted anyway because the code claims
|
||
// it for EVERY branch, and a branch nobody holds to its claim is the one that quietly stops holding: swap
|
||
// the guard back to `Delivered > 0` and this is the only test in the tree that notices.
|
||
func TestReconcileKeepsContainmentOnTheFreshBranch(t *testing.T) {
|
||
s := &volumeScope{
|
||
admitted: map[chunkKey]bool{{1, 0}: true, {2, 0}: true},
|
||
leader: map[chunkKey]bool{{1, 0}: true, {2, 0}: true},
|
||
class: map[chunkKey]unitClass{{1, 0}: unitCarried, {2, 0}: unitFresh},
|
||
stop: VolumeStop{MaxUnits: 1, Delivered: 1, Carried: 1},
|
||
}
|
||
s.reconcile([]ChunkOutcome{{Chapter: 2, ChunkIdx: 0, Disposition: DispFlagged, FinalText: ""}})
|
||
if s.stop.Carried > s.stop.Delivered {
|
||
t.Fatalf("the fresh branch moved Delivered past Carried, so Carried is no longer a part of it: %+v", s.stop)
|
||
}
|
||
if g := s.stop.granted(); g < 0 {
|
||
t.Fatalf("the grant arithmetic went negative: granted()=%d from %+v", g, s.stop)
|
||
}
|
||
if line := s.stop.String(); strings.Contains(line, "0 NEW unit(s) delivered") && strings.Contains(line, "had been started by an earlier run") {
|
||
t.Fatalf("the line attributes carried units to a delivered count of zero: %s", line)
|
||
}
|
||
}
|
||
|
||
// TestTheRunLogSaysWhichKindOfVolumeStopItWas is the log half of the operator sentence. Both take the same
|
||
// fork and for the same reason: a run that reached the END of the book did not stop short of it, and one
|
||
// message that says «it will stop having done what was granted, not at the end of the book» about every
|
||
// bounded run is worthless exactly where the grant behaved unusually — when the report is attached because
|
||
// work happened OUTSIDE the grant rather than because the grant cut anything off.
|
||
//
|
||
// The log is a separate channel from the result (an operator reads stderr, not BookResult), so it needs its
|
||
// own pin: the operator line's test says nothing about it, and deleting the fork here leaves every test in
|
||
// the tree green.
|
||
func TestTheRunLogSaysWhichKindOfVolumeStopItWas(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
chapters int
|
||
maxUnits int
|
||
want, ban string
|
||
}{
|
||
{
|
||
// Two chapters, both started and abandoned, a grant of two: nothing is held back, and the run
|
||
// is reported only because it finished them outside its grant.
|
||
name: "the book ended", chapters: 2, maxUnits: 2,
|
||
want: "reached the end of the book under a VOLUME ceiling that held nothing back",
|
||
ban: "not at the end of the book",
|
||
},
|
||
{
|
||
// Three chapters, a grant of one: one is carried, one waits — the grant really did cut the run
|
||
// short, and the old sentence is the true one.
|
||
name: "the grant ran out", chapters: 3, maxUnits: 1,
|
||
want: "not at the end of the book",
|
||
ban: "reached the end of the book",
|
||
},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv, releaseEditor := interruptibleEditor(rec)
|
||
defer srv.Close()
|
||
bookPath := volumeBook(t, srv.URL, tc.chapters)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err == nil {
|
||
t.Fatal("precondition: the editor was supposed to fail this run")
|
||
}
|
||
r1.Close()
|
||
|
||
releaseEditor()
|
||
var logBuf bytes.Buffer
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||
r2.MaxUnits = tc.maxUnits
|
||
res, err := r2.TranslateBook(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Volume == nil {
|
||
t.Fatal("the run must report a volume stop: work was held back or done outside the grant")
|
||
}
|
||
out := logBuf.String()
|
||
if !strings.Contains(out, tc.want) {
|
||
t.Fatalf("the run log must say which kind of stop this was; want %q in:\n%s", tc.want, out)
|
||
}
|
||
if strings.Contains(out, tc.ban) {
|
||
t.Fatalf("the run log states the OTHER kind of stop; %q must not appear in:\n%s", tc.ban, out)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestTheStopLineSaysTheGrantHeldWorkBackWhenItDid is the OTHER side of the two-opening fork, and it is the
|
||
// side nobody held. TestARunThatWorkedOutsideItsGrantSaysSo pins the new opening (the book ended, nothing
|
||
// was held back); the opening that was there BEFORE the fork existed — the grant stopped the run short —
|
||
// went unpinned, so a fork that reaches the end-of-book wording unconditionally, or on the wrong question,
|
||
// passes the whole tree. Under either the mixed state below prints «reached the END of the book … Still in
|
||
// the book: 1 unit(s) NEVER delivered»: an operator told the book is finished and that a unit of it was
|
||
// never delivered, in one sentence.
|
||
//
|
||
// ⚠ TWO ROWS AND NOT ONE, because the two ways of losing the fork are lost on different states. A fork that
|
||
// is always true fails on either row; a fork asked about the CARRY instead of the remainder only fails
|
||
// where both are non-zero, which is precisely the state the carry made reachable.
|
||
func TestTheStopLineSaysTheGrantHeldWorkBackWhenItDid(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
stop VolumeStop
|
||
}{
|
||
{
|
||
// The plain grant stop: work was held back, nothing was carried.
|
||
name: "held back, nothing carried",
|
||
stop: VolumeStop{MaxUnits: 2, Delivered: 2, LeftFresh: 3},
|
||
},
|
||
{
|
||
// MIXED: the run finished units an earlier run began AND the grant still cut it short. The
|
||
// carry is non-zero here, so a fork keyed on the carry answers the wrong question.
|
||
name: "held back and carried",
|
||
stop: VolumeStop{MaxUnits: 3, Delivered: 3, Carried: 3, LeftFresh: 1},
|
||
},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
line := tc.stop.String()
|
||
if !strings.Contains(line, "not at the end of the book") {
|
||
t.Fatalf("the grant cut this run short and the line must say so: %s", line)
|
||
}
|
||
if strings.Contains(line, "reached the END of the book") {
|
||
t.Fatalf("units are still undelivered, and the line claims the book ended: %s", line)
|
||
}
|
||
if !strings.Contains(line, "Still in the book:") {
|
||
t.Fatalf("the remainder must be named: %s", line)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestOneCarriedUnitIsStillWorkOutsideTheGrant pins the BORDER of bound(), not its shape. bound() reports a
|
||
// run whose remainder is empty but whose carry is not, and the smallest such run — ONE interrupted unit
|
||
// finished at the end of the book — is the one a `> 1` border silently drops: the run delivers a unit it
|
||
// never paid a slot for and returns no report at all, which is the silent-limit anti-pattern D39.165 §1б
|
||
// names. TestARunThatWorkedOutsideItsGrantSaysSo carries two, so it survives that border and cannot see it.
|
||
func TestOneCarriedUnitIsStillWorkOutsideTheGrant(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv, releaseEditor := interruptibleEditor(rec)
|
||
defer srv.Close()
|
||
bookPath := volumeBook(t, srv.URL, 1)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
// Run 1: the single chapter is drafted and the editor dies — one started, never shipped unit.
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err == nil {
|
||
t.Fatal("precondition: the editor was supposed to fail this run")
|
||
}
|
||
r1.Close()
|
||
|
||
releaseEditor()
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.MaxUnits = 1
|
||
res, err := r2.TranslateBook(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
v := res.Volume
|
||
if v == nil {
|
||
t.Fatal("the run finished ONE unit outside its grant and must still report it: a report that starts at two units leaves the smallest discrepancy unexplained")
|
||
}
|
||
if v.Carried != 1 || v.Delivered != 1 || v.Left() != 0 {
|
||
t.Fatalf("one carried unit, nothing left: %+v", *v)
|
||
}
|
||
if !strings.Contains(v.String(), "1 of the new ones had been started by an earlier run") {
|
||
t.Fatalf("the line must name the unit finished outside the grant: %s", v.String())
|
||
}
|
||
}
|
||
|
||
// TestTheStopSentenceCountsEveryUnitTheRunPaidFor pins the FIRST number an operator reads. The struct's
|
||
// arithmetic is checked elsewhere; the SENTENCE is what reaches a human, and it was pinned nowhere — planted,
|
||
// dropping the flagged units out of «%d paying output unit(s)» left the whole tree green while under-counting
|
||
// what the run was billed for.
|
||
func TestTheStopSentenceCountsEveryUnitTheRunPaidFor(t *testing.T) {
|
||
// ⚠ Reworked and Flagged are DIFFERENT numbers here on purpose. They used to both be 1, and that made
|
||
// the flagged clause unpinned by coincidence: planted, the clause printed the REWORK count as the flagged
|
||
// count and the whole tree stayed green, because the two fixtures that read this sentence each had them
|
||
// equal. A fixture where every count is distinct is what turns a substring check into an assertion.
|
||
v := VolumeStop{MaxUnits: 4, Delivered: 2, Reworked: 1, Flagged: 2, Free: 3, LeftFresh: 2}
|
||
line := v.String()
|
||
// 2 delivered + 1 re-made + 2 paid-and-flagged = 5 units billed.
|
||
if !strings.Contains(line, "5 paying output unit(s)") {
|
||
t.Errorf("the paying count must include the units that flagged — they cost money and delivered nothing: %s", line)
|
||
}
|
||
// And the free riders are named, because otherwise the operator sees a run that touched seven units
|
||
// under a grant of four and cannot tell why.
|
||
if !strings.Contains(line, "3 rode along at $0") {
|
||
t.Errorf("the $0 riders must be named: %s", line)
|
||
}
|
||
if !strings.Contains(line, "2 PAID FOR BUT FLAGGED") {
|
||
t.Errorf("money spent for no readable text must be named apart, with its OWN count: %s", line)
|
||
}
|
||
// A stop that held back only ALREADY-DELIVERED units says so as unrefreshed, never as unbought — the
|
||
// head must not claim the book ended while the tail lists a remainder.
|
||
rework := VolumeStop{MaxUnits: 1, Delivered: 1, LeftRework: 3}.String()
|
||
if strings.Contains(rework, "reached the END of the book") {
|
||
t.Errorf("units remain (unrefreshed), so the grant did hold work back: %s", rework)
|
||
}
|
||
if !strings.Contains(rework, "3 already delivered but not yet re-made") {
|
||
t.Errorf("the unrefreshed remainder must be named as such: %s", rework)
|
||
}
|
||
}
|
||
|
||
// TestReconcileMovesAFlaggedCarryOutOfCarried is the branch nobody fixtured, and it is also where the
|
||
// ordering waverun.go depends on becomes visible: after this, a run whose only work outside the grant was
|
||
// that unit has Carried==0, so a report decided from the RECONCILED counters would say nothing at all.
|
||
func TestReconcileMovesAFlaggedCarryOutOfCarried(t *testing.T) {
|
||
s := &volumeScope{
|
||
admitted: map[chunkKey]bool{{1, 0}: true},
|
||
leader: map[chunkKey]bool{{1, 0}: true},
|
||
class: map[chunkKey]unitClass{{1, 0}: unitCarried},
|
||
stop: VolumeStop{MaxUnits: 1, Delivered: 1, Carried: 1},
|
||
}
|
||
if !s.bound() {
|
||
t.Fatal("premise: before reconcile the run has something to say — it finished a unit outside its grant")
|
||
}
|
||
s.reconcile([]ChunkOutcome{{Chapter: 1, ChunkIdx: 0, Disposition: DispFlagged, FinalText: ""}})
|
||
if s.stop.Carried != 0 || s.stop.Delivered != 0 || s.stop.Flagged != 1 {
|
||
t.Fatalf("a carried unit that flagged is paid-and-flagged, not delivered: %+v", s.stop)
|
||
}
|
||
if s.bound() {
|
||
t.Fatal("this is the state the ordering protects: after reconcile there is nothing left to report — which is why waverun asks bound() BEFORE it")
|
||
}
|
||
}
|
||
|
||
// TestReconcileLeavesUnpaidAndReworkedUnitsAlone pins reconcile's two remaining branches, both un-fixtured
|
||
// and both money-facing: a FREE unit that flags was never paid for and must not be counted as paid, and a
|
||
// re-made unit that flags must LEAVE the rework count rather than being counted twice.
|
||
func TestReconcileLeavesUnpaidAndReworkedUnitsAlone(t *testing.T) {
|
||
free := &volumeScope{
|
||
admitted: map[chunkKey]bool{{1, 0}: true},
|
||
leader: map[chunkKey]bool{{1, 0}: true},
|
||
class: map[chunkKey]unitClass{{1, 0}: unitFree},
|
||
stop: VolumeStop{MaxUnits: 1, Free: 1},
|
||
}
|
||
free.reconcile([]ChunkOutcome{{Chapter: 1, ChunkIdx: 0, Disposition: DispFlagged, FinalText: ""}})
|
||
if free.stop.Flagged != 0 || free.stop.Paid() != 0 {
|
||
t.Errorf("a unit that rode along at $0 and flagged cost nothing; counting it as PAID FOR BUT FLAGGED invents a charge: %+v", free.stop)
|
||
}
|
||
|
||
rework := &volumeScope{
|
||
admitted: map[chunkKey]bool{{1, 0}: true},
|
||
leader: map[chunkKey]bool{{1, 0}: true},
|
||
class: map[chunkKey]unitClass{{1, 0}: unitRework},
|
||
stop: VolumeStop{MaxUnits: 1, Reworked: 1},
|
||
}
|
||
rework.reconcile([]ChunkOutcome{{Chapter: 1, ChunkIdx: 0, Disposition: DispFlagged, FinalText: ""}})
|
||
if rework.stop.Reworked != 0 || rework.stop.Flagged != 1 {
|
||
t.Errorf("a re-made unit that flagged is flagged, not still re-made — counting both double-counts the grant: %+v", rework.stop)
|
||
}
|
||
if rework.stop.Paid()+rework.stop.Flagged != 1 {
|
||
t.Errorf("the sentence's paying count would over-report by one: %+v", rework.stop)
|
||
}
|
||
}
|
||
|
||
// TestTheVolumeReportIsAskedBeforeTheCountersAreTruedUp pins the ORDER of bound() and reconcile() at the
|
||
// level where the order exists — the wave driver — because nothing did.
|
||
//
|
||
// ⚠ waverun.go's own comment calls this order load-bearing, says it was found by planting, and sends the
|
||
// reader to TestReconcileMovesAFlaggedCarryOutOfCarried. That test is a unit test of volumeScope: it proves
|
||
// reconcile moves a flagged carry out of Carried, and it cannot see WHERE the driver asks bound(). Planted —
|
||
// the bound() block moved to just after reconcile — the whole pipeline package stayed green while
|
||
// `res.Volume` came back nil, i.e. the run that finished a unit outside its grant and flagged it said
|
||
// NOTHING about it. That is the disclosure the report exists for, and «the comment explains it» is not a
|
||
// guarantee; this is the same «a claim about structure passed off as a guarantee» class the round found
|
||
// four times, here in a comment that is otherwise entirely correct.
|
||
//
|
||
// The fixture is the smallest run that can tell the two orders apart: ONE carried unit, nothing left, and
|
||
// the carry comes back FLAGGED — so bound() is true before reconcile (Carried==1) and false after it
|
||
// (Carried==0, Flagged==1). A carry that DELIVERS cannot see this, which is why the neighbouring
|
||
// TestOneCarriedUnitIsStillWorkOutsideTheGrant passes under the planting.
|
||
func TestTheVolumeReportIsAskedBeforeTheCountersAreTruedUp(t *testing.T) {
|
||
rec := &reqRec{}
|
||
var mu sync.Mutex
|
||
editorRefuses := false
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||
body, _ := io.ReadAll(req.Body)
|
||
rec.record(string(body))
|
||
if isEditBody(string(body)) {
|
||
mu.Lock()
|
||
refuse := editorRefuses
|
||
mu.Unlock()
|
||
if !refuse {
|
||
w.WriteHeader(http.StatusInternalServerError) // run 1: the unit is started and never ships
|
||
return
|
||
}
|
||
writeFakeCompletion(w, "I'm sorry, but I can't help with that request.", "refusal")
|
||
return
|
||
}
|
||
writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop")
|
||
}))
|
||
defer srv.Close()
|
||
bookPath := volumeBook(t, srv.URL, 1)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err == nil {
|
||
t.Fatal("precondition: the editor was supposed to fail this run, leaving one started-but-unshipped unit")
|
||
}
|
||
r1.Close()
|
||
|
||
mu.Lock()
|
||
editorRefuses = true
|
||
mu.Unlock()
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.MaxUnits = 1
|
||
res, err := r2.TranslateBook(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Premise: the run's ONLY work was the carried unit, and it flagged. Without both halves the assertion
|
||
// below would pass under either order and prove nothing.
|
||
if res.Flagged != 1 {
|
||
t.Fatalf("premise broken: the carried unit must come back FLAGGED, got flagged=%d over %d chunk(s)",
|
||
res.Flagged, len(res.Chunks))
|
||
}
|
||
v := res.Volume
|
||
if v == nil {
|
||
t.Fatal("the run finished a unit outside its grant, paid for it, and it FLAGGED — and the run reported " +
|
||
"no volume at all. bound() is being asked AFTER reconcile has moved that carry out of Carried, so " +
|
||
"the one run that most needs the disclosure is the one that goes silent")
|
||
}
|
||
if !strings.Contains(v.String(), "PAID FOR BUT FLAGGED") {
|
||
t.Errorf("the report must name the money spent for no readable text: %s", v.String())
|
||
}
|
||
}
|
||
|
||
// TestTheVolumeReportIsAskedBeforeReconcileOnADraftOnlyPipeline is the draft-only twin of the test above,
|
||
// and it exists because the first version of that pin turned out to be carried by its neighbours.
|
||
//
|
||
// ⚠ The bound() decision sits BEFORE the fork into the draft-only and editor paths, so BOTH paths depend on
|
||
// its position — but only the editor path had a fixture that could tell the two orders apart. A planting
|
||
// that moved the decision after the editor path's reconcile reddened three draft-only tests for the wrong
|
||
// reason (they lost the report entirely, not out of order), and a planting that moved it after BOTH
|
||
// reconciles would have been caught by nothing on this shape. On a draft-only pipeline the SHIPPING wave is
|
||
// the draft one, so the same carried-and-flagged unit is reachable here and reports the same way.
|
||
func TestTheVolumeReportIsAskedBeforeReconcileOnADraftOnlyPipeline(t *testing.T) {
|
||
var mu sync.Mutex
|
||
calls, released := 0, false
|
||
rec := &reqRec{}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||
body, _ := io.ReadAll(req.Body)
|
||
rec.record(string(body))
|
||
mu.Lock()
|
||
calls++
|
||
n, rel := calls, released
|
||
mu.Unlock()
|
||
switch {
|
||
case rel:
|
||
// Run 2: the second draft stage answers, and REFUSES — the carried unit is finished and flagged.
|
||
writeFakeCompletion(w, "I'm sorry, but I can't help with that request.", "refusal")
|
||
case n == 1:
|
||
writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop") // stage 1 of run 1 checkpoints
|
||
default:
|
||
w.WriteHeader(http.StatusInternalServerError) // stage 2 of run 1 dies: started, never shipped
|
||
}
|
||
}))
|
||
defer srv.Close()
|
||
|
||
eps := []chunktest.Chapter{{ID: "c1", Href: "c1.xhtml", Body: "<p>静かな図書館の朝1。</p>"}}
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{epub: eps, spine: []string{"c1"}, draftOnly: true, waveWorkers: 1})
|
||
addSecondDraftStage(t, bookPath)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err == nil {
|
||
t.Fatal("precondition: the second draft stage was supposed to fail this run")
|
||
}
|
||
r1.Close()
|
||
|
||
mu.Lock()
|
||
released = true
|
||
mu.Unlock()
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.MaxUnits = 1
|
||
res, err := r2.TranslateBook(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Flagged != 1 {
|
||
t.Fatalf("premise broken: the carried unit must come back FLAGGED on the shipping (draft) wave, got "+
|
||
"flagged=%d over %d chunk(s)", res.Flagged, len(res.Chunks))
|
||
}
|
||
if res.Volume == nil {
|
||
t.Fatal("a draft-only run finished a unit outside its grant, paid for it, and it FLAGGED — and reported " +
|
||
"no volume at all. The bound() decision is being taken from counters reconcile has already trued up")
|
||
}
|
||
if !strings.Contains(res.Volume.String(), "PAID FOR BUT FLAGGED") {
|
||
t.Errorf("the report must name the money spent for no readable text: %s", res.Volume.String())
|
||
}
|
||
}
|