Files
khosra/internal/render/render_test.go
T
Claude Opus 5andbdeshi b5ec3bfc9d serve one theme snapshot to the whole site, and honour -poll
Per-render reparsing could not keep the site coherent, and did not. Only two of
the four render methods called fresh() — Bundle and Tag never did — so under
-dev on a listing served an edited template while a bundle served the old one.
Verified on the pre-G1 binary: /posts/ answered V2 while /posts/hello/ answered
V1, permanently, not for a window.

Adding the two missing calls would have left four places that must each
remember, and Partial runs during a page's Markdown conversion, so one page could
still mix two themes. So the per-render path is deleted instead: Renderer.reload,
Reload() and fresh() are gone, Refresh is the only thing that replaces a theme,
and -dev on gets its promptness from polling every 250ms. Coherence is now
structural rather than a discipline four methods share.

-poll arrives as ADR-0022 specified it and never delivered: it sets the interval,
and 0 stops watching for an immutable deployment. Watch takes the interval and
settle window as arguments, so the Interval and Settle package variables are
gone and no test mutates package state to control timing.

The theme is reparsed in the watcher's callback rather than inside rebuilder, so
startup parses it exactly once, in New — there is one call site and it is not on
the startup path. The two swaps it leaves are not one transaction; state.md's
latent list carries that gap and its trigger.

Measured on the real binary: bundle, section listing and tag listing all moved
V1 -> V9 together within 1s of editing two templates; -poll 0 served and then
ignored an edit; -dev on -poll 3s kept 3s. core 2780/2800, ext 1027/2000.
2026-08-01 10:54:30 +06:00

354 lines
12 KiB
Go

package render
import (
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
)
func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) {
r, err := New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\n\n# Heading\n\nSome *prose*.\n"))
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
got := string(out)
for _, want := range []string{
"<!doctype html>", `<html lang="en">`, "<title>Hello</title>",
"<h1>Hello</h1>", "<em>prose</em>", "<style>",
} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q\n---\n%s", want, got)
}
}
}
func TestBundleWithoutTitleFallsBackToKey(t *testing.T) {
r, err := New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("status/note.md", []byte("just a note\n"))
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "<title>status/note</title>") {
t.Errorf("a titleless bundle must still produce a title element:\n%s", out)
}
}
func TestSiteOverridesOneBlockAndInheritsTheRest(t *testing.T) {
siteFS := fstest.MapFS{
"templates/page.html": {Data: []byte(`{{define "main"}}<section class="mine">{{.Title}}</section>{{end}}`)},
}
r, err := New(siteFS, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("pages/about.md", []byte("---\ntitle: About\n---\nbody\n"))
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
got := string(out)
if !strings.Contains(got, `<section class="mine">About</section>`) {
t.Errorf("the site override should win:\n%s", got)
}
if !strings.Contains(got, "<!doctype html>") || !strings.Contains(got, `<link rel="canonical"`) {
t.Errorf("the document should still come from the embedded base:\n%s", got)
}
if strings.Contains(got, "<article>") {
t.Error("the embedded main should have been replaced, not appended")
}
}
// The site is never half-stale: every render method reads the theme snapshot that was current when it started,
// and only Refresh replaces it (ADR-0056). Before that, two of the four reparsed on their own, so a listing
// could serve a new template while a bundle served the old one — and a tag listing reparsed never.
func TestEveryRenderMethodServesOneThemeSnapshot(t *testing.T) {
mark := func(v string) []byte {
return []byte(`{{define "main"}}<section>` + v + `</section>{{end}}`)
}
siteFS := fstest.MapFS{
"templates/page.html": {Data: mark("V1")},
"templates/list.html": {Data: mark("V1")},
"templates/extras.html": {Data: mark("V1")},
}
r, err := New(siteFS, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("posts/essay.md", []byte("---\ntitle: Essay\ntags: [monsoon]\n---\nbody\n"))
if err != nil {
t.Fatal(err)
}
b.Route = b.Key
// One call per render method the theme reaches. Any of them reparsing on its own is what makes a site
// half-stale, so they are checked together or not at all.
paths := map[string]func() ([]byte, error){
"Bundle": func() ([]byte, error) { return r.Bundle(b, "en", []string{"en"}, nil) },
"Listing": func() ([]byte, error) { return r.Listing("posts", "en", []content.Bundle{b}, 1) },
"Tag": func() ([]byte, error) { return r.Tag("", "monsoon", "en", []content.Bundle{b}, 1) },
"Extras": func() ([]byte, error) { return r.Extras(b, "en", nil, nil) },
}
assertAll := func(want, when string) {
t.Helper()
for name, render := range paths {
out, err := render()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if !strings.Contains(string(out), "<section>"+want+"</section>") {
t.Errorf("%s served the wrong theme %s — want %s:\n%s", name, when, want, out)
}
}
}
assertAll("V1", "before any edit")
for _, name := range []string{"templates/page.html", "templates/list.html", "templates/extras.html"} {
siteFS[name] = &fstest.MapFile{Data: mark("V2")}
}
assertAll("V1", "after an edit but before a Refresh")
if err := r.Refresh(); err != nil {
t.Fatal(err)
}
assertAll("V2", "after a Refresh")
}
// Refresh is what a rebuild calls, so an edited template takes effect without a restart (ADR-0055). Before it
// existed, the watcher noticed a template edit and the rebuild it fired changed nothing.
func TestRefreshSwapsAnEditedTemplateInAndKeepsTheWorkingOneOnAnError(t *testing.T) {
siteFS := fstest.MapFS{
"templates/page.html": {Data: []byte(`{{define "main"}}<section>first</section>{{end}}`)},
}
r, err := New(siteFS, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("pages/about.md", []byte("---\ntitle: About\n---\nbody\n"))
if err != nil {
t.Fatal(err)
}
rendered := func() string {
t.Helper()
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
return string(out)
}
if got := rendered(); !strings.Contains(got, "<section>first</section>") {
t.Fatalf("the site override should be in use before any edit:\n%s", got)
}
siteFS["templates/page.html"] = &fstest.MapFile{Data: []byte(`{{define "main"}}<section>second</section>{{end}}`)}
if got := rendered(); !strings.Contains(got, "<section>first</section>") {
t.Error("an edit on disk must not reach a render on its own: parsing stays off the request path")
}
if err := r.Refresh(); err != nil {
t.Fatal(err)
}
got := rendered()
if !strings.Contains(got, "<section>second</section>") || strings.Contains(got, "<section>first</section>") {
t.Errorf("Refresh should have swapped the edited template in:\n%s", got)
}
// A typo must not cost the site its working theme, because the alternative is serving nothing at all.
siteFS["templates/page.html"] = &fstest.MapFile{Data: []byte(`{{define "main"}}{{end`)}
if err := r.Refresh(); err == nil {
t.Fatal("a malformed template must be reported, not stored")
}
if got := rendered(); !strings.Contains(got, "<section>second</section>") {
t.Errorf("the last good theme should still be serving:\n%s", got)
}
}
func TestAListingOverrideDoesNotLeakIntoBundlePages(t *testing.T) {
siteFS := fstest.MapFS{
"templates/list.html": {Data: []byte(`{{define "main"}}LISTING ONLY{{end}}`)},
}
r, err := New(siteFS, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("pages/about.md", []byte("---\ntitle: About\n---\nbody\n"))
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", nil, nil)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "LISTING ONLY") {
t.Error("a listing override must not reach bundle pages — that is why sets are per kind")
}
}
func TestSiteStylesheetReplacesTheReferenceOne(t *testing.T) {
siteFS := fstest.MapFS{"templates/theme.css": {Data: []byte("body{color:rebeccapurple}")}}
r, err := New(siteFS, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, _ := content.Parse("pages/x.md", []byte("hi\n"))
out, err := r.Bundle(b, "en", nil, nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), "rebeccapurple") {
t.Error("the site stylesheet should replace the reference one")
}
}
func TestADeclaredBaseMakesMachineReadableURLsAbsolute(t *testing.T) {
// A canonical link and an hreflang are read by machines that resolve neither against the page, so both
// go absolute as soon as the site says where it lives (ADR-0039).
r, err := New(nil, content.Settings{Base: "https://khosra.example", Title: "Khosra"}, nil)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\nhi\n"))
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", []string{"en", "bn"}, nil)
if err != nil {
t.Fatal(err)
}
got := string(out)
for _, want := range []string{
`rel="canonical" href="https://khosra.example/posts/hello/"`,
`hreflang="bn" href="https://khosra.example/bn/posts/hello/"`,
`property="og:url" content="https://khosra.example/posts/hello/"`,
`<title>Hello · Khosra</title>`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
}
func TestWithoutABaseEverythingStaysRelative(t *testing.T) {
r, err := New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, _ := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\nhi\n"))
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
got := string(out)
if !strings.Contains(got, `rel="canonical" href="/posts/hello/"`) {
t.Errorf("a site that declared no base still links to itself:\n%s", got)
}
if strings.Contains(got, "og:site_name") {
t.Error("no declared title means no site_name tag, rather than an empty one")
}
}
func TestATagListingOffersBothShapesAndLetsTheThemeChoose(t *testing.T) {
// The engine may not decide that a tag listing looks grouped (ADR-0046). It supplies the partition, because
// a template cannot group for itself, and the flat list beside it, because choosing is markup.
r, err := New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
var bundles []content.Bundle
for _, spec := range []struct{ name, section string }{
{"posts/essay", "posts"}, {"comics/strip", "comics"}, {"posts/other", "posts"},
} {
b, err := content.Parse(spec.name+".md", []byte("---\ntitle: "+spec.name+"\n---\n"))
if err != nil {
t.Fatal(err)
}
b.Route = b.Key
bundles = append(bundles, b)
}
out, err := r.Tag("", "monsoon", "en", bundles, 1)
if err != nil {
t.Fatal(err)
}
// The reference theme renders groups, so the sections appear as headings.
got := string(out)
for _, want := range []string{"<h2>posts</h2>", "<h2>comics</h2>"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
// A theme preferring a flat list must have one, with each entry able to say where it came from.
if !strings.Contains(got, "posts/essay") || !strings.Contains(got, "comics/strip") {
t.Errorf("every entry should be present whichever shape is used:\n%s", got)
}
}
func TestAPageCanReachTheRestOfTheSite(t *testing.T) {
// The four things a reader could not get to before the reference theme was finished (ADR-0049): another
// section, this page's tags, its extras, and the same page in another language.
fsys := fstest.MapFS{
"content/posts/one/index.md": {Data: []byte("---\ntitle: One\ntags: [Monsoon]\n---\nx\n")},
"content/posts/one/extras/note.md": {Data: []byte("a note\n")},
}
r, err := New(fsys, content.Settings{Title: "Khosra"}, nil)
if err != nil {
t.Fatal(err)
}
r.Navigation(func() []string { return []string{"posts", "comics"} })
b, err := content.Parse("posts/one/index.md", []byte("---\ntitle: One\ntags: [Monsoon]\n---\nx\n"))
if err != nil {
t.Fatal(err)
}
b.Path, b.Route = "content/posts/one/index.md", b.Key
out, err := r.Bundle(b, "en", []string{"en", "bn"}, nil)
if err != nil {
t.Fatal(err)
}
got := string(out)
for _, want := range []string{
`href="/posts/"`, // navigation
`href="/comics/"`, // a section this page is not in
`rel="tag" href="/tags/monsoon/"`, // its own tags, at the term's listing
`href="/posts/one/extras/"`, // its extras, offered only because they exist
`href="/bn/posts/one/" hreflang="bn"`, // the other language, as a visible relative link
} {
if !strings.Contains(got, want) {
t.Errorf("a reader cannot reach %s:\n%s", want, got)
}
}
}
func TestAPageOffersNoExtrasLinkWhenThereAreNone(t *testing.T) {
// Guessing would give every page a link to a 404.
fsys := fstest.MapFS{"content/posts/bare/index.md": {Data: []byte("---\ntitle: Bare\n---\nx\n")}}
r, err := New(fsys, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, _ := content.Parse("posts/bare/index.md", []byte("---\ntitle: Bare\n---\nx\n"))
b.Path, b.Route = "content/posts/bare/index.md", b.Key
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "/extras/") {
t.Errorf("no extras exist, so nothing should link them:\n%s", out)
}
}