package calendar_test // Tests for the memoised EF transfer plan (transfer_plan_cache.go). These // live in the external test package (calendar_test), exercising the real // tridentine sanctoral data through the public Compute entry point, the same // style precedence_ef_repro_test.go already uses -- the risk this cache // introduces is entirely about whether the CACHE KEY captures every real // input, which can only be demonstrated by varying an input through Compute // and checking the observed office actually changes, not by testing the // cache's internals in isolation. import ( "sync" "testing" "time" "github.com/lukaszkasprzak/lectio/internal/caldata" "github.com/lukaszkasprzak/lectio/internal/calendar" ) // TestEFTransferPlanCacheInvalidatesOnOverlay is THE bad-key test: it warms // the memoised plan for (2008, EF, the shipped calendar) across a whole // week -- mobile.Days's own access pattern -- then asks the SAME year // through a layer stack with ONE user overlay added (suppressing the // Annunciation) and asserts the observed office on 31 March and 1 April // CHANGES accordingly. // // What is varied: the layer stack / merged sanctoral content (a user // overlay), holding year and Selection fixed. This is deliberately not a // synthetic scenario: with the Annunciation present, its RG 96(a) proper // seat (the Monday after Low Sunday, RG 97/98) forces St Joseph -- also // impeded that year -- to walk one day further, to 1 April // (TestEFTwoTransfersDoNotCollide already pins this). Suppress the // Annunciation and it no longer claims that Monday, so Joseph lands ON it, // 31 March, and 1 April reverts to an ordinary feria. Verified empirically // against the pre-cache code before this test was written (both outcomes // reproduced exactly as asserted below). // // A cache keyed on year alone -- or on year+sel without the merged content // -- would serve 2008's BASE-CALENDAR plan back for the overlaid query too, // since both share every other key component; this test fails loudly if // that happens (it would see 1 April still reporting Joseph, and 31 March // still reporting the Annunciation, from the first, unrelated warm-up). func TestEFTransferPlanCacheInvalidatesOnOverlay(t *testing.T) { sel := calendar.DefaultSelection() sel.Form = "old" base := caldata.Tridentine() baseLayers := []calendar.Layer{base} compute := func(layers []calendar.Layer, date string) string { d, err := time.Parse("2006-01-02", date) if err != nil { t.Fatalf("bad test date %q: %v", date, err) } return calendar.Compute(d.UTC(), sel, layers).Observed.Slug } // Warm the cache for (2008, sel, base-only-hash) across a whole week, // exactly like mobile.Days resolving seven consecutive dates against the // same Prepared layer stack. for _, date := range []string{ "2008-03-26", "2008-03-27", "2008-03-28", "2008-03-29", "2008-03-30", "2008-03-31", "2008-04-01", "2008-04-02", } { compute(baseLayers, date) } if got := compute(baseLayers, "2008-03-31"); got != "annunciation-of-the-blessed-virgin-mary" { t.Fatalf("baseline 2008-03-31 = %q, want annunciation-of-the-blessed-virgin-mary", got) } if got := compute(baseLayers, "2008-04-01"); got != "joseph-spouse-of-the-bl-virgin-mary" { t.Fatalf("baseline 2008-04-01 = %q, want joseph-spouse-of-the-bl-virgin-mary", got) } // Same year, same Selection, ONE overlay layer added: suppress the // Annunciation. If the cache key omitted the sanctoral content, these // two calls would silently return the baseline plan warmed above. overlay := calendar.Layer{ID: "user", Cels: map[string]calendar.RawCelebration{ "annunciation-of-the-blessed-virgin-mary": { Fields: map[string]string{"suppress": "true"}, Variants: map[string]map[string]string{}, }, }} overlaid := []calendar.Layer{base, overlay} if got := compute(overlaid, "2008-03-31"); got != "joseph-spouse-of-the-bl-virgin-mary" { t.Errorf("overlaid 2008-03-31 = %q, want joseph-spouse-of-the-bl-virgin-mary (Joseph now lands here, the Annunciation no longer claims it)", got) } if got := compute(overlaid, "2008-04-01"); got == "joseph-spouse-of-the-bl-virgin-mary" { t.Errorf("overlaid 2008-04-01 = %q, want NOT joseph (a stale cache hit from the base-calendar warm-up)", got) } // And the base calendar's own answer must be unaffected by having since // computed the overlaid one -- the two keys must not collide either way. if got := compute(baseLayers, "2008-03-31"); got != "annunciation-of-the-blessed-virgin-mary" { t.Errorf("base calendar 2008-03-31 after overlaid query = %q, want annunciation-of-the-blessed-virgin-mary (unaffected)", got) } if got := compute(baseLayers, "2008-04-01"); got != "joseph-spouse-of-the-bl-virgin-mary" { t.Errorf("base calendar 2008-04-01 after overlaid query = %q, want joseph-spouse-of-the-bl-virgin-mary (unaffected)", got) } } // TestEFTransferPlanCacheInvalidatesOnYear is a lighter companion: the same // week-then-query pattern, varying the YEAR instead of the overlay (2008 vs // 2035, both real Joseph/Annunciation collision years -- see // TestEFTwoTransfersDoNotCollide -- but with different transfer targets). // year is an explicit field of the cache key already, so this mainly guards // against a key struct refactor accidentally dropping it; the overlay test // above is the one guarding the field that is easy to omit by mistake. func TestEFTransferPlanCacheInvalidatesOnYear(t *testing.T) { sel := calendar.DefaultSelection() sel.Form = "old" layers := []calendar.Layer{caldata.Tridentine()} compute := func(date string) string { d, err := time.Parse("2006-01-02", date) if err != nil { t.Fatalf("bad test date %q: %v", date, err) } return calendar.Compute(d.UTC(), sel, layers).Observed.Slug } if got := compute("2008-04-01"); got != "joseph-spouse-of-the-bl-virgin-mary" { t.Fatalf("2008-04-01 = %q, want joseph-spouse-of-the-bl-virgin-mary", got) } // 2035's Joseph lands on 2035-04-03, not 04-01 (a later Easter shifts the // whole window). If the cache ignored the year, this would wrongly // return 2008's plan. if got := compute("2035-04-01"); got == "joseph-spouse-of-the-bl-virgin-mary" { t.Errorf("2035-04-01 = %q, want NOT joseph (that is 2008's landing day, not 2035's)", got) } if got := compute("2035-04-03"); got != "joseph-spouse-of-the-bl-virgin-mary" { t.Errorf("2035-04-03 = %q, want joseph-spouse-of-the-bl-virgin-mary", got) } } // TestEFTransferPlanCacheConcurrentUse exercises the memoised plan from many // goroutines at once -- gomobile may call Day/Days in from multiple threads, // and none of the tests above (all sequential) can catch a data race on the // shared cache map/list. Run with -race; it is the actual proof of the // "thread-safe" claim, not merely built with a mutex and assumed correct. func TestEFTransferPlanCacheConcurrentUse(t *testing.T) { sel := calendar.DefaultSelection() sel.Form = "old" base := caldata.Tridentine() overlay := calendar.Layer{ID: "user", Cels: map[string]calendar.RawCelebration{ "annunciation-of-the-blessed-virgin-mary": { Fields: map[string]string{"suppress": "true"}, Variants: map[string]map[string]string{}, }, }} baseLayers := []calendar.Layer{base} overlaidLayers := []calendar.Layer{base, overlay} years := []int{2008, 2011, 2035, 2046} var wg sync.WaitGroup for g := 0; g < 12; g++ { g := g wg.Add(1) go func() { defer wg.Done() layers := baseLayers if g%2 == 0 { layers = overlaidLayers // different goroutines hammer different cache keys } for i := 0; i < 6; i++ { y := years[(g+i)%len(years)] for _, md := range []string{"03-19", "03-31", "04-01"} { d, err := time.Parse("2006-01-02", time.Date(y, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006")+"-"+md) if err != nil { t.Errorf("bad date: %v", err) return } _ = calendar.Compute(d.UTC(), sel, layers).Observed.Slug } } }() } wg.Wait() // After the concurrent hammering, correctness must still hold for both // keys -- the concurrency test is not a substitute for the correctness // test above, so re-assert both outcomes here too. d, _ := time.Parse("2006-01-02", "2008-04-01") if got := calendar.Compute(d.UTC(), sel, baseLayers).Observed.Slug; got != "joseph-spouse-of-the-bl-virgin-mary" { t.Errorf("after concurrent use, base 2008-04-01 = %q, want joseph-spouse-of-the-bl-virgin-mary", got) } if got := calendar.Compute(d.UTC(), sel, overlaidLayers).Observed.Slug; got == "joseph-spouse-of-the-bl-virgin-mary" { t.Errorf("after concurrent use, overlaid 2008-04-01 = %q, want NOT joseph", got) } }