diff --git a/calibrate.go b/calibrate.go index 5e94713..2f7a393 100644 --- a/calibrate.go +++ b/calibrate.go @@ -52,9 +52,9 @@ func CalibrateDomains(ctx context.Context, modelA, modelB inference.TextModel, return nil, log.E("CalibrateDomains", "empty sample set", nil) } - cfg := defaultClassifyConfig() + configuration := defaultClassifyConfig() for _, o := range opts { - o(&cfg) + o(&configuration) } stats := &CalibrationStats{ @@ -66,18 +66,18 @@ 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] = core.Sprintf(configuration.promptTemplate, s.Text) } // Classify with model A. - domainsA, durA, err := classifyAll(ctx, modelA, prompts, cfg.batchSize) + domainsA, durA, err := classifyAll(ctx, modelA, prompts, configuration.batchSize) if err != nil { return nil, log.E("CalibrateDomains", "classify with model A", err) } stats.DurationA = durA // Classify with model B. - domainsB, durB, err := classifyAll(ctx, modelB, prompts, cfg.batchSize) + domainsB, durB, err := classifyAll(ctx, modelB, prompts, configuration.batchSize) if err != nil { return nil, log.E("CalibrateDomains", "classify with model B", err) } diff --git a/calibrate_test.go b/calibrate_test.go index 26ff0f8..65e86a7 100644 --- a/calibrate_test.go +++ b/calibrate_test.go @@ -194,6 +194,71 @@ func TestCalibrateDomains_EmptySamples(t *testing.T) { } } +// TestCalibrateDomains_Good verifies full agreement returns rate of 1.0. +// +// stats.AgreementRate == 1.0 when both models return the same domain +func TestCalibrateDomains_Good(t *testing.T) { + model := &mockModel{ + classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) { + results := make([]inference.ClassifyResult, len(prompts)) + for i := range prompts { + results[i] = inference.ClassifyResult{Token: inference.Token{Text: "technical"}} + } + return results, nil + }, + } + samples := []CalibrationSample{{Text: "Build the image", TrueDomain: "technical"}} + stats, err := CalibrateDomains(context.Background(), model, model, samples) + if err != nil { + t.Fatalf("CalibrateDomains: %v", err) + } + if stats.AgreementRate != 1.0 { + t.Errorf("AgreementRate = %f, want 1.0", stats.AgreementRate) + } +} + +// TestCalibrateDomains_Bad verifies nil samples return an error. +// +// CalibrateDomains(ctx, m, m, nil) // error +func TestCalibrateDomains_Bad(t *testing.T) { + model := &mockModel{ + classifyFunc: func(_ context.Context, _ []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) { + return nil, nil + }, + } + _, err := CalibrateDomains(context.Background(), model, model, nil) + if err == nil { + t.Error("expected error for nil samples") + } +} + +// TestCalibrateDomains_Ugly verifies samples with no TrueDomain are still counted. +// +// stats.WithTruth == 0 when no samples have TrueDomain set +func TestCalibrateDomains_Ugly(t *testing.T) { + model := &mockModel{ + classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) { + results := make([]inference.ClassifyResult, len(prompts)) + for i := range prompts { + results[i] = inference.ClassifyResult{Token: inference.Token{Text: "casual"}} + } + return results, nil + }, + } + // Sample has no TrueDomain — unusual but valid. + samples := []CalibrationSample{{Text: "random text"}} + stats, err := CalibrateDomains(context.Background(), model, model, samples) + if err != nil { + t.Fatalf("CalibrateDomains: %v", err) + } + if stats.WithTruth != 0 { + t.Errorf("WithTruth = %d, want 0 (no ground truth provided)", stats.WithTruth) + } + if stats.Total != 1 { + t.Errorf("Total = %d, want 1", stats.Total) + } +} + func TestCalibrateDomains_BatchBoundary(t *testing.T) { // 7 samples with batch size 3: tests partial last batch. model := &mockModel{ diff --git a/classify.go b/classify.go index 4dc8dbe..8efe375 100644 --- a/classify.go +++ b/classify.go @@ -81,9 +81,9 @@ func mapTokenToDomain(token string) string { func ClassifyCorpus(ctx context.Context, model inference.TextModel, input io.Reader, output io.Writer, opts ...ClassifyOption) (*ClassifyStats, error) { - cfg := defaultClassifyConfig() + configuration := defaultClassifyConfig() for _, o := range opts { - o(&cfg) + o(&configuration) } stats := &ClassifyStats{ByDomain: make(map[string]int)} @@ -105,7 +105,7 @@ 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] = core.Sprintf(configuration.promptTemplate, p.prompt) } results, err := model.Classify(ctx, prompts, inference.WithMaxTokens(1)) if err != nil { @@ -134,7 +134,7 @@ func ClassifyCorpus(ctx context.Context, model inference.TextModel, stats.Skipped++ continue } - promptVal, ok := record[cfg.promptField] + promptVal, ok := record[configuration.promptField] if !ok { stats.Skipped++ continue @@ -146,7 +146,7 @@ func ClassifyCorpus(ctx context.Context, model inference.TextModel, } batch = append(batch, pending{record: record, prompt: prompt}) - if len(batch) >= cfg.batchSize { + if len(batch) >= configuration.batchSize { if err := flush(); err != nil { return stats, err } diff --git a/classify_test.go b/classify_test.go index 5858760..e382dd7 100644 --- a/classify_test.go +++ b/classify_test.go @@ -151,6 +151,34 @@ func TestClassifyCorpus_SkipsMalformed(t *testing.T) { } } +// TestMapTokenToDomain_Good verifies a known domain token maps correctly. +// +// mapTokenToDomain("technical") // "technical" +func TestMapTokenToDomain_Good(t *testing.T) { + if got := mapTokenToDomain("technical"); got != "technical" { + t.Errorf("mapTokenToDomain(technical) = %q, want %q", got, "technical") + } +} + +// TestMapTokenToDomain_Bad verifies an empty token maps to unknown. +// +// mapTokenToDomain("") // "unknown" +func TestMapTokenToDomain_Bad(t *testing.T) { + if got := mapTokenToDomain(""); got != "unknown" { + t.Errorf("mapTokenToDomain(\"\") = %q, want %q", got, "unknown") + } +} + +// TestMapTokenToDomain_Ugly verifies a word that starts with a domain name but is not a domain +// maps to unknown — prefix collision must not fire. +// +// mapTokenToDomain("cascade") // "unknown" +func TestMapTokenToDomain_Ugly(t *testing.T) { + if got := mapTokenToDomain("cascade"); got != "unknown" { + t.Errorf("mapTokenToDomain(cascade) = %q, want %q", got, "unknown") + } +} + func TestClassifyCorpus_DomainMapping(t *testing.T) { model := &mockModel{ classifyFunc: func(_ context.Context, prompts []string, _ ...inference.GenerateOption) ([]inference.ClassifyResult, error) { diff --git a/compose.go b/compose.go index 5e53dd0..d66d0ec 100644 --- a/compose.go +++ b/compose.go @@ -1,6 +1,11 @@ package i18n -import "fmt" +import "dappco.re/go/core" + +// stringer is a local interface for values that can describe themselves as a string. +type stringer interface { + String() string +} // S creates a new Subject with the given noun and value. // @@ -62,15 +67,15 @@ func (s *Subject) String() string { if s == nil { return "" } - if stringer, ok := s.Value.(fmt.Stringer); ok { - return stringer.String() + if v, ok := s.Value.(stringer); ok { + return v.String() } - return fmt.Sprint(s.Value) + return core.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 fmt.Sprint(s.count) } +func (s *Subject) CountString() string { if s == nil { return "1" }; return core.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 } diff --git a/compose_test.go b/compose_test.go index e4cd0e7..b3f2d6d 100644 --- a/compose_test.go +++ b/compose_test.go @@ -209,3 +209,32 @@ func TestSubject_FullChain_Good(t *testing.T) { assert.True(t, subj.IsFormal()) assert.True(t, subj.IsPlural()) } + +// TestSubject_String_Ugly verifies that an integer value is rendered as a decimal string. +// +// S("count", 42).String() // "42" +func TestSubject_String_Ugly(t *testing.T) { + subject := S("count", 42) + if got := subject.String(); got != "42" { + t.Errorf("S(count, 42).String() = %q, want %q", got, "42") + } +} + +// TestSubject_Count_Ugly verifies a zero count makes the subject plural. +// +// S("item", "x").Count(0).IsPlural() // true +func TestSubject_Count_Ugly(t *testing.T) { + subject := S("item", "x").Count(0) + if !subject.IsPlural() { + t.Error("Count(0) should make IsPlural() true") + } +} + +// TestS_Ugly verifies that a nil value does not panic. +// +// S("file", nil).String() // "" (implementation-defined) +func TestS_Ugly(t *testing.T) { + subject := S("file", nil) + got := subject.String() + _ = got // must not panic +} diff --git a/context_test.go b/context_test.go index 31fe215..4b68d17 100644 --- a/context_test.go +++ b/context_test.go @@ -114,3 +114,13 @@ func TestTranslationContext_FullChain_Good(t *testing.T) { assert.Equal(t, FormalityFormal, ctx.FormalityValue()) assert.Equal(t, "cardiology", ctx.Get("speciality")) } + +// TestTranslationContext_Set_Ugly verifies overwriting an existing key in Extra works. +// +// C("x").Set("k", "a").Set("k", "b").Get("k") // "b" +func TestTranslationContext_Set_Ugly(t *testing.T) { + ctx := C("x").Set("k", "a").Set("k", "b") + if got := ctx.Get("k"); got != "b" { + t.Errorf("Set overwrite: Get(k) = %v, want %q", got, "b") + } +} diff --git a/debug_test.go b/debug_test.go index c89ee5c..fa696b5 100644 --- a/debug_test.go +++ b/debug_test.go @@ -69,3 +69,23 @@ func TestDebugMode_Good_Integration(t *testing.T) { got = svc.Raw("prompt.yes") assert.Equal(t, "[prompt.yes] y", got) } + +// TestSetDebug_Bad verifies that calling SetDebug on a nil service does not panic. +// +// SetDefault(nil); SetDebug(true) // must not panic +func TestSetDebug_Bad(t *testing.T) { + SetDefault(nil) + SetDebug(true) // must not panic + // Restore a working default for subsequent tests. + svc, err := New() + require.NoError(t, err) + SetDefault(svc) +} + +// TestDebugFormat_Ugly verifies that both key and text empty produces "[] ". +// +// debugFormat("", "") // "[] " +func TestDebugFormat_Ugly(t *testing.T) { + got := debugFormat("", "") + assert.Equal(t, "[] ", got) +} diff --git a/grammar_test.go b/grammar_test.go index 2d6ef75..ee2efc1 100644 --- a/grammar_test.go +++ b/grammar_test.go @@ -592,6 +592,101 @@ func TestTemplateFuncs(t *testing.T) { } } +// --- AX: Good/Bad/Ugly --- + +// TestPastTense_Good verifies correct past tense for a known regular verb. +// +// PastTense("deploy") // "deployed" +func TestPastTense_Good(t *testing.T) { + svc, _ := New() + SetDefault(svc) + if got := PastTense("deploy"); got != "deployed" { + t.Errorf("PastTense(deploy) = %q, want %q", got, "deployed") + } +} + +// TestPastTense_Bad verifies that an empty verb returns an empty string. +// +// PastTense("") // "" +func TestPastTense_Bad(t *testing.T) { + svc, _ := New() + SetDefault(svc) + if got := PastTense(""); got != "" { + t.Errorf("PastTense(\"\") = %q, want empty", got) + } +} + +// TestPastTense_Ugly verifies that a whitespace-only verb is trimmed and returns empty. +// +// PastTense(" ") // "" +func TestPastTense_Ugly(t *testing.T) { + svc, _ := New() + SetDefault(svc) + if got := PastTense(" "); got != "" { + t.Errorf("PastTense(whitespace) = %q, want empty", got) + } +} + +// TestGerund_Good verifies correct gerund for a known verb. +// +// Gerund("build") // "building" +func TestGerund_Good(t *testing.T) { + svc, _ := New() + SetDefault(svc) + if got := Gerund("build"); got != "building" { + t.Errorf("Gerund(build) = %q, want %q", got, "building") + } +} + +// TestGerund_Bad verifies that an empty verb returns empty. +// +// Gerund("") // "" +func TestGerund_Bad(t *testing.T) { + svc, _ := New() + SetDefault(svc) + if got := Gerund(""); got != "" { + t.Errorf("Gerund(\"\") = %q, want empty", got) + } +} + +// TestGerund_Ugly verifies that a mixed-case verb is normalised before lookup. +// +// Gerund("BUILD") // "building" +func TestGerund_Ugly(t *testing.T) { + svc, _ := New() + SetDefault(svc) + if got := Gerund("BUILD"); got != "building" { + t.Errorf("Gerund(BUILD) = %q, want %q", got, "building") + } +} + +// TestArticle_Good verifies correct indefinite article for a known noun. +// +// Article("error") // "an" +func TestArticle_Good(t *testing.T) { + if got := Article("error"); got != "an" { + t.Errorf("Article(error) = %q, want %q", got, "an") + } +} + +// TestArticle_Bad verifies that an empty string returns empty. +// +// Article("") // "" +func TestArticle_Bad(t *testing.T) { + if got := Article(""); got != "" { + t.Errorf("Article(\"\") = %q, want empty", got) + } +} + +// TestArticle_Ugly verifies the silent-h exception "hour" gets "an". +// +// Article("hour") // "an" +func TestArticle_Ugly(t *testing.T) { + if got := Article("hour"); got != "an" { + t.Errorf("Article(hour) = %q, want %q", got, "an") + } +} + // --- Benchmarks --- func BenchmarkPastTense_Irregular(b *testing.B) { diff --git a/handler_test.go b/handler_test.go index 3a954fa..e8dc79d 100644 --- a/handler_test.go +++ b/handler_test.go @@ -191,3 +191,72 @@ func TestDefaultHandlers(t *testing.T) { t.Errorf("DefaultHandlers() returned %d handlers, want 6", len(handlers)) } } + +// TestLabelHandler_Good verifies a label key produces a colon-suffixed result. +// +// h.Handle("i18n.label.status", nil, nil) // "Status:" +func TestLabelHandler_Good(t *testing.T) { + svc, _ := New() + SetDefault(svc) + h := LabelHandler{} + if got := h.Handle("i18n.label.status", nil, nil); got != "Status:" { + t.Errorf("LabelHandler.Handle = %q, want %q", got, "Status:") + } +} + +// TestLabelHandler_Bad verifies that a non-matching key is not handled. +// +// h.Match("other.key") // false +func TestLabelHandler_Bad(t *testing.T) { + h := LabelHandler{} + if h.Match("other.key") { + t.Error("LabelHandler should not match other.key") + } +} + +// TestLabelHandler_Ugly verifies that an empty word suffix returns a colon. +// +// h.Handle("i18n.label.", nil, nil) // ":" +func TestLabelHandler_Ugly(t *testing.T) { + svc, _ := New() + SetDefault(svc) + h := LabelHandler{} + got := h.Handle("i18n.label.", nil, nil) + // An empty word suffix produces just the punctuation — should not panic. + _ = got +} + +// TestProgressHandler_Good verifies a progress key produces a gerund message. +// +// h.Handle("i18n.progress.build", nil, nil) // "Building..." +func TestProgressHandler_Good(t *testing.T) { + svc, _ := New() + SetDefault(svc) + h := ProgressHandler{} + if got := h.Handle("i18n.progress.build", nil, nil); got != "Building..." { + t.Errorf("ProgressHandler.Handle = %q, want %q", got, "Building...") + } +} + +// TestProgressHandler_Bad verifies that a non-matching key returns false from Match. +// +// h.Match("i18n.label.status") // false +func TestProgressHandler_Bad(t *testing.T) { + h := ProgressHandler{} + if h.Match("i18n.label.status") { + t.Error("ProgressHandler should not match i18n.label.* keys") + } +} + +// TestProgressHandler_Ugly verifies that a subject string appended to the progress message works. +// +// h.Handle("i18n.progress.build", []any{"config.yaml"}, nil) // "Building config.yaml..." +func TestProgressHandler_Ugly(t *testing.T) { + svc, _ := New() + SetDefault(svc) + h := ProgressHandler{} + got := h.Handle("i18n.progress.build", []any{"config.yaml"}, nil) + if got != "Building config.yaml..." { + t.Errorf("ProgressHandler.Handle with subject = %q, want %q", got, "Building config.yaml...") + } +} diff --git a/hooks.go b/hooks.go index 8b3eeb5..15c0f2a 100644 --- a/hooks.go +++ b/hooks.go @@ -2,10 +2,11 @@ package i18n import ( "io/fs" - "log" "runtime" "sync" "sync/atomic" + + log "dappco.re/go/core/log" ) var missingKeyHandler atomic.Value @@ -37,7 +38,7 @@ func RegisterLocales(fsys fs.FS, dir string) { 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) + log.Error("i18n: RegisterLocales failed to load locale directory", "dir", dir, "err", err) } } } @@ -48,7 +49,7 @@ func loadRegisteredLocales(svc *Service) { defer registeredLocalesMu.Unlock() for _, reg := range registeredLocales { if err := svc.LoadFS(reg.fsys, reg.dir); err != nil { - log.Printf("i18n: loadRegisteredLocales failed to load %q: %v", reg.dir, err) + log.Error("i18n: loadRegisteredLocales failed to load locale directory", "dir", reg.dir, "err", err) } } localesLoaded = true diff --git a/hooks_test.go b/hooks_test.go index d6c78b0..5aaaedf 100644 --- a/hooks_test.go +++ b/hooks_test.go @@ -134,3 +134,28 @@ func TestDispatchMissingKey_Good_NoHandler(t *testing.T) { // Should not panic when dispatching with nil handler dispatchMissingKey("test.key", nil) } + +// TestOnMissingKey_Bad verifies that registering a nil handler clears any existing handler. +// +// OnMissingKey(nil) // clears handler; subsequent dispatch is a no-op +func TestOnMissingKey_Bad(t *testing.T) { + called := false + OnMissingKey(func(_ MissingKey) { called = true }) + OnMissingKey(nil) // clear the handler + dispatchMissingKey("any.key", nil) + if called { + t.Error("handler should have been cleared by OnMissingKey(nil)") + } +} + +// TestRegisterLocales_Ugly verifies re-registering the embedded locales is idempotent and safe. +// +// RegisterLocales(localeFS, "locales") // idempotent; must not panic +func TestRegisterLocales_Ugly(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("RegisterLocales panicked: %v", r) + } + }() + RegisterLocales(localeFS, "locales") +} diff --git a/i18n_test.go b/i18n_test.go index 2f0d4c5..75c67f4 100644 --- a/i18n_test.go +++ b/i18n_test.go @@ -261,3 +261,27 @@ func TestErrServiceNotInitialised_Good(t *testing.T) { func TestErrServiceNotInitialized_DeprecatedAlias(t *testing.T) { assert.Equal(t, ErrServiceNotInitialised, ErrServiceNotInitialized, "deprecated alias must point to the same error") } + +// TestT_Ugly verifies T() with no default service returns the key unchanged. +// +// SetDefault(nil); T("any.key") // "any.key" +func TestT_Ugly(t *testing.T) { + SetDefault(nil) + got := T("any.key") + assert.Equal(t, "any.key", got) + svc, err := New() + require.NoError(t, err) + SetDefault(svc) +} + +// TestSetLanguage_Ugly verifies SetLanguage returns ErrServiceNotInitialised with no default. +// +// SetDefault(nil); SetLanguage("en") // ErrServiceNotInitialised +func TestSetLanguage_Ugly(t *testing.T) { + SetDefault(nil) + err := SetLanguage("en") + assert.ErrorIs(t, err, ErrServiceNotInitialised) + svc, initErr := New() + require.NoError(t, initErr) + SetDefault(svc) +} diff --git a/language_test.go b/language_test.go index dec8ed6..7658d26 100644 --- a/language_test.go +++ b/language_test.go @@ -81,3 +81,30 @@ func TestGetPluralRule(t *testing.T) { t.Error("Unknown rule(1) should fallback to English PluralOne") } } + +// TestGetPluralCategory_Good verifies German singular follows English rules. +// +// GetPluralCategory("de", 1) // PluralOne +func TestGetPluralCategory_Good(t *testing.T) { + if got := GetPluralCategory("de", 1); got != PluralOne { + t.Errorf("GetPluralCategory(de, 1) = %v, want PluralOne", got) + } +} + +// TestGetPluralCategory_Bad verifies an unknown language code uses English rules. +// +// GetPluralCategory("zz", 2) // PluralOther +func TestGetPluralCategory_Bad(t *testing.T) { + if got := GetPluralCategory("zz", 2); got != PluralOther { + t.Errorf("GetPluralCategory(zz, 2) = %v, want PluralOther", got) + } +} + +// TestGetPluralCategory_Ugly verifies negative counts don't panic. +// +// GetPluralCategory("en", -1) // PluralOther (falls through) +func TestGetPluralCategory_Ugly(t *testing.T) { + // Negative counts are unusual but must not panic. + got := GetPluralCategory("en", -1) + _ = got // result is implementation-defined; just verify no panic +} diff --git a/loader.go b/loader.go index 41c02df..8fd87b4 100644 --- a/loader.go +++ b/loader.go @@ -3,7 +3,6 @@ package i18n import ( "io/fs" "path" - "strings" "sync" "dappco.re/go/core" @@ -117,8 +116,8 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa // 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 + if core.HasPrefix(fullKey, "gram.verb.") { + verbName = core.TrimPrefix(fullKey, "gram.verb.") } forms := VerbForms{} if past, ok := v["past"].(string); ok { @@ -134,8 +133,8 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa // Noun form object (under gram.noun.* or has gender field) if grammar != nil && (core.HasPrefix(fullKey, "gram.noun.") || isNounFormObject(v)) { nounName := key - if after, ok := strings.CutPrefix(fullKey, "gram.noun."); ok { - nounName = after + if core.HasPrefix(fullKey, "gram.noun.") { + nounName = core.TrimPrefix(fullKey, "gram.noun.") } _, hasOne := v["one"] _, hasOther := v["other"] diff --git a/loader_test.go b/loader_test.go index 2605f8a..18b6dc3 100644 --- a/loader_test.go +++ b/loader_test.go @@ -291,3 +291,27 @@ func TestCustomFSLoader(t *testing.T) { t.Errorf("verb 'zap' not loaded correctly") } } + +// TestFSLoader_Ugly verifies that loading a locale with only a gram block (no messages) works. +// +// loader.Load("en") // returns empty messages map but populated grammar +func TestFSLoader_Ugly(t *testing.T) { + gramOnly := `{"gram":{"verb":{"zap":{"past":"zapped","gerund":"zapping"}}}}` + fsys := fstest.MapFS{ + "locales/eo.json": &fstest.MapFile{Data: []byte(gramOnly)}, + } + loader := NewFSLoader(fsys, "locales") + messages, grammar, err := loader.Load("eo") + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(messages) != 0 { + t.Errorf("messages = %d, want 0 for gram-only locale", len(messages)) + } + if grammar == nil { + t.Fatal("grammar should not be nil") + } + if v, ok := grammar.Verbs["zap"]; !ok || v.Past != "zapped" { + t.Errorf("gram-only verb not loaded: %v", grammar.Verbs) + } +} diff --git a/localise.go b/localise.go index 794368c..17599d1 100644 --- a/localise.go +++ b/localise.go @@ -1,8 +1,6 @@ package i18n import ( - "os" - "dappco.re/go/core" "golang.org/x/text/language" ) @@ -85,11 +83,11 @@ func Direction() TextDirection { func IsRTL() bool { return Direction() == DirRTL } func detectLanguage(supported []language.Tag) string { - langEnv := os.Getenv("LANG") + langEnv := core.Env("LANG") if langEnv == "" { - langEnv = os.Getenv("LC_ALL") + langEnv = core.Env("LC_ALL") if langEnv == "" { - langEnv = os.Getenv("LC_MESSAGES") + langEnv = core.Env("LC_MESSAGES") } } if langEnv == "" { diff --git a/localise_test.go b/localise_test.go index 9f6157b..3cdb977 100644 --- a/localise_test.go +++ b/localise_test.go @@ -168,3 +168,28 @@ func TestMode_String_Good(t *testing.T) { }) } } + +// TestIsRTLLanguage_Bad verifies an unknown language code returns false. +// +// IsRTLLanguage("xx") // false +func TestIsRTLLanguage_Bad(t *testing.T) { + assert.False(t, IsRTLLanguage("xx")) +} + +// TestFormality_Ugly verifies an out-of-range Formality value returns "neutral". +// +// Formality(99).String() // "neutral" +func TestFormality_Ugly(t *testing.T) { + assert.Equal(t, "neutral", Formality(99).String()) +} + +// TestSetFormality_Ugly verifies SetFormality on nil default does not panic. +// +// SetDefault(nil); SetFormality(FormalityFormal) // must not panic +func TestSetFormality_Ugly(t *testing.T) { + SetDefault(nil) + SetFormality(FormalityFormal) // must not panic + svc, err := New() + require.NoError(t, err) + SetDefault(svc) +} diff --git a/numbers.go b/numbers.go index b84a4ea..4da6a0d 100644 --- a/numbers.go +++ b/numbers.go @@ -12,8 +12,8 @@ func getNumberFormat() NumberFormat { if idx := indexAny(lang, "-_"); idx > 0 { lang = lang[:idx] } - if fmt, ok := numberFormats[lang]; ok { - return fmt + if numberFormat, ok := numberFormats[lang]; ok { + return numberFormat } return numberFormats["en"] } diff --git a/numbers_test.go b/numbers_test.go index e86f86a..281fa47 100644 --- a/numbers_test.go +++ b/numbers_test.go @@ -143,3 +143,36 @@ func TestFormatOrdinal(t *testing.T) { } } } + +// TestFormatNumber_Good verifies thousands separator is applied for large numbers. +// +// FormatNumber(1000000) // "1,000,000" +func TestFormatNumber_Good(t *testing.T) { + svc, _ := New() + SetDefault(svc) + if got := FormatNumber(1_000_000); got != "1,000,000" { + t.Errorf("FormatNumber(1000000) = %q, want %q", got, "1,000,000") + } +} + +// TestFormatNumber_Bad verifies negative numbers are formatted correctly. +// +// FormatNumber(-42) // "-42" +func TestFormatNumber_Bad(t *testing.T) { + svc, _ := New() + SetDefault(svc) + if got := FormatNumber(-42); got != "-42" { + t.Errorf("FormatNumber(-42) = %q, want %q", got, "-42") + } +} + +// TestFormatNumber_Ugly verifies zero is formatted without separators. +// +// FormatNumber(0) // "0" +func TestFormatNumber_Ugly(t *testing.T) { + svc, _ := New() + SetDefault(svc) + if got := FormatNumber(0); got != "0" { + t.Errorf("FormatNumber(0) = %q, want %q", got, "0") + } +} diff --git a/reversal/anomaly_test.go b/reversal/anomaly_test.go index 568bce6..81af50e 100644 --- a/reversal/anomaly_test.go +++ b/reversal/anomaly_test.go @@ -167,3 +167,52 @@ func TestAnomalyStats_Rate(t *testing.T) { t.Errorf("Rate = %.2f, want ~1.0 (all should be anomalies)", stats.Rate) } } + +// TestDetectAnomalies_Good verifies that full agreement produces zero anomalies. +// +// rs.DetectAnomalies(tok, samples) // stats.Anomalies == 0 +func TestDetectAnomalies_Good(t *testing.T) { + tok := initI18n(t) + refSamples := []ClassifiedText{ + {Text: "Delete the file", Domain: "technical"}, + {Text: "Build the project", Domain: "technical"}, + } + rs, err := BuildReferences(tok, refSamples) + if err != nil { + t.Fatalf("BuildReferences: %v", err) + } + _, stats := rs.DetectAnomalies(tok, refSamples) + if stats.Anomalies != 0 { + t.Errorf("Anomalies = %d, want 0 for matching domain samples", stats.Anomalies) + } +} + +// TestDetectAnomalies_Bad verifies that empty test samples produce zero anomalies. +// +// rs.DetectAnomalies(tok, nil) // stats.Total == 0 +func TestDetectAnomalies_Bad(t *testing.T) { + tok := initI18n(t) + refSamples := []ClassifiedText{ + {Text: "Delete the file", Domain: "technical"}, + } + rs, _ := BuildReferences(tok, refSamples) + _, stats := rs.DetectAnomalies(tok, nil) + if stats.Total != 0 { + t.Errorf("Total = %d, want 0 for empty input", stats.Total) + } +} + +// TestDetectAnomalies_Ugly verifies that samples with empty Domain are skipped. +// +// rs.DetectAnomalies(tok, []{{Text: "foo", Domain: ""}}) // stats.Total == 0 +func TestDetectAnomalies_Ugly(t *testing.T) { + tok := initI18n(t) + refSamples := []ClassifiedText{ + {Text: "Delete the file", Domain: "technical"}, + } + rs, _ := BuildReferences(tok, refSamples) + _, stats := rs.DetectAnomalies(tok, []ClassifiedText{{Text: "anything", Domain: ""}}) + if stats.Total != 0 { + t.Errorf("Total = %d, want 0 (empty Domain skipped)", stats.Total) + } +} diff --git a/reversal/imprint_test.go b/reversal/imprint_test.go index 7f4421c..cb94877 100644 --- a/reversal/imprint_test.go +++ b/reversal/imprint_test.go @@ -157,3 +157,39 @@ func TestImprint_ConfidenceWeighting_BackwardsCompat(t *testing.T) { t.Error("NounDistribution should contain 'file'") } } + +// TestNewImprint_Good verifies a verb token contributes to VerbDistribution. +// +// NewImprint(tokens).VerbDistribution["delete"] > 0 +func TestNewImprint_Good(t *testing.T) { + tok := initI18n(t) + tokens := tok.Tokenise("Delete the file") + imp := NewImprint(tokens) + if imp.VerbDistribution["delete"] == 0 { + t.Error("VerbDistribution should contain 'delete'") + } +} + +// TestNewImprint_Bad verifies an empty token slice produces a zero-value imprint. +// +// NewImprint(nil).TokenCount == 0 +func TestNewImprint_Bad(t *testing.T) { + imp := NewImprint(nil) + if imp.TokenCount != 0 { + t.Errorf("TokenCount = %d, want 0 for nil tokens", imp.TokenCount) + } +} + +// TestImprint_Similar_Ugly verifies similarity between one empty and one non-empty imprint is 0. +// +// empty.Similar(nonEmpty) // 0.0 +func TestImprint_Similar_Ugly(t *testing.T) { + tok := initI18n(t) + tokens := tok.Tokenise("Build the image") + nonEmpty := NewImprint(tokens) + empty := NewImprint(nil) + sim := empty.Similar(nonEmpty) + if sim != 0.0 { + t.Errorf("empty.Similar(nonEmpty) = %f, want 0.0", sim) + } +} diff --git a/reversal/multiplier_test.go b/reversal/multiplier_test.go index 3dc62ad..6cc10d1 100644 --- a/reversal/multiplier_test.go +++ b/reversal/multiplier_test.go @@ -107,3 +107,40 @@ func TestMultiplier_TransformedTokenConfidence(t *testing.T) { t.Error("Noun-transformed token has zero Confidence, want 1.0") } } + +// TestMultiplier_Good verifies that Expand produces at least the original text. +// +// m.Expand("delete the file") // first element == "delete the file" +func TestMultiplier_Good(t *testing.T) { + initI18n(t) + m := NewMultiplier() + variants := m.Expand("delete the file") + if len(variants) == 0 { + t.Fatal("Expand should produce at least one variant") + } + if variants[0] != "delete the file" { + t.Errorf("first variant = %q, want original text", variants[0]) + } +} + +// TestMultiplier_Bad verifies that an empty string produces no variants. +// +// m.Expand("") // nil +func TestMultiplier_Bad(t *testing.T) { + m := NewMultiplier() + variants := m.Expand("") + if variants != nil { + t.Errorf("Expand(\"\") = %v, want nil", variants) + } +} + +// TestMultiplier_Ugly verifies that a whitespace-only input produces no variants. +// +// m.Expand(" ") // nil (trimmed to empty) +func TestMultiplier_Ugly(t *testing.T) { + m := NewMultiplier() + variants := m.Expand(" ") + if variants != nil { + t.Errorf("Expand(whitespace) = %v, want nil", variants) + } +} diff --git a/reversal/reference_test.go b/reversal/reference_test.go index 83bb220..d712b94 100644 --- a/reversal/reference_test.go +++ b/reversal/reference_test.go @@ -233,3 +233,48 @@ func TestComputeVariance_SingleSample(t *testing.T) { t.Errorf("Single-sample variance should be nil, got %v", v) } } + +// TestBuildReferences_Good verifies a non-empty sample set builds a valid ReferenceSet. +// +// BuildReferences(tok, samples).Domains["technical"] != nil +func TestBuildReferences_Good(t *testing.T) { + tok := initI18n(t) + samples := []ClassifiedText{ + {Text: "Delete the file", Domain: "technical"}, + } + rs, err := BuildReferences(tok, samples) + if err != nil { + t.Fatalf("BuildReferences: %v", err) + } + if rs.Domains["technical"] == nil { + t.Error("expected technical domain in reference set") + } +} + +// TestBuildReferences_Bad verifies empty samples return an error. +// +// BuildReferences(tok, nil) // error +func TestBuildReferences_Bad(t *testing.T) { + tok := initI18n(t) + _, err := BuildReferences(tok, nil) + if err == nil { + t.Error("expected error for empty samples") + } +} + +// TestReferenceSet_Classify_Ugly verifies classification when only one domain exists. +// +// rs.Classify(imp).Domain == "technical" (only option) +func TestReferenceSet_Classify_Ugly(t *testing.T) { + tok := initI18n(t) + samples := []ClassifiedText{ + {Text: "Build the project", Domain: "technical"}, + } + rs, _ := BuildReferences(tok, samples) + tokens := tok.Tokenise("Run the tests") + imp := NewImprint(tokens) + cls := rs.Classify(imp) + if cls.Domain != "technical" { + t.Errorf("Classify domain = %q, want %q (only domain)", cls.Domain, "technical") + } +} diff --git a/reversal/tokeniser.go b/reversal/tokeniser.go index 501665a..57f106c 100644 --- a/reversal/tokeniser.go +++ b/reversal/tokeniser.go @@ -16,8 +16,6 @@ package reversal import ( - "strings" - "dappco.re/go/core" i18n "dappco.re/go/core/i18n" ) @@ -619,7 +617,7 @@ func (t *Tokeniser) Tokenise(text string) []Token { return nil } - parts := strings.Fields(text) + parts := splitFields(text) var tokens []Token // --- Pass 1: Classify & Mark --- @@ -1025,3 +1023,29 @@ func DisambiguationStatsFromTokens(tokens []Token) DisambiguationStats { } return s } + +// splitFields splits text on runs of whitespace, equivalent to strings.Fields. +// text is expected to already be trimmed of leading/trailing whitespace. +// +// splitFields("delete the file") // ["delete", "the", "file"] +// splitFields("") // nil +func splitFields(text string) []string { + if text == "" { + return nil + } + var words []string + wordStart := -1 + for i, r := range text { + isSpace := r == ' ' || r == '\t' || r == '\n' || r == '\r' + if !isSpace && wordStart < 0 { + wordStart = i + } else if isSpace && wordStart >= 0 { + words = append(words, text[wordStart:i]) + wordStart = -1 + } + } + if wordStart >= 0 { + words = append(words, text[wordStart:]) + } + return words +} diff --git a/reversal/tokeniser_test.go b/reversal/tokeniser_test.go index 6cff2b0..c55ca83 100644 --- a/reversal/tokeniser_test.go +++ b/reversal/tokeniser_test.go @@ -588,6 +588,47 @@ func TestWithWeights_Override(t *testing.T) { } } +// --- AX: Good/Bad/Ugly --- + +// TestTokeniser_MatchVerb_Good verifies that a known past-tense verb is matched. +// +// tok.MatchVerb("deleted") // VerbMatch{Base:"delete", Tense:"past"} +func TestTokeniser_MatchVerb_Good(t *testing.T) { + setup(t) + tok := NewTokeniser() + match, ok := tok.MatchVerb("deleted") + if !ok { + t.Fatal("MatchVerb(deleted) returned false, want true") + } + if match.Base != "delete" { + t.Errorf("VerbMatch.Base = %q, want %q", match.Base, "delete") + } +} + +// TestTokeniser_MatchVerb_Bad verifies that a non-verb word is not matched. +// +// tok.MatchVerb("banana") // ok == false +func TestTokeniser_MatchVerb_Bad(t *testing.T) { + setup(t) + tok := NewTokeniser() + _, ok := tok.MatchVerb("banana") + if ok { + t.Error("MatchVerb(banana) returned true, want false") + } +} + +// TestTokeniser_Tokenise_Ugly verifies that tokenising whitespace-only text returns nil. +// +// tok.Tokenise(" ") // nil +func TestTokeniser_Tokenise_Ugly(t *testing.T) { + setup(t) + tok := NewTokeniser() + tokens := tok.Tokenise(" ") + if tokens != nil { + t.Errorf("Tokenise(whitespace) = %v, want nil", tokens) + } +} + // --- Benchmarks --- func benchSetup(b *testing.B) { diff --git a/service_test.go b/service_test.go index 76076c0..d7a7238 100644 --- a/service_test.go +++ b/service_test.go @@ -413,3 +413,20 @@ func TestServicePluralCategory(t *testing.T) { t.Errorf("PluralCategory(5) = %v, want PluralOther", svc.PluralCategory(5)) } } + +// TestService_Ugly verifies that T() in ModeStrict panics on a missing key. +// +// svc.SetMode(ModeStrict); svc.T("nonexistent.key") // panic +func TestService_Ugly(t *testing.T) { + svc, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + svc.SetMode(ModeStrict) + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for missing key in ModeStrict") + } + }() + svc.T("nonexistent.key.that.does.not.exist") +} diff --git a/time_test.go b/time_test.go index a0be0d1..0772dae 100644 --- a/time_test.go +++ b/time_test.go @@ -130,3 +130,14 @@ func TestFormatAgo_Good_SingularUnit(t *testing.T) { got := FormatAgo(1, "fortnight") assert.Equal(t, "1 fortnight ago", got) } + +// TestTimeAgo_Ugly verifies that a future time (negative duration) does not panic. +// +// TimeAgo(time.Now().Add(10 * time.Minute)) // implementation-defined but no panic +func TestTimeAgo_Ugly(t *testing.T) { + svc, err := New() + require.NoError(t, err) + SetDefault(svc) + got := TimeAgo(time.Now().Add(10 * time.Minute)) + _ = got // must not panic +} diff --git a/transform_test.go b/transform_test.go index e21606b..1d4048d 100644 --- a/transform_test.go +++ b/transform_test.go @@ -124,3 +124,25 @@ func TestToFloat64_Good(t *testing.T) { }) } } + +// TestGetCount_Bad verifies a map without a Count key returns 0. +// +// getCount(map[string]any{"other": 5}) // 0 +func TestGetCount_Bad(t *testing.T) { + data := map[string]any{"other": 5} + assert.Equal(t, 0, getCount(data)) +} + +// TestToInt_Ugly verifies that nil returns 0 without panic. +// +// toInt(nil) // 0 +func TestToInt_Ugly(t *testing.T) { + assert.Equal(t, 0, toInt(nil)) +} + +// TestToFloat64_Ugly verifies that nil returns 0 without panic. +// +// toFloat64(nil) // 0 +func TestToFloat64_Ugly(t *testing.T) { + assert.InDelta(t, 0.0, toFloat64(nil), 0.001) +} diff --git a/types_test.go b/types_test.go index 6f41853..d484e37 100644 --- a/types_test.go +++ b/types_test.go @@ -251,3 +251,36 @@ func TestIsRTLLanguage(t *testing.T) { } } } + +// TestMessage_Good verifies a simple message returns its text. +// +// Message{Text: "hello"}.ForCategory(PluralOther) // "hello" +func TestMessage_Good(t *testing.T) { + m := Message{Text: "hello"} + if got := m.ForCategory(PluralOther); got != "hello" { + t.Errorf("Message.ForCategory = %q, want %q", got, "hello") + } +} + +// TestMessage_Bad verifies an empty message returns empty string. +// +// Message{}.ForCategory(PluralOne) // "" +func TestMessage_Bad(t *testing.T) { + m := Message{} + if got := m.ForCategory(PluralOne); got != "" { + t.Errorf("empty Message.ForCategory = %q, want empty", got) + } +} + +// TestMessage_Ugly verifies plural forms are selected by category when both Text and plural forms are set. +// +// Message{Text:"item", One:"item", Other:"items"}.ForCategory(PluralOther) // "items" +func TestMessage_Ugly(t *testing.T) { + m := Message{Text: "item", One: "item", Other: "items"} + if got := m.ForCategory(PluralOne); got != "item" { + t.Errorf("Message.ForCategory(PluralOne) = %q, want %q", got, "item") + } + if got := m.ForCategory(PluralOther); got != "items" { + t.Errorf("Message.ForCategory(PluralOther) = %q, want %q", got, "items") + } +} diff --git a/validate.go b/validate.go index c7f3bec..2f14f79 100644 --- a/validate.go +++ b/validate.go @@ -60,14 +60,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() + builder := core.NewBuilder() for tok := range m.Generate(ctx, prompt, inference.WithMaxTokens(1), inference.WithTemperature(0.05)) { - sb.WriteString(tok.Text) + builder.WriteString(tok.Text) } if err := m.Err(); err != nil { return "", err } - return core.Trim(core.Lower(sb.String())), nil + return core.Trim(core.Lower(builder.String())), nil } // ValidateArticle checks whether a given article usage is grammatically correct diff --git a/validate_test.go b/validate_test.go index d5b1718..d4b8289 100644 --- a/validate_test.go +++ b/validate_test.go @@ -336,3 +336,33 @@ func TestIrregularPrompt(t *testing.T) { t.Errorf("prompt should contain the tense: %q", prompt) } } + +// TestArticlePrompt_Good verifies the article prompt contains the noun. +// +// articlePrompt("error") // contains "error" +func TestArticlePrompt_Good(t *testing.T) { + prompt := articlePrompt("error") + if !contains(prompt, "error") { + t.Errorf("articlePrompt should contain noun %q, got %q", "error", prompt) + } +} + +// TestArticlePrompt_Bad verifies an empty noun still produces a non-empty prompt. +// +// articlePrompt("") // non-empty +func TestArticlePrompt_Bad(t *testing.T) { + prompt := articlePrompt("") + if prompt == "" { + t.Error("articlePrompt(\"\") should not be empty") + } +} + +// TestIrregularPrompt_Ugly verifies the prompt is not empty for an unusual tense string. +// +// irregularPrompt("run", "subjunctive") // non-empty +func TestIrregularPrompt_Ugly(t *testing.T) { + prompt := irregularPrompt("run", "subjunctive") + if prompt == "" { + t.Error("irregularPrompt should not be empty for unusual tense") + } +}