htmgo/framework/h/cache_integration_test.go
Eliah Rusin 04997d7315
Refactor caching system to use pluggable stores
The commit modernizes the caching implementation by introducing a pluggable store interface that allows different cache backends. Key changes:

- Add Store interface for custom cache implementations
- Create default TTL-based store for backwards compatibility
- Add example LRU store for memory-bounded caching
- Support cache store configuration via options pattern
- Make cache cleanup logic implementation-specific
- Add comprehensive tests and documentation

The main goals were to:

1. Prevent unbounded memory growth through pluggable stores
2. Enable distributed caching support
3. Maintain backwards compatibility
4. Improve testability and maintainability

Signed-off-by: franchb <hello@franchb.com>
2025-06-26 21:38:38 +03:00

448 lines
11 KiB
Go

package h
import (
"fmt"
"sync"
"testing"
"time"
"github.com/maddalax/htmgo/framework/h/cache"
)
func TestCached_WithDefaultStore(t *testing.T) {
callCount := 0
// Create a cached component
CachedDiv := Cached(1*time.Hour, func() *Element {
callCount++
return Div(Text(fmt.Sprintf("Rendered %d times", callCount)))
})
// First render
html1 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Second render should use cache
html2 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", callCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCached_WithCustomStore(t *testing.T) {
// Use LRU store with small capacity
lruStore := cache.NewLRUStore[any, string](10)
defer lruStore.Close()
callCount := 0
// Create cached component with custom store
CachedDiv := Cached(1*time.Hour, func() *Element {
callCount++
return Div(Text(fmt.Sprintf("Rendered %d times", callCount)))
}, WithStore(lruStore))
// First render
html1 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Second render should use cache
html2 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", callCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCachedPerKey_WithDefaultStore(t *testing.T) {
renderCounts := make(map[int]int)
// Create per-key cached component
UserProfile := CachedPerKeyT(1*time.Hour, func(userID int) (int, GetElementFunc) {
return userID, func() *Element {
renderCounts[userID]++
return Div(Text(fmt.Sprintf("User %d (rendered %d times)", userID, renderCounts[userID])))
}
})
// Render for different users
html1_user1 := Render(UserProfile(1))
html1_user2 := Render(UserProfile(2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected each user to be rendered once")
}
// Render again - should use cache
html2_user1 := Render(UserProfile(1))
html2_user2 := Render(UserProfile(2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected renders to be cached")
}
if html1_user1 != html2_user1 || html1_user2 != html2_user2 {
t.Error("Expected same HTML from cache")
}
// Different users should have different content
if html1_user1 == html1_user2 {
t.Error("Expected different content for different users")
}
}
func TestCachedPerKey_WithLRUStore(t *testing.T) {
// Small LRU cache that can only hold 2 items
lruStore := cache.NewLRUStore[any, string](2)
defer lruStore.Close()
renderCounts := make(map[int]int)
// Create per-key cached component with LRU store
UserProfile := CachedPerKeyT(1*time.Hour, func(userID int) (int, GetElementFunc) {
return userID, func() *Element {
renderCounts[userID]++
return Div(Text(fmt.Sprintf("User %d", userID)))
}
}, WithStore(lruStore))
// Render 2 users - fill cache to capacity
Render(UserProfile(1))
Render(UserProfile(2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected each user to be rendered once")
}
// Render user 3 - should evict user 1 (least recently used)
Render(UserProfile(3))
if renderCounts[3] != 1 {
t.Error("Expected user 3 to be rendered once")
}
// Render user 1 again - should re-render (was evicted)
Render(UserProfile(1))
if renderCounts[1] != 2 {
t.Errorf("Expected user 1 to be re-rendered after eviction, got %d renders", renderCounts[1])
}
// Render user 2 again - should re-render (was evicted when user 1 was added back)
Render(UserProfile(2))
if renderCounts[2] != 2 {
t.Errorf("Expected user 2 to be re-rendered after eviction, got %d renders", renderCounts[2])
}
// At this point, cache contains users 1 and 2 (most recently used)
// Render user 1 again - should be cached
Render(UserProfile(1))
if renderCounts[1] != 2 {
t.Errorf("Expected user 1 to still be cached, got %d renders", renderCounts[1])
}
}
func TestCachedT_WithDefaultStore(t *testing.T) {
type Product struct {
ID int
Name string
Price float64
}
renderCount := 0
// Create cached component that takes typed data
ProductCard := CachedT(1*time.Hour, func(p Product) *Element {
renderCount++
return Div(
H3(Text(p.Name)),
P(Text(fmt.Sprintf("$%.2f", p.Price))),
)
})
product := Product{ID: 1, Name: "Widget", Price: 9.99}
// First render
html1 := Render(ProductCard(product))
if renderCount != 1 {
t.Errorf("Expected 1 render, got %d", renderCount)
}
// Second render should use cache
html2 := Render(ProductCard(product))
if renderCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", renderCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCachedPerKeyT_WithCustomStore(t *testing.T) {
type Article struct {
ID int
Title string
Content string
}
ttlStore := cache.NewTTLStore[any, string]()
defer ttlStore.Close()
renderCounts := make(map[int]int)
// Create per-key cached component with custom store
ArticleView := CachedPerKeyT(1*time.Hour, func(a Article) (int, GetElementFunc) {
return a.ID, func() *Element {
renderCounts[a.ID]++
return Div(
H1(Text(a.Title)),
P(Text(a.Content)),
)
}
}, WithStore(ttlStore))
article1 := Article{ID: 1, Title: "First", Content: "Content 1"}
article2 := Article{ID: 2, Title: "Second", Content: "Content 2"}
// Render articles
Render(ArticleView(article1))
Render(ArticleView(article2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected each article to be rendered once")
}
// Render again - should use cache
Render(ArticleView(article1))
Render(ArticleView(article2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected renders to be cached")
}
}
func TestDefaultCacheProvider_Override(t *testing.T) {
// Save original provider
originalProvider := DefaultCacheProvider
defer func() {
DefaultCacheProvider = originalProvider
}()
// Track which cache is used
customCacheUsed := false
// Override default provider
DefaultCacheProvider = func() cache.Store[any, string] {
customCacheUsed = true
return cache.NewLRUStore[any, string](100)
}
// Create cached component without specifying store
CachedDiv := Cached(1*time.Hour, func() *Element {
return Div(Text("Content"))
})
// Render to trigger cache creation
Render(CachedDiv())
if !customCacheUsed {
t.Error("Expected custom default cache provider to be used")
}
}
func TestCachedPerKey_ConcurrentAccess(t *testing.T) {
lruStore := cache.NewLRUStore[any, string](1000)
defer lruStore.Close()
UserProfile := CachedPerKeyT(1*time.Hour, func(userID int) (int, GetElementFunc) {
return userID, func() *Element {
// Simulate some work
time.Sleep(10 * time.Millisecond)
return Div(Text(fmt.Sprintf("User %d", userID)))
}
}, WithStore(lruStore))
const numGoroutines = 50
const numUsers = 20
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Many goroutines accessing overlapping user IDs
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numUsers; j++ {
userID := j % 10 // Reuse user IDs to test cache hits
html := Render(UserProfile(userID))
expectedContent := fmt.Sprintf("User %d", userID)
if !contains(html, expectedContent) {
t.Errorf("Goroutine %d: Expected content for user %d", id, userID)
}
}
}(i)
}
wg.Wait()
}
func TestCachedT2_MultipleParameters(t *testing.T) {
renderCount := 0
// Component that takes two parameters
CombinedView := CachedT2(1*time.Hour, func(title string, count int) *Element {
renderCount++
return Div(
H2(Text(title)),
P(Text(fmt.Sprintf("Count: %d", count))),
)
})
// First render
html1 := Render(CombinedView("Test", 42))
if renderCount != 1 {
t.Errorf("Expected 1 render, got %d", renderCount)
}
// Second render with same params should use cache
html2 := Render(CombinedView("Test", 42))
if renderCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", renderCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCachedPerKeyT3_ComplexKey(t *testing.T) {
type CompositeKey struct {
UserID int
ProductID int
Timestamp int64
}
renderCount := 0
// Component with composite key
UserProductView := CachedPerKeyT3(1*time.Hour,
func(userID int, productID int, timestamp int64) (CompositeKey, GetElementFunc) {
key := CompositeKey{UserID: userID, ProductID: productID, Timestamp: timestamp}
return key, func() *Element {
renderCount++
return Div(Text(fmt.Sprintf("User %d viewed product %d at %d", userID, productID, timestamp)))
}
},
)
// Render with specific combination
ts := time.Now().Unix()
html1 := Render(UserProductView(1, 100, ts))
if renderCount != 1 {
t.Errorf("Expected 1 render, got %d", renderCount)
}
// Same combination should use cache
html2 := Render(UserProductView(1, 100, ts))
if renderCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", renderCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
// Different combination should render again
Render(UserProductView(1, 101, ts))
if renderCount != 2 {
t.Errorf("Expected 2 renders for different key, got %d", renderCount)
}
}
func TestCached_Expiration(t *testing.T) {
callCount := 0
// Create cached component with short TTL
CachedDiv := Cached(100*time.Millisecond, func() *Element {
callCount++
return Div(Text(fmt.Sprintf("Render %d", callCount)))
})
// First render
Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Immediate second render should use cache
Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", callCount)
}
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should render again after expiration
Render(CachedDiv())
if callCount != 2 {
t.Errorf("Expected 2 renders after expiration, got %d", callCount)
}
}
func TestCachedNode_ClearCache(t *testing.T) {
lruStore := cache.NewLRUStore[any, string](10)
defer lruStore.Close()
callCount := 0
CachedDiv := Cached(1*time.Hour, func() *Element {
callCount++
return Div(Text("Content"))
}, WithStore(lruStore))
// Render and cache
element := CachedDiv()
Render(element)
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Clear cache
node := element.meta.(*CachedNode)
node.ClearCache()
// Should render again after cache clear
Render(element)
if callCount != 2 {
t.Errorf("Expected 2 renders after cache clear, got %d", callCount)
}
}
// Helper function
func contains(s, substr string) bool {
return len(s) >= len(substr) && s[0:len(substr)] == substr ||
len(s) > len(substr) && contains(s[1:], substr)
}