oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package layout_test
2
3import (
4 "encoding/json"
5 "math"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "amadan.net/oficina/slides/internal/slides/deck"
12 "amadan.net/oficina/slides/internal/slides/fonts"
13 "amadan.net/oficina/slides/internal/slides/layout"
14)
15
16// goldenNames pairs a fixture slide with the paint list it writes.
17// Between them the nine cover every flow template, both layout modes,
18// and every block type in §2.4.
19var goldenNames = []struct {
20 name string
21 index int
22}{
23 {"title", 0},
24 {"title-body", 1},
25 {"two-column", 2},
26 {"section", 3},
27 {"image-full", 4},
28 {"quote", 5},
29 {"blank", 6},
30 {"canvas", 7},
31 {"canvas-reflow", 8},
32}
33
34func paintOf(t *testing.T, d *deck.Deck, i int) (layout.Paint, []layout.Warning) {
35 t.Helper()
36 p, warnings, err := layout.Layout(d, i)
37 if err != nil {
38 t.Fatalf("Layout(%d): %v", i, err)
39 }
40 return p, warnings
41}
42
43func TestPaintGoldens(t *testing.T) {
44 d := fixture()
45 for _, g := range goldenNames {
46 t.Run(g.name, func(t *testing.T) {
47 p, _ := paintOf(t, d, g.index)
48 got, err := json.MarshalIndent(p, "", " ")
49 if err != nil {
50 t.Fatalf("marshal: %v", err)
51 }
52 got = append(got, '\n')
53
54 path := filepath.Join("testdata", g.name+".golden.json")
55 if *update {
56 if err := os.WriteFile(path, got, 0o644); err != nil {
57 t.Fatalf("write golden: %v", err)
58 }
59 return
60 }
61 want, err := os.ReadFile(path)
62 if err != nil {
63 t.Fatalf("read golden (run with -update to create it): %v", err)
64 }
65 if string(got) != string(want) {
66 t.Errorf("%s does not match the golden; run with -update and read the diff", g.name)
67 }
68 })
69 }
70}
71
72// Every golden also has to be a paint list somebody could actually
73// draw: no NaN, no infinity, nothing with a negative size. A NaN
74// coordinate serialises as `null` in JSON and draws as nothing at all,
75// so it would pass a golden comparison forever without anybody seeing
76// the slide it wrecked.
77func TestNoOpIsNaNOrNegativelySized(t *testing.T) {
78 d := fixture()
79 for _, g := range goldenNames {
80 p, _ := paintOf(t, d, g.index)
81 finite := func(name string, v float64) {
82 t.Helper()
83 if math.IsNaN(v) || math.IsInf(v, 0) {
84 t.Errorf("%s: %s is %v", g.name, name, v)
85 }
86 }
87 size := func(name string, v float64) {
88 t.Helper()
89 finite(name, v)
90 if v < 0 {
91 t.Errorf("%s: %s is negative (%v)", g.name, name, v)
92 }
93 }
94 for _, op := range p.Ops {
95 switch o := op.(type) {
96 case layout.Rect:
97 finite("rect x", o.X)
98 finite("rect y", o.Y)
99 size("rect w", o.W)
100 size("rect h", o.H)
101 size("rect radius", o.Radius)
102 size("rect stroke width", o.StrokeW)
103 case layout.Ellipse:
104 finite("ellipse x", o.X)
105 finite("ellipse y", o.Y)
106 size("ellipse w", o.W)
107 size("ellipse h", o.H)
108 case layout.Line:
109 finite("line x1", o.X1)
110 finite("line y1", o.Y1)
111 finite("line x2", o.X2)
112 finite("line y2", o.Y2)
113 size("line width", o.W)
114 case layout.Image:
115 finite("image x", o.X)
116 finite("image y", o.Y)
117 size("image w", o.W)
118 size("image h", o.H)
119 case layout.Text:
120 finite("text x", o.X)
121 finite("text baseline", o.Y)
122 if len(o.Runs) == 0 {
123 t.Errorf("%s: a text op has no runs", g.name)
124 }
125 for _, r := range o.Runs {
126 size("run size", r.Size)
127 if r.S == "" {
128 t.Errorf("%s: a run has no text", g.name)
129 }
130 if r.Fill == "" {
131 t.Errorf("%s: a run has no colour", g.name)
132 }
133 }
134 }
135 }
136 }
137}
138
139func TestTheFrameIsAlways1920By1080(t *testing.T) {
140 d := fixture()
141 for _, g := range goldenNames {
142 p, _ := paintOf(t, d, g.index)
143 if p.W != 1920 || p.H != 1080 {
144 t.Errorf("%s is %vx%v", g.name, p.W, p.H)
145 }
146 }
147}
148
149func TestLayoutRejectsAnIndexOutsideTheDeck(t *testing.T) {
150 d := fixture()
151 for _, i := range []int{-1, len(d.Slides), len(d.Slides) + 10} {
152 if _, _, err := layout.Layout(d, i); err == nil {
153 t.Errorf("Layout(%d) of a %d-slide deck returned no error", i, len(d.Slides))
154 }
155 }
156 if _, _, err := layout.Layout(nil, 0); err == nil {
157 t.Error("Layout(nil) returned no error")
158 }
159}
160
161// The background is the theme's, or the slide's own colour when it set
162// one — the same value render writes as --slides-bg, because both come
163// from render.SlideVars.
164func TestTheBackgroundIsTheSlidesOwnColour(t *testing.T) {
165 d := fixture()
166 p, _ := paintOf(t, d, 0)
167 if p.Background != "#faf7f0" {
168 t.Errorf("a paper slide's background is %q, want #faf7f0", p.Background)
169 }
170 p, _ = paintOf(t, d, 3) // the section slide sets its own plum
171 if p.Background != "#2b1030" {
172 t.Errorf("a slide with its own background is %q, want #2b1030", p.Background)
173 }
174}
175
176// A slide with a picture behind it paints that first, under
177// everything: an op list is drawn in order.
178func TestASlideBackgroundImageIsPaintedFirst(t *testing.T) {
179 d := fixture()
180 p, _ := paintOf(t, d, 4)
181 if len(p.Ops) == 0 {
182 t.Fatal("the image-full slide painted nothing")
183 }
184 img, ok := p.Ops[0].(layout.Image)
185 if !ok {
186 t.Fatalf("the first op is %T, want the background image", p.Ops[0])
187 }
188 if img.Asset != "asset0002bbb" || img.W != 1920 || img.H != 1080 {
189 t.Errorf("the background image is %+v", img)
190 }
191}
192
193// §2.3: a canvas box is a fraction of the frame, verbatim. This is the
194// one place the engine is not allowed any judgement at all — somebody
195// put the block there.
196func TestCanvasBoxesArePlacedVerbatim(t *testing.T) {
197 d := &deck.Deck{
198 Meta: deck.Meta{Theme: "paper"},
199 Slides: []deck.Slide{{
200 ID: "s", Layout: "canvas", Template: "blank",
201 Blocks: []deck.Block{{
202 ID: "b", Type: "shape", Z: 1, Box: box(0.25, 0.5, 0.5, 0.25),
203 Props: map[string]any{"kind": "rect", "fill": "#ff0000", "stroke": "none"},
204 }},
205 }},
206 }
207 p, _ := paintOf(t, d, 0)
208 var got layout.Rect
209 for _, op := range p.Ops {
210 if r, ok := op.(layout.Rect); ok {
211 got = r
212 }
213 }
214 // The 1% inset is render's own viewBox geometry (`x="1"` in a
215 // 0..100 box), not a margin this package invented.
216 wantX, wantW := 0.25*1920+0.5*1920*0.01, 0.5*1920*0.98
217 if math.Abs(got.X-wantX) > 0.01 || math.Abs(got.W-wantW) > 0.01 {
218 t.Errorf("a box at x 0.25 w 0.5 painted at x %v w %v, want %v and %v", got.X, got.W, wantX, wantW)
219 }
220 if math.Abs(got.Y-(0.5*1080+0.25*1080*0.01)) > 0.01 {
221 t.Errorf("a box at y 0.5 painted at y %v", got.Y)
222 }
223}
224
225// z is paint order (§2.3), and a tie keeps the array's order.
226func TestCanvasPaintsInZOrder(t *testing.T) {
227 mk := func(id string, z int) deck.Block {
228 return deck.Block{ID: id, Type: "text", Z: z, Box: box(0, 0, 1, 0.2), Text: txt(id)}
229 }
230 d := &deck.Deck{
231 Meta: deck.Meta{Theme: "paper"},
232 Slides: []deck.Slide{{
233 ID: "s", Layout: "canvas", Template: "blank",
234 Blocks: []deck.Block{mk("third", 9), mk("first", 1), mk("second", 4), mk("tie", 9)},
235 }},
236 }
237 p, _ := paintOf(t, d, 0)
238 var got []string
239 for _, op := range p.Ops {
240 if txt, ok := op.(layout.Text); ok {
241 got = append(got, txt.Runs[0].S)
242 }
243 }
244 want := []string{"first", "second", "third", "tie"}
245 if strings.Join(got, ",") != strings.Join(want, ",") {
246 t.Errorf("painted %v, want %v", got, want)
247 }
248}
249
250// §2.5: the print engine never reads the phone layer. A block hidden
251// on a phone is still on the printed page, and one the phone reorders
252// is still where the desktop put it.
253func TestThePhoneLayerIsNeverRead(t *testing.T) {
254 d := fixture()
255 p, _ := paintOf(t, d, 8) // the reflow slide, whose shape is phone-hidden
256 var found bool
257 for _, op := range p.Ops {
258 if txt, ok := op.(layout.Text); ok && strings.Contains(txt.Runs[0].S, "Desktop only") {
259 found = true
260 }
261 }
262 if !found {
263 t.Error("a block hidden on phones was left out of the paint list")
264 }
265}
266
267// The overflow rule, stated in the plan and in the stylesheet's own
268// canvas comment: never clip. A word wider than its column is drawn
269// past the edge and reported.
270func TestALongWordOverflowsRatherThanBeingCut(t *testing.T) {
271 const long = "Unconscionablyunhyphenatedsupercalifragilisticexpialidocioussequence"
272 d := &deck.Deck{
273 Meta: deck.Meta{Theme: "paper"},
274 Slides: []deck.Slide{{
275 ID: "s", Layout: "flow", Template: "two-column",
276 Blocks: []deck.Block{{ID: "b1", Type: "text", Region: "body", Text: txt(long)}},
277 }},
278 }
279 p, warnings := paintOf(t, d, 0)
280
281 var drawn string
282 var right float64
283 for _, op := range p.Ops {
284 if txt, ok := op.(layout.Text); ok {
285 for _, r := range txt.Runs {
286 drawn += r.S
287 w, _ := fonts.Advance(r.Face, r.Size, r.S)
288 right = math.Max(right, txt.X+w)
289 }
290 }
291 }
292 if drawn != long {
293 t.Errorf("the word was drawn as %q, want it whole", drawn)
294 }
295 col := 999.936 // where two-column's body region ends
296 if right <= col {
297 t.Errorf("the word ends at %v, inside the %v column: it should overflow, not be cut", right, col)
298 }
299 if !hasWarning(warnings, layout.WarnOverflow, "b1") {
300 t.Errorf("no overflow warning; got %+v", warnings)
301 }
302}
303
304// A block that runs past the bottom of its region is drawn and
305// reported too — the CSS lets it overflow (slides.css's canvas rules
306// say why), so this does.
307func TestABlockTallerThanItsRegionIsReported(t *testing.T) {
308 lines := make(deck.Text, 40)
309 for i := range lines {
310 lines[i] = []deck.Run{{T: "Another line of a body that will not fit on one slide."}}
311 }
312 d := &deck.Deck{
313 Meta: deck.Meta{Theme: "paper"},
314 Slides: []deck.Slide{{
315 ID: "s", Layout: "flow", Template: "blank",
316 Blocks: []deck.Block{{ID: "tall", Type: "text", Region: "body", Text: lines}},
317 }},
318 }
319 p, warnings := paintOf(t, d, 0)
320 if len(p.Ops) < 40 {
321 t.Errorf("only %d ops: the overflowing lines were dropped rather than drawn", len(p.Ops))
322 }
323 if !hasWarning(warnings, layout.WarnOverflow, "tall") {
324 t.Errorf("no overflow warning; got %+v", warnings)
325 }
326}
327
328// A rune no embedded face can draw is reported once, and the text
329// around it is still laid out.
330func TestARuneNoFaceCanDrawIsReported(t *testing.T) {
331 d := &deck.Deck{
332 Meta: deck.Meta{Theme: "paper"},
333 Slides: []deck.Slide{{
334 ID: "s", Layout: "flow", Template: "blank",
335 Blocks: []deck.Block{{ID: "b1", Type: "text", Region: "body",
336 Text: deck.Text{{{T: "emoji 😀 and 😀 again"}}}}},
337 }},
338 }
339 _, warnings := paintOf(t, d, 0)
340 var glyph int
341 for _, w := range warnings {
342 if w.Kind == layout.WarnGlyph {
343 glyph++
344 if !strings.Contains(w.Detail, "😀") {
345 t.Errorf("the warning does not name the rune: %q", w.Detail)
346 }
347 }
348 }
349 if glyph != 1 {
350 t.Errorf("got %d glyph warnings, want exactly one for a rune that appears twice", glyph)
351 }
352}
353
354// An image whose asset the deck never registered draws nothing and
355// says so, rather than leaving a hole nobody can explain.
356func TestAnUnregisteredImageIsReported(t *testing.T) {
357 d := &deck.Deck{
358 Meta: deck.Meta{Theme: "paper"},
359 Slides: []deck.Slide{{
360 ID: "s", Layout: "flow", Template: "blank",
361 Blocks: []deck.Block{{ID: "b1", Type: "image", Region: "body",
362 Props: map[string]any{"asset": "nothing"}}},
363 }},
364 }
365 p, warnings := paintOf(t, d, 0)
366 for _, op := range p.Ops {
367 if _, ok := op.(layout.Image); ok {
368 t.Error("an image with no asset was drawn anyway")
369 }
370 }
371 if !hasWarning(warnings, layout.WarnAsset, "b1") {
372 t.Errorf("no asset warning; got %+v", warnings)
373 }
374}
375
376// meta.fonts can name any family at all (§2.1), and one this build
377// does not carry is measured in a substitute — which the browser will
378// not be using, so it is reported.
379func TestADeckFontThisBuildLacksIsReported(t *testing.T) {
380 d := fixture()
381 d.Meta.Fonts = &deck.Fonts{Heading: "Comic Sans MS, cursive", Body: "Comic Sans MS, cursive"}
382 _, warnings := paintOf(t, d, 0)
383 var font int
384 for _, w := range warnings {
385 if w.Kind == layout.WarnFont {
386 font++
387 }
388 }
389 if font != 2 {
390 t.Errorf("got %d font warnings for a heading and a body face, want 2: %+v", font, warnings)
391 }
392}
393
394// The six themes all lay out, and paper is the one that measures its
395// headings in a serif — which is the fact fonts.Get holds and the
396// stylesheet's --slides-font-heading agrees with.
397func TestEveryThemeLaysOutAndPaperHeadsInSerif(t *testing.T) {
398 for _, theme := range []string{"paper", "ink", "sunset", "forest", "slate", "candy", "no-such-theme"} {
399 d := fixture()
400 d.Meta.Theme = theme
401 p, _ := paintOf(t, d, 0)
402
403 var heading layout.Run
404 for _, op := range p.Ops {
405 if txt, ok := op.(layout.Text); ok && txt.Runs[0].Size > 100 {
406 heading = txt.Runs[0]
407 break
408 }
409 }
410 if heading.S == "" {
411 t.Errorf("%s: no heading was painted", theme)
412 continue
413 }
414 want := fonts.Sans
415 if theme == "paper" || theme == "no-such-theme" { // an unknown theme is paper
416 want = fonts.Serif
417 }
418 if heading.Face.Family != want {
419 t.Errorf("%s heads in %s, want %s", theme, heading.Face.Family.CSSName(), want.CSSName())
420 }
421 if !heading.Face.Bold {
422 t.Errorf("%s: a heading is font-weight 600, which is the bold face", theme)
423 }
424 }
425}
426
427// Marks change the face, not the line: §2.6's five marks and a link,
428// all on one line of one block.
429func TestMarksBecomeFacesColoursAndAnUnderline(t *testing.T) {
430 d := fixture()
431 p, _ := paintOf(t, d, 1) // the marks slide
432 faces := map[string]bool{}
433 var underlines int
434 var marks int
435 for _, op := range p.Ops {
436 switch o := op.(type) {
437 case layout.Text:
438 for _, r := range o.Runs {
439 faces[r.Face.PostScriptName()] = true
440 }
441 case layout.Line:
442 underlines++
443 case layout.Rect:
444 marks++
445 }
446 }
447 for _, want := range []string{
448 "LiberationSans-Regular", "LiberationSans-Bold", "LiberationSans-Italic",
449 "LiberationMono-Regular", "LiberationMono-BoldItalic", "LiberationSerif-Bold",
450 } {
451 if !faces[want] {
452 t.Errorf("no run was set in %s; got %v", want, keys(faces))
453 }
454 }
455 if underlines == 0 {
456 t.Error("the link has no underline: §8 does not let colour alone carry it")
457 }
458 if marks == 0 {
459 t.Error("the highlighted run has no mark behind it")
460 }
461}
462
463// A list draws a marker per item, in the accent colour, hanging to the
464// left of the text.
465func TestAListDrawsItsMarkers(t *testing.T) {
466 d := fixture()
467 p, _ := paintOf(t, d, 1)
468 markers := map[string]float64{}
469 textX := map[string]float64{}
470 for _, op := range p.Ops {
471 txt, ok := op.(layout.Text)
472 if !ok {
473 continue
474 }
475 switch s := txt.Runs[0].S; {
476 case s == "•" || s == "1." || s == "2.":
477 markers[s] = txt.X
478 case strings.HasPrefix(s, "One item"), strings.HasPrefix(s, "First"):
479 textX[s] = txt.X
480 }
481 }
482 for _, want := range []string{"•", "1.", "2."} {
483 if _, ok := markers[want]; !ok {
484 t.Errorf("no %q marker was drawn", want)
485 }
486 }
487 for s, x := range textX {
488 for m, mx := range markers {
489 if mx >= x && strings.HasPrefix(s, "One item") && m == "•" {
490 t.Errorf("the bullet at %v is not left of its text at %v", mx, x)
491 }
492 }
493 }
494}
495
496// A table is equal columns with a rule under every row, the header's
497// in the accent colour.
498func TestATableIsEqualColumnsWithRules(t *testing.T) {
499 d := fixture()
500 p, _ := paintOf(t, d, 6)
501 var rules []layout.Line
502 for _, op := range p.Ops {
503 if l, ok := op.(layout.Line); ok {
504 rules = append(rules, l)
505 }
506 }
507 if len(rules) < 2 {
508 t.Fatalf("a two-row table drew %d rules", len(rules))
509 }
510 if rules[0].Stroke != "#8a5410" {
511 t.Errorf("the header rule is %q, want the accent", rules[0].Stroke)
512 }
513 if rules[0].W != 1 {
514 t.Errorf("a table rule is %vpx, want 1", rules[0].W)
515 }
516}
517
518// The section template's accent bar is part of what gets centred, and
519// it is the only decoration the stylesheet has.
520func TestASectionSlideDrawsItsAccentBar(t *testing.T) {
521 d := fixture()
522 p, _ := paintOf(t, d, 3)
523 var bar *layout.Rect
524 for _, op := range p.Ops {
525 if r, ok := op.(layout.Rect); ok && r.H < 20 && r.W > 300 && r.W < 400 {
526 bar = &r
527 }
528 }
529 if bar == nil {
530 t.Fatal("no accent bar on a section slide")
531 }
532 if bar.Fill == "" || bar.Fill == p.Background {
533 t.Errorf("the accent bar is %q on a %q background", bar.Fill, p.Background)
534 }
535 if mid := bar.X + bar.W/2; math.Abs(mid-960) > 1 {
536 t.Errorf("the accent bar is centred on %v, want 960", mid)
537 }
538}
539
540// Nothing on a title slide starts outside its region, and the pair of
541// regions straddles the middle — which is what `align-content: center`
542// over two auto rows does.
543func TestATitleSlideIsCentred(t *testing.T) {
544 d := fixture()
545 p, _ := paintOf(t, d, 0)
546 var top, bottom float64 = math.Inf(1), math.Inf(-1)
547 for _, op := range p.Ops {
548 if txt, ok := op.(layout.Text); ok {
549 top = math.Min(top, txt.Y)
550 bottom = math.Max(bottom, txt.Y)
551 }
552 }
553 if math.IsInf(top, 1) {
554 t.Fatal("the title slide painted no text")
555 }
556 if mid := (top + bottom) / 2; mid < 400 || mid > 680 {
557 t.Errorf("the title slide's text is centred on %v, which is not near the middle of 1080", mid)
558 }
559}
560
561// Every warning says which slide it is about. The export's status page
562// lists warnings from a whole deck, and a line that cannot name its
563// slide is a line nobody can act on.
564func TestEveryWarningCarriesItsSlide(t *testing.T) {
565 d := &deck.Deck{
566 Meta: deck.Meta{Theme: "paper", Fonts: &deck.Fonts{Heading: "Nonesuch Grotesk", Body: "Nonesuch Grotesk"}},
567 Slides: []deck.Slide{
568 {ID: "first", Layout: "flow", Template: "title-body",
569 Blocks: []deck.Block{{ID: "b1", Type: "text", Region: "body", Text: txt("ok")}}},
570 {ID: "second", Layout: "flow", Template: "title-body",
571 Background: deck.Background{Kind: "image", Value: "never-registered"},
572 Blocks: []deck.Block{
573 {ID: "b2", Type: "image", Region: "body", Props: map[string]any{"asset": "also-missing"}},
574 {ID: "b3", Type: "text", Region: "body", Text: txt("a rune no face draws: \U0001F600")},
575 }},
576 },
577 }
578 for i, s := range d.Slides {
579 _, warnings := paintOf(t, d, i)
580 if len(warnings) == 0 {
581 t.Errorf("slide %q made no warnings, so this test is asserting nothing about it", s.ID)
582 }
583 for _, w := range warnings {
584 if w.Slide != s.ID {
585 t.Errorf("slide %q: warning %+v carries slide %q", s.ID, w, w.Slide)
586 }
587 }
588 }
589}
590
591func hasWarning(ws []layout.Warning, kind, block string) bool {
592 for _, w := range ws {
593 if w.Kind == kind && w.Block == block {
594 return true
595 }
596 }
597 return false
598}
599
600func keys(m map[string]bool) []string {
601 out := make([]string, 0, len(m))
602 for k := range m {
603 out = append(out, k)
604 }
605 return out
606}
607