test(17-06): Journal menu item type unit tests

- TestMenuItemTypesPatterns (Docker-free): the five types, ordered
  placeholder fill, date parts only for published posts in UTC,
  alternates and the unprefixed locale, translation fallback, reference
  parse, category tree, refusals without a booted app
- TestMenuItemTypes (Postgres): host overrides of one record type reach
  only its list types, item and list-key overrides, references, nesting
  at every depth, order with ties by id, published filters, descendant
  categories, alternates in sort_order with ties by id
This commit is contained in:
2026-10-12 19:33:36 +02:00
parent 4e2aab6fe0
commit 3b252f2cfa
2 changed files with 491 additions and 0 deletions

View File

@@ -0,0 +1,244 @@
package classes
import (
"context"
"fmt"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the five
// registered types, the ordered placeholder fill of PHP buildUrlFromPattern,
// the post placeholders (date parts only for a published post), the
// alternates (x-default, unprefixed locale, prefixes, first alternate as the
// URL), the translation fallback, the reference parse, the category tree and
// the refusals without a booted app.
func TestMenuItemTypesPatterns(t *testing.T) {
t.Run("the five types", func(t *testing.T) {
types := MenuItemTypes(nil)
var got []string
for _, typ := range types {
refs := typ.References != nil
got = append(got, fmt.Sprintf("%s|%s|%s|nest=%v|dyn=%v|refs=%v|%s|%s", typ.Key, typ.DefaultPattern, typ.PatternFrom, typ.Nesting, typ.DynamicItems, refs, strings.Join(typ.Placeholders, ","), typ.Label))
if typ.Resolve == nil {
t.Fatalf("%s has no Resolve", typ.Key)
}
}
want := []string{
"journal-category|/blog/category/:slug||nest=true|dyn=true|refs=true|:slug,:id|golem15.journal::lang.menuitem.journal_category",
"all-journal-categories|/blog/category/:slug|journal-category|nest=false|dyn=true|refs=false|:slug,:id|golem15.journal::lang.menuitem.all_journal_categories",
"journal-post|/blog/:slug||nest=false|dyn=false|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.journal_post",
"all-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=false|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.all_journal_posts",
"category-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.category_journal_posts",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("types:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
// Placeholders are copies: changing one type's list leaves the others.
types[2].Placeholders[0] = "changed"
if again := MenuItemTypes(nil); again[2].Placeholders[0] != ":slug" || types[3].Placeholders[0] != ":slug" {
t.Fatal("placeholder slices are shared")
}
})
t.Run("ordered fill", func(t *testing.T) {
for _, tc := range []struct {
pattern string
params [][2]string
want string
}{
{"/blog/:slug", [][2]string{{"id", "7"}, {"slug", "hello"}}, "/blog/hello"},
// PHP replaces in params order: a slug holding ":id" was filled
// after :id, so it stays literal.
{"/:id/:slug", [][2]string{{"id", "7"}, {"slug", "a-:id"}}, "/7/a-:id"},
// A value replaced first can feed a later placeholder.
{"/:slug", [][2]string{{"slug", ":category"}, {"category", "news"}}, "/news"},
{"/:slug/:slug", [][2]string{{"slug", "x"}}, "/x/x"},
{"/:unknown/:slug", [][2]string{{"slug", "x"}}, "/:unknown/x"},
{"/:slugger", [][2]string{{"slug", "x"}}, "/xger"},
{"/static", nil, "/static"},
} {
if got := fillMenuPattern(tc.pattern, tc.params); got != tc.want {
t.Fatalf("fillMenuPattern(%q, %v) = %q, want %q", tc.pattern, tc.params, got, tc.want)
}
}
})
t.Run("post placeholders", func(t *testing.T) {
at := time.Date(2026, 3, 4, 23, 30, 0, 0, time.FixedZone("plus2", 2*3600))
published := &models.Post{ID: 9, Slug: "s", Published: true, PublishedAt: &at}
if got := fmt.Sprint(postURLParams(published, "slug", "cat")); got != "[[id 9] [slug slug] [category cat] [year 2026] [month 03] [day 04]]" {
t.Fatalf("published params = %s", got)
}
late := time.Date(2026, 12, 31, 23, 30, 0, 0, time.FixedZone("minus5", -5*3600))
utc := &models.Post{ID: 1, Published: true, PublishedAt: &late}
if got := fmt.Sprint(postURLParams(utc, "s", "")); !strings.Contains(got, "[year 2027] [month 01] [day 01]") {
t.Fatalf("date parts are not UTC: %s", got)
}
for name, p := range map[string]*models.Post{
"draft with a date": {ID: 2, Published: false, PublishedAt: &at},
"published, no date": {ID: 3, Published: true},
"draft without dates": {ID: 4},
} {
params := postURLParams(p, "s", "c")
if len(params) != 3 {
t.Fatalf("%s: params = %v, want id, slug and category only", name, params)
}
if got := fillMenuPattern("/:year/:month/:day/:slug", params); got != "/:year/:month/:day/s" {
t.Fatalf("%s: url = %q", name, got)
}
}
})
t.Run("alternates", func(t *testing.T) {
params := func(locale string) [][2]string { return [][2]string{{"slug", "slug-" + locale}} }
title := func(locale string) string { return "T-" + locale }
for _, tc := range []struct {
name string
locales menuLocales
want string
}{
{"no enabled locale", menuLocales{}, "x-default=/p/slug-|T-"},
{"default unprefixed", menuLocales{codes: []string{"pl", "en"}, defaultCode: "en", unprefixed: "en"}, "pl=/pl/p/slug-pl|T-pl en=/p/slug-en|T-en"},
{"another unprefixed", menuLocales{codes: []string{"en", "pl", "de"}, defaultCode: "en", unprefixed: "pl"}, "en=/en/p/slug-en|T-en pl=/p/slug-pl|T-pl de=/de/p/slug-de|T-de"},
} {
var parts []string
for _, alt := range menuAlternates("/p/:slug", tc.locales, params, title) {
parts = append(parts, alt.Locale+"="+alt.URL+"|"+alt.Title)
}
if got := strings.Join(parts, " "); got != tc.want {
t.Fatalf("%s: %s, want %s", tc.name, got, tc.want)
}
}
mtime := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
item := resolvedMenuItem("/p/:slug", menuLocales{codes: []string{"pl", "en"}, defaultCode: "en", unprefixed: "en"}, "Host", &mtime, params, title)
if item.URL != "/pl/p/slug-pl" || item.Title != "Host" || item.MTime == nil || !item.MTime.Equal(mtime) || item.MTime == &mtime {
t.Fatalf("item = %+v (URL is the first alternate's, MTime a copy)", item)
}
bare := resolvedMenuItem("/p/:slug", menuLocales{}, "Host", nil, params, title)
if bare.URL != "/p/slug-" || bare.MTime != nil || len(bare.Alternates) != 1 {
t.Fatalf("item without locales = %+v", bare)
}
})
t.Run("translation fallback", func(t *testing.T) {
tr := menuTranslations{defaultCode: "en", data: map[string]map[uint]map[string]any{
"pl": {1: {"slug": "witaj", "title": "", "zero": "0", "num": float64(2), "falsy": false, "list": []any{}}},
}}
for _, tc := range []struct {
locale, field, want string
id uint
}{
{"pl", "slug", "witaj", 1},
{"pl", "title", "host", 1},
{"pl", "zero", "host", 1},
{"pl", "falsy", "host", 1},
{"pl", "list", "host", 1},
{"pl", "num", "2", 1},
{"pl", "missing", "host", 1},
{"pl", "slug", "host", 2},
{"de", "slug", "host", 1},
{"en", "slug", "host", 1},
{"", "slug", "host", 1},
} {
if got := tr.value(tc.locale, tc.id, tc.field, "host"); got != tc.want {
t.Fatalf("value(%s, %d, %s) = %q, want %q", tc.locale, tc.id, tc.field, got, tc.want)
}
}
for v, want := range map[any]bool{nil: false, "": false, "0": false, "a": true, true: true, false: false, float64(0): false, float64(1): true} {
if phpTruthy(v) != want {
t.Fatalf("phpTruthy(%#v) != %v", v, want)
}
}
if !phpTruthy(map[string]any{"a": 1}) || phpTruthy(map[string]any{}) || !phpTruthy([]any{1}) {
t.Fatal("phpTruthy of maps and lists")
}
})
t.Run("references", func(t *testing.T) {
for ref, want := range map[string]uint{"7": 7, " 12 ": 12} {
if got, ok := menuReferenceID(ref); !ok || got != want {
t.Fatalf("menuReferenceID(%q) = %d, %v", ref, got, ok)
}
}
for _, ref := range []string{"", "0", "-1", "abc", "1.5", "99999999999"} {
if _, ok := menuReferenceID(ref); ok {
t.Fatalf("menuReferenceID(%q) accepted", ref)
}
}
})
t.Run("category tree", func(t *testing.T) {
parent := func(id uint) *uint { return &id }
cats := []models.Category{
{ID: 1, Name: "Root", Slug: "root"},
{ID: 2, Name: "Child", Slug: "child", ParentID: parent(1)},
{ID: 3, Name: "Grandchild", Slug: "grand", ParentID: parent(2)},
{ID: 4, Name: "Orphan", Slug: "orphan", ParentID: parent(99)},
{ID: 5, Name: "Self", Slug: "self", ParentID: parent(5)},
}
tree := categoryTree{cats: cats, index: map[uint]int{}, children: map[uint][]int{}}
for i, c := range cats {
tree.index[c.ID] = i
}
for i, c := range cats {
if c.ParentID != nil && *c.ParentID != 0 && *c.ParentID != c.ID {
if _, ok := tree.index[*c.ParentID]; ok {
tree.children[*c.ParentID] = append(tree.children[*c.ParentID], i)
continue
}
}
tree.roots = append(tree.roots, i)
}
var walk func(refs []pact.MenuItemReference, depth int) string
walk = func(refs []pact.MenuItemReference, depth int) string {
var parts []string
for _, r := range refs {
parts = append(parts, fmt.Sprintf("%d:%s=%s", depth, r.Value, r.Label)+walk(r.Items, depth+1))
}
if len(parts) == 0 {
return ""
}
return "[" + strings.Join(parts, " ") + "]"
}
if got := walk(tree.references(tree.roots), 0); got != "[0:1=Root[1:2=Child[2:3=Grandchild]] 0:4=Orphan 0:5=Self]" {
t.Fatalf("references = %s", got)
}
ci := categoryItems{pattern: "/c/:id/:slug", locales: menuLocales{}}
sub := ci.subtree(tree, 1)
if len(sub) != 1 || sub[0].URL != "/c/2/child" || len(sub[0].Items) != 1 || sub[0].Items[0].URL != "/c/3/grand" || len(sub[0].Items[0].Items) != 0 {
t.Fatalf("subtree = %+v", sub)
}
if got := ci.subtree(tree, 3); len(got) != 0 {
t.Fatalf("leaf subtree = %+v", got)
}
})
t.Run("without a booted app every resolve is an error, and a bad reference resolves to nothing first", func(t *testing.T) {
for name, app := range map[string]func() *backpack.App{"nil accessor": nil, "nil app": func() *backpack.App { return nil }} {
for _, typ := range MenuItemTypes(app) {
got, err := typ.Resolve(context.Background(), pact.MenuItemResolve{Type: typ.Key, Reference: "1", Pattern: typ.DefaultPattern})
if err == nil || got != nil || !strings.HasPrefix(err.Error(), "golem15.journal: menu item types: ") {
t.Fatalf("%s %s: %+v %v", name, typ.Key, got, err)
}
if typ.References != nil {
if _, err := typ.References(context.Background()); err == nil {
t.Fatalf("%s %s references without an app", name, typ.Key)
}
}
if typ.Key == MenuTypeAllCategories || typ.Key == MenuTypeAllPosts {
continue
}
got, err = typ.Resolve(context.Background(), pact.MenuItemResolve{Type: typ.Key, Reference: "abc"})
if err != nil || got != nil {
t.Fatalf("%s %s with a bad reference: %+v %v", name, typ.Key, got, err)
}
}
}
})
}

247
menu_item_types_test.go Normal file
View File

@@ -0,0 +1,247 @@
package journal_test
import (
"context"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models"
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
// TestMenuItemTypes pins D-04 on Postgres: each type's pattern layers
// (a host override of only one record type reaches only its list types; an
// item override reaches the list children and the nested categories), the
// references, nesting with the subtree at any depth, list order with ties by
// id, the published filters, descendant categories, alternates in sort_order
// with ties by id, translated titles with PHP's fallback, date placeholders
// only for published posts, and unknown references.
func TestMenuItemTypes(t *testing.T) {
requirePostgres(t)
ctx := context.Background()
t.Run("a host override of journal-post reaches only the post list types", func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-post: /p/:slug\n"})
seedMenuTree(t, gdb)
for key, want := range map[string]string{
classes.MenuTypePost: "/p/:slug",
classes.MenuTypeAllPosts: "/p/:slug",
classes.MenuTypeCategoryPosts: "/p/:slug",
classes.MenuTypeCategory: classes.DefaultCategoryPattern,
classes.MenuTypeAllCategories: classes.DefaultCategoryPattern,
} {
if got := reg.Pattern(key, ""); got != want {
t.Fatalf("Pattern(%s) = %q, want %q", key, got, want)
}
}
got := resolve(t, reg, classes.MenuTypeAllCategories, "", "", false)
if itemURLs(got.Items) != "Alpha=/blog/category/alpha Beta=/blog/category/beta Gamma=/blog/category/gamma" {
t.Fatalf("categories under the default pattern = %s", itemURLs(got.Items))
}
})
t.Run("a host override of journal-category reaches only the category list type", func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-category: /:slug\n"})
seed := seedMenuTree(t, gdb)
got := resolve(t, reg, classes.MenuTypeAllCategories, "", "", false)
if itemURLs(got.Items) != "Alpha=/alpha Beta=/beta Gamma=/gamma" {
t.Fatalf("categories = %s", itemURLs(got.Items))
}
got = resolve(t, reg, classes.MenuTypeAllPosts, "", "", false)
if !strings.HasPrefix(itemURLs(got.Items), "Apple=/blog/apple") {
t.Fatalf("posts keep their default = %s", itemURLs(got.Items))
}
// An item override of the list item reaches its children.
got = resolve(t, reg, classes.MenuTypeAllCategories, "", "/topics/:id-:slug", false)
if want := "Alpha=/topics/" + id(seed.alpha.ID) + "-alpha"; !strings.HasPrefix(itemURLs(got.Items), want) {
t.Fatalf("item override = %s, want prefix %s", itemURLs(got.Items), want)
}
// The list key's own host override wins over its record type's.
_, gdb2, reg2 := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-category: /:slug\n all-journal-categories: /all/:slug\n"})
seedMenuTree(t, gdb2)
got = resolve(t, reg2, classes.MenuTypeAllCategories, "", "", false)
if !strings.HasPrefix(itemURLs(got.Items), "Alpha=/all/alpha") {
t.Fatalf("list key override = %s", itemURLs(got.Items))
}
})
t.Run("references, nesting, order, published filters and descendants", func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
seed := seedMenuTree(t, gdb)
refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypePost)
if err != nil || !refs.Applies {
t.Fatalf("post references = %+v %v", refs, err)
}
var labels []string
for _, opt := range refs.Options {
labels = append(labels, opt.Label)
}
// Every post by title, drafts and future posts included, ties by id.
if strings.Join(labels, ",") != "Apple,Draft,Future,Same,Same,Zebra" || refs.Options[3].Value != id(seed.same1.ID) {
t.Fatalf("post references = %v (%+v)", labels, refs.Options)
}
refs, err = signpost.ReferenceOptions(ctx, reg, classes.MenuTypeCategory)
if err != nil || len(refs.Options) != 3 || refs.Options[0].Label != "Beta" || refs.Options[1].Label != "Alpha" || refs.Options[1].Depth != 1 || refs.Options[2].Label != "Gamma" || refs.Options[2].Depth != 2 {
t.Fatalf("category references = %+v %v", refs, err)
}
if same, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypeCategoryPosts); err != nil || len(same.Options) != 3 {
t.Fatalf("category-journal-posts references = %+v %v", same, err)
}
// journal-post resolves without a published filter.
if got := resolve(t, reg, classes.MenuTypePost, id(seed.draft.ID), "", false); got.URL != "/blog/draft" {
t.Fatalf("draft post = %+v", got)
}
// Nesting returns the subtree at every depth; without nesting no items.
got := resolve(t, reg, classes.MenuTypeCategory, id(seed.beta.ID), "", true)
if got.URL != "/blog/category/beta" || len(got.Items) != 1 || got.Items[0].Title != "Alpha" || len(got.Items[0].Items) != 1 || got.Items[0].Items[0].URL != "/blog/category/gamma" {
t.Fatalf("nested = %+v", got)
}
got = resolve(t, reg, classes.MenuTypeCategory, id(seed.beta.ID), "/c/:slug", true)
if got.URL != "/c/beta" || got.Items[0].URL != "/c/alpha" || got.Items[0].Items[0].URL != "/c/gamma" {
t.Fatalf("nested with an item override = %+v", got)
}
if got := resolve(t, reg, classes.MenuTypeCategory, id(seed.gamma.ID), "", true); len(got.Items) != 0 {
t.Fatalf("leaf nesting = %+v", got)
}
if got := resolve(t, reg, classes.MenuTypeCategory, id(seed.beta.ID), "", false); len(got.Items) != 0 {
t.Fatalf("no nesting = %+v", got)
}
// all-journal-posts: published with a past date only, by title, ties by id.
got = resolve(t, reg, classes.MenuTypeAllPosts, "", "/:id", false)
if want := "Apple=/" + id(seed.apple.ID) + " Same=/" + id(seed.same1.ID) + " Same=/" + id(seed.same2.ID) + " Zebra=/" + id(seed.zebra.ID); itemURLs(got.Items) != want {
t.Fatalf("all posts = %s, want %s", itemURLs(got.Items), want)
}
// category-journal-posts: the category and every descendant.
if got := resolve(t, reg, classes.MenuTypeCategoryPosts, id(seed.beta.ID), "", false); itemURLs(got.Items) != "Apple=/blog/apple Same=/blog/same-1 Zebra=/blog/zebra" {
t.Fatalf("beta posts = %s", itemURLs(got.Items))
}
if got := resolve(t, reg, classes.MenuTypeCategoryPosts, id(seed.alpha.ID), "", false); itemURLs(got.Items) != "Apple=/blog/apple Same=/blog/same-1" {
t.Fatalf("alpha posts = %s", itemURLs(got.Items))
}
if got := resolve(t, reg, classes.MenuTypeCategoryPosts, id(seed.gamma.ID), "", false); itemURLs(got.Items) != "Apple=/blog/apple" {
t.Fatalf("gamma posts = %s", itemURLs(got.Items))
}
// :category is the first category by name; date parts only when published.
got = resolve(t, reg, classes.MenuTypePost, id(seed.apple.ID), "/:category/:year/:month/:day/:slug", false)
if got.URL != "/alpha/2026/03/04/apple" {
t.Fatalf("published post url = %q", got.URL)
}
got = resolve(t, reg, classes.MenuTypePost, id(seed.draft.ID), "/:category/:year/:slug", false)
if got.URL != "/beta/:year/draft" {
t.Fatalf("draft post url = %q", got.URL)
}
// Unknown or malformed references resolve to nothing.
for _, key := range []string{classes.MenuTypePost, classes.MenuTypeCategory, classes.MenuTypeCategoryPosts} {
for _, ref := range []string{"999999", "abc", ""} {
if got, err := reg.Resolve(ctx, key, ref, "", true, ""); err != nil || got != nil {
t.Fatalf("%s %q = %+v %v", key, ref, got, err)
}
}
}
})
t.Run("alternates in sort order with ties by id, translated titles with fallback", func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
seed := seedMenuTree(t, gdb)
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES ('de', 'Deutsch', FALSE, TRUE, 5), ('cs', 'Čeština', FALSE, TRUE, 5)`).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`UPDATE golem15_translate_locales SET sort_order = CASE code WHEN 'en' THEN 1 WHEN 'pl' THEN 5 ELSE sort_order END`).Error; err != nil {
t.Fatal(err)
}
var order []string
if err := gdb.Raw(`SELECT code FROM golem15_translate_locales WHERE is_enabled ORDER BY sort_order, id`).Scan(&order).Error; err != nil {
t.Fatal(err)
}
if err := translate.SetTranslated(ctx, gdb, &seed.apple, "slug", "pl", "jablko"); err != nil {
t.Fatal(err)
}
if err := translate.SetTranslated(ctx, gdb, &seed.apple, "title", "pl", "Jabłko"); err != nil {
t.Fatal(err)
}
if err := translate.SetTranslated(ctx, gdb, &seed.apple, "slug", "de", ""); err != nil {
t.Fatal(err)
}
got := resolve(t, reg, classes.MenuTypePost, id(seed.apple.ID), "", false)
var parts []string
for _, alt := range got.Alternates {
parts = append(parts, alt.Locale+"="+alt.URL+"|"+alt.Title)
}
want := []string{}
for _, code := range order {
switch code {
case "en":
want = append(want, "en=/blog/apple|Apple")
case "pl":
want = append(want, "pl=/pl/blog/jablko|Jabłko")
default:
want = append(want, code+"=/"+code+"/blog/apple|Apple")
}
}
if strings.Join(parts, " ") != strings.Join(want, " ") || got.URL != "/blog/apple" || got.Title != "Apple" {
t.Fatalf("alternates = %s\nwant %s (url %q)", strings.Join(parts, " "), strings.Join(want, " "), got.URL)
}
// The unprefixed locale may be another one.
got = resolve(t, reg, classes.MenuTypePost, id(seed.apple.ID), "", false, "pl")
if got.Alternates[0].URL != "/en/blog/apple" || !strings.Contains(altString(got.Alternates), "pl=/blog/jablko") {
t.Fatalf("unprefixed pl = %s", altString(got.Alternates))
}
})
}
// menuSeed is the fixture of TestMenuItemTypes: categories Beta > Alpha >
// Gamma, posts in them, a draft, a future post and two posts titled Same.
type menuSeed struct {
beta, alpha, gamma models.Category
apple, zebra, draft, future, same1, same2 models.Post
}
func seedMenuTree(t *testing.T, gdb *gorm.DB) menuSeed {
t.Helper()
if err := gdb.Exec(`DELETE FROM golem15_journal_categories`).Error; err != nil {
t.Fatal(err)
}
var s menuSeed
s.beta = menuCategory(t, gdb, "Beta", "beta", nil, 1, 6)
s.alpha = menuCategory(t, gdb, "Alpha", "alpha", &s.beta.ID, 2, 5)
s.gamma = menuCategory(t, gdb, "Gamma", "gamma", &s.alpha.ID, 3, 4)
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
future := time.Now().Add(240 * time.Hour)
s.zebra = menuPost(t, gdb, "Zebra", "zebra", true, &past)
s.apple = menuPost(t, gdb, "Apple", "apple", true, &past)
s.draft = menuPost(t, gdb, "Draft", "draft", false, &past)
s.future = menuPost(t, gdb, "Future", "future", true, &future)
s.same1 = menuPost(t, gdb, "Same", "same-1", true, &past)
s.same2 = menuPost(t, gdb, "Same", "same-2", true, &past)
attachCategory(t, gdb, s.apple, s.gamma)
attachCategory(t, gdb, s.apple, s.alpha)
attachCategory(t, gdb, s.zebra, s.beta)
attachCategory(t, gdb, s.draft, s.beta)
attachCategory(t, gdb, s.future, s.beta)
attachCategory(t, gdb, s.same1, s.alpha)
return s
}
// resolve resolves one item and fails when it resolves to nothing.
func resolve(t *testing.T, reg *signpost.Registry, key, ref, pattern string, nesting bool, unprefixed ...string) *pact.ResolvedMenuItem {
t.Helper()
locale := ""
if len(unprefixed) > 0 {
locale = unprefixed[0]
}
got, err := reg.Resolve(context.Background(), key, ref, pattern, nesting, locale)
if err != nil || got == nil {
t.Fatalf("Resolve(%s, %q, %q) = %+v, %v", key, ref, pattern, got, err)
}
return got
}