move the demo's coverage test beside the wiring it proves
The test rebuilt the feature list by hand, because a package cannot import a main, and it drifted three times in one session — the dialect, notation, Compose — each caught by a failing case rather than by the copy. The obvious fix was a composition package under internal/ext, and it was the wrong one: ADR-0069 forbids a feature importing its sibling, so that package would have failed the gate on its first build and the invariant would have been weakened one commit after becoming mechanical. Moving the test is smaller and points the other way — cmd stays the only place a feature is named, which is what extensions.md asks for, and wire_test.go was already here for the same reason. runServe and the test now call one function for the renderer, so there is no list to keep in step. Four files named a feature before; three do now, all of them package main, plus a benchmark that deliberately wires one extension to measure the render path and never claimed to be the shipped list. The core ceiling paid for it rather than being raised a third time: `given` was a helper with one caller and is now inlined into the only function that used it. core 2842/2850. Latent item cleared — the one that was marked due.
This commit is contained in:
@@ -1,210 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/ext/notation"
|
||||
"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)
|
||||
}
|
||||
// This list has to match cmd/khosra/wire.go, which a package cannot import because it is a main. Kept in
|
||||
// step by hand, and by the dialect's own test living beside the list it ships (ADR-0058).
|
||||
r, err := render.New(fsys, settings, func(p render.Partial) []goldmark.Extender {
|
||||
return []goldmark.Extender{
|
||||
extension.Table,
|
||||
extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(shortcodes.FootnotePrefix)),
|
||||
extension.DefinitionList,
|
||||
notation.New(),
|
||||
shortcodes.New(p),
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Compose(shortcodes.Merge)
|
||||
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 site's own template supplies its own words in each language", path: "/bn/posts/", code: 200,
|
||||
expect: []string{"সাইটের নিজের টেমপ্লেট", "পুরোনো"}, absent: []string{"rendered by the site's own template"}},
|
||||
{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: "generated widths come with a sizes hint, and thumbnails load lazily", path: "/art/monsoon-studies/", code: 200,
|
||||
expect: []string{`sizes="(min-width: 36rem) 16rem, 100vw"`, `loading="lazy"`, `decoding="async"`}},
|
||||
{what: "a lead figure is sized for the measure and never deferred", path: "/posts/first-light/", code: 200,
|
||||
expect: []string{`sizes="(min-width: 36rem) 32rem, 100vw"`}, absent: []string{`loading="lazy"`}},
|
||||
{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 id="method">Method</h2>`, "<em>Emphasis and links survive</em>"}},
|
||||
{what: "a fragment is not a bundle", path: "/writing/notes-on-water/_method/", code: 404},
|
||||
{what: "notation marks become elements, and a single tilde is a subscript not a strike", path: "/writing/typography/", code: 200,
|
||||
expect: []string{"H<sub>2</sub>O", "10<sup>-3</sup>", "<mark>second column</mark>", "<del>this phrase</del>"},
|
||||
absent: []string{"<del>2</del>", "<sup>2 + y</sup>"}},
|
||||
{what: "an abbreviation expands and its definition line renders nothing", path: "/writing/typography/", code: 200,
|
||||
expect: []string{`<abbr title="Normalization Form C">NFC</abbr>`}, absent: []string{"*[NFC]"}},
|
||||
{what: "authored HTML renders, because the site root is trusted", path: "/writing/typography/", code: 200,
|
||||
expect: []string{"<kbd>Shift</kbd>"}, absent: []string{"raw HTML omitted"}},
|
||||
{what: "a margin note is a container the theme places", path: "/writing/typography/", code: 200,
|
||||
expect: []string{`<aside class="side">`, `<p class="side-title">On line breaking</p>`}},
|
||||
{what: "the reference theme's fragments come from a directory, not one file", path: "/pages/colophon/", code: 200,
|
||||
expect: []string{`<details name="run" open>`, "💡"}},
|
||||
{what: "an icon renders through the theme, and an unknown one keeps its text", path: "/pages/colophon/", code: 200,
|
||||
expect: []string{"💡", ":nosuchicon:"}},
|
||||
{what: "grouped panels are tabs, and need no script", path: "/pages/colophon/", code: 200,
|
||||
expect: []string{`<details name="run" open>`, "<summary>Serve your own</summary>"}, absent: []string{"<script"}},
|
||||
{what: "a lone panel is an expando", path: "/pages/about/", code: 200,
|
||||
expect: []string{"<details>", "<summary>What is deliberately not shown</summary>"}},
|
||||
{what: "an admonition renders inside a comic chapter too", path: "/comics/the-long-monsoon/the-flood/", code: 200,
|
||||
expect: []string{`<aside class="admonition note">`, `<p class="admonition-title">On the timeline</p>`}},
|
||||
{what: "the dialect renders tables and definition lists", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{"<table>", "<th>Gauge</th>", "<dl>", "<dt>Monsoon</dt>"}},
|
||||
{what: "a container renders its body through the theme fragment", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`<aside class="admonition warn">`, `<p class="admonition-title">Calibration</p>`, "<em>emphasis</em>"},
|
||||
absent: []string{":::"}},
|
||||
{what: "a table of contents links the page's own headings", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`<nav class="toc"`, `<a href="#gauge-readings">Readings</a>`, `class="toc-2"`}},
|
||||
{what: "a merging bundle has one footnote list, numbered straight through", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`id="fn:1"`, `id="fn:2"`}, absent: []string{"_method-fn:", `class="footnotes"><hr><ol><li id="fn:2"`}},
|
||||
{what: "a heading may declare an anchor that outlives its wording", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`<h2 id="gauge-readings">`, `href="#gauge-readings"`}},
|
||||
{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{"<not markup>"}},
|
||||
{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{"“Quotes", "–", "…", `<code>"quotes" -- 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user