diff options
Diffstat (limited to 'integrationtests/integration_test.go')
| -rw-r--r-- | integrationtests/integration_test.go | 382 |
1 files changed, 382 insertions, 0 deletions
diff --git a/integrationtests/integration_test.go b/integrationtests/integration_test.go new file mode 100644 index 0000000..634971e --- /dev/null +++ b/integrationtests/integration_test.go @@ -0,0 +1,382 @@ +// Package integrationtests runs end-to-end tests of the snonux generator pipeline. +// Each test creates temporary input/output directories, places fixture files, runs +// the full processor+generator pipeline, and asserts the expected outputs. +package integrationtests + +import ( + "encoding/xml" + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/generator" + "codeberg.org/snonux/snonux/internal/processor" +) + +// runPipeline executes both pipeline stages and returns the config used. +func runPipeline(t *testing.T, inputDir, outputDir string) *config.Config { + t.Helper() + + cfg := &config.Config{ + InputDir: inputDir, + OutputDir: outputDir, + BaseURL: "https://snonux.foo", + Theme: "neon", + } + + _, err := processor.Run(cfg) + if err != nil { + t.Fatalf("processor.Run: %v", err) + } + + if err := generator.Run(cfg); err != nil { + t.Fatalf("generator.Run: %v", err) + } + + return cfg +} + +// makeDirs creates temporary input and output directories for a test. +func makeDirs(t *testing.T) (inputDir, outputDir string) { + t.Helper() + + base := t.TempDir() + inputDir = filepath.Join(base, "inbox") + outputDir = filepath.Join(base, "outdir") + + if err := os.MkdirAll(inputDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatal(err) + } + + return inputDir, outputDir +} + +// readFile is a helper that reads a file and fails the test on error. +func readFile(t *testing.T, path string) string { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + + return string(data) +} + +// assertContains fails the test if content does not contain substr. +func assertContains(t *testing.T, content, substr, label string) { + t.Helper() + + if !strings.Contains(content, substr) { + t.Errorf("%s: expected to contain %q\ngot:\n%s", label, substr, content[:min(len(content), 500)]) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// TestTxtInput verifies plain text files are converted to posts. +func TestTxtInput(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + if err := os.WriteFile(filepath.Join(inputDir, "hello.txt"), []byte("Hello, Nexus!"), 0o644); err != nil { + t.Fatal(err) + } + + runPipeline(t, inputDir, outputDir) + + // Source file should have been removed after processing. + if _, err := os.Stat(filepath.Join(inputDir, "hello.txt")); !os.IsNotExist(err) { + t.Error("source file should have been deleted from input dir") + } + + // A post directory should exist under outdir/posts/. + entries, err := os.ReadDir(filepath.Join(outputDir, "posts")) + if err != nil { + t.Fatalf("read posts dir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 post dir, got %d", len(entries)) + } + + // index.html must contain the post text. + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, "Hello, Nexus!", "index.html") +} + +// TestMarkdownInput verifies Markdown files are converted to HTML. +func TestMarkdownInput(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + md := "# Hello Nexus\n\nThis is **bold** text." + if err := os.WriteFile(filepath.Join(inputDir, "post.md"), []byte(md), 0o644); err != nil { + t.Fatal(err) + } + + runPipeline(t, inputDir, outputDir) + + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, "<strong>bold</strong>", "index.html markdown bold") + assertContains(t, index, "<h1>", "index.html markdown h1") +} + +// TestImageInput verifies image files are processed and embedded in pages. +func TestImageInput(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + writeSamplePNG(t, filepath.Join(inputDir, "photo.png")) + runPipeline(t, inputDir, outputDir) + + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, `<img`, "index.html image tag") + assertContains(t, index, `image.jpg`, "index.html image filename") + + // Converted JPEG should exist in the post asset dir. + postDirs, _ := os.ReadDir(filepath.Join(outputDir, "posts")) + if len(postDirs) != 1 { + t.Fatalf("expected 1 post, got %d", len(postDirs)) + } + imgPath := filepath.Join(outputDir, "posts", postDirs[0].Name(), "image.jpg") + if _, err := os.Stat(imgPath); err != nil { + t.Errorf("expected image.jpg in post dir: %v", err) + } +} + +// TestAudioInput verifies .mp3 files are copied and an audio element is generated. +func TestAudioInput(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + // Write a minimal non-empty file as a stand-in for MP3 content. + if err := os.WriteFile(filepath.Join(inputDir, "track.mp3"), []byte("ID3fake"), 0o644); err != nil { + t.Fatal(err) + } + + runPipeline(t, inputDir, outputDir) + + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, `<audio`, "index.html audio tag") + assertContains(t, index, `track.mp3`, "index.html audio filename") +} + +// TestMarkdownWithImage verifies that a Markdown post referencing a local image +// copies the image into the post dir and updates the src path. +func TestMarkdownWithImage(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + md := "Look at this:\n\n\n" + if err := os.WriteFile(filepath.Join(inputDir, "post.md"), []byte(md), 0o644); err != nil { + t.Fatal(err) + } + + writeSamplePNG(t, filepath.Join(inputDir, "photo.png")) + + runPipeline(t, inputDir, outputDir) + + postDirs, _ := os.ReadDir(filepath.Join(outputDir, "posts")) + if len(postDirs) != 1 { + t.Fatalf("expected 1 post, got %d", len(postDirs)) + } + + // The referenced image should be copied into the post dir. + imgPath := filepath.Join(outputDir, "posts", postDirs[0].Name(), "photo.png") + if _, err := os.Stat(imgPath); err != nil { + t.Errorf("expected photo.png in post dir: %v", err) + } +} + +// TestPagination verifies that 45 posts are split across two pages (42 + 3). +func TestPagination(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + for i := 0; i < 45; i++ { + name := fmt.Sprintf("post%02d.txt", i) + content := fmt.Sprintf("Post number %d", i) + if err := os.WriteFile(filepath.Join(inputDir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + runPipeline(t, inputDir, outputDir) + + // index.html should exist and contain 42 posts. + index := readFile(t, filepath.Join(outputDir, "index.html")) + if count := strings.Count(index, `class="post"`); count != 42 { + t.Errorf("index.html: expected 42 posts, got %d", count) + } + + // page2.html should exist and contain 3 posts. + page2 := readFile(t, filepath.Join(outputDir, "page2.html")) + if count := strings.Count(page2, `class="post"`); count != 3 { + t.Errorf("page2.html: expected 3 posts, got %d", count) + } +} + +// TestPaginationNavLinks verifies prev/next navigation links are positioned correctly. +func TestPaginationNavLinks(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + for i := 0; i < 45; i++ { + if err := os.WriteFile(filepath.Join(inputDir, fmt.Sprintf("p%02d.txt", i)), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + runPipeline(t, inputDir, outputDir) + + index := readFile(t, filepath.Join(outputDir, "index.html")) + // index.html (page 1) has no prev, should have next link (page2.html). + assertContains(t, index, "page2.html", "index.html next link") + if strings.Contains(index, "NEWER TRANSMISSIONS") { + t.Error("index.html should not have a prev-page link") + } + + page2 := readFile(t, filepath.Join(outputDir, "page2.html")) + // page2.html should have a prev link (index.html) and no next. + assertContains(t, page2, "NEWER TRANSMISSIONS", "page2.html prev link") + if strings.Contains(page2, "OLDER TRANSMISSIONS") { + t.Error("page2.html should not have a next-page link") + } +} + +// TestAtomFeed verifies that atom.xml is well-formed and contains ≤42 entries. +func TestAtomFeed(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + for i := 0; i < 5; i++ { + if err := os.WriteFile(filepath.Join(inputDir, fmt.Sprintf("p%d.txt", i)), []byte("feed post"), 0o644); err != nil { + t.Fatal(err) + } + } + + runPipeline(t, inputDir, outputDir) + + atomPath := filepath.Join(outputDir, "atom.xml") + data, err := os.ReadFile(atomPath) + if err != nil { + t.Fatalf("read atom.xml: %v", err) + } + + // Validate well-formed XML. + var feed struct { + XMLName xml.Name `xml:"feed"` + Entries []struct { + Title string `xml:"title"` + } `xml:"entry"` + } + if err := xml.Unmarshal(data, &feed); err != nil { + t.Fatalf("atom.xml not valid XML: %v", err) + } + + if len(feed.Entries) != 5 { + t.Errorf("expected 5 entries in atom.xml, got %d", len(feed.Entries)) + } +} + +// TestInputCleanup verifies all source files are removed from the input dir. +func TestInputCleanup(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + for _, name := range []string{"a.txt", "b.txt", "c.txt"} { + if err := os.WriteFile(filepath.Join(inputDir, name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + runPipeline(t, inputDir, outputDir) + + entries, _ := os.ReadDir(inputDir) + if len(entries) != 0 { + t.Errorf("input dir should be empty after processing, got %d files", len(entries)) + } +} + +// TestKeyboardNavJS verifies that the generated HTML includes navigation attributes. +func TestKeyboardNavJS(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + if err := os.WriteFile(filepath.Join(inputDir, "nav.txt"), []byte("nav test"), 0o644); err != nil { + t.Fatal(err) + } + + runPipeline(t, inputDir, outputDir) + + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, `data-index="0"`, "index.html data-index attribute") + assertContains(t, index, `.post-active`, "index.html .post-active CSS") + assertContains(t, index, `playNavSound`, "index.html playNavSound function") +} + +// TestThemeSelection verifies that every registered theme renders a valid +// index.html containing core structural elements (post text, nav script). +func TestThemeSelection(t *testing.T) { + themes := []string{ + "aurora", "brutalist", "glass", "matrix", "minimal", + "neon", "ocean", "paper", "retro", "synthwave", "terminal", + } + + for _, theme := range themes { + theme := theme // capture for parallel sub-test + + t.Run(theme, func(t *testing.T) { + inputDir, outputDir := makeDirs(t) + + if err := os.WriteFile(filepath.Join(inputDir, "hello.txt"), []byte("theme test post"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + InputDir: inputDir, + OutputDir: outputDir, + BaseURL: "https://snonux.foo", + Theme: theme, + } + + if _, err := processor.Run(cfg); err != nil { + t.Fatalf("processor.Run: %v", err) + } + if err := generator.Run(cfg); err != nil { + t.Fatalf("generator.Run for theme %q: %v", theme, err) + } + + index := readFile(t, filepath.Join(outputDir, "index.html")) + assertContains(t, index, "theme test post", "post text") + assertContains(t, index, "playNavSound", "nav JS") + assertContains(t, index, `data-index="0"`, "data-index attribute") + }) + } +} + +// writeSamplePNG writes a small 10×10 solid-colour PNG to path. +func writeSamplePNG(t *testing.T, path string) { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + img.Set(x, y, color.RGBA{R: 0, G: 245, B: 255, A: 255}) + } + } + + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } +} |
