textmachine/backend/internal/lang/langpack_test.go

143 lines
5.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 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["古月"] || !p.SurnamesCompound["欧阳"] {
t.Error("surnames-compound missing 古月/欧阳")
}
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 transliteration spot-check.
if p.PalladiusInitials["b"] != "б" || p.PalladiusInitials["zh"] != "чж" {
t.Errorf("palladius initials wrong: b=%q zh=%q", p.PalladiusInitials["b"], p.PalladiusInitials["zh"])
}
if p.PalladiusSpecialI["zhi"] != "чжи" {
t.Errorf("palladius special_i zhi = %q, want чжи", p.PalladiusSpecialI["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())
}
}
// 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(pair, "palladius.txt"): "# synthetic\ninitials\tb\tб\nfinals\ta\tа\nyw\tyi\tи\nspecial_i\tzhi\tчжи\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)
}
}
}