textmachine/backend/internal/lang/langpack_test.go

216 lines
9.3 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 lang
import (
"os"
"path/filepath"
"strings"
"testing"
)
// realRoot is the in-repo langpack root, relative to this package's test cwd (internal/lang/).
const realRoot = "../../configs/langpacks"
// TestLoadResolvesRealZhRu loads the REAL zh→ru pack from disk and pins a spot-check of the moved data
// (the const→file move must be byte-faithful; the full-book parity that pins the exact miner output is
// the stand-gated TestMinerFullBookParity, so this is the CI-runnable anchor). It exercises the real
// disk-loading path end to end.
func TestLoadResolvesRealZhRu(t *testing.T) {
p, err := Load(realRoot, "zh", "ru")
if err != nil {
t.Fatalf("Load(zh,ru): %v", err)
}
if p.Pair != "zh-ru" {
t.Errorf("Pair = %q, want zh-ru", p.Pair)
}
// Source morphology spot-check (byte-faithful move).
if !p.SurnamesSingle['赵'] || !p.SurnamesSingle['方'] {
t.Error("surnames-single missing 赵/方")
}
if p.SurnamesSingle['凝'] {
t.Error("surnames-single must NOT contain 凝 (discarded — not a surname)")
}
if !p.SurnamesCompound["欧阳"] {
t.Error("surnames-compound missing 欧阳")
}
if p.SurnamesCompound["古月"] {
t.Error("surnames-compound must NOT contain 古月 (pair-14 §1: a 蛊真人 clan, moved to the book overlay)")
}
if len(p.TitleSuffix) == 0 || p.TitleSuffix[0] != "公子" {
t.Errorf("title-suffix order not preserved: %v", p.TitleSuffix)
}
if !p.TopoSuffix['山'] || !p.Numeral['三'] || !p.GradePrefix['甲'] || !p.AliasParticle['的'] {
t.Error("rune-set membership missing an expected char (山/三/甲/的)")
}
// pair-14 moves: title-formant + sentence-terminator rune sets, and the Palladius phonotactic sets.
if !p.TitleFormant['等'] || !p.TitleFormant['转'] || !p.TitleFormant['阶'] {
t.Error("title-formant missing 等/转/阶")
}
if !p.SentenceTerminator['。'] || !p.SentenceTerminator[''] || !p.SentenceTerminator[''] {
t.Error("sentence-terminator missing 。//")
}
if !p.Palladius.Retroflex["zh"] || !p.Palladius.VFinal["v"] || !p.Palladius.VFinalInitial["j"] {
t.Error("palladius phonotactics missing retroflex zh / vfinal v / vfinal_initial j")
}
// Pair transliteration spot-check.
if p.Palladius.Initials["b"] != "б" || p.Palladius.Initials["zh"] != "чж" {
t.Errorf("palladius initials wrong: b=%q zh=%q", p.Palladius.Initials["b"], p.Palladius.Initials["zh"])
}
if p.Palladius.SpecialI["zhi"] != "чжи" {
t.Errorf("palladius special_i zhi = %q, want чжи", p.Palladius.SpecialI["zhi"])
}
if !strings.HasPrefix(p.Version(), packAlgoVersion+"-") {
t.Errorf("Version() = %q, want %s-<hash>", p.Version(), packAlgoVersion)
}
}
// TestRoutesByPairToDifferentBytes is the load-bearing generality proof: a SECOND pair, dropped as data
// (no pipeline/ edit, no recompile), routes to DIFFERENT bytes and a DIFFERENT version. Proves "add a
// language = data only" — the pair is a genuine routing key, not a baked-in zh constant.
func TestRoutesByPairToDifferentBytes(t *testing.T) {
root := t.TempDir()
writeSyntheticPack(t, root, "xx", "yy")
real, err := Load(realRoot, "zh", "ru")
if err != nil {
t.Fatalf("load real: %v", err)
}
synth, err := Load(root, "xx", "yy")
if err != nil {
t.Fatalf("load synthetic xx-yy: %v", err)
}
// The synthetic pair carries its OWN surnames (not zh's), proving the resolver routes by pair, not
// to a hardcoded set.
if !synth.SurnamesSingle['甴'] {
t.Error("synthetic pack must carry its own surname 甴")
}
if synth.SurnamesSingle['赵'] {
t.Error("synthetic pack must NOT inherit zh's surname 赵 (would mean a baked-in constant)")
}
if !real.SurnamesSingle['赵'] || real.SurnamesSingle['甴'] {
t.Error("real zh pack routing leaked/borrowed synthetic bytes")
}
if real.Version() == synth.Version() {
t.Errorf("different-byte packs must have different versions (real=%s synth=%s)", real.Version(), synth.Version())
}
}
// TestBookOverlayUnionsAndReVersions pins the pair-14 §1 book-overlay contract: an overlay UNIONS its
// private canon onto the shared pack (additive — the base entries survive, the overlay entry is added) and
// SHIFTS Version() (a book-canon edit is a loud --resnapshot for that book), while a no-overlay Load is
// byte-identical to before. Guards the exact mechanism the miner's {古月:22} parity rides.
func TestBookOverlayUnionsAndReVersions(t *testing.T) {
base, err := Load(realRoot, "zh", "ru")
if err != nil {
t.Fatalf("load base: %v", err)
}
if base.SurnamesCompound["古月"] {
t.Fatal("shared pack must not carry 古月 (it is a book clan)")
}
overlay := t.TempDir()
if err := os.MkdirAll(filepath.Join(overlay, "zh"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(overlay, "zh", "surnames-compound.txt"), []byte("# book canon\n古月\n"), 0o644); err != nil {
t.Fatal(err)
}
ext, err := LoadWithOverlay(realRoot, "zh", "ru", overlay)
if err != nil {
t.Fatalf("load with overlay: %v", err)
}
if !ext.SurnamesCompound["古月"] {
t.Error("overlay must add 古月 to the effective pack")
}
if !ext.SurnamesCompound["欧阳"] {
t.Error("overlay must UNION (keep the shared 欧阳), not replace")
}
if ext.Version() == base.Version() {
t.Errorf("overlay must shift Version() (base=%s overlay=%s)", base.Version(), ext.Version())
}
// An empty overlayRoot is exactly Load — same version, no re-hash.
same, err := LoadWithOverlay(realRoot, "zh", "ru", "")
if err != nil {
t.Fatal(err)
}
if same.Version() != base.Version() {
t.Errorf("empty overlay must equal Load (%s != %s)", same.Version(), base.Version())
}
// A MISNAMED overlay file must FAIL LOUD, not be silently ignored (review finding, scale lens): a typo'd
// canon file that never reaches the miner would degrade recall with no signal.
bad := t.TempDir()
if err := os.MkdirAll(filepath.Join(bad, "zh"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bad, "zh", "surname-compound.txt"), []byte("古月\n"), 0o644); err != nil { // typo: missing 's'
t.Fatal(err)
}
if _, err := LoadWithOverlay(realRoot, "zh", "ru", bad); err == nil {
t.Error("a misnamed overlay file must fail loud (silently-ignored canon would degrade recall)")
} else if !strings.Contains(err.Error(), "surname-compound.txt") {
t.Errorf("error must name the unexpected file, got: %v", err)
}
}
// TestFailsLoudOnMissingPair pins the fail-loud contract: a pair with no pack directory errors, naming the
// missing file — never a silent empty pack (mirrors the prompt seam's PromptPathFor fail-loud). This is
// the load-time invariant a live consumer relies on (fail before billing).
func TestFailsLoudOnMissingPair(t *testing.T) {
_, err := Load(realRoot, "ja", "ru")
if err == nil {
t.Fatal("Load(ja,ru) must fail loud (no ja langpack), got nil error")
}
if !strings.Contains(err.Error(), "ja-ru") {
t.Errorf("error must name the missing pair ja-ru, got: %v", err)
}
}
// TestFailsLoudOnEmptyTable pins the "never silently empty" contract: a present-but-empty (comment-only)
// required file is a corrupt pack and must fail loud at load — not parse to an empty set that silently
// disables a miner channel once packs are hand-authored (R1).
func TestFailsLoudOnEmptyTable(t *testing.T) {
root := t.TempDir()
writeSyntheticPack(t, root, "xx", "yy")
// Blank out one required table (keep the file present, drop its content).
if err := os.WriteFile(filepath.Join(root, "xx", "surnames-single.txt"), []byte("# emptied by a bad edit\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Load(root, "xx", "yy")
if err == nil {
t.Fatal("an empty required table must fail loud, got nil error")
}
if !strings.Contains(err.Error(), "surnames-single") {
t.Errorf("error must name the empty table, got: %v", err)
}
}
// writeSyntheticPack writes a minimal, VALID pack for (src, tgt) under root with bytes distinct from zh —
// enough for Load to succeed and for the routing assertion to bite.
func writeSyntheticPack(t *testing.T, root, src, tgt string) {
t.Helper()
pair := src + "-" + tgt
files := map[string]string{
filepath.Join(src, "surnames-single.txt"): "# synthetic\n甴甶甹\n",
filepath.Join(src, "surnames-compound.txt"): "# synthetic\n甲乙\n",
filepath.Join(src, "title-suffix.txt"): "# synthetic\n阁下\n",
filepath.Join(src, "ordinal-title.txt"): "# synthetic\n第甲\n",
filepath.Join(src, "rank-word.txt"): "# synthetic\n級\n",
filepath.Join(src, "topo-suffix.txt"): "# synthetic\n峰\n",
filepath.Join(src, "grade-prefix.txt"): "# synthetic\n子丑\n",
filepath.Join(src, "numeral.txt"): "# synthetic\n壹貳\n",
filepath.Join(src, "alias-particle.txt"): "# synthetic\n之乎\n",
filepath.Join(src, "title-formant.txt"): "# synthetic\n甼\n",
filepath.Join(src, "sentence-terminator.txt"): "# synthetic\n。\n",
filepath.Join(pair, "palladius.txt"): "# synthetic\ninitials\tb\tб\nfinals\ta\tа\nyw\tyi\tи\nspecial_i\tzhi\tчжи\n",
filepath.Join(pair, "palladius-phonotactics.txt"): "# synthetic\nretroflex\tzh\nvfinal\tv\nvfinal_initial\tj\n",
}
for rel, body := range files {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
}