A go template renderer based on Perl's Template Toolkit
at main 13 kB view raw
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}