595 lines
31 KiB
Go
595 lines
31 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
"os"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/store"
|
||
"textmachine/backend/internal/terminology"
|
||
"textmachine/backend/internal/text"
|
||
)
|
||
|
||
// askedAsCandidate reports whether a surface was sent to the paid role as a CANDIDATE of the block, which
|
||
// is the only question about money. The candidate list is spelled as markdown headings, and every
|
||
// candidate carries the source sentences it occurs in — so a surface can be all over a request without
|
||
// anybody having been asked about it.
|
||
func askedAsCandidate(rec *reqRec, src string) bool {
|
||
for _, body := range rec.all() {
|
||
if isTerminologyBody(body) && strings.Contains(body, "### "+src) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// terminologyBodies is what the role was actually sent, for a failure that has to be readable.
|
||
func terminologyBodies(rec *reqRec) string {
|
||
var out []string
|
||
for _, body := range rec.all() {
|
||
if isTerminologyBody(body) {
|
||
out = append(out, body)
|
||
}
|
||
}
|
||
return strings.Join(out, "\n---\n")
|
||
}
|
||
|
||
// banksettled_test.go pins the filter that stops the paid terminology role being asked about surfaces the
|
||
// bank has already settled. The two cases below are the same run with ONE byte of the draft's proposal
|
||
// changed, so what they measure is the agreement and not two different fixtures.
|
||
|
||
// TestABankSettledCandidateIsNotPaidFor is case (i): the seed holds the surface, the draft proposed the
|
||
// rendering the seed holds, and the role is therefore never asked about it.
|
||
func TestABankSettledCandidateIsNotPaidFor(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isTerminologyBody(body) {
|
||
return "青茅山\tгора Цинмао", "stop"
|
||
}
|
||
// The draft proposes for 方源 exactly what the seed already holds.
|
||
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
seed := "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n"
|
||
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
|
||
defer r.Close()
|
||
var runLog bytes.Buffer
|
||
r.Log = slog.New(slog.NewTextHandler(&runLog, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||
_ = runToSignatureStop(t, r)
|
||
|
||
tres := r.lastTerminology
|
||
// The number is stated WITH its denominator: "1 skipped" says nothing without "of how many".
|
||
if tres.BankSettled != 1 {
|
||
t.Fatalf("the settled candidate must be skipped before payment: skipped=%d of candidates=%d", tres.BankSettled, tres.Candidates)
|
||
}
|
||
if tres.Candidates < 2 {
|
||
t.Fatalf("premise broken: with fewer than two candidates a skip is indistinguishable from an empty run: %d", tres.Candidates)
|
||
}
|
||
// What the money question actually asks: did 方源 reach a paid request as a CANDIDATE. Asked on the
|
||
// block heading and not on the bare surface — every candidate's source contexts quote the sentences it
|
||
// occurs in, so 方源 appears in the request whatever the filter does, and a substring assertion here
|
||
// would have reported the filter broken while it was working.
|
||
if askedAsCandidate(rec, "方源") {
|
||
t.Errorf("the settled surface was sent to the paid role anyway:\n%s", terminologyBodies(rec))
|
||
}
|
||
// CONTROL: the OTHER candidate did reach it, so the absence above is the filter and not a role that
|
||
// was never called.
|
||
if !askedAsCandidate(rec, "青茅山") {
|
||
t.Fatalf("control: the unsettled candidate must still be paid for, or nothing was measured")
|
||
}
|
||
// ⚠ THE KEYS OF THE SAVING LINE ARE PINNED HERE and not by the operator-message catalogue, because that
|
||
// catalogue guards message TEXTS and says so — the structured arguments are outside it by declaration.
|
||
// This is the one line a later shift will grep to learn what the filter bought, and a number without
|
||
// its denominator is unreadable: «12 skipped» says nothing without «of 300».
|
||
// ⚠ Asserted on the ONE line that carries the saving, not on the whole buffer: three Contains over a
|
||
// shared buffer are satisfiable by three DIFFERENT lines, so the denominator could go to zero and the
|
||
// test stay green. Found by review after it was written that way.
|
||
var saving string
|
||
for _, line := range strings.Split(runLog.String(), "\n") {
|
||
if strings.Contains(line, "already settled") {
|
||
saving = line
|
||
}
|
||
}
|
||
if saving == "" {
|
||
t.Fatalf("the saving must be logged at all:\n%s", runLog.String())
|
||
}
|
||
for _, want := range []string{"skipped=1", "of_candidates=3", "still_paid=2"} {
|
||
if !strings.Contains(saving, want) {
|
||
t.Errorf("the saving line must carry %q — a number without its denominator is unreadable:\n%s", want, saving)
|
||
}
|
||
}
|
||
// The sheet still lists the settled surface — the skip is about what was BOUGHT, not about what the
|
||
// owner is shown. Its dst column is empty, because no role answered for it and the sheet must not
|
||
// print a rendering nobody produced this run.
|
||
var settled *BankStopRow
|
||
for i, row := range r.lastBankStopRows {
|
||
if row.Src == "方源" {
|
||
settled = &r.lastBankStopRows[i]
|
||
}
|
||
}
|
||
if settled == nil {
|
||
t.Fatalf("the skipped candidate must still be on the sheet: %+v", r.lastBankStopRows)
|
||
}
|
||
if settled.Dst != "" {
|
||
t.Errorf("no role answered for this candidate, so the sheet must not carry a rendering as if one had: %q", settled.Dst)
|
||
}
|
||
// ⛔ AND THE SHEET MUST SAY WHY. An empty dst is the same glyph as «the role declined», «no reply line
|
||
// covered it» and «the budget did not reach it» — three facts that mean UNDECIDED beside one that means
|
||
// NOTHING TO DECIDE, and the review ranking files all four together. The report of this pack first
|
||
// claimed the owner could read the difference off the row; he could not, and this is that claim made
|
||
// true instead of withdrawn.
|
||
if !settled.SettledByBank {
|
||
t.Errorf("a row the role was never asked about must be marked as such: %+v", *settled)
|
||
}
|
||
if !strings.Contains(renderBankStopTable(r.lastBankStopRows), "NOT ASKED(the bank already renders this surface") {
|
||
t.Errorf("the rendered sheet must carry the mark, not only the struct:\n%s", renderBankStopTable(r.lastBankStopRows))
|
||
}
|
||
// CONTROL: the candidate that WAS paid for is not marked, so the mark is the filter answering.
|
||
for _, row := range r.lastBankStopRows {
|
||
if row.Src == "青茅山" && row.SettledByBank {
|
||
t.Errorf("a candidate the role WAS asked about must not be marked as unasked: %+v", row)
|
||
}
|
||
}
|
||
if len(settled.Variants) == 0 {
|
||
t.Errorf("the drafts' own proposal must still be on the sheet — that is what the owner reads instead: %+v", *settled)
|
||
}
|
||
}
|
||
|
||
// TestADisagreeingBankedCandidateIsStillPaidFor is case (ii), and it is the half that keeps the filter
|
||
// from paying for itself with the owner's information: the seed holds the surface, the draft proposed
|
||
// something ELSE, and that disagreement is the whole reason the role is asked about banked surfaces.
|
||
// Measured on the corpus, this is the majority of the population — 366 of the 439 candidates the owner's
|
||
// own seed holds — so a filter without this half would buy its saving by silencing them.
|
||
func TestADisagreeingBankedCandidateIsStillPaidFor(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isTerminologyBody(body) {
|
||
return "方源\tИсточник Фана\n青茅山\tгора Цинмао", "stop"
|
||
}
|
||
// One byte of difference from the case above: the draft calls 方源 something the seed does not.
|
||
return "Странник пришёл." + "\n" + bankSeparator + "\n方源\tСтранник\tname\n青茅山\tгора Цинмао\tplace", "stop"
|
||
})
|
||
defer srv.Close()
|
||
seed := "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n"
|
||
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
|
||
defer r.Close()
|
||
_ = runToSignatureStop(t, r)
|
||
|
||
if n := r.lastTerminology.BankSettled; n != 0 {
|
||
t.Fatalf("a draft that disagrees with the bank must be PAID for, not skipped: skipped=%d", n)
|
||
}
|
||
if !askedAsCandidate(rec, "方源") {
|
||
t.Fatalf("the disagreeing surface must reach the paid role as a candidate:\n%s", terminologyBodies(rec))
|
||
}
|
||
// And the finding the payment bought has to be on the sheet, which is what the payment is FOR.
|
||
var row *BankStopRow
|
||
for i, cur := range r.lastBankStopRows {
|
||
if cur.Src == "方源" {
|
||
row = &r.lastBankStopRows[i]
|
||
}
|
||
}
|
||
if row == nil {
|
||
t.Fatalf("premise broken: 方源 is not on the sheet: %+v", r.lastBankStopRows)
|
||
}
|
||
if len(row.BankHolds) == 0 {
|
||
t.Errorf("the payment bought the mark that the book already calls this term something else, and it must reach the sheet: %+v", *row)
|
||
}
|
||
}
|
||
|
||
// TestAResumeFindsItsCheckpointsAfterTheFilter pins the resume cure, and it is the assertion that caught
|
||
// the first cure being wrong. Batches are packed greedily, so dropping a candidate changes the composition
|
||
// of a surviving batch, and a bank-role batch is addressed by the hash of its request: a second run whose
|
||
// composition differs from the first finds no checkpoint and buys the whole pass again. The first cure
|
||
// written here made the filter stand down for a book that had already paid — which produced exactly the
|
||
// re-purchase it was meant to prevent, because run one had paid under the FILTERED composition.
|
||
//
|
||
// What is asserted is money, not a flag: the resumed run reaches the provider for the bank role zero
|
||
// times and its pass costs $0.
|
||
func TestAResumeFindsItsCheckpointsAfterTheFilter(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isTerminologyBody(body) {
|
||
return "青茅山\tгора Цинмао", "stop"
|
||
}
|
||
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
seed := "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n"
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed})
|
||
|
||
// ⚠ BOTH runs AUTO-CONTINUE (no --verify-bank), and that is load-bearing rather than convenience: a
|
||
// run that STOPS at the bank boundary does not write the auto-bank (18-bank-ontology.md — the view is
|
||
// not changed on a stop boundary), so the bank the second run reads would be byte-identical to the
|
||
// first's whatever the filter looked at. The first version of this test stopped, and a planting that
|
||
// made the filter read the WHOLE bank — auto-bank rows this very run rewrites, and therefore a bank
|
||
// that MOVES between runs — survived it. Auto-continuing is what makes the second run see a bank that
|
||
// the first one changed.
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(context.Background()); err != nil {
|
||
t.Fatalf("the first run: %v", err)
|
||
}
|
||
if r1.lastTerminology.BankSettled != 1 {
|
||
t.Fatalf("premise broken: the first run must filter, or the resume proves nothing: %+v", r1.lastTerminology)
|
||
}
|
||
if r1.lastTerminology.CostUSD == 0 {
|
||
t.Fatalf("premise broken: the first run must actually BUY the pass, or a $0 resume is meaningless")
|
||
}
|
||
terminologyCallsAfterFirst := countTerminologyCalls(rec)
|
||
r1.Close()
|
||
|
||
// PREMISE for the whole test: the first run really did leave an auto-bank behind, so the second run's
|
||
// bank is not the same object. Without this the assertions below hold for a reason unrelated to them.
|
||
if _, err := os.Stat(r1.autoBankPath()); err != nil {
|
||
t.Fatalf("premise broken: the first run wrote no auto-bank, so the bank cannot move between runs: %v", err)
|
||
}
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
var log bytes.Buffer
|
||
r2.Log = slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
if _, err := r2.TranslateBook(context.Background()); err != nil {
|
||
t.Fatalf("the resumed run: %v", err)
|
||
}
|
||
// ⛔ RUN 2 IS THE ONE-TIME RE-PACK, AND IT IS THE PACK'S ORDERED PRICE — NOT A REGRESSION.
|
||
//
|
||
// Until the settled basis existed, run 2 was $0: the filter is unconditional, so the composition was a
|
||
// pure function of the candidates and the bank, identical on every run, and every checkpoint replayed.
|
||
// The basis adds a THIRD input, and that input goes from empty to full at run 1's output boundary. So
|
||
// run 2 sees a composition run 1 never had, finds no checkpoint for it, and buys the pass ONCE. From
|
||
// run 3 on the basis is stable and every run replays for $0 — which is the half this test now pins, and
|
||
// the half the old two-run shape could not express at all.
|
||
//
|
||
// ⚠ THE CURE THAT LOOKS OBVIOUS IS THE BUG THIS FILE ALREADY DOCUMENTS: «stand down for a book that has
|
||
// already paid» makes the rule decide differently on the second run, and a rule that decides differently
|
||
// on the second run is not a resume rule (see dropBankSettled). The one-time cost is bounded by
|
||
// gates.terminology.budget_usd and ANNOUNCED — the reconsolidation warning below is that announcement,
|
||
// and run 2 is exactly the case its sentence describes.
|
||
if !strings.Contains(log.String(), logKeyReconsolidated+"=true") {
|
||
t.Errorf("run 2 re-bought the pass and said nothing: the one-time cost must announce itself, or an operator meets it as an unexplained charge\n%s", log.String())
|
||
}
|
||
if r2.lastTerminology.BankSettled != 1 {
|
||
t.Errorf("the filter must decide the same way on every run, or the composition moves: skipped=%d", r2.lastTerminology.BankSettled)
|
||
}
|
||
if r2.lastTerminology.BasisServed == 0 {
|
||
t.Errorf("premise broken: run 2 must be served by the basis run 1 wrote, or the re-pack below has another cause entirely")
|
||
}
|
||
callsAfterSecond := countTerminologyCalls(rec)
|
||
// The re-pack is a PURCHASE, and it is asserted rather than left to the cost field: «run 2 paid» and
|
||
// «run 2 called the provider» are different facts, and the second is what makes run 3's zero below mean
|
||
// «replayed» instead of «never ran».
|
||
if callsAfterSecond <= terminologyCallsAfterFirst {
|
||
t.Errorf("run 2 announced a re-purchase but reached the provider %d → %d times: either the announcement is false or the pass never ran",
|
||
terminologyCallsAfterFirst, callsAfterSecond)
|
||
}
|
||
r2.Close()
|
||
|
||
// --- run 3: the basis is stable now, so the composition is, so every checkpoint replays ---
|
||
r3 := newRunner(t, bookPath)
|
||
defer r3.Close()
|
||
var log3 bytes.Buffer
|
||
r3.Log = slog.New(slog.NewTextHandler(&log3, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
if _, err := r3.TranslateBook(context.Background()); err != nil {
|
||
t.Fatalf("the third run: %v", err)
|
||
}
|
||
if r3.lastTerminology.CostUSD != 0 {
|
||
t.Errorf("run 3 must replay from its checkpoints for $0, got $%f — the basis is not stable between runs and EVERY purchase would re-buy the pass", r3.lastTerminology.CostUSD)
|
||
}
|
||
// Counted on the BANK ROLE's own calls: the run still walks the edit wave, so a count of all provider
|
||
// calls would move for a reason that has nothing to do with this contour.
|
||
if n := countTerminologyCalls(rec); n != callsAfterSecond {
|
||
t.Errorf("run 3 must reach the provider zero times for the bank role: role calls went %d → %d",
|
||
callsAfterSecond, n)
|
||
}
|
||
if strings.Contains(log3.String(), logKeyReconsolidated+"=true") {
|
||
t.Errorf("the one-time-cost warning fired on a run that re-bought nothing — it would then cry money on every ordinary resume, which is the defect this message was rewritten to remove:\n%s", log3.String())
|
||
}
|
||
}
|
||
|
||
// countTerminologyCalls is how many provider calls carried the terminology role's block.
|
||
func countTerminologyCalls(rec *reqRec) int {
|
||
n := 0
|
||
for _, body := range rec.all() {
|
||
if isTerminologyBody(body) {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// TestTheFilterChangesNothingOnTheCANDIDATESITKEEPS is the output-equivalence half of the order: the run
|
||
// must buy less and produce the same artifact for everything it still buys.
|
||
//
|
||
// The comparison is against a run of the SAME corpus and the SAME replies whose seed names an unrelated
|
||
// surface, so nothing is settled and the role is paid for every candidate. What the two runs must agree on,
|
||
// byte for byte, is the sheet row of the candidate both of them paid for; what they may differ in is the
|
||
// skipped surface itself, which is exactly "equal to the original minus the skipped rows".
|
||
func TestTheFilterChangesNothingOnTheCANDIDATESITKEEPS(t *testing.T) {
|
||
reply := func(body string) (string, string) {
|
||
if isTerminologyBody(body) {
|
||
return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop"
|
||
}
|
||
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
|
||
}
|
||
run := func(seed string) (*Runner, []BankStopRow) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, reply)
|
||
t.Cleanup(srv.Close)
|
||
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
|
||
t.Cleanup(func() { _ = r.Close() })
|
||
_ = runToSignatureStop(t, r)
|
||
return r, r.lastBankStopRows
|
||
}
|
||
// A: the seed settles 方源. B: the seed names a surface this book does not contain, so nothing is
|
||
// settled and every candidate is paid for.
|
||
withFilter, rowsA := run("terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n")
|
||
_, rowsB := run("terms:\n - { src: 張三, dst: Чжан Сань, status: approved }\n")
|
||
|
||
if withFilter.lastTerminology.BankSettled != 1 {
|
||
t.Fatalf("premise broken: run A must have filtered exactly one candidate: %+v", withFilter.lastTerminology)
|
||
}
|
||
find := func(rows []BankStopRow, src string) *BankStopRow {
|
||
for i := range rows {
|
||
if rows[i].Src == src {
|
||
return &rows[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
kept, kept2 := find(rowsA, "青茅山"), find(rowsB, "青茅山")
|
||
if kept == nil || kept2 == nil {
|
||
t.Fatalf("premise broken: the paid-for candidate must be on both sheets: A=%+v B=%+v", rowsA, rowsB)
|
||
}
|
||
if !reflect.DeepEqual(*kept, *kept2) {
|
||
t.Errorf("the filter changed the sheet row of a candidate it did not skip:\n with filter: %+v\n without: %+v", *kept, *kept2)
|
||
}
|
||
// CONTROL for that equality: the SKIPPED surface is where the two runs are allowed to differ, and they
|
||
// must — otherwise the DeepEqual above is comparing two runs that took the same path.
|
||
skipped, paidFor := find(rowsA, "方源"), find(rowsB, "方源")
|
||
if skipped == nil || paidFor == nil {
|
||
t.Fatalf("premise broken: the skipped surface must be on both sheets: A=%+v B=%+v", rowsA, rowsB)
|
||
}
|
||
if skipped.Dst != "" {
|
||
t.Errorf("the skipped candidate was never asked about, so its sheet row carries no rendering: %q", skipped.Dst)
|
||
}
|
||
if paidFor.Dst == "" {
|
||
t.Fatalf("control: without the filter the same candidate IS answered for, or the two runs did not differ")
|
||
}
|
||
}
|
||
|
||
// TestTheClassifiersTypeStillReachesTheSheetThroughTheFilter is the regression the filter's own first
|
||
// implementation carried, and it is here because the tests written for that filter could not have seen it.
|
||
//
|
||
// The classifier and the scorer MUTATE candidates in place, and the caller renders the sheet and the delta
|
||
// from the slice it passed in. Filtering the caller's slice copied the survivors into a new array, so those
|
||
// mutations stopped reaching the caller: the sheet would have carried the heuristic type and the unscored
|
||
// ranking, with every counter reading clean. Every other fixture in this package runs with the classifier
|
||
// OFF, where neither mutation happens — so this test turns it ON, which is the only state in which the
|
||
// defect is observable at all.
|
||
func TestTheClassifiersTypeStillReachesTheSheetThroughTheFilter(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isClassifierBody(body) {
|
||
// The heuristic drafted 青茅山 as a place; the classifier says otherwise, and THAT is the byte
|
||
// that has to survive the filter and reach the sheet.
|
||
return "青茅山\tterm\tnone", "stop"
|
||
}
|
||
if isTerminologyBody(body) {
|
||
return "青茅山\tгора Цинмао", "stop"
|
||
}
|
||
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
seed := "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n"
|
||
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true, glossarySeed: seed}))
|
||
defer r.Close()
|
||
_ = runToSignatureStop(t, r)
|
||
|
||
if r.lastTerminology.BankSettled != 1 {
|
||
t.Fatalf("premise broken: the filter must have skipped a candidate, or nothing is being crossed: %+v", r.lastTerminology)
|
||
}
|
||
if r.lastTerminology.Reclassified != 1 {
|
||
t.Fatalf("premise broken: the classifier must have CHANGED a type, or there is no mutation to lose: %+v", r.lastTerminology)
|
||
}
|
||
var row *BankStopRow
|
||
for i, cur := range r.lastBankStopRows {
|
||
if cur.Src == "青茅山" {
|
||
row = &r.lastBankStopRows[i]
|
||
}
|
||
}
|
||
if row == nil {
|
||
t.Fatalf("premise broken: 青茅山 is not on the sheet: %+v", r.lastBankStopRows)
|
||
}
|
||
if row.Type != "term" {
|
||
t.Errorf("the classifier's type must reach the sheet through the filter, got %q — the filter copied the candidates and the mutation was lost", row.Type)
|
||
}
|
||
// ⛔ AND THE WIRE, which neither of the two assertions above reaches. The classifier is bought FOR the
|
||
// type field, and that field travels to the ROLE for the whole batch — so the money is wasted the
|
||
// moment the request carries the heuristic's guess while the run prints `reclassified=N`. Asserted on
|
||
// the request bytes: this is the third form of one defect (the subset held as its own array), and the
|
||
// first two were both found after their own tests went green.
|
||
var asked string
|
||
for _, body := range rec.all() {
|
||
if isTerminologyBody(body) && strings.Contains(body, "### 青茅山") {
|
||
asked = body
|
||
}
|
||
}
|
||
if asked == "" {
|
||
t.Fatalf("premise broken: the surface must reach the paid role at all:\n%s", terminologyBodies(rec))
|
||
}
|
||
if !strings.Contains(asked, "type: term") || strings.Contains(asked, "type: place") {
|
||
t.Errorf("the classifier's corrected type must reach the ROLE's request, not only the sheet — the run paid for that field:\n%s", asked)
|
||
}
|
||
// THE SECOND HALF OF THE SAME GUARANTEE, and it needs its own assertion: applyTypes and ScoreVariants
|
||
// are two in-place mutations, and pinning one leaves the other free. Re-scoring is what makes the
|
||
// heuristic's `conform` signal go away here — transliteration conformance speaks only about name/place
|
||
// (scoreOpts), so once the classifier calls this surface a `term` the signal is no longer true of it.
|
||
// Applied to the filtered copy instead, the sheet keeps a ranking factor the classification retired,
|
||
// and the operator reads a reason that no longer holds. Measured both ways: correct code gives no
|
||
// signals, the planting gives [conform].
|
||
for _, sig := range row.Signals {
|
||
if sig == "conform" {
|
||
t.Errorf("the re-scoring must reach the sheet too: %q is ranked by a conformance that only speaks about name/place, and the classifier made it a term — signals=%v", row.Src, row.Signals)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTheOneTimeCostIsAnnouncedExactlyWhenItHappens pins the migration warning in BOTH directions, and it
|
||
// exists because that warning has been wrong twice, each time in a way its own test could not see.
|
||
//
|
||
// - first it was emitted before the pass, on «this book has paid» — true of every ordinary resume of a
|
||
// filtered book, so a live resume that re-bought nothing was told it had paid again;
|
||
// - then the probe moved after the pass, where the pass's OWN checkpoints make «has paid» trivially true
|
||
// — so a fresh book's FIRST run, having paid once, was told it had paid twice.
|
||
//
|
||
// Both halves are therefore asserted: the warning must be SILENT on a fresh first run and must FIRE on the
|
||
// one case it describes. A test that only asserts silence is vacuous here — the firing branch is
|
||
// unreachable in it — and that is exactly how the second version shipped.
|
||
func TestTheOneTimeCostIsAnnouncedExactlyWhenItHappens(t *testing.T) {
|
||
reply := func(body string) (string, string) {
|
||
if isTerminologyBody(body) {
|
||
return "青茅山\tгора Цинмао", "stop"
|
||
}
|
||
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
|
||
}
|
||
|
||
t.Run("silent on a fresh book that paid once", func(t *testing.T) {
|
||
srv := newJSONProvider(&reqRec{}, reply)
|
||
defer srv.Close()
|
||
seed := "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n"
|
||
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
|
||
defer r.Close()
|
||
var log bytes.Buffer
|
||
r.Log = slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
_ = runToSignatureStop(t, r)
|
||
if r.lastTerminology.BankSettled != 1 || r.lastTerminology.CostUSD == 0 {
|
||
t.Fatalf("premise broken: the run must both FILTER and PAY, or the branch is not reached: %+v", r.lastTerminology)
|
||
}
|
||
if strings.Contains(log.String(), logKeyReconsolidated+"=true") {
|
||
t.Errorf("a book paying for the FIRST time was told it had paid before — the probe is reading this run's own checkpoints:\n%s", log.String())
|
||
}
|
||
})
|
||
|
||
t.Run("fires when an earlier composition really was paid for", func(t *testing.T) {
|
||
srv := newJSONProvider(&reqRec{}, reply)
|
||
defer srv.Close()
|
||
// Run one has NO seed surface for 方源, so nothing is filtered and the pass is paid for over the
|
||
// FULL candidate set.
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
|
||
r1 := newVerifyRunner(t, bookPath)
|
||
_ = runToSignatureStop(t, r1)
|
||
if r1.lastTerminology.BankSettled != 0 || r1.lastTerminology.CostUSD == 0 {
|
||
t.Fatalf("premise broken: run one must filter NOTHING and pay: %+v", r1.lastTerminology)
|
||
}
|
||
minedDelta := r1.Book.MinedDelta
|
||
r1.Close()
|
||
|
||
// The owner signs 方源 with the rendering the drafts proposed. Its surface is now a seed surface,
|
||
// so run two filters it — and the composition it pays under is not the one run one paid under.
|
||
writeFile(t, minedDelta, "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n")
|
||
|
||
r2 := newVerifyRunner(t, bookPath)
|
||
defer r2.Close()
|
||
var log bytes.Buffer
|
||
r2.Log = slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
if _, err := r2.TranslateBook(context.Background()); err != nil {
|
||
var stop *WaveSignatureStop
|
||
if !errors.As(err, &stop) {
|
||
t.Fatalf("run two: %v", err)
|
||
}
|
||
}
|
||
if r2.lastTerminology.BankSettled != 1 {
|
||
t.Fatalf("premise broken: run two must filter the newly-signed surface: %+v", r2.lastTerminology)
|
||
}
|
||
if r2.lastTerminology.CostUSD == 0 {
|
||
t.Fatalf("premise broken: the changed composition must make run two PAY again, or there is nothing to announce")
|
||
}
|
||
if !strings.Contains(log.String(), logKeyReconsolidated+"=true") {
|
||
t.Errorf("the one-time re-consolidation happened and was not announced:\n%s", log.String())
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestTheSettledTestAnswersOnEachOfItsConditions pins the three conditions bankSettles rests on, one at a
|
||
// time. Two of them had no witness at all: every fixture that reached the filter carried a banknote-origin
|
||
// candidate whose surface matched a seed row's own src, so «banknote-only» and «aliases count» were both
|
||
// true of the fixture by accident and could have been dropped without a test noticing.
|
||
func TestTheSettledTestAnswersOnEachOfItsConditions(t *testing.T) {
|
||
seed := []store.GlossaryEntry{
|
||
{Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed",
|
||
Aliases: []store.GlossaryAlias{{Alias: "小方"}}},
|
||
{Src: "青茅山", Dst: "", Status: "approved", Source: "seed"}, // no rendering: nothing to agree with
|
||
}
|
||
settled := bankSettledSurfaces(seed)
|
||
// A row with no rendering is not in the index at all — there is nothing for a draft to agree with.
|
||
if _, ok := settled[text.NormalizeSourceKey("青茅山")]; ok {
|
||
t.Errorf("a seed row with no dst cannot settle anything: %+v", settled)
|
||
}
|
||
banknote := func(key, draft string) terminology.Candidate {
|
||
return terminology.Candidate{Key: key, Src: key, Origin: terminology.OriginBanknote,
|
||
Variants: []terminology.Variant{{Dst: draft}}}
|
||
}
|
||
// BASE: the shape the filter is for.
|
||
if !bankSettles(settled, banknote("方源", "Фан Юань")) {
|
||
t.Fatalf("premise broken: a banknote candidate on a seeded surface whose draft agrees must be settled")
|
||
}
|
||
// (1) ORIGIN. A MINER-origin candidate is not dropped by reverseSectionTerms, so it DOES reach the
|
||
// delta — filtering it would lose a term from the artifact, not just a paid question.
|
||
mined := banknote("方源", "Фан Юань")
|
||
mined.Origin = terminology.OriginMined
|
||
if bankSettles(settled, mined) {
|
||
t.Errorf("only the population the EMISSION drops may be filtered; a mined candidate reaches the delta")
|
||
}
|
||
// (2) ALIASES. The emission's own guard reads a seed row's aliases, so this one must too, or a
|
||
// proposal named by an alias is paid for and then thrown away.
|
||
if !bankSettles(settled, banknote("小方", "Фан Юань")) {
|
||
t.Errorf("a surface the seed holds as an ALIAS is settled just as its src is")
|
||
}
|
||
// (3) AGREEMENT, per variant and not per candidate: one disagreeing draft among several is enough.
|
||
two := banknote("方源", "Фан Юань")
|
||
two.Variants = append(two.Variants, terminology.Variant{Dst: "Источник Фана"})
|
||
if bankSettles(settled, two) {
|
||
t.Errorf("one disagreeing draft is the finding; the candidate must be paid for")
|
||
}
|
||
// (4) NO EVIDENCE is not evidence of agreement. Unreachable from a banknote candidate by construction
|
||
// — they always carry a proposal — and asserted so the rule cannot be quietly inverted.
|
||
none := banknote("方源", "")
|
||
none.Variants = nil
|
||
if bankSettles(settled, none) {
|
||
t.Errorf("a candidate with no observed rendering has not agreed with anything")
|
||
}
|
||
}
|
||
|
||
// TestTheFilterFoldsBothSidesTheWayProductionDoes pins the two normalizations inside the money filter,
|
||
// each of which decides whether a paid call happens and neither of which had a witness. Breaking either
|
||
// left the whole battery green: the source fold silently stops the filter firing for every traditional or
|
||
// katakana spelling — and BankSettled then reads 0, indistinguishable from «nothing to skip» — while the
|
||
// target fold is the money decision itself.
|
||
func TestTheFilterFoldsBothSidesTheWayProductionDoes(t *testing.T) {
|
||
// A seed row spelled TRADITIONALLY, and a candidate keyed the way production keys it.
|
||
seed := []store.GlossaryEntry{{Src: "長空", Dst: "Пустота Небес", Status: "approved", Source: "seed"}}
|
||
settled := bankSettledSurfaces(seed)
|
||
if _, ok := settled["长空"]; !ok {
|
||
t.Fatalf("the seed surface must be indexed by its NORMALIZED key, or the filter never fires for the traditional population: %+v", settled)
|
||
}
|
||
cand := func(draft string) terminology.Candidate {
|
||
return terminology.Candidate{Key: "长空", Src: "長空", Origin: terminology.OriginBanknote,
|
||
Variants: []terminology.Variant{{Dst: draft}}}
|
||
}
|
||
if !bankSettles(settled, cand("Пустота Небес")) {
|
||
t.Fatalf("premise broken: an agreeing draft on a seeded surface must settle")
|
||
}
|
||
// THE TARGET FOLD. A draft differing only by case and ё is the SAME rendering under the fold the vote
|
||
// is counted with, so it agrees — and paying for it would be paying to be told what the bank says.
|
||
if !bankSettles(settled, cand("пустота небес")) {
|
||
t.Errorf("the agreement must be judged under the same target-form fold the vote uses, not byte-wise")
|
||
}
|
||
// CONTROL: a genuinely different rendering still disagrees, so the fold above is a fold and not a
|
||
// comparison that has stopped comparing.
|
||
if bankSettles(settled, cand("Небесная пустота")) {
|
||
t.Errorf("a different rendering is the finding and must be paid for")
|
||
}
|
||
}
|