package loop import ( "context" "path/filepath" "testing" "time" "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/store" ) // An ambient meeting suppresses a nudge for its own span and no longer // (Vikunja #513). The span is read back off the event fact, so there is no // expiry to configure and no way for it to outlive the meeting. func TestAmbientEventSuppressesNudgesForItsOwnSpan(t *testing.T) { ctx := context.Background() s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy.db")) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { _ = s.Close() }) day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local) ev := calendar.Event{ Summary: "Встреча с Аней", Start: day.Add(14 * time.Hour), End: day.Add(15 * time.Hour), } if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev), calendar.SourceAmbient, calendar.FactValue(ev), day); err != nil { t.Fatalf("SetValue: %v", err) } g := NewGatherer(s, nil) for _, tc := range []struct { name string now time.Time busy bool }{ {"before it starts", day.Add(13*time.Hour + 59*time.Minute), false}, {"at the first minute", day.Add(14 * time.Hour), true}, {"in the middle", day.Add(14*time.Hour + 30*time.Minute), true}, {"at the end instant", day.Add(15 * time.Hour), false}, {"an hour after", day.Add(16 * time.Hour), false}, } { t.Run(tc.name, func(t *testing.T) { st, _, err := g.GatherState(ctx, tc.now) if err != nil { t.Fatalf("GatherState: %v", err) } if st.CalendarBusy != tc.busy { t.Fatalf("CalendarBusy = %v at %s, want %v", st.CalendarBusy, tc.now.Format("15:04"), tc.busy) } }) } } // A meeting on another day must not make today busy at the same clock reading. // The day comes from the key, which is what makes this hold. func TestAnEventOnAnotherDayDoesNotSuppress(t *testing.T) { ctx := context.Background() s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy_day.db")) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { _ = s.Close() }) yesterday := time.Date(2026, 8, 4, 0, 0, 0, 0, time.Local) ev := calendar.Event{Summary: "Standup", Start: yesterday.Add(14 * time.Hour), End: yesterday.Add(15 * time.Hour)} if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev), calendar.SourceAmbient, calendar.FactValue(ev), yesterday); err != nil { t.Fatalf("SetValue: %v", err) } today := time.Date(2026, 8, 5, 14, 30, 0, 0, time.Local) st, _, err := NewGatherer(s, nil).GatherState(ctx, today) if err != nil { t.Fatalf("GatherState: %v", err) } if st.CalendarBusy { t.Fatal("yesterday's meeting suppressed a nudge today") } } func TestFactSpanReadsBackWhatFactValueWrote(t *testing.T) { day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local) ev := calendar.Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(13*time.Hour + 45*time.Minute)} start, end, ok := calendar.FactSpan(calendar.FactKey(ev), calendar.FactValue(ev), time.Local) if !ok { t.Fatal("FactSpan could not read its own encoding") } if !start.Equal(ev.Start) || !end.Equal(ev.End) { t.Fatalf("span = %s-%s, want %s-%s", start, end, ev.Start, ev.End) } } // An end at or before the start is a meeting crossing midnight, not a zero-length // one. Reading it as zero-length would silently drop the suppression. func TestFactSpanCrossesMidnight(t *testing.T) { start, end, ok := calendar.FactSpan("calendar_event_20260805_Night", "Night @ 23:30-00:15", time.Local) if !ok { t.Fatal("FactSpan rejected a midnight-crossing event") } if got := end.Sub(start); got != 45*time.Minute { t.Fatalf("span length = %s, want 45m", got) } } // A fact that does not parse says nothing about now. Guessing a span here is // how one suppressed nudge becomes all of them. func TestFactSpanRejectsWhatItCannotRead(t *testing.T) { for _, tc := range []struct{ key, value string }{ {"other_key_20260805_x", "x @ 10:00-11:00"}, {"calendar_event_20260805_x", "x"}, {"calendar_event_notadate_x", "x @ 10:00-11:00"}, {"calendar_event_20260805_x", "x @ 25:00-11:00"}, {"calendar_event_20260805_x", "x @ 10:00"}, } { if _, _, ok := calendar.FactSpan(tc.key, tc.value, time.Local); ok { t.Errorf("FactSpan(%q, %q) parsed, want rejected", tc.key, tc.value) } } }