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:
244
classes/menu_item_types_test.go
Normal file
244
classes/menu_item_types_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user