Phase 7 (AUD-149), cheapest first. - internal/brand: vendored Simple Icons path data (CC0) for 50 marks, looked up by the technology label the content already uses. Inline SVG, 16 px, currentColor, no request and no font. tools/gen_brand_icons.sh regenerates it. - Six hand-drawn area icons replace the two-letter badges in the nav and on the landing cards; the evidence index gets a seventh. - TechChips renders a mark where one exists and plain text where one does not, so a missing icon reads as a decision rather than a broken image. - Client wall: 45 names as one typeface, one size, one grid of equal tiles, and the reason for no logo collage written on the page. No mark invented. - Two diagrams, authored in-repo: homelab topology (platform-network.md) and the RAG pipeline (ai-rag-corpus.md), each referenced from the page it explains. - Content images now carry intrinsic width/height, loading="lazy" and decoding="async" at render time; a site test fails the build if an image ships without them. - Topbar is two rows at desktop: the area names plus marks no longer overlap the search form (Lighthouse target-size was failing at 1440 px). - Favicon, so the browser stops requesting a missing /favicon.ico. Satisfies: logos on every area page, area icons in nav and cards, two diagrams referenced from their pages, images sized and lazy, Lighthouse >= 95 held. (cherry picked from commit a473e4738a3751418c2097df2bcf0568fb6d0208)
298 lines
8.4 KiB
Go
298 lines
8.4 KiB
Go
package site
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
|
|
)
|
|
|
|
// newSite loads the repository's own content. If this fails, every other test
|
|
// here would be testing nothing.
|
|
func newSite(t *testing.T) *Site {
|
|
t.Helper()
|
|
root, err := filepath.Abs("../..")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := New(Config{Root: root, OutDir: filepath.Join(t.TempDir(), "dist")})
|
|
if err != nil {
|
|
t.Fatalf("load site: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// TestSixAreasHaveLandingPages is the DoD line "L1 (6) and L2 (6) pages
|
|
// complete": every area has a published level 1 page and at least one level 2
|
|
// page, and every area's L1 fragment is the default stream.
|
|
func TestSixAreasHaveLandingPages(t *testing.T) {
|
|
s := newSite(t)
|
|
if len(content.Areas) != 6 {
|
|
t.Fatalf("%d areas, want 6", len(content.Areas))
|
|
}
|
|
if got := len(s.StreamKeys()); got != 6 {
|
|
t.Fatalf("stream keys = %d, want the six area landing fragments", got)
|
|
}
|
|
for i, a := range content.Areas {
|
|
l1, ok := s.Content.AreaLanding(a.Key)
|
|
if !ok {
|
|
t.Errorf("%s: no level 1 page", a.Key)
|
|
continue
|
|
}
|
|
if !l1.Public {
|
|
t.Errorf("%s: level 1 page %s is not public", a.Key, l1.ID)
|
|
}
|
|
if f, ok := s.Fragment(s.StreamKeys()[i]); !ok {
|
|
t.Errorf("%s: stream key %s is not a fragment", a.Key, s.StreamKeys()[i])
|
|
} else if f.DocID != l1.ID {
|
|
t.Errorf("%s: stream fragment %s belongs to %s, want %s", a.Key, f.ID, f.DocID, l1.ID)
|
|
}
|
|
levels := map[int]int{}
|
|
for _, d := range s.PublicInArea(a.Key) {
|
|
levels[d.Level]++
|
|
}
|
|
if levels[2] == 0 {
|
|
t.Errorf("%s: no level 2 pages", a.Key)
|
|
}
|
|
if levels[3] == 0 {
|
|
t.Errorf("%s: no level 3 pages", a.Key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEveryArtefactIsReachableWithinThreeClicks is acceptance test C4 against
|
|
// the view models: landing → area → artefact page is three clicks, and every
|
|
// artefact has a working link.
|
|
func TestEveryArtefactIsReachableWithinThreeClicks(t *testing.T) {
|
|
s := newSite(t)
|
|
for _, area := range content.Areas {
|
|
l1, ok := s.Content.AreaLanding(area.Key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
linkedFromL1 := map[string]bool{}
|
|
for _, l := range s.Doc(l1).SubPages {
|
|
linkedFromL1[l.Href] = true
|
|
}
|
|
for _, l := range s.Doc(l1).Evidence {
|
|
linkedFromL1[l.Href] = true
|
|
}
|
|
|
|
for _, d := range s.PublicInArea(area.Key) {
|
|
if d.Level == 1 {
|
|
continue
|
|
}
|
|
if d.Level == 2 {
|
|
if !linkedFromL1[d.URL] {
|
|
t.Errorf("%s/level 2 page %s is not linked from the area landing page", area.Key, d.URL)
|
|
}
|
|
continue
|
|
}
|
|
// Level 3: linked from an L2 page's drill-down or evidence table.
|
|
reachable := false
|
|
for _, l2 := range s.PublicInArea(area.Key) {
|
|
if l2.Level != 2 {
|
|
continue
|
|
}
|
|
view := s.Doc(l2)
|
|
for _, l := range view.SubPages {
|
|
if l.Href == d.URL {
|
|
reachable = true
|
|
}
|
|
}
|
|
for _, l := range view.Evidence {
|
|
if l.Href == d.URL {
|
|
reachable = true
|
|
}
|
|
}
|
|
}
|
|
if !reachable {
|
|
t.Errorf("%s/level 3 page %s is not linked from any level 2 page", area.Key, d.URL)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEveryArtefactHasAStageFragment keeps the panel honest: if an evidence row
|
|
// offers hx-get, the fragment it names must exist.
|
|
func TestEveryArtefactHasAStageFragment(t *testing.T) {
|
|
s := newSite(t)
|
|
for _, d := range s.PublicDocs() {
|
|
for _, l := range s.evidence(d) {
|
|
if l.HXGet == "" {
|
|
t.Errorf("%s: evidence %q has no stage fragment", d.ID, l.Label)
|
|
continue
|
|
}
|
|
id := strings.TrimPrefix(l.HXGet, "/stage/")
|
|
if _, ok := s.Fragment(id); !ok {
|
|
t.Errorf("%s: evidence %q points at missing fragment %q", d.ID, l.Label, id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildWritesEveryPublishedPage runs the real build once and checks the
|
|
// files the server depends on.
|
|
func TestBuildWritesEveryPublishedPage(t *testing.T) {
|
|
s := newSite(t)
|
|
if err := s.Build(); err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
for _, url := range s.PublishedPages() {
|
|
file := s.filePathForURL(url)
|
|
if !strings.HasPrefix(file, s.Config.OutDir) {
|
|
t.Errorf("%s: output escapes the build directory", url)
|
|
}
|
|
if _, err := os.ReadFile(file); err != nil {
|
|
t.Errorf("%s: %v", url, err)
|
|
}
|
|
}
|
|
for _, name := range []string{IndexFile, ManifestFile, BuildInfoFile, "static/site.css", "static/htmx.min.js", "static/chroma.css"} {
|
|
if _, err := os.ReadFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(name))); err != nil {
|
|
t.Errorf("build output %s: %v", name, err)
|
|
}
|
|
}
|
|
info, err := LoadBuildInfo(s.Config.OutDir)
|
|
if err != nil {
|
|
t.Fatalf("build info: %v", err)
|
|
}
|
|
if info.Pages == 0 || info.Fragments == 0 || info.Indexed == 0 {
|
|
t.Errorf("build info is empty: %+v", info)
|
|
}
|
|
// The manifest order is the server's default stream: six document
|
|
// fragments first, and they are the six area landings.
|
|
if len(s.Manifest.Entries) < 6 {
|
|
t.Fatalf("manifest has %d entries", len(s.Manifest.Entries))
|
|
}
|
|
for _, e := range s.Manifest.Entries[:6] {
|
|
if !strings.HasPrefix(e.ID, "doc-") {
|
|
t.Errorf("manifest entry %s is not a document fragment; the default stream must be the six areas", e.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// copyTree copies a directory tree. The freshness test needs its own content
|
|
// tree so it can change a page without touching the repository.
|
|
func copyTree(src, dst string) error {
|
|
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(src, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
if d.IsDir() {
|
|
return os.MkdirAll(target, 0o755)
|
|
}
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(target, raw, 0o644)
|
|
})
|
|
}
|
|
|
|
// TestSearchIndexFollowsContent is the AUD-151 freshness line: a content change
|
|
// followed by a build is reflected in search. The deploy path is exactly this —
|
|
// deploy/deploy.sh runs `make dist` before it stages a release, and the build
|
|
// rewrites dist/search.db from content/ every time (search.Build removes the old
|
|
// file), so search can never serve an index from a previous content tree.
|
|
func TestSearchIndexFollowsContent(t *testing.T) {
|
|
root, err := filepath.Abs("../..")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := t.TempDir()
|
|
contentDir := filepath.Join(dir, "content")
|
|
if err := copyTree(filepath.Join(root, "content"), contentDir); err != nil {
|
|
t.Fatalf("copy content: %v", err)
|
|
}
|
|
out := filepath.Join(dir, "dist")
|
|
|
|
build := func() {
|
|
t.Helper()
|
|
s, err := New(Config{Root: root, ContentDir: contentDir, OutDir: out})
|
|
if err != nil {
|
|
t.Fatalf("load site: %v", err)
|
|
}
|
|
if err := s.Build(); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
}
|
|
query := func(q string) []search.Result {
|
|
t.Helper()
|
|
idx, err := search.Open(filepath.Join(out, IndexFile))
|
|
if err != nil {
|
|
t.Fatalf("open index: %v", err)
|
|
}
|
|
defer idx.Close()
|
|
results, err := idx.Query(q, 20)
|
|
if err != nil {
|
|
t.Fatalf("query %q: %v", q, err)
|
|
}
|
|
return results
|
|
}
|
|
|
|
const token = "kumquatprotocol"
|
|
build()
|
|
if got := query(token); len(got) != 0 {
|
|
t.Fatalf("token is already indexed in %d results; the test proves nothing", len(got))
|
|
}
|
|
|
|
page := filepath.Join(contentDir, "edge", "edge-fleet.md")
|
|
raw, err := os.ReadFile(page)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
added := string(raw) + "\n\nA " + token + " harness keeps the nodes in step.\n"
|
|
if err := os.WriteFile(page, []byte(added), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
build()
|
|
results := query(token)
|
|
if len(results) == 0 {
|
|
t.Fatal("content change is not in the rebuilt index")
|
|
}
|
|
if results[0].URL != "/edge/edge-fleet/" {
|
|
t.Errorf("token found at %s, want /edge/edge-fleet/", results[0].URL)
|
|
}
|
|
}
|
|
|
|
// TestImagesReserveTheirSpace is the layout-shift guard: every image a content
|
|
// page carries must declare its intrinsic size and load lazily, so a page of
|
|
// diagrams and screenshots never reflows as the files arrive.
|
|
func TestImagesReserveTheirSpace(t *testing.T) {
|
|
s := newSite(t)
|
|
seen := 0
|
|
for _, d := range s.Content.Docs {
|
|
html := d.HTML
|
|
for i := 0; ; i++ {
|
|
start := strings.Index(html, "<img ")
|
|
if start < 0 {
|
|
break
|
|
}
|
|
end := strings.Index(html[start:], ">")
|
|
if end < 0 {
|
|
break
|
|
}
|
|
tag := html[start : start+end+1]
|
|
html = html[start+end+1:]
|
|
seen++
|
|
for _, want := range []string{`width="`, `height="`, `loading="lazy"`, `alt="`} {
|
|
if !strings.Contains(tag, want) {
|
|
t.Errorf("%s: image tag missing %s: %s", d.Path, want, tag)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if seen == 0 {
|
|
t.Fatal("no images found in content; the guard is not exercising anything")
|
|
}
|
|
}
|