textmachine/backend/internal/pipeline/voicerun_test.go

200 lines
7 KiB
Go

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)
}
}