From ad1b76085a6de3aee75db57d356a956fe80bffa9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 18:20:21 +0200 Subject: [PATCH] test(09-03): add failing tests for form locale and dropdown options - The same cached schema must localize pl and en independently, including Accept-Language parent fallback and raw keys - YAML option maps keep order and scalar type, and a method provider is required at boot --- cabana/form_schema.go | 21 ++++ cabana/form_schema_test.go | 232 +++++++++++++++++++++++++++++++++++++ cabana/schema_types.go | 13 +++ 3 files changed, 266 insertions(+) diff --git a/cabana/form_schema.go b/cabana/form_schema.go index 5ca008b..08374ee 100644 --- a/cabana/form_schema.go +++ b/cabana/form_schema.go @@ -10,7 +10,10 @@ import ( "strconv" "strings" + "context" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/phrasebook" "github.com/goccy/go-yaml" "github.com/goccy/go-yaml/ast" ) @@ -455,6 +458,24 @@ func normalizeYAMLError(err error) error { return err } +// Localize resolves display strings for one request. It does not yet. +func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) (*FormView, error) { + if s == nil || ctx == nil { + return nil, fmt.Errorf("cabana: form localization is not implemented") + } + _ = tr + _ = provider + return nil, fmt.Errorf("cabana: form localization is not implemented") +} + +// CanonicalJSON will emit one localized response. It does not yet. +func (v *FormView) CanonicalJSON() ([]byte, error) { + if v == nil { + return nil, fmt.Errorf("cabana: form localization is not implemented") + } + return nil, fmt.Errorf("cabana: form localization is not implemented") +} + func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) { dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/") if dir == "." || strings.HasPrefix(dir, "..") { diff --git a/cabana/form_schema_test.go b/cabana/form_schema_test.go index f5ca978..35d72ab 100644 --- a/cabana/form_schema_test.go +++ b/cabana/form_schema_test.go @@ -2,14 +2,19 @@ package cabana import ( "bytes" + "context" "encoding/json" "io/fs" "strings" + "sync" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" + "git.golem15.com/golem15/summercms/phrasebook" + "git.golem15.com/golem15/summercms/towel" ) const formConfig = `name: demo.form @@ -331,4 +336,231 @@ func compileFormJSON(t *testing.T, ctl schemaController, config, fields string) return buf.String() } +func TestFormSchemaLocalization(t *testing.T) { + schema := mustCompileForm(t, schemaController{model: "Widget"}, strings.Replace(formConfig, "name: demo.form", "name: acme.demo::lang.form", 1), localizedFieldsYAML) + before := mustCanonical(t, schema) + en := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "en", Fallback: "en"}) + pl := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"}) + + enJSON := localizeJSON(t, schema, towel.WithLocale(context.Background(), "en"), en, nil) + plJSON := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), pl, nil) + if enJSON == plJSON { + t.Fatalf("locales collapsed:\n%s", enJSON) + } + for _, want := range []string{`"locale":"en"`, `"label":"Title"`, `"comment":"Comment"`, `"tab":"Tab"`, `"emptyOption":"None"`, `"name":"Form"`} { + if !strings.Contains(enJSON, want) { + t.Fatalf("en JSON missing %s:\n%s", want, enJSON) + } + } + for _, want := range []string{`"locale":"pl"`, `"label":"Tytuł"`, `"comment":"Komentarz"`, `"tab":"Zakładka"`, `"emptyOption":"Brak"`, `"name":"Formularz"`} { + if !strings.Contains(plJSON, want) { + t.Fatalf("pl JSON missing %s:\n%s", want, plJSON) + } + } + if !strings.Contains(enJSON, `"label":"missing.label"`) || !strings.Contains(plJSON, `"label":"missing.label"`) { + t.Fatalf("raw key fallback lost:\nen %s\npl %s", enJSON, plJSON) + } + after := mustCanonical(t, schema) + if before != after { + t.Fatalf("localization mutated the cached schema:\n%s\n%s", before, after) + } + + t.Run("parent and app locale", func(t *testing.T) { + app := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"}) + parent := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl-PL,en;q=0.5"), app, nil) + if !strings.Contains(parent, `"locale":"pl-PL"`) || !strings.Contains(parent, `"label":"Tytuł"`) { + t.Fatalf("Accept-Language parent fallback = %s", parent) + } + fallback := localizeJSON(t, schema, context.Background(), app, nil) + if !strings.Contains(fallback, `"locale":"pl"`) || !strings.Contains(fallback, `"name":"Formularz"`) { + t.Fatalf("app.locale fallback = %s", fallback) + } + }) +} + +func TestFormSchemaDropdownOptions(t *testing.T) { + schema := mustCompileForm(t, schemaController{model: "Widget"}, formConfig, optionMapYAML) + tr := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "pl", Fallback: "en"}) + got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil) + vinyl := strings.Index(got, `{"value":"vinyl","label":"Winyl"}`) + two := strings.Index(got, `{"value":2,"label":"Dwa"}`) + yes := strings.Index(got, `{"value":true,"label":"Yes"}`) + if vinyl < 0 || two < 0 || yes < 0 || !(vinyl < two && two < yes) { + t.Fatalf("option order or scalar type drifted: %s", got) + } + if strings.Contains(got, `"value":"2"`) || strings.Contains(got, `"value":"true"`) { + t.Fatalf("option values became strings: %s", got) + } + + methodSchema := mustCompileForm(t, providerController{schemaController: schemaController{model: "Widget"}}, formConfig, methodFieldsYAML) + methodJSON := localizeJSON(t, methodSchema, towel.WithLocale(context.Background(), "pl"), tr, providerController{}) + if !strings.Contains(methodJSON, `{"value":"vinyl","label":"Winyl"}`) || !strings.Contains(methodJSON, `{"value":"cd","label":"Płyta"}`) { + t.Fatalf("method options = %s", methodJSON) + } + if strings.Contains(methodJSON, "getFormatOptions") { + t.Fatalf("method name leaked into the response: %s", methodJSON) + } + source := mustCanonical(t, methodSchema) + if strings.Contains(source, "getFormatOptions") { + t.Fatalf("cached schema stored the method name in JSON: %s", source) + } + + modelSchema := mustCompileForm(t, sourceController{schemaController: schemaController{model: "Widget"}, rec: modelOptions{}}, formConfig, "fields:\n format:\n type: dropdown\n options: getFormatOptions\n") + modelJSON := localizeJSON(t, modelSchema, towel.WithLocale(context.Background(), "pl"), tr, modelOptions{}) + if !strings.Contains(modelJSON, `{"value":"lp","label":"Winyl"}`) { + t.Fatalf("model provider options = %s", modelJSON) + } + + for _, method := range []string{"getFormatOptions", "GetFormatOptions"} { + _, err := CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, "fields:\n format:\n type: dropdown\n options: "+method+"\n")) + if err == nil || !strings.Contains(err.Error(), method) || !strings.Contains(err.Error(), "acme.demo.widgets") || !strings.Contains(err.Error(), "models/widget/fields.yaml") { + t.Fatalf("method %s err = %v", method, err) + } + } +} + +func TestFormSchemaLocaleIsolation(t *testing.T) { + schema := mustCompileForm(t, schemaController{model: "Widget"}, strings.Replace(formConfig, "name: demo.form", "name: acme.demo::lang.form", 1), localizedFieldsYAML) + before := mustCanonical(t, schema) + tr := phrasebook.NewTranslator(formCatalog(t), phrasebook.Options{Locale: "en", Fallback: "en"}) + enWant := localizeJSON(t, schema, towel.WithLocale(context.Background(), "en"), tr, nil) + plWant := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil) + + var wg sync.WaitGroup + errCh := make(chan string, 32) + for i := 0; i < 16; i++ { + wg.Add(2) + go func() { + defer wg.Done() + got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "en"), tr, nil) + if got != enWant { + errCh <- "en drifted: " + got + } + }() + go func() { + defer wg.Done() + got := localizeJSON(t, schema, towel.WithLocale(context.Background(), "pl"), tr, nil) + if got != plWant { + errCh <- "pl drifted: " + got + } + }() + } + wg.Wait() + close(errCh) + for msg := range errCh { + t.Fatal(msg) + } + if after := mustCanonical(t, schema); after != before { + t.Fatal("concurrent localization mutated the cache") + } +} + +const localizedFieldsYAML = `fields: + title: + label: acme.demo::lang.title + comment: acme.demo::lang.comment + tab: acme.demo::lang.tab + emptyOption: acme.demo::lang.empty + type: dropdown + options: + vinyl: acme.demo::lang.vinyl + note: + label: missing.label + type: text +` + +const optionMapYAML = `fields: + format: + label: acme.demo::lang.title + type: dropdown + emptyOption: acme.demo::lang.empty + options: + vinyl: acme.demo::lang.vinyl + 2: acme.demo::lang.two + true: "Yes" +` + +const methodFieldsYAML = `fields: + Format: + label: acme.demo::lang.title + type: dropdown + options: getFormatOptions +` + +func formCatalog(t *testing.T) *phrasebook.Catalog { + t.Helper() + cat := phrasebook.NewCatalog() + err := cat.Load("acme.demo", fstest.MapFS{ + "lang/en/lang.yaml": &fstest.MapFile{Data: []byte("form: Form\ntitle: Title\ncomment: Comment\ntab: Tab\nempty: None\nvinyl: Vinyl\ncd: CD\ntwo: Two\n")}, + "lang/pl/lang.yaml": &fstest.MapFile{Data: []byte("form: Formularz\ntitle: Tytuł\ncomment: Komentarz\ntab: Zakładka\nempty: Brak\nvinyl: Winyl\ncd: Płyta\ntwo: Dwa\n")}, + }) + if err != nil { + t.Fatalf("catalog: %v", err) + } + return cat +} + +func mustCompileForm(t *testing.T, ctl pact.AdminController, config, fields string) *FormSchema { + t.Helper() + schema, err := CompileForm("acme.demo", ctl, formFS(config, fields)) + if err != nil { + t.Fatalf("compile: %v", err) + } + return schema +} + +func mustCanonical(t *testing.T, schema *FormSchema) string { + t.Helper() + raw, err := schema.CanonicalJSON() + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func localizeJSON(t *testing.T, schema *FormSchema, ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) string { + t.Helper() + view, err := schema.Localize(ctx, tr, provider) + if err != nil { + t.Fatalf("localize: %v", err) + } + raw, err := view.CanonicalJSON() + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + t.Fatalf("compact: %v\n%s", err, raw) + } + return buf.String() +} + +type providerController struct{ schemaController } + +func (providerController) DropdownOptions(field string) []pact.Option { + if field != "Format" { + return nil + } + return []pact.Option{ + {Value: "vinyl", Label: "acme.demo::lang.vinyl"}, + {Value: "cd", Label: "acme.demo::lang.cd"}, + } +} + +type sourceController struct { + schemaController + rec any +} + +func (s sourceController) NewRecord() any { return s.rec } + +type modelOptions struct{} + +func (modelOptions) DropdownOptions(field string) []pact.Option { + if field != "format" { + return nil + } + return []pact.Option{{Value: "lp", Label: "acme.demo::lang.vinyl"}} +} + var _ party.Plugin = formPlugin{} diff --git a/cabana/schema_types.go b/cabana/schema_types.go index 5c99ff6..3c7ee38 100644 --- a/cabana/schema_types.go +++ b/cabana/schema_types.go @@ -10,6 +10,19 @@ type FormSchema struct { Fields []FormField `json:"fields"` } +// FormView is one request's localized form, including the locale actually used. +type FormView struct { + Name string `json:"name,omitempty"` + ModelClass string `json:"modelClass,omitempty"` + Fields []FormField `json:"fields"` + Meta FormMeta `json:"meta"` +} + +// FormMeta reports the locale selected for a schema response. +type FormMeta struct { + Locale string `json:"locale"` +} + // FormField is one Winter field in source order. JSON keys keep Winter spelling. type FormField struct { Name string `json:"name"`