| 1 | package slides |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/xml" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "net/http/httptest" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "amadan.net/oficina/slides/internal/slides/deck" |
| 14 | "amadan.net/oficina/slides/internal/slides/layout" |
| 15 | "amadan.net/oficina/slides/internal/slides/svg" |
| 16 | ) |
| 17 | |
| 18 | // sampleView builds the deck a person meets on their first sign-in — |
| 19 | // every flow template, both layout modes, and the block types worth |
| 20 | // knowing about — and opens it. |
| 21 | // |
| 22 | // The sample rather than a hand-built fixture, because it is the one |
| 23 | // deck in the repo that goes through the core the way a real one does: |
| 24 | // the ops are applied by the wasm module, the document comes back out |
| 25 | // of it, and what these tests measure is therefore a view the app could |
| 26 | // actually be asked to draw. |
| 27 | func sampleView(t *testing.T) (*app, Deck, *deck.Deck) { |
| 28 | t.Helper() |
| 29 | d := testDB(t) |
| 30 | a, _, err := newApp(d, Config{Env: EnvDevelopment, Logger: quietLogger()}) |
| 31 | if err != nil { |
| 32 | t.Fatalf("newApp: %v", err) |
| 33 | } |
| 34 | t.Cleanup(func() { a.Close() }) |
| 35 | ctx := context.Background() |
| 36 | |
| 37 | owner, _, err := ensureUser(ctx, d.G, "paul@example.test") |
| 38 | if err != nil { |
| 39 | t.Fatalf("ensureUser: %v", err) |
| 40 | } |
| 41 | if err := a.seedSampleDeck(ctx, owner); err != nil { |
| 42 | t.Fatalf("seedSampleDeck: %v", err) |
| 43 | } |
| 44 | var row Deck |
| 45 | if err := d.G.WithContext(ctx).Where("owner_id = ?", owner.ID).Take(&row).Error; err != nil { |
| 46 | t.Fatalf("load the sample deck: %v", err) |
| 47 | } |
| 48 | h, view, err := a.openDeck(ctx, row) |
| 49 | if err != nil { |
| 50 | t.Fatalf("openDeck: %v", err) |
| 51 | } |
| 52 | t.Cleanup(func() { h.Close(context.Background()) }) |
| 53 | // Eight, not the nine the slice 3 plan says: the nine is |
| 54 | // internal/slides/layout's own fixture, which has a canvas slide |
| 55 | // AND a reflowing one. sample.go's last slide is both. The number |
| 56 | // is asserted so a slide quietly dropped from the sample does not |
| 57 | // quietly shrink what these tests cover. |
| 58 | if len(view.Slides) != 8 { |
| 59 | t.Fatalf("the sample deck has %d slides, want 8", len(view.Slides)) |
| 60 | } |
| 61 | return a, row, view |
| 62 | } |
| 63 | |
| 64 | // Every slide of the sample deck comes out as XML a parser will read. |
| 65 | // This is the whole promise of internal/slides/svg said against real |
| 66 | // content: every flow template, both layout modes and every |
| 67 | // block type in §2.4, each parsed back rather than eyeballed. |
| 68 | func TestEverySampleSlideIsWellFormedSVG(t *testing.T) { |
| 69 | a, row, view := sampleView(t) |
| 70 | r := httptest.NewRequest("GET", "/d/"+row.Sqid+"/slides/x.svg", nil) |
| 71 | |
| 72 | for i, s := range view.Slides { |
| 73 | out, err := a.thumbnail(r, row, view, i, "/d/"+row.Sqid) |
| 74 | if err != nil { |
| 75 | t.Fatalf("slide %d (%s): %v", i, s.ID, err) |
| 76 | } |
| 77 | dec := xml.NewDecoder(bytes.NewReader(out)) |
| 78 | var depth, elements int |
| 79 | for { |
| 80 | tok, err := dec.Token() |
| 81 | if errors.Is(err, io.EOF) { |
| 82 | break |
| 83 | } |
| 84 | if err != nil { |
| 85 | t.Fatalf("slide %d (%s, template %q) is not well-formed XML: %v\n%s", |
| 86 | i, s.ID, s.Template, err, out) |
| 87 | } |
| 88 | switch tok.(type) { |
| 89 | case xml.StartElement: |
| 90 | depth++ |
| 91 | elements++ |
| 92 | case xml.EndElement: |
| 93 | depth-- |
| 94 | } |
| 95 | } |
| 96 | if depth != 0 { |
| 97 | t.Errorf("slide %d closes at depth %d", i, depth) |
| 98 | } |
| 99 | // A slide that drew nothing at all would still parse. Every |
| 100 | // one of the sample's slides has words on it. |
| 101 | if elements < 3 { |
| 102 | t.Errorf("slide %d (template %q) drew %d elements; it looks empty:\n%s", |
| 103 | i, s.Template, elements, out) |
| 104 | } |
| 105 | if !bytes.Contains(out, []byte(`role="img"`)) || !bytes.Contains(out, []byte("<title>")) { |
| 106 | t.Errorf("slide %d has no accessible name:\n%s", i, out) |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // Every slide, measured and written, each inside 50ms on this machine. |
| 112 | // |
| 113 | // A generous bound on purpose: the number that matters is the order of |
| 114 | // magnitude, because the editor strip draws one per slide after every |
| 115 | // save and the export draws one per page. If a slide ever takes half a |
| 116 | // second, something has started re-reading the font programs per run — |
| 117 | // which is exactly the mistake this catches and nothing else would. |
| 118 | func TestEverySampleSlideEncodesQuickly(t *testing.T) { |
| 119 | if testing.Short() { |
| 120 | t.Skip("timing test") |
| 121 | } |
| 122 | _, _, view := sampleView(t) |
| 123 | |
| 124 | // Once through first: fonts.Parsed caches a parsed face, and |
| 125 | // timing the first call would be timing the cache being filled, |
| 126 | // which happens once per process and not per thumbnail. |
| 127 | for i := range view.Slides { |
| 128 | if _, _, err := layout.Layout(view, i); err != nil { |
| 129 | t.Fatalf("warm-up slide %d: %v", i, err) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | const budget = 50 * time.Millisecond |
| 134 | for i, s := range view.Slides { |
| 135 | start := time.Now() |
| 136 | paint, _, err := layout.Layout(view, i) |
| 137 | if err != nil { |
| 138 | t.Fatalf("slide %d: %v", i, err) |
| 139 | } |
| 140 | out := svg.Encode(paint, svg.Options{Title: "Slide"}) |
| 141 | took := time.Since(start) |
| 142 | if took > budget { |
| 143 | t.Errorf("slide %d (template %q) took %v to measure and write, want under %v", |
| 144 | i, s.Template, took, budget) |
| 145 | } |
| 146 | if len(out) == 0 { |
| 147 | t.Errorf("slide %d wrote nothing", i) |
| 148 | } |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // ?w= is read and is in the tag, and is otherwise ignored: an SVG |
| 153 | // scales, so the bytes are the same at every width. It is in the tag so |
| 154 | // a raster twin can land later without every cache in between already |
| 155 | // holding one size under a key that does not mention it. |
| 156 | func TestTheRequestedWidthIsReadAndBounded(t *testing.T) { |
| 157 | for _, c := range []struct{ raw, want string }{ |
| 158 | {"", ""}, |
| 159 | {"180", "180"}, |
| 160 | {" 900 ", "900"}, |
| 161 | {"0", ""}, |
| 162 | {"-4", ""}, |
| 163 | {"9999999", ""}, |
| 164 | {"wide", ""}, |
| 165 | } { |
| 166 | if got := thumbWidth(c.raw); got != c.want { |
| 167 | t.Errorf("thumbWidth(%q) = %q, want %q", c.raw, got, c.want) |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | |