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