package membank import ( "strings" "testing" "textmachine/backend/internal/lang" "textmachine/backend/internal/store" ) // memvoice_test.go pins the pack-19 record types on the axes that cost money if they are wrong: the // CONDITIONAL fold (a book must not re-pay for authoring a profile, and must re-pay the moment one // reaches the wire), the scope order the fold shares with the mined filter, and the structural promise // that a voice profile is never a matchable term. func term(src, dst string) store.GlossaryEntry { return store.GlossaryEntry{Src: src, Dst: dst, Status: "approved", Source: "seed"} } func profile(src string) store.VoiceProfile { return store.VoiceProfile{Src: src, Register: "ледяная краткость", SelfRef: "ваша служанка"} } // The load-bearing money property: while the injection is off, voice/address content is invisible to the // hash, so a book that gains a whole cast of profiles keeps its snapshot and re-pays nothing. func TestVoiceRowsDoNotMoveTheBankVersionWhileUninjected(t *testing.T) { rows := []store.GlossaryEntry{term("方源", "Фан Юань")} bare := ComputeVersionScopedIn(BankInput{Rows: rows}, false, false) withVoice := ComputeVersionScopedIn(BankInput{ Rows: rows, Voices: []store.VoiceProfile{profile("方源")}, Pairs: []store.AddressPair{{SpeakerSrc: "方源", AddresseeSrc: "方源", Register: "formal"}}, }, false, false) if bare != withVoice { t.Fatalf("an UNINJECTED profile moved the bank version — every book with one would re-pay a wave\n bare %s\n with %s", bare, withVoice) } // And the pre-pack-19 entry point must reproduce the same bytes, so no existing book moves at all. if legacy := ComputeVersion(rows, false); legacy != bare { t.Fatalf("the glossary-only fold changed shape: %s vs %s", legacy, bare) } } // The other half of the same contract: once injected, the rows are wire bytes, so any edit MUST be a // loud --resnapshot. Not folding them then would reopen the class D39.42 п.3 closed. func TestInjectedVoiceRowsMoveTheBankVersion(t *testing.T) { rows := []store.GlossaryEntry{term("方源", "Фан Юань")} off := ComputeVersionScopedIn(BankInput{Rows: rows, Voices: []store.VoiceProfile{profile("方源")}}, false, false) on := ComputeVersionScopedIn(BankInput{Rows: rows, Voices: []store.VoiceProfile{profile("方源")}, InjectVoice: true}, false, false) if off == on { t.Fatal("turning the injection on left the bank version unchanged — the flip would be a silent re-payment") } edited := profile("方源") edited.SelfRef = "я" after := ComputeVersionScopedIn(BankInput{Rows: rows, Voices: []store.VoiceProfile{edited}, InjectVoice: true}, false, false) if after == on { t.Fatal("editing an INJECTED profile left the bank version unchanged — a wire change the snapshot cannot see") } // A pair edit is the same class. p1 := BankInput{Rows: rows, Pairs: []store.AddressPair{{SpeakerSrc: "方源", AddresseeSrc: "白凝冰", Register: "formal"}}, InjectVoice: true} p2 := p1 p2.Pairs = []store.AddressPair{{SpeakerSrc: "方源", AddresseeSrc: "白凝冰", Register: "informal"}} rows2 := []store.GlossaryEntry{term("方源", "Фан Юань"), term("白凝冰", "Бай Нинбин")} p1.Rows, p2.Rows = rows2, rows2 if ComputeVersionScopedIn(p1, false, false) == ComputeVersionScopedIn(p2, false, false) { t.Fatal("flipping a pair's register left the bank version unchanged") } } // The scope order (D39.55 «ровно так» (г)): the exclusion runs BEFORE the tag condition, so a profile // whose character is mined-only folds into the ENRICHED version and NOT into the base — otherwise // editing a mined-only character's profile would move the DRAFT wave, which never sees that character. func TestVoiceFoldRespectsTheMinedScopeBeforeCountingIt(t *testing.T) { mined := term("蛊", "Гу") mined.Source = "mined" rows := []store.GlossaryEntry{term("方源", "Фан Юань"), mined} withMinedProfile := BankInput{Rows: rows, Voices: []store.VoiceProfile{profile("蛊")}, InjectVoice: true} noProfile := BankInput{Rows: rows, InjectVoice: true} if got, want := ComputeVersionScopedIn(withMinedProfile, false, true), ComputeVersionScopedIn(noProfile, false, true); got != want { t.Fatalf("a mined-only character's profile folded into the BASE version — the draft wave would re-pay for a character it never sees\n got %s\n want %s", got, want) } if ComputeVersionScopedIn(withMinedProfile, false, false) == ComputeVersionScopedIn(noProfile, false, false) { t.Fatal("a mined character's profile did NOT fold into the enriched version — the edit wave would not re-pay for a wire change") } } // (в) A voice profile is bank content, never a matchable surface: nothing it carries may reach the // automaton or either injection block. The type system makes it structural; this pins the observable. func TestVoiceProfileNeverReachesTheMatcherOrTheInjection(t *testing.T) { const canary = "СЕКРЕТПРОФИЛЬ" vp := store.VoiceProfile{Src: "方源", Register: canary, SelfRef: canary, Exemplars: `["` + canary + `"]`} b := MaterializeBank(BankInput{ Rows: []store.GlossaryEntry{term("方源", "Фан Юань")}, Voices: []store.VoiceProfile{vp}, Pairs: []store.AddressPair{{SpeakerSrc: "方源", AddresseeSrc: "白凝冰", Register: "formal", Form: canary}}, }, false) sel := b.Select("方源 стоял тут.", 1, nil, 0) if len(sel.Injected) != 1 { t.Fatalf("the TERM must still fire normally, got %d injected", len(sel.Injected)) } tx := lang.InjectionTextsFor("ru") for name, block := range map[string]string{ "draft": RenderGlossaryBlock(sel.Injected, tx), "editor": RenderEditorConstraintBlock(sel.Injected, tx), } { if strings.Contains(block, canary) { t.Fatalf("%s injection leaked voice-profile content: %q", name, block) } } // And the profile's own src must not have become a second, separate match. if n := len(b.Select(canary, 1, nil, 0).Injected); n != 0 { t.Fatalf("profile content became matchable: %d hits on the canary", n) } } // (ж) A sticky carry the window rejects is RECORDED, not dropped: n_spoiler_blocked must not under-count. func TestStickyCarryBlockedByWindowIsRecordedAsRejected(t *testing.T) { late := term("真相", "правда") late.SinceCh = 5 b := Materialize([]store.GlossaryEntry{late}, false) // The carry arrives at chapter 1, where the window blocks it. Reachable only through the exported // API — precomputeSticky resets per chapter — which is exactly why the guard belongs here. sel := b.Select("ничего не совпадает", 1, map[string]InjectionDisposition{ b.entries[0].id: Confirmed, }, 0) if len(sel.Injected) != 0 { t.Fatalf("a blocked carry must not inject, got %d", len(sel.Injected)) } if len(sel.Rejected) != 1 || !sel.Rejected[0].Sticky { t.Fatalf("a window-blocked sticky carry must be recorded as a sticky reject, got %+v", sel.Rejected) } } func TestSpoilerLeakFlagger(t *testing.T) { late := store.GlossaryEntry{Src: "真相", Dst: "её брат", Status: "approved", Source: "seed", SinceCh: 5, Decl: `{"forms":["её брата"]}`} b := Materialize([]store.GlossaryEntry{late}, false) sel := b.Select("真相 出现了。", 1, nil, 0) // the key FIRES, the window rejects it if len(sel.Rejected) != 1 { t.Fatalf("setup: want one rejected row, got %d", len(sel.Rejected)) } if n := len(b.SpoilerLeaks(sel.Rejected, "Он молчал о прошлом.")); n != 0 { t.Fatalf("no leak expected, got %d", n) } leaks := b.SpoilerLeaks(sel.Rejected, "Это был её брат.") if len(leaks) != 1 || leaks[0].Src != "真相" || leaks[0].SinceCh != 5 { t.Fatalf("the rejected rendering reached the output and must be flagged, got %+v", leaks) } if n := len(b.SpoilerLeaks(sel.Rejected, "Он ждал её брата.")); n != 1 { t.Fatalf("a DECLINED form of the rejected rendering is the same leak, got %d", n) } // The ratified scope is "a leak on a FIRED key": a sticky reject did not fire here, so it is out — // otherwise the (ж) fix above would silently widen what this flagger claims to measure. sticky := []PickedEntry{{entry: sel.Rejected[0].entry, Sticky: true, Disp: Reject}} if n := len(b.SpoilerLeaks(sticky, "Это был её брат.")); n != 0 { t.Fatalf("a STICKY reject is outside the ratified scope, got %d", n) } } func TestVoiceProjectionRespectsWindows(t *testing.T) { rows := []store.GlossaryEntry{term("方源", "Фан Юань"), term("白凝冰", "Бай Нинбин")} early := profile("方源") early.UntilCh = 10 late := profile("方源") late.SinceCh = 11 late.SelfRef = "я" b := MaterializeBank(BankInput{ Rows: rows, Voices: []store.VoiceProfile{early, late}, Pairs: []store.AddressPair{ {SpeakerSrc: "方源", AddresseeSrc: "白凝冰", Register: "formal", UntilCh: 10}, {SpeakerSrc: "方源", AddresseeSrc: "白凝冰", Register: "informal", SinceCh: 11}, }, }, false) chars, addrs := b.VoiceProjection(3) if len(chars) == 0 || chars[0].SelfRef != "ваша служанка" { t.Fatalf("chapter 3 must project the EARLY profile, got %+v", chars) } if len(addrs) != 1 || addrs[0].Register != "formal" { t.Fatalf("chapter 3 must project the formal register, got %+v", addrs) } _, addrs = b.VoiceProjection(20) if len(addrs) != 1 || addrs[0].Register != "informal" { t.Fatalf("chapter 20 must project the switched register, got %+v", addrs) } // A character whose term is spoiler-blocked cannot be matched in the output, so it is not projected. hidden := term("影", "Тень") hidden.SinceCh = 30 b2 := MaterializeBank(BankInput{Rows: []store.GlossaryEntry{hidden}, Voices: []store.VoiceProfile{profile("影")}}, false) if chars, _ := b2.VoiceProjection(1); len(chars) != 0 { t.Fatalf("a spoiler-blocked character must not project, got %+v", chars) } } func TestBankSeedLoadsVoiceSections(t *testing.T) { bs, err := LoadBankSeed(writeSeed(t, ` terms: - src: 方源 dst: Фан Юань - src: 白凝冰 dst: Бай Нинбин voices: - src: 白凝冰 register: ледяная краткость self_ref: ваша служанка ng_lexicon: [ладно, окей] exemplars: ["Слушаюсь."] addresses: - speaker: 白凝冰 addressee: 方源 register: formal since_ch: 1 until_ch: 40 `)) if err != nil { t.Fatal(err) } if len(bs.Voices) != 1 || bs.Voices[0].SelfRef != "ваша служанка" || bs.Voices[0].NGLexicon != `["ладно","окей"]` { t.Fatalf("voice section not materialized: %+v", bs.Voices) } if len(bs.Pairs) != 1 || bs.Pairs[0].Register != "formal" || bs.Pairs[0].UntilCh != 40 { t.Fatalf("address section not materialized: %+v", bs.Pairs) } } func TestBankSeedRefusesMalformedVoiceSections(t *testing.T) { cases := map[string]string{ "empty profile": "voices:\n - src: 方源\n", "bad register": "addresses:\n - speaker: A\n addressee: B\n register: ты\n", "self-addressed pair": "addresses:\n - speaker: A\n addressee: A\n register: formal\n", "overlapping windows": "voices:\n - src: A\n self_ref: x\n until_ch: 10\n - src: A\n self_ref: y\n since_ch: 5\n", "contradictory pair": "addresses:\n - speaker: A\n addressee: B\n register: formal\n until_ch: 10\n - speaker: A\n addressee: B\n register: informal\n since_ch: 5\n", "empty exemplar": "voices:\n - src: A\n exemplars: [\"\"]\n", "missing addressee": "addresses:\n - speaker: A\n register: formal\n", } for name, body := range cases { t.Run(name, func(t *testing.T) { if _, err := LoadBankSeed(writeSeed(t, body)); err == nil { t.Fatal("a malformed voice/address section must fail loud at load, before any billing") } }) } // A ты↔вы switch — the same pair, non-overlapping windows — is the legitimate journal shape. if _, err := LoadBankSeed(writeSeed(t, "addresses:\n - speaker: A\n addressee: B\n register: formal\n until_ch: 10\n - speaker: A\n addressee: B\n register: informal\n since_ch: 11\n")); err != nil { t.Fatalf("a register switch across non-overlapping windows is legitimate: %v", err) } } func TestUnknownVoiceCharactersAndWindowGaps(t *testing.T) { rows := []store.GlossaryEntry{term("方源", "Фан Юань")} bad := UnknownVoiceCharacters(rows, []store.VoiceProfile{{Src: "нет-такого"}}, []store.AddressPair{{SpeakerSrc: "方源", AddresseeSrc: "тоже-нет"}}) if len(bad) != 2 { t.Fatalf("both the unknown profile and the unknown addressee must be named, got %v", bad) } if n := len(UnknownVoiceCharacters(rows, []store.VoiceProfile{{Src: "方源"}}, nil)); n != 0 { t.Fatalf("a known character must not be reported, got %d", n) } gaps := VoiceWindowGaps([]store.VoiceProfile{ {Src: "A", UntilCh: 10}, {Src: "A", SinceCh: 20}, }) if len(gaps) != 1 || !strings.Contains(gaps[0], "11–19") { t.Fatalf("the uncovered range must be named, got %v", gaps) } if n := len(VoiceWindowGaps([]store.VoiceProfile{{Src: "A", UntilCh: 10}, {Src: "A", SinceCh: 11}})); n != 0 { t.Fatalf("adjacent windows are not a gap, got %d", n) } }