// 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 (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"image"
"image/color"
"image/color/palette"
"image/gif"
"image/jpeg"
"image/png"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"codeberg.org/snonux/snonux/internal/config"
"codeberg.org/snonux/snonux/internal/generator"
"codeberg.org/snonux/snonux/internal/processor"
"codeberg.org/snonux/snonux/internal/version"
)
var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file
// 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(ctx, cfg)
if err != nil {
t.Fatalf("processor.Run: %v", err)
}
if err := generator.Run(ctx, 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
}
func max(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, `
pmusic", "index.html nav hint p=ambient")
assertContains(t, index, "fflash", "index.html nav hint f=flash")
}
// TestSplashMusicChoiceMarkup verifies (task js0) that the generated shared.js
// injects an explicit "with music" / "without music" choice into the splash
// overlay, and that shared.css styles it. The buttons are added at runtime
// (identically for every theme) rather than baked into each theme's
// splash_inner_html, so this asserts on the generated JS/CSS rather than on
// index.html markup.
func TestSplashMusicChoiceMarkup(t *testing.T) {
inputDir, outputDir := makeDirs(t)
if err := os.WriteFile(filepath.Join(inputDir, "splash-music.txt"), []byte("splash music choice test"), 0o644); err != nil {
t.Fatal(err)
}
runPipeline(t, inputDir, outputDir)
sharedJS := readFile(t, filepath.Join(outputDir, "shared.js"))
assertContains(t, sharedJS, "function snonuxRenderSplashMusicChoice()", "shared.js defines the splash music choice injector")
assertContains(t, sharedJS, "splash-music-choice", "shared.js references the splash-music-choice container class")
assertContains(t, sharedJS, `data-sno-music="on"`, "shared.js with-music button markup")
assertContains(t, sharedJS, `data-sno-music="off"`, "shared.js without-music button markup")
assertContains(t, sharedJS, "snonuxAmbientSavePreference(withMusic)", "shared.js saves the chosen preference")
// Re-injected after both places that replace #splash-overlay's innerHTML
// wholesale (theme switch at runtime, and theme-meta application on load
// when a visitor's saved theme differs from the page's baked-in default),
// otherwise those choice buttons would silently disappear again. Assert
// the exact count (not just presence) so a regression that drops one of
// the two call sites doesn't hide behind the other still matching.
reinjectLine := "if (typeof snonuxRenderSplashMusicChoice === 'function') snonuxRenderSplashMusicChoice();"
if got := strings.Count(sharedJS, reinjectLine); got != 2 {
t.Errorf("shared.js re-injects splash music choice at %d call sites; want 2 (snonuxSwitchTheme + snonuxApplyThemeMeta)", got)
}
sharedCSS := readFile(t, filepath.Join(outputDir, "shared.css"))
assertContains(t, sharedCSS, ".splash-music-choice", "shared.css styles the splash music choice container")
assertContains(t, sharedCSS, ".splash-music-btn", "shared.css styles the splash music choice buttons")
}
// TestSplashMusicChoiceBrowser drives a real headless browser to verify (task
// js0) that clicking the splash's "without music" button dismisses the splash,
// persists the decline, and actually leaves the ambient engine stopped; that
// "with music" persists acceptance and actually starts the ambient engine;
// and that the choice buttons survive (and keep working after) something
// wiping #splash-overlay's innerHTML wholesale — the exact hazard
// snonuxSwitchTheme() and snonuxApplyThemeMeta() pose, since both replace it
// on the fly when a visitor's saved theme differs from the page default.
func TestSplashMusicChoiceBrowser(t *testing.T) {
chromium, ok := findChromium()
if !ok {
t.Skip("Chromium executable not found; skipping browser splash-music-choice test")
}
inputDir, outputDir := makeDirs(t)
if err := os.WriteFile(filepath.Join(inputDir, "splash-music-browser.txt"), []byte("splash music choice browser test"), 0o644); err != nil {
t.Fatal(err)
}
runPipeline(t, inputDir, outputDir)
writeSplashMusicChoiceBrowserHarness(t, outputDir)
testHTML := filepath.Join(outputDir, "splash-music-choice-test.html")
pageURL := url.URL{Scheme: "file", Path: testHTML}
out, err := exec.Command(
chromium,
"--headless",
"--disable-gpu",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-background-networking",
"--allow-file-access-from-files",
"--window-size=900,700",
"--virtual-time-budget=4000",
"--dump-dom",
pageURL.String(),
).CombinedOutput()
if err != nil {
t.Fatalf("run headless Chromium: %v\n%s", err, string(out))
}
dom := string(out)
if !strings.Contains(dom, `data-splash-music-test="pass"`) {
t.Fatalf("splash music choice behavior failed; DOM dump tail:\n%s", dom[max(0, len(dom)-3000):])
}
}
// writeSplashMusicChoiceBrowserHarness clones index.html (stripping CDN
// assets unreachable from a file:// context, same as the scroll-selection
// harness) and appends a script that exercises both splash music choice
// buttons in sequence, recording pass/fail on document.body.
func writeSplashMusicChoiceBrowserHarness(t *testing.T, outputDir string) {
t.Helper()
indexPath := filepath.Join(outputDir, "index.html")
html := readFile(t, indexPath)
html = strings.ReplaceAll(html, ``, "")
html = strings.ReplaceAll(html, ``, "")
html = strings.Replace(html,
``,
``+"\n"+
` `,
1,
)
if err := os.WriteFile(filepath.Join(outputDir, "splash-music-choice-test.html"), []byte(html), 0o644); err != nil {
t.Fatalf("write splash-music-choice-test.html: %v", err)
}
harness := `
(function () {
function finish(status, detail) {
document.body.setAttribute('data-splash-music-test', status);
var pre = document.createElement('pre');
pre.id = 'splash-music-test-result';
pre.textContent = detail;
document.body.appendChild(pre);
}
function ambientPref() {
try { return localStorage.getItem('snonuxAmbientEnabled'); } catch (_) { return null; }
}
function ambientPlaying() {
return !!(window.snonuxAmbientIsPlaying && window.snonuxAmbientIsPlaying());
}
function findButtons(overlay) {
return {
on: overlay.querySelector('.splash-music-btn[data-sno-music="on"]'),
off: overlay.querySelector('.splash-music-btn[data-sno-music="off"]')
};
}
function run() {
var overlay = document.getElementById('splash-overlay');
var btns = overlay && findButtons(overlay);
if (!overlay || !btns.on || !btns.off) {
finish('fail', 'missing splash overlay or music-choice buttons');
return;
}
// Negative path: decline music. Should dismiss the splash, persist
// '0', and actually leave the ambient engine stopped (not just the
// localStorage side effect).
try { localStorage.removeItem('snonuxAmbientEnabled'); } catch (_) {}
btns.off.click();
var declinedDismissed = overlay.classList.contains('splash--dismissed');
var declinedPref = ambientPref();
var declinedPlaying = ambientPlaying();
// Re-open the splash (buttons must still be attached and working)
// so the positive path starts from a clean slate.
if (window._snonuxShowSplash) window._snonuxShowSplash();
var reopened = !overlay.classList.contains('splash--dismissed');
// Positive path: accept music. Should dismiss the splash, persist
// '1', and actually start the ambient engine.
btns.on.click();
var acceptedDismissed = overlay.classList.contains('splash--dismissed');
var acceptedPref = ambientPref();
var acceptedPlaying = ambientPlaying();
// Simulate the hazard the re-injection fix guards against:
// snonuxSwitchTheme()/snonuxApplyThemeMeta() replace #splash-overlay's
// entire innerHTML with a fresh theme's markup on the fly, which wipes
// out anything appended to it — including these choice buttons. Drop
// just the choice element (same end state as that wholesale replace)
// and confirm the exported re-injector rebuilds a single, live copy.
var inner = overlay.querySelector('.splash-inner');
var choiceBeforeWipe = inner && inner.querySelector('.splash-music-choice');
if (choiceBeforeWipe) choiceBeforeWipe.remove();
var wiped = !overlay.querySelector('.splash-music-choice');
if (window.snonuxRenderSplashMusicChoice) window.snonuxRenderSplashMusicChoice();
var rebuilt = overlay.querySelectorAll('.splash-music-choice').length === 1;
// The rebuilt buttons must be freshly wired, not stale references —
// re-fetch them and exercise the decline path once more.
try { localStorage.removeItem('snonuxAmbientEnabled'); } catch (_) {}
if (window._snonuxShowSplash) window._snonuxShowSplash();
var rebuiltBtns = findButtons(overlay);
var rebuiltBtnsFound = !!(rebuiltBtns.on && rebuiltBtns.off);
if (rebuiltBtnsFound) rebuiltBtns.off.click();
var rebuiltDismissed = overlay.classList.contains('splash--dismissed');
var rebuiltPref = ambientPref();
// The prior scenario left the engine playing (acceptedPlaying), so this
// decline must flip it back off too — not just the localStorage side effect.
var rebuiltPlaying = ambientPlaying();
// Regression check for the keyboard-focus fix: the document-level
// splash keydown handler used to unconditionally preventDefault()
// and dismiss on Enter/Space whenever the splash was open, which
// swallows the native "activate the focused button" default action
// a keyboard-only visitor relies on to actually press one of these
// buttons. A trusted, browser-synthesized key press can't be faked
// from page script, but we can verify the property our fix controls
// directly: with a music button focused, our handler must leave the
// event un-prevented and must not itself dismiss the splash, so the
// browser is free to run its native button-activation default action.
if (window._snonuxShowSplash) window._snonuxShowSplash();
rebuiltBtns.off.focus();
var focusedButtonIsActive = document.activeElement === rebuiltBtns.off;
var enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
rebuiltBtns.off.dispatchEvent(enterEvent);
var keydownPreventedByUs = enterEvent.defaultPrevented;
var dismissedByGenericKeydown = overlay.classList.contains('splash--dismissed');
var detail = 'declinedDismissed=' + declinedDismissed + ' declinedPref=' + declinedPref +
' declinedPlaying=' + declinedPlaying +
' reopened=' + reopened +
' acceptedDismissed=' + acceptedDismissed + ' acceptedPref=' + acceptedPref +
' acceptedPlaying=' + acceptedPlaying +
' wiped=' + wiped + ' rebuilt=' + rebuilt +
' rebuiltBtnsFound=' + rebuiltBtnsFound +
' rebuiltDismissed=' + rebuiltDismissed + ' rebuiltPref=' + rebuiltPref +
' rebuiltPlaying=' + rebuiltPlaying +
' focusedButtonIsActive=' + focusedButtonIsActive +
' keydownPreventedByUs=' + keydownPreventedByUs +
' dismissedByGenericKeydown=' + dismissedByGenericKeydown;
var pass = declinedDismissed && declinedPref === '0' && !declinedPlaying &&
reopened &&
acceptedDismissed && acceptedPref === '1' && acceptedPlaying &&
wiped && rebuilt &&
rebuiltBtnsFound && rebuiltDismissed && rebuiltPref === '0' && !rebuiltPlaying &&
focusedButtonIsActive && !keydownPreventedByUs && !dismissedByGenericKeydown;
finish(pass ? 'pass' : 'fail', detail);
}
if (document.readyState === 'complete') run();
else window.addEventListener('load', run);
})();
`
if err := os.WriteFile(filepath.Join(outputDir, "splash-music-choice-test.js"), []byte(harness), 0o644); err != nil {
t.Fatalf("write splash-music-choice-test.js: %v", err)
}
}
// TestScrollDrivenPostSelection verifies the generated page behavior in a real
// browser: scrolling the post container moves .post-active to the article
// nearest the container center.
func TestScrollDrivenPostSelection(t *testing.T) {
chromium, ok := findChromium()
if !ok {
t.Skip("Chromium executable not found; skipping browser scroll-selection test")
}
inputDir, outputDir := makeDirs(t)
for i := 0; i < 24; i++ {
name := fmt.Sprintf("scroll-post-%02d.txt", i)
content := fmt.Sprintf("Scroll selection fixture post %02d\n\n%s", i, strings.Repeat("body line\n", 6))
if err := os.WriteFile(filepath.Join(inputDir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
runPipeline(t, inputDir, outputDir)
writeScrollSelectionBrowserHarness(t, outputDir)
testHTML := filepath.Join(outputDir, "scroll-selection-test.html")
pageURL := url.URL{Scheme: "file", Path: testHTML}
out, err := exec.Command(
chromium,
"--headless",
"--disable-gpu",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-background-networking",
"--allow-file-access-from-files",
"--window-size=900,700",
"--virtual-time-budget=4000",
"--dump-dom",
pageURL.String(),
).CombinedOutput()
if err != nil {
t.Fatalf("run headless Chromium: %v\n%s", err, string(out))
}
dom := string(out)
if !strings.Contains(dom, `data-scroll-selection-test="pass"`) {
t.Fatalf("scroll-driven post selection failed; result=%q DOM dump tail:\n%s",
scrollSelectionBrowserResult(dom), dom[max(0, len(dom)-3000):])
}
}
func findChromium() (string, bool) {
for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable"} {
path, err := exec.LookPath(name)
if err == nil {
return path, true
}
}
return "", false
}
func writeScrollSelectionBrowserHarness(t *testing.T, outputDir string) {
t.Helper()
indexPath := filepath.Join(outputDir, "index.html")
html := readFile(t, indexPath)
html = strings.ReplaceAll(html, ``, "")
html = strings.ReplaceAll(html, ``, "")
html = strings.Replace(html,
``,
``+"\n"+
` `+"\n"+
` `,
1,
)
if err := os.WriteFile(filepath.Join(outputDir, "scroll-selection-test.html"), []byte(html), 0o644); err != nil {
t.Fatalf("write scroll-selection-test.html: %v", err)
}
harness := `
(function () {
function finish(status, detail) {
document.body.setAttribute('data-scroll-selection-test', status);
var pre = document.createElement('pre');
pre.id = 'scroll-selection-test-result';
pre.textContent = detail;
document.body.appendChild(pre);
}
function postIndex(post) {
return Array.prototype.indexOf.call(document.querySelectorAll('.post'), post);
}
function expectedCenterIndex(sc, posts) {
var sr = sc.getBoundingClientRect();
var centerY = sr.top + sr.height / 2;
var bestIndex = -1;
var bestDistance = Infinity;
posts.forEach(function (post, index) {
var r = post.getBoundingClientRect();
if (r.top <= centerY && centerY < r.bottom) {
bestIndex = index;
bestDistance = -1;
return;
}
if (bestDistance < 0 || r.bottom <= sr.top || r.top >= sr.bottom) return;
var visibleTop = Math.max(r.top, sr.top);
var visibleBottom = Math.min(r.bottom, sr.bottom);
var distance = Math.abs(((visibleTop + visibleBottom) / 2) - centerY);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
});
return bestIndex;
}
function run() {
var sc = document.getElementById('post-content');
var posts = Array.prototype.slice.call(document.querySelectorAll('.post'));
if (!sc || posts.length < 8) {
finish('fail', 'missing scroll container or posts');
return;
}
if (sc.scrollHeight <= sc.clientHeight) {
finish('fail', 'post container is not scrollable: scrollHeight=' + sc.scrollHeight + ' clientHeight=' + sc.clientHeight);
return;
}
var target = posts[Math.min(10, posts.length - 2)];
var targetTop = target.offsetTop - (sc.clientHeight / 2) + (target.offsetHeight / 2);
sc.style.scrollBehavior = 'auto';
var realRequestAnimationFrame = window.requestAnimationFrame;
window.__snoTestNow += 1000;
window.requestAnimationFrame = function (callback) {
callback(performance.now());
return 1;
};
sc.scrollTop = Math.max(0, Math.min(targetTop, sc.scrollHeight - sc.clientHeight));
sc.dispatchEvent(new Event('scroll', { bubbles: true }));
window.requestAnimationFrame = realRequestAnimationFrame;
var active = document.querySelector('.post.post-active');
var activeIndex = postIndex(active);
var expectedIndex = expectedCenterIndex(sc, posts);
var detail = 'active=' + activeIndex + ' expected=' + expectedIndex + ' scrollTop=' + sc.scrollTop;
if (expectedIndex <= 0) {
finish('fail', 'scroll did not move center past the first post: ' + detail);
return;
}
finish(activeIndex === expectedIndex ? 'pass' : 'fail', detail);
}
run();
})();
`
if err := os.WriteFile(filepath.Join(outputDir, "scroll-selection-test.js"), []byte(harness), 0o644); err != nil {
t.Fatalf("write scroll-selection-test.js: %v", err)
}
}
func scrollSelectionBrowserResult(dom string) string {
marker := `id="scroll-selection-test-result"`
idx := strings.Index(dom, marker)
if idx < 0 {
return "missing result marker"
}
start := max(0, idx-200)
end := min(len(dom), idx+500)
return dom[start:end]
}
// 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) {
if testing.Short() {
t.Skip("skipping long-running theme selection integration test in short mode")
}
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(ctx, cfg); err != nil {
t.Fatalf("processor.Run: %v", err)
}
if err := generator.Run(ctx, 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")
assertContains(t, index, "snonux v"+version.Version, "index.html header version")
// Per-theme assets are written under themes//.
themeDir := filepath.Join(outputDir, "themes", theme)
for _, fname := range []string{"theme.css", "theme.js", "meta.json", "sounds.json"} {
path := filepath.Join(themeDir, 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)
}
}
fontAssets := map[string][]string{
"matrix": {"VT323-Regular.woff2", "FONT_LICENSE.txt"},
"retro": {"VT323-Regular.woff2", "FONT_LICENSE.txt"},
"nukem": {"Web437_IBM_VGA_8x16.woff", "FONT_LICENSE.txt"},
"retrofuture": {
"FONT_LICENSE.txt",
"orbitron-v35-latin_latin-ext-700.woff2",
"orbitron-v35-latin_latin-ext-regular.woff2",
"share-tech-mono-v16-latin_latin-ext-regular.woff2",
},
"tropicale": {
"FONT_LICENSE.txt",
"quicksand-v37-latin_latin-ext-500.woff2",
"quicksand-v37-latin_latin-ext-regular.woff2",
},
}
fontURLs := map[string][]string{
"matrix": {"url('VT323-Regular.woff2')"},
"retro": {"url('VT323-Regular.woff2')"},
"nukem": {"url('Web437_IBM_VGA_8x16.woff')"},
"retrofuture": {
"url('orbitron-v35-latin_latin-ext-700.woff2')",
"url('orbitron-v35-latin_latin-ext-regular.woff2')",
"url('share-tech-mono-v16-latin_latin-ext-regular.woff2')",
},
"tropicale": {
"url('quicksand-v37-latin_latin-ext-500.woff2')",
"url('quicksand-v37-latin_latin-ext-regular.woff2')",
},
}
if assets, ok := fontAssets[theme]; ok {
for _, fname := range assets {
path := filepath.Join(themeDir, fname)
info, err := os.Stat(path)
if err != nil {
t.Fatalf("%s extra asset missing %s: %v", theme, path, err)
}
if info.Size() == 0 {
t.Fatalf("%s extra asset %s is empty", theme, path)
}
}
themeCSS := readFile(t, filepath.Join(themeDir, "theme.css"))
for _, fontURL := range fontURLs[theme] {
assertContains(t, themeCSS, fontURL, theme+" local font URL")
}
for _, forbidden := range []string{"googleapis", "gstatic", "fonts.cdn", "@import url(http"} {
if strings.Contains(themeCSS, forbidden) {
t.Fatalf("%s theme.css contains runtime third-party font reference %q", theme, forbidden)
}
}
}
// 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)
}
}
for _, key := range []string{"normal", "wild"} {
preset, ok := ambient[key].(map[string]interface{})
if !ok {
continue
}
if preset["file"] != "ambient.ogg" {
t.Errorf("sounds.json ambient.%s file=%v for theme %q; want ambient.ogg", key, preset["file"], theme)
}
}
for _, asset := range []string{"ambient.ogg", "MUSIC_LICENSE.txt"} {
if _, err := os.Stat(filepath.Join(themeDir, asset)); err != nil {
t.Errorf("%s missing generated %s: %v", theme, asset, err)
}
}
metaData, err := os.ReadFile(filepath.Join(themeDir, "meta.json"))
if err != nil {
t.Fatalf("read generated meta.json: %v", err)
}
var meta struct {
HeaderHTML string `json:"header_html"`
}
if err := json.Unmarshal(metaData, &meta); err != nil {
t.Fatalf("generated meta.json invalid JSON: %v", err)
}
if !strings.Contains(meta.HeaderHTML, "snonux v"+version.Version) {
t.Errorf("%s generated meta.json header missing version", 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)
}
}