summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-10 09:46:59 +0300
committerPaul Buetow <paul@buetow.org>2026-04-10 09:46:59 +0300
commitc8e10b6c5ab26d8bd34c288a7ce91c320862b58e (patch)
tree107e842ca542c80cdf187017251ef6d4d63d597a /internal
parent53ad6846aa7506b621cd38f4933442379638a01d (diff)
test: add table-driven unit tests for post, processor, generator
Cover NewID; txt autolink helpers; markdown local image discovery; pagination, page filenames, JSON script literals, time formatting, and buildPageData nav links. Made-with: Cursor
Diffstat (limited to 'internal')
-rw-r--r--internal/generator/generator_test.go182
-rw-r--r--internal/post/post_test.go55
-rw-r--r--internal/processor/markdown_test.go90
-rw-r--r--internal/processor/txt_test.go120
4 files changed, 447 insertions, 0 deletions
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
new file mode 100644
index 0000000..ba0964e
--- /dev/null
+++ b/internal/generator/generator_test.go
@@ -0,0 +1,182 @@
+package generator
+
+import (
+ "html/template"
+ "testing"
+ "time"
+
+ "codeberg.org/snonux/snonux/internal/post"
+)
+
+func TestPageFilename(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ index int
+ want string
+ }{
+ {0, "index.html"},
+ {1, "page2.html"},
+ {2, "page3.html"},
+ }
+
+ for _, tt := range tests {
+ if got := pageFilename(tt.index); got != tt.want {
+ t.Fatalf("pageFilename(%d) = %q; want %q", tt.index, got, tt.want)
+ }
+ }
+}
+
+func TestPaginate(t *testing.T) {
+ t.Parallel()
+
+ p := func(ids ...string) []*post.Post {
+ out := make([]*post.Post, len(ids))
+ for i, id := range ids {
+ out[i] = &post.Post{ID: id}
+ }
+ return out
+ }
+
+ tests := []struct {
+ name string
+ posts []*post.Post
+ pageSize int
+ wantLens []int
+ }{
+ {name: "empty", posts: nil, pageSize: 3, wantLens: nil},
+ {name: "one page exact", posts: p("a", "b"), pageSize: 2, wantLens: []int{2}},
+ {name: "two pages", posts: p("a", "b", "c"), pageSize: 2, wantLens: []int{2, 1}},
+ {name: "singleton pages", posts: p("x", "y"), pageSize: 1, wantLens: []int{1, 1}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ pages := paginate(tt.posts, tt.pageSize)
+ if len(pages) != len(tt.wantLens) {
+ t.Fatalf("len(pages)=%d; want %d", len(pages), len(tt.wantLens))
+ }
+ for i, n := range tt.wantLens {
+ if len(pages[i]) != n {
+ t.Fatalf("page %d len=%d; want %d", i, len(pages[i]), n)
+ }
+ }
+ })
+ }
+}
+
+func TestJSONStringOrNull(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ in string
+ want template.JS
+ }{
+ {in: "", want: "null"},
+ {in: "page2.html", want: `"page2.html"`},
+ {in: `say "hi"`, want: `"say \"hi\""`},
+ }
+
+ for _, tt := range tests {
+ got := jsonStringOrNull(tt.in)
+ if got != tt.want {
+ t.Fatalf("jsonStringOrNull(%q) = %q; want %q", tt.in, got, tt.want)
+ }
+ }
+}
+
+func TestFormatPostTime(t *testing.T) {
+ t.Parallel()
+
+ tm := time.Date(2026, 4, 9, 14, 30, 0, 0, time.FixedZone("CET", 3600))
+ got := formatPostTime(tm)
+ want := "09.04.26 • 13:30 UTC"
+ if got != want {
+ t.Fatalf("formatPostTime = %q; want %q", got, want)
+ }
+}
+
+func TestBuildPageData_navLinks(t *testing.T) {
+ t.Parallel()
+
+ p := &post.Post{
+ ID: "1",
+ Timestamp: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
+ Content: "<p>x</p>",
+ }
+
+ tests := []struct {
+ name string
+ pageIndex int
+ totalPages int
+ wantPrev string
+ wantNext string
+ wantPrevJSON template.JS
+ wantNextJSON template.JS
+ wantPostsCount int
+ }{
+ {
+ name: "first of three",
+ pageIndex: 0,
+ totalPages: 3,
+ wantPrev: "",
+ wantNext: "page2.html",
+ wantPrevJSON: "null",
+ wantNextJSON: `"page2.html"`,
+ wantPostsCount: 1,
+ },
+ {
+ name: "middle",
+ pageIndex: 1,
+ totalPages: 3,
+ wantPrev: "index.html",
+ wantNext: "page3.html",
+ wantPrevJSON: `"index.html"`,
+ wantNextJSON: `"page3.html"`,
+ wantPostsCount: 1,
+ },
+ {
+ name: "last",
+ pageIndex: 2,
+ totalPages: 3,
+ wantPrev: "page2.html",
+ wantNext: "",
+ wantPrevJSON: `"page2.html"`,
+ wantNextJSON: "null",
+ wantPostsCount: 1,
+ },
+ {
+ name: "single page",
+ pageIndex: 0,
+ totalPages: 1,
+ wantPrev: "",
+ wantNext: "",
+ wantPrevJSON: "null",
+ wantNextJSON: "null",
+ wantPostsCount: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ data := buildPageData([]*post.Post{p}, tt.pageIndex, tt.totalPages)
+ if data.PrevPage != tt.wantPrev {
+ t.Fatalf("PrevPage=%q; want %q", data.PrevPage, tt.wantPrev)
+ }
+ if data.NextPage != tt.wantNext {
+ t.Fatalf("NextPage=%q; want %q", data.NextPage, tt.wantNext)
+ }
+ if data.PrevPageJSON != tt.wantPrevJSON {
+ t.Fatalf("PrevPageJSON=%q; want %q", data.PrevPageJSON, tt.wantPrevJSON)
+ }
+ if data.NextPageJSON != tt.wantNextJSON {
+ t.Fatalf("NextPageJSON=%q; want %q", data.NextPageJSON, tt.wantNextJSON)
+ }
+ if len(data.Posts) != tt.wantPostsCount {
+ t.Fatalf("len(Posts)=%d", len(data.Posts))
+ }
+ })
+ }
+}
diff --git a/internal/post/post_test.go b/internal/post/post_test.go
new file mode 100644
index 0000000..7283afd
--- /dev/null
+++ b/internal/post/post_test.go
@@ -0,0 +1,55 @@
+package post
+
+import (
+ "testing"
+ "time"
+)
+
+func TestNewID(t *testing.T) {
+ t.Parallel()
+
+ loc := time.FixedZone("CET", 1*3600)
+ base := time.Date(2026, 4, 9, 14, 30, 22, 0, loc)
+
+ tests := []struct {
+ name string
+ tm time.Time
+ suffix int
+ want string
+ }{
+ {
+ name: "utc no suffix",
+ tm: time.Date(2026, 4, 9, 14, 30, 22, 0, time.UTC),
+ suffix: 0,
+ want: "2026-04-09-143022",
+ },
+ {
+ name: "non utc converts to utc",
+ tm: base,
+ suffix: 0,
+ want: "2026-04-09-133022",
+ },
+ {
+ name: "suffix one",
+ tm: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
+ suffix: 1,
+ want: "2026-01-02-030405-1",
+ },
+ {
+ name: "suffix large",
+ tm: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
+ suffix: 42,
+ want: "2026-01-02-030405-42",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := NewID(tt.tm, tt.suffix)
+ if got != tt.want {
+ t.Fatalf("NewID(%v, %d) = %q; want %q", tt.tm, tt.suffix, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/processor/markdown_test.go b/internal/processor/markdown_test.go
new file mode 100644
index 0000000..a17b704
--- /dev/null
+++ b/internal/processor/markdown_test.go
@@ -0,0 +1,90 @@
+package processor
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestFindLocalImages(t *testing.T) {
+ t.Parallel()
+
+ t.Run("remote skipped", func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ got := findLocalImages(`![](https://cdn.example/p.png) ![](http://x/y.jpg)`, dir)
+ if len(got) != 0 {
+ t.Fatalf("expected no locals, got %v", got)
+ }
+ })
+
+ t.Run("missing file ignored", func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ got := findLocalImages(`![](nope.png)`, dir)
+ if len(got) != 0 {
+ t.Fatalf("expected no locals, got %v", got)
+ }
+ })
+
+ t.Run("picks existing basename", func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "shot.png"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got := findLocalImages(`![alt](shot.png)`, dir)
+ if len(got) != 1 || got[0] != "shot.png" {
+ t.Fatalf("got %v; want [shot.png]", got)
+ }
+ })
+
+ tests := []struct {
+ name string
+ md string
+ files []string
+ want []string
+ wantLen int
+ }{
+ {
+ name: "multiple locals order",
+ md: `![](a.png) ![](b.png)`,
+ files: []string{"a.png", "b.png"},
+ wantLen: 2,
+ },
+ {
+ name: "alt with spaces",
+ md: `![my photo](z.gif)`,
+ files: []string{"z.gif"},
+ want: []string{"z.gif"},
+ wantLen: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ for _, f := range tt.files {
+ if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got := findLocalImages(tt.md, dir)
+ if tt.want != nil {
+ if len(got) != len(tt.want) {
+ t.Fatalf("got %v; want %v", got, tt.want)
+ }
+ for i := range tt.want {
+ if got[i] != tt.want[i] {
+ t.Fatalf("got %v; want %v", got, tt.want)
+ }
+ }
+ return
+ }
+ if len(got) != tt.wantLen {
+ t.Fatalf("len(got)=%d; want %d (%v)", len(got), tt.wantLen, got)
+ }
+ })
+ }
+}
diff --git a/internal/processor/txt_test.go b/internal/processor/txt_test.go
new file mode 100644
index 0000000..bb44c28
--- /dev/null
+++ b/internal/processor/txt_test.go
@@ -0,0 +1,120 @@
+package processor
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestStripURLTrailing(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {name: "plain", in: "https://example.com", want: "https://example.com"},
+ {name: "trailing period", in: "https://example.com.", want: "https://example.com"},
+ {name: "multiple punctuation", in: "https://a.b/c).", want: "https://a.b/c"},
+ {name: "empty", in: "", want: ""},
+ {name: "only punctuation", in: "...", want: ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := stripURLTrailing(tt.in)
+ if got != tt.want {
+ t.Fatalf("stripURLTrailing(%q) = %q; want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestAutolinkLine(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {
+ name: "no url escapes",
+ in: `hello <world>`,
+ want: `hello &lt;world&gt;`,
+ },
+ {
+ name: "single url",
+ in: "see https://foo.test ok",
+ want: `see <a href="https://foo.test" target="_blank" rel="noopener noreferrer">https://foo.test</a> ok`,
+ },
+ {
+ name: "url with trailing period in prose",
+ in: "Visit https://foo.test.",
+ want: `Visit <a href="https://foo.test" target="_blank" rel="noopener noreferrer">https://foo.test</a>.`,
+ },
+ {
+ name: "two urls",
+ in: "a http://a.com b https://b.org c",
+ want: `a <a href="http://a.com" target="_blank" rel="noopener noreferrer">http://a.com</a> b <a href="https://b.org" target="_blank" rel="noopener noreferrer">https://b.org</a> c`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := autolinkLine(tt.in)
+ if got != tt.want {
+ t.Fatalf("autolinkLine(%q) = %q; want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFormatParagraph(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {
+ name: "single line",
+ in: "hello",
+ want: "hello",
+ },
+ {
+ name: "line break",
+ in: "line one\nline two",
+ want: "line one<br>\nline two",
+ },
+ {
+ name: "skips blank lines inside para",
+ in: "a\n\nb",
+ want: "a<br>\nb",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := formatParagraph(tt.in)
+ if got != tt.want {
+ t.Fatalf("formatParagraph(%q) = %q; want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFormatParagraph_autolinkMultiline(t *testing.T) {
+ t.Parallel()
+ got := formatParagraph("u https://x.y\nv")
+ if !strings.Contains(got, `<a href="https://x.y"`) {
+ t.Fatalf("expected autolink in multiline paragraph, got %q", got)
+ }
+ if !strings.Contains(got, "<br>") {
+ t.Fatalf("expected br between lines, got %q", got)
+ }
+}