package checks import ( "strings" "testing" "textmachine/backend/internal/lang" ) // voice_test.go pins the four axes AND the inert paths — the second half matters as much as the first, // because "a pair without T/V activates nothing without a Go edit" is the §0 promise this flagger makes. func ruVoiceCheckers() *Checkers { return CompileCheckers(nil, lang.TargetChecksFor("ru")) } // служанка is the worked example of research/15: a declared self-designation, a forbidden lexeme, and a // formal register toward her master. func maid() VoiceCharacter { return VoiceCharacter{Key: "maid", Name: "Бай Нинбин", Forms: []string{"бай нинбин", "бай нинбин"}, SelfRef: "ваша служанка", NG: []string{"ладно"}} } // The declined forms are what the bank actually supplies (decl), and axis-D/attribution precision // depends on them: without «фан юаню» the ambiguous-attribution case below would read as unambiguous. func master() VoiceCharacter { return VoiceCharacter{Key: "master", Name: "Фан Юань", Forms: []string{"фан юань", "фан юаню", "фан юаня"}} } func twoCharRegistry() VoiceRegistry { return VoiceRegistry{ Chars: []VoiceCharacter{maid(), master()}, Pairs: []VoiceAddressPair{{Speaker: "maid", Addressee: "master", Register: "formal"}}, } } func TestVoiceAxisATVContradictionInOneReply(t *testing.T) { c := ruVoiceCheckers() got := RunVoiceChecks("", "— Ты придёшь, но вас я не звала, — сказала Бай Нинбин.", twoCharRegistry(), c) if got.TVContradiction != 1 { t.Fatalf("one reply carrying both registers must flag, got %+v", got) } if got.Total() == 0 { t.Fatal("axis A must reach the headline count") } // The plural veto: addressing one person and a group in one turn is correct Russian, not a flip. got = RunVoiceChecks("", "— Ты иди, а вы оба ждите, — сказала Бай Нинбин.", twoCharRegistry(), c) if got.TVContradiction != 0 { t.Fatalf("a plural-addressing marker must veto the contradiction, got %+v", got) } // One register alone is not a contradiction. got = RunVoiceChecks("", "— Вы придёте? — спросила Бай Нинбин.", twoCharRegistry(), c) if got.TVContradiction != 0 { t.Fatalf("a single register is no contradiction, got %+v", got) } } func TestVoiceAxisBSelfReferenceFlattened(t *testing.T) { c := ruVoiceCheckers() got := RunVoiceChecks("", "— Я всё сделаю, — сказала Бай Нинбин.", twoCharRegistry(), c) if got.SelfRefFlat != 1 { t.Fatalf("a plain pronoun where the profile declares a self-designation must flag, got %+v", got) } // The declared designation present → no flag, even beside a pronoun. got = RunVoiceChecks("", "— Ваша служанка всё сделает, я не подведу, — сказала Бай Нинбин.", twoCharRegistry(), c) if got.SelfRefFlat != 0 { t.Fatalf("the declared designation is present — nothing was flattened, got %+v", got) } // No self-reference at all → no evidence either way. got = RunVoiceChecks("", "— Хорошо, — сказала Бай Нинбин.", twoCharRegistry(), c) if got.SelfRefFlat != 0 { t.Fatalf("a reply with no self-reference is not evidence of flattening, got %+v", got) } // A character with no declared self_ref is never checked on this axis. reg := twoCharRegistry() reg.Chars[0].SelfRef = "" if got := RunVoiceChecks("", "— Я всё сделаю, — сказала Бай Нинбин.", reg, c); got.SelfRefFlat != 0 { t.Fatalf("no declared self_ref means no axis-B check, got %+v", got) } } func TestVoiceAxisCForbiddenLexeme(t *testing.T) { c := ruVoiceCheckers() got := RunVoiceChecks("", "— Ладно, ваша служанка сделает, — сказала Бай Нинбин.", twoCharRegistry(), c) if got.NGLexicon != 1 { t.Fatalf("a forbidden lexeme in an attributed reply must flag, got %+v", got) } if len(got.Detail) == 0 || !strings.Contains(strings.Join(got.Detail, "|"), "Бай Нинбин") { t.Fatalf("the detail must name the character, got %v", got.Detail) } // The SAME word in somebody else's reply is not this character's defect. got = RunVoiceChecks("", "— Ладно, — сказал Фан Юань.", twoCharRegistry(), c) if got.NGLexicon != 0 { t.Fatalf("another speaker's word must not be charged to this profile, got %+v", got) } } func TestVoiceAxisDIsAnIndicatorOutsideTheCount(t *testing.T) { c := ruVoiceCheckers() // Both characters are on stage, the maid speaks, the registry says she owes him the formal register, // and she uses the informal one. got := RunVoiceChecks("", "— Ты придёшь, Фан Юань? — спросила Бай Нинбин.", twoCharRegistry(), c) if got.PairRegister != 1 { t.Fatalf("a register contradicting the registry must be indicated, got %+v", got) } if got.Total() != 0 { t.Fatalf("axis D must stay OUT of the headline count, got total=%d (%+v)", got.Total(), got) } // Matching the registry → nothing. got = RunVoiceChecks("", "— Вы придёте, Фан Юань? — спросила Бай Нинбин.", twoCharRegistry(), c) if got.PairRegister != 0 { t.Fatalf("the declared register must not be indicated, got %+v", got) } // A third character on stage breaks the two-on-stage heuristic → no addressee, no indicator. reg := twoCharRegistry() reg.Chars = append(reg.Chars, VoiceCharacter{Key: "third", Name: "Третий", Forms: []string{"третий"}}) // The third name sits in the NARRATIVE, not in the attribution, so the speaker stays unambiguous and // what is actually under test is the two-on-stage precondition rather than attribution ambiguity. got = RunVoiceChecks("", "— Ты придёшь, Фан Юань? — спросила Бай Нинбин.\nТретий молчал.", reg, c) if got.Attributed != 1 { t.Fatalf("the speaker must still be attributed — otherwise this case tests the wrong rule: %+v", got) } if got.PairRegister != 0 { t.Fatalf("with three characters on stage the addressee is unknown — no indicator, got %+v", got) } } func TestVoiceAttributionRequiresOneSpeakerAndASpeechVerb(t *testing.T) { c := ruVoiceCheckers() // No attribution at all: the reply still counts, but nothing speaker-scoped is checked. got := RunVoiceChecks("", "— Я всё сделаю.", twoCharRegistry(), c) if got.Replies != 1 || got.Attributed != 0 || got.SelfRefFlat != 0 { t.Fatalf("an unattributed reply is counted but not charged to anybody, got %+v", got) } // TWO characters in the attribution: guessing which one speaks is the coreference this pack lacks. got = RunVoiceChecks("", "— Я всё сделаю, — сказала Бай Нинбин Фан Юаню.", twoCharRegistry(), c) if got.Attributed != 0 { t.Fatalf("an ambiguous attribution must yield no speaker, got %+v", got) } // A narrative sentence that merely names somebody is not an attribution. got = RunVoiceChecks("", "— Я всё сделаю, — Бай Нинбин смотрела в окно.", twoCharRegistry(), c) if got.Attributed != 0 { t.Fatalf("no speech verb means no attribution, got %+v", got) } } func TestVoiceQuotedRepliesNeedASpeechVerbAndRespectTheInnerVeto(t *testing.T) { c := ruVoiceCheckers() got := RunVoiceChecks("", "«Я всё сделаю», — сказала Бай Нинбин.", twoCharRegistry(), c) if got.Replies != 1 || got.SelfRefFlat != 1 { t.Fatalf("a quoted turn with a speech verb is a reply, got %+v", got) } // Inner speech is a thought, not an addressed turn: no register, no self-designation duty. got = RunVoiceChecks("", "«Я всё сделаю», — пробормотала про себя Бай Нинбин.", twoCharRegistry(), c) if got.Replies != 0 { t.Fatalf("inner speech is not an addressed reply, got %+v", got) } // A bare citation is not a reply either. got = RunVoiceChecks("", "На двери висела табличка «Я всё сделаю».", twoCharRegistry(), c) if got.Replies != 0 { t.Fatalf("a citation is not a reply, got %+v", got) } } // The §0 promise, executed: with no target T/V data the whole flagger is inert — no Go edit needed for a // pair whose target has no such distinction. func TestVoiceFlaggerIsInertWithoutTargetData(t *testing.T) { bare := CompileCheckers(nil, lang.TargetChecks{}) got := RunVoiceChecks("", "— Ты придёшь, но вас я не звала, — сказала Бай Нинбин.", twoCharRegistry(), bare) if got.Total() != 0 || got.Replies != 0 || got.PairRegister != 0 { t.Fatalf("a target with no voice data must flag nothing, got %+v", got) } // And with data but no registry (a book that authored no profiles) — equally inert. if got := RunVoiceChecks("", "— Ты и вы, — сказал он.", VoiceRegistry{}, ruVoiceCheckers()); got.Total() != 0 { t.Fatalf("an empty registry must flag nothing, got %+v", got) } if RunVoiceChecks("", "текст", twoCharRegistry(), nil).Total() != 0 { t.Fatal("a nil checker spec must be inert, not a panic") } // The case that isolates the T/V guard from the segmentation guard: a target that DOES mark dialogue // (so replies are found and attributed) but has NO T/V distinction — an en-like target. Nothing on the // register axes may fire, with no Go edit anywhere. noTV := &Checkers{ replyDash: "—", speechVerb: []string{"сказала"}, selfRefGeneric: []string{"я"}, } got = RunVoiceChecks("", "— Ты придёшь, но вас я не звала, — сказала Бай Нинбин.", twoCharRegistry(), noTV) if got.Total() != 0 || got.Replies != 0 || got.PairRegister != 0 { t.Fatalf("a target with dialogue markers but no T/V distinction must flag nothing, got %+v", got) } } // The source-side denominator: read from PAIR data, and 0 (not "none") when the source ships no file. func TestVoiceSourceReplyDenominator(t *testing.T) { c := ruVoiceCheckers() if got := RunVoiceChecks("“来吧。”方源说道。", "", twoCharRegistry(), c); got.SourceReplies != 0 { t.Fatalf("without a speech-cue table the count is NOT MEASURED, got %d", got.SourceReplies) } c.speechCue = &lang.SpeechCue{ Cues: []string{"说", "道"}, QuoteOpen: map[rune]bool{'“': true}, QuoteClose: map[rune]bool{'”': true}, Window: 12, } got := RunVoiceChecks("“来吧。”方源说道。他走了。“不。”", "", twoCharRegistry(), c) if got.SourceReplies != 1 { t.Fatalf("only the cue-attributed quoted turn counts, got %d", got.SourceReplies) } } func TestVoiceResultIsDeterministic(t *testing.T) { c := ruVoiceCheckers() const final = "— Ладно, я всё сделаю, но вас я не звала, — сказала Бай Нинбин." first := RunVoiceChecks("", final, twoCharRegistry(), c) for i := 0; i < 20; i++ { if got := RunVoiceChecks("", final, twoCharRegistry(), c); strings.Join(got.Detail, "|") != strings.Join(first.Detail, "|") || got.Total() != first.Total() { t.Fatalf("run %d diverged: %+v vs %+v", i, got, first) } } }