package membank import ( "testing" "textmachine/backend/internal/lang" "textmachine/backend/internal/store" "textmachine/backend/internal/text" ) func present(t *testing.T, e *entry, output string, s lang.TargetStemmer) bool { t.Helper() norm := text.NormalizeTargetForm(output) return dstFormPresent(e, []rune(norm), lang.TokenizeWords(norm), s) } // TestDstFormPresentAcceptsDeclension is the §3 pin: a term rendered in an oblique case is ACCEPTED against // its nominative even when the seed listed no decl forms (the measured 142/142-null noise), while a term // genuinely absent is still a miss. Multi-word terms match component-wise, whichever word inflected. func TestDstFormPresentAcceptsDeclension(t *testing.T) { s := lang.NewTargetStemmer(lang.TargetChecksFor("ru")) single := &entry{dst: "меч"} if !present(t, single, "он поднял свой мечом высоко", s) { t.Fatal("an oblique case of a single-word term must be accepted (мечом ~ меч)") } if present(t, single, "он поднял свой клинок высоко", s) { t.Fatal("a genuinely absent term must still be a miss") } multi := &entry{dst: "гора Цинмао"} if !present(t, multi, "они стояли у горы Цинмао", s) { t.Fatal("a multi-word term with the head inflected must be accepted (горы Цинмао ~ гора Цинмао)") } // Fix-pack §а: the Palladius soft-sign case («Фан Юаню»~«Фан Юань») is NOT closed here — a bare «ь» // decl_suffix that would close it also collapses «Синь»/«Линь» onto «синий»/«линия» (a SpoilerLeaks // false-positive), so it was rejected. The soft-sign gap is left for seed decl-enumeration; this asserts // the current honest behaviour (an oblique soft-sign name is NOT matched) so a naive re-add is visible. if present(t, &entry{dst: "Синь"}, "небо было синим", s) { t.Fatal("«Синь» must NOT match «синим» — a bare «ь» decl_suffix re-introduced the collision (fix-pack §а)") } } // TestDstFormPresentAcceptsSeedDeclForms is the D39.76 POSITIVE counterpart to the «Синь» negative guard // above: the soft-sign gap is closed by DATA (seed decl.forms), not by adding «ь» to the stemmer. A name // whose seed lists its oblique forms is accepted in an oblique case via the generic whole-word declForms // path, while WITHOUT those forms the same case still misses (the honest §3 behaviour). This pins the case // that previously held only by a comment (review D39.75 PARTIAL). declForms are stored normalized // (memory.go:320 runs NormalizeTargetForm on load) — mirror that when building the entry directly. func TestDstFormPresentAcceptsSeedDeclForms(t *testing.T) { s := lang.NewTargetStemmer(lang.TargetChecksFor("ru")) norm := func(fs ...string) []string { out := make([]string, 0, len(fs)) for _, f := range fs { out = append(out, text.NormalizeTargetForm(f)) } return out } seeded := &entry{dst: "Фан Юань", declForms: norm("Фан Юаня", "Фан Юаню", "Фан Юанем", "Фан Юане")} if !present(t, seeded, "он поклонился Фан Юаню и ушёл", s) { t.Fatal("a soft-sign name with seed decl.forms must be accepted in an oblique case (Фан Юаню via forms[], not «ь» stemming)") } if present(t, &entry{dst: "Фан Юань"}, "он поклонился Фан Юаню и ушёл", s) { t.Fatal("without seed decl.forms the soft-sign oblique case must still miss — else the gap was closed by a global suffix, not by data") } } // TestDstFormPresentInertWithoutStemmer guards the generality path: a target with no decl_suffix registry // (an inert stemmer) keeps EXACT-match behaviour, so it never falsely accepts a declined form. func TestDstFormPresentInertWithoutStemmer(t *testing.T) { var inert lang.TargetStemmer e := &entry{dst: "меч"} if present(t, e, "он поднял мечом", inert) { t.Fatal("without a stemmer the declined form must NOT match") } if !present(t, e, "он поднял меч", inert) { t.Fatal("the exact nominative must still match with no stemmer") } } func TestGenderConstraintNoteNeuter(t *testing.T) { tx := lang.InjectionTextsFor("ru") if tx.GenderNeuter == "" { t.Fatal("ru must ship a gender_neuter directive") } for _, g := range []string{"neuter", "n"} { if got := genderConstraintNote(g, tx); got != tx.GenderNeuter { t.Fatalf("gender %q must map to the neuter directive, got %q", g, got) } } if genderConstraintNote("xyz", tx) != "" { t.Fatal("an unknown gender must produce no directive (the row-84 silent no-op it once was)") } } func TestGenderVocabViolations(t *testing.T) { entries := []store.GlossaryEntry{ {Src: "甲", Dst: "А", Gender: "male"}, {Src: "乙", Dst: "Б", Gender: "neuter"}, // now valid {Src: "丙", Dst: "В", Gender: ""}, // absent is fine {Src: "丁", Dst: "Г", Gender: "Male"}, // wrong case → a silent no-op today, must flag {Src: "戊", Dst: "Д", Gender: "мужской"}, // a typo → must flag } bad := GenderVocabViolations(entries) if len(bad) != 2 { t.Fatalf("exactly the two off-vocabulary genders must flag, got %d: %v", len(bad), bad) } }