// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package post import ( "strings" "testing" "time" "sourcedock.dev/petrbalvin/interpres/v2" "sourcedock.dev/petrbalvin/volumen/internal/fediverse" "sourcedock.dev/petrbalvin/volumen/internal/frontmatter" ) func frontmatterMeta(m map[string]any) *frontmatter.Meta { meta := frontmatter.NewMeta() for k, v := range m { meta.Set(k, v) } return meta } func frontmatterPost(m map[string]any) *Post { return New(frontmatterMeta(m), "") } func mustParse(t *testing.T, content string) *Post { t.Helper() p, err := Parse(content) if err != nil { t.Fatalf("post.Parse: %v", err) } return p } const sample = `+++ title = "Ahoj světe" slug = "ahoj-svete" lang = "cs" author = "Petr" tags = ["go", "blog"] date = 2026-08-18 series = "Série" series_order = 2 cover = "/media/cover.webp" cover_alt = "alt" cover_caption = "caption" fediverse_creator = "@petr@mastodon.social" aliases = ["old-slug"] [translations] en = "hello-world" +++ # Hlavička První odstavec s dostatkem slov pro shrnutí, který popisuje obsah celého článku a nemá nadpis, takže se z něj dá odvodit excerpt. ## Sekce Další text. ` func TestParseBasics(t *testing.T) { p := mustParse(t, sample) if p.Slug() != "ahoj-svete" { t.Fatalf("slug = %q", p.Slug()) } if p.Title() != "Ahoj světe" || p.Lang() != "cs" || p.Author() != "Petr" { t.Fatalf("title/lang/author wrong") } if tags := p.Tags(); len(tags) != 2 || tags[0] != "go" || tags[1] != "blog" { t.Fatalf("tags = %v", tags) } if p.Draft() || p.AllLangs() { t.Fatal("draft/all_langs should be false") } if p.Translations()["en"] != "hello-world" { t.Fatalf("translations = %v", p.Translations()) } if p.Aliases()[0] != "old-slug" { t.Fatalf("aliases = %v", p.Aliases()) } if p.Cover() != "/media/cover.webp" || p.CoverAlt() != "alt" || p.CoverCaption() != "caption" { t.Fatal("cover fields wrong") } if !fediverse.Valid(p.FediverseCreator()) { t.Fatal("fediverse creator should be valid") } if p.Series() != "Série" { t.Fatalf("series = %q", p.Series()) } if order, ok := p.SeriesOrder(); !ok || order != 2 { t.Fatalf("series_order = %d, %v", order, ok) } if !strings.HasPrefix(p.Body, "# Hlavička") { t.Fatalf("body = %q", p.Body) } } func TestDateStringAndTimestamp(t *testing.T) { p := mustParse(t, sample) if p.DateString() != "2026-08-18" { t.Fatalf("DateString = %q", p.DateString()) } d, ok := p.PublishedDate() if !ok || d.Format("2006-01-02") != "2026-08-18" { t.Fatalf("PublishedDate = %v, %v", d, ok) } ts, ok := p.PublishedTimestamp() if !ok { t.Fatal("PublishedTimestamp missing") } want := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC).UnixMilli() if ts != want { t.Fatalf("timestamp = %d, want %d", ts, want) } } func TestPublishAtAndScheduled(t *testing.T) { future := mustParse(t, "+++\npublish_at = 2999-01-01\n+++\nx\n") if !future.Scheduled() { t.Fatal("future publish_at should be scheduled") } past := mustParse(t, "+++\npublish_at = 2000-01-01\n+++\nx\n") if past.Scheduled() { t.Fatal("past publish_at should not be scheduled") } none := mustParse(t, "+++\ntitle = \"x\"\n+++\nx\n") if none.Scheduled() { t.Fatal("missing publish_at should not be scheduled") } if _, ok := none.DueAt(); ok { t.Fatal("missing publish_at returned a date") } } func TestPublishAtStringForms(t *testing.T) { p := mustParse(t, "+++\npublish_at = \"2026-02-03\"\n+++\nx\n") d, ok := p.DueAt() if !ok || d.Format("2006-01-02") != "2026-02-03" { t.Fatalf("string publish_at = %v, %v", d, ok) } } func TestFlagsStrict(t *testing.T) { p := mustParse(t, "+++\ndraft = \"true\"\n+++\nx\n") if p.Draft() { t.Fatal("string draft flag must not count as draft") } p2 := mustParse(t, "+++\ndraft = true\n+++\nx\n") if !p2.Draft() { t.Fatal("boolean draft flag should count") } } func TestReadingTime(t *testing.T) { short := New(nil, "one two three") if got := short.ReadingTime(); got != 1 { t.Fatalf("ReadingTime = %d, want 1", got) } body := strings.Repeat("word ", 450) long := New(nil, body) if got := long.ReadingTime(); got != 3 { t.Fatalf("ReadingTime = %d, want 3", got) } empty := New(nil, "") if got := empty.ReadingTime(); got != 1 { t.Fatalf("empty ReadingTime = %d, want 1", got) } } func TestExcerptPrefersMetadata(t *testing.T) { p := mustParse(t, "+++\nexcerpt = \"Vlastní perex\"\n+++\nbody\n") if p.Excerpt() != "Vlastní perex" { t.Fatalf("Excerpt = %q", p.Excerpt()) } } func TestDerivedExcerpt(t *testing.T) { p := New(nil, "# Head\n\nFirst paragraph with **markdown** and html.\n\nSecond.") ex := p.Excerpt() if strings.Contains(ex, "*") || strings.Contains(ex, "<") || strings.HasPrefix(ex, "#") { t.Fatalf("derived excerpt not cleaned: %q", ex) } if !strings.Contains(ex, "First paragraph") { t.Fatalf("derived excerpt wrong: %q", ex) } } func TestDerivedExcerptTruncates(t *testing.T) { p := New(nil, strings.Repeat("x", 500)) ex := p.Excerpt() if !strings.HasSuffix(ex, "…") { t.Fatalf("excerpt not truncated: %q", ex) } if len([]rune(ex)) != 201 { t.Fatalf("excerpt length = %d, want 201", len([]rune(ex))) } } func TestHTMLAndTOCCached(t *testing.T) { p := mustParse(t, "+++\ntitle = \"x\"\n+++\n# H\n\nText\n") h1, err := p.HTML() if err != nil { t.Fatalf("HTML: %v", err) } h2, err := p.HTML() if err != nil || h1 != h2 { t.Fatalf("HTML not cached: %q vs %q", h1, h2) } toc, err := p.TOC() if err != nil { t.Fatalf("TOC: %v", err) } if !strings.Contains(toc, `href="#h"`) { t.Fatalf("toc = %q", toc) } } func TestToFileRoundTrip(t *testing.T) { p := mustParse(t, sample) out, err := p.ToFile() if err != nil { t.Fatalf("ToFile: %v", err) } p2 := mustParse(t, out) if p2.Title() != p.Title() || p2.Slug() != p.Slug() || p2.Body != p.Body { t.Fatal("round-trip lost data") } } func TestCivilDateVariants(t *testing.T) { meta := frontmatterMeta(map[string]any{ "date": interpres.LocalDate{Time: time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC)}, }) if got := New(meta, "").DateString(); got != "2026-03-04" { t.Fatalf("LocalDate DateString = %q", got) } meta = frontmatterMeta(map[string]any{"date": "2026-03-05"}) if got := New(meta, "").DateString(); got != "2026-03-05" { t.Fatalf("string DateString = %q", got) } meta = frontmatterMeta(map[string]any{"date": 42}) if got := New(meta, "").DateString(); got != "42" { t.Fatalf("int DateString = %q", got) } } func TestFediverseCreatorValidation(t *testing.T) { if !fediverse.Valid("@user@host.social") { t.Fatal("valid handle rejected") } for _, bad := range []string{"user@host", "@user@", "@@host", "@us er@host", ""} { if fediverse.Valid(bad) { t.Fatalf("invalid handle accepted: %q", bad) } } } func TestDOIORCIDReservedAndReadable(t *testing.T) { p := mustParse(t, "+++\nslug = \"paper\"\ndoi = \"10.1000/xyz\"\norcid = \"0000-0002-1825-0097\"\nmethod = \"gravimetric\"\n+++\nx\n") if p.DOI() != "10.1000/xyz" { t.Fatalf("DOI = %q", p.DOI()) } if p.ORCID() != "0000-0002-1825-0097" { t.Fatalf("ORCID = %q", p.ORCID()) } // The identifiers are reserved: only the author's own key reaches // the fields object. fields := p.CustomFields() if _, present := fields["doi"]; present { t.Fatal("doi leaked into custom fields") } if _, present := fields["orcid"]; present { t.Fatal("orcid leaked into custom fields") } if fields["method"] != "gravimetric" { t.Fatalf("custom field lost: %v", fields) } } func TestSeriesOrderCoercions(t *testing.T) { p := frontmatterPost(map[string]any{"series_order": "7"}) if order, ok := p.SeriesOrder(); !ok || order != 7 { t.Fatalf("string series_order = %d, %v", order, ok) } p = frontmatterPost(map[string]any{"series_order": 3.9}) if order, ok := p.SeriesOrder(); !ok || order != 3 { t.Fatalf("float series_order = %d, %v", order, ok) } p = frontmatterPost(map[string]any{"series_order": "x"}) if _, ok := p.SeriesOrder(); ok { t.Fatal("garbage series_order accepted") } } func TestUnparseablePublishAtWithholdsThePost(t *testing.T) { // A typo in publish_at must not publish the post early: it is // withheld, and validate reports it. p := mustParse(t, "+++\npublish_at = \"18/09/2026\"\n+++\nx\n") if p.Published() { t.Fatal("a post with an unreadable publish_at was published") } if !p.Scheduled() { t.Fatal("a post with an unreadable publish_at was not withheld") } if _, ok := p.DueAt(); ok { t.Fatal("an unreadable publish_at produced a date") } } func TestEmptyPublishAtIsPublished(t *testing.T) { p := mustParse(t, "+++\npublish_at = \"\"\n+++\nx\n") if !p.Published() { t.Fatal("an empty publish_at withheld the post") } } func TestStatusPrecedence(t *testing.T) { draft := mustParse(t, "+++\ndraft = true\npublish_at = 2999-01-01\n+++\nx\n") if draft.Status() != StatusDraft { t.Fatal("a draft outranks a scheduled post") } if draft.Published() { t.Fatal("a draft was published") } } func TestCloneIsIndependent(t *testing.T) { original := mustParse(t, "+++\ntitle = \"A\"\ntags = [\"go\", \"blog\"]\n[extra]\nkey = \"value\"\n+++\nbody\n") clone := original.Clone() clone.Metadata.Set("title", "B") clone.Metadata.Set("tags", []string{"other"}) if table, ok := clone.Metadata.Get("extra"); ok { if m, isMap := table.(map[string]any); isMap { m["key"] = "changed" } } title, _ := original.Metadata.Get("title") if title != "A" { t.Fatalf("the clone wrote through to the original: title = %v", title) } if got := original.Tags(); len(got) != 2 || got[0] != "go" { t.Fatalf("the clone shares the tag list: %v", got) } if table, ok := original.Metadata.Get("extra"); ok { if m, isMap := table.(map[string]any); isMap && m["key"] != "value" { t.Fatalf("the clone shares the nested table: %v", m) } } if clone.Path != original.Path || clone.Body != original.Body { t.Fatal("the clone lost the body or the path") } } func TestStoredExcerptIsNotDerived(t *testing.T) { withExcerpt := mustParse(t, "+++\nexcerpt = \"Hand written\"\n+++\nFirst paragraph.\n") if withExcerpt.StoredExcerpt() != "Hand written" { t.Fatalf("StoredExcerpt = %q", withExcerpt.StoredExcerpt()) } derived := mustParse(t, "+++\ntitle = \"x\"\n+++\nFirst paragraph.\n") if derived.StoredExcerpt() != "" { t.Fatalf("StoredExcerpt = %q, want empty before the editor fills it", derived.StoredExcerpt()) } if derived.Excerpt() == "" { t.Fatal("Excerpt should still derive from the body") } } func TestPlainTextAndTruncate(t *testing.T) { if got := PlainText("

Hello world

", 100); got != "Hello world" { t.Fatalf("PlainText = %q", got) } if got := PlainText("

one two

", 100); got != "one two" { t.Fatalf("PlainText did not collapse whitespace: %q", got) } if got := Truncate("abcdefghij", 4); got != "abcd…" { t.Fatalf("Truncate = %q", got) } if got := Truncate("abc", 4); got != "abc" { t.Fatalf("Truncate changed a short value: %q", got) } }