package pipeline import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "textmachine/backend/internal/chunk/chunktest" "textmachine/backend/internal/obs" ) // volumedelivery_test.go: unified backlog row 232 — the fresh/rework axis was derived from ROW // COMPLETENESS instead of from the fact of DELIVERY. // // The two questions look alike and diverge exactly where money is: "does this unit still have a row for // every position the pipeline runs" is re-answered against whatever stages the config runs TODAY, while // "has a reader been told this unit shipped" is a fact about the past that nothing can un-happen. Add a // stage and every finished unit loses a row it never had — and a fully read book starts reporting // «N unit(s) NEVER delivered», which is the number an operator turns into "buy more". // addPolishStage appends a SECOND editor stage to a fixture's pipeline. It is the cheapest reproduction // of the shape row 232 names: the book is unchanged, the rows are unchanged, and the only thing that // moved is what the config asks for. func addPolishStage(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 := string(b) if !strings.Contains(s, "name: edit") { t.Fatalf("fixture drifted: the pipeline has no edit stage to append after:\n%s", s) } s += " - { name: polish, role: editor, model: fake-model, prompt_override: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: \"off\" }\n" if err := os.WriteFile(p, []byte(s), 0o644); err != nil { t.Fatal(err) } } // TestAddingAStageDoesNotUndeliverAReadBook is row 232's money-facing half. // // A four-unit book is translated whole — every unit resolved, every unit ANNOUNCED. A second editor stage // is then added, which is a legitimate thing to do to a pipeline and says nothing about the book. Every // unit now lacks a row for the new position, so every unit costs again; that half is correct and is not // what this pins. What it pins is the WORD: the units are already-delivered book being re-made, not book // that was never delivered, and the remainder is unrefreshed rather than unbought. // // Mutation this catches: delete the `if delivered[key]` branch in classifyUnits (i.e. return to judging // delivery by row completeness) and this run reports Delivered=1 / LeftFresh=3 with the stop line saying // «3 unit(s) NEVER delivered» — every assertion below goes RED, and the string assertion goes red on the // exact sentence an operator reads. func TestAddingAStageDoesNotUndeliverAReadBook(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()}) r1 := newRunner(t, bookPath) res1, err := r1.TranslateBook(ctx) if err != nil { t.Fatal(err) } if res1.Flagged != 0 || len(res1.Chunks) != 4 { t.Fatalf("fixture drifted: the first run must deliver all four units cleanly, got %d chunks / %d flagged", len(res1.Chunks), res1.Flagged) } r1.Close() // The ledger the axis is read from must actually have been written — otherwise this test would pass // for the wrong reason (nothing announced, nothing to mis-classify). probe := newRunner(t, bookPath) announced, err := probe.Store.AnnouncedOnceKeys() if err != nil { t.Fatal(err) } probe.Close() edits := 0 for k := range announced { if strings.Contains(k, ":edit:") { edits++ } } if edits != 4 { t.Fatalf("the first run must have announced four EDIT-wave units; got %d of %d keys", edits, len(announced)) } addPolishStage(t, bookPath) r2 := newRunner(t, bookPath) defer r2.Close() r2.Resnapshot = true // the new stage moves the edit-wave snapshot; consenting is not what is under test r2.AcceptRebill = RebillConsent{Given: true} // nor is the re-payment consent r2.MaxUnits = 1 res, err := r2.TranslateBook(ctx) if err != nil { t.Fatal(err) } v := res.Volume if v == nil { t.Fatal("three units were held back, so the run must report a volume stop") } if v.Delivered != 0 { t.Fatalf("every unit of this book has already been delivered — adding a stage cannot make one NEW, "+ "yet %d was reported as delivery", v.Delivered) } if v.Reworked != 1 { t.Fatalf("the granted unit is an already-delivered unit being re-made; got Reworked=%d", v.Reworked) } if v.LeftFresh != 0 { t.Fatalf("⚠ %d unit(s) reported as NEVER delivered on a book the reader has already read whole — "+ "this is the number an operator is invited to buy, and buying it re-sells chapters he owns", v.LeftFresh) } if v.LeftRework != 3 { t.Fatalf("three delivered units still await the new stage; got LeftRework=%d", v.LeftRework) } line := v.String() if !strings.Contains(line, "0 NEW unit(s) delivered") || !strings.Contains(line, "0 unit(s) NEVER delivered") { t.Fatalf("the sentence an operator reads must not offer undelivered book that does not exist: %s", line) } } // TestAnAnnouncedButFLAGGEDUnitIsStillNewBook is the OTHER half of "delivered", and it is the mirror of the // test above rather than a variation on it. // // A `unit_done` line is written for every RESOLVED unit — waverun.go passes `shipped` as a payload field, // not as a condition — so a unit that flagged and shipped nothing is announced exactly like one that // shipped. Reading the announcement alone would therefore call a unit the reader has NO text for an // "already-delivered re-make", which is the same lie as the one above with the sign flipped: it would hide // real undelivered book from the person deciding what to buy. // // A unit that was attempted and FLAGGED took its slot in the run that attempted it; with a stage added it // is «started, never shipped» and rides outside the grant (volume.go, unitCarried) — so the grant here // goes to a unit the reader has, and the flagged one is re-attempted beside it and counted, once it // flags again, as Flagged. The third chapter exists so the grant is actually exhausted and the stop is // reported. // // Mutation this catches: make `unitShipped` answer `len(rows) > 0` instead of reading the SHIPPING row, // and the flagged unit becomes rework: it then competes for the ONE slot with the units that have text, the // grant goes to chapter 1 and the flagged unit is held back as «already delivered, not re-made» — // Reworked=1 / Flagged=0 is exactly the mutation's signature, over a unit the reader has no text for. // (The edit has to be inside unitShipped: weakening its CALL SITE leaves `shipStages` unused and the // package stops building, which is an inconclusive planting rather than a red one.) func TestAnAnnouncedButFLAGGEDUnitIsStillNewBook(t *testing.T) { rec := &reqRec{} // Chapter 2 echoes its CJK source on every call → cjk_artifact, no escalation is configured in this // fixture, so the unit resolves FLAGGED and ships nothing. Chapters 1 and 3 translate cleanly. srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" } if strings.Contains(body, "朝2") { return "静かな図書館の朝2。", "stop" } return "Тихое утро в библиотеке.", "stop" }) defer srv.Close() bookPath := volumeBook(t, srv.URL, 3) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r1 := newRunner(t, bookPath) res1, err := r1.TranslateBook(ctx) if err != nil { t.Fatal(err) } if res1.Flagged != 1 { t.Fatalf("fixture drifted: exactly one unit must flag, got %d", res1.Flagged) } r1.Close() // Both units were ANNOUNCED — that is the premise; only one of them has text. probe := newRunner(t, bookPath) announced, err := probe.Store.AnnouncedOnceKeys() probe.Close() if err != nil { t.Fatal(err) } edits := 0 for k := range announced { if strings.Contains(k, ":edit:") { edits++ } } if edits != 3 { t.Fatalf("premise: all three units must be announced in the shipping wave, got %d", edits) } addPolishStage(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("one unit was held back, so the run must report a volume stop") } // The FLAGGED unit is not re-work — an announcement is not text — so it does not compete for the slot: // it is carried, flags again, ships nothing, and reconcile() counts it as Flagged. The one slot goes to // a unit the reader HAS (chapter 1, re-made under the new stage) and chapter 3 waits for its re-make. if v.Flagged != 1 || v.Reworked != 1 || v.Delivered != 0 || v.Carried != 0 || v.Free != 0 { t.Fatalf("the FLAGGED unit has no text, so it is not re-work and is re-attempted beside the grant — "+ "an announcement is not text; got Delivered=%d Carried=%d Reworked=%d Flagged=%d Free=%d", v.Delivered, v.Carried, v.Reworked, v.Flagged, v.Free) } if v.LeftRework != 1 || v.LeftFresh != 0 { t.Fatalf("one unit that really did ship is still awaiting its re-make, and nothing is NEVER delivered; got LeftRework=%d LeftFresh=%d", v.LeftRework, v.LeftFresh) } } // TestAUnitWhoseEDITFlaggedIsStillNewBook is the hole the sibling test above did not cover, and it is the // ordinary failure shape rather than an exotic one: the DRAFT succeeds — its row carries a final hash — // and the EDIT flags and ships nothing. Such a unit exports "" and the reader has no text for it. // // The first version of unitShipped scanned every row of the unit, so the draft's hash answered for the // whole unit and the stop line offered «0 unit(s) NEVER delivered» over a hole. Reading the SHIPPING row // is what closes it. // // Mutation this catches: widen unitShipped's own body to "any row carries a final hash" and the flagged // unit becomes re-work — it then competes for the one slot with the units that have text, and is held back // as «already delivered, not re-made»: Reworked=1 / Flagged=0 over a unit the reader has no text for. func TestAUnitWhoseEDITFlaggedIsStillNewBook(t *testing.T) { rec := &reqRec{} // Chapter 2's EDITOR echoes the CJK source → the edit row flags with no text, while its draft row is // ok and carries a final hash. Chapters 1 and 3 are clean end to end. srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { if strings.Contains(body, "朝2") { return "静かな図書館の朝2。", "stop" } return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" } return "Тихое утро в библиотеке.", "stop" }) 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) } // The premise, asserted rather than assumed: the flagged unit's DRAFT row carries a hash and its EDIT // row does not. Without this the test could pass because nothing carried a hash at all. rows, err := r1.Store.ChunkStatusesForBook("test-book") if err != nil { t.Fatal(err) } var draftHash, editHash string for _, cs := range rows { if cs.Chapter != 2 { continue } switch cs.Stage { case "draft": draftHash = cs.FinalHash case "edit": editHash = cs.FinalHash } } if draftHash == "" || editHash != "" { t.Fatalf("premise: ch2 must have an ok DRAFT (hash set) and a flagged EDIT (hash empty); got draft=%q edit=%q", draftHash, editHash) } r1.Close() addPolishStage(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("one unit was held back, so the run must report a volume stop") } // The unit whose EDIT flagged has no text — a successful DRAFT is not a delivery — so it is not // re-work and takes no slot: it is carried, its editor runs again and flags again, and reconcile() // counts it as Flagged. The slot goes to chapter 1's re-make; chapter 3's waits. if v.Flagged != 1 || v.Reworked != 1 || v.Delivered != 0 || v.Carried != 0 { t.Fatalf("the unit whose EDIT flagged is not re-work and is re-attempted beside the grant; got Delivered=%d Carried=%d Reworked=%d Flagged=%d", v.Delivered, v.Carried, v.Reworked, v.Flagged) } if v.LeftRework != 1 || v.LeftFresh != 0 { t.Fatalf("one unit that really shipped is awaiting its re-make, and nothing is NEVER delivered; got LeftRework=%d LeftFresh=%d", v.LeftRework, v.LeftFresh) } } // addSecondDraftStage appends a second TRANSLATOR-role stage, which keeps the shipping wave the DRAFT one // (finalStageWave looks at the last stage's role) while making every recorded unit incomplete. func addSecondDraftStage(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 := string(b) if strings.Contains(s, "role: editor") { t.Fatalf("fixture drifted: this must be a DRAFT-ONLY pipeline:\n%s", s) } s += " - { name: draft2, role: translator, model: fake-model, prompt_override: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: \"off\" }\n" if err := os.WriteFile(p, []byte(s), 0o644); err != nil { t.Fatal(err) } } // TestDeliveryIsReadFromTheSHIPPINGWaveOnADraftOnlyPipeline pins the fork the pack's order called out by // name: the announce key carries a WAVE, so "delivered" is a per-wave fact and something has to choose the // wave. The choice is finalStageWave — the wave owning the pipeline's LAST stage — and on a draft-only // pipeline that is the DRAFT wave, because the draft is what ships. // // Without the fork (a hard-coded edit wave, the shape a reader would reach for on the ordinary two-stage // pipeline) a draft-only book has no edit announcements at all, so the lookup answers "nothing was ever // delivered" for every unit and the axis never classifies anything as re-work — «an axis that never counts // anything as shipped», which is exactly what the order warned about. // // Mutation this catches: replace the fork with `waveName := runevents.WaveEdit` and this run reports the // already-read units as NEW book → Delivered≥1 / LeftFresh≥1 → RED. Deleting the fork leaves every other // test in the repo green. func TestDeliveryIsReadFromTheSHIPPINGWaveOnADraftOnlyPipeline(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) 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("
静かな図書館の朝%d。
", i)}) spine = append(spine, id) } bookPath := setupProjectOpts(t, srv.URL, projectOpts{epub: eps, spine: spine, draftOnly: true, waveWorkers: 1}) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } announced, err := r1.Store.AnnouncedOnceKeys() if err != nil { t.Fatal(err) } r1.Close() drafts, edits := 0, 0 for k := range announced { if strings.Contains(k, ":draft:") { drafts++ } if strings.Contains(k, ":edit:") { edits++ } } if drafts != 3 || edits != 0 { t.Fatalf("premise: a draft-only book announces its units in the DRAFT wave and in no other; got draft=%d edit=%d", drafts, edits) } addSecondDraftStage(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.Delivered != 0 || v.LeftFresh != 0 { t.Fatalf("every unit of this draft-only book has already shipped its draft — reading the EDIT wave "+ "instead would report all of them as new book; got Delivered=%d LeftFresh=%d Reworked=%d LeftRework=%d", v.Delivered, v.LeftFresh, v.Reworked, v.LeftRework) } }