269 lines
13 KiB
Go
269 lines
13 KiB
Go
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)
|
||
}
|
||
}
|