package pipeline import ( "context" "strings" "testing" ) // voicerun_test.go: the pack-19 flaggers THROUGH THE REAL DRIVER — a seed with voice/address sections, a // mock editor whose output carries the defect, and the assertion that the count reaches retrieval_state // and the quality report WITHOUT flagging the unit. const voiceGateBlock = "\ngates:\n voice:\n enabled: true\n" // voiceSeed carries both new sections plus the terms they name. 白凝冰 owes 方源 the formal register and // calls herself «ваша служанка» — the worked example of research/15. const voiceSeed = ` terms: - src: 方源 dst: Фан Юань decl: { forms: [Фан Юаня, Фан Юаню] } - src: 白凝冰 dst: Бай Нинбин voices: - src: 白凝冰 register: ледяная краткость self_ref: ваша служанка ng_lexicon: [ладно] addresses: - speaker: 白凝冰 addressee: 方源 register: formal ` const voiceSource = "白凝冰对方源说话。" func TestVoiceFlaggerReachesTheStoreAndTheReport(t *testing.T) { srv := newJSONProvider(&reqRec{}, func(body string) (string, string) { if isEditBody(body) { // One reply, attributed, that flattens the self-designation AND says a forbidden word. return "— Ладно, я всё сделаю, — сказала Бай Нинбин.", "stop" } return "ЧЕРНОВИК", "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: voiceSource, glossarySeed: voiceSeed, gatesYAML: voiceGateBlock, }) r := newRunner(t, bookPath) defer r.Close() res, err := r.TranslateBook(context.Background()) if err != nil { t.Fatal(err) } if res.Flagged != 0 { t.Fatalf("the voice flagger is observability — it must never flag a unit, got flagged=%d", res.Flagged) } rs, err := r.Store.GetRetrievalState("test-book", 1, 0) if err != nil || rs == nil { t.Fatalf("no retrieval state persisted: %v", err) } if rs.NVoiceFlags == 0 { t.Fatalf("the voice flags did not reach retrieval_state: %+v", rs) } if !strings.Contains(rs.VoiceDetail, "Бай Нинбин") { t.Fatalf("the detail must name the character: %q", rs.VoiceDetail) } rep, err := r.QualityReport() if err != nil { t.Fatal(err) } if rep.VoiceFlags != rs.NVoiceFlags || rep.VoiceReplies == 0 || rep.VoiceAttributed == 0 { t.Fatalf("the report must aggregate the flags AND their denominators: %+v", rep) } if rep.VoiceCheckVersion == "" { t.Fatal("the gate is not snapshot-folded, so the report MUST carry the rule version the counts came from") } } // The gate is off by default, and off means zero — not "computed and hidden". func TestVoiceFlaggerIsSilentWhenTheGateIsOff(t *testing.T) { srv := newJSONProvider(&reqRec{}, func(body string) (string, string) { if isEditBody(body) { return "— Ладно, я всё сделаю, — сказала Бай Нинбин.", "stop" } return "ЧЕРНОВИК", "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: voiceSource, glossarySeed: voiceSeed}) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(context.Background()); err != nil { t.Fatal(err) } rs, err := r.Store.GetRetrievalState("test-book", 1, 0) if err != nil || rs == nil { t.Fatalf("no retrieval state persisted: %v", err) } if rs.NVoiceFlags != 0 || rs.VoiceDetail != "" { t.Fatalf("gate off must write nothing at all: %+v", rs) } rep, err := r.QualityReport() if err != nil { t.Fatal(err) } if rep.VoiceFlags != 0 || rep.VoiceCheckVersion != "" { t.Fatalf("gate off must report nothing: %+v", rep) } } // The reveal half of D21 п.3: a rendering the spoiler window rejected for this chapter that reached the // shipped text. NOT gated by gates.voice — the window is a standing safety mechanism. func TestSpoilerLeakSurfacesUngated(t *testing.T) { const seed = ` terms: - src: 真相 dst: её брат since_ch: 5 ` srv := newJSONProvider(&reqRec{}, func(body string) (string, string) { if isEditBody(body) { return "Это был её брат.", "stop" // the post-reveal rendering, in chapter 1 } return "ЧЕРНОВИК", "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "真相 出现了。", glossarySeed: seed}) r := newRunner(t, bookPath) defer r.Close() res, err := r.TranslateBook(context.Background()) if err != nil { t.Fatal(err) } if res.Flagged != 0 { t.Fatalf("a leak is observability, not a disposition, got flagged=%d", res.Flagged) } rs, err := r.Store.GetRetrievalState("test-book", 1, 0) if err != nil || rs == nil { t.Fatalf("no retrieval state persisted: %v", err) } if rs.NSpoilerLeaks != 1 || !strings.Contains(rs.SpoilerLeakDetail, "真相") { t.Fatalf("the spoiler leak did not reach retrieval_state: %+v", rs) } rep, err := r.QualityReport() if err != nil { t.Fatal(err) } if rep.SpoilerLeaks != 1 { t.Fatalf("the report must aggregate the leak: %+v", rep) } } // A voice/address row naming a character the bank does not hold is silently inert — so the run must stop // at seeding, before any provider call, rather than translate a book whose profiles can never fire. func TestSeedRefusesAVoiceProfileForAnUnknownCharacter(t *testing.T) { const seed = ` terms: - src: 方源 dst: Фан Юань voices: - src: 白凝冰 self_ref: ваша служанка ` srv := newJSONProvider(&reqRec{}, func(string) (string, string) { return "ЧЕРНОВИК", "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: voiceSource, glossarySeed: seed}) r := newRunner(t, bookPath) defer r.Close() _, err := r.TranslateBook(context.Background()) if err == nil { t.Fatal("a profile for a term that does not exist must stop the run") } if !strings.Contains(err.Error(), "白凝冰") { t.Fatalf("the error must name the character: %v", err) } } // The voice content is bank content: it is replaced in the SAME transaction as the glossary and read // back for the projection, so a re-seed converges every column exactly as the terms do. func TestVoiceContentRoundTripsThroughTheBankReplace(t *testing.T) { srv := newJSONProvider(&reqRec{}, func(string) (string, string) { return "ЧЕРНОВИК", "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: voiceSource, glossarySeed: voiceSeed}) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(context.Background()); err != nil { t.Fatal(err) } voices, err := r.Store.VoiceProfilesForBook("test-book") if err != nil || len(voices) != 1 { t.Fatalf("voice profiles not persisted: %v %+v", err, voices) } if voices[0].SelfRef != "ваша служанка" || voices[0].NGLexicon != `["ладно"]` { t.Fatalf("voice profile columns did not round-trip: %+v", voices[0]) } pairs, err := r.Store.AddressPairsForBook("test-book") if err != nil || len(pairs) != 1 || pairs[0].Register != "formal" { t.Fatalf("address pairs not persisted: %v %+v", err, pairs) } }