| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | |
| 6 | "github.com/carlosframework/docs/internal/export" |
| 7 | ) |
| 8 | |
| 9 | // bars is the only arithmetic in the thumbnail path that is not simply |
| 10 | // reported from the layout, so it is the part worth pinning. |
| 11 | |
| 12 | func TestBarsOfNothingIsNothing(t *testing.T) { |
| 13 | if got := bars(nil); got != nil { |
| 14 | t.Errorf("got %+v, want nil", got) |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | func TestABarSitsInsideTheLineItStandsFor(t *testing.T) { |
| 19 | line := export.LineBox{Left: 72, Top: 100, Width: 300, Height: 16.8, Size: 12} |
| 20 | got := bars([]export.LineBox{line}) |
| 21 | if len(got) != 1 { |
| 22 | t.Fatalf("got %d bars, want 1", len(got)) |
| 23 | } |
| 24 | b := got[0] |
| 25 | if b.X != 72 { |
| 26 | t.Errorf("X = %d, want the line's own left edge 72", b.X) |
| 27 | } |
| 28 | if b.W != 300 { |
| 29 | t.Errorf("W = %d, want the line's measured width 300", b.W) |
| 30 | } |
| 31 | // Vertically centred in the line box, and never taller than it — |
| 32 | // a bar that overflowed its line would overlap its neighbour and |
| 33 | // a page of text would read as one solid block. |
| 34 | if float64(b.Y) < line.Top-1 || float64(b.Y+b.H) > line.Top+line.Height+1 { |
| 35 | t.Errorf("bar [%d, %d] is not inside the line box [%.1f, %.1f]", |
| 36 | b.Y, b.Y+b.H, line.Top, line.Top+line.Height) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | // A heading draws heavier than body text, and it does so because the |
| 41 | // type really is bigger — this is what carries the distinction without |
| 42 | // colour (WCAG 2.2, and AGENTS.md's "colour never carries meaning |
| 43 | // alone"). |
| 44 | func TestAHeadingBarIsTallerThanABodyBar(t *testing.T) { |
| 45 | got := bars([]export.LineBox{ |
| 46 | {Left: 72, Top: 100, Width: 200, Height: 25, Size: 19.2}, |
| 47 | {Left: 72, Top: 130, Width: 200, Height: 16.8, Size: 12}, |
| 48 | }) |
| 49 | if len(got) != 2 { |
| 50 | t.Fatalf("got %d bars, want 2", len(got)) |
| 51 | } |
| 52 | if got[0].H <= got[1].H { |
| 53 | t.Errorf("heading bar %d is not taller than body bar %d", got[0].H, got[1].H) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // The floor. A bar of literally correct height at this scale is a |
| 58 | // fraction of a pixel; without a minimum, small type vanishes. |
| 59 | func TestATinyLineStillDrawsSomething(t *testing.T) { |
| 60 | got := bars([]export.LineBox{{Left: 72, Top: 100, Width: 200, Height: 4, Size: 1}}) |
| 61 | if len(got) != 1 || got[0].H <= 0 { |
| 62 | t.Fatalf("a tiny line drew nothing: %+v", got) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // The cache's whole freshness story is this test: an edit changes |
| 67 | // updated_at, and a changed updated_at makes the old entry |
| 68 | // unreachable without anyone having called an invalidate. |
| 69 | func TestAChangedUpdatedAtMissesTheCache(t *testing.T) { |
| 70 | p := newPreviews() |
| 71 | want := []previewBar{{X: 1, Y: 2, W: 3, H: 4}} |
| 72 | p.put(7, 1000, want) |
| 73 | |
| 74 | if got, ok := p.get(7, 1000); !ok || len(got) != 1 || got[0] != want[0] { |
| 75 | t.Errorf("same version did not hit: got %+v ok=%v", got, ok) |
| 76 | } |
| 77 | if _, ok := p.get(7, 1001); ok { |
| 78 | t.Error("a document edited since caching still hit the cache") |
| 79 | } |
| 80 | if _, ok := p.get(8, 1000); ok { |
| 81 | t.Error("a different document hit another document's entry") |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // A cached failure is a hit, not a miss: the answer is known and it is |
| 86 | // "draw nothing". Without this a document whose log will not replay is |
| 87 | // laid out again on every single list render. |
| 88 | func TestACachedFailureIsAHit(t *testing.T) { |
| 89 | p := newPreviews() |
| 90 | p.put(7, 1000, nil) |
| 91 | got, ok := p.get(7, 1000) |
| 92 | if !ok { |
| 93 | t.Fatal("a cached failure missed, so it will be retried on every render") |
| 94 | } |
| 95 | if got != nil { |
| 96 | t.Errorf("a cached failure returned bars: %+v", got) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestTheCacheIsBounded(t *testing.T) { |
| 101 | p := newPreviews() |
| 102 | for i := int64(0); i < maxPreviews+10; i++ { |
| 103 | p.put(i, 1, []previewBar{{X: 1}}) |
| 104 | } |
| 105 | p.mu.Lock() |
| 106 | n := len(p.by) |
| 107 | p.mu.Unlock() |
| 108 | if n > maxPreviews { |
| 109 | t.Errorf("cache holds %d entries, past its %d bound", n, maxPreviews) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // Overflowing the bound must not empty it. This is the bug an earlier |
| 114 | // version had: the map was wiped when full, and combined with |
| 115 | // thumbnailBudget that left an instance with more documents than the |
| 116 | // bound permanently cold — everything past the budget drew the empty |
| 117 | // box on every render, for ever, because each render wiped what the |
| 118 | // last one warmed. |
| 119 | func TestOverflowingTheCacheKeepsItWarm(t *testing.T) { |
| 120 | p := newPreviews() |
| 121 | for i := int64(0); i < maxPreviews*2; i++ { |
| 122 | p.put(i, 1, []previewBar{{X: 1}}) |
| 123 | } |
| 124 | p.mu.Lock() |
| 125 | n := len(p.by) |
| 126 | p.mu.Unlock() |
| 127 | if n < maxPreviews { |
| 128 | t.Errorf("cache holds only %d entries after overflowing — it was emptied rather than trimmed", n) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // Re-caching the same document must not evict anything: a list that |
| 133 | // re-renders the same rows would otherwise churn the map for no reason. |
| 134 | func TestReplacingAnEntryEvictsNothing(t *testing.T) { |
| 135 | p := newPreviews() |
| 136 | for i := int64(0); i < maxPreviews; i++ { |
| 137 | p.put(i, 1, []previewBar{{X: 1}}) |
| 138 | } |
| 139 | for i := int64(0); i < maxPreviews; i++ { |
| 140 | p.put(i, 2, []previewBar{{X: 2}}) |
| 141 | } |
| 142 | p.mu.Lock() |
| 143 | n := len(p.by) |
| 144 | p.mu.Unlock() |
| 145 | if n != maxPreviews { |
| 146 | t.Errorf("cache holds %d entries after re-caching the same documents, want %d", n, maxPreviews) |
| 147 | } |
| 148 | } |
| 149 | |