test(17-06): framework and admin SPA unit tests for the blog companions

- signpost: activation refusals, host overrides, D-02 pattern layers
  incl. PatternFrom, ReferenceOptions depths
- seaglass: removal report, bluemonday bypass corpus, Check
- pact Option.Depth omitempty and the menu item type contracts; party
  activates menu item types after Register and before the first Boot
- cabana: repeater compile and decode, dependsOn compile and recheck,
  richeditor keys and notices, reorder and singleton boot checks
  (Docker-free); repeater save, dependent recheck, richeditor save,
  reorder and singleton routes on Postgres; conformance cases for the
  documented 404, 409 and 422 answers of the new routes
- admin: richeditor value contract, link popover, notices and chunk
  failure; repeater bounds, remove and reorder, collapsed rows;
  dependent dropdown states and last request wins; reorder
  announcements, partition switch and tree rules; SingletonForm spec
- gate: required names in the go, admin and postgres stages
This commit is contained in:
2026-10-12 19:29:37 +02:00
parent 490a4e3e6a
commit 91593da1f7
17 changed files with 3136 additions and 17 deletions

View File

@@ -0,0 +1,117 @@
package pact
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"gorm.io/gorm"
)
// TestOptionDepthOmitEmpty pins that Option.Depth leaves every flat option
// payload unchanged: depth 0 has no JSON key, a nested option carries it.
func TestOptionDepthOmitEmpty(t *testing.T) {
for _, tc := range []struct {
opt Option
want string
}{
{Option{Value: "a", Label: "A"}, `{"value":"a","label":"A"}`},
{Option{Value: "", Label: ""}, `{"value":"","label":""}`},
{Option{Value: "b", Label: "B", Depth: 1}, `{"value":"b","label":"B","depth":1}`},
{Option{Value: "c", Label: "C", Depth: 3}, `{"value":"c","label":"C","depth":3}`},
} {
raw, err := json.Marshal(tc.opt)
if err != nil || string(raw) != tc.want {
t.Fatalf("Marshal(%+v) = %s, %v; want %s", tc.opt, raw, err, tc.want)
}
var back Option
if err := json.Unmarshal(raw, &back); err != nil || back != tc.opt {
t.Fatalf("round trip of %s = %+v, %v", raw, back, err)
}
}
}
// menuTypesPlugin is a plugin-shaped value with menu item types.
type menuTypesPlugin struct{ types []MenuItemType }
func (p menuTypesPlugin) MenuItemTypes() []MenuItemType { return p.types }
// singletonController is a controller-shaped value that edits one record.
type singletonController struct{}
func (singletonController) SingletonRecord(context.Context, *gorm.DB) (any, error) { return 1, nil }
// dependentController answers the options of a dependent dropdown.
type dependentController struct{}
func (dependentController) DependentOptions(_ context.Context, field string, values map[string]any) (DependentOptionSet, error) {
if values["type"] == nil {
return DependentOptionSet{Applies: false}, nil
}
return DependentOptionSet{Applies: true, Options: []Option{{Value: field, Label: "x"}}}, nil
}
var (
_ HasMenuItemTypes = menuTypesPlugin{}
_ AdminSingleton = singletonController{}
_ DependentOptionsProvider = dependentController{}
)
// TestMenuItemTypeContracts pins the shape of the menu item type contracts:
// a type marshals without its functions, a resolved item keeps its
// alternates in the order given (never a map), list types carry their
// children in Items, and the capability interfaces are satisfied by plain
// values.
func TestMenuItemTypeContracts(t *testing.T) {
typ := MenuItemType{
Key: "acme-post", Label: "acme.blog::lang.post", DefaultPattern: "/blog/:slug", PatternFrom: "",
Placeholders: []string{":slug", ":id"}, Nesting: true, DynamicItems: false,
References: func(context.Context) ([]MenuItemReference, error) { return nil, nil },
Resolve: func(_ context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) {
return &ResolvedMenuItem{Title: in.Reference, URL: in.Pattern}, nil
},
}
raw, err := json.Marshal(typ)
if err != nil {
t.Fatalf("Marshal(MenuItemType) = %v", err)
}
if strings.Contains(string(raw), "Resolve") || strings.Contains(string(raw), "References") {
t.Fatalf("functions leaked into JSON: %s", raw)
}
got, err := typ.Resolve(context.Background(), MenuItemResolve{Type: "acme-post", Reference: "7", Pattern: "/blog/:slug"})
if err != nil || got.Title != "7" || got.URL != "/blog/:slug" {
t.Fatalf("Resolve = %+v, %v", got, err)
}
when := time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)
item := ResolvedMenuItem{
Title: "All posts", URL: "", MTime: &when,
Alternates: []MenuItemAlternate{{Locale: "pl", URL: "/a"}, {Locale: "en", URL: "/en/a"}, {Locale: "de", URL: "/de/a"}},
Items: []ResolvedMenuItem{{Title: "One", URL: "/blog/one"}, {Title: "Two", URL: "/blog/two"}},
}
var locales []string
for _, alt := range item.Alternates {
locales = append(locales, alt.Locale)
}
if strings.Join(locales, ",") != "pl,en,de" || item.URL != "" || len(item.Items) != 2 {
t.Fatalf("resolved list item = %+v", item)
}
var source any = menuTypesPlugin{types: []MenuItemType{typ}}
if p, ok := source.(HasMenuItemTypes); !ok || len(p.MenuItemTypes()) != 1 {
t.Fatal("HasMenuItemTypes not satisfied")
}
set, err := dependentController{}.DependentOptions(context.Background(), "reference", map[string]any{"type": nil})
if err != nil || set.Applies || set.Options != nil {
t.Fatalf("not-applying set = %+v, %v", set, err)
}
set, err = dependentController{}.DependentOptions(context.Background(), "rows.reference", map[string]any{"type": "acme-post"})
if err != nil || !set.Applies || set.Options[0].Value != "rows.reference" {
t.Fatalf("applying set = %+v, %v", set, err)
}
if rec, err := (singletonController{}).SingletonRecord(context.Background(), nil); err != nil || rec != 1 {
t.Fatalf("SingletonRecord = %v, %v", rec, err)
}
}