textmachine/backend/internal/membank/memvoice_test.go

269 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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], "1119") {
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)
}
}