Files
khosra/internal/web/example_test.go
T
bdeshi 8a2ef082be track the demo as a real site in examples/, gated like the docs
The human asked for a demo extensive enough to review by hand, tracked as files,
and kept current the way docs are. Generated filler cannot be reviewed — you cannot
read a Go function and see what a reader sees — so the generator is deleted and
`examples/demo-site/` is an ordinary site: 33 Markdown files, six pictures,
site.yaml, a template override and static files.

My reason for generating it was ADR-0011, and I had that rule wrong: it puts the
*author's site root* outside this repository, not fixtures. conventions.md already
keeps golden files in testdata/, and this is the same category one size up. Keeping
both a generator and files would have been two sources of truth, so one had to go.

Kept true by two gates rather than by good intentions. A table-driven test in
internal/web serves the directory through the real handler with one case per
feature — thirty-five of them, each naming what it proves — and verify.sh runs
`khosra check` over it and fails on anything fatal. Adding a feature now means
adding it to the demo and adding its case, and the build says so if you don't.

Extensive on purpose: eleven dated posts so a section actually paginates, a
four-chapter series so first/last are not the neighbours, a gallery with three
JPEGs and an SVG so both the resampled and the untouched paths show, a Bengali-only
bundle, a titleless status, a draft, a future date, an include, a nested extras
tree, and a typography page that demonstrates what the engine will not do.

Two expectations of mine were wrong and the demo corrected them: the site's own
list template overrides *tag* listings too, so there are no group headings there —
which turns out to be the better demonstration of ADR-0046, since the theme picking
the flat shape is exactly the point. And template literal text is not escaped, so
an apostrophe stays an apostrophe.
2026-07-31 20:08:28 +06:00

164 lines
8.3 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/yuin/goldmark"
"khosra/internal/content"
"khosra/internal/ext/shortcodes"
"khosra/internal/render"
)
// exampleSite serves examples/demo-site the way the binary does.
//
// The real site root, not a fixture: this is what makes the demo a test rather than a brochure. When a feature
// changes shape and the example stops showing it, this fails — which is the only way a demo stays current
// (ADR-0051).
func exampleSite(t *testing.T) http.Handler {
t.Helper()
const dir = "../../examples/demo-site"
if _, err := os.Stat(dir); err != nil {
t.Fatalf("the example site is missing: %v", err)
}
fsys := os.DirFS(dir)
bundles, problems, err := content.ScanReport(fsys)
if err != nil {
t.Fatal(err)
}
if len(problems) != 0 {
t.Fatalf("the engine cannot read its own example site: %v", problems)
}
settings, err := content.LoadSettings(fsys)
if err != nil {
t.Fatal(err)
}
// Derivatives into a throwaway directory: the example must never gain generated files of its own.
if _, err := shortcodes.Derive(fsys, t.TempDir()); err != nil {
t.Fatal(err)
}
r, err := render.New(fsys, settings, func(p render.Partial) []goldmark.Extender {
return []goldmark.Extender{shortcodes.New(p)}
})
if err != nil {
t.Fatal(err)
}
site := content.NewSite(bundles)
r.Navigation(site.Sections)
return Handler(Fixed(site), r, fsys, nil, settings)
}
func get(t *testing.T, h http.Handler, path string) (int, string) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec.Code, rec.Body.String()
}
// featureCase is one thing the example site must still show.
type featureCase struct {
what, path string
code int
expect []string
absent []string
}
// exampleFeatures is the demo's contract with the engine: one case per feature, each naming what it proves.
//
// A feature added without a case here is a feature the demo does not show; a case that fails is a demo that has
// gone stale. Both are the point, and both are why this is a table rather than prose in a README (ADR-0051).
var exampleFeatures = []featureCase{
{what: "the root lists everything", path: "/", code: 200,
expect: []string{"A Khosra Demo", `href="/comics/`, `href="/writing/`}},
{what: "a section listing paginates", path: "/posts/", code: 200,
expect: []string{`rel="next" href="/posts/page/2/"`, "Day 11"}},
{what: "page two links back", path: "/posts/page/2/", code: 200,
expect: []string{`rel="prev" href="/posts/"`}},
{what: "a site template overrides one block and inherits the document", path: "/posts/", code: 200,
expect: []string{"rendered by the site's own template", "<!doctype html>", `class="entry"`}},
{what: "navigation, feed discovery and OpenGraph are in the head", path: "/posts/first-light/", code: 200,
expect: []string{`<nav class="sections">`, `type="application/atom+xml"`, `property="og:title"`}},
{what: "a figure renders through the theme with a srcset and dimensions", path: "/posts/first-light/", code: 200,
expect: []string{"<figure>", "srcset=", `width="1400"`, "<figcaption>"}},
{what: "a bundle's own picture is served", path: "/posts/first-light/cover.jpg", code: 200},
{what: "tags link their listings", path: "/posts/first-light/", code: 200,
expect: []string{`rel="tag" href="/tags/monsoon/"`}},
{what: "language links are relative while hreflang is absolute", path: "/posts/first-light/", code: 200,
expect: []string{`href="/bn/posts/first-light/" hreflang="bn"`, `hreflang="bn" href="http://localhost:8080/bn/`}},
{what: "a Bengali variant localises chrome", path: "/bn/posts/first-light/", code: 200,
expect: []string{`lang="bn"`, "প্রথম আলো"}},
{what: "a missing variant falls back and says which it served", path: "/bn/posts/only-english/", code: 200,
expect: []string{"Only in English", `rel="canonical" href="http://localhost:8080/posts/only-english/"`}},
{what: "a bundle that exists only in Bengali is still served", path: "/pages/bengali-only/", code: 200,
expect: []string{"শুধু বাংলায়"}},
{what: "a slug moves the address", path: "/posts/a-better-name/", code: 200},
{what: "an alias keeps the old address working", path: "/posts/renamed-thing/", code: 301},
{what: "a draft is not served", path: "/posts/unfinished/", code: 404},
{what: "a future-dated bundle is not served", path: "/posts/scheduled/", code: 404},
{what: "a series landing lists its chapters in order", path: "/comics/the-long-monsoon/", code: 200,
expect: []string{"First Rain", "The Flood", "Aftermath", "What Remained"}},
{what: "a chapter has neighbours, a position, and the ends", path: "/comics/the-long-monsoon/the-flood/", code: 200,
expect: []string{`rel="prev"`, `rel="next"`, "2 of 4", `class="ends"`}},
{what: "a gallery sizes what it can and leaves alone what it cannot", path: "/art/monsoon-studies/", code: 200,
expect: []string{`class="gallery"`, "10-grey.jpg", "srcset=", `src="40-line.svg"`},
absent: []string{`src="40-line.svg" srcset`}},
{what: "an include is parsed as Markdown, and its fragment has no page of its own", path: "/writing/notes-on-water/", code: 200,
expect: []string{"<h2>Method</h2>", "<em>Emphasis and links survive</em>"}},
{what: "a fragment is not a bundle", path: "/writing/notes-on-water/_method/", code: 404},
{what: "a page offers its extras only when it has them", path: "/writing/notes-on-water/", code: 200,
expect: []string{`href="/writing/notes-on-water/extras/"`}},
{what: "the extras tree is classified", path: "/writing/notes-on-water/extras/", code: 200,
expect: []string{"research.md", "gauge.log", "scan.jpg", "drafts", "markdown", "text", "image"}},
{what: "a nested extras entry renders", path: "/writing/notes-on-water/extras/drafts/v1.md", code: 200,
expect: []string{"abandoned"}},
{what: "a text entry is escaped, not interpreted", path: "/writing/notes-on-water/extras/gauge.log", code: 200,
expect: []string{"&lt;not markup&gt;"}},
{what: "raw returns the bytes", path: "/writing/notes-on-water/extras/gauge.log?raw", code: 200,
expect: []string{"day one: 2m, rising"}, absent: []string{"<!doctype"}},
{what: "typography is smoothed in prose and not in code", path: "/writing/typography/", code: 200,
expect: []string{"&ldquo;Quotes", "&ndash;", "&hellip;", `<code>&quot;quotes&quot; -- and ellipses...</code>`}},
{what: "a titleless bundle still renders", path: "/status/2026-03-30-1400/", code: 200},
// The engine offers a tag listing in two shapes and the theme picks (ADR-0046). This site's template picks
// the flat one and labels each entry with its section, which is why there are no group headings here.
{what: "a tag listing spans sections, in whichever shape the theme chose", path: "/tags/monsoon/", code: 200,
expect: []string{"· comics", "· posts", "· art", "· writing"}, absent: []string{"<h2>comics</h2>"}},
{what: "a tag listing narrows to a section", path: "/comics/tags/monsoon/", code: 200,
absent: []string{"First Light"}},
{what: "the feed carries dated bundles and nothing else", path: "/feed.xml", code: 200,
expect: []string{"<feed", `xml:lang="en"`, "http://localhost:8080/posts/a-better-name/"},
absent: []string{"About This Demo", "Unfinished", "Scheduled"}},
{what: "a section has its own feed", path: "/comics/feed.xml", code: 200,
expect: []string{"The Flood"}, absent: []string{"Day 11"}},
{what: "the sitemap lists every variant", path: "/sitemap.xml", code: 200,
expect: []string{"/posts/first-light/", "/bn/posts/first-light/"}, absent: []string{"unfinished"}},
{what: "robots points at the sitemap", path: "/robots.txt", code: 200,
expect: []string{"Sitemap: http://localhost:8080/sitemap.xml"}},
{what: "static files are served verbatim", path: "/static/note.txt", code: 200,
expect: []string{"served verbatim"}},
}
func TestTheExampleSiteExercisesEveryFeature(t *testing.T) {
h := exampleSite(t)
for _, c := range exampleFeatures {
code, body := get(t, h, c.path)
if code != c.code {
t.Errorf("%s: GET %s = %d, want %d", c.what, c.path, code, c.code)
continue
}
for _, want := range c.expect {
if !strings.Contains(body, want) {
t.Errorf("%s: GET %s is missing %q", c.what, c.path, want)
}
}
for _, unwanted := range c.absent {
if strings.Contains(body, unwanted) {
t.Errorf("%s: GET %s should not contain %q", c.what, c.path, unwanted)
}
}
}
}