266 lines
12 KiB
Go
266 lines
12 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/lang"
|
||
"textmachine/backend/internal/membank"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
// gender_wire_test.go: backlog row 210 — the gender axis had a DELIVERY mechanism (D39.21, «род должен
|
||
// доезжать») and no producer at all. The terminologist's reply has three fields and none is gender; the
|
||
// miner reads the field out of a hand-written seed only. So on any book nobody seeded by hand the axis was
|
||
// dead by construction, and every gender test in the tree constructed the datum by hand — a green battery
|
||
// could not tell «the producer works» from «there is no producer».
|
||
//
|
||
// These tests are therefore END-TO-END on purpose: a real run, a real reply from the §2 classifier, and the
|
||
// question asked of the actual wire bytes rather than of a renderer called with a hand-built record.
|
||
|
||
// TestTheClassifierProducesGenderIntoTheBank is the producer half. The classifier answers the third column
|
||
// its prompt now asks for, and the datum has to survive every hop between the reply and the bank: the
|
||
// parser's closed vocabulary, attachClassifiedGender, the delta emission, and the seed schema the signature
|
||
// map and auto-bank are both written in.
|
||
func TestTheClassifierProducesGenderIntoTheBank(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isClassifierBody(body) {
|
||
// 方源 is a man; the mountain is a place and answers `none`, which must reach the bank as
|
||
// NOTHING rather than as the word.
|
||
return "方源\tname\tmale\n青茅山\tplace\tnone", "stop"
|
||
}
|
||
if isTerminologyBody(body) {
|
||
return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop"
|
||
}
|
||
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true})
|
||
|
||
r := newVerifyRunner(t, bookPath)
|
||
defer r.Close()
|
||
_ = runToSignatureStop(t, r)
|
||
|
||
if got := r.lastTerminology.Gendered; got != 1 {
|
||
t.Fatalf("exactly one term carried a gender datum, got Gendered=%d", got)
|
||
}
|
||
yamlMap := readSignatureMap(t, r)
|
||
if man := termBlock(t, yamlMap, "方源"); !strings.Contains(man, "gender: male") {
|
||
t.Fatalf("the produced gender must reach the row the owner signs:\n%s", man)
|
||
}
|
||
// `none` is an answer, not a datum: the place's gender field stays EMPTY. Writing the word itself
|
||
// would reach membank's closed gender vocabulary and refuse the whole file on its next load — the
|
||
// row-84 class, where an unknown value silently injected no directive and now fails loudly instead.
|
||
if place := termBlock(t, yamlMap, "青茅山"); !strings.Contains(place, `gender: ""`) {
|
||
t.Fatalf("`none` must reach the bank as no gender at all:\n%s", place)
|
||
}
|
||
}
|
||
|
||
// TestASignedGenderBeatsTheProducedOne is the negative control the brief asks for by name. The owner's seed
|
||
// is the authority; the engine's proposal for a term the owner already signed is dropped by loadAutoBank
|
||
// (the unique key is held), so «the model decided my character is female» is not a state a signed term can
|
||
// reach. Asserted on the WIRE, because that is where being wrong would cost something.
|
||
func TestASignedGenderBeatsTheProducedOne(t *testing.T) {
|
||
rec := &reqRec{}
|
||
var editBody string
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isClassifierBody(body) {
|
||
return "方源\tname\tmale", "stop" // the engine says male…
|
||
}
|
||
if isTerminologyBody(body) {
|
||
return "方源\tФан Юань", "stop"
|
||
}
|
||
if isEditBody(body) {
|
||
editBody = body
|
||
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
|
||
}
|
||
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
// …and the owner has signed FEMALE for the same term.
|
||
seed := "terms:\n - src: 方源\n dst: Фан Юань\n gender: female\n status: approved\n"
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true, glossarySeed: seed})
|
||
|
||
r := newRunner(t, bookPath) // AUTO mode: the run does not stop, so the edit wave actually renders
|
||
defer r.Close()
|
||
if _, err := r.TranslateBook(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if editBody == "" {
|
||
t.Fatal("the edit wave must have run")
|
||
}
|
||
tx := lang.InjectionTextsFor("ru")
|
||
if !strings.Contains(editBody, "方源 → «Фан Юань»"+tx.GenderFemale) {
|
||
t.Fatalf("the SIGNED gender is what reaches the wire:\n%s", editBody)
|
||
}
|
||
if strings.Contains(editBody, tx.GenderMale) {
|
||
t.Fatalf("the engine's proposal overrode the owner's signature:\n%s", editBody)
|
||
}
|
||
// And the bank keeps one row for the term — the owner's — rather than two disagreeing ones.
|
||
rows, err := r.Store.GlossaryForBook("test-book")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
n := 0
|
||
for _, e := range rows {
|
||
if e.Src == "方源" {
|
||
n++
|
||
if e.Gender != "female" {
|
||
t.Errorf("the banked row must keep the signed gender, got %q", e.Gender)
|
||
}
|
||
}
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("a signed term must hold its key alone, got %d rows for 方源", n)
|
||
}
|
||
}
|
||
|
||
// TestWhereAProducedGenderActuallyReachesTheWire holds the delivery boundary of the whole axis, and it
|
||
// holds it in THREE separate statements because there are three separate doors and «it works» would hide
|
||
// which of them is open:
|
||
//
|
||
// 1. an unsigned row on a trusted key DOES carry its gender to the EDITOR — the door row 210 exists to
|
||
// open, and the one that makes the classifier's answer worth buying;
|
||
// 2. a collision-prone match carries it NOWHERE, whatever its status — the door that must stay shut,
|
||
// because a gender note on the wrong entity is wrong rather than merely unsigned;
|
||
// 3. a produced gender reaches the DRAFT wire never, because the draft wave runs over the BASE bank,
|
||
// which excludes Source:"mined" by construction (D39.42 п.3's money contract — «one re-payment» is
|
||
// honest at the WIRE level, not only in the version hash), and the auto-bank is written as `mined`.
|
||
// Opening THAT door is a decision about paying for two waves instead of one, and nobody has made it.
|
||
//
|
||
// ⚠ WHAT DOOR 3 USED TO PROMISE AND DID NOT KEEP. Its comment said «if a later pack admits mined rows to
|
||
// the base bank, the term appears and this reds». By execution it does not: planted exactly that way, four
|
||
// MONEY tests went red and this one stayed green. The reason is timing, not the bank — inside ONE run the
|
||
// draft wave finishes before the classifier has produced anything, so no rule about which bank the draft
|
||
// reads can put a gender on that wire in this run. The absence assertion below is therefore a statement
|
||
// about ORDER, and it is kept as one; the money contract it was claiming to hold is asserted where it
|
||
// actually lives, on the two materialized banks, at the end of this test.
|
||
func TestWhereAProducedGenderActuallyReachesTheWire(t *testing.T) {
|
||
rec := &reqRec{}
|
||
var draftBodies, editBodies []string
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
switch {
|
||
case isClassifierBody(body):
|
||
return "方源\tname\tmale", "stop"
|
||
case isTerminologyBody(body):
|
||
return "方源\tФан Юань", "stop"
|
||
case isEditBody(body):
|
||
editBodies = append(editBodies, body)
|
||
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
|
||
}
|
||
draftBodies = append(draftBodies, body)
|
||
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true})
|
||
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
if _, err := r.TranslateBook(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tx := lang.InjectionTextsFor("ru")
|
||
// The datum IS in the bank the run finished with — the producer works.
|
||
rows, err := r.Store.GlossaryForBook("test-book")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
banked := ""
|
||
for _, e := range rows {
|
||
if e.Src == "方源" {
|
||
banked = e.Gender
|
||
}
|
||
}
|
||
if banked != "male" {
|
||
t.Fatalf("the produced gender must be in the bank the run ends with, got %q", banked)
|
||
}
|
||
// (1) THE EDITOR HAS IT — in the same run that produced it, off an UNSIGNED row.
|
||
gotEdit := false
|
||
for _, b := range editBodies {
|
||
if strings.Contains(b, tx.GenderMale) {
|
||
gotEdit = true
|
||
}
|
||
}
|
||
if !gotEdit {
|
||
t.Fatalf("the produced gender must reach the editor wire in the run that produced it:\n%s", strings.Join(editBodies, "\n---\n"))
|
||
}
|
||
// (3a) THE DRAFT WIRE HAS IT NEVER — in THIS run, as a matter of order: the draft wave is bought before
|
||
// the classifier answers. This assertion cannot see which bank the draft read, and no longer claims to.
|
||
for _, b := range draftBodies {
|
||
if strings.Contains(b, tx.GenderMale) {
|
||
t.Fatalf("a produced gender reached the DRAFT wire — that costs a second wave (D39.42 п.3); say which lock was opened and why:\n%s", b)
|
||
}
|
||
}
|
||
// (3b) AND THE MONEY CONTRACT ITSELF, asserted on the two banks the run ended with rather than on a
|
||
// wire that could not have carried the datum anyway. THIS is what reds when a later pack admits mined
|
||
// rows to the base bank — and what stayed green while door 3's comment said otherwise.
|
||
mined := false
|
||
for _, row := range r.bankRows {
|
||
if row.Src == "方源" && row.Source == "mined" {
|
||
mined = true
|
||
}
|
||
}
|
||
if !mined {
|
||
t.Fatalf("premise broken: the produced row must be stored as `mined`, or this pair of banks proves nothing about it: %+v", r.bankRows)
|
||
}
|
||
if r.baseMemory == r.memory {
|
||
t.Fatal("premise broken: with a mined row present the two banks must be separate objects — the base " +
|
||
"bank is what makes «one re-payment» honest at the wire level")
|
||
}
|
||
const namesTheTerm = "方源 пришёл к горе Цинмао."
|
||
enriched := r.memory.Select(namesTheTerm, 1, nil, r.Pipeline.Context.GlossaryTokenBudget)
|
||
base := r.baseMemory.Select(namesTheTerm, 1, nil, r.Pipeline.Context.GlossaryTokenBudget)
|
||
hasTerm := func(sel membank.Selection) bool {
|
||
for _, p := range sel.Injected {
|
||
if p.Src() == "方源" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
if !hasTerm(enriched) {
|
||
t.Errorf("the ENRICHED bank — the one the editor selects over — must carry the mined row, or the " +
|
||
"produced datum reaches nothing at all")
|
||
}
|
||
if hasTerm(base) {
|
||
t.Error("the BASE bank selected a mined row: the draft wave would then differ across a bank-mining " +
|
||
"enrichment, which is the second wave D39.42 п.3 says nobody has agreed to pay for")
|
||
}
|
||
// The auto-bank file, which the NEXT run reads, does carry it: the datum is durable, not lost.
|
||
raw, err := os.ReadFile(r.autoBankPath())
|
||
if err != nil {
|
||
t.Fatalf("the auto mode must have written its bank: %v", err)
|
||
}
|
||
if !strings.Contains(string(raw), "gender: male") {
|
||
t.Fatalf("the produced gender must be durable in the auto-bank:\n%s", raw)
|
||
}
|
||
}
|
||
|
||
// TestACollisionProneMatchCarriesNoGenderWhateverItsStatus is door 2, and it is a UNIT check on purpose:
|
||
// the branch cannot fire on zh at all (Han keys anchor at 2), so no end-to-end zh fixture can reach it,
|
||
// and a guard nothing exercises is a guard that quietly stops guarding. Both statuses are checked, because
|
||
// the whole point of the split is that this question is not the status question.
|
||
func TestACollisionProneMatchCarriesNoGenderWhateverItsStatus(t *testing.T) {
|
||
tx := lang.InjectionTextsFor("ru")
|
||
for _, status := range []string{"approved", "draft"} {
|
||
rows := []store.GlossaryEntry{{
|
||
BookID: "b", Src: "すずき", Dst: "Судзуки", Gender: "female", Status: status, Source: "seed",
|
||
}}
|
||
bank := membank.Materialize(rows, false)
|
||
sel := bank.Select("すずきが来た。", 1, nil, 0)
|
||
if len(sel.Injected) == 0 {
|
||
t.Fatalf("%s: the fixture must fire its short phonetic key", status)
|
||
}
|
||
for _, block := range []string{
|
||
membank.RenderGlossaryBlock(sel.Injected, tx),
|
||
membank.RenderEditorConstraintBlock(sel.Injected, tx),
|
||
} {
|
||
if strings.Contains(block, tx.GenderFemale) {
|
||
t.Fatalf("%s: a key that may have fired inside an unrelated word must carry no gender to any wire: %s", status, block)
|
||
}
|
||
}
|
||
}
|
||
}
|