aboutsummaryrefslogtreecommitdiff
path: root/internal/calendar/transfer_plan_cache_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/calendar/transfer_plan_cache_test.go')
-rw-r--r--internal/calendar/transfer_plan_cache_test.go313
1 files changed, 313 insertions, 0 deletions
diff --git a/internal/calendar/transfer_plan_cache_test.go b/internal/calendar/transfer_plan_cache_test.go
new file mode 100644
index 0000000..1592644
--- /dev/null
+++ b/internal/calendar/transfer_plan_cache_test.go
@@ -0,0 +1,313 @@
+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}
+ // A third, disjoint key exercising the occupancy-index collision path
+ // (TestEFOccupancyIndexDetectsSameDateClass1Collision) concurrently too
+ // -- the plan alone does not touch efOccupancyIndex.occupied unless a
+ // candidate's own precedence doesn't already decide it, which the
+ // Joseph/Annunciation scenario above never triggers (see that test's own
+ // doc comment).
+ collideLayers := []calendar.Layer{base, {ID: "user", Cels: map[string]calendar.RawCelebration{
+ "zz-test-alpha": {Fields: map[string]string{"rank": "class-1", "date": "07-06", "name.en": "ZZ Test Alpha"}, Variants: map[string]map[string]string{}},
+ "zz-test-beta": {Fields: map[string]string{"rank": "class-1", "date": "07-06", "name.en": "ZZ Test Beta"}, Variants: map[string]map[string]string{}},
+ }}}
+
+ years := []int{2008, 2011, 2035, 2046}
+
+ var wg sync.WaitGroup
+ for g := 0; g < 15; g++ {
+ g := g
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ switch g % 3 {
+ case 0:
+ 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, baseLayers).Observed.Slug
+ }
+ }
+ case 1:
+ 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, overlaidLayers).Observed.Slug
+ }
+ }
+ default:
+ for i := 0; i < 6; i++ {
+ for _, ymd := range []string{"2026-07-06", "2026-07-07", "2026-07-08"} {
+ d, err := time.Parse("2006-01-02", ymd)
+ if err != nil {
+ t.Errorf("bad date: %v", err)
+ return
+ }
+ _ = calendar.Compute(d.UTC(), sel, collideLayers).Observed.Slug
+ }
+ }
+ }
+ }()
+ }
+ wg.Wait()
+
+ // After the concurrent hammering, correctness must still hold for all
+ // three keys -- the concurrency test is not a substitute for the
+ // correctness tests above, so re-assert their 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)
+ }
+ d2, _ := time.Parse("2006-01-02", "2026-07-06")
+ if got := calendar.Compute(d2.UTC(), sel, collideLayers).Observed.Slug; got != "ef-time-after-pentecost-6-monday" {
+ t.Errorf("after concurrent use, colliding 2026-07-06 = %q, want ef-time-after-pentecost-6-monday", got)
+ }
+}
+
+// TestEFOccupancyIndexDetectsSameDateClass1Collision covers a path the tests
+// above do not: efOccupancyIndex.occupied's use from computeEF's
+// transferIfImpededEF call site (the per-candidate fallback for a class-1
+// entry efTransferPlan judged NOT impeded by temporal precedence alone), and
+// efOccupancyIndex's own use inside efTransferPlan's initial impeded check
+// (occupiedByClass1(when, cel.Slug) -- "does some OTHER fixed-date class-1
+// SANCTORAL feast already sit on `when`", the doc comment's own example, RG
+// 97/98). Neither TestEFTwoTransfersDoNotCollide nor the overlay/year tests
+// above exercise it: St Joseph and the Annunciation are impeded by HOLY
+// WEEK'S OWN temporal precedence, on DIFFERENT original dates -- never by
+// colliding with each other's original date -- so no existing test had ever
+// driven two class-1 SANCTORAL entries onto the exact same calendar date.
+//
+// A synthetic overlay is used because no two class-1 feasts share a fixed
+// date in the shipped 1962 calendar (an ordinary Time-after-Pentecost
+// Monday, 6 July 2026, was checked empirically before writing this test:
+// alone, a single synthetic class-1 entry there is simply observed, since
+// nothing outranks or occupies it; the real collision case exists only by
+// construction).
+//
+// What is varied: whether a SECOND class-1 entry shares the first one's
+// date -- both entries otherwise identical (rank class-1, real content
+// unrelated to any liturgical rule under test). If efOccupancyIndex failed
+// to detect the collision (or wrongly matched exceptSlug against ITSELF,
+// the most dangerous failure shape -- see the mutation proof below), 6 July
+// would keep reporting the lone entry regardless.
+func TestEFOccupancyIndexDetectsSameDateClass1Collision(t *testing.T) {
+ sel := calendar.DefaultSelection()
+ sel.Form = "old"
+ base := caldata.Tridentine()
+
+ 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
+ }
+
+ alpha := calendar.RawCelebration{
+ Fields: map[string]string{"rank": "class-1", "date": "07-06", "name.en": "ZZ Test Alpha"},
+ Variants: map[string]map[string]string{},
+ }
+ beta := calendar.RawCelebration{
+ Fields: map[string]string{"rank": "class-1", "date": "07-06", "name.en": "ZZ Test Beta"},
+ Variants: map[string]map[string]string{},
+ }
+
+ soloLayers := []calendar.Layer{base, {ID: "user", Cels: map[string]calendar.RawCelebration{
+ "zz-test-alpha": alpha,
+ }}}
+ collideLayers := []calendar.Layer{base, {ID: "user", Cels: map[string]calendar.RawCelebration{
+ "zz-test-alpha": alpha,
+ "zz-test-beta": beta,
+ }}}
+
+ // Alone, alpha is simply observed on its own date: nothing occupies 6
+ // July, so efTransferPlan judges it unimpeded and computeEF's fallback
+ // (occ.occupied via transferIfImpededEF) must agree and leave it there.
+ if got := compute(soloLayers, "2026-07-06"); got != "zz-test-alpha" {
+ t.Fatalf("alpha alone, 2026-07-06 = %q, want zz-test-alpha", got)
+ }
+
+ // Add beta on the SAME date. Both now occupy each other's date, so BOTH
+ // are impeded (RG 97/98) and transfer forward in table/impeded-first
+ // order (alpha to 7 July, beta to 8 -- empirically confirmed before
+ // writing this test); 6 July itself reverts to the ordinary temporal
+ // office, since NEITHER candidate keeps its place.
+ if got := compute(collideLayers, "2026-07-06"); got != "ef-time-after-pentecost-6-monday" {
+ t.Errorf("alpha+beta colliding, 2026-07-06 = %q, want ef-time-after-pentecost-6-monday (a stale/broken occupancy index would still show zz-test-alpha)", got)
+ }
+ if got := compute(collideLayers, "2026-07-07"); got != "zz-test-alpha" {
+ t.Errorf("alpha+beta colliding, 2026-07-07 = %q, want zz-test-alpha (transferred here)", got)
+ }
+ if got := compute(collideLayers, "2026-07-08"); got != "zz-test-beta" {
+ t.Errorf("alpha+beta colliding, 2026-07-08 = %q, want zz-test-beta (transferred here)", got)
+ }
+
+ // And alpha alone (no beta) must be unaffected by having since computed
+ // the colliding scenario -- the two overlays must not cross-contaminate
+ // the shared cache.
+ if got := compute(soloLayers, "2026-07-06"); got != "zz-test-alpha" {
+ t.Errorf("alpha alone after collision query, 2026-07-06 = %q, want zz-test-alpha (unaffected)", got)
+ }
+}