Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2a16f4282 | |||
|
|
cf9ea9b4b7 |
58 changed files with 787 additions and 10904 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -4,9 +4,3 @@
|
|||
# Knowledge base
|
||||
KB/
|
||||
.core/
|
||||
|
||||
# Local Go tooling caches
|
||||
.cache/
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
.gopath/
|
||||
|
|
|
|||
30
Makefile
30
Makefile
|
|
@ -1,30 +0,0 @@
|
|||
.PHONY: build vet test cover tidy clean
|
||||
|
||||
override GOCACHE := $(CURDIR)/.cache/go-build
|
||||
override GOPATH := $(CURDIR)/.cache/go
|
||||
GO ?= go
|
||||
|
||||
GO_ENV = GOCACHE=$(GOCACHE) GOPATH=$(GOPATH)
|
||||
|
||||
build:
|
||||
@mkdir -p $(GOCACHE) $(GOPATH)
|
||||
@$(GO_ENV) $(GO) build ./...
|
||||
|
||||
vet:
|
||||
@mkdir -p $(GOCACHE) $(GOPATH)
|
||||
@$(GO_ENV) $(GO) vet ./...
|
||||
|
||||
test:
|
||||
@mkdir -p $(GOCACHE) $(GOPATH)
|
||||
@$(GO_ENV) $(GO) test ./... -count=1 -timeout 120s
|
||||
|
||||
cover:
|
||||
@mkdir -p $(GOCACHE) $(GOPATH)
|
||||
@$(GO_ENV) $(GO) test -cover ./...
|
||||
|
||||
tidy:
|
||||
@mkdir -p $(GOCACHE) $(GOPATH)
|
||||
@$(GO_ENV) $(GO) mod tidy
|
||||
|
||||
clean:
|
||||
@rm -rf $(CURDIR)/.cache $(CURDIR)/.gocache $(CURDIR)/.gomodcache $(CURDIR)/.gopath
|
||||
11
README.md
11
README.md
|
|
@ -45,17 +45,6 @@ go test -bench=. ./...
|
|||
go build ./...
|
||||
```
|
||||
|
||||
For repeatable local runs in a clean workspace, the repo also ships a
|
||||
`Makefile` with the standard workflow targets:
|
||||
|
||||
```bash
|
||||
make build
|
||||
make vet
|
||||
make test
|
||||
make cover
|
||||
make tidy
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
European Union Public Licence 1.2 — see [LICENCE](LICENCE) for details.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ package i18n
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
"forge.lthn.ai/core/go-inference"
|
||||
)
|
||||
|
|
@ -66,7 +66,7 @@ func CalibrateDomains(ctx context.Context, modelA, modelB inference.TextModel,
|
|||
// Build classification prompts from sample texts.
|
||||
prompts := make([]string, len(samples))
|
||||
for i, s := range samples {
|
||||
prompts[i] = core.Sprintf(cfg.promptTemplate, s.Text)
|
||||
prompts[i] = fmt.Sprintf(cfg.promptTemplate, s.Text)
|
||||
}
|
||||
|
||||
// Classify with model A.
|
||||
|
|
@ -93,7 +93,7 @@ func CalibrateDomains(ctx context.Context, modelA, modelB inference.TextModel,
|
|||
if agree {
|
||||
stats.Agreed++
|
||||
} else {
|
||||
key := core.Sprintf("%s->%s", a, b)
|
||||
key := fmt.Sprintf("%s->%s", a, b)
|
||||
stats.ConfusionPairs[key]++
|
||||
}
|
||||
stats.ByDomainA[a]++
|
||||
|
|
@ -140,7 +140,7 @@ func classifyAll(ctx context.Context, model inference.TextModel, prompts []strin
|
|||
|
||||
results, err := model.Classify(ctx, batch, inference.WithMaxTokens(1))
|
||||
if err != nil {
|
||||
return nil, 0, log.E("classifyAll", core.Sprintf("classify batch [%d:%d]", i, end), err)
|
||||
return nil, 0, log.E("classifyAll", fmt.Sprintf("classify batch [%d:%d]", i, end), err)
|
||||
}
|
||||
|
||||
for j, r := range results {
|
||||
|
|
|
|||
28
classify.go
28
classify.go
|
|
@ -3,10 +3,12 @@ package i18n
|
|||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
"forge.lthn.ai/core/go-inference"
|
||||
)
|
||||
|
|
@ -61,7 +63,7 @@ func mapTokenToDomain(token string) string {
|
|||
if len(token) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
lower := core.Lower(token)
|
||||
lower := strings.ToLower(token)
|
||||
switch {
|
||||
case lower == "technical" || lower == "tech":
|
||||
return "technical"
|
||||
|
|
@ -105,31 +107,25 @@ func ClassifyCorpus(ctx context.Context, model inference.TextModel,
|
|||
}
|
||||
prompts := make([]string, len(batch))
|
||||
for i, p := range batch {
|
||||
prompts[i] = core.Sprintf(cfg.promptTemplate, p.prompt)
|
||||
prompts[i] = fmt.Sprintf(cfg.promptTemplate, p.prompt)
|
||||
}
|
||||
results, err := model.Classify(ctx, prompts, inference.WithMaxTokens(1))
|
||||
if err != nil {
|
||||
return log.E("ClassifyCorpus", "classify batch", err)
|
||||
}
|
||||
if len(results) != len(batch) {
|
||||
return log.E(
|
||||
"ClassifyCorpus",
|
||||
core.Sprintf("classify batch returned %d results for %d prompts", len(results), len(batch)),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
for i, r := range results {
|
||||
domain := mapTokenToDomain(r.Token.Text)
|
||||
batch[i].record["domain_1b"] = domain
|
||||
stats.ByDomain[domain]++
|
||||
stats.Total++
|
||||
|
||||
mr := core.JSONMarshal(batch[i].record)
|
||||
if !mr.OK {
|
||||
return log.E("ClassifyCorpus", "marshal output", mr.Value.(error))
|
||||
line, err := json.Marshal(batch[i].record)
|
||||
if err != nil {
|
||||
return log.E("ClassifyCorpus", "marshal output", err)
|
||||
}
|
||||
if _, err := fmt.Fprintf(output, "%s\n", line); err != nil {
|
||||
return log.E("ClassifyCorpus", "write output", err)
|
||||
}
|
||||
line := mr.Value.([]byte)
|
||||
core.Print(output, "%s", line)
|
||||
}
|
||||
batch = batch[:0]
|
||||
return nil
|
||||
|
|
@ -137,7 +133,7 @@ func ClassifyCorpus(ctx context.Context, model inference.TextModel,
|
|||
|
||||
for scanner.Scan() {
|
||||
var record map[string]any
|
||||
if r := core.JSONUnmarshal(scanner.Bytes(), &record); !r.OK {
|
||||
if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ package i18n
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"iter"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/go-inference"
|
||||
)
|
||||
|
||||
|
|
@ -67,11 +68,11 @@ func (m *mockModel) BatchGenerate(_ context.Context, _ []string, _ ...inference.
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockModel) ModelType() string { return "mock" }
|
||||
func (m *mockModel) Info() inference.ModelInfo { return inference.ModelInfo{} }
|
||||
func (m *mockModel) ModelType() string { return "mock" }
|
||||
func (m *mockModel) Info() inference.ModelInfo { return inference.ModelInfo{} }
|
||||
func (m *mockModel) Metrics() inference.GenerateMetrics { return inference.GenerateMetrics{} }
|
||||
func (m *mockModel) Err() error { return nil }
|
||||
func (m *mockModel) Close() error { return nil }
|
||||
func (m *mockModel) Err() error { return nil }
|
||||
func (m *mockModel) Close() error { return nil }
|
||||
|
||||
func TestClassifyCorpus_Basic(t *testing.T) {
|
||||
model := &mockModel{
|
||||
|
|
@ -84,7 +85,7 @@ func TestClassifyCorpus_Basic(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
input := core.NewReader(
|
||||
input := strings.NewReader(
|
||||
`{"seed_id":"1","domain":"general","prompt":"Delete the file"}` + "\n" +
|
||||
`{"seed_id":"2","domain":"science","prompt":"Explain gravity"}` + "\n",
|
||||
)
|
||||
|
|
@ -101,15 +102,15 @@ func TestClassifyCorpus_Basic(t *testing.T) {
|
|||
t.Errorf("Skipped = %d, want 0", stats.Skipped)
|
||||
}
|
||||
|
||||
lines := core.Split(core.Trim(output.String()), "\n")
|
||||
lines := strings.Split(strings.TrimSpace(output.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("output lines = %d, want 2", len(lines))
|
||||
}
|
||||
|
||||
for i, line := range lines {
|
||||
var record map[string]any
|
||||
if r := core.JSONUnmarshal([]byte(line), &record); !r.OK {
|
||||
t.Fatalf("line %d: unmarshal: %v", i, r.Value)
|
||||
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||
t.Fatalf("line %d: unmarshal: %v", i, err)
|
||||
}
|
||||
if record["domain_1b"] != "technical" {
|
||||
t.Errorf("line %d: domain_1b = %v, want %q", i, record["domain_1b"], "technical")
|
||||
|
|
@ -132,7 +133,7 @@ func TestClassifyCorpus_SkipsMalformed(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
input := core.NewReader(
|
||||
input := strings.NewReader(
|
||||
"not valid json\n" +
|
||||
`{"seed_id":"1","domain":"general","prompt":"Hello world"}` + "\n" +
|
||||
`{"seed_id":"2","domain":"general"}` + "\n",
|
||||
|
|
@ -156,7 +157,7 @@ func TestClassifyCorpus_DomainMapping(t *testing.T) {
|
|||
classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) {
|
||||
results := make([]inference.ClassifyResult, len(prompts))
|
||||
for i, p := range prompts {
|
||||
if core.Contains(p, "Delete") {
|
||||
if strings.Contains(p, "Delete") {
|
||||
results[i] = inference.ClassifyResult{Token: inference.Token{Text: "technical"}}
|
||||
} else {
|
||||
results[i] = inference.ClassifyResult{Token: inference.Token{Text: "ethical"}}
|
||||
|
|
@ -166,7 +167,7 @@ func TestClassifyCorpus_DomainMapping(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
input := core.NewReader(
|
||||
input := strings.NewReader(
|
||||
`{"prompt":"Delete the file now"}` + "\n" +
|
||||
`{"prompt":"Is it right to lie?"}` + "\n",
|
||||
)
|
||||
|
|
@ -183,31 +184,3 @@ func TestClassifyCorpus_DomainMapping(t *testing.T) {
|
|||
t.Errorf("ByDomain[ethical] = %d, want 1", stats.ByDomain["ethical"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyCorpus_ResultCountMismatch(t *testing.T) {
|
||||
model := &mockModel{
|
||||
classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) {
|
||||
if len(prompts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []inference.ClassifyResult{{Token: inference.Token{Text: "technical"}}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
input := core.NewReader(
|
||||
`{"prompt":"Delete the file now"}` + "\n" +
|
||||
`{"prompt":"Create the repo"}` + "\n",
|
||||
)
|
||||
|
||||
var output bytes.Buffer
|
||||
stats, err := ClassifyCorpus(context.Background(), model, input, &output, WithBatchSize(16))
|
||||
if err == nil {
|
||||
t.Fatal("ClassifyCorpus returned nil error, want mismatch failure")
|
||||
}
|
||||
if stats.Total != 0 {
|
||||
t.Errorf("Total = %d, want 0", stats.Total)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Errorf("output len = %d, want 0", output.Len())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
compose.go
62
compose.go
|
|
@ -1,10 +1,6 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
import "fmt"
|
||||
|
||||
// S creates a new Subject with the given noun and value.
|
||||
//
|
||||
|
|
@ -14,23 +10,6 @@ func S(noun string, value any) *Subject {
|
|||
return &Subject{Noun: noun, Value: value, count: 1}
|
||||
}
|
||||
|
||||
// ComposeIntent renders an intent's templates into concrete output.
|
||||
func ComposeIntent(intent Intent, subject *Subject) Composed {
|
||||
return intent.Compose(subject)
|
||||
}
|
||||
|
||||
// Compose renders an intent's templates into concrete output.
|
||||
func (i Intent) Compose(subject *Subject) Composed {
|
||||
data := newTemplateData(subject)
|
||||
return Composed{
|
||||
Question: executeIntentTemplate(i.Question, data),
|
||||
Confirm: executeIntentTemplate(i.Confirm, data),
|
||||
Success: executeIntentTemplate(i.Success, data),
|
||||
Failure: executeIntentTemplate(i.Failure, data),
|
||||
Meta: i.Meta,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Subject) Count(n int) *Subject {
|
||||
if s == nil {
|
||||
return nil
|
||||
|
|
@ -86,40 +65,15 @@ func (s *Subject) String() string {
|
|||
if stringer, ok := s.Value.(fmt.Stringer); ok {
|
||||
return stringer.String()
|
||||
}
|
||||
return core.Sprintf("%v", s.Value)
|
||||
return fmt.Sprint(s.Value)
|
||||
}
|
||||
|
||||
func (s *Subject) IsPlural() bool { return s != nil && s.count != 1 }
|
||||
func (s *Subject) CountInt() int {
|
||||
if s == nil {
|
||||
return 1
|
||||
}
|
||||
return s.count
|
||||
}
|
||||
func (s *Subject) CountString() string {
|
||||
if s == nil {
|
||||
return "1"
|
||||
}
|
||||
return FormatNumber(int64(s.count))
|
||||
}
|
||||
func (s *Subject) GenderString() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.gender
|
||||
}
|
||||
func (s *Subject) LocationString() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.location
|
||||
}
|
||||
func (s *Subject) NounString() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.Noun
|
||||
}
|
||||
func (s *Subject) IsPlural() bool { return s != nil && s.count != 1 }
|
||||
func (s *Subject) CountInt() int { if s == nil { return 1 }; return s.count }
|
||||
func (s *Subject) CountString() string { if s == nil { return "1" }; return fmt.Sprint(s.count) }
|
||||
func (s *Subject) GenderString() string { if s == nil { return "" }; return s.gender }
|
||||
func (s *Subject) LocationString() string { if s == nil { return "" }; return s.location }
|
||||
func (s *Subject) NounString() string { if s == nil { return "" }; return s.Noun }
|
||||
func (s *Subject) FormalityString() string {
|
||||
if s == nil {
|
||||
return FormalityNeutral.String()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -41,21 +41,6 @@ func TestSubject_Count_Good(t *testing.T) {
|
|||
assert.True(t, subj.IsPlural())
|
||||
}
|
||||
|
||||
func TestSubject_CountString_UsesLocaleFormatting(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
require.NoError(t, SetLanguage("fr"))
|
||||
|
||||
subj := S("file", "test.txt").Count(1234)
|
||||
assert.Equal(t, "1 234", subj.CountString())
|
||||
}
|
||||
|
||||
func TestSubject_Count_Bad_NilReceiver(t *testing.T) {
|
||||
var s *Subject
|
||||
result := s.Count(5)
|
||||
|
|
@ -138,7 +123,7 @@ func TestSubject_String_Good(t *testing.T) {
|
|||
|
||||
func TestSubject_String_Good_Stringer(t *testing.T) {
|
||||
// Use a type that implements fmt.Stringer
|
||||
subj := S("error", core.NewError("something broke"))
|
||||
subj := S("error", fmt.Errorf("something broke"))
|
||||
assert.Equal(t, "something broke", subj.String())
|
||||
}
|
||||
|
||||
|
|
|
|||
101
context.go
101
context.go
|
|
@ -7,72 +7,41 @@ package i18n
|
|||
type TranslationContext struct {
|
||||
Context string
|
||||
Gender string
|
||||
Location string
|
||||
Formality Formality
|
||||
count int
|
||||
countSet bool
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
// C creates a TranslationContext.
|
||||
func C(context string) *TranslationContext {
|
||||
return &TranslationContext{Context: context, count: 1}
|
||||
return &TranslationContext{Context: context}
|
||||
}
|
||||
|
||||
func (c *TranslationContext) WithGender(gender string) *TranslationContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c == nil { return nil }
|
||||
c.Gender = gender
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) In(location string) *TranslationContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.Location = location
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) Formal() *TranslationContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c == nil { return nil }
|
||||
c.Formality = FormalityFormal
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) Informal() *TranslationContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c == nil { return nil }
|
||||
c.Formality = FormalityInformal
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) WithFormality(f Formality) *TranslationContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c == nil { return nil }
|
||||
c.Formality = f
|
||||
return c
|
||||
}
|
||||
|
||||
// Count sets the count used for plural-sensitive translations.
|
||||
func (c *TranslationContext) Count(n int) *TranslationContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.count = n
|
||||
c.countSet = true
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TranslationContext) Set(key string, value any) *TranslationContext {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c == nil { return nil }
|
||||
if c.Extra == nil {
|
||||
c.Extra = make(map[string]any)
|
||||
}
|
||||
|
|
@ -81,71 +50,21 @@ func (c *TranslationContext) Set(key string, value any) *TranslationContext {
|
|||
}
|
||||
|
||||
func (c *TranslationContext) Get(key string) any {
|
||||
if c == nil || c.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
if c == nil || c.Extra == nil { return nil }
|
||||
return c.Extra[key]
|
||||
}
|
||||
|
||||
func (c *TranslationContext) ContextString() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Context
|
||||
}
|
||||
|
||||
func (c *TranslationContext) String() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
if c == nil { return "" }
|
||||
return c.Context
|
||||
}
|
||||
|
||||
func (c *TranslationContext) GenderString() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
if c == nil { return "" }
|
||||
return c.Gender
|
||||
}
|
||||
|
||||
func (c *TranslationContext) LocationString() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Location
|
||||
}
|
||||
|
||||
func (c *TranslationContext) FormalityValue() Formality {
|
||||
if c == nil {
|
||||
return FormalityNeutral
|
||||
}
|
||||
if c == nil { return FormalityNeutral }
|
||||
return c.Formality
|
||||
}
|
||||
|
||||
// CountInt returns the current count value.
|
||||
func (c *TranslationContext) CountInt() int {
|
||||
if c == nil {
|
||||
return 1
|
||||
}
|
||||
return c.count
|
||||
}
|
||||
|
||||
// CountString returns the current count value formatted as text.
|
||||
func (c *TranslationContext) CountString() string {
|
||||
if c == nil {
|
||||
return "1"
|
||||
}
|
||||
return FormatNumber(int64(c.count))
|
||||
}
|
||||
|
||||
// IsPlural reports whether the count is plural.
|
||||
func (c *TranslationContext) IsPlural() bool {
|
||||
return c != nil && c.count != 1
|
||||
}
|
||||
|
||||
func (c *TranslationContext) countValue() (int, bool) {
|
||||
if c == nil {
|
||||
return 1, false
|
||||
}
|
||||
return c.count, c.countSet
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
package i18n
|
||||
|
||||
import "dappco.re/go/core"
|
||||
|
||||
func mapValueString(values any, key string) (string, bool) {
|
||||
switch m := values.(type) {
|
||||
case map[string]any:
|
||||
raw, ok := m[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
text := core.Trim(core.Sprintf("%v", raw))
|
||||
if text == "" {
|
||||
return "", false
|
||||
}
|
||||
return text, true
|
||||
case map[string]string:
|
||||
text, ok := m[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
text = core.Trim(text)
|
||||
if text == "" {
|
||||
return "", false
|
||||
}
|
||||
return text, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func contextMapValues(values any) map[string]any {
|
||||
switch m := values.(type) {
|
||||
case map[string]any:
|
||||
return contextMapValuesAny(m)
|
||||
case map[string]string:
|
||||
return contextMapValuesString(m)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func contextMapValuesAny(values map[string]any) map[string]any {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
extra := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
switch key {
|
||||
case "Context", "Gender", "Location", "Formality", "Count", "IsPlural":
|
||||
continue
|
||||
case "Extra", "extra", "Extras", "extras":
|
||||
mergeContextExtra(extra, value)
|
||||
continue
|
||||
default:
|
||||
extra[key] = value
|
||||
}
|
||||
}
|
||||
if len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
func contextMapValuesString(values map[string]string) map[string]any {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
extra := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
switch key {
|
||||
case "Context", "Gender", "Location", "Formality", "Count", "IsPlural",
|
||||
"Extra", "extra", "Extras", "extras":
|
||||
continue
|
||||
default:
|
||||
extra[key] = value
|
||||
}
|
||||
}
|
||||
if len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
|
@ -14,10 +14,6 @@ func TestC_Good(t *testing.T) {
|
|||
require.NotNil(t, ctx)
|
||||
assert.Equal(t, "navigation", ctx.Context)
|
||||
assert.Equal(t, "navigation", ctx.ContextString())
|
||||
assert.Equal(t, "navigation", ctx.String())
|
||||
assert.Equal(t, 1, ctx.CountInt())
|
||||
assert.Equal(t, "1", ctx.CountString())
|
||||
assert.False(t, ctx.IsPlural())
|
||||
}
|
||||
|
||||
func TestC_Good_EmptyContext(t *testing.T) {
|
||||
|
|
@ -31,9 +27,7 @@ func TestC_Good_EmptyContext(t *testing.T) {
|
|||
func TestTranslationContext_NilReceiver_Good(t *testing.T) {
|
||||
var ctx *TranslationContext
|
||||
|
||||
assert.Nil(t, ctx.Count(2))
|
||||
assert.Nil(t, ctx.WithGender("masculine"))
|
||||
assert.Nil(t, ctx.In("workspace"))
|
||||
assert.Nil(t, ctx.Formal())
|
||||
assert.Nil(t, ctx.Informal())
|
||||
assert.Nil(t, ctx.WithFormality(FormalityFormal))
|
||||
|
|
@ -41,11 +35,7 @@ func TestTranslationContext_NilReceiver_Good(t *testing.T) {
|
|||
assert.Nil(t, ctx.Get("key"))
|
||||
assert.Equal(t, "", ctx.ContextString())
|
||||
assert.Equal(t, "", ctx.GenderString())
|
||||
assert.Equal(t, "", ctx.LocationString())
|
||||
assert.Equal(t, FormalityNeutral, ctx.FormalityValue())
|
||||
assert.Equal(t, 1, ctx.CountInt())
|
||||
assert.Equal(t, "1", ctx.CountString())
|
||||
assert.False(t, ctx.IsPlural())
|
||||
}
|
||||
|
||||
// --- WithGender ---
|
||||
|
|
@ -56,17 +46,6 @@ func TestTranslationContext_WithGender_Good(t *testing.T) {
|
|||
assert.Equal(t, "feminine", ctx.GenderString())
|
||||
}
|
||||
|
||||
func TestTranslationContext_In_Good(t *testing.T) {
|
||||
ctx := C("test").In("workspace")
|
||||
assert.Equal(t, "workspace", ctx.Location)
|
||||
assert.Equal(t, "workspace", ctx.LocationString())
|
||||
}
|
||||
|
||||
func TestTranslationContext_In_Bad_NilReceiver(t *testing.T) {
|
||||
var ctx *TranslationContext
|
||||
assert.Nil(t, ctx.In("workspace"))
|
||||
}
|
||||
|
||||
// --- Formal / Informal ---
|
||||
|
||||
func TestTranslationContext_Formal_Good(t *testing.T) {
|
||||
|
|
@ -101,21 +80,6 @@ func TestTranslationContext_WithFormality_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTranslationContext_CountString_UsesLocaleFormatting(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
require.NoError(t, SetLanguage("fr"))
|
||||
|
||||
ctx := C("test").Count(1234)
|
||||
assert.Equal(t, "1 234", ctx.CountString())
|
||||
}
|
||||
|
||||
// --- Set / Get ---
|
||||
|
||||
func TestTranslationContext_SetGet_Good(t *testing.T) {
|
||||
|
|
@ -141,18 +105,12 @@ func TestTranslationContext_Get_Bad_NilExtra(t *testing.T) {
|
|||
|
||||
func TestTranslationContext_FullChain_Good(t *testing.T) {
|
||||
ctx := C("medical").
|
||||
Count(3).
|
||||
WithGender("feminine").
|
||||
In("clinic").
|
||||
Formal().
|
||||
Set("speciality", "cardiology")
|
||||
|
||||
assert.Equal(t, "medical", ctx.ContextString())
|
||||
assert.Equal(t, 3, ctx.CountInt())
|
||||
assert.Equal(t, "3", ctx.CountString())
|
||||
assert.True(t, ctx.IsPlural())
|
||||
assert.Equal(t, "feminine", ctx.GenderString())
|
||||
assert.Equal(t, "clinic", ctx.LocationString())
|
||||
assert.Equal(t, FormalityFormal, ctx.FormalityValue())
|
||||
assert.Equal(t, "cardiology", ctx.Get("speciality"))
|
||||
}
|
||||
|
|
|
|||
436
core_service.go
436
core_service.go
|
|
@ -18,33 +18,14 @@ type CoreService struct {
|
|||
|
||||
missingKeys []MissingKey
|
||||
missingKeysMu sync.Mutex
|
||||
hookInstalled bool
|
||||
}
|
||||
|
||||
var _ core.Startable = (*CoreService)(nil)
|
||||
var _ core.Stoppable = (*CoreService)(nil)
|
||||
|
||||
func (s *CoreService) wrapped() *Service {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.svc
|
||||
}
|
||||
|
||||
// ServiceOptions configures the i18n Core service.
|
||||
type ServiceOptions struct {
|
||||
// Language overrides auto-detection (e.g., "en-GB", "de")
|
||||
Language string
|
||||
// Fallback sets the fallback language for missing translations.
|
||||
Fallback string
|
||||
// Formality sets the default formality level.
|
||||
Formality Formality
|
||||
// Location sets the default location context.
|
||||
Location string
|
||||
// Mode sets the translation mode (Normal, Strict, Collect)
|
||||
Mode Mode
|
||||
// Debug prefixes translated output with the message key.
|
||||
Debug bool
|
||||
// ExtraFS loads additional translation files on top of the embedded defaults.
|
||||
// Each entry is an fs.FS + directory path within it.
|
||||
ExtraFS []FSSource
|
||||
|
|
@ -56,36 +37,6 @@ type FSSource struct {
|
|||
Dir string
|
||||
}
|
||||
|
||||
// String returns a compact summary of the filesystem source.
|
||||
func (s FSSource) String() string {
|
||||
if s.Dir == "" {
|
||||
return core.Sprintf("FSSource{fs=%T}", s.FS)
|
||||
}
|
||||
return core.Sprintf("FSSource{fs=%T dir=%q}", s.FS, s.Dir)
|
||||
}
|
||||
|
||||
// String returns a compact summary of the service options.
|
||||
func (o ServiceOptions) String() string {
|
||||
extraFS := "[]"
|
||||
if len(o.ExtraFS) > 0 {
|
||||
parts := make([]string, len(o.ExtraFS))
|
||||
for i, src := range o.ExtraFS {
|
||||
parts[i] = src.String()
|
||||
}
|
||||
extraFS = "[" + core.Join(", ", parts...) + "]"
|
||||
}
|
||||
return core.Sprintf(
|
||||
"ServiceOptions{language=%q fallback=%q formality=%s location=%q mode=%s debug=%t extraFS=%s}",
|
||||
o.Language,
|
||||
o.Fallback,
|
||||
o.Formality,
|
||||
o.Location,
|
||||
o.Mode,
|
||||
o.Debug,
|
||||
extraFS,
|
||||
)
|
||||
}
|
||||
|
||||
// NewCoreService creates an i18n Core service factory.
|
||||
// Automatically loads locale filesystems from:
|
||||
// 1. Embedded go-i18n base translations (grammar, verbs, nouns)
|
||||
|
|
@ -105,28 +56,11 @@ func NewCoreService(opts ServiceOptions) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Preserve the same init-time locale registration behaviour used by Init().
|
||||
// Core bootstrap should not bypass packages that registered locale files
|
||||
// before the service was constructed.
|
||||
loadRegisteredLocales(svc)
|
||||
|
||||
if opts.Language != "" {
|
||||
if langErr := svc.SetLanguage(opts.Language); langErr != nil {
|
||||
return nil, langErr
|
||||
}
|
||||
}
|
||||
if opts.Fallback != "" {
|
||||
svc.SetFallback(opts.Fallback)
|
||||
}
|
||||
if opts.Formality != FormalityNeutral {
|
||||
svc.SetFormality(opts.Formality)
|
||||
}
|
||||
if opts.Location != "" {
|
||||
svc.SetLocation(opts.Location)
|
||||
_ = svc.SetLanguage(opts.Language)
|
||||
}
|
||||
|
||||
svc.SetMode(opts.Mode)
|
||||
svc.SetDebug(opts.Debug)
|
||||
SetDefault(svc)
|
||||
|
||||
return &CoreService{
|
||||
|
|
@ -138,54 +72,30 @@ func NewCoreService(opts ServiceOptions) func(*core.Core) (any, error) {
|
|||
}
|
||||
|
||||
// OnStartup initialises the i18n service.
|
||||
func (s *CoreService) OnStartup(_ context.Context) core.Result {
|
||||
if svc := s.wrapped(); svc != nil && svc.Mode() == ModeCollect {
|
||||
s.ensureMissingKeyCollector()
|
||||
func (s *CoreService) OnStartup(_ context.Context) error {
|
||||
if s.svc.Mode() == ModeCollect {
|
||||
OnMissingKey(s.handleMissingKey)
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
// OnShutdown finalises the i18n service.
|
||||
func (s *CoreService) OnShutdown(_ context.Context) core.Result {
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
func (s *CoreService) ensureMissingKeyCollector() {
|
||||
if s == nil || s.svc == nil || s.hookInstalled {
|
||||
return
|
||||
}
|
||||
AddMissingKeyHandler(s.handleMissingKey)
|
||||
s.hookInstalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CoreService) handleMissingKey(mk MissingKey) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
s.missingKeys = append(s.missingKeys, cloneMissingKey(mk))
|
||||
s.missingKeys = append(s.missingKeys, mk)
|
||||
}
|
||||
|
||||
// MissingKeys returns all missing keys collected in collect mode.
|
||||
func (s *CoreService) MissingKeys() []MissingKey {
|
||||
if s == nil {
|
||||
return []MissingKey{}
|
||||
}
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
result := make([]MissingKey, len(s.missingKeys))
|
||||
for i, mk := range s.missingKeys {
|
||||
result[i] = cloneMissingKey(mk)
|
||||
}
|
||||
copy(result, s.missingKeys)
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearMissingKeys resets the collected missing keys.
|
||||
func (s *CoreService) ClearMissingKeys() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
s.missingKeys = s.missingKeys[:0]
|
||||
|
|
@ -193,335 +103,15 @@ func (s *CoreService) ClearMissingKeys() {
|
|||
|
||||
// SetMode changes the translation mode.
|
||||
func (s *CoreService) SetMode(mode Mode) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetMode(mode)
|
||||
}
|
||||
if s != nil && s.svc != nil && mode == ModeCollect {
|
||||
s.ensureMissingKeyCollector()
|
||||
s.svc.SetMode(mode)
|
||||
if mode == ModeCollect {
|
||||
OnMissingKey(s.handleMissingKey)
|
||||
} else {
|
||||
OnMissingKey(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode returns the current translation mode.
|
||||
func (s *CoreService) Mode() Mode {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Mode()
|
||||
}
|
||||
return ModeNormal
|
||||
}
|
||||
|
||||
// CurrentMode returns the current translation mode.
|
||||
func (s *CoreService) CurrentMode() Mode {
|
||||
return s.Mode()
|
||||
}
|
||||
|
||||
// T translates a message through the wrapped i18n service.
|
||||
func (s *CoreService) T(messageID string, args ...any) string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.T(messageID, args...)
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
// Translate translates a message through the wrapped i18n service.
|
||||
func (s *CoreService) Translate(messageID string, args ...any) core.Result {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Translate(messageID, args...)
|
||||
}
|
||||
return core.Result{Value: messageID, OK: false}
|
||||
}
|
||||
|
||||
// Raw translates without namespace handler magic.
|
||||
func (s *CoreService) Raw(messageID string, args ...any) string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Raw(messageID, args...)
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
// AddMessages adds message strings to the wrapped service.
|
||||
func (s *CoreService) AddMessages(lang string, messages map[string]string) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.AddMessages(lang, messages)
|
||||
}
|
||||
}
|
||||
|
||||
// SetLanguage changes the wrapped service language.
|
||||
func (s *CoreService) SetLanguage(lang string) error {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.SetLanguage(lang)
|
||||
}
|
||||
return ErrServiceNotInitialised
|
||||
}
|
||||
|
||||
// Language returns the wrapped service language.
|
||||
func (s *CoreService) Language() string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Language()
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// CurrentLanguage returns the wrapped service language.
|
||||
func (s *CoreService) CurrentLanguage() string {
|
||||
return s.Language()
|
||||
}
|
||||
|
||||
// CurrentLang is a short alias for CurrentLanguage.
|
||||
func (s *CoreService) CurrentLang() string {
|
||||
return s.CurrentLanguage()
|
||||
}
|
||||
|
||||
// Prompt translates a prompt key from the prompt namespace using the wrapped service.
|
||||
func (s *CoreService) Prompt(key string) string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Prompt(key)
|
||||
}
|
||||
return namespaceLookupKey("prompt", key)
|
||||
}
|
||||
|
||||
// CurrentPrompt is a short alias for Prompt.
|
||||
func (s *CoreService) CurrentPrompt(key string) string {
|
||||
return s.Prompt(key)
|
||||
}
|
||||
|
||||
// Lang translates a language label from the lang namespace using the wrapped service.
|
||||
func (s *CoreService) Lang(key string) string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Lang(key)
|
||||
}
|
||||
return namespaceLookupKey("lang", key)
|
||||
}
|
||||
|
||||
// SetFallback changes the wrapped service fallback language.
|
||||
func (s *CoreService) SetFallback(lang string) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetFallback(lang)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback returns the wrapped service fallback language.
|
||||
func (s *CoreService) Fallback() string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Fallback()
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// CurrentFallback returns the wrapped service fallback language.
|
||||
func (s *CoreService) CurrentFallback() string {
|
||||
return s.Fallback()
|
||||
}
|
||||
|
||||
// SetFormality changes the wrapped service default formality.
|
||||
func (s *CoreService) SetFormality(f Formality) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetFormality(f)
|
||||
}
|
||||
}
|
||||
|
||||
// Formality returns the wrapped service default formality.
|
||||
func (s *CoreService) Formality() Formality {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Formality()
|
||||
}
|
||||
return FormalityNeutral
|
||||
}
|
||||
|
||||
// CurrentFormality returns the wrapped service default formality.
|
||||
func (s *CoreService) CurrentFormality() Formality {
|
||||
return s.Formality()
|
||||
}
|
||||
|
||||
// SetLocation changes the wrapped service default location.
|
||||
func (s *CoreService) SetLocation(location string) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
// Location returns the wrapped service default location.
|
||||
func (s *CoreService) Location() string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Location()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CurrentLocation returns the wrapped service default location.
|
||||
func (s *CoreService) CurrentLocation() string {
|
||||
return s.Location()
|
||||
}
|
||||
|
||||
// SetDebug changes the wrapped service debug mode.
|
||||
func (s *CoreService) SetDebug(enabled bool) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetDebug(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Debug reports whether wrapped service debug mode is enabled.
|
||||
func (s *CoreService) Debug() bool {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Debug()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CurrentDebug reports whether wrapped service debug mode is enabled.
|
||||
func (s *CoreService) CurrentDebug() bool {
|
||||
return s.Debug()
|
||||
}
|
||||
|
||||
// State returns a copy-safe snapshot of the wrapped service configuration.
|
||||
func (s *CoreService) State() ServiceState {
|
||||
if s == nil || s.svc == nil {
|
||||
return defaultServiceStateSnapshot()
|
||||
}
|
||||
return s.svc.State()
|
||||
}
|
||||
|
||||
// CurrentState is a more explicit alias for State.
|
||||
func (s *CoreService) CurrentState() ServiceState {
|
||||
return s.State()
|
||||
}
|
||||
|
||||
// String returns a concise snapshot of the wrapped service state.
|
||||
func (s *CoreService) String() string {
|
||||
return s.State().String()
|
||||
}
|
||||
|
||||
// AddHandler appends handlers to the wrapped service's chain.
|
||||
func (s *CoreService) AddHandler(handlers ...KeyHandler) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.AddHandler(handlers...)
|
||||
}
|
||||
}
|
||||
|
||||
// SetHandlers replaces the wrapped service's handler chain.
|
||||
func (s *CoreService) SetHandlers(handlers ...KeyHandler) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.SetHandlers(handlers...)
|
||||
}
|
||||
}
|
||||
|
||||
// PrependHandler inserts handlers at the front of the wrapped service's chain.
|
||||
func (s *CoreService) PrependHandler(handlers ...KeyHandler) {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.PrependHandler(handlers...)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearHandlers removes all handlers from the wrapped service.
|
||||
func (s *CoreService) ClearHandlers() {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.ClearHandlers()
|
||||
}
|
||||
}
|
||||
|
||||
// ResetHandlers restores the wrapped service's default handler chain.
|
||||
func (s *CoreService) ResetHandlers() {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
svc.ResetHandlers()
|
||||
}
|
||||
}
|
||||
|
||||
// Handlers returns a copy of the wrapped service's handler chain.
|
||||
func (s *CoreService) Handlers() []KeyHandler {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Handlers()
|
||||
}
|
||||
return []KeyHandler{}
|
||||
}
|
||||
|
||||
// CurrentHandlers returns a copy of the wrapped service's handler chain.
|
||||
func (s *CoreService) CurrentHandlers() []KeyHandler {
|
||||
return s.Handlers()
|
||||
}
|
||||
|
||||
// AddLoader loads extra locale data into the wrapped service.
|
||||
func (s *CoreService) AddLoader(loader Loader) error {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.AddLoader(loader)
|
||||
}
|
||||
return ErrServiceNotInitialised
|
||||
}
|
||||
|
||||
// LoadFS loads locale data from a filesystem into the wrapped service.
|
||||
func (s *CoreService) LoadFS(fsys fs.FS, dir string) error {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.LoadFS(fsys, dir)
|
||||
}
|
||||
return ErrServiceNotInitialised
|
||||
}
|
||||
|
||||
// AvailableLanguages returns the wrapped service languages.
|
||||
func (s *CoreService) AvailableLanguages() []string {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.AvailableLanguages()
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// CurrentAvailableLanguages returns the wrapped service languages.
|
||||
func (s *CoreService) CurrentAvailableLanguages() []string {
|
||||
return s.AvailableLanguages()
|
||||
}
|
||||
|
||||
// Direction returns the wrapped service text direction.
|
||||
func (s *CoreService) Direction() TextDirection {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.Direction()
|
||||
}
|
||||
return DirLTR
|
||||
}
|
||||
|
||||
// CurrentDirection returns the wrapped service text direction.
|
||||
func (s *CoreService) CurrentDirection() TextDirection {
|
||||
return s.Direction()
|
||||
}
|
||||
|
||||
// CurrentTextDirection is a more explicit alias for CurrentDirection.
|
||||
func (s *CoreService) CurrentTextDirection() TextDirection {
|
||||
return s.CurrentDirection()
|
||||
}
|
||||
|
||||
// IsRTL reports whether the wrapped service language is right-to-left.
|
||||
func (s *CoreService) IsRTL() bool {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.IsRTL()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RTL reports whether the wrapped service language is right-to-left.
|
||||
func (s *CoreService) RTL() bool {
|
||||
return s.IsRTL()
|
||||
}
|
||||
|
||||
// CurrentIsRTL reports whether the wrapped service language is right-to-left.
|
||||
func (s *CoreService) CurrentIsRTL() bool {
|
||||
return s.IsRTL()
|
||||
}
|
||||
|
||||
// CurrentRTL reports whether the wrapped service language is right-to-left.
|
||||
func (s *CoreService) CurrentRTL() bool {
|
||||
return s.CurrentIsRTL()
|
||||
}
|
||||
|
||||
// PluralCategory returns the plural category for the wrapped service language.
|
||||
func (s *CoreService) PluralCategory(n int) PluralCategory {
|
||||
if svc := s.wrapped(); svc != nil {
|
||||
return svc.PluralCategory(n)
|
||||
}
|
||||
return PluralOther
|
||||
}
|
||||
|
||||
// CurrentPluralCategory returns the plural category for the wrapped service language.
|
||||
func (s *CoreService) CurrentPluralCategory(n int) PluralCategory {
|
||||
return s.PluralCategory(n)
|
||||
}
|
||||
|
||||
// PluralCategoryOf is a short alias for CurrentPluralCategory.
|
||||
func (s *CoreService) PluralCategoryOf(n int) PluralCategory {
|
||||
return s.CurrentPluralCategory(n)
|
||||
return s.svc.Mode()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCoreServiceNilSafe(t *testing.T) {
|
||||
var svc *CoreService
|
||||
savedDefault := defaultService.Load()
|
||||
t.Cleanup(func() {
|
||||
defaultService.Store(savedDefault)
|
||||
})
|
||||
defaultService.Store(nil)
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
assert.Equal(t, ModeNormal, svc.Mode())
|
||||
assert.Equal(t, "en", svc.Language())
|
||||
assert.Equal(t, "en", svc.Fallback())
|
||||
assert.Equal(t, FormalityNeutral, svc.Formality())
|
||||
assert.Equal(t, "", svc.Location())
|
||||
assert.False(t, svc.Debug())
|
||||
assert.Equal(t, DirLTR, svc.Direction())
|
||||
assert.False(t, svc.IsRTL())
|
||||
assert.Equal(t, PluralOther, svc.PluralCategory(3))
|
||||
assert.Empty(t, svc.AvailableLanguages())
|
||||
assert.Empty(t, svc.Handlers())
|
||||
assert.Equal(t, "prompt.confirm", svc.Prompt("confirm"))
|
||||
assert.Equal(t, "lang.fr", svc.Lang("fr"))
|
||||
assert.Equal(t, "hello", svc.T("hello"))
|
||||
assert.Equal(t, "hello", svc.Raw("hello"))
|
||||
assert.Equal(t, core.Result{Value: "hello", OK: false}, svc.Translate("hello"))
|
||||
assert.Equal(t, defaultServiceStateSnapshot(), svc.State())
|
||||
assert.Equal(t, defaultServiceStateSnapshot(), svc.CurrentState())
|
||||
assert.Equal(t, defaultServiceStateSnapshot().String(), svc.String())
|
||||
})
|
||||
assert.Nil(t, defaultService.Load())
|
||||
|
||||
assert.Equal(t, core.Result{OK: true}, svc.OnStartup(nil))
|
||||
assert.Equal(t, core.Result{OK: true}, svc.OnShutdown(nil))
|
||||
svc.SetMode(ModeCollect)
|
||||
svc.SetFallback("fr")
|
||||
svc.SetFormality(FormalityFormal)
|
||||
svc.SetLocation("workspace")
|
||||
svc.SetDebug(true)
|
||||
svc.SetHandlers(nil)
|
||||
svc.AddHandler(nil)
|
||||
svc.PrependHandler(nil)
|
||||
svc.ClearHandlers()
|
||||
svc.ResetHandlers()
|
||||
svc.AddMessages("en", nil)
|
||||
|
||||
require.ErrorIs(t, svc.SetLanguage("fr"), ErrServiceNotInitialised)
|
||||
require.ErrorIs(t, svc.AddLoader(nil), ErrServiceNotInitialised)
|
||||
require.ErrorIs(t, svc.LoadFS(nil, "locales"), ErrServiceNotInitialised)
|
||||
}
|
||||
|
||||
func TestCoreServiceMissingKeysReturnsCopies(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
coreSvc := &CoreService{svc: svc}
|
||||
|
||||
coreSvc.SetMode(ModeCollect)
|
||||
_ = svc.T("missing.copy.key", map[string]any{"foo": "bar"})
|
||||
|
||||
missing := coreSvc.MissingKeys()
|
||||
require.Len(t, missing, 1)
|
||||
require.Equal(t, "bar", missing[0].Args["foo"])
|
||||
|
||||
missing[0].Args["foo"] = "mutated"
|
||||
|
||||
again := coreSvc.MissingKeys()
|
||||
require.Len(t, again, 1)
|
||||
assert.Equal(t, "bar", again[0].Args["foo"])
|
||||
}
|
||||
|
||||
func TestServiceOptionsAndFSSourceString(t *testing.T) {
|
||||
opts := ServiceOptions{
|
||||
Language: "en-GB",
|
||||
Fallback: "en",
|
||||
Formality: FormalityFormal,
|
||||
Location: "workspace",
|
||||
Mode: ModeCollect,
|
||||
Debug: true,
|
||||
ExtraFS: []FSSource{
|
||||
{FS: fstest.MapFS{}, Dir: "locales"},
|
||||
},
|
||||
}
|
||||
|
||||
got := opts.String()
|
||||
assert.Contains(t, got, `language="en-GB"`)
|
||||
assert.Contains(t, got, `fallback="en"`)
|
||||
assert.Contains(t, got, `formality=formal`)
|
||||
assert.Contains(t, got, `location="workspace"`)
|
||||
assert.Contains(t, got, `mode=collect`)
|
||||
assert.Contains(t, got, `debug=true`)
|
||||
assert.Contains(t, got, `FSSource{fs=fstest.MapFS dir="locales"}`)
|
||||
|
||||
src := FSSource{FS: fstest.MapFS{}, Dir: "translations"}
|
||||
assert.Equal(t, `FSSource{fs=fstest.MapFS dir="translations"}`, src.String())
|
||||
}
|
||||
14
debug.go
14
debug.go
|
|
@ -1,30 +1,24 @@
|
|||
package i18n
|
||||
|
||||
import "dappco.re/go/core"
|
||||
|
||||
// SetDebug enables or disables debug mode on the default service.
|
||||
func SetDebug(enabled bool) {
|
||||
withDefaultService(func(svc *Service) { svc.SetDebug(enabled) })
|
||||
if svc := Default(); svc != nil {
|
||||
svc.SetDebug(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SetDebug(enabled bool) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.debug = enabled
|
||||
}
|
||||
|
||||
func (s *Service) Debug() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.debug
|
||||
}
|
||||
|
||||
func debugFormat(key, text string) string {
|
||||
return core.Sprintf("[%s] %s", key, text)
|
||||
return "[" + key + "] " + text
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,20 +33,6 @@ func TestSetDebug_Good_ServiceLevel(t *testing.T) {
|
|||
assert.False(t, svc.Debug())
|
||||
}
|
||||
|
||||
func TestCurrentDebug_Good_PackageLevel(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
SetDebug(true)
|
||||
assert.True(t, CurrentDebug())
|
||||
|
||||
SetDebug(false)
|
||||
assert.False(t, CurrentDebug())
|
||||
}
|
||||
|
||||
func TestDebugFormat_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -83,20 +69,3 @@ func TestDebugMode_Good_Integration(t *testing.T) {
|
|||
got = svc.Raw("prompt.yes")
|
||||
assert.Equal(t, "[prompt.yes] y", got)
|
||||
}
|
||||
|
||||
func TestTranslate_DebugMode_PreservesOK(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
svc.SetDebug(true)
|
||||
defer svc.SetDebug(false)
|
||||
|
||||
translated := svc.Translate("prompt.yes")
|
||||
assert.True(t, translated.OK)
|
||||
assert.Equal(t, "[prompt.yes] y", translated.Value)
|
||||
|
||||
missing := svc.Translate("missing.translation.key")
|
||||
assert.False(t, missing.OK)
|
||||
assert.Equal(t, "[missing.translation.key] missing.translation.key", missing.Value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
package i18n
|
||||
|
||||
// withDefaultService runs fn when the default service is available.
|
||||
func withDefaultService(fn func(*Service)) {
|
||||
if svc := Default(); svc != nil {
|
||||
fn(svc)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultServiceValue returns the value produced by fn when the default
|
||||
// service exists, or fallback otherwise.
|
||||
func defaultServiceValue[T any](fallback T, fn func(*Service) T) T {
|
||||
if svc := Default(); svc != nil {
|
||||
return fn(svc)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// defaultServiceNamespaceValue resolves a namespace key against the default
|
||||
// service when available, or returns the namespace-qualified key otherwise.
|
||||
func defaultServiceNamespaceValue(namespace, key string, lookup func(*Service, string) string) string {
|
||||
return defaultServiceValue(namespaceLookupKey(namespace, key), func(svc *Service) string {
|
||||
return lookup(svc, key)
|
||||
})
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ svc := i18n.Default()
|
|||
|
||||
// Option 2: Explicit creation with options
|
||||
svc, err := i18n.New(
|
||||
i18n.WithLanguage("en-GB"),
|
||||
i18n.WithFallback("en"),
|
||||
i18n.WithDefaultHandlers(),
|
||||
)
|
||||
|
|
@ -31,31 +30,11 @@ svc, err := i18n.NewWithFS(myFS, "locales")
|
|||
|
||||
The service automatically detects the system language from `LANG`, `LC_ALL`, or `LC_MESSAGES` environment variables using BCP 47 tag matching.
|
||||
|
||||
### Current-State Aliases
|
||||
|
||||
The API exposes `Current*` aliases for the most common service getters so call sites can choose between terse and explicit naming without changing behaviour:
|
||||
|
||||
- `CurrentLanguage()` / `CurrentLang()`
|
||||
- `CurrentAvailableLanguages()`
|
||||
- `CurrentMode()`
|
||||
- `CurrentFallback()`
|
||||
- `CurrentFormality()`
|
||||
- `CurrentLocation()`
|
||||
- `CurrentDirection()` / `CurrentTextDirection()`
|
||||
- `CurrentIsRTL()` / `CurrentRTL()`
|
||||
- `CurrentPluralCategory()` / `PluralCategoryOf()`
|
||||
- `CurrentDebug()`
|
||||
- `CurrentHandlers()`
|
||||
- `CurrentPrompt()`
|
||||
- `State()` / `CurrentState()` snapshot of the full service configuration
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| `WithFallback("en")` | Set fallback language for missing translations |
|
||||
| `WithLanguage("fr")` | Set the initial language before the service starts serving |
|
||||
| `WithLocation("workspace")` | Set the default location context |
|
||||
| `WithDefaultHandlers()` | Register the six built-in `i18n.*` namespace handlers |
|
||||
| `WithHandlers(h...)` | Replace handlers entirely |
|
||||
| `WithMode(ModeStrict)` | Panic on missing keys (useful in CI) |
|
||||
|
|
@ -215,7 +194,7 @@ T("i18n.count.person", 3) // "3 people"
|
|||
Produces past-tense completion messages.
|
||||
|
||||
```go
|
||||
T("i18n.done.delete", "config.yaml") // "Config.yaml deleted"
|
||||
T("i18n.done.delete", "config.yaml") // "Config.Yaml deleted"
|
||||
T("i18n.done.push", "commits") // "Commits pushed"
|
||||
T("i18n.done.delete") // "Deleted"
|
||||
```
|
||||
|
|
@ -237,7 +216,7 @@ Locale-aware number formatting.
|
|||
T("i18n.numeric.number", 1234567) // "1,234,567"
|
||||
T("i18n.numeric.decimal", 3.14) // "3.14"
|
||||
T("i18n.numeric.percent", 0.85) // "85%"
|
||||
T("i18n.numeric.bytes", 1536000) // "1.46 MB"
|
||||
T("i18n.numeric.bytes", 1536000) // "1.5 MB"
|
||||
T("i18n.numeric.ordinal", 3) // "3rd"
|
||||
T("i18n.numeric.ago", 5, "minutes") // "5 minutes ago"
|
||||
```
|
||||
|
|
@ -247,7 +226,7 @@ The shorthand `N()` function wraps this namespace:
|
|||
```go
|
||||
i18n.N("number", 1234567) // "1,234,567"
|
||||
i18n.N("percent", 0.85) // "85%"
|
||||
i18n.N("bytes", 1536000) // "1.46 MB"
|
||||
i18n.N("bytes", 1536000) // "1.5 MB"
|
||||
i18n.N("ordinal", 1) // "1st"
|
||||
```
|
||||
|
||||
|
|
@ -359,4 +338,4 @@ All grammar functions are available as Go template functions via `TemplateFuncs(
|
|||
template.New("").Funcs(i18n.TemplateFuncs())
|
||||
```
|
||||
|
||||
Available functions: `title`, `lower`, `upper`, `past`, `gerund`, `plural`, `pluralForm`, `article`, `quote`, `label`, `progress`, `progressSubject`, `actionResult`, `actionFailed`, `timeAgo`, `formatAgo`.
|
||||
Available functions: `title`, `lower`, `upper`, `past`, `gerund`, `plural`, `pluralForm`, `article`, `quote`.
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ The `irregularVerbs` and `irregularNouns` Go maps and the regular morphology rul
|
|||
|
||||
**French reversal**
|
||||
|
||||
French article handling now covers elision (`l'`), plural forms (`les`, `des`), and gendered articles in the reversal tokeniser. The forward composer also uses `by_gender` when available, so the French article path is no longer a known limitation.
|
||||
Elision (`l'`) and plural articles (`les`, `des`) are not handled by the current `Article()` function or the reversal tokeniser. The `by_gender` article map supports gendered articles for composition, but the reversal tokeniser's `MatchArticle()` only checks `IndefiniteDefault`, `IndefiniteVowel`, and `Definite`. French reversal is therefore incomplete.
|
||||
|
||||
**Dual-class expansion candidates not yet measured**
|
||||
|
||||
|
|
@ -227,7 +227,7 @@ Measure imprint drift on the 88K seeds for the 20 candidate words listed above.
|
|||
|
||||
**French reversal**
|
||||
|
||||
If additional French article variants or locale-specific contractions are added later, update both the forward composer and `MatchArticle()` together so composition and reversal stay symmetric.
|
||||
Extend `Article()` to handle elision (`l'` before vowel-initial nouns) and plural forms (`les`, `des`). Update `MatchArticle()` in the reversal tokeniser to recognise the full French article set including gendered and plural variants.
|
||||
|
||||
**88K seed corpus processing**
|
||||
|
||||
|
|
|
|||
|
|
@ -149,27 +149,6 @@ func (l *FallbackLoader) Load(lang string) (map[string]i18n.Message, *i18n.Gramm
|
|||
}
|
||||
```
|
||||
|
||||
### Locale Providers
|
||||
|
||||
Packages that want to contribute more than one locale source can implement `LocaleProvider` and register it once:
|
||||
|
||||
```go
|
||||
type Provider struct{}
|
||||
|
||||
func (Provider) LocaleSources() []i18n.FSSource {
|
||||
return []i18n.FSSource{
|
||||
{FS: embedFS, Dir: "locales"},
|
||||
{FS: sharedFS, Dir: "translations"},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
i18n.RegisterLocaleProvider(Provider{})
|
||||
}
|
||||
```
|
||||
|
||||
This is the preferred path when a package needs to contribute translations to the default service without manually sequencing multiple `RegisterLocales()` calls.
|
||||
|
||||
## Custom Handlers
|
||||
|
||||
Handlers process keys before standard lookup. Use for dynamic patterns.
|
||||
|
|
|
|||
|
|
@ -214,9 +214,8 @@ i18n.Pluralize("file", 5) // "files"
|
|||
i18n.Pluralize("child", 2) // "children" (irregular)
|
||||
|
||||
// Articles
|
||||
i18n.Article("apple") // "an"
|
||||
i18n.ArticlePhrase("apple") // "an apple"
|
||||
i18n.ArticlePhrase("banana") // "a banana"
|
||||
i18n.Article("apple") // "an apple"
|
||||
i18n.Article("banana") // "a banana"
|
||||
|
||||
// Composed messages
|
||||
i18n.Label("status") // "Status:"
|
||||
|
|
@ -391,7 +390,6 @@ Use functional options when creating a service:
|
|||
|
||||
```go
|
||||
svc, err := i18n.New(
|
||||
i18n.WithLanguage("de-DE"), // Initial language
|
||||
i18n.WithFallback("de-DE"), // Fallback language
|
||||
i18n.WithFormality(i18n.FormalityFormal), // Default formality
|
||||
i18n.WithMode(i18n.ModeStrict), // Missing key mode
|
||||
|
|
|
|||
9
go.mod
9
go.mod
|
|
@ -5,12 +5,15 @@ go 1.26.0
|
|||
require golang.org/x/text v0.35.0
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core v0.5.0
|
||||
dappco.re/go/core/log v0.1.0
|
||||
dappco.re/go/core/inference v0.1.4
|
||||
forge.lthn.ai/core/go-inference v0.1.4
|
||||
)
|
||||
|
||||
require github.com/kr/text v0.2.0 // indirect
|
||||
require (
|
||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -1,5 +1,5 @@
|
|||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
|
||||
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
|
||||
|
|
|
|||
844
grammar.go
844
grammar.go
File diff suppressed because it is too large
Load diff
781
grammar_test.go
781
grammar_test.go
|
|
@ -1,21 +1,6 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
type regionFallbackLoader struct{}
|
||||
|
||||
func (regionFallbackLoader) Languages() []string {
|
||||
return []string{"en-GB"}
|
||||
}
|
||||
|
||||
func (regionFallbackLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
|
||||
return map[string]Message{}, nil, nil
|
||||
}
|
||||
import "testing"
|
||||
|
||||
func TestPastTense(t *testing.T) {
|
||||
// Ensure grammar data is loaded from embedded JSON
|
||||
|
|
@ -104,7 +89,6 @@ func TestPastTense(t *testing.T) {
|
|||
{"push", "pushed"},
|
||||
{"pull", "pulled"},
|
||||
{"start", "started"},
|
||||
{"panic", "panicked"},
|
||||
{"copy", "copied"},
|
||||
{"apply", "applied"},
|
||||
|
||||
|
|
@ -171,7 +155,6 @@ func TestGerund(t *testing.T) {
|
|||
{"push", "pushing"},
|
||||
{"pull", "pulling"},
|
||||
{"start", "starting"},
|
||||
{"panic", "panicking"},
|
||||
{"die", "dying"},
|
||||
|
||||
// Edge cases
|
||||
|
|
@ -236,62 +219,6 @@ func TestPluralize(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPluralize_UsesLocaleSingularOverride(t *testing.T) {
|
||||
const lang = "en-x-singular"
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
SetGrammarData(lang, nil)
|
||||
})
|
||||
|
||||
svc, err := NewWithLoader(pluralizeOverrideLoader{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithLoader() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
if err := SetLanguage(lang); err != nil {
|
||||
t.Fatalf("SetLanguage(%s) failed: %v", lang, err)
|
||||
}
|
||||
|
||||
if got, want := Pluralize("person", 1), "human"; got != want {
|
||||
t.Fatalf("Pluralize(%q, 1) = %q, want %q", "person", got, want)
|
||||
}
|
||||
if got, want := Pluralize("Person", 1), "Human"; got != want {
|
||||
t.Fatalf("Pluralize(%q, 1) = %q, want %q", "Person", got, want)
|
||||
}
|
||||
if got, want := Pluralize("person", 2), "people"; got != want {
|
||||
t.Fatalf("Pluralize(%q, 2) = %q, want %q", "person", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralize_PreservesUnicodeCapitalization(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
if got, want := Pluralize("Élément", 1), "Élément"; got != want {
|
||||
t.Fatalf("Pluralize(%q, 1) = %q, want %q", "Élément", got, want)
|
||||
}
|
||||
if got, want := Pluralize("Élément", 2), "Éléments"; got != want {
|
||||
t.Fatalf("Pluralize(%q, 2) = %q, want %q", "Élément", got, want)
|
||||
}
|
||||
if got, want := PluralForm("Élément"), "Éléments"; got != want {
|
||||
t.Fatalf("PluralForm(%q) = %q, want %q", "Élément", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralForm(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
|
|
@ -339,8 +266,6 @@ func TestArticle(t *testing.T) {
|
|||
{"honest", "an"}, // Vowel sound
|
||||
{"university", "a"}, // Consonant sound
|
||||
{"one", "a"}, // Consonant sound
|
||||
{"SSH", "an"}, // Initialism: "ess-ess-aitch"
|
||||
{"URL", "a"}, // Initialism: "you-are-ell"
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
|
|
@ -354,147 +279,6 @@ func TestArticle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestArticleTokenAliases(t *testing.T) {
|
||||
if got, want := ArticleToken("apple"), Article("apple"); got != want {
|
||||
t.Fatalf("ArticleToken(apple) = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := DefiniteToken("apple"), DefiniteArticle("apple"); got != want {
|
||||
t.Fatalf("DefiniteToken(apple) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleFrenchLocale(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"branche", "la"},
|
||||
{"branches", "les"},
|
||||
{"amis", "des"},
|
||||
{"enfant", "l'"},
|
||||
{"fichier", "le"},
|
||||
{"inconnu", "un"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := Article(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("Article(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleFrenchElisionKeepsLeadingConsonant(t *testing.T) {
|
||||
prevData := GetGrammarData("fr")
|
||||
t.Cleanup(func() {
|
||||
SetGrammarData("fr", prevData)
|
||||
})
|
||||
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
SetGrammarData("fr", &GrammarData{
|
||||
Nouns: map[string]NounForms{
|
||||
"amie": {One: "amie", Other: "amies", Gender: "f"},
|
||||
"accord": {One: "accord", Other: "accords", Gender: "d"},
|
||||
"homme": {One: "homme", Other: "hommes", Gender: "m"},
|
||||
"héros": {One: "héros", Other: "héros", Gender: "m"},
|
||||
"idole": {One: "idole", Other: "idoles", Gender: "j"},
|
||||
},
|
||||
Articles: ArticleForms{
|
||||
IndefiniteDefault: "un",
|
||||
IndefiniteVowel: "un",
|
||||
Definite: "le",
|
||||
ByGender: map[string]string{
|
||||
"d": "de",
|
||||
"f": "la",
|
||||
"j": "je",
|
||||
"m": "le",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"homme", "l'"},
|
||||
{"héros", "le"},
|
||||
{"amie", "l'"},
|
||||
{"accord", "d'"},
|
||||
{"idole", "j'"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := Article(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("Article(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
phraseTests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"accord", "d'accord"},
|
||||
{"idole", "j'idole"},
|
||||
}
|
||||
|
||||
for _, tt := range phraseTests {
|
||||
t.Run(tt.word+"_phrase", func(t *testing.T) {
|
||||
got := ArticlePhrase(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("ArticlePhrase(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type pluralizeOverrideLoader struct{}
|
||||
|
||||
func (pluralizeOverrideLoader) Languages() []string {
|
||||
return []string{"en-x-singular"}
|
||||
}
|
||||
|
||||
func (pluralizeOverrideLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
|
||||
grammar := &GrammarData{
|
||||
Nouns: map[string]NounForms{
|
||||
"person": {One: "human", Other: "people"},
|
||||
},
|
||||
}
|
||||
SetGrammarData(lang, grammar)
|
||||
return map[string]Message{}, grammar, nil
|
||||
}
|
||||
|
||||
func TestTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
|
@ -505,7 +289,6 @@ func TestTitle(t *testing.T) {
|
|||
{"", ""},
|
||||
{"HELLO", "HELLO"},
|
||||
{"hello-world", "Hello-World"},
|
||||
{"config.yaml", "Config.yaml"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -522,191 +305,6 @@ func TestQuote(t *testing.T) {
|
|||
if got := Quote("hello"); got != `"hello"` {
|
||||
t.Errorf("Quote(%q) = %q, want %q", "hello", got, `"hello"`)
|
||||
}
|
||||
if got := Quote(`a "quoted" path\name`); got != `"a \"quoted\" path\\name"` {
|
||||
t.Errorf("Quote(%q) = %q, want %q", `a "quoted" path\name`, got, `"a \"quoted\" path\\name"`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseHelpers(t *testing.T) {
|
||||
if got := Lower("HELLO"); got != "hello" {
|
||||
t.Fatalf("Lower(%q) = %q, want %q", "HELLO", got, "hello")
|
||||
}
|
||||
if got := Upper("hello"); got != "HELLO" {
|
||||
t.Fatalf("Upper(%q) = %q, want %q", "hello", got, "HELLO")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticlePhrase(t *testing.T) {
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"file", "a file"},
|
||||
{"error", "an error"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := ArticlePhrase(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("ArticlePhrase(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticlePhrase_RespectsWordMap(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["go_mod"]
|
||||
data.Words["go_mod"] = "go.mod"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["go_mod"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "go_mod")
|
||||
})
|
||||
|
||||
if got, want := ArticlePhrase("go_mod"), "a go.mod"; got != want {
|
||||
t.Fatalf("ArticlePhrase(%q) = %q, want %q", "go_mod", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticlePhrase_UsesRenderedWordForArticleSelection(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["ssh"]
|
||||
data.Words["ssh"] = "SSH"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["ssh"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "ssh")
|
||||
})
|
||||
|
||||
if got, want := ArticlePhrase("ssh"), "an SSH"; got != want {
|
||||
t.Fatalf("ArticlePhrase(%q) = %q, want %q", "ssh", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticlePhraseFrenchLocale(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"branche", "la branche"},
|
||||
{"branches", "les branches"},
|
||||
{"amis", "des amis"},
|
||||
{"enfant", "l'enfant"},
|
||||
{"fichier", "le fichier"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := ArticlePhrase(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("ArticlePhrase(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefiniteArticle(t *testing.T) {
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"file", "the"},
|
||||
{"error", "the"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := DefiniteArticle(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("DefiniteArticle(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefinitePhraseFrenchLocale(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
want string
|
||||
}{
|
||||
{"branche", "la branche"},
|
||||
{"branches", "les branches"},
|
||||
{"amis", "les amis"},
|
||||
{"enfant", "l'enfant"},
|
||||
{"fichier", "le fichier"},
|
||||
{"héros", "le héros"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
got := DefinitePhrase(tt.word)
|
||||
if got != tt.want {
|
||||
t.Errorf("DefinitePhrase(%q) = %q, want %q", tt.word, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabel(t *testing.T) {
|
||||
|
|
@ -734,27 +332,6 @@ func TestLabel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCompositionHelpersTrimWhitespace(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
if got, want := Label(" status "), "Status:"; got != want {
|
||||
t.Fatalf("Label(%q) = %q, want %q", " status ", got, want)
|
||||
}
|
||||
if got, want := Article(" error "), "an"; got != want {
|
||||
t.Fatalf("Article(%q) = %q, want %q", " error ", got, want)
|
||||
}
|
||||
if got, want := ArticlePhrase(" go_mod "), "a go.mod"; got != want {
|
||||
t.Fatalf("ArticlePhrase(%q) = %q, want %q", " go_mod ", got, want)
|
||||
}
|
||||
if got, want := ActionFailed(" delete ", " file "), "Failed to delete file"; got != want {
|
||||
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", " delete ", " file ", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgress(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
|
|
@ -806,10 +383,10 @@ func TestActionResult(t *testing.T) {
|
|||
verb, subject string
|
||||
want string
|
||||
}{
|
||||
{"delete", "config.yaml", "Config.yaml deleted"},
|
||||
{"delete", "config.yaml", "Config.Yaml deleted"},
|
||||
{"build", "project", "Project built"},
|
||||
{"", "file", ""},
|
||||
{"delete", "", "Deleted"},
|
||||
{"delete", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -828,7 +405,6 @@ func TestActionFailed(t *testing.T) {
|
|||
want string
|
||||
}{
|
||||
{"delete", "config.yaml", "Failed to delete config.yaml"},
|
||||
{"Delete", "config.yaml", "Failed to delete config.yaml"},
|
||||
{"push", "commits", "Failed to push commits"},
|
||||
{"push", "", "Failed to push"},
|
||||
{"", "", ""},
|
||||
|
|
@ -844,56 +420,6 @@ func TestActionFailed(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestActionFailed_RespectsWordMap(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["push"]
|
||||
data.Words["push"] = "submit"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["push"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "push")
|
||||
})
|
||||
|
||||
if got, want := ActionFailed("push", "commits"), "Failed to submit commits"; got != want {
|
||||
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", "push", "commits", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionFailedFrenchLocale(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
if got, want := ActionFailed("supprimer", ""), "Impossible de supprimer"; got != want {
|
||||
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", "supprimer", "", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrammarData_Signals(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
|
|
@ -1056,96 +582,9 @@ func TestFrenchGrammarData(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGrammarFallbackToBaseLanguageTag(t *testing.T) {
|
||||
prevDefault := Default()
|
||||
prevGrammar := GetGrammarData("en")
|
||||
t.Cleanup(func() {
|
||||
SetGrammarData("en", prevGrammar)
|
||||
SetDefault(prevDefault)
|
||||
})
|
||||
|
||||
SetGrammarData("en", &GrammarData{
|
||||
Verbs: map[string]VerbForms{
|
||||
"delete": {Past: "deleted", Gerund: "deleting"},
|
||||
},
|
||||
Nouns: map[string]NounForms{
|
||||
"file": {One: "file", Other: "files"},
|
||||
},
|
||||
Articles: ArticleForms{
|
||||
IndefiniteDefault: "a",
|
||||
IndefiniteVowel: "an",
|
||||
Definite: "the",
|
||||
},
|
||||
Punct: PunctuationRules{
|
||||
LabelSuffix: ":",
|
||||
ProgressSuffix: "...",
|
||||
},
|
||||
Words: map[string]string{
|
||||
"status": "Status",
|
||||
},
|
||||
})
|
||||
|
||||
svc, err := NewWithLoader(regionFallbackLoader{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithLoader() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
if err := svc.SetLanguage("en-GB"); err != nil {
|
||||
t.Fatalf("SetLanguage(en-GB) failed: %v", err)
|
||||
}
|
||||
|
||||
if got := PastTense("delete"); got != "deleted" {
|
||||
t.Fatalf("PastTense(delete) = %q, want %q", got, "deleted")
|
||||
}
|
||||
if got := Pluralize("file", 2); got != "files" {
|
||||
t.Fatalf("Pluralize(file, 2) = %q, want %q", got, "files")
|
||||
}
|
||||
if got := Article("apple"); got != "an" {
|
||||
t.Fatalf("Article(apple) = %q, want %q", got, "an")
|
||||
}
|
||||
if got := Label("status"); got != "Status:" {
|
||||
t.Fatalf("Label(status) = %q, want %q", got, "Status:")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateFuncs(t *testing.T) {
|
||||
funcs := TemplateFuncs()
|
||||
expected := []string{
|
||||
"title",
|
||||
"lower",
|
||||
"upper",
|
||||
"n",
|
||||
"number",
|
||||
"int",
|
||||
"decimal",
|
||||
"float",
|
||||
"percent",
|
||||
"pct",
|
||||
"bytes",
|
||||
"size",
|
||||
"ordinal",
|
||||
"ord",
|
||||
"ago",
|
||||
"past",
|
||||
"gerund",
|
||||
"plural",
|
||||
"pluralForm",
|
||||
"article",
|
||||
"articlePhrase",
|
||||
"definiteArticle",
|
||||
"definite",
|
||||
"definitePhrase",
|
||||
"quote",
|
||||
"label",
|
||||
"progress",
|
||||
"progressSubject",
|
||||
"actionResult",
|
||||
"actionFailed",
|
||||
"prompt",
|
||||
"lang",
|
||||
"timeAgo",
|
||||
"formatAgo",
|
||||
}
|
||||
expected := []string{"title", "lower", "upper", "past", "gerund", "plural", "pluralForm", "article", "quote"}
|
||||
for _, name := range expected {
|
||||
if _, ok := funcs[name]; !ok {
|
||||
t.Errorf("TemplateFuncs() missing %q", name)
|
||||
|
|
@ -1153,218 +592,6 @@ func TestTemplateFuncs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTemplateFuncs_Article(t *testing.T) {
|
||||
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(`{{article "apple"}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() failed: %v", err)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := tmpl.Execute(&buf, nil); err != nil {
|
||||
t.Fatalf("Execute() failed: %v", err)
|
||||
}
|
||||
|
||||
if got, want := buf.String(), "an apple"; got != want {
|
||||
t.Fatalf("template article = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
tmpl, err = template.New("").Funcs(TemplateFuncs()).Parse(`{{articleToken "apple"}}|{{articlePhrase "apple"}}|{{definiteToken "apple"}}|{{definitePhrase "apple"}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() alias helpers failed: %v", err)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := tmpl.Execute(&buf, nil); err != nil {
|
||||
t.Fatalf("Execute() alias helpers failed: %v", err)
|
||||
}
|
||||
|
||||
if got, want := buf.String(), "an|an apple|the|the apple"; got != want {
|
||||
t.Fatalf("template article aliases = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
tmpl, err = template.New("").Funcs(TemplateFuncs()).Parse(`{{definiteArticle "apple"}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() definite article helper failed: %v", err)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := tmpl.Execute(&buf, nil); err != nil {
|
||||
t.Fatalf("Execute() definite article helper failed: %v", err)
|
||||
}
|
||||
|
||||
if got, want := buf.String(), "the"; got != want {
|
||||
t.Fatalf("template definite article = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateFuncs_CompositeHelpers(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(
|
||||
`{{label "status"}}|{{progress "build"}}|{{progressSubject "build" "project"}}|{{actionResult "delete" "file"}}|{{actionFailed "delete" "file"}}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() failed: %v", err)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := tmpl.Execute(&buf, nil); err != nil {
|
||||
t.Fatalf("Execute() failed: %v", err)
|
||||
}
|
||||
|
||||
want := "Status:|Building...|Building project...|File deleted|Failed to delete file"
|
||||
if got := buf.String(); got != want {
|
||||
t.Fatalf("template composite helpers = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateFuncs_PromptAndLang(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(
|
||||
`{{prompt "confirm"}}|{{lang "de"}}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() failed: %v", err)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := tmpl.Execute(&buf, nil); err != nil {
|
||||
t.Fatalf("Execute() failed: %v", err)
|
||||
}
|
||||
|
||||
if got, want := buf.String(), "Are you sure?|German"; got != want {
|
||||
t.Fatalf("template prompt/lang = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateFuncs_NumericAlias(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(
|
||||
`{{n "number" 1234567}}|{{n "ago" 3 "hours"}}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() failed: %v", err)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := tmpl.Execute(&buf, nil); err != nil {
|
||||
t.Fatalf("Execute() failed: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if !strings.HasPrefix(got, "1,234,567|3 hours ago") {
|
||||
t.Fatalf("template numeric alias = %q, want prefix %q", got, "1,234,567|3 hours ago")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateFuncs_NumericDirectAliases(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(
|
||||
`{{int 1234567}}|{{float 3.14}}|{{pct 0.85}}|{{size 1536000}}|{{ord 3}}|{{ago 3 "hours"}}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() failed: %v", err)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := tmpl.Execute(&buf, nil); err != nil {
|
||||
t.Fatalf("Execute() failed: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if !strings.HasPrefix(got, "1,234,567|3.14|85%|1.46 MB|3rd|3 hours ago") {
|
||||
t.Fatalf("template direct numeric aliases = %q, want prefix %q", got, "1,234,567|3.14|85%|1.46 MB|3rd|3 hours ago")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateFuncs_TimeHelpers(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(
|
||||
`{{formatAgo 3 "hour"}}|{{timeAgo .}}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() failed: %v", err)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := tmpl.Execute(&buf, time.Now().Add(-5*time.Minute)); err != nil {
|
||||
t.Fatalf("Execute() failed: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if !strings.HasPrefix(got, "3 hours ago|") {
|
||||
t.Fatalf("template time helpers prefix = %q, want %q", got, "3 hours ago|")
|
||||
}
|
||||
if !strings.Contains(got, "minutes ago") && !strings.Contains(got, "just now") {
|
||||
t.Fatalf("template time helpers suffix = %q, want relative time output", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeHelpersRespectWordMap(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["go_mod"]
|
||||
data.Words["go_mod"] = "go.mod"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["go_mod"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "go_mod")
|
||||
})
|
||||
|
||||
if got, want := Label("go_mod"), "go.mod:"; got != want {
|
||||
t.Fatalf("Label(%q) = %q, want %q", "go_mod", got, want)
|
||||
}
|
||||
if got, want := ProgressSubject("build", "go_mod"), "Building go.mod..."; got != want {
|
||||
t.Fatalf("ProgressSubject(%q, %q) = %q, want %q", "build", "go_mod", got, want)
|
||||
}
|
||||
if got, want := ProgressSubject("build", ""), "Building..."; got != want {
|
||||
t.Fatalf("ProgressSubject(%q, %q) = %q, want %q", "build", "", got, want)
|
||||
}
|
||||
if got, want := ActionResult("delete", "go_mod"), "go.mod deleted"; got != want {
|
||||
t.Fatalf("ActionResult(%q, %q) = %q, want %q", "delete", "go_mod", got, want)
|
||||
}
|
||||
if got, want := ActionResult("delete", ""), "Deleted"; got != want {
|
||||
t.Fatalf("ActionResult(%q, %q) = %q, want %q", "delete", "", got, want)
|
||||
}
|
||||
if got, want := ActionFailed("delete", "go_mod"), "Failed to delete go.mod"; got != want {
|
||||
t.Fatalf("ActionFailed(%q, %q) = %q, want %q", "delete", "go_mod", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Benchmarks ---
|
||||
|
||||
func BenchmarkPastTense_Irregular(b *testing.B) {
|
||||
|
|
|
|||
245
handler.go
245
handler.go
|
|
@ -2,142 +2,100 @@ package i18n
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"unicode"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LabelHandler handles i18n.label.{word} -> "Status:" patterns.
|
||||
type LabelHandler struct{}
|
||||
|
||||
func (h LabelHandler) Match(key string) bool {
|
||||
return core.HasPrefix(key, "i18n.label.")
|
||||
return strings.HasPrefix(key, "i18n.label.")
|
||||
}
|
||||
|
||||
func (h LabelHandler) Handle(key string, args []any, next func() string) string {
|
||||
word := core.TrimPrefix(key, "i18n.label.")
|
||||
if got := Label(word); got != "" {
|
||||
return got
|
||||
}
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
word := strings.TrimPrefix(key, "i18n.label.")
|
||||
return Label(word)
|
||||
}
|
||||
|
||||
// ProgressHandler handles i18n.progress.{verb} -> "Building..." patterns.
|
||||
type ProgressHandler struct{}
|
||||
|
||||
func (h ProgressHandler) Match(key string) bool {
|
||||
return core.HasPrefix(key, "i18n.progress.")
|
||||
return strings.HasPrefix(key, "i18n.progress.")
|
||||
}
|
||||
|
||||
func (h ProgressHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := core.TrimPrefix(key, "i18n.progress.")
|
||||
verb := strings.TrimPrefix(key, "i18n.progress.")
|
||||
if len(args) > 0 {
|
||||
if subj := subjectArgText(args[0]); subj != "" {
|
||||
if got := ProgressSubject(verb, subj); got != "" {
|
||||
return got
|
||||
}
|
||||
if subj, ok := args[0].(string); ok {
|
||||
return ProgressSubject(verb, subj)
|
||||
}
|
||||
}
|
||||
if got := Progress(verb); got != "" {
|
||||
return got
|
||||
}
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
return Progress(verb)
|
||||
}
|
||||
|
||||
// CountHandler handles i18n.count.{noun} -> "5 files" patterns.
|
||||
type CountHandler struct{}
|
||||
|
||||
func (h CountHandler) Match(key string) bool {
|
||||
return core.HasPrefix(key, "i18n.count.")
|
||||
return strings.HasPrefix(key, "i18n.count.")
|
||||
}
|
||||
|
||||
func (h CountHandler) Handle(key string, args []any, next func() string) string {
|
||||
noun := core.TrimPrefix(key, "i18n.count.")
|
||||
lang := currentLangForGrammar()
|
||||
if core.Trim(noun) == "" {
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
noun := strings.TrimPrefix(key, "i18n.count.")
|
||||
if len(args) > 0 {
|
||||
count := getCount(args[0])
|
||||
return core.Sprintf("%s %s", FormatNumber(int64(count)), countWordForm(lang, noun, count))
|
||||
count := toInt(args[0])
|
||||
return fmt.Sprintf("%d %s", count, Pluralize(noun, count))
|
||||
}
|
||||
return countWordForm(lang, noun, 1)
|
||||
return noun
|
||||
}
|
||||
|
||||
// DoneHandler handles i18n.done.{verb} -> "File deleted" patterns.
|
||||
type DoneHandler struct{}
|
||||
|
||||
func (h DoneHandler) Match(key string) bool {
|
||||
return core.HasPrefix(key, "i18n.done.")
|
||||
return strings.HasPrefix(key, "i18n.done.")
|
||||
}
|
||||
|
||||
func (h DoneHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := core.TrimPrefix(key, "i18n.done.")
|
||||
verb := strings.TrimPrefix(key, "i18n.done.")
|
||||
if len(args) > 0 {
|
||||
if subj := subjectArgText(args[0]); subj != "" {
|
||||
if got := ActionResult(verb, subj); got != "" {
|
||||
return got
|
||||
}
|
||||
if subj, ok := args[0].(string); ok {
|
||||
return ActionResult(verb, subj)
|
||||
}
|
||||
}
|
||||
if got := Title(PastTense(verb)); got != "" {
|
||||
return got
|
||||
}
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
return Title(PastTense(verb))
|
||||
}
|
||||
|
||||
// FailHandler handles i18n.fail.{verb} -> "Failed to delete file" patterns.
|
||||
type FailHandler struct{}
|
||||
|
||||
func (h FailHandler) Match(key string) bool {
|
||||
return core.HasPrefix(key, "i18n.fail.")
|
||||
return strings.HasPrefix(key, "i18n.fail.")
|
||||
}
|
||||
|
||||
func (h FailHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := core.TrimPrefix(key, "i18n.fail.")
|
||||
verb := strings.TrimPrefix(key, "i18n.fail.")
|
||||
if len(args) > 0 {
|
||||
if subj := subjectArgText(args[0]); subj != "" {
|
||||
if got := ActionFailed(verb, subj); got != "" {
|
||||
return got
|
||||
}
|
||||
if subj, ok := args[0].(string); ok {
|
||||
return ActionFailed(verb, subj)
|
||||
}
|
||||
}
|
||||
if got := ActionFailed(verb, ""); got != "" {
|
||||
return got
|
||||
}
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
return ActionFailed(verb, "")
|
||||
}
|
||||
|
||||
// NumericHandler handles i18n.numeric.{format} -> formatted numbers.
|
||||
type NumericHandler struct{}
|
||||
|
||||
func (h NumericHandler) Match(key string) bool {
|
||||
return core.HasPrefix(key, "i18n.numeric.")
|
||||
return strings.HasPrefix(key, "i18n.numeric.")
|
||||
}
|
||||
|
||||
func (h NumericHandler) Handle(key string, args []any, next func() string) string {
|
||||
if len(args) == 0 {
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
return next()
|
||||
}
|
||||
format := core.TrimPrefix(key, "i18n.numeric.")
|
||||
format := strings.TrimPrefix(key, "i18n.numeric.")
|
||||
switch format {
|
||||
case "number", "int":
|
||||
return FormatNumber(toInt64(args[0]))
|
||||
|
|
@ -156,15 +114,10 @@ func (h NumericHandler) Handle(key string, args []any, next func() string) strin
|
|||
}
|
||||
}
|
||||
}
|
||||
if next != nil {
|
||||
return next()
|
||||
}
|
||||
return ""
|
||||
return next()
|
||||
}
|
||||
|
||||
// DefaultHandlers returns the built-in i18n.* namespace handlers.
|
||||
//
|
||||
// handlers := i18n.DefaultHandlers()
|
||||
func DefaultHandlers() []KeyHandler {
|
||||
return []KeyHandler{
|
||||
LabelHandler{},
|
||||
|
|
@ -176,135 +129,9 @@ func DefaultHandlers() []KeyHandler {
|
|||
}
|
||||
}
|
||||
|
||||
func countWordForm(lang, noun string, count int) string {
|
||||
if hasGrammarCountForms(lang, noun) {
|
||||
return Pluralize(noun, count)
|
||||
}
|
||||
|
||||
display := renderWord(lang, noun)
|
||||
if display == "" {
|
||||
return Pluralize(noun, count)
|
||||
}
|
||||
if count == 1 {
|
||||
return display
|
||||
}
|
||||
if !isPluralisableWordDisplay(display) {
|
||||
return display
|
||||
}
|
||||
if isUpperAcronymPlural(display) {
|
||||
return display
|
||||
}
|
||||
return Pluralize(display, count)
|
||||
}
|
||||
|
||||
func hasGrammarCountForms(lang, noun string) bool {
|
||||
data := GetGrammarData(lang)
|
||||
if data == nil || len(data.Nouns) == 0 {
|
||||
return false
|
||||
}
|
||||
forms, ok := data.Nouns[core.Lower(noun)]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return forms.One != "" || forms.Other != ""
|
||||
}
|
||||
|
||||
func isPluralisableWordDisplay(s string) bool {
|
||||
hasLetter := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.IsLetter(r):
|
||||
hasLetter = true
|
||||
case unicode.IsSpace(r):
|
||||
// Multi-word vocabulary entries should stay exact. The count handler
|
||||
// prefixes the quantity, but does not invent a plural form for phrases.
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter
|
||||
}
|
||||
|
||||
func isUpperAcronymPlural(s string) bool {
|
||||
if len(s) < 2 || !core.HasSuffix(s, "s") {
|
||||
return false
|
||||
}
|
||||
hasLetter := false
|
||||
for _, r := range s[:len(s)-1] {
|
||||
if !unicode.IsLetter(r) {
|
||||
continue
|
||||
}
|
||||
hasLetter = true
|
||||
if !unicode.IsUpper(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter
|
||||
}
|
||||
|
||||
func isAllUpper(s string) bool {
|
||||
hasLetter := false
|
||||
for _, r := range s {
|
||||
if !unicode.IsLetter(r) {
|
||||
continue
|
||||
}
|
||||
hasLetter = true
|
||||
if !unicode.IsUpper(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter
|
||||
}
|
||||
|
||||
func subjectArgText(arg any) string {
|
||||
switch v := arg.(type) {
|
||||
case string:
|
||||
return v
|
||||
case *Subject:
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.String()
|
||||
case *TranslationContext:
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if text := core.Trim(v.String()); text != "" {
|
||||
return text
|
||||
}
|
||||
if v.Extra != nil {
|
||||
if text := contextArgText(v.Extra); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
case map[string]any:
|
||||
return contextArgText(v)
|
||||
case map[string]string:
|
||||
return contextArgText(v)
|
||||
case fmt.Stringer:
|
||||
return v.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func contextArgText(values any) string {
|
||||
for _, key := range []string{"Subject", "subject", "Value", "value", "Text", "text", "Context", "context", "Noun", "noun"} {
|
||||
if text, ok := mapValueString(values, key); ok {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RunHandlerChain executes a chain of handlers for a key.
|
||||
func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback func() string) string {
|
||||
for i, h := range handlers {
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
if h.Match(key) {
|
||||
next := func() string {
|
||||
remaining := handlers[i+1:]
|
||||
|
|
@ -319,22 +146,6 @@ func RunHandlerChain(handlers []KeyHandler, key string, args []any, fallback fun
|
|||
return fallback()
|
||||
}
|
||||
|
||||
func filterNilHandlers(handlers []KeyHandler) []KeyHandler {
|
||||
if len(handlers) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]KeyHandler, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
if h != nil {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
var (
|
||||
_ KeyHandler = LabelHandler{}
|
||||
_ KeyHandler = ProgressHandler{}
|
||||
|
|
|
|||
224
handler_test.go
224
handler_test.go
|
|
@ -22,11 +22,6 @@ func TestLabelHandler(t *testing.T) {
|
|||
if got != "Status:" {
|
||||
t.Errorf("LabelHandler.Handle(status) = %q, want %q", got, "Status:")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.label.", nil, func() string { return "fallback" })
|
||||
if got != "fallback" {
|
||||
t.Errorf("LabelHandler.Handle(empty) = %q, want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressHandler(t *testing.T) {
|
||||
|
|
@ -53,31 +48,6 @@ func TestProgressHandler(t *testing.T) {
|
|||
if got != "Building project..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, project) = %q, want %q", got, "Building project...")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.progress.build", []any{S("project", "config.yaml")}, nil)
|
||||
if got != "Building config.yaml..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, Subject) = %q, want %q", got, "Building config.yaml...")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.progress.build", []any{C("project")}, nil)
|
||||
if got != "Building project..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, TranslationContext) = %q, want %q", got, "Building project...")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.progress.build", []any{map[string]any{"Subject": "project"}}, nil)
|
||||
if got != "Building project..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, map[Subject:project]) = %q, want %q", got, "Building project...")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.progress.build", []any{map[string]string{"Subject": "project"}}, nil)
|
||||
if got != "Building project..." {
|
||||
t.Errorf("ProgressHandler.Handle(build, map[string]string[Subject:project]) = %q, want %q", got, "Building project...")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.progress.", nil, func() string { return "fallback" })
|
||||
if got != "fallback" {
|
||||
t.Errorf("ProgressHandler.Handle(empty) = %q, want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler(t *testing.T) {
|
||||
|
|
@ -102,11 +72,7 @@ func TestCountHandler(t *testing.T) {
|
|||
{"i18n.count.file", []any{5}, "5 files"},
|
||||
{"i18n.count.file", []any{0}, "0 files"},
|
||||
{"i18n.count.child", []any{3}, "3 children"},
|
||||
{"i18n.count.url", []any{2}, "2 URLs"},
|
||||
{"i18n.count.api", []any{2}, "2 APIs"},
|
||||
{"i18n.count.cpus", []any{2}, "2 CPUs"},
|
||||
{"i18n.count.file", nil, "file"},
|
||||
{"i18n.count.url", nil, "URL"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -117,31 +83,6 @@ func TestCountHandler(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
got := h.Handle("i18n.count.file", []any{S("file", "config.yaml").Count(3)}, nil)
|
||||
if got != "3 files" {
|
||||
t.Errorf("CountHandler.Handle(file, Subject.Count(3)) = %q, want %q", got, "3 files")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.count.file", []any{map[string]string{"Count": "3"}}, nil)
|
||||
if got != "3 files" {
|
||||
t.Errorf("CountHandler.Handle(file, map[string]string[Count:3]) = %q, want %q", got, "3 files")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.count.file", []any{C("file").Set("Count", 3)}, nil)
|
||||
if got != "3 files" {
|
||||
t.Errorf("CountHandler.Handle(file, TranslationContext.Count=3) = %q, want %q", got, "3 files")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.count.file", []any{C("file")}, nil)
|
||||
if got != "1 file" {
|
||||
t.Errorf("CountHandler.Handle(file, TranslationContext default count) = %q, want %q", got, "1 file")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.count.", nil, func() string { return "fallback" })
|
||||
if got != "fallback" {
|
||||
t.Errorf("CountHandler.Handle(empty) = %q, want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoneHandler(t *testing.T) {
|
||||
|
|
@ -159,28 +100,8 @@ func TestDoneHandler(t *testing.T) {
|
|||
|
||||
// With subject
|
||||
got := h.Handle("i18n.done.delete", []any{"config.yaml"}, nil)
|
||||
if got != "Config.yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, config.yaml) = %q, want %q", got, "Config.yaml deleted")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.done.delete", []any{S("file", "config.yaml")}, nil)
|
||||
if got != "Config.yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, Subject) = %q, want %q", got, "Config.yaml deleted")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.done.delete", []any{C("config.yaml")}, nil)
|
||||
if got != "Config.yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, TranslationContext) = %q, want %q", got, "Config.yaml deleted")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.done.delete", []any{map[string]any{"Subject": "config.yaml"}}, nil)
|
||||
if got != "Config.yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, map[Subject:config.yaml]) = %q, want %q", got, "Config.yaml deleted")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.done.delete", []any{map[string]string{"Subject": "config.yaml"}}, nil)
|
||||
if got != "Config.yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, map[string]string[Subject:config.yaml]) = %q, want %q", got, "Config.yaml deleted")
|
||||
if got != "Config.Yaml deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete, config.yaml) = %q, want %q", got, "Config.Yaml deleted")
|
||||
}
|
||||
|
||||
// Without subject — just past tense
|
||||
|
|
@ -188,11 +109,6 @@ func TestDoneHandler(t *testing.T) {
|
|||
if got != "Deleted" {
|
||||
t.Errorf("DoneHandler.Handle(delete) = %q, want %q", got, "Deleted")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.done.", nil, func() string { return "fallback" })
|
||||
if got != "fallback" {
|
||||
t.Errorf("DoneHandler.Handle(empty) = %q, want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailHandler(t *testing.T) {
|
||||
|
|
@ -207,35 +123,10 @@ func TestFailHandler(t *testing.T) {
|
|||
t.Errorf("FailHandler.Handle(push, commits) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", []any{S("commit", "commits")}, nil)
|
||||
if got != "Failed to push commits" {
|
||||
t.Errorf("FailHandler.Handle(push, Subject) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", []any{C("commits")}, nil)
|
||||
if got != "Failed to push commits" {
|
||||
t.Errorf("FailHandler.Handle(push, TranslationContext) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", []any{map[string]any{"Subject": "commits"}}, nil)
|
||||
if got != "Failed to push commits" {
|
||||
t.Errorf("FailHandler.Handle(push, map[Subject:commits]) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", []any{map[string]string{"Subject": "commits"}}, nil)
|
||||
if got != "Failed to push commits" {
|
||||
t.Errorf("FailHandler.Handle(push, map[string]string[Subject:commits]) = %q, want %q", got, "Failed to push commits")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.push", nil, nil)
|
||||
if got != "Failed to push" {
|
||||
t.Errorf("FailHandler.Handle(push) = %q, want %q", got, "Failed to push")
|
||||
}
|
||||
|
||||
got = h.Handle("i18n.fail.", nil, func() string { return "fallback" })
|
||||
if got != "fallback" {
|
||||
t.Errorf("FailHandler.Handle(empty) = %q, want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericHandler(t *testing.T) {
|
||||
|
|
@ -258,9 +149,7 @@ func TestNumericHandler(t *testing.T) {
|
|||
{"i18n.numeric.ordinal", []any{3}, "3rd"},
|
||||
{"i18n.numeric.ordinal", []any{11}, "11th"},
|
||||
{"i18n.numeric.percent", []any{0.85}, "85%"},
|
||||
{"i18n.numeric.bytes", []any{int64(1536000)}, "1.46 MB"},
|
||||
{"i18n.numeric.number", []any{"1234567"}, "1,234,567"},
|
||||
{"i18n.numeric.ago", []any{5, "minutes"}, "5 minutes ago"},
|
||||
{"i18n.numeric.bytes", []any{int64(1536000)}, "1.5 MB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -277,113 +166,6 @@ func TestNumericHandler(t *testing.T) {
|
|||
if got != "fallback" {
|
||||
t.Errorf("NumericHandler with no args should fallback, got %q", got)
|
||||
}
|
||||
|
||||
// No args and no fallback should not panic.
|
||||
got = h.Handle("i18n.numeric.number", nil, nil)
|
||||
if got != "" {
|
||||
t.Errorf("NumericHandler with no args and no fallback = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler_UsesLocaleNumberFormat(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
h := CountHandler{}
|
||||
got := h.Handle("i18n.count.file", []any{1234}, nil)
|
||||
want := "1 234 files"
|
||||
if got != want {
|
||||
t.Errorf("CountHandler.Handle(locale format) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler_PreservesExactWordDisplay(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["go_mod"]
|
||||
data.Words["go_mod"] = "go.mod"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["go_mod"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "go_mod")
|
||||
})
|
||||
|
||||
h := CountHandler{}
|
||||
got := h.Handle("i18n.count.go_mod", []any{2}, nil)
|
||||
if got != "2 go.mod" {
|
||||
t.Fatalf("CountHandler.Handle(go_mod, 2) = %q, want %q", got, "2 go.mod")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler_PreservesPhraseDisplay(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData(\"en\") returned nil")
|
||||
}
|
||||
original, existed := data.Words["up_to_date"]
|
||||
data.Words["up_to_date"] = "up to date"
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
data.Words["up_to_date"] = original
|
||||
return
|
||||
}
|
||||
delete(data.Words, "up_to_date")
|
||||
})
|
||||
|
||||
h := CountHandler{}
|
||||
got := h.Handle("i18n.count.up_to_date", []any{2}, nil)
|
||||
if got != "2 up to date" {
|
||||
t.Fatalf("CountHandler.Handle(up_to_date, 2) = %q, want %q", got, "2 up to date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountHandler_PluralisesLocaleNounPhrases(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
h := CountHandler{}
|
||||
got := h.Handle("i18n.count.mise à jour", []any{2}, nil)
|
||||
if got != "2 mises à jour" {
|
||||
t.Fatalf("CountHandler.Handle(mise à jour, 2) = %q, want %q", got, "2 mises à jour")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHandlerChain(t *testing.T) {
|
||||
|
|
|
|||
230
hooks.go
230
hooks.go
|
|
@ -2,46 +2,23 @@ package i18n
|
|||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
var missingKeyHandler atomic.Value
|
||||
var missingKeyHandlerMu sync.Mutex
|
||||
|
||||
type missingKeyHandlersState struct {
|
||||
handlers []MissingKeyHandler
|
||||
}
|
||||
|
||||
type localeRegistration struct {
|
||||
fsys fs.FS
|
||||
dir string
|
||||
id int
|
||||
}
|
||||
|
||||
type localeProviderRegistration struct {
|
||||
provider LocaleProvider
|
||||
id int
|
||||
}
|
||||
|
||||
// LocaleProvider supplies one or more locale filesystems to the default service.
|
||||
//
|
||||
// i18n.RegisterLocaleProvider(myProvider)
|
||||
type LocaleProvider interface {
|
||||
LocaleSources() []FSSource
|
||||
}
|
||||
|
||||
var (
|
||||
registeredLocales []localeRegistration
|
||||
registeredLocaleProviders []localeProviderRegistration
|
||||
registeredLocalesMu sync.Mutex
|
||||
localesLoaded bool
|
||||
nextLocaleRegistrationID int
|
||||
nextLocaleProviderID int
|
||||
registeredLocales []localeRegistration
|
||||
registeredLocalesMu sync.Mutex
|
||||
localesLoaded bool
|
||||
)
|
||||
|
||||
// RegisterLocales registers a filesystem containing locale files.
|
||||
|
|
@ -54,202 +31,47 @@ var (
|
|||
// i18n.RegisterLocales(localeFS, "locales")
|
||||
// }
|
||||
func RegisterLocales(fsys fs.FS, dir string) {
|
||||
reg := localeRegistration{fsys: fsys, dir: dir}
|
||||
registeredLocalesMu.Lock()
|
||||
nextLocaleRegistrationID++
|
||||
reg.id = nextLocaleRegistrationID
|
||||
registeredLocales = append(registeredLocales, reg)
|
||||
svc := defaultService.Load()
|
||||
registeredLocalesMu.Unlock()
|
||||
if svc != nil {
|
||||
if err := svc.LoadFS(fsys, dir); err != nil {
|
||||
log.Error("i18n: RegisterLocales failed to load", "dir", dir, "err", err)
|
||||
} else {
|
||||
svc.markLocaleRegistrationLoaded(reg.id)
|
||||
markLocalesLoaded()
|
||||
defer registeredLocalesMu.Unlock()
|
||||
registeredLocales = append(registeredLocales, localeRegistration{fsys: fsys, dir: dir})
|
||||
if localesLoaded {
|
||||
if svc := Default(); svc != nil {
|
||||
if err := svc.LoadFS(fsys, dir); err != nil {
|
||||
log.Printf("i18n: RegisterLocales failed to load %q: %v", dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterLocaleProvider registers a provider that can contribute locale files.
|
||||
// This is useful for packages that need to expose multiple locale sources as a
|
||||
// single unit.
|
||||
//
|
||||
// i18n.RegisterLocaleProvider(myProvider)
|
||||
func RegisterLocaleProvider(provider LocaleProvider) {
|
||||
if provider == nil {
|
||||
return
|
||||
}
|
||||
reg := localeProviderRegistration{provider: provider}
|
||||
registeredLocalesMu.Lock()
|
||||
nextLocaleProviderID++
|
||||
reg.id = nextLocaleProviderID
|
||||
registeredLocaleProviders = append(registeredLocaleProviders, reg)
|
||||
svc := defaultService.Load()
|
||||
registeredLocalesMu.Unlock()
|
||||
if svc != nil {
|
||||
loadLocaleProvider(svc, reg)
|
||||
}
|
||||
}
|
||||
|
||||
func loadRegisteredLocales(svc *Service) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
registeredLocalesMu.Lock()
|
||||
locales := append([]localeRegistration(nil), registeredLocales...)
|
||||
providers := append([]localeProviderRegistration(nil), registeredLocaleProviders...)
|
||||
registeredLocalesMu.Unlock()
|
||||
|
||||
for _, reg := range locales {
|
||||
if svc != nil && svc.hasLocaleRegistrationLoaded(reg.id) {
|
||||
continue
|
||||
}
|
||||
defer registeredLocalesMu.Unlock()
|
||||
for _, reg := range registeredLocales {
|
||||
if err := svc.LoadFS(reg.fsys, reg.dir); err != nil {
|
||||
log.Error("i18n: loadRegisteredLocales failed to load", "dir", reg.dir, "err", err)
|
||||
continue
|
||||
}
|
||||
svc.markLocaleRegistrationLoaded(reg.id)
|
||||
}
|
||||
for _, provider := range providers {
|
||||
if svc != nil && svc.hasLocaleProviderLoaded(provider.id) {
|
||||
continue
|
||||
}
|
||||
loadLocaleProvider(svc, provider)
|
||||
}
|
||||
|
||||
markLocalesLoaded()
|
||||
}
|
||||
|
||||
func loadLocaleProvider(svc *Service, provider localeProviderRegistration) {
|
||||
if svc == nil || provider.provider == nil {
|
||||
return
|
||||
}
|
||||
for _, src := range provider.provider.LocaleSources() {
|
||||
if err := svc.LoadFS(src.FS, src.Dir); err != nil {
|
||||
log.Error("i18n: loadLocaleProvider failed to load", "dir", src.Dir, "err", err)
|
||||
log.Printf("i18n: loadRegisteredLocales failed to load %q: %v", reg.dir, err)
|
||||
}
|
||||
}
|
||||
svc.markLocaleProviderLoaded(provider.id)
|
||||
markLocalesLoaded()
|
||||
}
|
||||
|
||||
func markLocalesLoaded() {
|
||||
registeredLocalesMu.Lock()
|
||||
localesLoaded = true
|
||||
registeredLocalesMu.Unlock()
|
||||
}
|
||||
|
||||
// OnMissingKey registers a handler for missing translation keys.
|
||||
func OnMissingKey(h MissingKeyHandler) {
|
||||
if h == nil {
|
||||
ClearMissingKeyHandlers()
|
||||
return
|
||||
}
|
||||
AddMissingKeyHandler(h)
|
||||
}
|
||||
|
||||
// SetMissingKeyHandlers replaces the full missing-key handler chain.
|
||||
func SetMissingKeyHandlers(handlers ...MissingKeyHandler) {
|
||||
missingKeyHandlerMu.Lock()
|
||||
defer missingKeyHandlerMu.Unlock()
|
||||
handlers = filterNilMissingKeyHandlers(handlers)
|
||||
if len(handlers) == 0 {
|
||||
missingKeyHandler.Store(missingKeyHandlersState{})
|
||||
return
|
||||
}
|
||||
missingKeyHandler.Store(missingKeyHandlersState{handlers: handlers})
|
||||
}
|
||||
|
||||
// ClearMissingKeyHandlers removes all registered missing-key handlers.
|
||||
func ClearMissingKeyHandlers() {
|
||||
missingKeyHandlerMu.Lock()
|
||||
defer missingKeyHandlerMu.Unlock()
|
||||
missingKeyHandler.Store(missingKeyHandlersState{})
|
||||
}
|
||||
|
||||
// AddMissingKeyHandler appends a missing-key handler without replacing any
|
||||
// existing handlers.
|
||||
func AddMissingKeyHandler(h MissingKeyHandler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
missingKeyHandlerMu.Lock()
|
||||
defer missingKeyHandlerMu.Unlock()
|
||||
current := missingKeyHandlers()
|
||||
current.handlers = append(current.handlers, h)
|
||||
missingKeyHandler.Store(current)
|
||||
}
|
||||
|
||||
func filterNilMissingKeyHandlers(handlers []MissingKeyHandler) []MissingKeyHandler {
|
||||
if len(handlers) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([]MissingKeyHandler, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
if h != nil {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func missingKeyHandlers() missingKeyHandlersState {
|
||||
v := missingKeyHandler.Load()
|
||||
if v == nil {
|
||||
return missingKeyHandlersState{}
|
||||
}
|
||||
state, ok := v.(missingKeyHandlersState)
|
||||
if !ok {
|
||||
return missingKeyHandlersState{}
|
||||
}
|
||||
return state
|
||||
missingKeyHandler.Store(h)
|
||||
}
|
||||
|
||||
func dispatchMissingKey(key string, args map[string]any) {
|
||||
state := missingKeyHandlers()
|
||||
if len(state.handlers) == 0 {
|
||||
v := missingKeyHandler.Load()
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
file, line := missingKeyCaller()
|
||||
mk := cloneMissingKey(MissingKey{Key: key, Args: args, CallerFile: file, CallerLine: line})
|
||||
for _, h := range state.handlers {
|
||||
if h != nil {
|
||||
h(mk)
|
||||
}
|
||||
h, ok := v.(MissingKeyHandler)
|
||||
if !ok || h == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMissingKey(mk MissingKey) MissingKey {
|
||||
if len(mk.Args) == 0 {
|
||||
mk.Args = nil
|
||||
return mk
|
||||
}
|
||||
args := make(map[string]any, len(mk.Args))
|
||||
for key, value := range mk.Args {
|
||||
args[key] = value
|
||||
}
|
||||
mk.Args = args
|
||||
return mk
|
||||
}
|
||||
|
||||
func missingKeyCaller() (string, int) {
|
||||
const packagePrefix = "dappco.re/go/core/i18n."
|
||||
|
||||
pcs := make([]uintptr, 16)
|
||||
n := runtime.Callers(2, pcs)
|
||||
frames := runtime.CallersFrames(pcs[:n])
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
if !core.HasPrefix(frame.Function, packagePrefix) || core.HasSuffix(frame.File, "_test.go") {
|
||||
return frame.File, frame.Line
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
return "unknown", 0
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
if !ok {
|
||||
file = "unknown"
|
||||
line = 0
|
||||
}
|
||||
h(MissingKey{Key: key, Args: args, CallerFile: file, CallerLine: line})
|
||||
}
|
||||
|
|
|
|||
692
hooks_test.go
692
hooks_test.go
|
|
@ -1,8 +1,6 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
|
|
@ -10,28 +8,17 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testLocaleProvider struct {
|
||||
sources []FSSource
|
||||
}
|
||||
|
||||
func (p testLocaleProvider) LocaleSources() []FSSource {
|
||||
return p.sources
|
||||
}
|
||||
|
||||
func TestRegisterLocales_Good(t *testing.T) {
|
||||
// Save and restore registered locales state
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
|
@ -50,41 +37,6 @@ func TestRegisterLocales_Good(t *testing.T) {
|
|||
assert.Equal(t, 1, count, "should have 1 registered locale")
|
||||
}
|
||||
|
||||
func TestRegisterLocaleProvider_Good(t *testing.T) {
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"provider.loaded": "loaded from provider"}`),
|
||||
},
|
||||
}
|
||||
|
||||
RegisterLocaleProvider(testLocaleProvider{
|
||||
sources: []FSSource{{FS: fs, Dir: "locales"}},
|
||||
})
|
||||
|
||||
got := svc.T("provider.loaded")
|
||||
assert.Equal(t, "loaded from provider", got)
|
||||
}
|
||||
|
||||
func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
|
||||
// When localesLoaded is true, RegisterLocales should also call LoadFS immediately
|
||||
svc, err := New()
|
||||
|
|
@ -95,16 +47,13 @@ func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
|
|||
// Save and restore state
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = true // Simulate already loaded
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
|
@ -123,363 +72,6 @@ func TestRegisterLocales_Good_AfterLocalesLoaded(t *testing.T) {
|
|||
assert.Equal(t, "arrived late", got)
|
||||
}
|
||||
|
||||
func TestRegisterLocales_Good_WithInitializedDefaultService(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"eager.registration": "loaded immediately"}`),
|
||||
},
|
||||
}
|
||||
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
got := svc.T("eager.registration")
|
||||
assert.Equal(t, "loaded immediately", got)
|
||||
}
|
||||
|
||||
func TestSetDefault_Good_LoadsQueuedRegisteredLocales(t *testing.T) {
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"queued.registration": "loaded via setdefault"}`),
|
||||
},
|
||||
}
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
got := svc.T("queued.registration")
|
||||
assert.Equal(t, "loaded via setdefault", got)
|
||||
}
|
||||
|
||||
func TestSetDefault_Good_LoadsRegisteredLocalesIntoFreshService(t *testing.T) {
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"fresh.registration": "fresh value"}`),
|
||||
},
|
||||
}
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
first, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(first)
|
||||
require.Equal(t, "fresh value", first.T("fresh.registration"))
|
||||
|
||||
second, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(second)
|
||||
|
||||
got := second.T("fresh.registration")
|
||||
assert.Equal(t, "fresh value", got)
|
||||
}
|
||||
|
||||
func TestInit_LoadsRegisteredLocales(t *testing.T) {
|
||||
// Save and restore global service state.
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
|
||||
defaultService.Store(nil)
|
||||
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
defaultService.Store(nil)
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"init.registered": "loaded on init"}`),
|
||||
},
|
||||
}
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
require.NoError(t, Init())
|
||||
|
||||
svc := Default()
|
||||
require.NotNil(t, svc)
|
||||
|
||||
got := svc.T("init.registered")
|
||||
assert.Equal(t, "loaded on init", got)
|
||||
}
|
||||
|
||||
func TestNewCoreService_LoadsRegisteredLocales(t *testing.T) {
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
|
||||
prev := defaultService.Load()
|
||||
SetDefault(nil)
|
||||
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
SetDefault(prev)
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"core.registered": "loaded on core bootstrap"}`),
|
||||
},
|
||||
}
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
factory := NewCoreService(ServiceOptions{})
|
||||
_, err := factory(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := Default()
|
||||
require.NotNil(t, svc)
|
||||
got := svc.T("core.registered")
|
||||
assert.Equal(t, "loaded on core bootstrap", got)
|
||||
}
|
||||
|
||||
func TestNewCoreService_InvalidLanguagePreservesSetLanguageError(t *testing.T) {
|
||||
factory := NewCoreService(ServiceOptions{Language: "es"})
|
||||
|
||||
_, err := factory(nil)
|
||||
require.Error(t, err)
|
||||
|
||||
msg := err.Error()
|
||||
assert.Contains(t, msg, "unsupported language: es")
|
||||
assert.Contains(t, msg, "available:")
|
||||
assert.NotContains(t, msg, "invalid language")
|
||||
}
|
||||
|
||||
func TestNewCoreService_AppliesOptions(t *testing.T) {
|
||||
prev := Default()
|
||||
SetDefault(nil)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
factory := NewCoreService(ServiceOptions{
|
||||
Language: "en",
|
||||
Fallback: "fr",
|
||||
Formality: FormalityFormal,
|
||||
Location: "workspace",
|
||||
Mode: ModeCollect,
|
||||
Debug: true,
|
||||
})
|
||||
|
||||
_, err := factory(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := Default()
|
||||
require.NotNil(t, svc)
|
||||
assert.Equal(t, "en", svc.Language())
|
||||
assert.Equal(t, "fr", svc.Fallback())
|
||||
assert.Equal(t, FormalityFormal, svc.Formality())
|
||||
assert.Equal(t, "workspace", svc.Location())
|
||||
assert.Equal(t, ModeCollect, svc.Mode())
|
||||
assert.True(t, svc.Debug())
|
||||
}
|
||||
|
||||
func TestCoreService_DelegatesToWrappedService(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
coreSvc := &CoreService{svc: svc}
|
||||
|
||||
assert.Equal(t, svc.T("i18n.label.status"), coreSvc.T("i18n.label.status"))
|
||||
assert.Equal(t, svc.Raw("i18n.label.status"), coreSvc.Raw("i18n.label.status"))
|
||||
assert.Equal(t, svc.Translate("i18n.label.status"), coreSvc.Translate("i18n.label.status"))
|
||||
assert.Equal(t, svc.AvailableLanguages(), coreSvc.AvailableLanguages())
|
||||
assert.Equal(t, svc.AvailableLanguages(), coreSvc.CurrentAvailableLanguages())
|
||||
assert.Equal(t, svc.Direction(), coreSvc.Direction())
|
||||
assert.Equal(t, svc.Direction(), coreSvc.CurrentDirection())
|
||||
assert.Equal(t, svc.Direction(), coreSvc.CurrentTextDirection())
|
||||
assert.Equal(t, svc.IsRTL(), coreSvc.IsRTL())
|
||||
assert.Equal(t, svc.IsRTL(), coreSvc.CurrentIsRTL())
|
||||
assert.Equal(t, svc.IsRTL(), coreSvc.RTL())
|
||||
assert.Equal(t, svc.IsRTL(), coreSvc.CurrentRTL())
|
||||
assert.Equal(t, svc.PluralCategory(2), coreSvc.PluralCategory(2))
|
||||
assert.Equal(t, svc.PluralCategory(2), coreSvc.CurrentPluralCategory(2))
|
||||
assert.Equal(t, svc.PluralCategory(2), coreSvc.PluralCategoryOf(2))
|
||||
assert.Equal(t, svc.Mode(), coreSvc.CurrentMode())
|
||||
assert.Equal(t, svc.Language(), coreSvc.CurrentLanguage())
|
||||
assert.Equal(t, svc.Language(), coreSvc.CurrentLang())
|
||||
assert.Equal(t, svc.Prompt("confirm"), coreSvc.Prompt("confirm"))
|
||||
assert.Equal(t, svc.Prompt("confirm"), coreSvc.CurrentPrompt("confirm"))
|
||||
assert.Equal(t, svc.Lang("fr"), coreSvc.Lang("fr"))
|
||||
assert.Equal(t, svc.Fallback(), coreSvc.CurrentFallback())
|
||||
assert.Equal(t, svc.Formality(), coreSvc.CurrentFormality())
|
||||
assert.Equal(t, svc.Location(), coreSvc.CurrentLocation())
|
||||
assert.Equal(t, svc.Debug(), coreSvc.CurrentDebug())
|
||||
|
||||
require.NoError(t, coreSvc.SetLanguage("en"))
|
||||
assert.Equal(t, "en", coreSvc.Language())
|
||||
|
||||
coreSvc.SetFallback("fr")
|
||||
assert.Equal(t, "fr", coreSvc.Fallback())
|
||||
|
||||
coreSvc.SetFormality(FormalityFormal)
|
||||
assert.Equal(t, FormalityFormal, coreSvc.Formality())
|
||||
|
||||
coreSvc.SetLocation("workspace")
|
||||
assert.Equal(t, "workspace", coreSvc.Location())
|
||||
|
||||
coreSvc.SetDebug(true)
|
||||
assert.True(t, coreSvc.Debug())
|
||||
coreSvc.SetDebug(false)
|
||||
assert.False(t, coreSvc.Debug())
|
||||
|
||||
handlers := coreSvc.Handlers()
|
||||
assert.Equal(t, svc.Handlers(), handlers)
|
||||
assert.Equal(t, svc.Handlers(), coreSvc.CurrentHandlers())
|
||||
|
||||
coreSvc.SetHandlers(LabelHandler{})
|
||||
require.Len(t, coreSvc.Handlers(), 1)
|
||||
assert.IsType(t, LabelHandler{}, coreSvc.Handlers()[0])
|
||||
|
||||
coreSvc.AddHandler(ProgressHandler{})
|
||||
require.Len(t, coreSvc.Handlers(), 2)
|
||||
assert.IsType(t, ProgressHandler{}, coreSvc.Handlers()[1])
|
||||
|
||||
coreSvc.PrependHandler(CountHandler{})
|
||||
require.Len(t, coreSvc.Handlers(), 3)
|
||||
assert.IsType(t, CountHandler{}, coreSvc.Handlers()[0])
|
||||
|
||||
coreSvc.ClearHandlers()
|
||||
assert.Empty(t, coreSvc.Handlers())
|
||||
|
||||
coreSvc.ResetHandlers()
|
||||
require.NotEmpty(t, coreSvc.Handlers())
|
||||
assert.IsType(t, LabelHandler{}, coreSvc.Handlers()[0])
|
||||
|
||||
require.NoError(t, coreSvc.AddLoader(NewFSLoader(fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{Data: []byte(`{"core.service.loaded": "loaded"}`)},
|
||||
}, "locales")))
|
||||
assert.Equal(t, "loaded", coreSvc.T("core.service.loaded"))
|
||||
|
||||
require.NoError(t, coreSvc.LoadFS(fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{Data: []byte(`{"core.service.loaded.fs": "loaded via fs"}`)},
|
||||
}, "locales"))
|
||||
assert.Equal(t, "loaded via fs", coreSvc.T("core.service.loaded.fs"))
|
||||
|
||||
coreSvc.AddMessages("en", map[string]string{
|
||||
"core.service.add.messages": "loaded via add messages",
|
||||
})
|
||||
assert.Equal(t, "loaded via add messages", coreSvc.T("core.service.add.messages"))
|
||||
}
|
||||
|
||||
func TestInit_ReDetectsRegisteredLocales(t *testing.T) {
|
||||
t.Setenv("LANG", "de_DE.UTF-8")
|
||||
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
|
||||
defaultService.Store(nil)
|
||||
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
defaultService.Store(nil)
|
||||
}()
|
||||
|
||||
fs := fstest.MapFS{
|
||||
"locales/de.json": &fstest.MapFile{
|
||||
Data: []byte(`{"hello": "hallo"}`),
|
||||
},
|
||||
}
|
||||
RegisterLocales(fs, "locales")
|
||||
|
||||
require.NoError(t, Init())
|
||||
|
||||
svc := Default()
|
||||
require.NotNil(t, svc)
|
||||
assert.Contains(t, svc.Language(), "de")
|
||||
assert.Equal(t, "hallo", svc.T("hello"))
|
||||
}
|
||||
|
||||
func TestDefault_ReinitialisesAfterClear(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
SetDefault(nil)
|
||||
|
||||
require.NoError(t, Init())
|
||||
|
||||
svc := Default()
|
||||
require.NotNil(t, svc)
|
||||
assert.Equal(t, "y", svc.T("prompt.yes"))
|
||||
}
|
||||
|
||||
func TestLoadRegisteredLocales_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -487,7 +79,6 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
|
|||
// Save and restore state
|
||||
registeredLocalesMu.Lock()
|
||||
savedLocales := registeredLocales
|
||||
savedProviders := registeredLocaleProviders
|
||||
savedLoaded := localesLoaded
|
||||
registeredLocales = []localeRegistration{
|
||||
{
|
||||
|
|
@ -499,13 +90,11 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
|
|||
dir: "loc",
|
||||
},
|
||||
}
|
||||
registeredLocaleProviders = nil
|
||||
localesLoaded = false
|
||||
registeredLocalesMu.Unlock()
|
||||
defer func() {
|
||||
registeredLocalesMu.Lock()
|
||||
registeredLocales = savedLocales
|
||||
registeredLocaleProviders = savedProviders
|
||||
localesLoaded = savedLoaded
|
||||
registeredLocalesMu.Unlock()
|
||||
}()
|
||||
|
|
@ -524,11 +113,6 @@ func TestLoadRegisteredLocales_Good(t *testing.T) {
|
|||
func TestOnMissingKey_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var captured MissingKey
|
||||
|
|
@ -536,285 +120,17 @@ func TestOnMissingKey_Good(t *testing.T) {
|
|||
captured = m
|
||||
})
|
||||
|
||||
_ = T("missing.test.key", map[string]any{"foo": "bar"})
|
||||
_ = svc.T("missing.test.key", map[string]any{"foo": "bar"})
|
||||
|
||||
assert.Equal(t, "missing.test.key", captured.Key)
|
||||
assert.Equal(t, "bar", captured.Args["foo"])
|
||||
assert.Equal(t, "hooks_test.go", filepath.Base(captured.CallerFile))
|
||||
}
|
||||
|
||||
func TestOnMissingKey_Good_AppendsHandlers(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc.SetMode(ModeCollect)
|
||||
ClearMissingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
ClearMissingKeyHandlers()
|
||||
})
|
||||
|
||||
var first, second int
|
||||
OnMissingKey(func(MissingKey) { first++ })
|
||||
OnMissingKey(func(MissingKey) { second++ })
|
||||
|
||||
_ = T("missing.on.handler.append")
|
||||
|
||||
assert.Equal(t, 1, first)
|
||||
assert.Equal(t, 1, second)
|
||||
}
|
||||
|
||||
func TestAddMissingKeyHandler_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
ClearMissingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
ClearMissingKeyHandlers()
|
||||
})
|
||||
|
||||
var first, second int
|
||||
AddMissingKeyHandler(func(MissingKey) {
|
||||
first++
|
||||
})
|
||||
AddMissingKeyHandler(func(MissingKey) {
|
||||
second++
|
||||
})
|
||||
|
||||
_ = T("missing.multiple.handlers")
|
||||
|
||||
assert.Equal(t, 1, first)
|
||||
assert.Equal(t, 1, second)
|
||||
}
|
||||
|
||||
func TestSetMissingKeyHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var first, second int
|
||||
SetMissingKeyHandlers(
|
||||
nil,
|
||||
func(MissingKey) { first++ },
|
||||
func(MissingKey) { second++ },
|
||||
)
|
||||
|
||||
_ = T("missing.set.handlers")
|
||||
|
||||
assert.Equal(t, 1, first)
|
||||
assert.Equal(t, 1, second)
|
||||
assert.Len(t, missingKeyHandlers().handlers, 2)
|
||||
}
|
||||
|
||||
func TestSetMissingKeyHandlers_Good_Clear(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var called int
|
||||
SetMissingKeyHandlers(func(MissingKey) { called++ })
|
||||
SetMissingKeyHandlers(nil)
|
||||
|
||||
_ = T("missing.set.handlers.clear")
|
||||
|
||||
assert.Equal(t, 0, called)
|
||||
assert.Empty(t, missingKeyHandlers().handlers)
|
||||
}
|
||||
|
||||
func TestAddMissingKeyHandler_Good_Concurrent(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
ClearMissingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
ClearMissingKeyHandlers()
|
||||
})
|
||||
|
||||
const handlers = 32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(handlers)
|
||||
for i := 0; i < handlers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
AddMissingKeyHandler(func(MissingKey) {})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
state := missingKeyHandlers()
|
||||
assert.Len(t, state.handlers, handlers)
|
||||
}
|
||||
|
||||
func TestClearMissingKeyHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
prevHandlers := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prevHandlers)
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var called int
|
||||
AddMissingKeyHandler(func(MissingKey) {
|
||||
called++
|
||||
})
|
||||
|
||||
ClearMissingKeyHandlers()
|
||||
|
||||
_ = T("missing.after.clear")
|
||||
|
||||
assert.Equal(t, 0, called)
|
||||
}
|
||||
|
||||
func TestOnMissingKey_Good_SubjectArgs(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var captured MissingKey
|
||||
OnMissingKey(func(m MissingKey) {
|
||||
captured = m
|
||||
})
|
||||
|
||||
_ = T("missing.subject.key", S("file", "config.yaml").Count(3).In("workspace").Formal())
|
||||
|
||||
assert.Equal(t, "missing.subject.key", captured.Key)
|
||||
assert.Equal(t, "config.yaml", captured.Args["Subject"])
|
||||
assert.Equal(t, "file", captured.Args["Noun"])
|
||||
assert.Equal(t, 3, captured.Args["Count"])
|
||||
assert.Equal(t, "workspace", captured.Args["Location"])
|
||||
assert.Equal(t, FormalityFormal, captured.Args["Formality"])
|
||||
}
|
||||
|
||||
func TestOnMissingKey_Good_TranslationContextArgs(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var captured MissingKey
|
||||
OnMissingKey(func(m MissingKey) {
|
||||
captured = m
|
||||
})
|
||||
|
||||
_ = T("missing.context.key", C("navigation").WithGender("feminine").In("workspace").Formal())
|
||||
|
||||
assert.Equal(t, "missing.context.key", captured.Key)
|
||||
assert.Equal(t, "navigation", captured.Args["Context"])
|
||||
assert.Equal(t, "feminine", captured.Args["Gender"])
|
||||
assert.Equal(t, "workspace", captured.Args["Location"])
|
||||
assert.Equal(t, FormalityFormal, captured.Args["Formality"])
|
||||
}
|
||||
|
||||
func TestOnMissingKey_Good_MergesAdditionalArgs(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
svc.SetMode(ModeCollect)
|
||||
|
||||
var captured MissingKey
|
||||
OnMissingKey(func(m MissingKey) {
|
||||
captured = m
|
||||
})
|
||||
|
||||
_ = T("missing.extra.args", S("file", "config.yaml"), map[string]any{"trace": "abc123"})
|
||||
|
||||
assert.Equal(t, "missing.extra.args", captured.Key)
|
||||
assert.Equal(t, "config.yaml", captured.Args["Subject"])
|
||||
assert.Equal(t, "abc123", captured.Args["trace"])
|
||||
assert.NotEmpty(t, captured.CallerFile)
|
||||
}
|
||||
|
||||
func TestDispatchMissingKey_Good_NoHandler(t *testing.T) {
|
||||
// Reset to the empty handler set.
|
||||
OnMissingKey(nil)
|
||||
// Store nil handler (using correct type)
|
||||
missingKeyHandler.Store(MissingKeyHandler(nil))
|
||||
|
||||
// Should not panic when dispatching with nil handler
|
||||
dispatchMissingKey("test.key", nil)
|
||||
}
|
||||
|
||||
func TestCoreServiceSetMode_Good_PreservesMissingKeyHandlers(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
prev := missingKeyHandlers()
|
||||
t.Cleanup(func() {
|
||||
missingKeyHandler.Store(prev)
|
||||
})
|
||||
|
||||
var observed int
|
||||
OnMissingKey(func(MissingKey) {
|
||||
observed++
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
OnMissingKey(nil)
|
||||
})
|
||||
|
||||
coreSvc := &CoreService{svc: svc}
|
||||
coreSvc.SetMode(ModeCollect)
|
||||
|
||||
_ = svc.T("missing.core.service.key")
|
||||
|
||||
if observed != 1 {
|
||||
t.Fatalf("custom missing key handler called %d times, want 1", observed)
|
||||
}
|
||||
|
||||
missing := coreSvc.MissingKeys()
|
||||
if len(missing) != 1 {
|
||||
t.Fatalf("CoreService captured %d missing keys, want 1", len(missing))
|
||||
}
|
||||
if missing[0].Key != "missing.core.service.key" {
|
||||
t.Fatalf("captured missing key = %q, want %q", missing[0].Key, "missing.core.service.key")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
432
i18n.go
432
i18n.go
|
|
@ -2,389 +2,87 @@ package i18n
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"errors"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// T translates a message using the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.T("greeting")
|
||||
func T(messageID string, args ...any) string {
|
||||
return defaultServiceValue(messageID, func(svc *Service) string {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.T(messageID, args...)
|
||||
})
|
||||
}
|
||||
|
||||
// Translate translates a message using the default service and returns a Core result.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// result := i18n.Translate("greeting")
|
||||
func Translate(messageID string, args ...any) core.Result {
|
||||
return defaultServiceValue(core.Result{Value: messageID, OK: false}, func(svc *Service) core.Result {
|
||||
return svc.Translate(messageID, args...)
|
||||
})
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
// Raw translates without i18n.* namespace magic.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.Raw("prompt.yes")
|
||||
func Raw(messageID string, args ...any) string {
|
||||
return defaultServiceValue(messageID, func(svc *Service) string {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Raw(messageID, args...)
|
||||
})
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
// ErrServiceNotInitialised is returned when the service is not initialised.
|
||||
var ErrServiceNotInitialised = core.NewError("i18n: service not initialised")
|
||||
var ErrServiceNotInitialised = errors.New("i18n: service not initialised")
|
||||
|
||||
// ErrServiceNotInitialized is deprecated: use ErrServiceNotInitialised.
|
||||
var ErrServiceNotInitialized = ErrServiceNotInitialised
|
||||
|
||||
// SetLanguage sets the language for the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// _ = i18n.SetLanguage("fr")
|
||||
func SetLanguage(lang string) error {
|
||||
return defaultServiceValue(ErrServiceNotInitialised, func(svc *Service) error {
|
||||
return svc.SetLanguage(lang)
|
||||
})
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
return ErrServiceNotInitialised
|
||||
}
|
||||
return svc.SetLanguage(lang)
|
||||
}
|
||||
|
||||
// CurrentLanguage returns the current language code.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// lang := i18n.CurrentLanguage()
|
||||
func CurrentLanguage() string {
|
||||
return Language()
|
||||
}
|
||||
|
||||
// CurrentLang is a short alias for CurrentLanguage.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// lang := i18n.CurrentLang()
|
||||
func CurrentLang() string {
|
||||
return CurrentLanguage()
|
||||
}
|
||||
|
||||
// Language returns the current language code.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// lang := i18n.Language()
|
||||
func Language() string {
|
||||
return defaultServiceValue("en", func(svc *Service) string {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Language()
|
||||
})
|
||||
}
|
||||
|
||||
// AvailableLanguages returns the loaded language tags on the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// langs := i18n.AvailableLanguages()
|
||||
func AvailableLanguages() []string {
|
||||
return defaultServiceValue([]string{}, func(svc *Service) []string {
|
||||
return svc.AvailableLanguages()
|
||||
})
|
||||
}
|
||||
|
||||
// CurrentAvailableLanguages returns the loaded language tags on the default
|
||||
// service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// langs := i18n.CurrentAvailableLanguages()
|
||||
func CurrentAvailableLanguages() []string {
|
||||
return AvailableLanguages()
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// SetMode sets the translation mode for the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.SetMode(i18n.ModeCollect)
|
||||
func SetMode(m Mode) {
|
||||
withDefaultService(func(svc *Service) { svc.SetMode(m) })
|
||||
}
|
||||
|
||||
// SetFallback sets the fallback language for the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.SetFallback("en")
|
||||
func SetFallback(lang string) {
|
||||
withDefaultService(func(svc *Service) { svc.SetFallback(lang) })
|
||||
}
|
||||
|
||||
// Fallback returns the current fallback language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// fallback := i18n.Fallback()
|
||||
func Fallback() string {
|
||||
return defaultServiceValue("en", func(svc *Service) string {
|
||||
return svc.Fallback()
|
||||
})
|
||||
if svc := Default(); svc != nil {
|
||||
svc.SetMode(m)
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentMode returns the current translation mode.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// mode := i18n.CurrentMode()
|
||||
func CurrentMode() Mode {
|
||||
return defaultServiceValue(ModeNormal, func(svc *Service) Mode { return svc.Mode() })
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Mode()
|
||||
}
|
||||
return ModeNormal
|
||||
}
|
||||
|
||||
// CurrentFallback returns the current fallback language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// fallback := i18n.CurrentFallback()
|
||||
func CurrentFallback() string {
|
||||
return Fallback()
|
||||
}
|
||||
|
||||
// CurrentFormality returns the current default formality.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// formality := i18n.CurrentFormality()
|
||||
func CurrentFormality() Formality {
|
||||
return defaultServiceValue(FormalityNeutral, func(svc *Service) Formality { return svc.Formality() })
|
||||
}
|
||||
|
||||
// CurrentDebug reports whether debug mode is enabled on the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// debug := i18n.CurrentDebug()
|
||||
func CurrentDebug() bool {
|
||||
return Debug()
|
||||
}
|
||||
|
||||
// State returns a copy-safe snapshot of the default service configuration.
|
||||
//
|
||||
// state := i18n.State()
|
||||
func State() ServiceState {
|
||||
return defaultServiceValue(defaultServiceStateSnapshot(), func(svc *Service) ServiceState {
|
||||
return svc.State()
|
||||
})
|
||||
}
|
||||
|
||||
// CurrentState is a more explicit alias for State.
|
||||
//
|
||||
// state := i18n.CurrentState()
|
||||
func CurrentState() ServiceState {
|
||||
return State()
|
||||
}
|
||||
|
||||
// Debug reports whether debug mode is enabled on the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// debug := i18n.Debug()
|
||||
func Debug() bool {
|
||||
return defaultServiceValue(false, func(svc *Service) bool {
|
||||
return svc.Debug()
|
||||
})
|
||||
}
|
||||
|
||||
// N formats a value using the i18n.numeric.* namespace.
|
||||
// N formats a number using the i18n.numeric.* namespace.
|
||||
//
|
||||
// N("number", 1234567) // "1,234,567"
|
||||
// N("percent", 0.85) // "85%"
|
||||
// N("bytes", 1536000) // "1.46 MB"
|
||||
// N("bytes", 1536000) // "1.5 MB"
|
||||
// N("ordinal", 1) // "1st"
|
||||
//
|
||||
// Multi-argument formats such as "ago" also pass through unchanged:
|
||||
//
|
||||
// N("ago", 5, "minutes") // "5 minutes ago"
|
||||
func N(format string, value any, args ...any) string {
|
||||
format = normalizeLookupKey(format)
|
||||
switch format {
|
||||
case "number", "int":
|
||||
return FormatNumber(toInt64(value))
|
||||
case "decimal", "float":
|
||||
return FormatDecimal(toFloat64(value))
|
||||
case "percent", "pct":
|
||||
return FormatPercent(toFloat64(value))
|
||||
case "bytes", "size":
|
||||
return FormatBytes(toInt64(value))
|
||||
case "ordinal", "ord":
|
||||
return FormatOrdinal(toInt(value))
|
||||
case "ago":
|
||||
if len(args) > 0 {
|
||||
if unit, ok := args[0].(string); ok {
|
||||
return FormatAgo(toInt(value), unit)
|
||||
}
|
||||
}
|
||||
func N(format string, value any) string {
|
||||
return T("i18n.numeric."+format, value)
|
||||
}
|
||||
|
||||
// AddHandler appends a handler to the default service's handler chain.
|
||||
func AddHandler(h KeyHandler) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.AddHandler(h)
|
||||
}
|
||||
return T("i18n.numeric."+format, append([]any{value}, args...)...)
|
||||
}
|
||||
|
||||
// Prompt translates a prompt key from the prompt namespace.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.Prompt("confirm")
|
||||
//
|
||||
// Prompt("yes") // "y"
|
||||
// Prompt("confirm") // "Are you sure?"
|
||||
func Prompt(key string) string {
|
||||
return defaultServiceNamespaceValue("prompt", key, func(svc *Service, resolved string) string {
|
||||
return svc.Prompt(resolved)
|
||||
})
|
||||
}
|
||||
|
||||
// CurrentPrompt is a short alias for Prompt.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// prompt := i18n.CurrentPrompt("confirm")
|
||||
func CurrentPrompt(key string) string {
|
||||
return Prompt(key)
|
||||
}
|
||||
|
||||
// Lang translates a language label from the lang namespace.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.Lang("de")
|
||||
//
|
||||
// Lang("de") // "German"
|
||||
func Lang(key string) string {
|
||||
return defaultServiceNamespaceValue("lang", key, func(svc *Service, resolved string) string {
|
||||
return svc.Lang(resolved)
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeLookupKey(key string) string {
|
||||
return core.Lower(core.Trim(key))
|
||||
}
|
||||
|
||||
func namespaceLookupKey(namespace, key string) string {
|
||||
key = normalizeLookupKey(key)
|
||||
namespace = normalizeLookupKey(namespace)
|
||||
if key == "" {
|
||||
return namespace
|
||||
// PrependHandler inserts a handler at the start of the default service's handler chain.
|
||||
func PrependHandler(h KeyHandler) {
|
||||
if svc := Default(); svc != nil {
|
||||
svc.PrependHandler(h)
|
||||
}
|
||||
if namespace != "" && key == namespace {
|
||||
return key
|
||||
}
|
||||
if namespace != "" && core.HasPrefix(key, namespace+".") {
|
||||
return key
|
||||
}
|
||||
if namespace == "" {
|
||||
return key
|
||||
}
|
||||
return namespace + "." + key
|
||||
}
|
||||
|
||||
// AddHandler appends one or more handlers to the default service's handler chain.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.AddHandler(MyHandler{})
|
||||
func AddHandler(handlers ...KeyHandler) {
|
||||
withDefaultService(func(svc *Service) { svc.AddHandler(handlers...) })
|
||||
}
|
||||
|
||||
// SetHandlers replaces the default service's handler chain.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.SetHandlers(i18n.LabelHandler{}, i18n.ProgressHandler{})
|
||||
func SetHandlers(handlers ...KeyHandler) {
|
||||
withDefaultService(func(svc *Service) { svc.SetHandlers(handlers...) })
|
||||
}
|
||||
|
||||
// LoadFS loads additional translations from an fs.FS into the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.LoadFS(os.DirFS("."), "locales")
|
||||
//
|
||||
// Call this from init() in packages that ship their own locale files:
|
||||
//
|
||||
// //go:embed locales/*.json
|
||||
// var localeFS embed.FS
|
||||
//
|
||||
// func init() { i18n.LoadFS(localeFS, "locales") }
|
||||
func LoadFS(fsys fs.FS, dir string) {
|
||||
withDefaultService(func(svc *Service) {
|
||||
if err := svc.AddLoader(NewFSLoader(fsys, dir)); err != nil {
|
||||
log.Error("i18n: LoadFS failed", "dir", dir, "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// AddMessages adds message strings to the default service for a language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.AddMessages("en", map[string]string{"custom.greeting": "Hello!"})
|
||||
func AddMessages(lang string, messages map[string]string) {
|
||||
withDefaultService(func(svc *Service) { svc.AddMessages(lang, messages) })
|
||||
}
|
||||
|
||||
// PrependHandler inserts one or more handlers at the start of the default service's handler chain.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.PrependHandler(MyHandler{})
|
||||
func PrependHandler(handlers ...KeyHandler) {
|
||||
withDefaultService(func(svc *Service) { svc.PrependHandler(handlers...) })
|
||||
}
|
||||
|
||||
// CurrentHandlers returns a copy of the default service's handler chain.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// handlers := i18n.CurrentHandlers()
|
||||
func CurrentHandlers() []KeyHandler {
|
||||
return Handlers()
|
||||
}
|
||||
|
||||
// Handlers returns a copy of the default service's handler chain.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// handlers := i18n.Handlers()
|
||||
func Handlers() []KeyHandler {
|
||||
return defaultServiceValue([]KeyHandler{}, func(svc *Service) []KeyHandler {
|
||||
return svc.Handlers()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearHandlers removes all handlers from the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.ClearHandlers()
|
||||
func ClearHandlers() {
|
||||
withDefaultService(func(svc *Service) { svc.ClearHandlers() })
|
||||
}
|
||||
|
||||
// ResetHandlers restores the built-in default handler chain on the default
|
||||
// service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.ResetHandlers()
|
||||
func ResetHandlers() {
|
||||
withDefaultService(func(svc *Service) { svc.ResetHandlers() })
|
||||
}
|
||||
|
||||
func executeIntentTemplate(tmplStr string, data templateData) string {
|
||||
|
|
@ -411,10 +109,9 @@ func executeIntentTemplate(tmplStr string, data templateData) string {
|
|||
}
|
||||
|
||||
func applyTemplate(text string, data any) string {
|
||||
if !core.Contains(text, "{{") {
|
||||
if !strings.Contains(text, "{{") {
|
||||
return text
|
||||
}
|
||||
data = templateDataForRendering(data)
|
||||
if cached, ok := templateCache.Load(text); ok {
|
||||
var buf bytes.Buffer
|
||||
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
|
||||
|
|
@ -422,7 +119,7 @@ func applyTemplate(text string, data any) string {
|
|||
}
|
||||
return buf.String()
|
||||
}
|
||||
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(text)
|
||||
tmpl, err := template.New("").Parse(text)
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
|
|
@ -433,52 +130,3 @@ func applyTemplate(text string, data any) string {
|
|||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func templateDataForRendering(data any) any {
|
||||
switch v := data.(type) {
|
||||
case *TranslationContext:
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
count, explicit := v.countValue()
|
||||
if !explicit && v.Extra != nil {
|
||||
if c, ok := v.Extra["Count"]; ok {
|
||||
count = toInt(c)
|
||||
} else if c, ok := v.Extra["count"]; ok {
|
||||
count = toInt(c)
|
||||
}
|
||||
}
|
||||
rendered := map[string]any{
|
||||
"Context": v.Context,
|
||||
"Gender": v.Gender,
|
||||
"Location": v.Location,
|
||||
"Formality": v.Formality,
|
||||
"Count": count,
|
||||
"IsPlural": count != 1,
|
||||
"Extra": v.Extra,
|
||||
}
|
||||
for key, value := range v.Extra {
|
||||
if _, exists := rendered[key]; !exists {
|
||||
rendered[key] = value
|
||||
}
|
||||
}
|
||||
return rendered
|
||||
case *Subject:
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"Subject": v.String(),
|
||||
"Noun": v.Noun,
|
||||
"Count": v.count,
|
||||
"Gender": v.gender,
|
||||
"Location": v.location,
|
||||
"Formality": v.formality,
|
||||
"IsFormal": v.formality == FormalityFormal,
|
||||
"IsPlural": v.count != 1,
|
||||
"Value": v.Value,
|
||||
}
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
|
|
|||
519
i18n_test.go
519
i18n_test.go
|
|
@ -2,7 +2,6 @@ package i18n
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -40,48 +39,6 @@ func TestT_Good_MissingKey(t *testing.T) {
|
|||
assert.Equal(t, "nonexistent.key.test", got)
|
||||
}
|
||||
|
||||
// --- Package-level Translate() ---
|
||||
|
||||
func TestTranslate_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
result := Translate("prompt.yes")
|
||||
require.True(t, result.OK)
|
||||
assert.Equal(t, "y", result.Value)
|
||||
}
|
||||
|
||||
func TestTranslate_Good_MissingKey(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
result := Translate("nonexistent.translation.key")
|
||||
require.False(t, result.OK)
|
||||
assert.Equal(t, "nonexistent.translation.key", result.Value)
|
||||
}
|
||||
|
||||
func TestTranslate_Good_SameTextAsKey(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
AddMessages("en", map[string]string{
|
||||
"exact.same.key": "exact.same.key",
|
||||
})
|
||||
|
||||
result := Translate("exact.same.key")
|
||||
require.True(t, result.OK)
|
||||
assert.Equal(t, "exact.same.key", result.Value)
|
||||
}
|
||||
|
||||
// --- Package-level Raw() ---
|
||||
|
||||
func TestRaw_Good(t *testing.T) {
|
||||
|
|
@ -105,44 +62,6 @@ func TestRaw_Good_BypassesHandlers(t *testing.T) {
|
|||
assert.Equal(t, "i18n.label.status", got)
|
||||
}
|
||||
|
||||
func TestLoadFS_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{"loadfs.key": "loaded via package helper"}`),
|
||||
},
|
||||
}
|
||||
|
||||
LoadFS(fsys, "locales")
|
||||
|
||||
got := T("loadfs.key")
|
||||
assert.Equal(t, "loaded via package helper", got)
|
||||
}
|
||||
|
||||
func TestAddMessages_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
AddMessages("en", map[string]string{
|
||||
"add.messages.key": "loaded via package helper",
|
||||
})
|
||||
|
||||
got := T("add.messages.key")
|
||||
assert.Equal(t, "loaded via package helper", got)
|
||||
}
|
||||
|
||||
// --- SetLanguage / CurrentLanguage ---
|
||||
|
||||
func TestSetLanguage_Good(t *testing.T) {
|
||||
|
|
@ -156,27 +75,6 @@ func TestSetLanguage_Good(t *testing.T) {
|
|||
assert.Contains(t, CurrentLanguage(), "en")
|
||||
}
|
||||
|
||||
func TestSetLanguage_Good_UnderscoreTag(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
err = SetLanguage("fr_CA")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, len(CurrentLanguage()) >= 2)
|
||||
assert.Equal(t, "fr", CurrentLanguage()[:2])
|
||||
}
|
||||
|
||||
func TestLanguage_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, CurrentLanguage(), Language())
|
||||
}
|
||||
|
||||
func TestSetLanguage_Bad_Unsupported(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -194,95 +92,6 @@ func TestCurrentLanguage_Good(t *testing.T) {
|
|||
|
||||
lang := CurrentLanguage()
|
||||
assert.NotEmpty(t, lang)
|
||||
assert.Equal(t, lang, CurrentLang())
|
||||
}
|
||||
|
||||
func TestAvailableLanguages_Good(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
langs := AvailableLanguages()
|
||||
require.NotEmpty(t, langs)
|
||||
assert.Equal(t, svc.AvailableLanguages(), langs)
|
||||
|
||||
langs[0] = "zz"
|
||||
assert.NotEqual(t, "zz", svc.AvailableLanguages()[0])
|
||||
}
|
||||
|
||||
func TestCurrentAvailableLanguages_Good(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
langs := CurrentAvailableLanguages()
|
||||
require.NotEmpty(t, langs)
|
||||
assert.Equal(t, svc.AvailableLanguages(), langs)
|
||||
}
|
||||
|
||||
func TestFallback_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, "en", Fallback())
|
||||
|
||||
SetFallback("fr")
|
||||
assert.Equal(t, "fr", Fallback())
|
||||
}
|
||||
|
||||
func TestDebug_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.False(t, Debug())
|
||||
|
||||
SetDebug(true)
|
||||
assert.True(t, Debug())
|
||||
}
|
||||
|
||||
func TestCurrentState_Good(t *testing.T) {
|
||||
svc, err := NewWithLoader(messageBaseFallbackLoader{})
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
state := CurrentState()
|
||||
assert.Equal(t, svc.Language(), state.Language)
|
||||
assert.Equal(t, svc.AvailableLanguages(), state.AvailableLanguages)
|
||||
assert.Equal(t, svc.Mode(), state.Mode)
|
||||
assert.Equal(t, svc.Fallback(), state.Fallback)
|
||||
assert.Equal(t, svc.Formality(), state.Formality)
|
||||
assert.Equal(t, svc.Location(), state.Location)
|
||||
assert.Equal(t, svc.Direction(), state.Direction)
|
||||
assert.Equal(t, svc.IsRTL(), state.IsRTL)
|
||||
assert.Equal(t, svc.Debug(), state.Debug)
|
||||
assert.Len(t, state.Handlers, len(svc.Handlers()))
|
||||
|
||||
state.AvailableLanguages[0] = "zz"
|
||||
assert.NotEqual(t, "zz", CurrentState().AvailableLanguages[0])
|
||||
state.Handlers[0] = nil
|
||||
assert.NotNil(t, CurrentState().Handlers[0])
|
||||
}
|
||||
|
||||
func TestState_Good_WithoutDefaultService(t *testing.T) {
|
||||
var svc *Service
|
||||
state := svc.State()
|
||||
assert.Equal(t, defaultServiceStateSnapshot(), state)
|
||||
}
|
||||
|
||||
// --- SetMode / CurrentMode ---
|
||||
|
|
@ -321,136 +130,21 @@ func TestN_Good(t *testing.T) {
|
|||
name string
|
||||
format string
|
||||
value any
|
||||
args []any
|
||||
want string
|
||||
}{
|
||||
{"number", "number", int64(1234567), nil, "1,234,567"},
|
||||
{"percent", "percent", 0.85, nil, "85%"},
|
||||
{"bytes", "bytes", int64(1536000), nil, "1.46 MB"},
|
||||
{"ordinal", "ordinal", 1, nil, "1st"},
|
||||
{"ago", "ago", 5, []any{"minutes"}, "5 minutes ago"},
|
||||
{"number", "number", int64(1234567), "1,234,567"},
|
||||
{"percent", "percent", 0.85, "85%"},
|
||||
{"bytes", "bytes", int64(1536000), "1.5 MB"},
|
||||
{"ordinal", "ordinal", 1, "1st"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := N(tt.format, tt.value, tt.args...)
|
||||
got := N(tt.format, tt.value)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestN_Good_WithoutDefaultService(t *testing.T) {
|
||||
prev := Default()
|
||||
SetDefault(nil)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
value any
|
||||
args []any
|
||||
want string
|
||||
}{
|
||||
{"number", "number", int64(1234567), nil, "1,234,567"},
|
||||
{"percent", "percent", 0.85, nil, "85%"},
|
||||
{"bytes", "bytes", int64(1536000), nil, "1.46 MB"},
|
||||
{"ordinal", "ordinal", 1, nil, "1st"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := N(tt.format, tt.value, tt.args...)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prompt() prompt shorthand ---
|
||||
|
||||
func TestPrompt_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"yes", "yes", "y"},
|
||||
{"yes_trimmed", " yes ", "y"},
|
||||
{"yes_prefixed", "prompt.yes", "y"},
|
||||
{"confirm", "confirm", "Are you sure?"},
|
||||
{"confirm_prefixed", "prompt.confirm", "Are you sure?"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Prompt(tt.key)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentPrompt_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, Prompt("confirm"), CurrentPrompt("confirm"))
|
||||
}
|
||||
|
||||
// --- Lang() language label shorthand ---
|
||||
|
||||
func TestLang_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"de", "de", "German"},
|
||||
{"fr", "fr", "French"},
|
||||
{"fr_ca", "fr_CA", "French"},
|
||||
{"fr_prefixed", "lang.fr", "French"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Lang(tt.key)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLang_MissingKeyHandler_FiresOnce(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetMissingKeyHandlers()
|
||||
SetMode(ModeNormal)
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
SetMode(ModeCollect)
|
||||
calls := 0
|
||||
SetMissingKeyHandlers(func(MissingKey) {
|
||||
calls++
|
||||
})
|
||||
|
||||
got := Lang("zz")
|
||||
assert.Equal(t, "[lang.zz]", got)
|
||||
assert.Equal(t, 1, calls)
|
||||
}
|
||||
|
||||
// --- AddHandler / PrependHandler ---
|
||||
|
||||
func TestAddHandler_Good(t *testing.T) {
|
||||
|
|
@ -465,46 +159,6 @@ func TestAddHandler_Good(t *testing.T) {
|
|||
assert.Equal(t, initialCount+1, len(svc.Handlers()))
|
||||
}
|
||||
|
||||
func TestAddHandler_Good_Variadic(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
AddHandler(LabelHandler{}, ProgressHandler{})
|
||||
handlers := svc.Handlers()
|
||||
assert.Equal(t, 2, len(handlers))
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
assert.IsType(t, ProgressHandler{}, handlers[1])
|
||||
}
|
||||
|
||||
func TestAddHandler_Good_SkipsNil(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
var nilHandler KeyHandler
|
||||
AddHandler(nilHandler, LabelHandler{})
|
||||
|
||||
handlers := svc.Handlers()
|
||||
require.Len(t, handlers, 1)
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
}
|
||||
|
||||
func TestAddHandler_DoesNotMutateInputSlice(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
handlers := []KeyHandler{nil, LabelHandler{}}
|
||||
AddHandler(handlers...)
|
||||
|
||||
assert.Nil(t, handlers[0])
|
||||
assert.IsType(t, LabelHandler{}, handlers[1])
|
||||
}
|
||||
|
||||
func TestPrependHandler_Good(t *testing.T) {
|
||||
svc, err := New(WithHandlers()) // start with no handlers
|
||||
require.NoError(t, err)
|
||||
|
|
@ -520,132 +174,6 @@ func TestPrependHandler_Good(t *testing.T) {
|
|||
assert.Equal(t, 2, len(handlers))
|
||||
}
|
||||
|
||||
func TestPrependHandler_Good_Variadic(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
PrependHandler(LabelHandler{}, ProgressHandler{})
|
||||
handlers := svc.Handlers()
|
||||
assert.Equal(t, 2, len(handlers))
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
assert.IsType(t, ProgressHandler{}, handlers[1])
|
||||
}
|
||||
|
||||
func TestPrependHandler_Good_SkipsNil(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
var nilHandler KeyHandler
|
||||
PrependHandler(nilHandler, LabelHandler{})
|
||||
|
||||
handlers := svc.Handlers()
|
||||
require.Len(t, handlers, 1)
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
}
|
||||
|
||||
func TestPrependHandler_DoesNotMutateInputSlice(t *testing.T) {
|
||||
svc, err := New(WithHandlers())
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
SetDefault(svc)
|
||||
|
||||
handlers := []KeyHandler{nil, ProgressHandler{}}
|
||||
PrependHandler(handlers...)
|
||||
|
||||
assert.Nil(t, handlers[0])
|
||||
assert.IsType(t, ProgressHandler{}, handlers[1])
|
||||
}
|
||||
|
||||
func TestClearHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
AddHandler(LabelHandler{})
|
||||
require.NotEmpty(t, svc.Handlers())
|
||||
|
||||
ClearHandlers()
|
||||
assert.Empty(t, svc.Handlers())
|
||||
}
|
||||
|
||||
func TestResetHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
ClearHandlers()
|
||||
require.Empty(t, svc.Handlers())
|
||||
|
||||
svc.ResetHandlers()
|
||||
require.Len(t, svc.Handlers(), len(DefaultHandlers()))
|
||||
assert.IsType(t, LabelHandler{}, svc.Handlers()[0])
|
||||
|
||||
ClearHandlers()
|
||||
require.Empty(t, svc.Handlers())
|
||||
|
||||
ResetHandlers()
|
||||
handlers := svc.Handlers()
|
||||
require.Len(t, handlers, len(DefaultHandlers()))
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
assert.Equal(t, "Status:", T("i18n.label.status"))
|
||||
}
|
||||
|
||||
func TestSetHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
_ = Init()
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
SetHandlers(serviceStubHandler{})
|
||||
|
||||
handlers := CurrentHandlers()
|
||||
require.Len(t, handlers, 1)
|
||||
assert.IsType(t, serviceStubHandler{}, handlers[0])
|
||||
assert.Equal(t, "stub", T("custom.stub"))
|
||||
assert.Equal(t, "i18n.label.status", T("i18n.label.status"))
|
||||
}
|
||||
|
||||
func TestHandlers_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
handlers := Handlers()
|
||||
require.Len(t, handlers, len(svc.Handlers()))
|
||||
assert.Equal(t, svc.Handlers(), handlers)
|
||||
}
|
||||
|
||||
func TestNewWithHandlers_SkipsNil(t *testing.T) {
|
||||
svc, err := New(WithHandlers(nil, LabelHandler{}))
|
||||
require.NoError(t, err)
|
||||
|
||||
handlers := svc.Handlers()
|
||||
require.Len(t, handlers, 1)
|
||||
assert.IsType(t, LabelHandler{}, handlers[0])
|
||||
}
|
||||
|
||||
// --- executeIntentTemplate ---
|
||||
|
||||
func TestExecuteIntentTemplate_Good(t *testing.T) {
|
||||
|
|
@ -689,43 +217,6 @@ func TestExecuteIntentTemplate_Good_WithFuncs(t *testing.T) {
|
|||
assert.Equal(t, "built!", got)
|
||||
}
|
||||
|
||||
func TestComposeIntent_Good(t *testing.T) {
|
||||
intent := Intent{
|
||||
Meta: IntentMeta{
|
||||
Type: "action",
|
||||
Verb: "delete",
|
||||
Dangerous: true,
|
||||
Default: "no",
|
||||
Supports: []string{"yes", "no"},
|
||||
},
|
||||
Question: "Delete {{.Subject}}?",
|
||||
Confirm: "Really delete {{article .Subject}}?",
|
||||
Success: "{{title .Subject}} deleted",
|
||||
Failure: "Failed to delete {{lower .Subject}}",
|
||||
}
|
||||
|
||||
got := ComposeIntent(intent, S("file", "config.yaml"))
|
||||
|
||||
assert.Equal(t, "Delete config.yaml?", got.Question)
|
||||
assert.Equal(t, "Really delete a config.yaml?", got.Confirm)
|
||||
assert.Equal(t, "Config.yaml deleted", got.Success)
|
||||
assert.Equal(t, "Failed to delete config.yaml", got.Failure)
|
||||
assert.Equal(t, intent.Meta, got.Meta)
|
||||
}
|
||||
|
||||
func TestIntentCompose_Good_NilSubject(t *testing.T) {
|
||||
intent := Intent{
|
||||
Question: "Proceed?",
|
||||
}
|
||||
|
||||
got := intent.Compose(nil)
|
||||
|
||||
assert.Equal(t, "Proceed?", got.Question)
|
||||
assert.Empty(t, got.Confirm)
|
||||
assert.Empty(t, got.Success)
|
||||
assert.Empty(t, got.Failure)
|
||||
}
|
||||
|
||||
// --- applyTemplate ---
|
||||
|
||||
func TestApplyTemplate_Good(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ package integration
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/go-inference"
|
||||
_ "forge.lthn.ai/core/go-mlx" // registers Metal backend
|
||||
|
|
@ -549,7 +549,7 @@ func TestCalibrateDomains_1Bvs27B(t *testing.T) {
|
|||
disagreements++
|
||||
truth := ""
|
||||
if r.TrueDomain != "" {
|
||||
truth = core.Sprintf(" [truth=%s]", r.TrueDomain)
|
||||
truth = fmt.Sprintf(" [truth=%s]", r.TrueDomain)
|
||||
}
|
||||
t.Logf(" DISAGREE: 1B=%s 27B=%s%s | %.60s", r.DomainA, r.DomainB, truth, r.Text)
|
||||
if disagreements >= 50 {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ package integration
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/go-inference"
|
||||
_ "forge.lthn.ai/core/go-mlx" // registers Metal backend
|
||||
|
|
@ -22,9 +24,9 @@ func TestClassifyCorpus_Integration(t *testing.T) {
|
|||
// Build 50 technical prompts for throughput measurement
|
||||
var lines []string
|
||||
for i := 0; i < 50; i++ {
|
||||
lines = append(lines, core.Sprintf(`{"id":%d,"prompt":"Delete the configuration file and rebuild the project"}`, i))
|
||||
lines = append(lines, fmt.Sprintf(`{"id":%d,"prompt":"Delete the configuration file and rebuild the project"}`, i))
|
||||
}
|
||||
input := core.NewReader(core.Join("\n", lines...) + "\n")
|
||||
input := strings.NewReader(strings.Join(lines, "\n") + "\n")
|
||||
|
||||
var output bytes.Buffer
|
||||
start := time.Now()
|
||||
|
|
@ -56,10 +58,10 @@ func TestClassifyCorpus_Integration(t *testing.T) {
|
|||
}
|
||||
|
||||
// Also inspect the output JSONL for misclassified entries
|
||||
outLines := core.Split(core.Trim(output.String()), "\n")
|
||||
outLines := strings.Split(strings.TrimSpace(output.String()), "\n")
|
||||
for _, line := range outLines {
|
||||
var record map[string]any
|
||||
if r := core.JSONUnmarshal([]byte(line), &record); r.OK {
|
||||
if err := json.Unmarshal([]byte(line), &record); err == nil {
|
||||
if record["domain_1b"] != "technical" {
|
||||
t.Logf(" misclassified: id=%v domain_1b=%v", record["id"], record["domain_1b"])
|
||||
}
|
||||
|
|
|
|||
21
language.go
21
language.go
|
|
@ -2,11 +2,11 @@ package i18n
|
|||
|
||||
// GetPluralRule returns the plural rule for a language code.
|
||||
func GetPluralRule(lang string) PluralRule {
|
||||
lang = normalizeLanguageTag(lang)
|
||||
if rule, ok := pluralRules[lang]; ok {
|
||||
return rule
|
||||
}
|
||||
if base := baseLanguageTag(lang); base != "" {
|
||||
if len(lang) > 2 {
|
||||
base := lang[:2]
|
||||
if rule, ok := pluralRules[base]; ok {
|
||||
return rule
|
||||
}
|
||||
|
|
@ -83,20 +83,3 @@ func pluralRuleArabic(n int) PluralCategory {
|
|||
func pluralRuleChinese(n int) PluralCategory { return PluralOther }
|
||||
func pluralRuleJapanese(n int) PluralCategory { return PluralOther }
|
||||
func pluralRuleKorean(n int) PluralCategory { return PluralOther }
|
||||
|
||||
func pluralRuleWelsh(n int) PluralCategory {
|
||||
switch n {
|
||||
case 0:
|
||||
return PluralZero
|
||||
case 1:
|
||||
return PluralOne
|
||||
case 2:
|
||||
return PluralTwo
|
||||
case 3:
|
||||
return PluralFew
|
||||
case 6:
|
||||
return PluralMany
|
||||
default:
|
||||
return PluralOther
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,11 @@ func TestGetPluralCategory(t *testing.T) {
|
|||
{"en", 0, PluralOther},
|
||||
{"en", 1, PluralOne},
|
||||
{"en", 2, PluralOther},
|
||||
{"en_US", 1, PluralOne},
|
||||
|
||||
// French (0 and 1 are singular)
|
||||
{"fr", 0, PluralOne},
|
||||
{"fr", 1, PluralOne},
|
||||
{"fr", 2, PluralOther},
|
||||
{"fr_CA", 2, PluralOther},
|
||||
|
||||
// Russian
|
||||
{"ru", 1, PluralOne},
|
||||
|
|
@ -41,14 +39,6 @@ func TestGetPluralCategory(t *testing.T) {
|
|||
{"ar", 11, PluralMany},
|
||||
{"ar", 100, PluralOther},
|
||||
|
||||
// Welsh
|
||||
{"cy", 0, PluralZero},
|
||||
{"cy", 1, PluralOne},
|
||||
{"cy", 2, PluralTwo},
|
||||
{"cy", 3, PluralFew},
|
||||
{"cy", 6, PluralMany},
|
||||
{"cy", 7, PluralOther},
|
||||
|
||||
// Chinese (always other)
|
||||
{"zh", 0, PluralOther},
|
||||
{"zh", 1, PluralOther},
|
||||
|
|
@ -85,21 +75,6 @@ func TestGetPluralRule(t *testing.T) {
|
|||
t.Error("English-US rule(1) should be PluralOne")
|
||||
}
|
||||
|
||||
rule = GetPluralRule("fr-Latn-CA")
|
||||
if rule(0) != PluralOne {
|
||||
t.Error("French multi-part tag rule(0) should be PluralOne")
|
||||
}
|
||||
|
||||
rule = GetPluralRule("cy-GB")
|
||||
if rule(2) != PluralTwo {
|
||||
t.Error("Welsh-GB rule(2) should be PluralTwo")
|
||||
}
|
||||
|
||||
rule = GetPluralRule("en_US")
|
||||
if rule(1) != PluralOne {
|
||||
t.Error("English_US rule(1) should be PluralOne")
|
||||
}
|
||||
|
||||
// Unknown falls back to English
|
||||
rule = GetPluralRule("xx-YY")
|
||||
if rule(1) != PluralOne {
|
||||
|
|
|
|||
421
loader.go
421
loader.go
|
|
@ -1,14 +1,12 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"math"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
|
|
@ -29,30 +27,28 @@ func NewFSLoader(fsys fs.FS, dir string) *FSLoader {
|
|||
|
||||
// Load implements Loader.Load.
|
||||
func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
|
||||
variants := localeFilenameCandidates(lang)
|
||||
variants := []string{
|
||||
lang + ".json",
|
||||
strings.ReplaceAll(lang, "-", "_") + ".json",
|
||||
strings.ReplaceAll(lang, "_", "-") + ".json",
|
||||
}
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
var firstNonMissingErr error
|
||||
for _, filename := range variants {
|
||||
filePath := path.Join(l.dir, filename)
|
||||
data, err = fs.ReadFile(l.fsys, filePath)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if firstNonMissingErr == nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
firstNonMissingErr = err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if firstNonMissingErr != nil {
|
||||
err = firstNonMissingErr
|
||||
}
|
||||
return nil, nil, log.E("FSLoader.Load", "locale not found: "+lang, err)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if r := core.JSONUnmarshal(data, &raw); !r.OK {
|
||||
return nil, nil, log.E("FSLoader.Load", "invalid JSON in locale: "+lang, r.Value.(error))
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, nil, log.E("FSLoader.Load", "invalid JSON in locale: "+lang, err)
|
||||
}
|
||||
|
||||
messages := make(map[string]Message)
|
||||
|
|
@ -67,37 +63,6 @@ func (l *FSLoader) Load(lang string) (map[string]Message, *GrammarData, error) {
|
|||
return messages, grammar, nil
|
||||
}
|
||||
|
||||
func localeFilenameCandidates(lang string) []string {
|
||||
// Preserve the documented lookup order: exact tag first, then underscore /
|
||||
// hyphen variants, then the base language tag.
|
||||
variants := make([]string, 0, 4)
|
||||
addVariant := func(candidate string) {
|
||||
for _, existing := range variants {
|
||||
if existing == candidate {
|
||||
return
|
||||
}
|
||||
}
|
||||
variants = append(variants, candidate)
|
||||
}
|
||||
canonical := normalizeLanguageTag(lang)
|
||||
addTag := func(tag string) {
|
||||
if tag == "" {
|
||||
return
|
||||
}
|
||||
addVariant(tag + ".json")
|
||||
addVariant(core.Replace(tag, "-", "_") + ".json")
|
||||
addVariant(core.Replace(tag, "_", "-") + ".json")
|
||||
}
|
||||
addTag(lang)
|
||||
if canonical != "" && canonical != lang {
|
||||
addTag(canonical)
|
||||
}
|
||||
if base := baseLanguageTag(canonical); base != "" && base != canonical {
|
||||
addTag(base)
|
||||
}
|
||||
return variants
|
||||
}
|
||||
|
||||
// Languages implements Loader.Languages.
|
||||
func (l *FSLoader) Languages() []string {
|
||||
l.langOnce.Do(func() {
|
||||
|
|
@ -106,25 +71,16 @@ func (l *FSLoader) Languages() []string {
|
|||
l.langErr = log.E("FSLoader.Languages", "read locale directory: "+l.dir, err)
|
||||
return
|
||||
}
|
||||
seen := make(map[string]struct{}, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
lang := core.TrimSuffix(entry.Name(), ".json")
|
||||
lang = normalizeLanguageTag(core.Replace(lang, "_", "-"))
|
||||
if lang == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[lang]; ok {
|
||||
continue
|
||||
}
|
||||
seen[lang] = struct{}{}
|
||||
lang := strings.TrimSuffix(entry.Name(), ".json")
|
||||
lang = strings.ReplaceAll(lang, "_", "-")
|
||||
l.languages = append(l.languages, lang)
|
||||
}
|
||||
slices.Sort(l.languages)
|
||||
})
|
||||
return append([]string(nil), l.languages...)
|
||||
return l.languages
|
||||
}
|
||||
|
||||
// LanguagesErr returns any error from the directory scan.
|
||||
|
|
@ -150,33 +106,119 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
|
|||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if grammar != nil && loadGrammarWord(fullKey, v, grammar) {
|
||||
if grammar != nil && strings.HasPrefix(fullKey, "gram.word.") {
|
||||
wordKey := strings.TrimPrefix(fullKey, "gram.word.")
|
||||
grammar.Words[strings.ToLower(wordKey)] = v
|
||||
continue
|
||||
}
|
||||
out[fullKey] = Message{Text: v}
|
||||
|
||||
case map[string]any:
|
||||
if grammar != nil && loadGrammarVerb(fullKey, key, v, grammar) {
|
||||
// Verb form object (has base/past/gerund keys)
|
||||
if grammar != nil && isVerbFormObject(v) {
|
||||
verbName := key
|
||||
if after, ok := strings.CutPrefix(fullKey, "gram.verb."); ok {
|
||||
verbName = after
|
||||
}
|
||||
forms := VerbForms{}
|
||||
if past, ok := v["past"].(string); ok {
|
||||
forms.Past = past
|
||||
}
|
||||
if gerund, ok := v["gerund"].(string); ok {
|
||||
forms.Gerund = gerund
|
||||
}
|
||||
grammar.Verbs[strings.ToLower(verbName)] = forms
|
||||
continue
|
||||
}
|
||||
|
||||
if grammar != nil && loadGrammarNoun(fullKey, key, v, grammar) {
|
||||
// Noun form object (under gram.noun.* or has gender field)
|
||||
if grammar != nil && (strings.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
|
||||
nounName := key
|
||||
if after, ok := strings.CutPrefix(fullKey, "gram.noun."); ok {
|
||||
nounName = after
|
||||
}
|
||||
_, hasOne := v["one"]
|
||||
_, hasOther := v["other"]
|
||||
if hasOne && hasOther {
|
||||
forms := NounForms{}
|
||||
if one, ok := v["one"].(string); ok {
|
||||
forms.One = one
|
||||
}
|
||||
if other, ok := v["other"].(string); ok {
|
||||
forms.Other = other
|
||||
}
|
||||
if gender, ok := v["gender"].(string); ok {
|
||||
forms.Gender = gender
|
||||
}
|
||||
grammar.Nouns[strings.ToLower(nounName)] = forms
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Signal data for disambiguation
|
||||
if grammar != nil && fullKey == "gram.signal" {
|
||||
if nd, ok := v["noun_determiner"]; ok {
|
||||
if arr, ok := nd.([]any); ok {
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
grammar.Signals.NounDeterminers = append(grammar.Signals.NounDeterminers, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if va, ok := v["verb_auxiliary"]; ok {
|
||||
if arr, ok := va.([]any); ok {
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
grammar.Signals.VerbAuxiliaries = append(grammar.Signals.VerbAuxiliaries, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if vi, ok := v["verb_infinitive"]; ok {
|
||||
if arr, ok := vi.([]any); ok {
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
grammar.Signals.VerbInfinitive = append(grammar.Signals.VerbInfinitive, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if grammar != nil && loadGrammarSignals(fullKey, v, grammar) {
|
||||
// Article configuration
|
||||
if grammar != nil && fullKey == "gram.article" {
|
||||
if indef, ok := v["indefinite"].(map[string]any); ok {
|
||||
if def, ok := indef["default"].(string); ok {
|
||||
grammar.Articles.IndefiniteDefault = def
|
||||
}
|
||||
if vowel, ok := indef["vowel"].(string); ok {
|
||||
grammar.Articles.IndefiniteVowel = vowel
|
||||
}
|
||||
}
|
||||
if def, ok := v["definite"].(string); ok {
|
||||
grammar.Articles.Definite = def
|
||||
}
|
||||
if bg, ok := v["by_gender"].(map[string]any); ok {
|
||||
grammar.Articles.ByGender = make(map[string]string, len(bg))
|
||||
for g, art := range bg {
|
||||
if s, ok := art.(string); ok {
|
||||
grammar.Articles.ByGender[g] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if grammar != nil && loadGrammarArticle(fullKey, v, grammar) {
|
||||
continue
|
||||
}
|
||||
|
||||
if grammar != nil && loadGrammarPunctuation(fullKey, v, grammar) {
|
||||
continue
|
||||
}
|
||||
|
||||
if grammar != nil && loadGrammarNumber(fullKey, v, grammar) {
|
||||
// Punctuation rules
|
||||
if grammar != nil && fullKey == "gram.punct" {
|
||||
if label, ok := v["label"].(string); ok {
|
||||
grammar.Punct.LabelSuffix = label
|
||||
}
|
||||
if progress, ok := v["progress"].(string); ok {
|
||||
grammar.Punct.ProgressSuffix = progress
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -209,170 +251,11 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
|
|||
}
|
||||
}
|
||||
|
||||
func loadGrammarWord(fullKey, value string, grammar *GrammarData) bool {
|
||||
if grammar == nil || !core.HasPrefix(fullKey, "gram.word.") {
|
||||
return false
|
||||
}
|
||||
wordKey := core.TrimPrefix(fullKey, "gram.word.")
|
||||
if shouldSkipDeprecatedEnglishGrammarEntry(fullKey) {
|
||||
return true
|
||||
}
|
||||
grammar.Words[core.Lower(wordKey)] = value
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarVerb(fullKey, key string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || !isVerbFormObject(v) {
|
||||
return false
|
||||
}
|
||||
verbName := key
|
||||
if base, ok := v["base"].(string); ok && base != "" {
|
||||
verbName = base
|
||||
}
|
||||
if core.HasPrefix(fullKey, "gram.verb.") {
|
||||
after := core.TrimPrefix(fullKey, "gram.verb.")
|
||||
if base, ok := v["base"].(string); !ok || base == "" {
|
||||
verbName = after
|
||||
}
|
||||
}
|
||||
forms := VerbForms{}
|
||||
if past, ok := v["past"].(string); ok {
|
||||
forms.Past = past
|
||||
}
|
||||
if gerund, ok := v["gerund"].(string); ok {
|
||||
forms.Gerund = gerund
|
||||
}
|
||||
grammar.Verbs[core.Lower(verbName)] = forms
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarNoun(fullKey, key string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || !(core.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) {
|
||||
return false
|
||||
}
|
||||
nounName := key
|
||||
if core.HasPrefix(fullKey, "gram.noun.") {
|
||||
nounName = core.TrimPrefix(fullKey, "gram.noun.")
|
||||
}
|
||||
if shouldSkipDeprecatedEnglishGrammarEntry(fullKey) {
|
||||
return true
|
||||
}
|
||||
_, hasOne := v["one"]
|
||||
_, hasOther := v["other"]
|
||||
if !hasOne || !hasOther {
|
||||
return false
|
||||
}
|
||||
forms := NounForms{}
|
||||
if one, ok := v["one"].(string); ok {
|
||||
forms.One = one
|
||||
}
|
||||
if other, ok := v["other"].(string); ok {
|
||||
forms.Other = other
|
||||
}
|
||||
if gender, ok := v["gender"].(string); ok {
|
||||
forms.Gender = gender
|
||||
}
|
||||
grammar.Nouns[core.Lower(nounName)] = forms
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarSignals(fullKey string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || (fullKey != "gram.signal" && fullKey != "gram.signals") {
|
||||
return false
|
||||
}
|
||||
loadSignalStringList := func(dst *[]string, raw any) {
|
||||
arr, ok := raw.([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
*dst = append(*dst, core.Lower(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
loadSignalStringList(&grammar.Signals.NounDeterminers, v["noun_determiner"])
|
||||
loadSignalStringList(&grammar.Signals.VerbAuxiliaries, v["verb_auxiliary"])
|
||||
loadSignalStringList(&grammar.Signals.VerbInfinitive, v["verb_infinitive"])
|
||||
loadSignalStringList(&grammar.Signals.VerbNegation, v["verb_negation"])
|
||||
if priors, ok := v["prior"].(map[string]any); ok {
|
||||
loadSignalPriors(grammar, priors)
|
||||
}
|
||||
if priors, ok := v["priors"].(map[string]any); ok {
|
||||
loadSignalPriors(grammar, priors)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarArticle(fullKey string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || fullKey != "gram.article" {
|
||||
return false
|
||||
}
|
||||
if indef, ok := v["indefinite"].(map[string]any); ok {
|
||||
if def, ok := indef["default"].(string); ok {
|
||||
grammar.Articles.IndefiniteDefault = def
|
||||
}
|
||||
if vowel, ok := indef["vowel"].(string); ok {
|
||||
grammar.Articles.IndefiniteVowel = vowel
|
||||
}
|
||||
}
|
||||
if def, ok := v["definite"].(string); ok {
|
||||
grammar.Articles.Definite = def
|
||||
}
|
||||
if bg, ok := v["by_gender"].(map[string]any); ok {
|
||||
grammar.Articles.ByGender = make(map[string]string, len(bg))
|
||||
for g, art := range bg {
|
||||
if s, ok := art.(string); ok {
|
||||
grammar.Articles.ByGender[g] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if bg, ok := v["byGender"].(map[string]any); ok {
|
||||
grammar.Articles.ByGender = make(map[string]string, len(bg))
|
||||
for g, art := range bg {
|
||||
if s, ok := art.(string); ok {
|
||||
grammar.Articles.ByGender[g] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarPunctuation(fullKey string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || fullKey != "gram.punct" {
|
||||
return false
|
||||
}
|
||||
if label, ok := v["label"].(string); ok {
|
||||
grammar.Punct.LabelSuffix = label
|
||||
}
|
||||
if progress, ok := v["progress"].(string); ok {
|
||||
grammar.Punct.ProgressSuffix = progress
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadGrammarNumber(fullKey string, v map[string]any, grammar *GrammarData) bool {
|
||||
if grammar == nil || fullKey != "gram.number" {
|
||||
return false
|
||||
}
|
||||
if thousands, ok := v["thousands"].(string); ok {
|
||||
grammar.Number.ThousandsSep = thousands
|
||||
}
|
||||
if decimal, ok := v["decimal"].(string); ok {
|
||||
grammar.Number.DecimalSep = decimal
|
||||
}
|
||||
if percent, ok := v["percent"].(string); ok {
|
||||
grammar.Number.PercentFmt = percent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isVerbFormObject(m map[string]any) bool {
|
||||
_, hasBase := m["base"]
|
||||
_, hasPast := m["past"]
|
||||
_, hasGerund := m["gerund"]
|
||||
// Verb objects are identified by their inflected forms. A bare "base"
|
||||
// field is metadata, not enough to claim the object is a verb table.
|
||||
return (hasPast || hasGerund) && !isPluralObject(m)
|
||||
return (hasBase || hasPast || hasGerund) && !isPluralObject(m)
|
||||
}
|
||||
|
||||
func isNounFormObject(m map[string]any) bool {
|
||||
|
|
@ -397,77 +280,3 @@ func isPluralObject(m map[string]any) bool {
|
|||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadSignalPriors(grammar *GrammarData, priors map[string]any) {
|
||||
if grammar == nil || len(priors) == 0 {
|
||||
return
|
||||
}
|
||||
if grammar.Signals.Priors == nil {
|
||||
grammar.Signals.Priors = make(map[string]map[string]float64, len(priors))
|
||||
}
|
||||
for word, raw := range priors {
|
||||
bucket, ok := raw.(map[string]any)
|
||||
if !ok || len(bucket) == 0 {
|
||||
continue
|
||||
}
|
||||
key := core.Lower(word)
|
||||
if grammar.Signals.Priors[key] == nil {
|
||||
grammar.Signals.Priors[key] = make(map[string]float64, len(bucket))
|
||||
}
|
||||
for role, value := range bucket {
|
||||
score, ok := float64Value(value)
|
||||
if !ok || !validSignalPriorScore(score) {
|
||||
continue
|
||||
}
|
||||
grammar.Signals.Priors[key][core.Lower(role)] = score
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validSignalPriorScore(score float64) bool {
|
||||
return !math.IsNaN(score) && !math.IsInf(score, 0) && score >= 0
|
||||
}
|
||||
|
||||
func float64Value(v any) (float64, bool) {
|
||||
if v == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case float32:
|
||||
return float64(n), true
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
case int32:
|
||||
return float64(n), true
|
||||
case int16:
|
||||
return float64(n), true
|
||||
case int8:
|
||||
return float64(n), true
|
||||
case uint:
|
||||
return float64(n), true
|
||||
case uint64:
|
||||
return float64(n), true
|
||||
case uint32:
|
||||
return float64(n), true
|
||||
case uint16:
|
||||
return float64(n), true
|
||||
case uint8:
|
||||
return float64(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipDeprecatedEnglishGrammarEntry(fullKey string) bool {
|
||||
switch fullKey {
|
||||
case "gram.noun.passed", "gram.noun.failed", "gram.noun.skipped",
|
||||
"gram.word.passed", "gram.word.failed", "gram.word.skipped":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
598
loader_test.go
598
loader_test.go
|
|
@ -19,38 +19,6 @@ func TestFSLoaderLanguages(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLanguagesCanonicalAndUnique(t *testing.T) {
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{Data: []byte(`{}`)},
|
||||
"locales/en_US.json": &fstest.MapFile{Data: []byte(`{}`)},
|
||||
"locales/es-MX.json": &fstest.MapFile{Data: []byte(`{}`)},
|
||||
"locales/fr.json": &fstest.MapFile{Data: []byte(`{}`)},
|
||||
}
|
||||
|
||||
loader := NewFSLoader(fs, "locales")
|
||||
langs := loader.Languages()
|
||||
want := []string{"en", "en-US", "es-MX", "fr"}
|
||||
if !slices.Equal(langs, want) {
|
||||
t.Fatalf("Languages() = %v, want %v", langs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLanguagesReturnsCopy(t *testing.T) {
|
||||
loader := NewFSLoader(localeFS, "locales")
|
||||
|
||||
langs := loader.Languages()
|
||||
if len(langs) == 0 {
|
||||
t.Fatal("Languages() returned empty")
|
||||
}
|
||||
|
||||
langs[0] = "zz"
|
||||
|
||||
got := loader.Languages()
|
||||
if got[0] == "zz" {
|
||||
t.Fatalf("Languages() returned shared slice: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLoad(t *testing.T) {
|
||||
loader := NewFSLoader(localeFS, "locales")
|
||||
messages, grammar, err := loader.Load("en")
|
||||
|
|
@ -117,17 +85,6 @@ func TestFSLoaderLoad(t *testing.T) {
|
|||
t.Errorf("punct.progress = %q, want '...'", grammar.Punct.ProgressSuffix)
|
||||
}
|
||||
|
||||
// Number formatting from gram.number
|
||||
if grammar.Number.ThousandsSep != "," {
|
||||
t.Errorf("number.thousands = %q, want ','", grammar.Number.ThousandsSep)
|
||||
}
|
||||
if grammar.Number.DecimalSep != "." {
|
||||
t.Errorf("number.decimal = %q, want '.'", grammar.Number.DecimalSep)
|
||||
}
|
||||
if grammar.Number.PercentFmt != "%s%%" {
|
||||
t.Errorf("number.percent = %q, want '%%s%%%%'", grammar.Number.PercentFmt)
|
||||
}
|
||||
|
||||
// Words from gram.word.*
|
||||
if len(grammar.Words) == 0 {
|
||||
t.Error("grammar has 0 words")
|
||||
|
|
@ -148,72 +105,6 @@ func TestFSLoaderLoadMissing(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLoadFallsBackToBaseLanguage(t *testing.T) {
|
||||
fs := fstest.MapFS{
|
||||
"locales/en.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"greeting": "hello",
|
||||
"gram": {
|
||||
"article": {
|
||||
"indefinite": { "default": "a", "vowel": "an" },
|
||||
"definite": "the"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
loader := NewFSLoader(fs, "locales")
|
||||
messages, grammar, err := loader.Load("en-GB")
|
||||
if err != nil {
|
||||
t.Fatalf("Load(en-GB) error: %v", err)
|
||||
}
|
||||
if got := messages["greeting"].Text; got != "hello" {
|
||||
t.Fatalf("Load(en-GB) greeting = %q, want %q", got, "hello")
|
||||
}
|
||||
if grammar == nil {
|
||||
t.Fatal("Load(en-GB) returned nil grammar")
|
||||
}
|
||||
if grammar.Articles.Definite != "the" {
|
||||
t.Fatalf("Load(en-GB) grammar article = %q, want %q", grammar.Articles.Definite, "the")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleFilenameCandidates(t *testing.T) {
|
||||
got := localeFilenameCandidates("en-GB")
|
||||
want := []string{"en-GB.json", "en_GB.json", "en.json"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("localeFilenameCandidates(en-GB) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleFilenameCandidatesNormalisesCase(t *testing.T) {
|
||||
got := localeFilenameCandidates("en-us")
|
||||
want := []string{"en-us.json", "en_us.json", "en-US.json", "en_US.json", "en.json"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("localeFilenameCandidates(en-us) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSLoaderLoadUsesCanonicalVariant(t *testing.T) {
|
||||
fs := fstest.MapFS{
|
||||
"locales/en-US.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"greeting": "hello"
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
loader := NewFSLoader(fs, "locales")
|
||||
messages, _, err := loader.Load("en-us")
|
||||
if err != nil {
|
||||
t.Fatalf("Load(en-us) error: %v", err)
|
||||
}
|
||||
if got := messages["greeting"].Text; got != "hello" {
|
||||
t.Fatalf("Load(en-us) greeting = %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenWithGrammar(t *testing.T) {
|
||||
messages := make(map[string]Message)
|
||||
grammar := &GrammarData{
|
||||
|
|
@ -230,51 +121,20 @@ func TestFlattenWithGrammar(t *testing.T) {
|
|||
"past": "tested",
|
||||
"gerund": "testing",
|
||||
},
|
||||
"partial_past": map[string]any{
|
||||
"past": "partialed",
|
||||
},
|
||||
"partial_gerund": map[string]any{
|
||||
"gerund": "partialing",
|
||||
},
|
||||
"publish_draft": map[string]any{
|
||||
"base": "publish",
|
||||
"past": "published",
|
||||
"gerund": "publishing",
|
||||
},
|
||||
},
|
||||
"noun": map[string]any{
|
||||
"widget": map[string]any{
|
||||
"one": "widget",
|
||||
"other": "widgets",
|
||||
},
|
||||
"passed": map[string]any{
|
||||
"one": "passed",
|
||||
"other": "passed",
|
||||
},
|
||||
},
|
||||
"word": map[string]any{
|
||||
"api": "API",
|
||||
"failed": "failed",
|
||||
"skipped": "skipped",
|
||||
"api": "API",
|
||||
},
|
||||
"punct": map[string]any{
|
||||
"label": ":",
|
||||
"progress": "...",
|
||||
},
|
||||
"number": map[string]any{
|
||||
"thousands": ",",
|
||||
"decimal": ".",
|
||||
"percent": "%s%%",
|
||||
},
|
||||
"signal": map[string]any{
|
||||
"prior": map[string]any{
|
||||
"commit": map[string]any{
|
||||
"verb": 0.25,
|
||||
"noun": 0.75,
|
||||
},
|
||||
},
|
||||
"verb_negation": []any{"not", "never"},
|
||||
},
|
||||
"article": map[string]any{
|
||||
"indefinite": map[string]any{
|
||||
"default": "a",
|
||||
|
|
@ -299,35 +159,6 @@ func TestFlattenWithGrammar(t *testing.T) {
|
|||
t.Errorf("test.past = %q, want 'tested'", v.Past)
|
||||
}
|
||||
}
|
||||
if v, ok := grammar.Verbs["publish"]; !ok {
|
||||
t.Error("verb base override 'publish' not extracted")
|
||||
} else {
|
||||
if v.Past != "published" {
|
||||
t.Errorf("publish.past = %q, want 'published'", v.Past)
|
||||
}
|
||||
if v.Gerund != "publishing" {
|
||||
t.Errorf("publish.gerund = %q, want 'publishing'", v.Gerund)
|
||||
}
|
||||
}
|
||||
if _, ok := grammar.Verbs["publish_draft"]; ok {
|
||||
t.Error("verb should be stored under explicit base, not JSON key")
|
||||
}
|
||||
if v, ok := grammar.Verbs["partial_past"]; !ok {
|
||||
t.Error("incomplete verb entry with only past should be extracted")
|
||||
} else if v.Past != "partialed" || v.Gerund != "" {
|
||||
t.Errorf("partial_past forms = %+v, want Past only", v)
|
||||
}
|
||||
if v, ok := grammar.Verbs["partial_gerund"]; !ok {
|
||||
t.Error("incomplete verb entry with only gerund should be extracted")
|
||||
} else if v.Past != "" || v.Gerund != "partialing" {
|
||||
t.Errorf("partial_gerund forms = %+v, want Gerund only", v)
|
||||
}
|
||||
if _, ok := messages["gram.verb.partial_past"]; ok {
|
||||
t.Error("gram.verb.partial_past should not be flattened into messages")
|
||||
}
|
||||
if _, ok := messages["gram.verb.partial_gerund"]; ok {
|
||||
t.Error("gram.verb.partial_gerund should not be flattened into messages")
|
||||
}
|
||||
|
||||
// Noun extracted
|
||||
if n, ok := grammar.Nouns["widget"]; !ok {
|
||||
|
|
@ -337,34 +168,17 @@ func TestFlattenWithGrammar(t *testing.T) {
|
|||
t.Errorf("widget.other = %q, want 'widgets'", n.Other)
|
||||
}
|
||||
}
|
||||
if _, ok := grammar.Nouns["passed"]; ok {
|
||||
t.Error("deprecated noun 'passed' should be ignored")
|
||||
}
|
||||
|
||||
// Word extracted
|
||||
if grammar.Words["api"] != "API" {
|
||||
t.Errorf("word 'api' = %q, want 'API'", grammar.Words["api"])
|
||||
}
|
||||
if _, ok := grammar.Words["failed"]; ok {
|
||||
t.Error("deprecated word 'failed' should be ignored")
|
||||
}
|
||||
if _, ok := grammar.Words["skipped"]; ok {
|
||||
t.Error("deprecated word 'skipped' should be ignored")
|
||||
}
|
||||
|
||||
// Punct extracted
|
||||
if grammar.Punct.LabelSuffix != ":" {
|
||||
t.Errorf("punct.label = %q, want ':'", grammar.Punct.LabelSuffix)
|
||||
}
|
||||
|
||||
// Number formatting extracted
|
||||
if grammar.Number.ThousandsSep != "," {
|
||||
t.Errorf("number.thousands = %q, want ','", grammar.Number.ThousandsSep)
|
||||
}
|
||||
if len(grammar.Signals.VerbNegation) != 2 || grammar.Signals.VerbNegation[0] != "not" || grammar.Signals.VerbNegation[1] != "never" {
|
||||
t.Errorf("verb negation not extracted: %+v", grammar.Signals.VerbNegation)
|
||||
}
|
||||
|
||||
// Articles extracted
|
||||
if grammar.Articles.IndefiniteDefault != "a" {
|
||||
t.Errorf("article.indefinite.default = %q, want 'a'", grammar.Articles.IndefiniteDefault)
|
||||
|
|
@ -374,369 +188,6 @@ func TestFlattenWithGrammar(t *testing.T) {
|
|||
if msg, ok := messages["prompt.yes"]; !ok || msg.Text != "y" {
|
||||
t.Errorf("prompt.yes not flattened correctly, got %+v", messages["prompt.yes"])
|
||||
}
|
||||
if _, ok := messages["gram.number.thousands"]; ok {
|
||||
t.Error("gram.number.thousands should not be flattened into messages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenWithGrammar_DetectsSchemaObjectsOutsideGrammarPaths(t *testing.T) {
|
||||
messages := make(map[string]Message)
|
||||
grammar := &GrammarData{
|
||||
Verbs: make(map[string]VerbForms),
|
||||
Words: make(map[string]string),
|
||||
}
|
||||
|
||||
raw := map[string]any{
|
||||
"lexicon": map[string]any{
|
||||
"base_only": map[string]any{
|
||||
"base": "base",
|
||||
},
|
||||
},
|
||||
"phrases": map[string]any{
|
||||
"draft": map[string]any{
|
||||
"past": "drafted",
|
||||
"gerund": "drafting",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flattenWithGrammar("", raw, messages, grammar)
|
||||
|
||||
if _, ok := grammar.Verbs["draft"]; !ok {
|
||||
t.Fatal("verb schema object outside gram.verb.* was not extracted")
|
||||
}
|
||||
if _, ok := messages["phrases.draft"]; ok {
|
||||
t.Fatal("verb schema object should not be flattened into messages")
|
||||
}
|
||||
if _, ok := grammar.Verbs["base_only"]; ok {
|
||||
t.Fatal("base-only object should not be detected as a verb table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGrammarData(t *testing.T) {
|
||||
const lang = "zz"
|
||||
original := GetGrammarData(lang)
|
||||
t.Cleanup(func() {
|
||||
SetGrammarData(lang, original)
|
||||
})
|
||||
|
||||
SetGrammarData(lang, &GrammarData{
|
||||
Verbs: map[string]VerbForms{
|
||||
"keep": {Past: "kept", Gerund: "keeping"},
|
||||
},
|
||||
Nouns: map[string]NounForms{
|
||||
"file": {One: "file", Other: "files"},
|
||||
},
|
||||
Words: map[string]string{
|
||||
"url": "URL",
|
||||
},
|
||||
Articles: ArticleForms{
|
||||
IndefiniteDefault: "a",
|
||||
IndefiniteVowel: "an",
|
||||
Definite: "the",
|
||||
ByGender: map[string]string{
|
||||
"m": "le",
|
||||
},
|
||||
},
|
||||
Punct: PunctuationRules{
|
||||
LabelSuffix: ":",
|
||||
ProgressSuffix: "...",
|
||||
},
|
||||
Signals: SignalData{
|
||||
NounDeterminers: []string{"the"},
|
||||
VerbAuxiliaries: []string{"will"},
|
||||
VerbInfinitive: []string{"to"},
|
||||
VerbNegation: []string{"not"},
|
||||
Priors: map[string]map[string]float64{
|
||||
"run": {
|
||||
"verb": 0.7,
|
||||
},
|
||||
},
|
||||
},
|
||||
Number: NumberFormat{
|
||||
ThousandsSep: ",",
|
||||
DecimalSep: ".",
|
||||
PercentFmt: "%s%%",
|
||||
},
|
||||
})
|
||||
|
||||
MergeGrammarData(lang, &GrammarData{
|
||||
Verbs: map[string]VerbForms{
|
||||
"add": {Past: "added", Gerund: "adding"},
|
||||
},
|
||||
Nouns: map[string]NounForms{
|
||||
"repo": {One: "repo", Other: "repos"},
|
||||
},
|
||||
Words: map[string]string{
|
||||
"api": "API",
|
||||
},
|
||||
Articles: ArticleForms{
|
||||
ByGender: map[string]string{
|
||||
"f": "la",
|
||||
},
|
||||
},
|
||||
Punct: PunctuationRules{
|
||||
LabelSuffix: " !",
|
||||
},
|
||||
Signals: SignalData{
|
||||
NounDeterminers: []string{"a"},
|
||||
VerbAuxiliaries: []string{"can"},
|
||||
VerbInfinitive: []string{"go"},
|
||||
VerbNegation: []string{"never"},
|
||||
Priors: map[string]map[string]float64{
|
||||
"run": {
|
||||
"noun": 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
Number: NumberFormat{
|
||||
ThousandsSep: ".",
|
||||
},
|
||||
})
|
||||
|
||||
data := GetGrammarData(lang)
|
||||
if data == nil {
|
||||
t.Fatal("MergeGrammarData() cleared existing grammar data")
|
||||
}
|
||||
if _, ok := data.Verbs["keep"]; !ok {
|
||||
t.Error("existing verb entry was lost")
|
||||
}
|
||||
if _, ok := data.Verbs["add"]; !ok {
|
||||
t.Error("merged verb entry missing")
|
||||
}
|
||||
if _, ok := data.Nouns["file"]; !ok {
|
||||
t.Error("existing noun entry was lost")
|
||||
}
|
||||
if _, ok := data.Nouns["repo"]; !ok {
|
||||
t.Error("merged noun entry missing")
|
||||
}
|
||||
if data.Words["url"] != "URL" || data.Words["api"] != "API" {
|
||||
t.Errorf("words not merged correctly: %+v", data.Words)
|
||||
}
|
||||
if data.Articles.IndefiniteDefault != "a" || data.Articles.IndefiniteVowel != "an" || data.Articles.Definite != "the" {
|
||||
t.Errorf("article defaults changed unexpectedly: %+v", data.Articles)
|
||||
}
|
||||
if data.Articles.ByGender["m"] != "le" || data.Articles.ByGender["f"] != "la" {
|
||||
t.Errorf("article by_gender not merged correctly: %+v", data.Articles.ByGender)
|
||||
}
|
||||
if data.Punct.LabelSuffix != " !" || data.Punct.ProgressSuffix != "..." {
|
||||
t.Errorf("punctuation not merged correctly: %+v", data.Punct)
|
||||
}
|
||||
if len(data.Signals.NounDeterminers) != 2 || len(data.Signals.VerbAuxiliaries) != 2 || len(data.Signals.VerbInfinitive) != 2 || len(data.Signals.VerbNegation) != 2 {
|
||||
t.Errorf("signal slices not merged correctly: %+v", data.Signals)
|
||||
}
|
||||
if got := data.Signals.Priors["run"]["verb"]; got != 0.7 {
|
||||
t.Errorf("signal priors lost existing value: got %v", got)
|
||||
}
|
||||
if got := data.Signals.Priors["run"]["noun"]; got != 0.3 {
|
||||
t.Errorf("signal priors missing merged value: got %v", got)
|
||||
}
|
||||
if data.Signals.VerbNegation[0] != "not" || data.Signals.VerbNegation[1] != "never" {
|
||||
t.Errorf("signal negation not merged correctly: %+v", data.Signals.VerbNegation)
|
||||
}
|
||||
if data.Number.ThousandsSep != "." || data.Number.DecimalSep != "." || data.Number.PercentFmt != "%s%%" {
|
||||
t.Errorf("number format not merged correctly: %+v", data.Number)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGrammarData_DeduplicatesSignals(t *testing.T) {
|
||||
const lang = "zy"
|
||||
original := GetGrammarData(lang)
|
||||
t.Cleanup(func() {
|
||||
SetGrammarData(lang, original)
|
||||
})
|
||||
|
||||
SetGrammarData(lang, &GrammarData{
|
||||
Signals: SignalData{
|
||||
NounDeterminers: []string{"the", "a"},
|
||||
VerbAuxiliaries: []string{"will"},
|
||||
VerbInfinitive: []string{"to"},
|
||||
VerbNegation: []string{"not"},
|
||||
},
|
||||
})
|
||||
|
||||
MergeGrammarData(lang, &GrammarData{
|
||||
Signals: SignalData{
|
||||
NounDeterminers: []string{"a", "some"},
|
||||
VerbAuxiliaries: []string{"will", "can"},
|
||||
VerbInfinitive: []string{"to", "de"},
|
||||
VerbNegation: []string{"not", "never"},
|
||||
},
|
||||
})
|
||||
|
||||
data := GetGrammarData(lang)
|
||||
if data == nil {
|
||||
t.Fatal("GetGrammarData returned nil")
|
||||
}
|
||||
if got, want := data.Signals.NounDeterminers, []string{"the", "a", "some"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("NounDeterminers = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbAuxiliaries, []string{"will", "can"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("VerbAuxiliaries = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbInfinitive, []string{"to", "de"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("VerbInfinitive = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbNegation, []string{"not", "never"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("VerbNegation = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrammarDataLanguageTagNormalisation(t *testing.T) {
|
||||
const rawLang = "tl_PH"
|
||||
const canonicalLang = "tl-PH"
|
||||
|
||||
originalRaw := GetGrammarData(rawLang)
|
||||
originalCanonical := GetGrammarData(canonicalLang)
|
||||
t.Cleanup(func() {
|
||||
SetGrammarData(rawLang, originalRaw)
|
||||
SetGrammarData(canonicalLang, originalCanonical)
|
||||
})
|
||||
|
||||
SetGrammarData(rawLang, &GrammarData{
|
||||
Words: map[string]string{
|
||||
"demo": "Demo",
|
||||
},
|
||||
})
|
||||
|
||||
if got := GetGrammarData(rawLang); got == nil || got.Words["demo"] != "Demo" {
|
||||
t.Fatalf("GetGrammarData(%q) = %+v, want demo word", rawLang, got)
|
||||
}
|
||||
if got := GetGrammarData(canonicalLang); got == nil || got.Words["demo"] != "Demo" {
|
||||
t.Fatalf("GetGrammarData(%q) = %+v, want demo word", canonicalLang, got)
|
||||
}
|
||||
|
||||
MergeGrammarData(canonicalLang, &GrammarData{
|
||||
Words: map[string]string{
|
||||
"api": "API",
|
||||
},
|
||||
})
|
||||
|
||||
data := GetGrammarData(rawLang)
|
||||
if data == nil {
|
||||
t.Fatalf("GetGrammarData(%q) returned nil after merge", rawLang)
|
||||
}
|
||||
if data.Words["api"] != "API" {
|
||||
t.Fatalf("merged word normalisation failed: %+v", data.Words)
|
||||
}
|
||||
|
||||
SetGrammarData(rawLang, nil)
|
||||
if got := GetGrammarData(canonicalLang); got != nil {
|
||||
t.Fatalf("SetGrammarData(%q, nil) did not clear entry: %+v", rawLang, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithLoader_LoadsGrammarOnlyLocale(t *testing.T) {
|
||||
loaderFS := fstest.MapFS{
|
||||
"fr.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"gram": {
|
||||
"article": {
|
||||
"indefinite": { "default": "el", "vowel": "l'" },
|
||||
"definite": "el",
|
||||
"by_gender": { "m": "el", "f": "la" }
|
||||
},
|
||||
"punct": { "label": " !", "progress": " ..." },
|
||||
"signal": {
|
||||
"noun_determiner": ["el"],
|
||||
"verb_auxiliary": ["va"],
|
||||
"verb_infinitive": ["a"],
|
||||
"verb_negation": ["no", "nunca"]
|
||||
},
|
||||
"number": { "thousands": ".", "decimal": ",", "percent": "%s %%"}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
svc, err := NewWithLoader(NewFSLoader(loaderFS, "."))
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithLoader() failed: %v", err)
|
||||
}
|
||||
|
||||
data := GetGrammarData("fr")
|
||||
if data == nil {
|
||||
t.Fatal("grammar-only locale was not loaded")
|
||||
}
|
||||
if data.Articles.ByGender["f"] != "la" {
|
||||
t.Errorf("article by_gender[f] = %q, want %q", data.Articles.ByGender["f"], "la")
|
||||
}
|
||||
if data.Punct.LabelSuffix != " !" || data.Punct.ProgressSuffix != " ..." {
|
||||
t.Errorf("punctuation not loaded: %+v", data.Punct)
|
||||
}
|
||||
if len(data.Signals.NounDeterminers) != 1 || data.Signals.NounDeterminers[0] != "el" {
|
||||
t.Errorf("signals not loaded: %+v", data.Signals)
|
||||
}
|
||||
if len(data.Signals.VerbNegation) != 2 || data.Signals.VerbNegation[0] != "no" || data.Signals.VerbNegation[1] != "nunca" {
|
||||
t.Errorf("negation signal not loaded: %+v", data.Signals.VerbNegation)
|
||||
}
|
||||
if data.Number.DecimalSep != "," || data.Number.ThousandsSep != "." {
|
||||
t.Errorf("number format not loaded: %+v", data.Number)
|
||||
}
|
||||
|
||||
if err := svc.SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
if got := Label("status"); got != "Status !" {
|
||||
t.Errorf("Label(status) = %q, want %q", got, "Status !")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithLoader_AcceptsGrammarAliases(t *testing.T) {
|
||||
loaderFS := fstest.MapFS{
|
||||
"en.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"gram": {
|
||||
"article": {
|
||||
"byGender": { "f": "la", "m": "le" }
|
||||
},
|
||||
"noun": {
|
||||
"user": { "one": "user", "other": "users", "gender": "f" }
|
||||
},
|
||||
"signals": {
|
||||
"noun_determiner": ["the"],
|
||||
"verb_auxiliary": ["will"],
|
||||
"verb_infinitive": ["to"],
|
||||
"verb_negation": ["never"]
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
svc, err := NewWithLoader(NewFSLoader(loaderFS, "."))
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithLoader() failed: %v", err)
|
||||
}
|
||||
|
||||
data := GetGrammarData("en")
|
||||
if data == nil {
|
||||
t.Fatal("alias grammar data was not loaded")
|
||||
}
|
||||
if data.Articles.ByGender["f"] != "la" || data.Articles.ByGender["m"] != "le" {
|
||||
t.Fatalf("article byGender alias not loaded: %+v", data.Articles.ByGender)
|
||||
}
|
||||
if got, want := data.Signals.NounDeterminers, []string{"the"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("signal alias noun_determiner = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbAuxiliaries, []string{"will"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("signal alias verb_auxiliary = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbInfinitive, []string{"to"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("signal alias verb_infinitive = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := data.Signals.VerbNegation, []string{"never"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("signal alias verb_negation = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if err := svc.SetLanguage("en"); err != nil {
|
||||
t.Fatalf("SetLanguage(en) failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
if got := DefinitePhrase("user"); got != "la user" && got != "la User" {
|
||||
t.Fatalf("DefinitePhrase(user) = %q, want article from byGender alias", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenPluralObject(t *testing.T) {
|
||||
|
|
@ -810,14 +261,8 @@ func TestCustomFSLoader(t *testing.T) {
|
|||
Data: []byte(`{
|
||||
"gram": {
|
||||
"verb": {
|
||||
"draft": { "base": "draft", "past": "drafted", "gerund": "drafting" },
|
||||
"zap": { "base": "zap", "past": "zapped", "gerund": "zapping" }
|
||||
},
|
||||
"signal": {
|
||||
"priors": {
|
||||
"draft": { "verb": 0.6, "noun": 0.4 }
|
||||
}
|
||||
},
|
||||
"word": {
|
||||
"hello": "Hello"
|
||||
}
|
||||
|
|
@ -845,45 +290,4 @@ func TestCustomFSLoader(t *testing.T) {
|
|||
if v, ok := gd.Verbs["zap"]; !ok || v.Past != "zapped" {
|
||||
t.Errorf("verb 'zap' not loaded correctly")
|
||||
}
|
||||
if v, ok := gd.Verbs["draft"]; !ok || v.Past != "drafted" {
|
||||
t.Errorf("verb base override 'draft' not loaded correctly")
|
||||
}
|
||||
if gd.Signals.Priors["draft"]["verb"] != 0.6 || gd.Signals.Priors["draft"]["noun"] != 0.4 {
|
||||
t.Errorf("signal priors not loaded correctly: %+v", gd.Signals.Priors["draft"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomFSLoaderPreservesZeroSignalPriors(t *testing.T) {
|
||||
fs := fstest.MapFS{
|
||||
"locales/test.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"gram": {
|
||||
"signal": {
|
||||
"prior": {
|
||||
"commit": { "verb": 0, "noun": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
loader := NewFSLoader(fs, "locales")
|
||||
_, grammar, err := loader.Load("test")
|
||||
if err != nil {
|
||||
t.Fatalf("Load(test) failed: %v", err)
|
||||
}
|
||||
if grammar == nil {
|
||||
t.Fatal("expected grammar data")
|
||||
}
|
||||
bucket, ok := grammar.Signals.Priors["commit"]
|
||||
if !ok {
|
||||
t.Fatal("signal priors for commit were not loaded")
|
||||
}
|
||||
if got := bucket["verb"]; got != 0 {
|
||||
t.Fatalf("signal priors verb = %v, want 0", got)
|
||||
}
|
||||
if got := bucket["noun"]; got != 1 {
|
||||
t.Fatalf("signal priors noun = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,8 +92,7 @@
|
|||
"ssh": "SSH",
|
||||
"ssl": "SSL",
|
||||
"pr": "PR",
|
||||
"ci": "CI",
|
||||
"failed_to": "Impossible de"
|
||||
"ci": "CI"
|
||||
},
|
||||
"punct": {
|
||||
"label": " :",
|
||||
|
|
@ -145,9 +144,7 @@
|
|||
"minute": { "one": "il y a {{.Count}} minute", "other": "il y a {{.Count}} minutes" },
|
||||
"hour": { "one": "il y a {{.Count}} heure", "other": "il y a {{.Count}} heures" },
|
||||
"day": { "one": "il y a {{.Count}} jour", "other": "il y a {{.Count}} jours" },
|
||||
"week": { "one": "il y a {{.Count}} semaine", "other": "il y a {{.Count}} semaines" },
|
||||
"month": { "one": "il y a {{.Count}} mois", "other": "il y a {{.Count}} mois" },
|
||||
"year": { "one": "il y a {{.Count}} an", "other": "il y a {{.Count}} ans" }
|
||||
"week": { "one": "il y a {{.Count}} semaine", "other": "il y a {{.Count}} semaines" }
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
|
|
|
|||
174
localise.go
174
localise.go
|
|
@ -2,8 +2,8 @@ package i18n
|
|||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
|
|
@ -57,7 +57,6 @@ func (g GrammaticalGender) String() string {
|
|||
|
||||
// IsRTLLanguage returns true if the language code uses right-to-left text.
|
||||
func IsRTLLanguage(lang string) bool {
|
||||
lang = normalizeLanguageTag(lang)
|
||||
if rtlLanguages[lang] {
|
||||
return true
|
||||
}
|
||||
|
|
@ -68,180 +67,47 @@ func IsRTLLanguage(lang string) bool {
|
|||
}
|
||||
|
||||
// SetFormality sets the default formality level on the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.SetFormality(i18n.FormalityFormal)
|
||||
func SetFormality(f Formality) {
|
||||
withDefaultService(func(svc *Service) { svc.SetFormality(f) })
|
||||
}
|
||||
|
||||
// SetLocation sets the default location context on the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// i18n.SetLocation("workspace")
|
||||
func SetLocation(location string) {
|
||||
withDefaultService(func(svc *Service) { svc.SetLocation(location) })
|
||||
}
|
||||
|
||||
// CurrentLocation returns the current default location context.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// location := i18n.CurrentLocation()
|
||||
func CurrentLocation() string {
|
||||
return Location()
|
||||
}
|
||||
|
||||
// Location returns the current default location context.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// location := i18n.Location()
|
||||
func Location() string {
|
||||
return defaultServiceValue("", func(svc *Service) string {
|
||||
return svc.Location()
|
||||
})
|
||||
if svc := Default(); svc != nil {
|
||||
svc.SetFormality(f)
|
||||
}
|
||||
}
|
||||
|
||||
// Direction returns the text direction for the current language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dir := i18n.Direction()
|
||||
func Direction() TextDirection {
|
||||
return defaultServiceValue(DirLTR, func(svc *Service) TextDirection {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.Direction()
|
||||
})
|
||||
}
|
||||
|
||||
// CurrentDirection returns the current default text direction.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dir := i18n.CurrentDirection()
|
||||
func CurrentDirection() TextDirection {
|
||||
return Direction()
|
||||
}
|
||||
|
||||
// CurrentTextDirection is a more explicit alias for CurrentDirection.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dir := i18n.CurrentTextDirection()
|
||||
func CurrentTextDirection() TextDirection {
|
||||
return CurrentDirection()
|
||||
}
|
||||
return DirLTR
|
||||
}
|
||||
|
||||
// IsRTL returns true if the current language uses right-to-left text.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rtl := i18n.IsRTL()
|
||||
func IsRTL() bool { return Direction() == DirRTL }
|
||||
|
||||
// RTL is a short alias for IsRTL.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rtl := i18n.RTL()
|
||||
func RTL() bool { return IsRTL() }
|
||||
|
||||
// CurrentIsRTL returns true if the current default language uses
|
||||
// right-to-left text.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rtl := i18n.CurrentIsRTL()
|
||||
func CurrentIsRTL() bool {
|
||||
return IsRTL()
|
||||
}
|
||||
|
||||
// CurrentRTL is a short alias for CurrentIsRTL.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// rtl := i18n.CurrentRTL()
|
||||
func CurrentRTL() bool {
|
||||
return CurrentIsRTL()
|
||||
}
|
||||
|
||||
// CurrentPluralCategory returns the plural category for the current default language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// cat := i18n.CurrentPluralCategory(2)
|
||||
func CurrentPluralCategory(n int) PluralCategory {
|
||||
return defaultServiceValue(PluralOther, func(svc *Service) PluralCategory { return svc.PluralCategory(n) })
|
||||
}
|
||||
|
||||
// PluralCategoryOf returns the plural category for the current default language.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// cat := i18n.PluralCategoryOf(2)
|
||||
func PluralCategoryOf(n int) PluralCategory {
|
||||
return CurrentPluralCategory(n)
|
||||
}
|
||||
|
||||
func detectLanguage(supported []language.Tag) string {
|
||||
for _, langEnv := range []string{
|
||||
os.Getenv("LC_ALL"),
|
||||
firstLocaleFromList(os.Getenv("LANGUAGE")),
|
||||
os.Getenv("LC_MESSAGES"),
|
||||
os.Getenv("LANG"),
|
||||
} {
|
||||
langEnv := os.Getenv("LANG")
|
||||
if langEnv == "" {
|
||||
langEnv = os.Getenv("LC_ALL")
|
||||
if langEnv == "" {
|
||||
continue
|
||||
}
|
||||
if detected := detectLanguageFromEnv(langEnv, supported); detected != "" {
|
||||
return detected
|
||||
langEnv = os.Getenv("LC_MESSAGES")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectLanguageFromEnv(langEnv string, supported []language.Tag) string {
|
||||
baseLang := normalizeLanguageTag(core.Split(langEnv, ".")[0])
|
||||
if baseLang == "" || len(supported) == 0 {
|
||||
if langEnv == "" {
|
||||
return ""
|
||||
}
|
||||
baseLang := strings.Split(langEnv, ".")[0]
|
||||
baseLang = strings.ReplaceAll(baseLang, "_", "-")
|
||||
parsedLang, err := language.Parse(baseLang)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(supported) == 0 {
|
||||
return ""
|
||||
}
|
||||
matcher := language.NewMatcher(supported)
|
||||
bestMatch, bestIndex, confidence := matcher.Match(parsedLang)
|
||||
if confidence < language.Low {
|
||||
return ""
|
||||
}
|
||||
if bestIndex >= 0 && bestIndex < len(supported) {
|
||||
return supported[bestIndex].String()
|
||||
}
|
||||
return bestMatch.String()
|
||||
}
|
||||
|
||||
func firstLocaleFromList(langList string) string {
|
||||
if langList == "" {
|
||||
return ""
|
||||
}
|
||||
for _, lang := range core.Split(langList, ":") {
|
||||
if trimmed := core.Trim(lang); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
bestMatch, _, confidence := matcher.Match(parsedLang)
|
||||
if confidence >= language.Low {
|
||||
return bestMatch.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeLanguageTag(lang string) string {
|
||||
lang = core.Trim(lang)
|
||||
if lang == "" {
|
||||
return ""
|
||||
}
|
||||
lang = core.Replace(lang, "_", "-")
|
||||
if tag, err := language.Parse(lang); err == nil {
|
||||
return tag.String()
|
||||
}
|
||||
return lang
|
||||
}
|
||||
|
|
|
|||
232
localise_test.go
232
localise_test.go
|
|
@ -5,7 +5,6 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// --- Formality.String() ---
|
||||
|
|
@ -89,7 +88,6 @@ func TestIsRTLLanguage_Good(t *testing.T) {
|
|||
}{
|
||||
{"arabic", "ar", true},
|
||||
{"arabic_sa", "ar-SA", true},
|
||||
{"arabic_sa_underscore", "ar_EG", true},
|
||||
{"hebrew", "he", true},
|
||||
{"farsi", "fa", true},
|
||||
{"urdu", "ur", true},
|
||||
|
|
@ -97,7 +95,7 @@ func TestIsRTLLanguage_Good(t *testing.T) {
|
|||
{"german", "de", false},
|
||||
{"french", "fr", false},
|
||||
{"unknown", "xx", false},
|
||||
{"arabic_variant", "ar-EG-extra", true}, // len > 2 prefix check
|
||||
{"arabic_variant", "ar-EG-extra", true}, // len > 2 prefix check
|
||||
{"english_variant", "en-US-extra", false}, // len > 2, not RTL
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
|
@ -121,86 +119,6 @@ func TestSetFormality_Good(t *testing.T) {
|
|||
assert.Equal(t, FormalityNeutral, svc.Formality())
|
||||
}
|
||||
|
||||
// --- Package-level SetFallback ---
|
||||
|
||||
func TestSetFallback_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
SetFallback("fr")
|
||||
assert.Equal(t, "fr", svc.Fallback())
|
||||
|
||||
SetFallback("en")
|
||||
assert.Equal(t, "en", svc.Fallback())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentFormality ---
|
||||
|
||||
func TestCurrentFormality_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, FormalityNeutral, CurrentFormality())
|
||||
|
||||
SetFormality(FormalityFormal)
|
||||
assert.Equal(t, FormalityFormal, CurrentFormality())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentFallback ---
|
||||
|
||||
func TestCurrentFallback_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, "en", CurrentFallback())
|
||||
|
||||
SetFallback("fr")
|
||||
assert.Equal(t, "fr", CurrentFallback())
|
||||
}
|
||||
|
||||
// --- Package-level SetLocation ---
|
||||
|
||||
func TestSetLocation_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
SetLocation("workspace")
|
||||
assert.Equal(t, "workspace", svc.Location())
|
||||
|
||||
SetLocation("")
|
||||
assert.Equal(t, "", svc.Location())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentLocation ---
|
||||
|
||||
func TestCurrentLocation_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, "", CurrentLocation())
|
||||
|
||||
SetLocation("workspace")
|
||||
assert.Equal(t, "workspace", CurrentLocation())
|
||||
}
|
||||
|
||||
// --- Package-level Location ---
|
||||
|
||||
func TestLocation_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, CurrentLocation(), Location())
|
||||
|
||||
SetLocation("workspace")
|
||||
assert.Equal(t, CurrentLocation(), Location())
|
||||
}
|
||||
|
||||
// --- Package-level Direction ---
|
||||
|
||||
func TestDirection_Good(t *testing.T) {
|
||||
|
|
@ -212,26 +130,6 @@ func TestDirection_Good(t *testing.T) {
|
|||
assert.Equal(t, DirLTR, dir)
|
||||
}
|
||||
|
||||
// --- Package-level CurrentDirection ---
|
||||
|
||||
func TestCurrentDirection_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, DirLTR, CurrentDirection())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentTextDirection ---
|
||||
|
||||
func TestCurrentTextDirection_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, CurrentDirection(), CurrentTextDirection())
|
||||
}
|
||||
|
||||
// --- Package-level IsRTL ---
|
||||
|
||||
func TestIsRTL_Good(t *testing.T) {
|
||||
|
|
@ -242,91 +140,6 @@ func TestIsRTL_Good(t *testing.T) {
|
|||
assert.False(t, IsRTL(), "English should not be RTL")
|
||||
}
|
||||
|
||||
// --- Package-level RTL ---
|
||||
|
||||
func TestRTL_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, IsRTL(), RTL())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentIsRTL ---
|
||||
|
||||
func TestCurrentIsRTL_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.False(t, CurrentIsRTL(), "English should not be RTL")
|
||||
}
|
||||
|
||||
// --- Package-level CurrentRTL ---
|
||||
|
||||
func TestCurrentRTL_Good(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, CurrentIsRTL(), CurrentRTL())
|
||||
}
|
||||
|
||||
// --- Package-level CurrentPluralCategory ---
|
||||
|
||||
func TestCurrentPluralCategory_Good(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, PluralOther, CurrentPluralCategory(0))
|
||||
assert.Equal(t, PluralOne, CurrentPluralCategory(1))
|
||||
assert.Equal(t, PluralOther, CurrentPluralCategory(2))
|
||||
|
||||
require.NoError(t, SetLanguage("fr"))
|
||||
assert.Equal(t, PluralOne, CurrentPluralCategory(0))
|
||||
assert.Equal(t, PluralOne, CurrentPluralCategory(1))
|
||||
assert.Equal(t, PluralOther, CurrentPluralCategory(2))
|
||||
}
|
||||
|
||||
// --- Package-level PluralCategoryOf ---
|
||||
|
||||
func TestPluralCategoryOf_Good(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
assert.Equal(t, PluralOther, PluralCategoryOf(0))
|
||||
assert.Equal(t, PluralOne, PluralCategoryOf(1))
|
||||
assert.Equal(t, PluralOther, PluralCategoryOf(2))
|
||||
|
||||
require.NoError(t, SetLanguage("fr"))
|
||||
assert.Equal(t, PluralOne, PluralCategoryOf(0))
|
||||
assert.Equal(t, PluralOne, PluralCategoryOf(1))
|
||||
assert.Equal(t, PluralOther, PluralCategoryOf(2))
|
||||
}
|
||||
|
||||
func TestCurrentPluralCategory_NoDefaultService(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
SetDefault(nil)
|
||||
|
||||
assert.Equal(t, PluralOther, CurrentPluralCategory(2))
|
||||
}
|
||||
|
||||
// --- detectLanguage ---
|
||||
|
||||
func TestDetectLanguage_Good(t *testing.T) {
|
||||
|
|
@ -336,49 +149,6 @@ func TestDetectLanguage_Good(t *testing.T) {
|
|||
assert.Equal(t, "", result, "should return empty with no supported languages")
|
||||
}
|
||||
|
||||
func TestDetectLanguage_PrefersLocaleOverrides(t *testing.T) {
|
||||
t.Setenv("LANG", "en_US.UTF-8")
|
||||
t.Setenv("LC_MESSAGES", "fr_FR.UTF-8")
|
||||
t.Setenv("LC_ALL", "de_DE.UTF-8")
|
||||
|
||||
supported := []language.Tag{
|
||||
language.AmericanEnglish,
|
||||
language.French,
|
||||
language.German,
|
||||
}
|
||||
|
||||
result := detectLanguage(supported)
|
||||
assert.Equal(t, "de", result, "LC_ALL should win over LANG and LC_MESSAGES")
|
||||
}
|
||||
|
||||
func TestDetectLanguage_SkipsInvalidHigherPriorityLocale(t *testing.T) {
|
||||
t.Setenv("LANG", "en_US.UTF-8")
|
||||
t.Setenv("LC_MESSAGES", "fr_FR.UTF-8")
|
||||
t.Setenv("LC_ALL", "not-a-locale")
|
||||
|
||||
supported := []language.Tag{
|
||||
language.AmericanEnglish,
|
||||
language.French,
|
||||
}
|
||||
|
||||
result := detectLanguage(supported)
|
||||
assert.Equal(t, "fr", result, "invalid LC_ALL should not block a valid lower-priority locale")
|
||||
}
|
||||
|
||||
func TestDetectLanguage_PrefersLanguageList(t *testing.T) {
|
||||
t.Setenv("LANGUAGE", "fr_FR.UTF-8:de_DE.UTF-8")
|
||||
t.Setenv("LANG", "en_US.UTF-8")
|
||||
|
||||
supported := []language.Tag{
|
||||
language.AmericanEnglish,
|
||||
language.French,
|
||||
language.German,
|
||||
}
|
||||
|
||||
result := detectLanguage(supported)
|
||||
assert.Equal(t, "fr", result, "LANGUAGE should be considered before LANG")
|
||||
}
|
||||
|
||||
// --- Mode.String() ---
|
||||
|
||||
func TestMode_String_Good(t *testing.T) {
|
||||
|
|
|
|||
129
numbers.go
129
numbers.go
|
|
@ -1,33 +1,21 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getNumberFormat() NumberFormat {
|
||||
lang := currentLangForGrammar()
|
||||
if fmt, ok := getLocaleNumberFormat(lang); ok {
|
||||
return fmt
|
||||
}
|
||||
if idx := indexAny(lang, "-_"); idx > 0 {
|
||||
if fmt, ok := getLocaleNumberFormat(lang[:idx]); ok {
|
||||
return fmt
|
||||
}
|
||||
}
|
||||
return numberFormats["en"]
|
||||
}
|
||||
|
||||
func getLocaleNumberFormat(lang string) (NumberFormat, bool) {
|
||||
if data := GetGrammarData(lang); data != nil && data.Number != (NumberFormat{}) {
|
||||
return data.Number, true
|
||||
if idx := strings.IndexAny(lang, "-_"); idx > 0 {
|
||||
lang = lang[:idx]
|
||||
}
|
||||
if fmt, ok := numberFormats[lang]; ok {
|
||||
return fmt, true
|
||||
return fmt
|
||||
}
|
||||
return NumberFormat{}, false
|
||||
return numberFormats["en"]
|
||||
}
|
||||
|
||||
// FormatNumber formats an integer with locale-specific thousands separators.
|
||||
|
|
@ -43,35 +31,19 @@ func FormatDecimal(f float64) string {
|
|||
// FormatDecimalN formats a float with N decimal places.
|
||||
func FormatDecimalN(f float64, decimals int) string {
|
||||
nf := getNumberFormat()
|
||||
negative := f < 0
|
||||
absVal := math.Abs(f)
|
||||
intPart := int64(absVal)
|
||||
fracPart := absVal - float64(intPart)
|
||||
intPart := int64(f)
|
||||
fracPart := math.Abs(f - float64(intPart))
|
||||
intStr := formatIntWithSep(intPart, nf.ThousandsSep)
|
||||
if decimals <= 0 || fracPart == 0 {
|
||||
if negative {
|
||||
return "-" + intStr
|
||||
}
|
||||
return intStr
|
||||
}
|
||||
multiplier := math.Pow(10, float64(decimals))
|
||||
fracInt := int64(math.Round(fracPart * multiplier))
|
||||
if fracInt >= int64(multiplier) {
|
||||
intPart++
|
||||
intStr = formatIntWithSep(intPart, nf.ThousandsSep)
|
||||
fracInt = 0
|
||||
}
|
||||
if fracInt == 0 {
|
||||
if negative {
|
||||
return "-" + intStr
|
||||
}
|
||||
return intStr
|
||||
}
|
||||
fracStr := core.Sprintf("%0*d", decimals, fracInt)
|
||||
fracStr = trimRight(fracStr, "0")
|
||||
if negative {
|
||||
return "-" + intStr + nf.DecimalSep + fracStr
|
||||
}
|
||||
fracStr := fmt.Sprintf("%0*d", decimals, fracInt)
|
||||
fracStr = strings.TrimRight(fracStr, "0")
|
||||
return intStr + nf.DecimalSep + fracStr
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +57,7 @@ func FormatPercent(f float64) string {
|
|||
} else {
|
||||
numStr = FormatDecimalN(pct, 1)
|
||||
}
|
||||
return core.Sprintf(nf.PercentFmt, numStr)
|
||||
return fmt.Sprintf(nf.PercentFmt, numStr)
|
||||
}
|
||||
|
||||
// FormatBytes formats bytes as human-readable size.
|
||||
|
|
@ -96,6 +68,7 @@ func FormatBytes(bytes int64) string {
|
|||
GB = MB * 1024
|
||||
TB = GB * 1024
|
||||
)
|
||||
nf := getNumberFormat()
|
||||
var value float64
|
||||
var unit string
|
||||
switch {
|
||||
|
|
@ -112,51 +85,51 @@ func FormatBytes(bytes int64) string {
|
|||
value = float64(bytes) / KB
|
||||
unit = "KB"
|
||||
default:
|
||||
return core.Sprintf("%d B", bytes)
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
return FormatDecimalN(value, 2) + " " + unit
|
||||
intPart := int64(value)
|
||||
fracPart := value - float64(intPart)
|
||||
if fracPart < 0.05 {
|
||||
return fmt.Sprintf("%d %s", intPart, unit)
|
||||
}
|
||||
fracDigit := int(math.Round(fracPart * 10))
|
||||
if fracDigit == 10 {
|
||||
return fmt.Sprintf("%d %s", intPart+1, unit)
|
||||
}
|
||||
return fmt.Sprintf("%d%s%d %s", intPart, nf.DecimalSep, fracDigit, unit)
|
||||
}
|
||||
|
||||
// FormatOrdinal formats a number as an ordinal.
|
||||
func FormatOrdinal(n int) string {
|
||||
lang := currentLangForGrammar()
|
||||
if idx := indexAny(lang, "-_"); idx > 0 {
|
||||
if idx := strings.IndexAny(lang, "-_"); idx > 0 {
|
||||
lang = lang[:idx]
|
||||
}
|
||||
switch lang {
|
||||
case "fr":
|
||||
return formatFrenchOrdinal(n)
|
||||
case "en":
|
||||
return formatEnglishOrdinal(n)
|
||||
default:
|
||||
return core.Sprintf("%d.", n)
|
||||
return fmt.Sprintf("%d.", n)
|
||||
}
|
||||
}
|
||||
|
||||
func formatFrenchOrdinal(n int) string {
|
||||
if n == 1 || n == -1 {
|
||||
return core.Sprintf("%der", n)
|
||||
}
|
||||
return core.Sprintf("%de", n)
|
||||
}
|
||||
|
||||
func formatEnglishOrdinal(n int) string {
|
||||
abs := n
|
||||
if abs < 0 {
|
||||
abs = -abs
|
||||
}
|
||||
if abs%100 >= 11 && abs%100 <= 13 {
|
||||
return core.Sprintf("%dth", n)
|
||||
return fmt.Sprintf("%dth", n)
|
||||
}
|
||||
switch abs % 10 {
|
||||
case 1:
|
||||
return core.Sprintf("%dst", n)
|
||||
return fmt.Sprintf("%dst", n)
|
||||
case 2:
|
||||
return core.Sprintf("%dnd", n)
|
||||
return fmt.Sprintf("%dnd", n)
|
||||
case 3:
|
||||
return core.Sprintf("%drd", n)
|
||||
return fmt.Sprintf("%drd", n)
|
||||
default:
|
||||
return core.Sprintf("%dth", n)
|
||||
return fmt.Sprintf("%dth", n)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,22 +138,17 @@ func formatIntWithSep(n int64, sep string) string {
|
|||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
negative := n < 0
|
||||
var abs uint64
|
||||
if negative {
|
||||
// Convert via n+1 to avoid overflowing on math.MinInt64.
|
||||
abs = uint64(-(n + 1))
|
||||
abs++
|
||||
} else {
|
||||
abs = uint64(n)
|
||||
n = -n
|
||||
}
|
||||
str := strconv.FormatUint(abs, 10)
|
||||
str := strconv.FormatInt(n, 10)
|
||||
if len(str) <= 3 {
|
||||
if negative {
|
||||
return "-" + str
|
||||
}
|
||||
return str
|
||||
}
|
||||
result := core.NewBuilder()
|
||||
var result strings.Builder
|
||||
for i, c := range str {
|
||||
if i > 0 && (len(str)-i)%3 == 0 {
|
||||
result.WriteString(sep)
|
||||
|
|
@ -192,34 +160,3 @@ func formatIntWithSep(n int64, sep string) string {
|
|||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// indexAny returns the index of the first occurrence of any char in chars, or -1.
|
||||
func indexAny(s, chars string) int {
|
||||
for i, c := range s {
|
||||
for _, ch := range chars {
|
||||
if c == ch {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// trimRight returns s with all trailing occurrences of cutset removed.
|
||||
func trimRight(s, cutset string) string {
|
||||
for len(s) > 0 {
|
||||
found := false
|
||||
r := rune(s[len(s)-1])
|
||||
for _, c := range cutset {
|
||||
if r == c {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
109
numbers_test.go
109
numbers_test.go
|
|
@ -1,9 +1,6 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestFormatNumber(t *testing.T) {
|
||||
// Ensure service is initialised for English locale
|
||||
|
|
@ -34,20 +31,6 @@ func TestFormatNumber(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFormatNumber_MinInt64(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
got := FormatNumber(math.MinInt64)
|
||||
want := "-9,223,372,036,854,775,808"
|
||||
if got != want {
|
||||
t.Fatalf("FormatNumber(math.MinInt64) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDecimal(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
|
|
@ -61,12 +44,8 @@ func TestFormatDecimal(t *testing.T) {
|
|||
}{
|
||||
{1.5, "1.5"},
|
||||
{1.0, "1"},
|
||||
{1.995, "2"},
|
||||
{9.999, "10"},
|
||||
{1234.56, "1,234.56"},
|
||||
{0.1, "0.1"},
|
||||
{-0.1, "-0.1"},
|
||||
{-1234.56, "-1,234.56"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -77,31 +56,6 @@ func TestFormatDecimal(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFormatDecimalN_RoundsCarry(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
f float64
|
||||
decimals int
|
||||
want string
|
||||
}{
|
||||
{1.995, 2, "2"},
|
||||
{9.999, 2, "10"},
|
||||
{999.999, 2, "1,000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := FormatDecimalN(tt.f, tt.decimals)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatDecimalN(%v, %d) = %q, want %q", tt.f, tt.decimals, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPercent(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
|
|
@ -117,7 +71,6 @@ func TestFormatPercent(t *testing.T) {
|
|||
{1.0, "100%"},
|
||||
{0.0, "0%"},
|
||||
{0.333, "33.3%"},
|
||||
{-0.1, "-10%"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -144,7 +97,7 @@ func TestFormatBytes(t *testing.T) {
|
|||
{1024, "1 KB"},
|
||||
{1536, "1.5 KB"},
|
||||
{1048576, "1 MB"},
|
||||
{1536000, "1.46 MB"},
|
||||
{1536000, "1.5 MB"},
|
||||
{1073741824, "1 GB"},
|
||||
{1099511627776, "1 TB"},
|
||||
}
|
||||
|
|
@ -190,61 +143,3 @@ func TestFormatOrdinal(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOrdinalFromLocale(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{1, "1er"},
|
||||
{2, "2e"},
|
||||
{3, "3e"},
|
||||
{11, "11e"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := FormatOrdinal(tt.n)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatOrdinal(fr, %d) = %q, want %q", tt.n, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumberFromLocale(t *testing.T) {
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
if got := FormatNumber(1234567); got != "1 234 567" {
|
||||
t.Errorf("FormatNumber(fr) = %q, want %q", got, "1 234 567")
|
||||
}
|
||||
if got := FormatDecimal(1234.56); got != "1 234,56" {
|
||||
t.Errorf("FormatDecimal(fr) = %q, want %q", got, "1 234,56")
|
||||
}
|
||||
if got := FormatPercent(0.85); got != "85 %" {
|
||||
t.Errorf("FormatPercent(fr) = %q, want %q", got, "85 %")
|
||||
}
|
||||
if got := FormatDecimal(-0.1); got != "-0,1" {
|
||||
t.Errorf("FormatDecimal(fr, negative) = %q, want %q", got, "-0,1")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
package reversal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// Domain categories for classification ground truth.
|
||||
|
|
@ -418,17 +418,17 @@ func TestClassification_LeaveOneOut(t *testing.T) {
|
|||
|
||||
// Print confusion matrix
|
||||
t.Log("\nConfusion matrix (rows=actual, cols=predicted):")
|
||||
header := core.NewBuilder()
|
||||
header.WriteString(core.Sprintf(" %-10s", ""))
|
||||
var header strings.Builder
|
||||
header.WriteString(fmt.Sprintf(" %-10s", ""))
|
||||
for _, d := range domains {
|
||||
header.WriteString(core.Sprintf(" %10s", d[:4]))
|
||||
header.WriteString(fmt.Sprintf(" %10s", d[:4]))
|
||||
}
|
||||
t.Log(header.String())
|
||||
for _, actual := range domains {
|
||||
row := core.NewBuilder()
|
||||
row.WriteString(core.Sprintf(" %-10s", actual[:4]))
|
||||
var row strings.Builder
|
||||
row.WriteString(fmt.Sprintf(" %-10s", actual[:4]))
|
||||
for _, predicted := range domains {
|
||||
row.WriteString(core.Sprintf(" %10d", confusion[actual][predicted]))
|
||||
row.WriteString(fmt.Sprintf(" %10d", confusion[actual][predicted]))
|
||||
}
|
||||
t.Log(row.String())
|
||||
}
|
||||
|
|
@ -506,14 +506,14 @@ func TestClassification_TenseProfile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
parts := core.NewBuilder()
|
||||
parts.WriteString(core.Sprintf("%-10s verbs=%d", d, totalVerbs))
|
||||
var parts strings.Builder
|
||||
parts.WriteString(fmt.Sprintf("%-10s verbs=%d", d, totalVerbs))
|
||||
for _, tense := range tenses {
|
||||
pct := 0.0
|
||||
if totalVerbs > 0 {
|
||||
pct = float64(tenseCounts[tense]) / float64(totalVerbs) * 100
|
||||
}
|
||||
parts.WriteString(core.Sprintf(" %s=%.0f%%", tense, pct))
|
||||
parts.WriteString(fmt.Sprintf(" %s=%.0f%%", tense, pct))
|
||||
}
|
||||
t.Log(parts.String())
|
||||
}
|
||||
|
|
@ -550,12 +550,12 @@ func TestClassification_TopVerbs(t *testing.T) {
|
|||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count })
|
||||
|
||||
top := min(len(sorted), 8)
|
||||
verbs := core.NewBuilder()
|
||||
var verbs strings.Builder
|
||||
for i := 0; i < top; i++ {
|
||||
if i > 0 {
|
||||
verbs.WriteString(", ")
|
||||
}
|
||||
verbs.WriteString(core.Sprintf("%s(%d)", sorted[i].verb, sorted[i].count))
|
||||
verbs.WriteString(fmt.Sprintf("%s(%d)", sorted[i].verb, sorted[i].count))
|
||||
}
|
||||
t.Logf("%-10s unique=%d top: %s", d, len(verbCounts), verbs.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,21 +34,6 @@ func TestNewImprint(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNewImprint_WordPhrase(t *testing.T) {
|
||||
svc, err := i18n.New()
|
||||
if err != nil {
|
||||
t.Fatalf("i18n.New() failed: %v", err)
|
||||
}
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
tok := NewTokeniser()
|
||||
imp := NewImprint(tok.Tokenise("up to date"))
|
||||
|
||||
if imp.DomainVocabulary["up_to_date"] != 1 {
|
||||
t.Fatalf("DomainVocabulary[\"up_to_date\"] = %d, want 1", imp.DomainVocabulary["up_to_date"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewImprint_Empty(t *testing.T) {
|
||||
imp := NewImprint(nil)
|
||||
if imp.TokenCount != 0 {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package reversal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"dappco.re/go/core"
|
||||
i18n "dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ func NewMultiplierForLang(lang string) *Multiplier {
|
|||
// Expand produces: original + tense flips (past, gerund) + number flips (plural toggle) + combinations.
|
||||
// All output is deterministic and grammatically correct.
|
||||
func (m *Multiplier) Expand(text string) []string {
|
||||
text = core.Trim(text)
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -68,27 +68,39 @@ func (m *Multiplier) Expand(text string) []string {
|
|||
|
||||
// 2. Verb transforms: for each verb, produce past and gerund variants
|
||||
for _, vi := range verbIndices {
|
||||
addVariant(m.reconstructWithVerbTransform(tokens, vi, "past"))
|
||||
addVariant(m.reconstructWithVerbTransform(tokens, vi, "gerund"))
|
||||
addVariant(m.reconstructWithVerbTransform(tokens, vi, "base"))
|
||||
pastTokens := m.applyVerbTransform(tokens, vi, "past")
|
||||
addVariant(reconstruct(pastTokens))
|
||||
|
||||
gerundTokens := m.applyVerbTransform(tokens, vi, "gerund")
|
||||
addVariant(reconstruct(gerundTokens))
|
||||
|
||||
baseTokens := m.applyVerbTransform(tokens, vi, "base")
|
||||
addVariant(reconstruct(baseTokens))
|
||||
}
|
||||
|
||||
// 3. Noun transforms: for each noun, toggle plural/singular
|
||||
for _, ni := range nounIndices {
|
||||
addVariant(m.reconstructWithNounTransform(tokens, ni))
|
||||
pluralTokens := m.applyNounTransform(tokens, ni)
|
||||
addVariant(reconstruct(pluralTokens))
|
||||
}
|
||||
|
||||
// 4. Combinations: each verb transform + each noun transform
|
||||
for _, vi := range verbIndices {
|
||||
for _, ni := range nounIndices {
|
||||
// past + noun toggle
|
||||
addVariant(m.reconstructWithVerbAndNounTransform(tokens, vi, "past", ni))
|
||||
pastTokens := m.applyVerbTransform(tokens, vi, "past")
|
||||
pastPluralTokens := m.applyNounTransformOnTokens(pastTokens, ni)
|
||||
addVariant(reconstruct(pastPluralTokens))
|
||||
|
||||
// gerund + noun toggle
|
||||
addVariant(m.reconstructWithVerbAndNounTransform(tokens, vi, "gerund", ni))
|
||||
gerundTokens := m.applyVerbTransform(tokens, vi, "gerund")
|
||||
gerundPluralTokens := m.applyNounTransformOnTokens(gerundTokens, ni)
|
||||
addVariant(reconstruct(gerundPluralTokens))
|
||||
|
||||
// base + noun toggle
|
||||
addVariant(m.reconstructWithVerbAndNounTransform(tokens, vi, "base", ni))
|
||||
baseTokens := m.applyVerbTransform(tokens, vi, "base")
|
||||
basePluralTokens := m.applyNounTransformOnTokens(baseTokens, ni)
|
||||
addVariant(reconstruct(basePluralTokens))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +140,7 @@ func (m *Multiplier) applyVerbTransform(tokens []Token, vi int, targetTense stri
|
|||
|
||||
result[vi] = Token{
|
||||
Raw: newForm,
|
||||
Lower: core.Lower(newForm),
|
||||
Lower: strings.ToLower(newForm),
|
||||
Type: TokenVerb,
|
||||
Confidence: 1.0,
|
||||
VerbInfo: VerbMatch{
|
||||
|
|
@ -179,7 +191,7 @@ func (m *Multiplier) applyNounTransformOnTokens(tokens []Token, ni int) []Token
|
|||
|
||||
result[ni] = Token{
|
||||
Raw: newForm,
|
||||
Lower: core.Lower(newForm),
|
||||
Lower: strings.ToLower(newForm),
|
||||
Type: TokenNoun,
|
||||
Confidence: 1.0,
|
||||
NounInfo: NounMatch{
|
||||
|
|
@ -192,84 +204,9 @@ func (m *Multiplier) applyNounTransformOnTokens(tokens []Token, ni int) []Token
|
|||
return result
|
||||
}
|
||||
|
||||
func (m *Multiplier) reconstructWithVerbTransform(tokens []Token, vi int, targetTense string) string {
|
||||
return m.reconstructWithTransforms(tokens, vi, targetTense, -1)
|
||||
}
|
||||
|
||||
func (m *Multiplier) reconstructWithNounTransform(tokens []Token, ni int) string {
|
||||
return m.reconstructWithTransforms(tokens, -1, "", ni)
|
||||
}
|
||||
|
||||
func (m *Multiplier) reconstructWithVerbAndNounTransform(tokens []Token, vi int, targetTense string, ni int) string {
|
||||
return m.reconstructWithTransforms(tokens, vi, targetTense, ni)
|
||||
}
|
||||
|
||||
func (m *Multiplier) reconstructWithTransforms(tokens []Token, vi int, targetTense string, ni int) string {
|
||||
b := core.NewBuilder()
|
||||
for i, tok := range tokens {
|
||||
if i > 0 {
|
||||
// Punctuation tokens should stay attached to the preceding token.
|
||||
if tok.Type == TokenPunctuation {
|
||||
b.WriteString(tok.Raw)
|
||||
continue
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
switch {
|
||||
case i == vi:
|
||||
b.WriteString(transformedVerbRaw(tok, targetTense))
|
||||
case i == ni:
|
||||
b.WriteString(transformedNounRaw(tok))
|
||||
default:
|
||||
b.WriteString(tok.Raw)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func transformedVerbRaw(tok Token, targetTense string) string {
|
||||
base := tok.VerbInfo.Base
|
||||
currentTense := tok.VerbInfo.Tense
|
||||
if currentTense == targetTense {
|
||||
return tok.Raw
|
||||
}
|
||||
|
||||
var newForm string
|
||||
switch targetTense {
|
||||
case "past":
|
||||
newForm = i18n.PastTense(base)
|
||||
case "gerund":
|
||||
newForm = i18n.Gerund(base)
|
||||
case "base":
|
||||
newForm = base
|
||||
}
|
||||
if newForm == "" {
|
||||
return tok.Raw
|
||||
}
|
||||
return preserveCase(tok.Raw, newForm)
|
||||
}
|
||||
|
||||
func transformedNounRaw(tok Token) string {
|
||||
base := tok.NounInfo.Base
|
||||
if base == "" {
|
||||
return tok.Raw
|
||||
}
|
||||
|
||||
var newForm string
|
||||
if tok.NounInfo.Plural {
|
||||
newForm = base
|
||||
} else {
|
||||
newForm = i18n.PluralForm(base)
|
||||
}
|
||||
if newForm == "" {
|
||||
return tok.Raw
|
||||
}
|
||||
return preserveCase(tok.Raw, newForm)
|
||||
}
|
||||
|
||||
// reconstruct joins tokens back into a string, preserving spacing.
|
||||
func reconstruct(tokens []Token) string {
|
||||
b := core.NewBuilder()
|
||||
var b strings.Builder
|
||||
for i, tok := range tokens {
|
||||
if i > 0 {
|
||||
// Punctuation tokens that were split from the previous word
|
||||
|
|
@ -298,7 +235,7 @@ func preserveCase(original, replacement string) string {
|
|||
|
||||
// If the original is all uppercase (like "DELETE"), make replacement all uppercase.
|
||||
if isAllUpper(original) && len(original) > 1 {
|
||||
return core.Upper(replacement)
|
||||
return strings.ToUpper(replacement)
|
||||
}
|
||||
|
||||
// If the first character of the original is uppercase, capitalise the replacement.
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ func (rs *ReferenceSet) Classify(imprint GrammarImprint) ImprintClassification {
|
|||
result.Domain = ranked[0].domain
|
||||
if len(ranked) > 1 {
|
||||
result.Confidence = ranked[0].sim - ranked[1].sim
|
||||
} else {
|
||||
result.Confidence = ranked[0].sim
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -144,30 +144,6 @@ func TestReferenceSet_Classify(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReferenceSet_Classify_SingleDomainConfidence(t *testing.T) {
|
||||
tok := initI18n(t)
|
||||
|
||||
samples := []ClassifiedText{
|
||||
{Text: "Delete the configuration file", Domain: "technical"},
|
||||
{Text: "Build the project from source", Domain: "technical"},
|
||||
}
|
||||
|
||||
rs, err := BuildReferences(tok, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildReferences: %v", err)
|
||||
}
|
||||
|
||||
imp := NewImprint(tok.Tokenise("Run the tests before committing"))
|
||||
cls := rs.Classify(imp)
|
||||
|
||||
if cls.Domain == "" {
|
||||
t.Fatal("empty classification domain")
|
||||
}
|
||||
if cls.Confidence != 0 {
|
||||
t.Errorf("Confidence = %f, want 0 when only one domain is available", cls.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSet_DomainNames(t *testing.T) {
|
||||
tok := initI18n(t)
|
||||
samples := []ClassifiedText{
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,9 +20,9 @@ func TestTokeniser_MatchVerb_Irregular(t *testing.T) {
|
|||
tok := NewTokeniser()
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
wantOK bool
|
||||
wantBase string
|
||||
word string
|
||||
wantOK bool
|
||||
wantBase string
|
||||
wantTense string
|
||||
}{
|
||||
// Irregular past tense
|
||||
|
|
@ -159,7 +159,6 @@ func TestTokeniser_MatchWord(t *testing.T) {
|
|||
{"url", "url", true},
|
||||
{"ID", "id", true},
|
||||
{"SSH", "ssh", true},
|
||||
{"up to date", "up_to_date", true},
|
||||
{"PHP", "php", true},
|
||||
{"xyzzy", "", false},
|
||||
}
|
||||
|
|
@ -189,7 +188,6 @@ func TestTokeniser_MatchArticle(t *testing.T) {
|
|||
{"a", "indefinite", true},
|
||||
{"an", "indefinite", true},
|
||||
{"the", "definite", true},
|
||||
{"the.", "definite", true},
|
||||
{"A", "indefinite", true},
|
||||
{"The", "definite", true},
|
||||
{"foo", "", false},
|
||||
|
|
@ -208,516 +206,6 @@ func TestTokeniser_MatchArticle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_MatchArticle_FrenchGendered(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniserForLang("fr")
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
wantType string
|
||||
wantOK bool
|
||||
}{
|
||||
{"le", "definite", true},
|
||||
{"la", "definite", true},
|
||||
{"le serveur", "definite", true},
|
||||
{"le serveur.", "definite", true},
|
||||
{"la branche", "definite", true},
|
||||
{"les amis", "definite", true},
|
||||
{"Le", "definite", true},
|
||||
{"La", "definite", true},
|
||||
{"Un enfant", "indefinite", true},
|
||||
{"Une amie", "indefinite", true},
|
||||
{"de la", "indefinite", true},
|
||||
{"de le", "indefinite", true},
|
||||
{"de les", "indefinite", true},
|
||||
{"de l'", "indefinite", true},
|
||||
{"de l’", "indefinite", true},
|
||||
{"du serveur", "indefinite", true},
|
||||
{"des amis", "indefinite", true},
|
||||
{"un", "indefinite", true},
|
||||
{"une", "indefinite", true},
|
||||
{"l'enfant", "definite", true},
|
||||
{"l’ami", "definite", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
artType, ok := tok.MatchArticle(tt.word)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("MatchArticle(%q) ok=%v, want %v", tt.word, ok, tt.wantOK)
|
||||
}
|
||||
if ok && artType != tt.wantType {
|
||||
t.Errorf("MatchArticle(%q) = %q, want %q", tt.word, artType, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tokens := tok.Tokenise("la branche")
|
||||
if len(tokens) == 0 || tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("Tokenise(%q)[0] should be TokenArticle, got %#v", "la branche", tokens)
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("une branche")
|
||||
if len(tokens) == 0 || tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("Tokenise(%q)[0] should be TokenArticle, got %#v", "une branche", tokens)
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("Tokenise(%q)[0].ArtType = %q, want %q", "une branche", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise_WordPhrase(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
||||
tokens := tok.Tokenise("up to date")
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 1", "up to date", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenWord {
|
||||
t.Fatalf("Tokenise(%q)[0].Type = %v, want TokenWord", "up to date", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].WordCat != "up_to_date" {
|
||||
t.Fatalf("Tokenise(%q)[0].WordCat = %q, want %q", "up to date", tokens[0].WordCat, "up_to_date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise_WordPhraseWithPunctuation(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
||||
tokens := tok.Tokenise("up to date.")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "up to date.", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenWord {
|
||||
t.Fatalf("Tokenise(%q)[0].Type = %v, want TokenWord", "up to date.", tokens[0].Type)
|
||||
}
|
||||
if tokens[1].Type != TokenPunctuation {
|
||||
t.Fatalf("Tokenise(%q)[1].Type = %v, want TokenPunctuation", "up to date.", tokens[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_MatchArticle_FrenchExtended(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniserForLang("fr")
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
wantType string
|
||||
wantOK bool
|
||||
}{
|
||||
{"l'", "definite", true},
|
||||
{"l’", "definite", true},
|
||||
{"lʼ", "definite", true},
|
||||
{"L'", "definite", true},
|
||||
{"L’", "definite", true},
|
||||
{"Lʼ", "definite", true},
|
||||
{"les", "definite", true},
|
||||
{"au", "definite", true},
|
||||
{"aux", "definite", true},
|
||||
{"du", "indefinite", true},
|
||||
{"des", "indefinite", true},
|
||||
{"l'enfant", "definite", true},
|
||||
{"de l'enfant", "indefinite", true},
|
||||
{"de l’ami", "indefinite", true},
|
||||
{"De l’enfant", "indefinite", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
artType, ok := tok.MatchArticle(tt.word)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("MatchArticle(%q) ok=%v, want %v", tt.word, ok, tt.wantOK)
|
||||
}
|
||||
if ok && artType != tt.wantType {
|
||||
t.Errorf("MatchArticle(%q) = %q, want %q", tt.word, artType, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_MatchArticle_FrenchUnderscoreTagFallback(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniserForLang("fr_CA")
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
wantType string
|
||||
wantOK bool
|
||||
}{
|
||||
{"le", "definite", true},
|
||||
{"l'ami", "definite", true},
|
||||
{"de l'ami", "indefinite", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
artType, ok := tok.MatchArticle(tt.word)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("MatchArticle(%q) ok=%v, want %v", tt.word, ok, tt.wantOK)
|
||||
}
|
||||
if ok && artType != tt.wantType {
|
||||
t.Errorf("MatchArticle(%q) = %q, want %q", tt.word, artType, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tokens := tok.Tokenise("l'ami")
|
||||
if len(tokens) == 0 || tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("Tokenise(%q)[0] should be TokenArticle, got %#v", "l'ami", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_MatchArticle_ConfiguredPhrasePrefix(t *testing.T) {
|
||||
setup(t)
|
||||
|
||||
const lang = "xx"
|
||||
prev := i18n.GetGrammarData(lang)
|
||||
t.Cleanup(func() {
|
||||
i18n.SetGrammarData(lang, prev)
|
||||
})
|
||||
|
||||
i18n.SetGrammarData(lang, &i18n.GrammarData{
|
||||
Articles: i18n.ArticleForms{
|
||||
IndefiniteDefault: "a",
|
||||
IndefiniteVowel: "an",
|
||||
Definite: "the",
|
||||
},
|
||||
})
|
||||
|
||||
tok := NewTokeniserForLang(lang)
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
wantType string
|
||||
wantOK bool
|
||||
}{
|
||||
{"the file", "definite", true},
|
||||
{"a file", "indefinite", true},
|
||||
{"an error", "indefinite", true},
|
||||
{"file", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
artType, ok := tok.MatchArticle(tt.word)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("MatchArticle(%q) ok=%v, want %v", tt.word, ok, tt.wantOK)
|
||||
}
|
||||
if ok && artType != tt.wantType {
|
||||
t.Errorf("MatchArticle(%q) = %q, want %q", tt.word, artType, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_MatchArticle_ConfiguredElisionPrefix(t *testing.T) {
|
||||
setup(t)
|
||||
|
||||
const lang = "xy"
|
||||
prev := i18n.GetGrammarData(lang)
|
||||
t.Cleanup(func() {
|
||||
i18n.SetGrammarData(lang, prev)
|
||||
})
|
||||
|
||||
i18n.SetGrammarData(lang, &i18n.GrammarData{
|
||||
Articles: i18n.ArticleForms{
|
||||
IndefiniteDefault: "a",
|
||||
IndefiniteVowel: "an",
|
||||
Definite: "l'",
|
||||
ByGender: map[string]string{
|
||||
"m": "le",
|
||||
"f": "la",
|
||||
},
|
||||
},
|
||||
Nouns: map[string]i18n.NounForms{
|
||||
"ami": {One: "ami", Other: "amis", Gender: "m"},
|
||||
},
|
||||
})
|
||||
|
||||
tok := NewTokeniserForLang(lang)
|
||||
|
||||
tests := []struct {
|
||||
word string
|
||||
wantType string
|
||||
wantOK bool
|
||||
}{
|
||||
{"l'ami", "definite", true},
|
||||
{"l’ami", "definite", true},
|
||||
{"lʼami", "definite", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.word, func(t *testing.T) {
|
||||
artType, ok := tok.MatchArticle(tt.word)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("MatchArticle(%q) ok=%v, want %v", tt.word, ok, tt.wantOK)
|
||||
}
|
||||
if ok && artType != tt.wantType {
|
||||
t.Errorf("MatchArticle(%q) = %q, want %q", tt.word, artType, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tokens := tok.Tokenise("l'ami")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "l'ami", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle || tokens[0].ArtType != "definite" {
|
||||
t.Fatalf("Tokenise(%q)[0] = %#v, want definite article", "l'ami", tokens[0])
|
||||
}
|
||||
if tokens[1].Type != TokenNoun || tokens[1].Lower != "ami" {
|
||||
t.Fatalf("Tokenise(%q)[1] = %#v, want noun ami", "l'ami", tokens[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise_FrenchElision(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniserForLang("fr")
|
||||
|
||||
tokens := tok.Tokenise("l'enfant")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "l'enfant", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].ArtType != "definite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "definite")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "enfant" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("de l'enfant")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de l'enfant", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
if tokens[0].Lower != "de l'" {
|
||||
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de l'")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "enfant" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("de l' enfant")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de l' enfant", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
if tokens[0].Lower != "de l'" {
|
||||
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de l'")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "enfant" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("De l’enfant.")
|
||||
if len(tokens) != 3 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 3", "De l’enfant.", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "enfant" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
|
||||
}
|
||||
if tokens[2].Type != TokenPunctuation {
|
||||
t.Fatalf("tokens[2].Type = %v, want TokenPunctuation", tokens[2].Type)
|
||||
}
|
||||
if tokens[2].PunctType != "sentence_end" {
|
||||
t.Fatalf("tokens[2].PunctType = %q, want %q", tokens[2].PunctType, "sentence_end")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("de le serveur")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de le serveur", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
if tokens[0].Lower != "de le" {
|
||||
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de le")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "serveur" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "serveur")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("de les amis")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de les amis", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
if tokens[0].Lower != "de les" {
|
||||
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de les")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "amis" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "amis")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("de l’ enfant")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de l’ enfant", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].Lower != "de l'" {
|
||||
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de l'")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "enfant" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("de lʼenfant")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de lʼenfant", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].Lower != "de l'" {
|
||||
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de l'")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "enfant" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("d'enfant")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "d'enfant", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("l’enfant")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "l’enfant", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].ArtType != "definite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "definite")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "enfant" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "enfant")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("au serveur")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "au serveur", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].ArtType != "definite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "definite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise_FrenchPartitiveArticlePhrase(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniserForLang("fr")
|
||||
|
||||
tokens := tok.Tokenise("de la branche")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de la branche", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].Lower != "de la" {
|
||||
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de la")
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "branche" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "branche")
|
||||
}
|
||||
|
||||
tokens = tok.Tokenise("de les amis")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "de les amis", len(tokens))
|
||||
}
|
||||
if tokens[0].Type != TokenArticle {
|
||||
t.Fatalf("tokens[0].Type = %v, want TokenArticle", tokens[0].Type)
|
||||
}
|
||||
if tokens[0].Lower != "de les" {
|
||||
t.Fatalf("tokens[0].Lower = %q, want %q", tokens[0].Lower, "de les")
|
||||
}
|
||||
if tokens[0].ArtType != "indefinite" {
|
||||
t.Fatalf("tokens[0].ArtType = %q, want %q", tokens[0].ArtType, "indefinite")
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("tokens[1].Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Lower != "amis" {
|
||||
t.Fatalf("tokens[1].Lower = %q, want %q", tokens[1].Lower, "amis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
|
@ -771,55 +259,6 @@ func TestTokeniser_Tokenise_Punctuation(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise_ClauseBoundarySentence(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
||||
tokens := tok.Tokenise("run tests. commit")
|
||||
hasSentenceEnd := false
|
||||
|
||||
for _, token := range tokens {
|
||||
if token.Raw == "run" && token.Type != TokenVerb {
|
||||
t.Errorf("'run' should remain TokenVerb, got %v", token.Type)
|
||||
}
|
||||
if token.Type == TokenPunctuation && token.PunctType == "sentence_end" {
|
||||
hasSentenceEnd = true
|
||||
}
|
||||
if token.Lower == "commit" {
|
||||
// Without sentence-end boundary support, this can be demoted by verb saturation.
|
||||
// With boundary detection, it should still classify as a verb.
|
||||
if token.Type != TokenVerb {
|
||||
t.Errorf("'commit' after period should be TokenVerb, got %v", token.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSentenceEnd {
|
||||
t.Error("did not detect sentence-end punctuation in \"run tests. commit\"")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise_ClauseBoundaryStandalonePunctuation(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
||||
tokens := tok.Tokenise("run tests . commit")
|
||||
hasSentenceEnd := false
|
||||
|
||||
for _, token := range tokens {
|
||||
if token.Type == TokenPunctuation && token.PunctType == "sentence_end" {
|
||||
hasSentenceEnd = true
|
||||
}
|
||||
if token.Lower == "commit" && token.Type != TokenVerb {
|
||||
t.Errorf("'commit' after standalone period should be TokenVerb, got %v", token.Type)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSentenceEnd {
|
||||
t.Error("did not detect standalone sentence-end punctuation in \"run tests . commit\"")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise_Empty(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
|
@ -876,43 +315,6 @@ func TestTokeniser_WithSignals(t *testing.T) {
|
|||
_ = tok // verify it compiles and accepts the option
|
||||
}
|
||||
|
||||
func TestTokeniser_Tokenise_CorpusPriorBias(t *testing.T) {
|
||||
const lang = "zz-prior"
|
||||
original := i18n.GetGrammarData(lang)
|
||||
t.Cleanup(func() {
|
||||
i18n.SetGrammarData(lang, original)
|
||||
})
|
||||
|
||||
i18n.SetGrammarData(lang, &i18n.GrammarData{
|
||||
Verbs: map[string]i18n.VerbForms{
|
||||
"commit": {Past: "committed", Gerund: "committing"},
|
||||
},
|
||||
Nouns: map[string]i18n.NounForms{
|
||||
"commit": {One: "commit", Other: "commits"},
|
||||
},
|
||||
Signals: i18n.SignalData{
|
||||
Priors: map[string]map[string]float64{
|
||||
"commit": {
|
||||
"verb": 0.2,
|
||||
"noun": 0.8,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tok := NewTokeniserForLang(lang)
|
||||
tokens := tok.Tokenise("please commit")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want 2", "please commit", len(tokens))
|
||||
}
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("Tokenise(%q)[1].Type = %v, want TokenNoun", "please commit", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Confidence <= 0.5 {
|
||||
t.Fatalf("Tokenise(%q)[1].Confidence = %f, want > 0.5", "please commit", tokens[1].Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_DualClassDetection(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
|
@ -924,13 +326,7 @@ func TestTokeniser_DualClassDetection(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
for _, word := range []string{"change", "export", "function", "handle", "host", "import", "link", "log", "merge", "patch", "process", "pull", "push", "queue", "release", "stream", "tag", "trigger", "update", "watch"} {
|
||||
if !tok.IsDualClass(word) {
|
||||
t.Errorf("%q should be dual-class after expansion", word)
|
||||
}
|
||||
}
|
||||
|
||||
notDual := []string{"delete", "go", "branch", "repo"}
|
||||
notDual := []string{"delete", "go", "push", "branch", "repo"}
|
||||
for _, word := range notDual {
|
||||
if tok.IsDualClass(word) {
|
||||
t.Errorf("%q should not be dual-class", word)
|
||||
|
|
@ -938,78 +334,6 @@ func TestTokeniser_DualClassDetection(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_IgnoresDeprecatedGrammarEntries(t *testing.T) {
|
||||
setup(t)
|
||||
|
||||
const lang = "zz-deprecated"
|
||||
original := i18n.GetGrammarData(lang)
|
||||
t.Cleanup(func() {
|
||||
i18n.SetGrammarData(lang, original)
|
||||
})
|
||||
|
||||
i18n.SetGrammarData(lang, &i18n.GrammarData{
|
||||
Nouns: map[string]i18n.NounForms{
|
||||
"passed": {One: "passed", Other: "passed"},
|
||||
"failed": {One: "failed", Other: "failed"},
|
||||
"skipped": {One: "skipped", Other: "skipped"},
|
||||
"commit": {One: "commit", Other: "commits"},
|
||||
},
|
||||
Words: map[string]string{
|
||||
"passed": "passed",
|
||||
"failed": "failed",
|
||||
"skipped": "skipped",
|
||||
"url": "URL",
|
||||
},
|
||||
})
|
||||
|
||||
tok := NewTokeniserForLang(lang)
|
||||
for _, word := range []string{"passed", "failed", "skipped"} {
|
||||
if tok.IsDualClass(word) {
|
||||
t.Fatalf("%q should not be treated as dual-class", word)
|
||||
}
|
||||
if cat, ok := tok.MatchWord(word); ok {
|
||||
t.Fatalf("MatchWord(%q) = %q, %v; want not found", word, cat, ok)
|
||||
}
|
||||
if _, ok := tok.MatchNoun(word); ok {
|
||||
t.Fatalf("MatchNoun(%q) should be ignored", word)
|
||||
}
|
||||
}
|
||||
if cat, ok := tok.MatchWord("url"); !ok || cat != "url" {
|
||||
t.Fatalf("MatchWord(%q) = %q, %v; want %q, true", "url", cat, ok, "url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_DualClassExpansion_ClassifiesCommonDevOpsWords(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
||||
tests := []struct {
|
||||
text string
|
||||
wantType TokenType
|
||||
wantLower string
|
||||
}{
|
||||
{"the merge", TokenNoun, "merge"},
|
||||
{"please merge the file", TokenVerb, "merge"},
|
||||
{"the process", TokenNoun, "process"},
|
||||
{"please process the log", TokenVerb, "process"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.text, func(t *testing.T) {
|
||||
tokens := tok.Tokenise(tt.text)
|
||||
if len(tokens) < 2 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want at least 2", tt.text, len(tokens))
|
||||
}
|
||||
if tokens[1].Lower != tt.wantLower {
|
||||
t.Fatalf("Tokenise(%q)[1].Lower = %q, want %q", tt.text, tokens[1].Lower, tt.wantLower)
|
||||
}
|
||||
if tokens[1].Type != tt.wantType {
|
||||
t.Fatalf("Tokenise(%q)[1].Type = %v, want %v", tt.text, tokens[1].Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToken_ConfidenceField(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
|
@ -1130,46 +454,6 @@ func TestTokeniser_Disambiguate_ContractionAux(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Disambiguate_ContractionAux_FallbackDefaults(t *testing.T) {
|
||||
tok := NewTokeniserForLang("zz")
|
||||
tokens := tok.Tokenise("don't run the tests")
|
||||
// The hardcoded fallback auxiliaries should still recognise contractions
|
||||
// even when no locale grammar data is loaded.
|
||||
for _, token := range tokens {
|
||||
if token.Lower == "run" && token.Type != TokenVerb {
|
||||
t.Errorf("'run' after \"don't\": Type = %v, want TokenVerb", token.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_Disambiguate_NegationSignal(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser(WithSignals())
|
||||
|
||||
tokens := tok.Tokenise("no longer commit the changes")
|
||||
if len(tokens) < 3 {
|
||||
t.Fatalf("Tokenise(%q) returned %d tokens, want at least 3", "no longer commit the changes", len(tokens))
|
||||
}
|
||||
|
||||
commitTok := tokens[2]
|
||||
if commitTok.Type != TokenVerb {
|
||||
t.Fatalf("'commit' after 'no longer': Type = %v, want TokenVerb", commitTok.Type)
|
||||
}
|
||||
if commitTok.Signals == nil {
|
||||
t.Fatal("'commit' after 'no longer' should have signal breakdown")
|
||||
}
|
||||
foundNegation := false
|
||||
for _, component := range commitTok.Signals.Components {
|
||||
if component.Name == "verb_negation" {
|
||||
foundNegation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundNegation {
|
||||
t.Error("verb_negation signal should have fired for 'no longer commit'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_WithSignals_Breakdown(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser(WithSignals())
|
||||
|
|
@ -1217,7 +501,7 @@ func TestDisambiguationStats_WithAmbiguous(t *testing.T) {
|
|||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
tokens := tok.Tokenise("The commit passed the test")
|
||||
stats := tok.DisambiguationStats(tokens)
|
||||
stats := DisambiguationStatsFromTokens(tokens)
|
||||
if stats.AmbiguousTokens == 0 {
|
||||
t.Error("expected ambiguous tokens for dual-class words")
|
||||
}
|
||||
|
|
@ -1230,7 +514,7 @@ func TestDisambiguationStats_NoAmbiguous(t *testing.T) {
|
|||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
tokens := tok.Tokenise("Deleted the files")
|
||||
stats := tok.DisambiguationStats(tokens)
|
||||
stats := DisambiguationStatsFromTokens(tokens)
|
||||
if stats.AmbiguousTokens != 0 {
|
||||
t.Errorf("AmbiguousTokens = %d, want 0", stats.AmbiguousTokens)
|
||||
}
|
||||
|
|
@ -1239,7 +523,7 @@ func TestDisambiguationStats_NoAmbiguous(t *testing.T) {
|
|||
func TestWithWeights_Override(t *testing.T) {
|
||||
setup(t)
|
||||
// Override noun_determiner to 0 — "The commit" should no longer resolve as noun
|
||||
weights := map[string]float64{
|
||||
tok := NewTokeniser(WithWeights(map[string]float64{
|
||||
"noun_determiner": 0.0,
|
||||
"verb_auxiliary": 0.25,
|
||||
"following_class": 0.15,
|
||||
|
|
@ -1247,8 +531,7 @@ func TestWithWeights_Override(t *testing.T) {
|
|||
"verb_saturation": 0.10,
|
||||
"inflection_echo": 0.03,
|
||||
"default_prior": 0.02,
|
||||
}
|
||||
tok := NewTokeniser(WithWeights(weights))
|
||||
}))
|
||||
tokens := tok.Tokenise("The commit")
|
||||
// With noun_determiner zeroed, default_prior (verb) should win
|
||||
if tokens[1].Type != TokenVerb {
|
||||
|
|
@ -1256,107 +539,6 @@ func TestWithWeights_Override(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestWithWeights_CopiesInputMap(t *testing.T) {
|
||||
setup(t)
|
||||
weights := map[string]float64{
|
||||
"noun_determiner": 0.35,
|
||||
"verb_auxiliary": 0.25,
|
||||
"following_class": 0.15,
|
||||
"sentence_position": 0.10,
|
||||
"verb_saturation": 0.10,
|
||||
"inflection_echo": 0.03,
|
||||
"default_prior": 0.02,
|
||||
}
|
||||
tok := NewTokeniser(WithWeights(weights))
|
||||
|
||||
// Mutate the caller's map after construction; the tokeniser should keep
|
||||
// using the original copied values.
|
||||
weights["noun_determiner"] = 0
|
||||
|
||||
tokens := tok.Tokenise("The commit")
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("with copied weights, 'commit' Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithWeights_PartialOverrideKeepsDefaults(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser(WithWeights(map[string]float64{
|
||||
"verb_auxiliary": 0.25,
|
||||
}))
|
||||
|
||||
tokens := tok.Tokenise("The commit")
|
||||
if tokens[1].Type != TokenNoun {
|
||||
t.Fatalf("with partial weights, 'commit' Type = %v, want TokenNoun", tokens[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultWeights_ReturnsCopy(t *testing.T) {
|
||||
first := DefaultWeights()
|
||||
second := DefaultWeights()
|
||||
|
||||
if first["noun_determiner"] != 0.35 {
|
||||
t.Fatalf("DefaultWeights()[noun_determiner] = %v, want 0.35", first["noun_determiner"])
|
||||
}
|
||||
first["noun_determiner"] = 0
|
||||
|
||||
if second["noun_determiner"] != 0.35 {
|
||||
t.Fatalf("DefaultWeights() should return a fresh copy, got %v", second["noun_determiner"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniserSignalWeights_ReturnsCopy(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser(WithWeights(map[string]float64{
|
||||
"noun_determiner": 0.5,
|
||||
"default_prior": 0.1,
|
||||
}))
|
||||
|
||||
weights := tok.SignalWeights()
|
||||
if weights["noun_determiner"] != 0.5 {
|
||||
t.Fatalf("SignalWeights()[noun_determiner] = %v, want 0.5", weights["noun_determiner"])
|
||||
}
|
||||
|
||||
weights["noun_determiner"] = 0
|
||||
if got := tok.SignalWeights()["noun_determiner"]; got != 0.5 {
|
||||
t.Fatalf("SignalWeights() should return a fresh copy, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowInformationConfidenceConstants(t *testing.T) {
|
||||
if LowInformationScoreThreshold != 0.10 {
|
||||
t.Fatalf("LowInformationScoreThreshold = %v, want 0.10", LowInformationScoreThreshold)
|
||||
}
|
||||
if LowInformationVerbConfidence != 0.55 {
|
||||
t.Fatalf("LowInformationVerbConfidence = %v, want 0.55", LowInformationVerbConfidence)
|
||||
}
|
||||
if LowInformationNounConfidence != 0.45 {
|
||||
t.Fatalf("LowInformationNounConfidence = %v, want 0.45", LowInformationNounConfidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokeniser_LowInformationConfidenceFloor(t *testing.T) {
|
||||
setup(t)
|
||||
tok := NewTokeniser()
|
||||
|
||||
tokens := tok.Tokenise("maybe commit")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("Tokenise(maybe commit) produced %d tokens, want 2", len(tokens))
|
||||
}
|
||||
if tokens[1].Type != TokenVerb {
|
||||
t.Fatalf("Tokenise(maybe commit) Type = %v, want TokenVerb", tokens[1].Type)
|
||||
}
|
||||
if tokens[1].Confidence != 0.55 {
|
||||
t.Fatalf("Tokenise(maybe commit) Confidence = %v, want 0.55", tokens[1].Confidence)
|
||||
}
|
||||
if tokens[1].AltType != TokenNoun {
|
||||
t.Fatalf("Tokenise(maybe commit) AltType = %v, want TokenNoun", tokens[1].AltType)
|
||||
}
|
||||
if tokens[1].AltConf != 0.45 {
|
||||
t.Fatalf("Tokenise(maybe commit) AltConf = %v, want 0.45", tokens[1].AltConf)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Benchmarks ---
|
||||
|
||||
func benchSetup(b *testing.B) {
|
||||
|
|
|
|||
1122
service.go
1122
service.go
File diff suppressed because it is too large
Load diff
1127
service_test.go
1127
service_test.go
File diff suppressed because it is too large
Load diff
179
state.go
179
state.go
|
|
@ -1,179 +0,0 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
func newServiceStateSnapshot(
|
||||
language string,
|
||||
requestedLanguage string,
|
||||
languageExplicit bool,
|
||||
availableLanguages []string,
|
||||
mode Mode,
|
||||
fallback string,
|
||||
formality Formality,
|
||||
location string,
|
||||
direction TextDirection,
|
||||
debug bool,
|
||||
handlers []KeyHandler,
|
||||
) ServiceState {
|
||||
return ServiceState{
|
||||
Language: language,
|
||||
RequestedLanguage: requestedLanguage,
|
||||
LanguageExplicit: languageExplicit,
|
||||
AvailableLanguages: availableLanguages,
|
||||
Mode: mode,
|
||||
Fallback: fallback,
|
||||
Formality: formality,
|
||||
Location: location,
|
||||
Direction: direction,
|
||||
IsRTL: direction == DirRTL,
|
||||
Debug: debug,
|
||||
Handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultServiceStateSnapshot() ServiceState {
|
||||
// Keep the nil/default snapshot aligned with Service.State() so callers get
|
||||
// the same shape regardless of whether a Service has been initialised.
|
||||
return newServiceStateSnapshot(
|
||||
"en",
|
||||
"",
|
||||
false,
|
||||
[]string{},
|
||||
ModeNormal,
|
||||
"en",
|
||||
FormalityNeutral,
|
||||
"",
|
||||
DirLTR,
|
||||
false,
|
||||
[]KeyHandler{},
|
||||
)
|
||||
}
|
||||
|
||||
// ServiceState captures the current configuration of a service in one
|
||||
// copy-safe snapshot.
|
||||
//
|
||||
// state := i18n.CurrentState()
|
||||
type ServiceState struct {
|
||||
Language string
|
||||
RequestedLanguage string
|
||||
LanguageExplicit bool
|
||||
AvailableLanguages []string
|
||||
Mode Mode
|
||||
Fallback string
|
||||
Formality Formality
|
||||
Location string
|
||||
Direction TextDirection
|
||||
IsRTL bool
|
||||
Debug bool
|
||||
Handlers []KeyHandler
|
||||
}
|
||||
|
||||
// HandlerTypeNames returns the short type names of the snapshot's handlers.
|
||||
//
|
||||
// names := i18n.CurrentState().HandlerTypeNames()
|
||||
//
|
||||
// The returned slice is a fresh copy, so callers can inspect or mutate it
|
||||
// without affecting the snapshot.
|
||||
func (s ServiceState) HandlerTypeNames() []string {
|
||||
if len(s.Handlers) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
names := make([]string, 0, len(s.Handlers))
|
||||
for _, handler := range s.Handlers {
|
||||
if handler == nil {
|
||||
names = append(names, "<nil>")
|
||||
continue
|
||||
}
|
||||
names = append(names, shortHandlerTypeName(handler))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// String returns a concise, stable summary of the service snapshot.
|
||||
//
|
||||
// fmt.Println(i18n.CurrentState().String())
|
||||
func (s ServiceState) String() string {
|
||||
langs := "[]"
|
||||
if len(s.AvailableLanguages) > 0 {
|
||||
langs = "[" + core.Join(", ", s.AvailableLanguages...) + "]"
|
||||
}
|
||||
handlerNames := s.HandlerTypeNames()
|
||||
handlers := "[]"
|
||||
if len(handlerNames) > 0 {
|
||||
handlers = "[" + core.Join(", ", handlerNames...) + "]"
|
||||
}
|
||||
return core.Sprintf(
|
||||
"ServiceState{language=%q requested=%q explicit=%t fallback=%q mode=%s formality=%s location=%q direction=%s rtl=%t debug=%t available=%s handlers=%d types=%s}",
|
||||
s.Language,
|
||||
s.RequestedLanguage,
|
||||
s.LanguageExplicit,
|
||||
s.Fallback,
|
||||
s.Mode,
|
||||
s.Formality,
|
||||
s.Location,
|
||||
s.Direction,
|
||||
s.IsRTL,
|
||||
s.Debug,
|
||||
langs,
|
||||
len(s.Handlers),
|
||||
handlers,
|
||||
)
|
||||
}
|
||||
|
||||
func shortHandlerTypeName(handler KeyHandler) string {
|
||||
name := core.Sprintf("%T", handler)
|
||||
parts := core.Split(name, ".")
|
||||
if len(parts) > 0 {
|
||||
name = parts[len(parts)-1]
|
||||
}
|
||||
return core.TrimPrefix(name, "*")
|
||||
}
|
||||
|
||||
func (s *Service) State() ServiceState {
|
||||
if s == nil {
|
||||
return defaultServiceStateSnapshot()
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
langs := make([]string, len(s.availableLangs))
|
||||
for i, tag := range s.availableLangs {
|
||||
langs[i] = tag.String()
|
||||
}
|
||||
|
||||
handlers := make([]KeyHandler, len(s.handlers))
|
||||
copy(handlers, s.handlers)
|
||||
|
||||
dir := DirLTR
|
||||
if IsRTLLanguage(s.currentLang) {
|
||||
dir = DirRTL
|
||||
}
|
||||
|
||||
return newServiceStateSnapshot(
|
||||
s.currentLang,
|
||||
s.requestedLang,
|
||||
s.languageExplicit,
|
||||
langs,
|
||||
s.mode,
|
||||
s.fallbackLang,
|
||||
s.formality,
|
||||
s.location,
|
||||
dir,
|
||||
s.debug,
|
||||
handlers,
|
||||
)
|
||||
}
|
||||
|
||||
// String returns a concise snapshot of the service state.
|
||||
func (s *Service) String() string {
|
||||
return s.State().String()
|
||||
}
|
||||
|
||||
// CurrentState is a more explicit alias for State.
|
||||
//
|
||||
// state := i18n.CurrentState()
|
||||
func (s *Service) CurrentState() ServiceState {
|
||||
return s.State()
|
||||
}
|
||||
56
time.go
56
time.go
|
|
@ -1,85 +1,39 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// TimeAgo returns a localised relative time string.
|
||||
//
|
||||
// TimeAgo(time.Now().Add(-4 * time.Second)) // "just now"
|
||||
// TimeAgo(time.Now().Add(-5 * time.Minute)) // "5 minutes ago"
|
||||
func TimeAgo(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
switch {
|
||||
case duration < 5*time.Second:
|
||||
if text := T("time.just_now"); text != "time.just_now" {
|
||||
return text
|
||||
}
|
||||
return "just now"
|
||||
case duration < time.Minute:
|
||||
return FormatAgo(int(duration/time.Second), "second")
|
||||
return T("time.just_now")
|
||||
case duration < time.Hour:
|
||||
return FormatAgo(int(duration.Minutes()), "minute")
|
||||
case duration < 24*time.Hour:
|
||||
return FormatAgo(int(duration.Hours()), "hour")
|
||||
case duration < 7*24*time.Hour:
|
||||
return FormatAgo(int(duration.Hours()/24), "day")
|
||||
case duration < 30*24*time.Hour:
|
||||
return FormatAgo(int(duration.Hours()/(24*7)), "week")
|
||||
case duration < 365*24*time.Hour:
|
||||
return FormatAgo(int(duration.Hours()/(24*30)), "month")
|
||||
default:
|
||||
return FormatAgo(int(duration.Hours()/(24*365)), "year")
|
||||
return FormatAgo(int(duration.Hours()/(24*7)), "week")
|
||||
}
|
||||
}
|
||||
|
||||
// FormatAgo formats "N unit ago" with proper pluralisation.
|
||||
func FormatAgo(count int, unit string) string {
|
||||
svc := Default()
|
||||
unit = normalizeAgoUnit(unit)
|
||||
if svc == nil {
|
||||
return core.Sprintf("%d %s ago", count, Pluralize(unit, count))
|
||||
return fmt.Sprintf("%d %ss ago", count, unit)
|
||||
}
|
||||
key := "time.ago." + unit
|
||||
result := svc.T(key, map[string]any{"Count": count})
|
||||
if result == key {
|
||||
return core.Sprintf("%d %s ago", count, fallbackAgoUnit(unit, count))
|
||||
return fmt.Sprintf("%d %s ago", count, Pluralize(unit, count))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fallbackAgoUnit(unit string, count int) string {
|
||||
lang := currentLangForGrammar()
|
||||
rendered := renderWord(lang, unit)
|
||||
if rendered != unit {
|
||||
return rendered
|
||||
}
|
||||
return Pluralize(unit, count)
|
||||
}
|
||||
|
||||
func normalizeAgoUnit(unit string) string {
|
||||
unit = core.Lower(core.Trim(unit))
|
||||
switch unit {
|
||||
case "seconds":
|
||||
return "second"
|
||||
case "minutes":
|
||||
return "minute"
|
||||
case "hours":
|
||||
return "hour"
|
||||
case "days":
|
||||
return "day"
|
||||
case "weeks":
|
||||
return "week"
|
||||
case "months":
|
||||
return "month"
|
||||
case "years":
|
||||
return "year"
|
||||
default:
|
||||
return unit
|
||||
}
|
||||
}
|
||||
|
|
|
|||
144
time_test.go
144
time_test.go
|
|
@ -2,7 +2,6 @@ package i18n
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -21,14 +20,11 @@ func TestTimeAgo_Good(t *testing.T) {
|
|||
duration time.Duration
|
||||
contains string
|
||||
}{
|
||||
{"just_now", 4 * time.Second, "just now"},
|
||||
{"seconds_ago", 5 * time.Second, "5 seconds ago"},
|
||||
{"just_now", 5 * time.Second, "just now"},
|
||||
{"minutes_ago", 5 * time.Minute, "5 minutes ago"},
|
||||
{"hours_ago", 3 * time.Hour, "3 hours ago"},
|
||||
{"days_ago", 2 * 24 * time.Hour, "2 days ago"},
|
||||
{"weeks_ago", 3 * 7 * 24 * time.Hour, "3 weeks ago"},
|
||||
{"months_ago", 40 * 24 * time.Hour, "1 month ago"},
|
||||
{"years_ago", 400 * 24 * time.Hour, "1 year ago"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -45,7 +41,7 @@ func TestTimeAgo_Good_EdgeCases(t *testing.T) {
|
|||
|
||||
// Just under 1 minute
|
||||
got := TimeAgo(time.Now().Add(-59 * time.Second))
|
||||
assert.Contains(t, got, "seconds ago")
|
||||
assert.Contains(t, got, "just now")
|
||||
|
||||
// Exactly 1 minute
|
||||
got = TimeAgo(time.Now().Add(-60 * time.Second))
|
||||
|
|
@ -62,14 +58,6 @@ func TestTimeAgo_Good_EdgeCases(t *testing.T) {
|
|||
// Just under 1 week
|
||||
got = TimeAgo(time.Now().Add(-6 * 24 * time.Hour))
|
||||
assert.Contains(t, got, "days ago")
|
||||
|
||||
// Just over 4 weeks
|
||||
got = TimeAgo(time.Now().Add(-31 * 24 * time.Hour))
|
||||
assert.Contains(t, got, "month ago")
|
||||
|
||||
// Well over a year
|
||||
got = TimeAgo(time.Now().Add(-800 * 24 * time.Hour))
|
||||
assert.Contains(t, got, "years ago")
|
||||
}
|
||||
|
||||
func TestTimeAgo_Good_SingleUnits(t *testing.T) {
|
||||
|
|
@ -94,24 +82,6 @@ func TestTimeAgo_Good_SingleUnits(t *testing.T) {
|
|||
assert.Contains(t, got, "1 week ago")
|
||||
}
|
||||
|
||||
func TestTimeAgo_Good_MissingJustNowKeyFallback(t *testing.T) {
|
||||
svc, err := NewWithFS(fstest.MapFS{
|
||||
"xx.json": &fstest.MapFile{
|
||||
Data: []byte(`{}`),
|
||||
},
|
||||
}, ".")
|
||||
require.NoError(t, err)
|
||||
|
||||
prev := Default()
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
got := TimeAgo(time.Now().Add(-4 * time.Second))
|
||||
assert.Equal(t, "just now", got)
|
||||
}
|
||||
|
||||
// --- FormatAgo ---
|
||||
|
||||
func TestFormatAgo_Good(t *testing.T) {
|
||||
|
|
@ -142,46 +112,6 @@ func TestFormatAgo_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFormatAgo_Good_PluralUnitAlias(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
got := FormatAgo(5, "minutes")
|
||||
assert.Equal(t, "5 minutes ago", got)
|
||||
}
|
||||
|
||||
func TestFormatAgo_Good_MorePluralUnitAliases(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
unit string
|
||||
want string
|
||||
}{
|
||||
{"months", 3, "months", "3 months ago"},
|
||||
{"year", 1, "years", "1 year ago"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatAgo(tt.count, tt.unit)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAgo_Good_NormalisesUnitInput(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
|
||||
got := FormatAgo(2, " Hours ")
|
||||
assert.Equal(t, "2 hours ago", got)
|
||||
}
|
||||
|
||||
func TestFormatAgo_Bad_UnknownUnit(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -200,73 +130,3 @@ func TestFormatAgo_Good_SingularUnit(t *testing.T) {
|
|||
got := FormatAgo(1, "fortnight")
|
||||
assert.Equal(t, "1 fortnight ago", got)
|
||||
}
|
||||
|
||||
func TestFormatAgo_Good_NoDefaultService(t *testing.T) {
|
||||
prev := Default()
|
||||
SetDefault(nil)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
got := FormatAgo(1, "second")
|
||||
assert.Equal(t, "1 second ago", got)
|
||||
|
||||
got = FormatAgo(5, "second")
|
||||
assert.Equal(t, "5 seconds ago", got)
|
||||
}
|
||||
|
||||
func TestFormatAgo_Good_FrenchRelativeTime(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
require.NoError(t, SetLanguage("fr"))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
unit string
|
||||
want string
|
||||
}{
|
||||
{"month", 1, "month", "il y a 1 mois"},
|
||||
{"months", 3, "month", "il y a 3 mois"},
|
||||
{"year", 1, "year", "il y a 1 an"},
|
||||
{"years", 4, "year", "il y a 4 ans"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatAgo(tt.count, tt.unit)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAgo_FallsBackToLocaleWordMap(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := NewWithFS(fstest.MapFS{
|
||||
"en.json": &fstest.MapFile{
|
||||
Data: []byte(`{
|
||||
"gram": {
|
||||
"word": {
|
||||
"month": "mois"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}, ".")
|
||||
require.NoError(t, err)
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
require.NoError(t, SetLanguage("en"))
|
||||
|
||||
got := FormatAgo(2, "month")
|
||||
assert.Equal(t, "2 mois ago", got)
|
||||
}
|
||||
|
|
|
|||
63
transform.go
63
transform.go
|
|
@ -1,60 +1,20 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
func getCount(data any) int {
|
||||
if data == nil {
|
||||
return 0
|
||||
}
|
||||
switch d := data.(type) {
|
||||
case *Subject:
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
return d.CountInt()
|
||||
case *TranslationContext:
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
if count, ok := d.countValue(); ok {
|
||||
return count
|
||||
}
|
||||
if d.Extra != nil {
|
||||
if c, ok := d.Extra["Count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
if c, ok := d.Extra["count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
}
|
||||
return d.count
|
||||
case map[string]any:
|
||||
if c, ok := d["Count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
if c, ok := d["count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
case map[string]int:
|
||||
if c, ok := d["Count"]; ok {
|
||||
return c
|
||||
}
|
||||
if c, ok := d["count"]; ok {
|
||||
return c
|
||||
}
|
||||
case map[string]string:
|
||||
if c, ok := d["Count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
if c, ok := d["count"]; ok {
|
||||
return toInt(c)
|
||||
}
|
||||
}
|
||||
return toInt(data)
|
||||
return 0
|
||||
}
|
||||
|
||||
func toInt(v any) int {
|
||||
|
|
@ -86,13 +46,6 @@ func toInt(v any) int {
|
|||
return int(n)
|
||||
case float32:
|
||||
return int(n)
|
||||
case string:
|
||||
if n == "" {
|
||||
return 0
|
||||
}
|
||||
if parsed, err := strconv.Atoi(core.Trim(n)); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -126,13 +79,6 @@ func toInt64(v any) int64 {
|
|||
return int64(n)
|
||||
case float32:
|
||||
return int64(n)
|
||||
case string:
|
||||
if n == "" {
|
||||
return 0
|
||||
}
|
||||
if parsed, err := strconv.ParseInt(core.Trim(n), 10, 64); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -166,13 +112,6 @@ func toFloat64(v any) float64 {
|
|||
return float64(n)
|
||||
case uint8:
|
||||
return float64(n)
|
||||
case string:
|
||||
if n == "" {
|
||||
return 0
|
||||
}
|
||||
if parsed, err := strconv.ParseFloat(core.Trim(n), 64); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ func TestGetCount_Good(t *testing.T) {
|
|||
{"map_string_any", map[string]any{"Count": 5}, 5},
|
||||
{"map_string_any_float", map[string]any{"Count": 3.7}, 3},
|
||||
{"map_string_int", map[string]int{"Count": 42}, 42},
|
||||
{"map_string_string", map[string]string{"Count": "9"}, 9},
|
||||
{"no_count_key", map[string]any{"Name": "test"}, 0},
|
||||
{"wrong_type", "a string", 0},
|
||||
}
|
||||
|
|
@ -30,16 +29,6 @@ func TestGetCount_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetCount_Good_TranslationContextDefault(t *testing.T) {
|
||||
ctx := C("test")
|
||||
assert.Equal(t, 1, getCount(ctx))
|
||||
}
|
||||
|
||||
func TestGetCount_Good_TranslationContextExtraCount(t *testing.T) {
|
||||
ctx := C("test").Set("Count", 3)
|
||||
assert.Equal(t, 3, getCount(ctx))
|
||||
}
|
||||
|
||||
// --- toInt ---
|
||||
|
||||
func TestToInt_Good(t *testing.T) {
|
||||
|
|
@ -61,7 +50,6 @@ func TestToInt_Good(t *testing.T) {
|
|||
{"uint8", uint8(50), 50},
|
||||
{"float64", float64(3.14), 3},
|
||||
{"float32", float32(2.71), 2},
|
||||
{"string_int", "123", 123},
|
||||
{"string", "not a number", 0},
|
||||
{"bool", true, 0},
|
||||
}
|
||||
|
|
@ -94,7 +82,6 @@ func TestToInt64_Good(t *testing.T) {
|
|||
{"uint8", uint8(50), 50},
|
||||
{"float64", float64(3.14), 3},
|
||||
{"float32", float32(2.71), 2},
|
||||
{"string_int64", "123", 123},
|
||||
{"string", "not a number", 0},
|
||||
{"bool", true, 0},
|
||||
}
|
||||
|
|
@ -127,7 +114,6 @@ func TestToFloat64_Good(t *testing.T) {
|
|||
{"uint32", uint32(30), 30.0},
|
||||
{"uint16", uint16(40), 40.0},
|
||||
{"uint8", uint8(50), 50.0},
|
||||
{"string_float", "3.5", 3.5},
|
||||
{"string", "not a number", 0},
|
||||
{"bool", true, 0},
|
||||
}
|
||||
|
|
|
|||
108
types.go
108
types.go
|
|
@ -8,7 +8,7 @@
|
|||
// T("i18n.label.status") // "Status:"
|
||||
// T("i18n.progress.build") // "Building..."
|
||||
// T("i18n.count.file", 5) // "5 files"
|
||||
// T("i18n.done.delete", "config.yaml") // "Config.yaml deleted"
|
||||
// T("i18n.done.delete", "config.yaml") // "Config.Yaml deleted"
|
||||
// T("i18n.fail.push", "commits") // "Failed to push commits"
|
||||
package i18n
|
||||
|
||||
|
|
@ -17,8 +17,6 @@ import "sync"
|
|||
// --- Core Types ---
|
||||
|
||||
// Mode determines how the service handles missing translation keys.
|
||||
//
|
||||
// i18n.SetMode(i18n.ModeStrict)
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
|
|
@ -41,8 +39,6 @@ func (m Mode) String() string {
|
|||
}
|
||||
|
||||
// Formality represents the level of formality in translations.
|
||||
//
|
||||
// i18n.S("user", "Alex").Formal()
|
||||
type Formality int
|
||||
|
||||
const (
|
||||
|
|
@ -52,18 +48,14 @@ const (
|
|||
)
|
||||
|
||||
// TextDirection represents text directionality.
|
||||
//
|
||||
// if i18n.Direction() == i18n.DirRTL { /* ... */ }
|
||||
type TextDirection int
|
||||
|
||||
const (
|
||||
DirLTR TextDirection = iota // Left-to-right
|
||||
DirRTL // Right-to-left
|
||||
DirRTL // Right-to-left
|
||||
)
|
||||
|
||||
// PluralCategory represents CLDR plural categories.
|
||||
//
|
||||
// cat := i18n.CurrentPluralCategory(2)
|
||||
type PluralCategory int
|
||||
|
||||
const (
|
||||
|
|
@ -76,8 +68,6 @@ const (
|
|||
)
|
||||
|
||||
// GrammaticalGender represents grammatical gender for nouns.
|
||||
//
|
||||
// i18n.S("user", "Alex").Gender("feminine")
|
||||
type GrammaticalGender int
|
||||
|
||||
const (
|
||||
|
|
@ -90,8 +80,6 @@ const (
|
|||
// --- Message Types ---
|
||||
|
||||
// Message represents a translation — either a simple string or plural forms.
|
||||
//
|
||||
// msg := i18n.Message{One: "{{.Count}} file", Other: "{{.Count}} files"}
|
||||
type Message struct {
|
||||
Text string // Simple string value (non-plural)
|
||||
Zero string // count == 0 (Arabic, Latvian, Welsh)
|
||||
|
|
@ -144,8 +132,6 @@ func (m Message) IsPlural() bool {
|
|||
// --- Subject Types ---
|
||||
|
||||
// Subject represents a typed subject with metadata for semantic translations.
|
||||
//
|
||||
// subj := i18n.S("file", "config.yaml").Count(3).In("workspace")
|
||||
type Subject struct {
|
||||
Noun string // The noun type (e.g., "file", "repo")
|
||||
Value any // The actual value (e.g., filename)
|
||||
|
|
@ -158,8 +144,6 @@ type Subject struct {
|
|||
// --- Intent Types ---
|
||||
|
||||
// IntentMeta defines the behaviour of an intent.
|
||||
//
|
||||
// intent := i18n.Intent{Meta: i18n.IntentMeta{Type: "action", Verb: "delete"}}
|
||||
type IntentMeta struct {
|
||||
Type string // "action", "question", "info"
|
||||
Verb string // Reference to verb key
|
||||
|
|
@ -169,8 +153,6 @@ type IntentMeta struct {
|
|||
}
|
||||
|
||||
// Composed holds all output forms for an intent after template resolution.
|
||||
//
|
||||
// composed := i18n.ComposeIntent(i18n.Intent{Question: "Delete {{.Subject}}?"}, i18n.S("file", "config.yaml"))
|
||||
type Composed struct {
|
||||
Question string // "Delete config.yaml?"
|
||||
Confirm string // "Really delete config.yaml?"
|
||||
|
|
@ -180,8 +162,6 @@ type Composed struct {
|
|||
}
|
||||
|
||||
// Intent defines a semantic intent with templates for all output forms.
|
||||
//
|
||||
// intent := i18n.Intent{Question: "Delete {{.Subject}}?"}
|
||||
type Intent struct {
|
||||
Meta IntentMeta
|
||||
Question string // Template for question form
|
||||
|
|
@ -206,8 +186,6 @@ type templateData struct {
|
|||
// --- Grammar Types ---
|
||||
|
||||
// GrammarData holds language-specific grammar forms loaded from JSON.
|
||||
//
|
||||
// i18n.SetGrammarData("en", &i18n.GrammarData{Articles: i18n.ArticleForms{IndefiniteDefault: "a"}})
|
||||
type GrammarData struct {
|
||||
Verbs map[string]VerbForms // verb -> forms
|
||||
Nouns map[string]NounForms // noun -> forms
|
||||
|
|
@ -215,20 +193,15 @@ type GrammarData struct {
|
|||
Words map[string]string // base word translations
|
||||
Punct PunctuationRules // language-specific punctuation
|
||||
Signals SignalData // disambiguation signal word lists
|
||||
Number NumberFormat // locale-specific number formatting
|
||||
}
|
||||
|
||||
// VerbForms holds verb conjugations.
|
||||
//
|
||||
// forms := i18n.VerbForms{Past: "deleted", Gerund: "deleting"}
|
||||
type VerbForms struct {
|
||||
Past string // "deleted"
|
||||
Gerund string // "deleting"
|
||||
}
|
||||
|
||||
// NounForms holds plural and gender information for a noun.
|
||||
//
|
||||
// forms := i18n.NounForms{One: "file", Other: "files"}
|
||||
type NounForms struct {
|
||||
One string // Singular form
|
||||
Other string // Plural form
|
||||
|
|
@ -236,8 +209,6 @@ type NounForms struct {
|
|||
}
|
||||
|
||||
// ArticleForms holds article configuration for a language.
|
||||
//
|
||||
// articles := i18n.ArticleForms{IndefiniteDefault: "a", IndefiniteVowel: "an"}
|
||||
type ArticleForms struct {
|
||||
IndefiniteDefault string // "a"
|
||||
IndefiniteVowel string // "an"
|
||||
|
|
@ -246,29 +217,22 @@ type ArticleForms struct {
|
|||
}
|
||||
|
||||
// PunctuationRules holds language-specific punctuation patterns.
|
||||
//
|
||||
// rules := i18n.PunctuationRules{LabelSuffix: ":", ProgressSuffix: "..."}
|
||||
type PunctuationRules struct {
|
||||
LabelSuffix string // ":" (French uses " :")
|
||||
ProgressSuffix string // "..."
|
||||
}
|
||||
|
||||
// SignalData holds word lists used for disambiguation signals.
|
||||
//
|
||||
// signals := i18n.SignalData{VerbAuxiliaries: []string{"is", "was"}}
|
||||
type SignalData struct {
|
||||
NounDeterminers []string // Words that precede nouns: "the", "a", "this", "my", ...
|
||||
VerbAuxiliaries []string // Auxiliaries/modals before verbs: "is", "was", "will", ...
|
||||
VerbInfinitive []string // Infinitive markers: "to"
|
||||
VerbNegation []string // Negation cues that weakly signal a verb: "not", "never", ...
|
||||
Priors map[string]map[string]float64 // Corpus-derived verb/noun priors for ambiguous words, consumed by the reversal tokeniser.
|
||||
Priors map[string]map[string]float64 // Reserved for Phase 2: corpus-derived per-word priors. Not yet loaded.
|
||||
}
|
||||
|
||||
// --- Number Formatting ---
|
||||
|
||||
// NumberFormat defines locale-specific number formatting rules.
|
||||
//
|
||||
// fmt := i18n.NumberFormat{ThousandsSep: ",", DecimalSep: ".", PercentFmt: "%s%%"}
|
||||
type NumberFormat struct {
|
||||
ThousandsSep string // "," for en, "." for de
|
||||
DecimalSep string // "." for en, "," for de
|
||||
|
|
@ -278,18 +242,12 @@ type NumberFormat struct {
|
|||
// --- Function Types ---
|
||||
|
||||
// PluralRule determines the plural category for a count.
|
||||
//
|
||||
// rule := i18n.GetPluralRule("en")
|
||||
type PluralRule func(n int) PluralCategory
|
||||
|
||||
// MissingKeyHandler receives missing key events.
|
||||
//
|
||||
// i18n.OnMissingKey(func(m i18n.MissingKey) {})
|
||||
type MissingKeyHandler func(missing MissingKey)
|
||||
|
||||
// MissingKey is dispatched when a translation key is not found in ModeCollect.
|
||||
//
|
||||
// func handle(m i18n.MissingKey) { _ = m.Key }
|
||||
type MissingKey struct {
|
||||
Key string
|
||||
Args map[string]any
|
||||
|
|
@ -301,24 +259,18 @@ type MissingKey struct {
|
|||
|
||||
// KeyHandler processes translation keys before standard lookup.
|
||||
// Handlers form a chain; each can handle a key or delegate to the next.
|
||||
//
|
||||
// i18n.AddHandler(i18n.LabelHandler{})
|
||||
type KeyHandler interface {
|
||||
Match(key string) bool
|
||||
Handle(key string, args []any, next func() string) string
|
||||
}
|
||||
|
||||
// Loader provides translation data to the Service.
|
||||
//
|
||||
// svc, err := i18n.NewWithLoader(loader)
|
||||
type Loader interface {
|
||||
Load(lang string) (map[string]Message, *GrammarData, error)
|
||||
Languages() []string
|
||||
}
|
||||
|
||||
// Translator defines the interface for translation services.
|
||||
//
|
||||
// var t i18n.Translator = i18n.Default()
|
||||
type Translator interface {
|
||||
T(messageID string, args ...any) string
|
||||
SetLanguage(lang string) error
|
||||
|
|
@ -368,7 +320,6 @@ var pluralRules = map[string]PluralRule{
|
|||
"ru": pluralRuleRussian, "ru-RU": pluralRuleRussian,
|
||||
"pl": pluralRulePolish, "pl-PL": pluralRulePolish,
|
||||
"ar": pluralRuleArabic, "ar-SA": pluralRuleArabic,
|
||||
"cy": pluralRuleWelsh, "cy-GB": pluralRuleWelsh,
|
||||
"zh": pluralRuleChinese, "zh-CN": pluralRuleChinese, "zh-TW": pluralRuleChinese,
|
||||
"ja": pluralRuleJapanese, "ja-JP": pluralRuleJapanese,
|
||||
"ko": pluralRuleKorean, "ko-KR": pluralRuleKorean,
|
||||
|
|
@ -434,7 +385,7 @@ var irregularVerbs = map[string]VerbForms{
|
|||
"rebel": {Past: "rebelled", Gerund: "rebelling"}, "excel": {Past: "excelled", Gerund: "excelling"},
|
||||
"cancel": {Past: "cancelled", Gerund: "cancelling"}, "travel": {Past: "travelled", Gerund: "travelling"},
|
||||
"label": {Past: "labelled", Gerund: "labelling"}, "model": {Past: "modelled", Gerund: "modelling"},
|
||||
"level": {Past: "levelled", Gerund: "levelling"},
|
||||
"level": {Past: "levelled", Gerund: "levelling"},
|
||||
"format": {Past: "formatted", Gerund: "formatting"},
|
||||
"analyse": {Past: "analysed", Gerund: "analysing"},
|
||||
"organise": {Past: "organised", Gerund: "organising"},
|
||||
|
|
@ -496,57 +447,6 @@ var irregularNouns = map[string]string{
|
|||
"calf": "calves", "loaf": "loaves", "thief": "thieves",
|
||||
}
|
||||
|
||||
// dualClassVerbs seeds additional regular verbs that are also common nouns in
|
||||
// dev/ops text. The forms are regular, but listing them here makes the
|
||||
// reversal tokeniser treat them as known bases for dual-class disambiguation.
|
||||
var dualClassVerbs = map[string]VerbForms{
|
||||
"change": {Past: "changed", Gerund: "changing"},
|
||||
"export": {Past: "exported", Gerund: "exporting"},
|
||||
"function": {Past: "functioned", Gerund: "functioning"},
|
||||
"handle": {Past: "handled", Gerund: "handling"},
|
||||
"host": {Past: "hosted", Gerund: "hosting"},
|
||||
"import": {Past: "imported", Gerund: "importing"},
|
||||
"link": {Past: "linked", Gerund: "linking"},
|
||||
"log": {Past: "logged", Gerund: "logging"},
|
||||
"merge": {Past: "merged", Gerund: "merging"},
|
||||
"patch": {Past: "patched", Gerund: "patching"},
|
||||
"process": {Past: "processed", Gerund: "processing"},
|
||||
"queue": {Past: "queued", Gerund: "queuing"},
|
||||
"release": {Past: "released", Gerund: "releasing"},
|
||||
"pull": {Past: "pulled", Gerund: "pulling"},
|
||||
"push": {Past: "pushed", Gerund: "pushing"},
|
||||
"stream": {Past: "streamed", Gerund: "streaming"},
|
||||
"tag": {Past: "tagged", Gerund: "tagging"},
|
||||
"trigger": {Past: "triggered", Gerund: "triggering"},
|
||||
"watch": {Past: "watched", Gerund: "watching"},
|
||||
"update": {Past: "updated", Gerund: "updating"},
|
||||
}
|
||||
|
||||
// dualClassNouns mirrors the same vocabulary as nouns so the tokeniser can
|
||||
// classify the base forms as ambiguous when they appear without inflection.
|
||||
var dualClassNouns = map[string]string{
|
||||
"change": "changes",
|
||||
"export": "exports",
|
||||
"function": "functions",
|
||||
"handle": "handles",
|
||||
"host": "hosts",
|
||||
"import": "imports",
|
||||
"link": "links",
|
||||
"log": "logs",
|
||||
"merge": "merges",
|
||||
"patch": "patches",
|
||||
"process": "processes",
|
||||
"queue": "queues",
|
||||
"release": "releases",
|
||||
"pull": "pulls",
|
||||
"push": "pushes",
|
||||
"stream": "streams",
|
||||
"tag": "tags",
|
||||
"trigger": "triggers",
|
||||
"watch": "watches",
|
||||
"update": "updates",
|
||||
}
|
||||
|
||||
var vowelSounds = map[string]bool{
|
||||
"hour": true, "honest": true, "honour": true, "honor": true, "heir": true, "herb": true,
|
||||
}
|
||||
|
|
|
|||
26
validate.go
26
validate.go
|
|
@ -4,8 +4,9 @@ package i18n
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core"
|
||||
log "dappco.re/go/core/log"
|
||||
"forge.lthn.ai/core/go-inference"
|
||||
)
|
||||
|
|
@ -44,18 +45,7 @@ type IrregularResult struct {
|
|||
|
||||
// articlePrompt builds a fill-in-the-blank prompt for article prediction.
|
||||
func articlePrompt(noun string) string {
|
||||
return articlePromptForLang(currentLangForGrammar(), noun)
|
||||
}
|
||||
|
||||
func articlePromptForLang(lang, noun string) string {
|
||||
noun = core.Trim(noun)
|
||||
if isFrenchLanguage(lang) {
|
||||
return core.Sprintf(
|
||||
"Complete with the correct article (le/la/l'/les/du/au/aux/un/une/des): ___ %s. Answer with just the article:",
|
||||
noun,
|
||||
)
|
||||
}
|
||||
return core.Sprintf(
|
||||
return fmt.Sprintf(
|
||||
"Complete with the correct article (a/an/the): ___ %s. Answer with just the article:",
|
||||
noun,
|
||||
)
|
||||
|
|
@ -63,7 +53,7 @@ func articlePromptForLang(lang, noun string) string {
|
|||
|
||||
// irregularPrompt builds a fill-in-the-blank prompt for irregular verb prediction.
|
||||
func irregularPrompt(verb, tense string) string {
|
||||
return core.Sprintf(
|
||||
return fmt.Sprintf(
|
||||
"What is the %s form of the verb '%s'? Answer with just the word:",
|
||||
tense, verb,
|
||||
)
|
||||
|
|
@ -71,14 +61,14 @@ func irregularPrompt(verb, tense string) string {
|
|||
|
||||
// collectGenerated runs a single-token generation and returns the trimmed, lowercased output.
|
||||
func collectGenerated(ctx context.Context, m inference.TextModel, prompt string) (string, error) {
|
||||
sb := core.NewBuilder()
|
||||
var sb strings.Builder
|
||||
for tok := range m.Generate(ctx, prompt, inference.WithMaxTokens(1), inference.WithTemperature(0.05)) {
|
||||
sb.WriteString(tok.Text)
|
||||
}
|
||||
if err := m.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return core.Trim(core.Lower(sb.String())), nil
|
||||
return strings.TrimSpace(strings.ToLower(sb.String())), nil
|
||||
}
|
||||
|
||||
// ValidateArticle checks whether a given article usage is grammatically correct
|
||||
|
|
@ -90,7 +80,7 @@ func ValidateArticle(ctx context.Context, m inference.TextModel, noun string, ar
|
|||
if err != nil {
|
||||
return ArticleResult{}, log.E("ValidateArticle", "validate: "+noun, err)
|
||||
}
|
||||
given := core.Trim(core.Lower(article))
|
||||
given := strings.TrimSpace(strings.ToLower(article))
|
||||
return ArticleResult{
|
||||
Noun: noun,
|
||||
Given: given,
|
||||
|
|
@ -109,7 +99,7 @@ func ValidateIrregular(ctx context.Context, m inference.TextModel, verb string,
|
|||
if err != nil {
|
||||
return IrregularResult{}, log.E("ValidateIrregular", "validate: "+verb+" ("+tense+")", err)
|
||||
}
|
||||
given := core.Trim(core.Lower(form))
|
||||
given := strings.TrimSpace(strings.ToLower(form))
|
||||
return IrregularResult{
|
||||
Verb: verb,
|
||||
Tense: tense,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ package i18n
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"iter"
|
||||
"testing"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/go-inference"
|
||||
)
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ func newMockIrregularModel(forms map[string]string) *mockGenerateModel {
|
|||
// containsVerb checks if the prompt contains the verb in the expected format.
|
||||
func containsVerb(prompt, verb string) bool {
|
||||
return len(prompt) > 0 && len(verb) > 0 &&
|
||||
contains(prompt, core.Sprintf("'%s'", verb))
|
||||
contains(prompt, fmt.Sprintf("'%s'", verb))
|
||||
}
|
||||
|
||||
// contains is a simple substring check (avoids importing strings in test).
|
||||
|
|
@ -327,30 +327,6 @@ func TestArticlePrompt(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestArticlePromptFrenchLocale(t *testing.T) {
|
||||
prev := Default()
|
||||
svc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
SetDefault(svc)
|
||||
t.Cleanup(func() {
|
||||
SetDefault(prev)
|
||||
})
|
||||
|
||||
if err := SetLanguage("fr"); err != nil {
|
||||
t.Fatalf("SetLanguage(fr) failed: %v", err)
|
||||
}
|
||||
|
||||
prompt := articlePrompt("livre")
|
||||
if !contains(prompt, "livre") {
|
||||
t.Errorf("prompt should contain the noun: %q", prompt)
|
||||
}
|
||||
if !contains(prompt, "le/la/l'/les/du/au/aux/un/une/des") {
|
||||
t.Errorf("prompt should mention French article options: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIrregularPrompt(t *testing.T) {
|
||||
prompt := irregularPrompt("swim", "past participle")
|
||||
if !contains(prompt, "'swim'") {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue