textmachine/backend/internal/pipeline/memnorm_test.go

186 lines
7.9 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 pipeline
import (
"strings"
"testing"
"textmachine/backend/internal/store"
)
// TestTradTableCoversKnownCases asserts the embedded trad→simp table covers every
// character referenced by the eval specs / research/14 live bugs. A mutation
// (deleting a mapping line) fails exactly here — the guard against silently
// regressing the A4 hole (爺→爷 was research/14's live "тихо пусто" bug).
func TestTradTableCoversKnownCases(t *testing.T) {
want := map[rune]rune{
'魯': '鲁', '鎮': '镇', '趙': '赵', '蕭': '萧', '煉': '炼',
'門': '门', '闆': '板', '莊': '庄', '錢': '钱', '陳': '陈',
'萬': '万', '舊': '旧', '臉': '脸', '爺': '爷', '羅': '罗',
}
for tr, si := range want {
if got, ok := trad2simp[tr]; !ok || got != si {
t.Errorf("trad2simp[%q] = %q, ok=%v; want %q", string(tr), string(got), ok, string(si))
}
}
if len(trad2simp) < 200 {
t.Errorf("trad2simp table has only %d entries — expected the curated high-frequency set (≥200)", len(trad2simp))
}
if memoryNormVersion == "" || !strings.HasPrefix(memoryNormVersion, memoryNormAlgoVersion) {
t.Errorf("memoryNormVersion=%q must start with the algo tag %q", memoryNormVersion, memoryNormAlgoVersion)
}
}
func TestNormalizeSourceKey(t *testing.T) {
cases := []struct{ name, in, want string }{
{"trad→simp two-char", "魯鎮", "鲁镇"},
{"trad→simp name", "趙太爺", "赵太爷"},
{"fullwidth latin lowered", "阿Q", "阿q"},
{"halfwidth latin lowered", "阿Q", "阿q"},
{"katakana→hiragana", "スズキ", "すずき"},
{"halfwidth katakana via NFKC", "ススキ", "すすき"}, // NFKC folds half→full katakana, then kana-fold to hiragana
{"plain han unchanged", "送灶", "送灶"},
{"mixed", "萧炎", "萧炎"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := normalizeSourceKey(c.in); got != c.want {
t.Errorf("normalizeSourceKey(%q) = %q; want %q", c.in, got, c.want)
}
})
}
}
// TestNormalizeSymmetry is the A4 invariant: a key written in one orthography
// matches a chunk written in another, because normalization is applied to BOTH.
func TestNormalizeSymmetry(t *testing.T) {
// glossary key in Simplified, chunk text in Traditional
key := normalizeSourceKey("鲁镇") // simplified
text := normalizeSourceKey("魯鎮的冬天很冷。") // traditional
if !strings.Contains(text, key) {
t.Errorf("normalized simplified key %q not found in normalized traditional text %q", key, text)
}
// katakana name key vs hiragana surface
k2 := normalizeSourceKey("スズキ")
t2 := normalizeSourceKey("すずきさんが来た。")
if !strings.Contains(t2, k2) {
t.Errorf("normalized katakana key %q not found in normalized hiragana text %q", k2, t2)
}
}
func TestNormalizeTargetForm(t *testing.T) {
cases := []struct{ in, want string }{
{"А-кью", "а-кью"},
{"Вэйчжуан", "вэйчжуан"},
{"Тёмный страж", "темный страж"}, // NFC → lower → ё→е fold
}
for _, c := range cases {
if got := normalizeTargetForm(c.in); got != c.want {
t.Errorf("normalizeTargetForm(%q) = %q; want %q", c.in, got, c.want)
}
}
// ё and е must fold to the SAME normalized form (symmetric — no false miss)
if normalizeTargetForm("сёстры") != normalizeTargetForm("сестры") {
t.Error("ё→е fold is not symmetric: сёстры vs сестры differ after normalization")
}
// self-review #2: internal whitespace runs (incl. newline/NBSP) collapse to one space.
for _, v := range []string{"Бородатого\nВана", "Бородатого Вана", "Бородатого Вана"} {
if got := normalizeTargetForm(v); got != "бородатого вана" {
t.Errorf("whitespace not canonicalized: %q → %q", v, got)
}
}
// self-review #8: dash/hyphen variants fold to ASCII '-'.
for _, v := range []string{"А–кью", "А‑кью", "А−кью"} {
if got := normalizeTargetForm(v); got != "а-кью" {
t.Errorf("dash variant not unified: %q → %q", v, got)
}
}
}
func TestSignificantLen(t *testing.T) {
cases := []struct {
in string
want int
}{
{"阿q", 2}, // han + latin letter
{"炎", 1}, // single han — banned as a standalone key
{"气", 1}, // single han
{"钱", 1}, // single han (research/14 homograph)
{"送灶", 2}, // bigram — allowed
{"すずき", 3}, // three kana
{"q", 1}, // bare latin letter
{"а-кью", 4}, // cyrillic letters, hyphen not counted: а к ь ю = 4
}
for _, c := range cases {
if got := significantLen(c.in); got != c.want {
t.Errorf("significantLen(%q) = %d; want %d", c.in, got, c.want)
}
}
}
// TestNormalizeStripsDefaultIgnorable covers the workflow-confirmed A4 major: zero-width /
// format / variation-selector code points survive NFKC/NFC and silently break a match. A
// source key with an embedded ZWSP/joiner/BOM/VS must normalize identically to the clean
// key (else the A4 "тихо пусто" hole re-opens); a target form with a mid-word joiner must
// normalize identically to the clean form (else a correct rendering is a false MISS).
// Reverting the isIgnorableForMatch strip in either normalizer fails this test.
func TestNormalizeStripsDefaultIgnorable(t *testing.T) {
// Explicit escapes via code points (the chars are invisible by nature — the bug being tested).
zwsp := string(rune(0x200B)) // ZERO WIDTH SPACE
zwj := string(rune(0x200D)) // ZERO WIDTH JOINER
wj := string(rune(0x2060)) // WORD JOINER
bom := string(rune(0xFEFF)) // BOM / ZWNBSP
vs16 := string(rune(0xFE0F)) // VARIATION SELECTOR-16
shy := string(rune(0x00AD)) // SOFT HYPHEN (unicode.Cf)
cleanSrc := normalizeSourceKey("渡邊")
for _, in := range []string{
"渡" + zwsp + "邊",
"渡" + zwj + "邊",
"渡" + wj + "邊",
"渡" + bom + "邊",
"渡邊" + vs16, // VS decorating the final key char
"渡" + shy + "邊", // soft hyphen dropped on the source side (no dash semantics)
} {
if got := normalizeSourceKey(in); got != cleanSrc {
t.Errorf("source key %+q → %q, want %q (default-ignorable not stripped)", in, got, cleanSrc)
}
}
cleanTgt := normalizeTargetForm("вана")
for _, in := range []string{
"ва" + wj + "на",
"ва" + zwsp + "на",
"ва" + bom + "на",
} {
if got := normalizeTargetForm(in); got != cleanTgt {
t.Errorf("target form %+q → %q, want %q (default-ignorable not stripped)", in, got, cleanTgt)
}
}
// Regression guard for self-review #8 AND for the strip's ORDERING: the soft hyphen must
// STILL fold to a real '-' on the target side (a hyphenation point), NOT be stripped — so
// «А­кью» keeps matching a stored «А-кью». Placing the ignorable-strip before isDashVariant
// would break this.
if got, want := normalizeTargetForm("А"+shy+"кью"), normalizeTargetForm("А-кью"); got != want {
t.Errorf("soft hyphen on the target side = %q, want %q (must fold to '-', not be stripped)", got, want)
}
}
// TestApostropheFold covers the Task-6 re-audit finding: a typographic apostrophe (U+2019,
// smart-quote editors) folds to ASCII on BOTH sides, so a source key O'Brien matches either
// spelling (no тихо пусто) and the post-check does not false-MISS a д'Артаньян. Reverting the
// isApostropheVariant folds breaks these.
func TestApostropheFold(t *testing.T) {
if normalizeSourceKey("OBrien") != normalizeSourceKey("O'Brien") {
t.Error("source: typographic and ASCII apostrophe must normalize identically")
}
if normalizeTargetForm("д’Артаньян") != normalizeTargetForm("д'Артаньян") {
t.Error("target: typographic and ASCII apostrophe must normalize identically")
}
// End-to-end: an ASCII-apostrophe key matches a chunk written with a typographic apostrophe.
e := gl("o'brien", "О'Брайен", "", "approved")
b := bankFrom([]store.GlossaryEntry{e})
if _, ok := injMap(b.Select("mr obrien arrived.", 1, nil, 0))["o'brien"]; !ok {
t.Error("typographic apostrophe in the chunk must match the ASCII-apostrophe key")
}
}