// 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/json"
"encoding/xml"
"fmt"
"image"
"image/color"
"image/color/palette"
"image/gif"
"image/jpeg"
"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")
// splash WebGL canvas is part of the per-theme splash markup baked in.
assertContains(t, index, "splash-gl-canvas", "index.html splash WebGL canvas")
assertContains(t, index, `href="atom.xml"`, "index.html atom feed link")
// shared bundles must also be written.
if _, err := os.Stat(filepath.Join(outputDir, "shared.css")); err != nil {
t.Fatalf("shared.css missing: %v", err)
}
if _, err := os.Stat(filepath.Join(outputDir, "shared.js")); err != nil {
t.Fatalf("shared.js missing: %v", err)
}
}
// 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, "bold", "index.html markdown bold")
assertContains(t, index, "
", "index.html markdown h1")
}
// assertStandaloneImagePost checks index.html and posts//image.jpg after a lone image input.
func assertStandaloneImagePost(t *testing.T, outputDir string) {
t.Helper()
index := readFile(t, filepath.Join(outputDir, "index.html"))
assertContains(t, index, `
p music", "index.html nav hint p=ambient")
assertContains(t, index, "f flash", "index.html nav hint f=flash")
}
// TestIndexHTMLBakesSounds verifies that the generated index.html bakes the
// default theme's sounds into window.SNONUX_SOUNDS so the ambient engine can
// start before any async theme fetches complete.
func TestIndexHTMLBakesSounds(t *testing.T) {
inputDir, outputDir := makeDirs(t)
if err := os.WriteFile(filepath.Join(inputDir, "hello.txt"), []byte("sounds test"), 0o644); err != nil {
t.Fatal(err)
}
runPipeline(t, inputDir, outputDir)
index := readFile(t, filepath.Join(outputDir, "index.html"))
assertContains(t, index, "window.SNONUX_SOUNDS", "index.html bakes SNONUX_SOUNDS")
assertContains(t, index, `"ambient"`, "index.html sounds include ambient key")
assertContains(t, index, `"normal"`, "index.html sounds include ambient.normal")
assertContains(t, index, `"wild"`, "index.html sounds include ambient.wild")
}
// 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 := generator.ListThemes()
if len(themes) == 0 {
t.Fatal("no themes returned by ListThemes()")
}
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, `data-index="0"`, "data-index attribute")
assertContains(t, index, `SNONUX_DEFAULT_THEME = "`+theme+`"`, "default theme baked in")
// Per-theme assets are written under themes//.
for _, fname := range []string{"theme.css", "theme.js", "meta.json", "sounds.json"} {
path := filepath.Join(outputDir, "themes", theme, fname)
info, err := os.Stat(path)
if err != nil {
t.Fatalf("theme asset missing %s: %v", path, err)
}
if info.Size() == 0 {
t.Fatalf("theme asset %s is empty", path)
}
}
// sounds.json must include ambient data.
soundsPath := filepath.Join(outputDir, "themes", theme, "sounds.json")
soundsData, err := os.ReadFile(soundsPath)
if err != nil {
t.Fatalf("read sounds.json: %v", err)
}
var sounds map[string]interface{}
if err := json.Unmarshal(soundsData, &sounds); err != nil {
t.Fatalf("sounds.json invalid JSON: %v", err)
}
ambient, ok := sounds["ambient"].(map[string]interface{})
if !ok {
t.Fatalf("sounds.json missing ambient object for theme %q", theme)
}
for _, key := range []string{"normal", "wild"} {
if _, ok := ambient[key]; !ok {
t.Errorf("sounds.json ambient missing %q variant for theme %q", key, theme)
}
}
// shared.js holds the nav logic.
sharedJS := readFile(t, filepath.Join(outputDir, "shared.js"))
assertContains(t, sharedJS, "playNavSound", "shared.js playNavSound")
})
}
}
func sampleRGBAImage() *image.RGBA {
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})
}
}
return img
}
// writeSamplePNG writes a small 10×10 solid-colour PNG to path.
func writeSamplePNG(t *testing.T, path string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, sampleRGBAImage()); err != nil {
t.Fatal(err)
}
}
// writeSampleJPEG writes a small valid JPEG to path.
func writeSampleJPEG(t *testing.T, path string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := jpeg.Encode(f, sampleRGBAImage(), &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
}
// writeSampleGIF writes a small single-frame GIF to path.
func writeSampleGIF(t *testing.T, path string) {
t.Helper()
bounds := image.Rect(0, 0, 10, 10)
paletted := image.NewPaletted(bounds, palette.Plan9)
for y := 0; y < 10; y++ {
for x := 0; x < 10; x++ {
paletted.SetColorIndex(x, y, 1)
}
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := gif.Encode(f, paletted, &gif.Options{}); err != nil {
t.Fatal(err)
}
}