diff --git a/classes/menu_item_types_test.go b/classes/menu_item_types_test.go new file mode 100644 index 0000000..550af5e --- /dev/null +++ b/classes/menu_item_types_test.go @@ -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) + } + } + } + }) +} diff --git a/menu_item_types_test.go b/menu_item_types_test.go new file mode 100644 index 0000000..65697f8 --- /dev/null +++ b/menu_item_types_test.go @@ -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 +}