返回 DeepSeek-Reasonix
catalog_parity_test.go
根目录 / internal / i18n / catalog_parity_test.go
1 package i18n
2
3 import (
4 "reflect"
5 "regexp"
6 "sort"
7 "testing"
8 )
9
10 // TestCatalogsAgreeOnCodeTokens guards against a specific drift class: a
11 // translated message dropping (or inventing) a "code token" that its English
12 // counterpart carries. Code tokens are the machine-readable bits a user must
13 // type or press, so they must survive translation verbatim:
14 //
15 // - `backtick-quoted spans` — commands the user is told to run
16 // - leading-slash command tokens — /compact, /language, /init, ...
17 // - key hints — PgUp/PgDn/Ctrl+Home/End, Shift+Tab, Ctrl+C/Y/D, Esc, arrows
18 //
19 // Example regression this test exists for: zh-TW ChatStatusPlanApproval read
20 // "PgUp/PgDn 捲動" while en/zh also carry Ctrl+Home/End. Because the check is
21 // a pure set comparison per key, re-introducing that drift deterministically
22 // fails the test (en has {Ctrl+Home, End} tokens the translation lacks).
23 //
24 // The test enumerates fields of the baseline catalogue (English) — the same
25 // Messages type all three catalogues use — exactly like TestCatalogsComplete,
26 // so newly added keys are covered automatically. Fields that are deliberately
27 // out of scope are skipped via the explicit list below.
28 func TestCatalogsAgreeOnCodeTokens(t *testing.T) {
29 // UsageBody and ReportUsageBody are multi-line free-form translated prose
30 // (help text), not code tokens. All other fields are checked automatically.
31 excluded := map[string]bool{
32 "UsageBody": true,
33 "ReportUsageBody": true,
34 }
35
36 en := reflect.ValueOf(English)
37 typ := en.Type()
38 for i := 0; i < typ.NumField(); i++ {
39 name := typ.Field(i).Name
40 if excluded[name] {
41 continue
42 }
43 want := extractCodeTokens(en.Field(i).String())
44 for _, cat := range []struct {
45 tag string
46 v reflect.Value
47 }{
48 {"zh", reflect.ValueOf(Chinese)},
49 {"zh-TW", reflect.ValueOf(ChineseTraditional)},
50 } {
51 got := extractCodeTokens(cat.v.Field(i).String())
52 if !equalTokenSets(want, got) {
53 t.Errorf("%s (%s): code tokens differ from en\n en: %v\n %-5s: %v",
54 name, cat.tag, sortedTokens(want), cat.tag, sortedTokens(got))
55 }
56 }
57 }
58 }
59
60 // --- token extraction -------------------------------------------------
61
62 var (
63 reBacktick = regexp.MustCompile("`([^`]+)`")
64 // A slash token is a leading-slash word (/init, /resume <n>). Requiring a
65 // non-word boundary before it keeps enumerations such as "y/a/p/n",
66 // "drag-select/scrollbar" or "auth/quota" from being misread as commands.
67 reSlashCmd = regexp.MustCompile("(?:^|[ \\t\\n\\r(()·\\[:“\"'`,,;;::、])(/[A-Za-z][A-Za-z0-9_-]*)")
68 reKeyToken = regexp.MustCompile(`\b(?:PgUp|PgDn|Home|End|Esc|Shift\+Tab|Ctrl[-+][A-Za-z]+)\b`)
69 reArrow = regexp.MustCompile(`[↑↓←→]`)
70 // Localizable filler inside a backtick span is normalized away before
71 // comparison so translations may localize examples:
72 // `reasonix run "your task"` vs `reasonix run "你的任務"`
73 // `reasonix remote add <name>` vs `reasonix remote add <名稱>`
74 reSpanQuoted = regexp.MustCompile(`"[^"]*"`)
75 reSpanAngle = regexp.MustCompile(`<[^>]*>`)
76 )
77
78 func extractCodeTokens(s string) map[string]bool {
79 toks := make(map[string]bool)
80 for _, m := range reBacktick.FindAllStringSubmatch(s, -1) {
81 span := reSpanQuoted.ReplaceAllString(m[1], `"x"`)
82 span = reSpanAngle.ReplaceAllString(span, "<x>")
83 toks["`"+span+"`"] = true
84 }
85 for _, m := range reSlashCmd.FindAllStringSubmatch(s, -1) {
86 toks[m[1]] = true
87 }
88 for _, m := range reKeyToken.FindAllString(s, -1) {
89 toks[m] = true
90 }
91 for _, m := range reArrow.FindAllString(s, -1) {
92 toks[m] = true
93 }
94 return toks
95 }
96
97 func equalTokenSets(a, b map[string]bool) bool {
98 if len(a) != len(b) {
99 return false
100 }
101 for k := range a {
102 if !b[k] {
103 return false
104 }
105 }
106 return true
107 }
108
109 func sortedTokens(m map[string]bool) []string {
110 out := make([]string, 0, len(m))
111 for k := range m {
112 out = append(out, k)
113 }
114 sort.Strings(out)
115 return out
116 }
117
117 lines GO