oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package render_test
2
3import (
4 "flag"
5 "fmt"
6 "math"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strconv"
11 "strings"
12 "testing"
13
14 "amadan.net/oficina/slides/internal/slides/deck"
15 "amadan.net/oficina/slides/internal/slides/fonts"
16 "amadan.net/oficina/slides/internal/slides/render"
17)
18
19// update rewrites the golden files instead of comparing against them:
20//
21// go test ./internal/slides/render -update
22//
23// Read the diff before committing a regenerated golden. The goldens are
24// the renderer's contract with the viewer, the presenter and the editor
25// stage, so a change to one is a change somebody should have meant to
26// make.
27var update = flag.Bool("update", false, "rewrite the golden files")
28
29// txt is one paragraph of unmarked text — the shape most fixture blocks
30// want.
31func txt(s string) deck.Text { return deck.Text{{{T: s}}} }
32
33// paras is several unmarked paragraphs: list items, code lines, a quote
34// that runs on.
35func paras(ss ...string) deck.Text {
36 t := make(deck.Text, 0, len(ss))
37 for _, s := range ss {
38 t = append(t, []deck.Run{{T: s}})
39 }
40 return t
41}
42
43// box is a canvas block's frame, spelled positionally so a fixture
44// slide reads as a picture rather than as four labelled fields.
45func box(x, y, w, h float64) *deck.Box { return &deck.Box{X: x, Y: y, W: w, H: h} }
46
47func intp(n int) *int { return &n }
48
49// fixture is the deck the golden tests render: one slide per flow
50// template, one canvas slide, one canvas slide set to reflow, and every
51// block type in §2.4 somewhere among them.
52//
53// Hand-built rather than driven through the core: the goldens are about
54// what the renderer does with a view, and a literal deck is that view
55// with the wasm boundary taken out of the loop. internal/slides/sample.go
56// is the same deck said in ops, and this fixture follows its shape.
57func fixture() *deck.Deck {
58 return &deck.Deck{
59 Meta: deck.Meta{Title: "Renderer fixture", Theme: "paper", Aspect: "16:9"},
60 Assets: map[string]deck.Asset{
61 // Refs are what the object store minted: a hex SHA-256,
62 // abbreviated here so a golden stays readable. What the
63 // <img> carries is the REF, never the id — the id is the
64 // deck's own name for the asset and means nothing to the
65 // route that serves the bytes.
66 "asset0001aaa": {Ref: "aa01bb02cc03dd04", Name: "chart.png", Width: 1600, Height: 900, Alt: "A bar chart with no bars"},
67 "asset0002bbb": {Ref: "ee05ff06aa07bb08", Name: "texture.png", Width: 64, Height: 64},
68 },
69 Slides: []deck.Slide{
70 // 0 — title
71 {
72 ID: "slide001aaaa", Layout: "flow", Template: "title",
73 Background: deck.Background{Kind: "theme"},
74 Notes: txt("The title template: one heading and one line under it."),
75 Blocks: []deck.Block{
76 {ID: "b001aaaaaaaa", Type: "heading", Region: "heading",
77 Props: map[string]any{"level": float64(1)}, Text: txt("Renderer fixture")},
78 {ID: "b002aaaaaaaa", Type: "text", Region: "body",
79 Props: map[string]any{"size": "l"}, Text: txt("One section per slide, one element per block.")},
80 },
81 },
82 // 1 — title-body, with every inline mark and both list styles
83 {
84 ID: "slide002aaaa", Layout: "flow", Template: "title-body",
85 Background: deck.Background{Kind: "theme"},
86 Blocks: []deck.Block{
87 {ID: "b011aaaaaaaa", Type: "heading", Region: "heading",
88 Props: map[string]any{"level": float64(1)}, Text: txt("Text and marks")},
89 {ID: "b012aaaaaaaa", Type: "text", Region: "body",
90 Props: map[string]any{"size": "m", "align": "start"},
91 Text: deck.Text{{
92 {T: "Marks: "},
93 {T: "bold", B: true},
94 {T: ", "},
95 {T: "italic", I: true},
96 {T: ", "},
97 {T: "code", C: true},
98 {T: ", "},
99 {T: "highlight", H: true},
100 {T: ", "},
101 {T: "all four", B: true, I: true, C: true, H: true},
102 {T: " and a "},
103 {T: "link", Href: "https://amadan.net/oficina/slides"},
104 {T: " <not markup>."},
105 }}},
106 {ID: "b013aaaaaaaa", Type: "list", Region: "body",
107 Props: map[string]any{"style": "bullet", "size": "m"},
108 Text: paras("One item per paragraph", "Bullets or numbers")},
109 {ID: "b014aaaaaaaa", Type: "list", Region: "body",
110 Props: map[string]any{"style": "number", "size": "s", "indent": float64(1)},
111 Text: paras("First", "Second")},
112 },
113 },
114 // 2 — two-column, and a columns block whose children must not
115 // also render at the top level
116 {
117 ID: "slide003aaaa", Layout: "flow", Template: "two-column",
118 Background: deck.Background{Kind: "theme"},
119 Blocks: []deck.Block{
120 {ID: "b021aaaaaaaa", Type: "heading", Region: "heading",
121 Props: map[string]any{"level": float64(2)}, Text: txt("Regions, not coordinates")},
122 {ID: "b022aaaaaaaa", Type: "text", Region: "body",
123 Text: txt("A flow slide names regions and blocks stack inside the one they are in.")},
124 {ID: "b023aaaaaaaa", Type: "columns", Region: "body",
125 Props: map[string]any{"count": float64(2), "children": []any{"b025aaaaaaaa", "b024aaaaaaaa"}}},
126 {ID: "b024aaaaaaaa", Type: "text", Region: "body", Text: txt("Second column, first in the array.")},
127 {ID: "b025aaaaaaaa", Type: "text", Region: "body", Text: txt("First column, second in the array.")},
128 {ID: "b026aaaaaaaa", Type: "text", Region: "aside",
129 Text: txt("The aside is the second column.")},
130 },
131 },
132 // 3 — section, on a slide background the deck set by colour
133 {
134 ID: "slide004aaaa", Layout: "flow", Template: "section",
135 Background: deck.Background{Kind: "color", Value: "#2b1030"},
136 Blocks: []deck.Block{
137 {ID: "b031aaaaaaaa", Type: "heading", Region: "heading",
138 Props: map[string]any{"level": float64(1)}, Text: txt("Part two")},
139 },
140 },
141 // 4 — image-full: the alt that comes from the asset
142 {
143 ID: "slide005aaaa", Layout: "flow", Template: "image-full",
144 Background: deck.Background{Kind: "image", Value: "asset0002bbb"},
145 Blocks: []deck.Block{
146 {ID: "b041aaaaaaaa", Type: "image", Region: "media",
147 Props: map[string]any{"asset": "asset0001aaa", "fit": "cover", "radius": float64(8)}},
148 {ID: "b042aaaaaaaa", Type: "heading", Region: "heading",
149 Props: map[string]any{"level": float64(2)}, Text: txt("A picture would go here")},
150 },
151 },
152 // 5 — quote, with and without an attribution
153 {
154 ID: "slide006aaaa", Layout: "flow", Template: "quote",
155 Background: deck.Background{Kind: "theme"},
156 Blocks: []deck.Block{
157 {ID: "b051aaaaaaaa", Type: "quote", Region: "body",
158 Props: map[string]any{"cite": "The design spec"},
159 Text: txt("A deck is a document, not a drawing.")},
160 {ID: "b052aaaaaaaa", Type: "quote", Region: "body",
161 Text: txt("Said by nobody in particular.")},
162 },
163 },
164 // 6 — blank, carrying the block types the other slides do not
165 {
166 ID: "slide007aaaa", Layout: "flow", Template: "blank",
167 Background: deck.Background{Kind: "theme"},
168 Blocks: []deck.Block{
169 {ID: "b061aaaaaaaa", Type: "code", Region: "body",
170 Props: map[string]any{"language": "go", "size": "m"},
171 Text: paras("func main() {", "\tfmt.Println(\"hi <there>\")", "}")},
172 {ID: "b062aaaaaaaa", Type: "table", Region: "body",
173 Props: map[string]any{
174 "rows": float64(2), "cols": float64(2), "header": true,
175 "cells": []any{
176 []any{[]any{map[string]any{"t": "Region"}}},
177 []any{[]any{map[string]any{"t": "Holds"}}},
178 []any{[]any{map[string]any{"t": "heading"}}},
179 []any{[]any{map[string]any{"t": "the title"}}},
180 },
181 }},
182 {ID: "b063aaaaaaaa", Type: "divider", Region: "body"},
183 {ID: "b064aaaaaaaa", Type: "spacer", Region: "body",
184 Props: map[string]any{"size": "l"}},
185 {ID: "b065aaaaaaaa", Type: "shape", Region: "body",
186 Props: map[string]any{"kind": "rect", "fill": "#e8e2d6", "stroke": "#8a7f6a", "strokeWidth": float64(2), "radius": float64(12)},
187 Text: txt("A labelled rectangle")},
188 {ID: "b066aaaaaaaa", Type: "shape", Region: "body",
189 Props: map[string]any{"kind": "ellipse", "fill": "#cfd9e8", "stroke": "#5a6f8a", "strokeWidth": float64(2)}},
190 {ID: "b067aaaaaaaa", Type: "shape", Region: "body",
191 Props: map[string]any{"kind": "line", "stroke": "#5a6f8a", "strokeWidth": float64(3)}},
192 {ID: "b068aaaaaaaa", Type: "shape", Region: "body",
193 Props: map[string]any{"kind": "arrow", "stroke": "currentColor"}},
194 {ID: "b069aaaaaaaa", Type: "image", Region: "body",
195 Props: map[string]any{"asset": "asset0002bbb", "fit": "contain", "decorative": true, "alt": ""}},
196 {ID: "b070aaaaaaaa", Type: "image", Region: "body",
197 Props: map[string]any{"asset": "asset0002bbb", "fit": "contain"}},
198 },
199 },
200 // 7 — canvas, with a phone override on one block
201 {
202 ID: "slide008aaaa", Layout: "canvas", Template: "blank", Phone: "inherit",
203 Background: deck.Background{Kind: "theme"},
204 Blocks: []deck.Block{
205 {ID: "b081aaaaaaaa", Type: "heading", Box: box(0.06, 0.08, 0.88, 0.16), Z: 3,
206 Props: map[string]any{"level": float64(1)}, Text: txt("Canvas: put it where you want it")},
207 {ID: "b082aaaaaaaa", Type: "shape", Box: box(0.08, 0.34, 0.36, 0.44), Z: 1,
208 Props: map[string]any{"kind": "rect", "fill": "#e8e2d6", "stroke": "#8a7f6a", "strokeWidth": float64(2), "radius": float64(12)}},
209 {ID: "b083aaaaaaaa", Type: "text", Box: box(0.58, 0.36, 0.36, 0.4), Z: 4,
210 Phone: deck.PhoneOverride{Order: intp(0), Size: "s", Box: box(0.05, 0.5, 0.9, 0.45)},
211 Text: txt("Every box is a fraction of the 16:9 frame.")},
212 },
213 },
214 // 8 — canvas with phone: reflow, whose z order is not its
215 // array order, and one block hidden on phones
216 {
217 ID: "slide009aaaa", Layout: "canvas", Template: "blank", Phone: "reflow", Transition: "fade",
218 Background: deck.Background{Kind: "theme"},
219 Notes: txt("The phone layer only ever adds to the desktop layout."),
220 Blocks: []deck.Block{
221 {ID: "b091aaaaaaaa", Type: "heading", Box: box(0.06, 0.1, 0.88, 0.16), Z: 1,
222 Props: map[string]any{"level": float64(1)}, Text: txt("On a phone this one reflows")},
223 {ID: "b092aaaaaaaa", Type: "shape", Box: box(0.64, 0.32, 0.3, 0.44), Z: 2,
224 Phone: deck.PhoneOverride{Hidden: true},
225 Props: map[string]any{"kind": "rect", "fill": "#e0d7e8", "stroke": "#75608a", "strokeWidth": float64(2), "radius": float64(12)},
226 Text: txt("Desktop only")},
227 {ID: "b093aaaaaaaa", Type: "text", Box: box(0.06, 0.32, 0.52, 0.44), Z: 2,
228 Text: txt("Setting phone to reflow lays the blocks out as one column.")},
229 {ID: "b094aaaaaaaa", Type: "text", Box: box(0.06, 0.8, 0.88, 0.12), Z: 3,
230 Text: txt("A footnote, last by z.")},
231 },
232 },
233 },
234 }
235}
236
237// goldenNames pairs a fixture slide index with the golden it writes.
238var goldenNames = []struct {
239 name string
240 index int
241}{
242 {"title", 0},
243 {"title-body", 1},
244 {"two-column", 2},
245 {"section", 3},
246 {"image-full", 4},
247 {"quote", 5},
248 {"blank", 6},
249 {"canvas", 7},
250 {"canvas-reflow", 8},
251}
252
253var viewports = []struct {
254 name string
255 vp render.Viewport
256}{
257 {"desktop", render.Desktop},
258 {"phone", render.Phone},
259}
260
261func TestSlideGoldens(t *testing.T) {
262 d := fixture()
263 for _, g := range goldenNames {
264 for _, v := range viewports {
265 name := g.name + "-" + v.name
266 t.Run(name, func(t *testing.T) {
267 got, err := render.Slide(d, g.index, v.vp)
268 if err != nil {
269 t.Fatalf("render.Slide(%d, %s): %v", g.index, v.name, err)
270 }
271 path := filepath.Join("testdata", name+".golden.html")
272 body := string(got) + "\n"
273 if *update {
274 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
275 t.Fatalf("write golden: %v", err)
276 }
277 return
278 }
279 want, err := os.ReadFile(path)
280 if err != nil {
281 t.Fatalf("read golden (run with -update to create it): %v", err)
282 }
283 if body != string(want) {
284 t.Errorf("%s does not match the golden.\n got: %s\nwant: %s", name, body, want)
285 }
286 })
287 }
288 }
289}
290
291// TestSlideRejectsAnIndexOutsideTheDeck: a caller asking for slide 9 of
292// nine gets an error, not a panic and not an empty section.
293func TestSlideRejectsAnIndexOutsideTheDeck(t *testing.T) {
294 d := fixture()
295 for _, i := range []int{-1, len(d.Slides), 1000} {
296 if _, err := render.Slide(d, i, render.Desktop); err == nil {
297 t.Errorf("render.Slide(%d) returned no error", i)
298 }
299 }
300 if _, err := render.Slide(nil, 0, render.Desktop); err == nil {
301 t.Error("render.Slide(nil deck) returned no error")
302 }
303}
304
305// TestColumnsChildrenRenderOnceInsideTheColumns: a block named by a
306// columns block's props.children renders in the columns element, in the
307// order children names, and nowhere else.
308func TestColumnsChildrenRenderOnceInsideTheColumns(t *testing.T) {
309 got, err := render.Slide(fixture(), 2, render.Desktop)
310 if err != nil {
311 t.Fatal(err)
312 }
313 s := string(got)
314 for _, id := range []string{"b024aaaaaaaa", "b025aaaaaaaa"} {
315 if n := strings.Count(s, id); n != 1 {
316 t.Errorf("child %s appears %d times, want 1", id, n)
317 }
318 }
319 first, second := strings.Index(s, "b025aaaaaaaa"), strings.Index(s, "b024aaaaaaaa")
320 if first < 0 || second < 0 || first > second {
321 t.Errorf("children are not in props.children order: b025 at %d, b024 at %d", first, second)
322 }
323 cols := strings.Index(s, `data-block="columns"`)
324 if cols < 0 || cols > first {
325 t.Errorf("the children do not sit inside the columns element")
326 }
327}
328
329// TestABlockNamedTwiceRendersOnce: nothing stops two columns blocks
330// naming the same child, and a block rendered twice would put the same
331// data-id on two elements — which is the id the editor and the drive
332// API address a block by. First claim wins.
333func TestABlockNamedTwiceRendersOnce(t *testing.T) {
334 d := &deck.Deck{
335 Meta: deck.Meta{Theme: "paper"},
336 Slides: []deck.Slide{{
337 ID: "s", Layout: "flow", Template: "blank",
338 Blocks: []deck.Block{
339 {ID: "colA", Type: "columns", Region: "body",
340 Props: map[string]any{"children": []any{"kid"}}},
341 {ID: "colB", Type: "columns", Region: "body",
342 Props: map[string]any{"children": []any{"kid"}}},
343 {ID: "kid", Type: "text", Region: "body", Text: txt("Claimed twice")},
344 },
345 }},
346 }
347 out, err := render.Slide(d, 0, render.Desktop)
348 if err != nil {
349 t.Fatal(err)
350 }
351 if n := strings.Count(string(out), `data-id="kid"`); n != 1 {
352 t.Errorf("the shared child rendered %d times, want 1: %s", n, out)
353 }
354
355 // And a cycle through two columns blocks terminates rather than
356 // recurring until the stack runs out.
357 cyclic := &deck.Deck{
358 Meta: deck.Meta{Theme: "paper"},
359 Slides: []deck.Slide{{
360 ID: "s", Layout: "flow", Template: "blank",
361 Blocks: []deck.Block{
362 {ID: "one", Type: "columns", Region: "body",
363 Props: map[string]any{"children": []any{"two"}}},
364 {ID: "two", Type: "columns", Region: "body",
365 Props: map[string]any{"children": []any{"one"}}},
366 },
367 }},
368 }
369 if _, err := render.Slide(cyclic, 0, render.Desktop); err != nil {
370 t.Fatal(err)
371 }
372}
373
374// TestHrefSchemesAreFiltered: a link mark carries a URL somebody typed.
375// http, https and mailto become links; everything else renders as the
376// text it was, with no anchor at all — never a stripped href, never a
377// javascript: URL that html/template merely neutered.
378func TestHrefSchemesAreFiltered(t *testing.T) {
379 linked := []string{
380 "https://amadan.net",
381 "http://example.test/path?q=1#f",
382 "HTTPS://Amadan.net/Shouting",
383 "mailto:paul@tito.io",
384 "MailTo:paul@tito.io",
385 }
386 for _, href := range linked {
387 out := string(render.Text(deck.Text{{{T: "click", Href: href}}}))
388 if !strings.Contains(out, "<a href=") {
389 t.Errorf("href %q rendered no link: %s", href, out)
390 }
391 if !strings.Contains(out, `rel="noopener"`) {
392 t.Errorf("href %q rendered a link with no rel: %s", href, out)
393 }
394 if !strings.Contains(out, "click") {
395 t.Errorf("href %q lost the text: %s", href, out)
396 }
397 }
398 refused := []string{
399 "javascript:alert(1)",
400 "JaVaScRiPt:alert(1)",
401 " javascript:alert(1)",
402 "data:text/html;base64,PHNjcmlwdD4=",
403 "vbscript:msgbox",
404 "file:///etc/passwd",
405 "ftp://example.test/x",
406 "/decks/1",
407 "decks/1",
408 "//evil.test",
409 "",
410 }
411 for _, href := range refused {
412 out := string(render.Text(deck.Text{{{T: "click", Href: href}}}))
413 if strings.Contains(out, "<a ") || strings.Contains(out, "href") {
414 t.Errorf("href %q was rendered as a link: %s", href, out)
415 }
416 if !strings.Contains(out, "click") {
417 t.Errorf("href %q lost the text: %s", href, out)
418 }
419 }
420}
421
422// TestTextEscapesEverything: the text schema is somebody's typing, and
423// none of it is markup.
424func TestTextEscapesEverything(t *testing.T) {
425 out := string(render.Text(deck.Text{{
426 {T: `<script>alert("x")</script>`},
427 {T: "&amp;", B: true},
428 }}))
429 for _, bad := range []string{"<script>", `alert("x")`} {
430 if strings.Contains(out, bad) {
431 t.Errorf("output carries %q verbatim: %s", bad, out)
432 }
433 }
434 if !strings.Contains(out, "&amp;amp;") {
435 t.Errorf("a literal &amp; was not escaped again: %s", out)
436 }
437}
438
439// TestNotesRenderLikeText: the notes surfaces (presenter view, the
440// scriptless deck page) get the same paragraphs the slide does.
441func TestNotesRenderLikeText(t *testing.T) {
442 n := fixture().Slides[0].Notes
443 if got, want := string(render.Notes(n)), string(render.Text(n)); got != want {
444 t.Errorf("Notes and Text disagree:\n got %s\nwant %s", got, want)
445 }
446 if !strings.HasPrefix(string(render.Notes(n)), "<p>") {
447 t.Errorf("notes did not render a paragraph: %s", render.Notes(n))
448 }
449}
450
451// TestPhoneReflowOfCanvasSlideOrdersByZThenPosition: slide 8's array
452// order is heading, shape, text, footnote; its z order is heading (1),
453// then the shape and the text tied at 2, then the footnote (3). The tie
454// breaks on top, then left — the text's box is further left than the
455// shape's — so the phone render must read heading, text, shape,
456// footnote, which is not the array order.
457func TestPhoneReflowOfCanvasSlideOrdersByZThenPosition(t *testing.T) {
458 d := fixture()
459 phone, err := render.Slide(d, 8, render.Phone)
460 if err != nil {
461 t.Fatal(err)
462 }
463 s := string(phone)
464
465 want := []string{"b091aaaaaaaa", "b093aaaaaaaa", "b092aaaaaaaa", "b094aaaaaaaa"}
466 at := make([]int, len(want))
467 for i, id := range want {
468 if at[i] = strings.Index(s, id); at[i] < 0 {
469 t.Fatalf("block %s is missing from the reflowed slide: %s", id, s)
470 }
471 if i > 0 && at[i-1] > at[i] {
472 t.Errorf("block %s comes after %s; want z then top-left order", want[i-1], id)
473 }
474 }
475
476 // Reflowed means flow: the blank template, no absolute boxes.
477 if !strings.Contains(s, `data-layout="flow"`) || !strings.Contains(s, `data-template="blank"`) {
478 t.Errorf("a reflowed canvas slide is not a blank flow slide: %s", s)
479 }
480 if strings.Contains(s, "z-index") || strings.Contains(s, "left:") {
481 t.Errorf("a reflowed canvas slide still carries canvas positions: %s", s)
482 }
483 // The hidden block is still in the output, carrying the attribute
484 // the stylesheet hides it by: the phone layer adds, it never
485 // deletes, and the presenter's notes still need the words.
486 if !strings.Contains(s, "data-phone-hidden") {
487 t.Errorf("the phone override was dropped: %s", s)
488 }
489
490 // The desktop render of the same slide is still a canvas.
491 desktop, err := render.Slide(d, 8, render.Desktop)
492 if err != nil {
493 t.Fatal(err)
494 }
495 if !strings.Contains(string(desktop), `data-layout="canvas"`) {
496 t.Errorf("the desktop render lost its canvas: %s", desktop)
497 }
498 // A canvas slide left on phone: inherit keeps its boxes on a phone.
499 inherit, err := render.Slide(d, 7, render.Phone)
500 if err != nil {
501 t.Fatal(err)
502 }
503 if !strings.Contains(string(inherit), `data-layout="canvas"`) {
504 t.Errorf("phone: inherit reflowed anyway: %s", inherit)
505 }
506}
507
508// TestThemes: six themes, stable ids in the app's picker order, a name
509// each, and every one answers ThemeVars.
510func TestThemes(t *testing.T) {
511 want := []string{"paper", "ink", "sunset", "forest", "slate", "candy"}
512 got := render.Themes()
513 if len(got) != len(want) {
514 t.Fatalf("Themes() returned %d themes, want %d: %+v", len(got), len(want), got)
515 }
516 for i, th := range got {
517 if th.ID != want[i] {
518 t.Errorf("theme %d is %q, want %q", i, th.ID, want[i])
519 }
520 if th.Name == "" {
521 t.Errorf("theme %q has no name", th.ID)
522 }
523 if _, ok := render.ThemeVars(th.ID); !ok {
524 t.Errorf("ThemeVars(%q) reported unknown", th.ID)
525 }
526 }
527 if _, ok := render.ThemeVars("no-such-theme"); ok {
528 t.Error("ThemeVars accepted a theme that does not exist")
529 }
530}
531
532// TestThemeContrast holds the accessibility floor §8 sets (WCAG 2.2 AA,
533// no exemptions): body text on the slide background at 4.5:1, and each
534// accent on it at 3:1 — the large-text and non-text threshold, which is
535// what an accent is used for here (headings, rules, the section bar).
536func TestThemeContrast(t *testing.T) {
537 for _, th := range render.Themes() {
538 vars, ok := render.ThemeVars(th.ID)
539 if !ok {
540 t.Fatalf("ThemeVars(%q) reported unknown", th.ID)
541 }
542 bg, err := parseHex(vars["--slides-bg"])
543 if err != nil {
544 t.Fatalf("theme %q background: %v", th.ID, err)
545 }
546 for _, c := range []struct {
547 name string
548 min float64
549 }{
550 {"--slides-fg", 4.5},
551 {"--slides-accent", 3},
552 {"--slides-accent-2", 3},
553 } {
554 fg, err := parseHex(vars[c.name])
555 if err != nil {
556 t.Fatalf("theme %q %s: %v", th.ID, c.name, err)
557 }
558 if got := contrast(fg, bg); got < c.min {
559 t.Errorf("theme %q: %s on --slides-bg is %.2f:1, want at least %.1f:1",
560 th.ID, c.name, got, c.min)
561 }
562 }
563 for _, key := range []string{"--slides-font-heading", "--slides-font-body"} {
564 if vars[key] == "" {
565 t.Errorf("theme %q has no %s", th.ID, key)
566 }
567 }
568 }
569}
570
571// parseHex reads #rgb or #rrggbb into 0..1 sRGB channels. The themes
572// are fixed hex on purpose (see themes.go), which is what lets this
573// test compute their contrast at all.
574func parseHex(s string) ([3]float64, error) {
575 var out [3]float64
576 s = strings.TrimSpace(s)
577 if !strings.HasPrefix(s, "#") {
578 return out, fmt.Errorf("%q is not a hex colour", s)
579 }
580 h := s[1:]
581 if len(h) == 3 {
582 h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
583 }
584 if len(h) != 6 {
585 return out, fmt.Errorf("%q is not #rgb or #rrggbb", s)
586 }
587 for i := range out {
588 v, err := strconv.ParseUint(h[i*2:i*2+2], 16, 8)
589 if err != nil {
590 return out, fmt.Errorf("%q: %w", s, err)
591 }
592 out[i] = float64(v) / 255
593 }
594 return out, nil
595}
596
597// luminance is WCAG 2.2's relative luminance.
598func luminance(c [3]float64) float64 {
599 lin := func(v float64) float64 {
600 if v <= 0.04045 {
601 return v / 12.92
602 }
603 return math.Pow((v+0.055)/1.055, 2.4)
604 }
605 return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2])
606}
607
608// contrast is WCAG 2.2's contrast ratio, 1:1 to 21:1.
609func contrast(a, b [3]float64) float64 {
610 la, lb := luminance(a), luminance(b)
611 if la < lb {
612 la, lb = lb, la
613 }
614 return (la + 0.05) / (lb + 0.05)
615}
616
617// TestContrastFunctionItself: the contrast helper is the only thing
618// standing between a theme and an unreadable slide, so it is checked
619// against ratios that are known independently of it.
620func TestContrastFunctionItself(t *testing.T) {
621 black, _ := parseHex("#000000")
622 white, _ := parseHex("#ffffff")
623 if got := contrast(black, white); math.Abs(got-21) > 0.01 {
624 t.Errorf("black on white is %.4f:1, want 21:1", got)
625 }
626 if got := contrast(white, white); math.Abs(got-1) > 0.0001 {
627 t.Errorf("white on white is %.4f:1, want 1:1", got)
628 }
629 // The canonical AA boundary: #767676 on white is 4.54:1.
630 grey, _ := parseHex("#767676")
631 if got := contrast(grey, white); got < 4.5 || got > 4.6 {
632 t.Errorf("#767676 on white is %.4f:1, want ~4.54:1", got)
633 }
634 // A pair that must fail, so a broken helper cannot pass everything.
635 pale, _ := parseHex("#eeeeee")
636 if got := contrast(pale, white); got > 1.2 {
637 t.Errorf("#eeeeee on white is %.4f:1, want ~1.1:1", got)
638 }
639}
640
641// TestMetaOverridesAreSanitised: meta.accent, meta.fonts and a slide's
642// background are typed by a person and land in a style attribute. A
643// value that is not a plain colour or a plain font stack is ignored,
644// never emitted.
645func TestMetaOverridesAreSanitised(t *testing.T) {
646 d := fixture()
647 d.Meta.Accent = "#123456"
648 d.Meta.Fonts = &deck.Fonts{Heading: "Georgia, serif", Body: "Helvetica, sans-serif"}
649 out, err := render.Slide(d, 0, render.Desktop)
650 if err != nil {
651 t.Fatal(err)
652 }
653 for _, want := range []string{"--slides-accent:#123456", "--slides-font-heading:Georgia, serif", "--slides-font-body:Helvetica, sans-serif"} {
654 if !strings.Contains(string(out), want) {
655 t.Errorf("a valid override was dropped: want %q in %s", want, out)
656 }
657 }
658
659 bad := fixture()
660 bad.Meta.Accent = "red;background:url(https://evil.test/x)"
661 bad.Meta.Fonts = &deck.Fonts{Heading: `x"><script>alert(1)</script>`, Body: "expression(alert(1))"}
662 bad.Slides[3].Background.Value = "url(https://evil.test/x)"
663 bad.Slides[4].Background.Value = "../../etc/passwd"
664 for _, i := range []int{0, 3, 4} {
665 out, err := render.Slide(bad, i, render.Desktop)
666 if err != nil {
667 t.Fatal(err)
668 }
669 for _, gone := range []string{"evil.test", "<script", "expression(", "etc/passwd"} {
670 if strings.Contains(string(out), gone) {
671 t.Errorf("slide %d: an unsafe value survived: %q in %s", i, gone, out)
672 }
673 }
674 }
675}
676
677// varsOf pulls the custom properties back out of a rendered section's
678// style attribute, so a test can hold the renderer to the same contrast
679// floor TestThemeContrast holds the themes to.
680func varsOf(t *testing.T, html string) map[string]string {
681 t.Helper()
682 i := strings.Index(html, ` style="`)
683 if i < 0 {
684 t.Fatalf("no style attribute: %s", html)
685 }
686 rest := html[i+len(` style="`):]
687 j := strings.Index(rest, `"`)
688 if j < 0 {
689 t.Fatalf("unterminated style attribute: %s", html)
690 }
691 out := map[string]string{}
692 for _, decl := range strings.Split(rest[:j], ";") {
693 k, v, ok := strings.Cut(decl, ":")
694 if ok {
695 out[k] = v
696 }
697 }
698 return out
699}
700
701// TestSlideBackgroundStaysReadable: a slide that sets its own
702// background colour gets a foreground and accents that clear §8's
703// thresholds against it. The fixture's section slide is deliberately a
704// deep plum under `paper`, whose own text is near-black — the pairing
705// that would be unreadable if the renderer just wrote the theme's
706// foreground out beside the deck's background.
707func TestSlideBackgroundStaysReadable(t *testing.T) {
708 d := fixture()
709 for _, value := range []string{"#2b1030", "#ffffff", "#000000", "#7f7f7f", "#0f2418", "#faf7f0"} {
710 d.Slides[3].Background = deck.Background{Kind: "color", Value: value}
711 out, err := render.Slide(d, 3, render.Desktop)
712 if err != nil {
713 t.Fatal(err)
714 }
715 vars := varsOf(t, string(out))
716 if vars["--slides-bg"] != value {
717 t.Errorf("background %s was not used: %q", value, vars["--slides-bg"])
718 continue
719 }
720 bg, err := parseHex(vars["--slides-bg"])
721 if err != nil {
722 t.Fatalf("background %s: %v", value, err)
723 }
724 for _, c := range []struct {
725 key string
726 min float64
727 }{{"--slides-fg", 4.5}, {"--slides-accent", 3}, {"--slides-accent-2", 3}} {
728 fg, err := parseHex(vars[c.key])
729 if err != nil {
730 t.Fatalf("background %s, %s: %v", value, c.key, err)
731 }
732 if got := contrast(fg, bg); got < c.min {
733 t.Errorf("background %s: %s is %.2f:1, want at least %.1f:1", value, c.key, got, c.min)
734 }
735 }
736 }
737
738 // A background this package cannot measure is not used at all: the
739 // theme's own pairing, which is known to pass, stands instead.
740 d.Slides[3].Background = deck.Background{Kind: "color", Value: "rebeccapurple"}
741 out, err := render.Slide(d, 3, render.Desktop)
742 if err != nil {
743 t.Fatal(err)
744 }
745 if got := varsOf(t, string(out))["--slides-bg"]; got != "#faf7f0" {
746 t.Errorf("an unmeasurable background colour was used: %q", got)
747 }
748}
749
750// TestDeckAccentIsRefusedWhenItWouldNotBeVisible: meta.accent is a
751// person's colour pick, and a pick that disappears into the background
752// is not honoured — the theme's accent stands and the slide stays
753// legible.
754func TestDeckAccentIsRefusedWhenItWouldNotBeVisible(t *testing.T) {
755 d := fixture() // paper: a warm white background
756 d.Meta.Accent = "#fdfcf8"
757 out, err := render.Slide(d, 0, render.Desktop)
758 if err != nil {
759 t.Fatal(err)
760 }
761 if got := varsOf(t, string(out))["--slides-accent"]; got != "#8a5410" {
762 t.Errorf("an invisible accent was used: %q", got)
763 }
764}
765
766// TestShapeColoursAreSanitised: fill and stroke are props on a block
767// anybody with edit rights can write, and they land in SVG presentation
768// attributes. Only a plain colour gets through.
769func TestShapeColoursAreSanitised(t *testing.T) {
770 d := fixture()
771 d.Slides[6].Blocks[4].Props["fill"] = "url(#x) onload=alert(1)"
772 d.Slides[6].Blocks[4].Props["stroke"] = "rgb(0,0,0);behavior:url(x)"
773 d.Slides[6].Blocks[4].Props["strokeWidth"] = "4;fill:url(#y)"
774 out, err := render.Slide(d, 6, render.Desktop)
775 if err != nil {
776 t.Fatal(err)
777 }
778 for _, gone := range []string{"url(#x)", "onload", "behavior", "url(#y)"} {
779 if strings.Contains(string(out), gone) {
780 t.Errorf("an unsafe shape prop survived: %q in %s", gone, out)
781 }
782 }
783}
784
785// TestImagesAlwaysCarryAnAlt: an image whose alt is missing renders an
786// empty alt plus data-missing-alt, so a screen reader never reads a
787// filename and the editor can show the block as unfinished. §2.4 makes
788// alt required; the renderer never omits the attribute.
789func TestImagesAlwaysCarryAnAlt(t *testing.T) {
790 out, err := render.Slide(fixture(), 6, render.Desktop)
791 if err != nil {
792 t.Fatal(err)
793 }
794 s := string(out)
795 if n := strings.Count(s, "<img "); n != 2 {
796 t.Fatalf("want 2 images on the blank slide, got %d: %s", n, s)
797 }
798 if n := strings.Count(s, " alt="); n != 2 {
799 t.Errorf("an image rendered without an alt attribute: %s", s)
800 }
801 if n := strings.Count(s, "data-missing-alt"); n != 1 {
802 t.Errorf("want exactly one data-missing-alt (the undecorated one), got %d: %s", n, s)
803 }
804
805 // The image-full slide takes its alt from the registered asset.
806 full, err := render.Slide(fixture(), 4, render.Desktop)
807 if err != nil {
808 t.Fatal(err)
809 }
810 if !strings.Contains(string(full), `alt="A bar chart with no bars"`) {
811 t.Errorf("the asset's alt did not reach the img: %s", full)
812 }
813 if strings.Contains(string(full), "data-missing-alt") {
814 t.Errorf("an image with an alt was marked as missing one: %s", full)
815 }
816}
817
818// TestShapesAreLabelledOrHidden: a shape with a label is an image with
819// a name; a shape without one is decoration and is hidden from the
820// accessibility tree rather than announced as "graphic".
821func TestShapesAreLabelledOrHidden(t *testing.T) {
822 out, err := render.Slide(fixture(), 6, render.Desktop)
823 if err != nil {
824 t.Fatal(err)
825 }
826 s := string(out)
827 if !strings.Contains(s, `role="img"`) || !strings.Contains(s, "<title>A labelled rectangle</title>") {
828 t.Errorf("the labelled shape is not an img with a title: %s", s)
829 }
830 if n := strings.Count(s, `aria-hidden="true"`); n < 3 {
831 t.Errorf("want the three unlabelled shapes hidden, got %d: %s", n, s)
832 }
833}
834
835// TestEveryBlockTypeRenders: the §2.4 vocabulary, all of it, somewhere
836// in the fixture — the guard against a new block type quietly rendering
837// as nothing.
838func TestEveryBlockTypeRenders(t *testing.T) {
839 d := fixture()
840 seen := map[string]bool{}
841 for i := range d.Slides {
842 for _, v := range viewports {
843 out, err := render.Slide(d, i, v.vp)
844 if err != nil {
845 t.Fatal(err)
846 }
847 for _, b := range d.Slides[i].Blocks {
848 if strings.Contains(string(out), b.ID) {
849 seen[b.Type] = true
850 }
851 }
852 }
853 }
854 for _, kind := range []string{"heading", "text", "list", "quote", "image", "columns", "code", "shape", "table", "divider", "spacer"} {
855 if !seen[kind] {
856 t.Errorf("no fixture slide rendered a %q block", kind)
857 }
858 }
859}
860
861// TestPhoneViewportIsMarked: the section says which viewport it was
862// rendered for, so the stylesheet and the editor stage can both tell.
863func TestPhoneViewportIsMarked(t *testing.T) {
864 d := fixture()
865 desktop, _ := render.Slide(d, 0, render.Desktop)
866 phone, _ := render.Slide(d, 0, render.Phone)
867 if strings.Contains(string(desktop), "data-vp") {
868 t.Errorf("the desktop render carries data-vp: %s", desktop)
869 }
870 if !strings.Contains(string(phone), `data-vp="phone"`) {
871 t.Errorf("the phone render is not marked: %s", phone)
872 }
873}
874
875// TestPhoneBreakpointCollapsesEveryTemplateToOneColumn guards against a
876// regression where the `@container slide (max-width: 600px)` block reset
877// `[slides-region] { grid-area: auto }` but left a template's explicit
878// `grid-template-columns` in place. `two-column` sets its slide to
879// `1fr 1fr`; resetting only the regions' grid-area does not remove that
880// track, so the browser auto-places body and aside side by side instead
881// of stacking them in document order (spec §2.3).
882//
883// A Go test cannot lay out CSS grid or run a browser, so this is a
884// text/structural assertion against slides.css rather than a rendered
885// one: it parses the stylesheet as plain text, finds the phone
886// container block, and checks that some rule inside it collapses the
887// slide itself (not just its regions) to a single column in a way that
888// covers `two-column` — the one template with a multi-track grid. It
889// cannot verify the visual result, only that the reset exists; a
890// human (or a browser-based check) still has to look at the phone
891// breakpoint to be sure grid-template-rows and the rest don't leave a
892// stray multi-track behind.
893func TestPhoneBreakpointCollapsesEveryTemplateToOneColumn(t *testing.T) {
894 css, err := os.ReadFile(filepath.Join("..", "static", "slides.css"))
895 if err != nil {
896 t.Fatal(err)
897 }
898
899 const marker = "@container slide (max-width: 600px) {"
900 start := strings.Index(string(css), marker)
901 if start == -1 {
902 t.Fatal("no phone container block (@container slide (max-width: 600px)) in slides.css")
903 }
904 body := string(css)[start+len(marker):]
905
906 // Walk to the matching close brace by counting depth, so a nested
907 // rule's own "}" doesn't end the block early.
908 depth := 1
909 end := -1
910 for i, r := range body {
911 switch r {
912 case '{':
913 depth++
914 case '}':
915 depth--
916 if depth == 0 {
917 end = i
918 }
919 }
920 if end != -1 {
921 break
922 }
923 }
924 if end == -1 {
925 t.Fatal("phone container block in slides.css is never closed")
926 }
927 block := body[:end]
928
929 // Split the block into its sibling rules (selector { declarations }).
930 // None of the rules inside this block nest further, so a non-greedy
931 // match between braces is enough.
932 rulePattern := regexp.MustCompile(`(?s)([^{}]+)\{([^{}]*)\}`)
933 rules := rulePattern.FindAllStringSubmatch(block, -1)
934 if len(rules) == 0 {
935 t.Fatal("found no rules inside the phone container block")
936 }
937
938 // A rule "covers" two-column if its selector isn't scoped to a
939 // different, specific template (an image-full/quote/etc selector
940 // would not apply to a two-column slide) and either names
941 // two-column directly or applies to every flow slide generically
942 // (e.g. via [data-layout="flow"], with no data-template filter).
943 coversTwoColumn := func(selector string) bool {
944 if strings.Contains(selector, `data-template="two-column"`) {
945 return true
946 }
947 if strings.Contains(selector, "data-template=") {
948 return false // scoped to some other, specific template
949 }
950 return strings.Contains(selector, "slides-slide")
951 }
952
953 singleColumn := regexp.MustCompile(`grid-template-columns\s*:\s*(1fr|minmax\(0,\s*1fr\)|none)\s*;`)
954 areasReset := regexp.MustCompile(`grid-template-areas\s*:\s*none\s*;`)
955
956 found := false
957 for _, r := range rules {
958 selector, decls := r[1], r[2]
959 if !coversTwoColumn(selector) {
960 continue
961 }
962 if singleColumn.MatchString(decls) && areasReset.MatchString(decls) {
963 found = true
964 break
965 }
966 }
967 if !found {
968 t.Errorf("no rule in the phone container block resets both grid-template-columns to a single track and grid-template-areas to none for two-column (or every flow slide); two-column's desktop `1fr 1fr` track survives the breakpoint, so body and aside sit side by side instead of stacking")
969 }
970}
971
972// TestThemeFacesMatchTheStylesheetStacks is the join between the two
973// halves of "one font set everywhere": the browser sets a slide in
974// whatever --slides-font-heading names, and internal/slides/layout
975// measures it in whatever fonts.Get says that theme's headings are. If
976// those two ever disagree the PDF stops matching the screen, and
977// nothing else would notice — fonts cannot import render (render
978// imports fonts), so this test is the only place the two tables meet.
979func TestThemeFacesMatchTheStylesheetStacks(t *testing.T) {
980 for _, th := range render.Themes() {
981 vars, ok := render.ThemeVars(th.ID)
982 if !ok {
983 t.Fatalf("ThemeVars(%q) reported unknown", th.ID)
984 }
985 for _, c := range []struct {
986 prop string
987 role fonts.Role
988 }{
989 {"--slides-font-heading", fonts.Heading},
990 {"--slides-font-body", fonts.Body},
991 } {
992 stack := vars[c.prop]
993 named, ok := fonts.FamilyForStack(stack)
994 if !ok {
995 t.Errorf("theme %q's %s is %q, which names no embedded family: the layout engine could not measure it",
996 th.ID, c.prop, stack)
997 continue
998 }
999 if got := fonts.Get(th.ID, c.role, false, false).Family; got != named {
1000 t.Errorf("theme %q sets %s to %q but fonts.Get measures with %s",
1001 th.ID, c.prop, stack, got.CSSName())
1002 }
1003 }
1004 }
1005}
1006
1007// An unknown theme resolves to the default rather than to nothing, and
1008// the layout engine has to ask with the resolved name — see ThemeID.
1009func TestThemeIDResolvesToOneThisBuildHas(t *testing.T) {
1010 for _, th := range render.Themes() {
1011 if got := render.ThemeID(th.ID); got != th.ID {
1012 t.Errorf("ThemeID(%q) = %q", th.ID, got)
1013 }
1014 }
1015 got := render.ThemeID("a theme from a later build")
1016 if _, ok := render.ThemeVars(got); !ok {
1017 t.Errorf("ThemeID of an unknown theme is %q, which is not a theme either", got)
1018 }
1019}
1020
1021// SlideVars is the seam the layout engine paints from: it has to give
1022// the same colours Slide writes on the section, overrides and all.
1023func TestSlideVarsAreWhatTheSectionCarries(t *testing.T) {
1024 d := fixture()
1025 for i := range d.Slides {
1026 vars, ok := render.SlideVars(d, i)
1027 if !ok {
1028 t.Fatalf("SlideVars(%d) reported unknown", i)
1029 }
1030 html, err := render.Slide(d, i, render.Desktop)
1031 if err != nil {
1032 t.Fatalf("Slide(%d): %v", i, err)
1033 }
1034 for _, k := range []string{"--slides-bg", "--slides-fg", "--slides-accent", "--slides-accent-2"} {
1035 if vars[k] == "" {
1036 t.Errorf("slide %d has no %s", i, k)
1037 }
1038 if want := k + ":" + vars[k]; !strings.Contains(string(html), want) {
1039 t.Errorf("slide %d: SlideVars says %q, the section does not carry it", i, want)
1040 }
1041 }
1042 }
1043 if _, ok := render.SlideVars(d, len(d.Slides)); ok {
1044 t.Error("SlideVars accepted an index outside the deck")
1045 }
1046 if _, ok := render.SlideVars(nil, 0); ok {
1047 t.Error("SlideVars accepted a nil deck")
1048 }
1049}
1050
1051// Slide 3 of the fixture sets its own background colour, so its vars
1052// are not its theme's — the case that makes SlideVars worth having.
1053func TestSlideVarsHonourASlideBackground(t *testing.T) {
1054 d := fixture()
1055 vars, ok := render.SlideVars(d, 3)
1056 if !ok {
1057 t.Fatal("SlideVars(3) reported unknown")
1058 }
1059 if vars["--slides-bg"] != "#2b1030" {
1060 t.Errorf("--slides-bg is %q, want the slide's own #2b1030", vars["--slides-bg"])
1061 }
1062 theme, _ := render.ThemeVars("paper")
1063 if vars["--slides-fg"] == theme["--slides-fg"] {
1064 t.Errorf("--slides-fg stayed %q on a plum background", vars["--slides-fg"])
1065 }
1066}
1067