package pipeline import ( "bytes" "log/slog" "strings" "testing" ) // phasefits_test.go: A2 — a phase printed its estimate beside its budget, saw that it did not fit, and // started anyway. // // The old shape aborted PART-WAY through: the per-batch gate stopped the pass mid-flight, leaving a // half-consolidated bank and paid work whose report said nothing about being cut short. On the cold run // that happened twice and three batches of four were never bought. The fix decides BEFORE the first call. // TestThePhaseCutsItsPlanBeforeTheFirstCall is the landing. // // Mutation this catches: move the budget decision back inside the execution loop (or drop the `fits` // bound) and the pass runs batches it cannot afford before discovering it — the announcement then arrives // after money is gone, and the ordering assertion below fires. func TestThePhaseCutsItsPlanBeforeTheFirstCall(t *testing.T) { var logBuf bytes.Buffer rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isTerminologyBody(body) { return "方源\tФан Юань\n花家\tклан Хуа\n青茅山\tгора Цинмао", "stop" } return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop" }) defer srv.Close() probe := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, batchRunes: 400})) _ = runToSignatureStop(t, probe) if probe.lastTerminology.Batches < 2 { t.Fatalf("the fixture must actually batch, got %d", probe.lastTerminology.Batches) } perBatch := probe.lastTerminology.EstimateUSD / float64(probe.lastTerminology.Batches) probe.Close() r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{ terminology: true, batchRunes: 400, budgetUSD: perBatch * 1.5, // one batch fits, the rest do not })) defer r.Close() r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn})) _ = runToSignatureStop(t, r) res := r.lastTerminology if res.BatchesDropped == 0 { t.Fatalf("the fixture must actually exceed the budget: %d batches, none dropped", res.Batches) } out := logBuf.String() // (1) THE DECISION IS ANNOUNCED, and it names every term the operator's next action depends on. if !strings.Contains(out, "CUT TO WHAT DOES") { t.Fatalf("a phase that cannot afford its own plan must say so, and say it as a decision rather "+ "than as an interruption:\n%s", out) } for _, want := range []string{"batches_planned", "batches_running", "batches_dropped", "budget_usd"} { if !strings.Contains(out, want) { t.Fatalf("the cut must name %q — an operator raises the budget or accepts a partial bank, and "+ "both need the numbers:\n%s", want, out) } } // (2) AND THE RESULT SAYS THE BANK IS PARTIAL, because «unanswered» alone reads as a verdict about the // TERMS when it is partly a verdict about the money. if !strings.Contains(out, "PARTIALLY consolidated") { t.Fatalf("a bank the budget cut short must be reported as partial:\n%s", out) } } // TestTheCutIsDecidedBeforeAnyMoneyMoves is the axis the pack called out separately: NO PARTIAL WORK MAY // BE PAID FOR by a decision made after the fact. It is shown on the fixture rather than argued. // // The pass is given a budget BELOW one batch. Under the old shape the gate sat inside the loop and the // first batch was still attempted — the estimate was checked against the budget, so it refused, but the // pass had already been entered and the decision belonged to the loop. Here the plan is cut to ZERO // batches before the loop is entered at all, so the provider is never reached and the bank is untouched. func TestTheCutIsDecidedBeforeAnyMoneyMoves(t *testing.T) { // TWO providers: the sizing probe legitimately calls the role (that is how the per-batch estimate is // obtained at all), and only the BUDGETED run is forbidden to reach it. permissive := newJSONProvider(&reqRec{}, func(body string) (string, string) { if isTerminologyBody(body) { return "方源\tФан Юань", "stop" } return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop" }) defer permissive.Close() probe := newVerifyRunner(t, setupMiningStopProject(t, permissive.URL, miningStopOpts{terminology: true, batchRunes: 400})) _ = runToSignatureStop(t, probe) perBatch := probe.lastTerminology.EstimateUSD / float64(probe.lastTerminology.Batches) probe.Close() rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isTerminologyBody(body) { t.Error("a pass that cannot afford ONE batch must not reach the provider at all") return "方源\tФан Юань", "stop" } return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop" }) defer srv.Close() r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{ terminology: true, batchRunes: 400, budgetUSD: perBatch * 0.4, // not even one batch fits })) defer r.Close() _ = runToSignatureStop(t, r) res := r.lastTerminology if res.Batches == 0 { t.Fatal("the fixture must have produced batches to drop") } if res.BatchesDropped != res.Batches { t.Fatalf("nothing fits, so the whole plan is dropped: planned=%d dropped=%d", res.Batches, res.BatchesDropped) } if res.CostUSD != 0 { t.Fatalf("a plan that fits nothing must spend NOTHING — partial work paid for by a decision taken "+ "after the money moved is the defect this pass was rebuilt to remove; got $%.6f", res.CostUSD) } if res.Consolidated != 0 { t.Fatalf("no batch ran, so nothing can have been consolidated: %d", res.Consolidated) } }