feat: release v0.26 portable i18n and presentation adapters

This commit is contained in:
2026-09-29 13:31:42 +08:00
parent 6a95cad184
commit d1a747c36f
75 changed files with 5681 additions and 52 deletions

353
i18n/bundle_test.go Normal file
View 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)
}
}
}