feat: release v0.26 portable i18n and presentation adapters
This commit is contained in:
353
i18n/bundle_test.go
Normal file
353
i18n/bundle_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user