| 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 |