A go template renderer based on Perl's Template Toolkit
1package gott
2
3import (
4 "io/fs"
5 "os"
6 "path/filepath"
7 "testing"
8 "testing/fstest"
9)
10
11// TestInMemoryCache tests the LRU cache behavior
12func TestInMemoryCache(t *testing.T) {
13 t.Run("basic get and put", func(t *testing.T) {
14 cache := newCache(0) // unlimited
15
16 // Put a template
17 tmpl := &Template{Nodes: []Node{&TextNode{Text: "hello"}}}
18 cache.Put("key1", tmpl)
19
20 // Get it back
21 got, ok := cache.Get("key1")
22 if !ok {
23 t.Fatal("expected to find key1 in cache")
24 }
25 if got != tmpl {
26 t.Error("got different template than stored")
27 }
28 })
29
30 t.Run("cache miss", func(t *testing.T) {
31 cache := newCache(0)
32
33 _, ok := cache.Get("nonexistent")
34 if ok {
35 t.Error("expected cache miss for nonexistent key")
36 }
37 })
38
39 t.Run("LRU eviction", func(t *testing.T) {
40 cache := newCache(2) // max 2 entries
41
42 tmpl1 := &Template{Nodes: []Node{&TextNode{Text: "1"}}}
43 tmpl2 := &Template{Nodes: []Node{&TextNode{Text: "2"}}}
44 tmpl3 := &Template{Nodes: []Node{&TextNode{Text: "3"}}}
45
46 cache.Put("key1", tmpl1)
47 cache.Put("key2", tmpl2)
48
49 // Verify both are present
50 if cache.Len() != 2 {
51 t.Errorf("expected 2 entries, got %d", cache.Len())
52 }
53
54 // Add a third, should evict key1 (LRU)
55 cache.Put("key3", tmpl3)
56
57 if cache.Len() != 2 {
58 t.Errorf("expected 2 entries after eviction, got %d", cache.Len())
59 }
60
61 // key1 should be gone (LRU)
62 _, ok := cache.Get("key1")
63 if ok {
64 t.Error("key1 should have been evicted")
65 }
66
67 // key2 and key3 should still be present
68 if _, ok := cache.Get("key2"); !ok {
69 t.Error("key2 should still be in cache")
70 }
71 if _, ok := cache.Get("key3"); !ok {
72 t.Error("key3 should still be in cache")
73 }
74 })
75
76 t.Run("LRU access updates order", func(t *testing.T) {
77 cache := newCache(2)
78
79 tmpl1 := &Template{Nodes: []Node{&TextNode{Text: "1"}}}
80 tmpl2 := &Template{Nodes: []Node{&TextNode{Text: "2"}}}
81 tmpl3 := &Template{Nodes: []Node{&TextNode{Text: "3"}}}
82
83 cache.Put("key1", tmpl1)
84 cache.Put("key2", tmpl2)
85
86 // Access key1 to make it recently used
87 cache.Get("key1")
88
89 // Add key3, should evict key2 (now LRU)
90 cache.Put("key3", tmpl3)
91
92 // key1 should still be present
93 if _, ok := cache.Get("key1"); !ok {
94 t.Error("key1 should still be in cache after being accessed")
95 }
96
97 // key2 should be gone
98 if _, ok := cache.Get("key2"); ok {
99 t.Error("key2 should have been evicted")
100 }
101 })
102
103 t.Run("clear cache", func(t *testing.T) {
104 cache := newCache(0)
105
106 cache.Put("key1", &Template{})
107 cache.Put("key2", &Template{})
108
109 cache.Clear()
110
111 if cache.Len() != 0 {
112 t.Errorf("expected empty cache after clear, got %d entries", cache.Len())
113 }
114
115 if _, ok := cache.Get("key1"); ok {
116 t.Error("key1 should not be in cache after clear")
117 }
118 })
119
120 t.Run("update existing key", func(t *testing.T) {
121 cache := newCache(0)
122
123 tmpl1 := &Template{Nodes: []Node{&TextNode{Text: "1"}}}
124 tmpl2 := &Template{Nodes: []Node{&TextNode{Text: "2"}}}
125
126 cache.Put("key1", tmpl1)
127 cache.Put("key1", tmpl2) // update
128
129 if cache.Len() != 1 {
130 t.Errorf("expected 1 entry after update, got %d", cache.Len())
131 }
132
133 got, _ := cache.Get("key1")
134 if got != tmpl2 {
135 t.Error("expected updated template")
136 }
137 })
138}
139
140// TestDiskCache tests the disk-based cache
141func TestDiskCache(t *testing.T) {
142 // Create a temp directory for cache
143 tmpDir := filepath.Join(os.TempDir(), "gott-cache-test")
144 defer os.RemoveAll(tmpDir)
145
146 t.Run("create disk cache", func(t *testing.T) {
147 dc, err := newDiskCache(tmpDir)
148 if err != nil {
149 t.Fatalf("newDiskCache() error = %v", err)
150 }
151 if dc == nil {
152 t.Fatal("expected non-nil diskCache")
153 }
154 })
155
156 t.Run("put and get", func(t *testing.T) {
157 dc, _ := newDiskCache(tmpDir)
158
159 // Create a template with various node types to test serialization
160 tmpl := &Template{
161 Position: Position{Line: 1, Column: 1},
162 Nodes: []Node{
163 &TextNode{Text: "Hello "},
164 &OutputStmt{
165 Expr: &IdentExpr{Parts: []string{"name"}},
166 },
167 &IfStmt{
168 Condition: &BinaryExpr{
169 Op: TokenEq,
170 Left: &IdentExpr{Parts: []string{"x"}},
171 Right: &LiteralExpr{Value: float64(1)},
172 },
173 Body: []Node{&TextNode{Text: "yes"}},
174 },
175 },
176 }
177
178 err := dc.Put("test-key", tmpl)
179 if err != nil {
180 t.Fatalf("Put() error = %v", err)
181 }
182
183 got, err := dc.Get("test-key")
184 if err != nil {
185 t.Fatalf("Get() error = %v", err)
186 }
187 if got == nil {
188 t.Fatal("expected non-nil template from disk cache")
189 }
190
191 // Verify structure was preserved
192 if len(got.Nodes) != 3 {
193 t.Errorf("expected 3 nodes, got %d", len(got.Nodes))
194 }
195
196 // Check text node
197 if textNode, ok := got.Nodes[0].(*TextNode); !ok || textNode.Text != "Hello " {
198 t.Error("text node not preserved correctly")
199 }
200
201 // Check output stmt with ident
202 if outputStmt, ok := got.Nodes[1].(*OutputStmt); !ok {
203 t.Error("expected OutputStmt")
204 } else if identExpr, ok := outputStmt.Expr.(*IdentExpr); !ok || identExpr.Parts[0] != "name" {
205 t.Error("ident expr not preserved correctly")
206 }
207 })
208
209 t.Run("get nonexistent key", func(t *testing.T) {
210 dc, _ := newDiskCache(tmpDir)
211
212 got, err := dc.Get("nonexistent")
213 if err != nil {
214 t.Errorf("Get() error = %v", err)
215 }
216 if got != nil {
217 t.Error("expected nil for nonexistent key")
218 }
219 })
220
221 t.Run("clear disk cache", func(t *testing.T) {
222 dc, _ := newDiskCache(tmpDir)
223
224 // Add some entries
225 dc.Put("key1", &Template{})
226 dc.Put("key2", &Template{})
227
228 err := dc.Clear()
229 if err != nil {
230 t.Fatalf("Clear() error = %v", err)
231 }
232
233 // Verify entries are gone
234 if got, _ := dc.Get("key1"); got != nil {
235 t.Error("key1 should be cleared")
236 }
237 if got, _ := dc.Get("key2"); got != nil {
238 t.Error("key2 should be cleared")
239 }
240 })
241}
242
243// TestCachingIntegration tests that caching works with the Renderer
244func TestCachingIntegration(t *testing.T) {
245 memFS := fstest.MapFS{
246 "test.html": &fstest.MapFile{Data: []byte("Hello [% name %]")},
247 }
248
249 t.Run("memory cache speeds up processing", func(t *testing.T) {
250 r, err := New(&Config{
251 IncludePaths: []fs.FS{memFS},
252 MaxCacheSize: 10,
253 })
254 if err != nil {
255 t.Fatalf("New() error = %v", err)
256 }
257
258 vars := map[string]any{"name": "World"}
259
260 // First call parses and caches
261 result1, err := r.ProcessFile("test.html", vars)
262 if err != nil {
263 t.Fatalf("ProcessFile() error = %v", err)
264 }
265 if result1 != "Hello World" {
266 t.Errorf("got %q, want %q", result1, "Hello World")
267 }
268
269 // Verify it's in cache
270 if r.cache.Len() != 1 {
271 t.Errorf("expected 1 entry in cache, got %d", r.cache.Len())
272 }
273
274 // Second call should use cache
275 result2, err := r.ProcessFile("test.html", vars)
276 if err != nil {
277 t.Fatalf("ProcessFile() error = %v", err)
278 }
279 if result2 != "Hello World" {
280 t.Errorf("got %q, want %q", result2, "Hello World")
281 }
282 })
283
284 t.Run("disk cache persistence", func(t *testing.T) {
285 tmpDir := filepath.Join(os.TempDir(), "gott-disk-cache-test")
286 defer os.RemoveAll(tmpDir)
287
288 r1, err := New(&Config{
289 IncludePaths: []fs.FS{memFS},
290 CachePath: tmpDir,
291 })
292 if err != nil {
293 t.Fatalf("New() error = %v", err)
294 }
295
296 // Process to populate cache
297 _, err = r1.ProcessFile("test.html", map[string]any{"name": "Test"})
298 if err != nil {
299 t.Fatalf("ProcessFile() error = %v", err)
300 }
301
302 // Create new renderer with same cache path
303 r2, err := New(&Config{
304 IncludePaths: []fs.FS{memFS},
305 CachePath: tmpDir,
306 })
307 if err != nil {
308 t.Fatalf("New() error = %v", err)
309 }
310
311 // Memory cache should be empty in new renderer
312 if r2.cache.Len() != 0 {
313 t.Errorf("expected empty memory cache in new renderer, got %d", r2.cache.Len())
314 }
315
316 // But processing should load from disk cache
317 result, err := r2.ProcessFile("test.html", map[string]any{"name": "FromDisk"})
318 if err != nil {
319 t.Fatalf("ProcessFile() error = %v", err)
320 }
321 if result != "Hello FromDisk" {
322 t.Errorf("got %q, want %q", result, "Hello FromDisk")
323 }
324
325 // Now memory cache should have the entry
326 if r2.cache.Len() != 1 {
327 t.Errorf("expected 1 entry in memory cache after disk load, got %d", r2.cache.Len())
328 }
329 })
330
331 t.Run("ClearCache clears both caches", func(t *testing.T) {
332 tmpDir := filepath.Join(os.TempDir(), "gott-clear-cache-test")
333 defer os.RemoveAll(tmpDir)
334
335 r, err := New(&Config{
336 IncludePaths: []fs.FS{memFS},
337 CachePath: tmpDir,
338 })
339 if err != nil {
340 t.Fatalf("New() error = %v", err)
341 }
342
343 // Populate caches
344 _, _ = r.ProcessFile("test.html", map[string]any{"name": "Test"})
345
346 // Clear
347 r.ClearCache()
348
349 // Memory cache should be empty
350 if r.cache.Len() != 0 {
351 t.Errorf("expected empty memory cache after clear, got %d", r.cache.Len())
352 }
353
354 // Disk cache should also be empty - verify by checking that
355 // the file no longer exists
356 entries, _ := os.ReadDir(tmpDir)
357 gobCount := 0
358 for _, e := range entries {
359 if filepath.Ext(e.Name()) == ".gob" {
360 gobCount++
361 }
362 }
363 if gobCount != 0 {
364 t.Errorf("expected 0 .gob files after clear, got %d", gobCount)
365 }
366 })
367
368 t.Run("Process caches by content hash", func(t *testing.T) {
369 r, err := New(&Config{
370 MaxCacheSize: 10,
371 })
372 if err != nil {
373 t.Fatalf("New() error = %v", err)
374 }
375
376 template := "Hello [% name %]!"
377
378 // First call
379 result1, _ := r.Process(template, map[string]any{"name": "Alice"})
380 if result1 != "Hello Alice!" {
381 t.Errorf("got %q, want %q", result1, "Hello Alice!")
382 }
383
384 // Second call with same template (different vars)
385 result2, _ := r.Process(template, map[string]any{"name": "Bob"})
386 if result2 != "Hello Bob!" {
387 t.Errorf("got %q, want %q", result2, "Hello Bob!")
388 }
389
390 // Should only have one cache entry (same template content)
391 if r.cache.Len() != 1 {
392 t.Errorf("expected 1 entry in cache for same template, got %d", r.cache.Len())
393 }
394
395 // Different template should add another entry
396 _, _ = r.Process("Different template", nil)
397 if r.cache.Len() != 2 {
398 t.Errorf("expected 2 entries for different templates, got %d", r.cache.Len())
399 }
400 })
401}
402
403// TestCacheWithIncludes tests that includes also use caching
404func TestCacheWithIncludes(t *testing.T) {
405 memFS := fstest.MapFS{
406 "main.html": &fstest.MapFile{Data: []byte("Main: [% INCLUDE header.html %]")},
407 "header.html": &fstest.MapFile{Data: []byte("Header: [% title %]")},
408 }
409
410 r, err := New(&Config{
411 IncludePaths: []fs.FS{memFS},
412 MaxCacheSize: 10,
413 })
414 if err != nil {
415 t.Fatalf("New() error = %v", err)
416 }
417
418 result, err := r.ProcessFile("main.html", map[string]any{"title": "Test"})
419 if err != nil {
420 t.Fatalf("ProcessFile() error = %v", err)
421 }
422
423 expected := "Main: Header: Test"
424 if result != expected {
425 t.Errorf("got %q, want %q", result, expected)
426 }
427
428 // Both templates should be cached
429 if r.cache.Len() != 2 {
430 t.Errorf("expected 2 entries in cache (main + header), got %d", r.cache.Len())
431 }
432}
433
434// TestRendererClose tests that Close stops the SIGHUP handler
435func TestRendererClose(t *testing.T) {
436 r, err := New(&Config{
437 EnableSIGHUP: true,
438 })
439 if err != nil {
440 t.Fatalf("New() error = %v", err)
441 }
442
443 // Close should not panic
444 r.Close()
445
446 // Second close should also not panic (idempotent-ish)
447 // Note: This will panic with "close of closed channel" if we don't handle it
448 // But for simplicity, we document that Close should only be called once
449}