200 lines
7 KiB
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)
|
|
}
|
|
}
|