| 1 | package i18n |
| 2 | |
| 3 | import ( |
| 4 | "reflect" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | // TestCatalogsComplete reflects over English (the baseline) and asserts every |
| 10 | // other catalogue populates the same fields. Empty strings count as missing |
| 11 | // translations so drift fails CI instead of surfacing as blank output. As new |
| 12 | // languages land they get added to the catalogs map below. |
| 13 | func TestCatalogsComplete(t *testing.T) { |
| 14 | en := reflect.ValueOf(English) |
| 15 | typ := en.Type() |
| 16 | catalogs := map[string]reflect.Value{"zh": reflect.ValueOf(Chinese), "zh-TW": reflect.ValueOf(ChineseTraditional)} |
| 17 | for tag, cat := range catalogs { |
| 18 | for i := 0; i < typ.NumField(); i++ { |
| 19 | name := typ.Field(i).Name |
| 20 | if strings.TrimSpace(cat.Field(i).String()) == "" { |
| 21 | t.Errorf("%s catalogue: field %q is empty", tag, name) |
| 22 | } |
| 23 | } |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | // TestCatalogsAgreeOnPlaceholders catches translations that silently drop or |
| 28 | // gain %s/%d/%q placeholders — a class of bug that only blows up when the |
| 29 | // affected message is rendered. Compares the count per format verb across |
| 30 | // languages for any field whose name ends in "Fmt". |
| 31 | func TestCatalogsAgreeOnPlaceholders(t *testing.T) { |
| 32 | en := reflect.ValueOf(English) |
| 33 | typ := en.Type() |
| 34 | for i := 0; i < typ.NumField(); i++ { |
| 35 | name := typ.Field(i).Name |
| 36 | if !strings.HasSuffix(name, "Fmt") { |
| 37 | continue |
| 38 | } |
| 39 | want := countVerbs(en.Field(i).String()) |
| 40 | got := countVerbs(reflect.ValueOf(Chinese).Field(i).String()) |
| 41 | if want != got { |
| 42 | t.Errorf("%s: en has %d verbs, zh has %d", name, want, got) |
| 43 | } |
| 44 | gotTW := countVerbs(reflect.ValueOf(ChineseTraditional).Field(i).String()) |
| 45 | if want != gotTW { |
| 46 | t.Errorf("%s: en has %d verbs, zh-TW has %d", name, want, gotTW) |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestPlanApprovalChoicesExposeThreeExplicitActions(t *testing.T) { |
| 52 | tests := []struct { |
| 53 | tag string |
| 54 | value string |
| 55 | want []string |
| 56 | }{ |
| 57 | {tag: "en", value: English.PlanApprovalChoices, want: []string{"Start execution", "Revise plan", "Exit without executing"}}, |
| 58 | {tag: "zh", value: Chinese.PlanApprovalChoices, want: []string{"开始执行", "修改计划", "暂不执行,退出计划模式"}}, |
| 59 | {tag: "zh-TW", value: ChineseTraditional.PlanApprovalChoices, want: []string{"開始執行", "修改計畫", "暫不執行,退出計畫模式"}}, |
| 60 | } |
| 61 | for _, tt := range tests { |
| 62 | t.Run(tt.tag, func(t *testing.T) { |
| 63 | numbered := 0 |
| 64 | for _, line := range strings.Split(tt.value, "\n") { |
| 65 | line = strings.TrimSpace(line) |
| 66 | if len(line) >= 3 && line[0] >= '1' && line[0] <= '9' && line[1] == '.' { |
| 67 | numbered++ |
| 68 | } |
| 69 | } |
| 70 | if numbered != 3 { |
| 71 | t.Fatalf("numbered Plan actions = %d, want 3:\n%s", numbered, tt.value) |
| 72 | } |
| 73 | for _, want := range tt.want { |
| 74 | if !strings.Contains(tt.value, want) { |
| 75 | t.Errorf("Plan choices missing %q:\n%s", want, tt.value) |
| 76 | } |
| 77 | } |
| 78 | }) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // countVerbs counts unescaped fmt placeholders (%s, %d, %q, %v, …). %% does |
| 83 | // not count. |
| 84 | func countVerbs(s string) int { |
| 85 | n := 0 |
| 86 | for i := 0; i < len(s); i++ { |
| 87 | if s[i] != '%' { |
| 88 | continue |
| 89 | } |
| 90 | if i+1 < len(s) && s[i+1] == '%' { |
| 91 | i++ |
| 92 | continue |
| 93 | } |
| 94 | n++ |
| 95 | } |
| 96 | return n |
| 97 | } |
| 98 | |
| 99 | // TestNormalize covers the locale-string shapes likely to land in $LANG / |
| 100 | // $LC_ALL / $REASONIX_LANG. Unknown locales return "" so DetectLanguage falls |
| 101 | // through to the next candidate instead of mis-routing. |
| 102 | func TestNormalize(t *testing.T) { |
| 103 | cases := map[string]string{ |
| 104 | "": "", |
| 105 | "en": "en", |
| 106 | "en_US.UTF-8": "en", |
| 107 | "zh": "zh", |
| 108 | "zh_CN.UTF-8": "zh", |
| 109 | "zh-Hans-CN": "zh", |
| 110 | "Chinese (China)": "zh", |
| 111 | "中文": "zh", |
| 112 | "zh-TW": "zh-TW", |
| 113 | "zh_TW.UTF-8": "zh-TW", |
| 114 | "zh-Hant-TW": "zh-TW", |
| 115 | "zh-Hant": "zh-TW", |
| 116 | "Chinese Traditional": "zh-TW", |
| 117 | "繁體": "zh-TW", |
| 118 | "fr_FR.UTF-8": "", |
| 119 | " ZH_TW ": "zh-TW", |
| 120 | } |
| 121 | for in, want := range cases { |
| 122 | if got := normalize(in); got != want { |
| 123 | t.Errorf("normalize(%q) = %q, want %q", in, got, want) |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // TestDetectLanguagePriority verifies override beats env and that REASONIX_LANG |
| 129 | // beats LANG. With a clean env we fall back to English. |
| 130 | func TestDetectLanguagePriority(t *testing.T) { |
| 131 | t.Setenv("REASONIX_LANG", "") |
| 132 | t.Setenv("LC_ALL", "") |
| 133 | t.Setenv("LC_MESSAGES", "") |
| 134 | t.Setenv("LANG", "") |
| 135 | defer DetectLanguage("") // restore default for other tests |
| 136 | |
| 137 | if got := DetectLanguage(""); got != "en" { |
| 138 | t.Errorf("clean env: got %q, want en", got) |
| 139 | } |
| 140 | |
| 141 | t.Setenv("LANG", "zh_CN.UTF-8") |
| 142 | if got := DetectLanguage(""); got != "zh" { |
| 143 | t.Errorf("LANG=zh_CN.UTF-8: got %q, want zh", got) |
| 144 | } |
| 145 | |
| 146 | t.Setenv("REASONIX_LANG", "en") |
| 147 | if got := DetectLanguage(""); got != "en" { |
| 148 | t.Errorf("REASONIX_LANG=en overriding LANG=zh: got %q, want en", got) |
| 149 | } |
| 150 | |
| 151 | if got := DetectLanguage("zh"); got != "zh" { |
| 152 | t.Errorf("override=zh: got %q, want zh", got) |
| 153 | } |
| 154 | if got := CurrentLanguage(); got != "zh" { |
| 155 | t.Errorf("current language = %q, want zh", got) |
| 156 | } |
| 157 | if got := DetectLanguage("zh-TW"); got != "zh-TW" || CurrentLanguage() != "zh-TW" { |
| 158 | t.Errorf("traditional Chinese current language = %q/%q, want zh-TW", got, CurrentLanguage()) |
| 159 | } |
| 160 | } |
| 161 |