textmachine/backend/internal/checks/voice_test.go

220 lines
11 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 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)
}
}
}