394 lines
16 KiB
Go
394 lines
16 KiB
Go
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("<p>静かな図書館の朝%d。</p>", 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)
|
|
}
|
|
}
|