Files
khosra/internal/web/example_test.go
T
Claude Opus 5andbdeshi 53e9ef473a add inline notation, and take the tilde back from strikethrough
~sub~, ^sup^, ==mark==, and ~~strike~~ moved in from goldmark. Not a
preference: goldmark's strikethrough claims a single tilde as well as a double,
so with it enabled H~2~O rendered as H<del>2</del>O — measured before the
change. Two features cannot share a byte and both be correct, so notation owns
it and the authored syntax stays exactly as ADR-0058 documented.

The second failure was worse and only showed up under test. Under delimiter
rules `x^2 + y^2 = z^2` pairs its carets across the whole expression and renders
x<sup>2 + y</sup>2 — prose silently becoming markup, in exactly the content this
engine is for. So a single run is scanned rather than paired, and may not cross
whitespace: a subscript holds a formula, never a phrase. Pandoc draws the same
line. The cost is that a single run takes its content literally, so there is no
emphasis inside a subscript, which the ADR states rather than leaving to be
discovered.

New package under internal/ext, which is a stop condition and was asked. It
takes the extensions counter to 4, past its threshold, and the answer is still
no: four features attach in three unrelated ways, and two goldmark extenders
compose in goldmark's own extender list, which is already the registry for that
shape.

The example site's hand-copied extender list drifted, exactly as the latent row
added last loop predicted — the demo case failed and named it. Both are now in
step again.

core 2794/2800, ext 1236/2000, 34 gates green, 0 warnings.
2026-08-01 21:21:04 +06:00

183 lines
9.7 KiB
Go

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)
}
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 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: "authored HTML renders, because the site root is trusted", path: "/writing/notes-on-water/", code: 200,
expect: []string{"<kbd>Shift</kbd>"}, absent: []string{"raw HTML omitted"}},
{what: "notation marks become elements, and a single tilde is a subscript not a strike", path: "/writing/notes-on-water/", code: 200,
expect: []string{"H<sub>2</sub>O", "10<sup>-3</sup>", "<mark>important</mark>", "<del>a struck phrase</del>"},
absent: []string{"<del>2</del>"}},
{what: "the dialect renders tables, definition lists and strikethrough", path: "/writing/notes-on-water/", code: 200,
expect: []string{"<table>", "<th>Gauge</th>", "<dl>", "<dt>Monsoon</dt>", "<del>a struck phrase</del>"}},
{what: "a fragment's footnote ids are namespaced, so the page's own keep working", path: "/writing/notes-on-water/", code: 200,
expect: []string{`id="fn:1"`, `id="_method-fn:1"`, `href="#_method-fn:1"`}},
{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)
}
}
}
}