package i18n_test import ( "bytes" "context" "encoding/json" "errors" "math" "os" "os/exec" "path/filepath" "reflect" "strings" "sync" "testing" "testing/fstest" "git.yuntuo.hk/k2safe/go-omni/i18n" ) func settings() i18n.Config { return i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans", "ru", "ar", "fr"}, Fallbacks: map[string][]string{"zh-Hans": {"en"}}} } func docs() []i18n.Document { plural := i18n.Pattern{Plural: "count", Cases: map[string]i18n.Pattern{}} for _, category := range []string{"zero", "one", "two", "few", "many", "other"} { plural.Cases[category] = i18n.Pattern{Text: category + ":{count}"} } out := []i18n.Document{} for _, locale := range settings().Locales { out = append(out, i18n.Document{Version: 1, Namespace: "common", Locale: locale, Messages: map[string]i18n.Message{ "count": {Params: map[string]i18n.Kind{"count": i18n.Integer}, Value: plural}, "decimal": {Params: map[string]i18n.Kind{"count": i18n.Number}, Value: plural}, "choice": {Params: map[string]i18n.Kind{"type": i18n.String, "count": i18n.Integer}, Value: i18n.Pattern{Select: "type", Cases: map[string]i18n.Pattern{"admin": {Text: "admin:{count}"}, "other": plural}}}, "literal": {Value: i18n.Pattern{Text: "${HOME} {notAParameter}", Literal: true}}, "empty": {Value: i18n.Pattern{}}, }}) } out[0].Messages["greeting"] = i18n.Message{Params: map[string]i18n.Kind{"first_name": i18n.String}, Value: i18n.Pattern{Text: "Hello {first_name} {{literal}}"}} return out } func makeBundle(t testing.TB) *i18n.Bundle { t.Helper() b, err := i18n.New(settings(), docs()...) if err != nil { t.Fatal(err) } return b } func TestIndependentImmutableBundlesAndFallback(t *testing.T) { resources := docs() cfg := settings() b, err := i18n.New(cfg, resources...) if err != nil { t.Fatal(err) } resources[0].Messages["greeting"] = i18n.Message{Value: i18n.Pattern{Text: "changed"}} cfg.Locales[0] = "de" cfg.Fallbacks["zh-Hans"][0] = "de" snapshot := b.Config() snapshot.Fallbacks["zh-Hans"][0] = "fr" exported := b.Documents("") exported[0].Messages["count"] = i18n.Message{} result, err := b.RenderContext(i18n.WithLocale(context.Background(), "zh-CN"), i18n.Ref{Namespace: "common", Key: "greeting"}, map[string]any{"first_name": "{count}"}) if err != nil || result.Text != "Hello {count} {literal}" || result.Locale != "en" || !result.Fallback || result.Requested != "zh-Hans" { t.Fatal(result, err) } if len(b.Check()) != 4 { t.Fatal(b.Check()) } if got := b.Match("ru-RU"); got != "ru" { t.Fatal(got) } for _, key := range []string{"literal", "empty"} { got, err := b.Render("en", i18n.Ref{Namespace: "common", Key: key}, nil) if err != nil { t.Fatal(err) } if key == "literal" && got.Text != "${HOME} {notAParameter}" || key == "empty" && got.Text != "" { t.Fatal(got) } } var wg sync.WaitGroup for n := 0; n < 80; n++ { wg.Add(1) go func(n int) { defer wg.Done() l, want := "en", "other:22" if n%2 == 0 { l, want = "ru", "few:22" } got, err := b.Render(l, i18n.Ref{Namespace: "common", Key: "count"}, map[string]any{"count": int64(22)}) if err != nil || got.Text != want { t.Errorf("%+v %v", got, err) } }(n) } wg.Wait() other := docs() other[0].Messages["greeting"] = i18n.Message{Value: i18n.Pattern{Text: "different application"}} b2, err := i18n.New(settings(), other...) if err != nil { t.Fatal(err) } got, _ := b2.Render("en", i18n.Ref{Namespace: "common", Key: "greeting"}, nil) if got.Text != "different application" { t.Fatal(got) } } func TestResourceAndParameterDiagnostics(t *testing.T) { b := makeBundle(t) for _, params := range []map[string]any{nil, {"count": "21"}, {"count": 21.0}, {"count": 21, "extra": "secret"}, {"count": uint64(math.MaxUint64)}, {"count": []string{"secret"}}} { _, err := b.Render("en", i18n.Ref{Namespace: "common", Key: "count"}, params) var d *i18n.Diagnostic if !errors.As(err, &d) || d.Kind != "parameter" || strings.Contains(err.Error(), "secret") { t.Fatal(err) } } _, err := b.Render("en", i18n.Ref{Namespace: "common", Key: "missing"}, nil) var d *i18n.Diagnostic if !errors.As(err, &d) || d.Kind != "missing" { t.Fatal(err) } _, err = b.Render("en", i18n.Ref{Namespace: "common", Key: "decimal"}, map[string]any{"count": 1e-16}) if err != nil { t.Fatal(err) } for _, change := range []func([]i18n.Document){ func(d []i18n.Document) { d[1].Messages["unknown"] = i18n.Message{} }, func(d []i18n.Document) { m := d[1].Messages["count"] m.Params = map[string]i18n.Kind{"count": i18n.String} d[1].Messages["count"] = m }, func(d []i18n.Document) { m := d[1].Messages["count"] m.Value = i18n.Pattern{Text: "{missing}"} d[1].Messages["count"] = m }, func(d []i18n.Document) { m := d[1].Messages["count"] m.Value = i18n.Pattern{Text: "unused"} d[1].Messages["count"] = m }, func(d []i18n.Document) { m := d[1].Messages["count"] delete(m.Value.Cases, "other") d[1].Messages["count"] = m }, func(d []i18n.Document) { d[1].Version = 2 }, } { d := docs() change(d) if _, err := i18n.New(settings(), d...); err == nil { t.Fatal("invalid document accepted") } } for _, change := range []func(*i18n.Config){func(c *i18n.Config) { c.Fallbacks["en"] = []string{"zh-Hans"} }, func(c *i18n.Config) { c.DefaultLocale = "de" }, func(c *i18n.Config) { c.Locales = append(c.Locales, "EN") }} { c := settings() change(&c) if _, err := i18n.New(c, docs()...); err == nil { t.Fatal("invalid config accepted") } } } func TestSourceLocaleCanBeFallbackOnly(t *testing.T) { // These tags also exercise browser portability: Intl can alias cmn to zh, // and rejects private-use tags despite Go's BCP 47 matcher accepting them. for _, locale := range []string{"fr", "cmn", "x-example"} { t.Run(locale, func(t *testing.T) { b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: locale, Locales: []string{locale}}, i18n.Document{Version: 1, Namespace: "common", Locale: "en", Messages: map[string]i18n.Message{"fallback": {Value: i18n.Pattern{Text: "Source"}}}}) if err != nil { t.Fatal(err) } got, err := b.Render(locale, i18n.Ref{Namespace: "common", Key: "fallback"}, nil) if err != nil || got.Text != "Source" || !got.Fallback || got.Locale != "en" || got.Requested != locale || len(b.Check()) != 1 { t.Fatal(got, err, b.Check()) } var pack bytes.Buffer if err := b.Export(&pack, locale, "common"); err != nil { t.Fatal(err) } if !bytes.Contains(pack.Bytes(), []byte("UNICODE LICENSE")) { t.Fatal("exported rule data must carry its license") } if node, err := exec.LookPath("node"); err == nil { input, _ := json.Marshal(struct { Pack json.RawMessage `json:"pack"` Cases []any `json:"cases"` }{pack.Bytes(), []any{map[string]any{"key": "fallback", "params": map[string]any{}, "want": "Source"}}}) cmd := exec.Command(node, "browser/conformance.mjs") cmd.Stdin = bytes.NewReader(input) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%s: %v", out, err) } } }) } } func TestStrictLoadingAndExplicitOverlay(t *testing.T) { raw := `{"version":1,"locale":"en","namespace":"common","messages":{"x":{"value":{"literal":true,"text":"${HOME}"}}}}` fs := fstest.MapFS{"en.json": {Data: []byte(raw)}} loaded, err := i18n.LoadFS(fs, "en.json") if err != nil { t.Fatal(err) } if loaded[0].Messages["x"].Value.Text != "${HOME}" { t.Fatal("environment interpolation") } for _, bad := range []string{strings.Replace(raw, `"version":1`, `"version":1,"version":1`, 1), strings.Replace(raw, `"version":1`, `"unknown":1`, 1), strings.Replace(raw, `{"value":{"literal":true,"text":"${HOME}"}}`, `{}`, 1), strings.Replace(raw, `"literal":true`, `"literal":true,"unknown":true`, 1), raw + ` {}`, strings.Repeat("[", 34) + "0" + strings.Repeat("]", 34)} { var d i18n.Document if err := i18n.DecodeJSON([]byte(bad), &d); err == nil { t.Fatal("invalid JSON accepted", bad) } } c := i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en"}} if _, err := i18n.New(c, loaded[0], loaded[0]); err == nil { t.Fatal("duplicate accepted") } override := i18n.Document{Version: 1, Locale: "EN", Namespace: "common", Messages: map[string]i18n.Message{"x": {Value: i18n.Pattern{Text: "replacement"}}}} merged, err := i18n.Overlay(loaded[0], override) if err != nil { t.Fatal(err) } b, err := i18n.New(c, merged) if err != nil { t.Fatal(err) } got, _ := b.Render("en", i18n.Ref{Namespace: "common", Key: "x"}, nil) if got.Text != "replacement" || loaded[0].Messages["x"].Value.Text != "${HOME}" { t.Fatal(got) } } func TestBrowserConformance(t *testing.T) { node, err := exec.LookPath("node") if err != nil { t.Skip("Node.js is required for cross-runtime conformance") } b := makeBundle(t) type sample struct { Key string `json:"key"` Params map[string]any `json:"params"` Want string `json:"want"` } cases := map[string][]sample{ "en": {{"count", map[string]any{"count": 1}, "one:1"}, {"count", map[string]any{"count": 2}, "other:2"}, {"count", map[string]any{"count": "9223372036854775807"}, "other:9223372036854775807"}, {"decimal", map[string]any{"count": 1.5}, "other:1.5"}, {"decimal", map[string]any{"count": 1e-16}, "other:1e-16"}, {"choice", map[string]any{"type": "admin", "count": 2}, "admin:2"}}, "ru": {{"count", map[string]any{"count": 21}, "one:21"}, {"count", map[string]any{"count": 22}, "few:22"}, {"count", map[string]any{"count": 11}, "many:11"}, {"count", map[string]any{"count": 10000021}, "one:10000021"}, {"decimal", map[string]any{"count": 1.25}, "other:1.25"}}, "ar": {{"count", map[string]any{"count": 0}, "zero:0"}, {"count", map[string]any{"count": 2}, "two:2"}, {"count", map[string]any{"count": 3}, "few:3"}, {"count", map[string]any{"count": 11}, "many:11"}, {"count", map[string]any{"count": 100}, "other:100"}}, "zh-Hans": {{"count", map[string]any{"count": 1}, "other:1"}, {"greeting", map[string]any{"first_name": "{count}"}, "Hello {count} {literal}"}}, "fr": {{"count", map[string]any{"count": 0}, "one:0"}, {"count", map[string]any{"count": 2}, "other:2"}, {"count", map[string]any{"count": 1000000}, "many:1000000"}}, } for locale, samples := range cases { for _, s := range samples { params := map[string]any{} for k, v := range s.Params { params[k] = v } if v, ok := params["count"].(string); ok { params["count"] = json.Number(v) } got, err := b.Render(locale, i18n.Ref{Namespace: "common", Key: s.Key}, params) if err != nil || got.Text != s.Want { t.Fatalf("Go %s %s: %+v %v", locale, s.Key, got, err) } } var pack bytes.Buffer if err := b.Export(&pack, locale, "common"); err != nil { t.Fatal(err) } input, _ := json.Marshal(struct { Pack json.RawMessage `json:"pack"` Cases []sample `json:"cases"` }{pack.Bytes(), samples}) cmd := exec.Command(node, "browser/conformance.mjs") cmd.Stdin = bytes.NewReader(input) if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("browser %s: %s %v", locale, output, err) } } var ts bytes.Buffer if err := b.TypeScript(&ts, "common"); err != nil { t.Fatal(err) } if !strings.Contains(ts.String(), "bigint") || !strings.Contains(ts.String(), `"first_name": string`) { t.Fatal(ts.String()) } if err := b.Export(&bytes.Buffer{}, "en"); err == nil { t.Fatal("export requires explicit namespaces") } } func TestTypeScriptConsumer(t *testing.T) { tsc, err := exec.LookPath("tsc") if err != nil { t.Skip("TypeScript compiler is required for generated type conformance") } dir := t.TempDir() var types bytes.Buffer if err := makeBundle(t).TypeScript(&types, "common"); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "messages.generated.d.mts"), types.Bytes(), 0600); err != nil { t.Fatal(err) } for target, source := range map[string]string{"runtime.d.mts": "browser/runtime.d.mts", "consumer.mts": "browser/testdata/consumer.mts"} { data, err := os.ReadFile(source) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, target), data, 0600); err != nil { t.Fatal(err) } } cmd := exec.Command(tsc, "--noEmit", "--strict", "--target", "ES2022", "--module", "NodeNext", "--moduleResolution", "NodeNext", "consumer.mts") cmd.Dir = dir if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("TypeScript consumer: %s %v", output, err) } } func FuzzDecodeJSON(f *testing.F) { f.Add([]byte(`{"version":1,"locale":"en","namespace":"x","messages":{"x":{"value":{"text":"hello"}}}}`)) f.Fuzz(func(t *testing.T, data []byte) { var d i18n.Document if i18n.DecodeJSON(data, &d) == nil { b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en"}}, d) if err == nil { for _, doc := range b.Documents("") { if !reflect.DeepEqual(doc, b.Documents(doc.Locale)[0]) { t.Fatal("unstable resource") } } } } }) } func BenchmarkRender(b *testing.B) { catalog := makeBundle(b) params := map[string]any{"count": 22} b.ReportAllocs() for i := 0; i < b.N; i++ { if _, err := catalog.Render("ru", i18n.Ref{Namespace: "common", Key: "count"}, params); err != nil { b.Fatal(err) } } }