cc72f69769
The ambient endpoint writes calendar_event_* and never calendar_busy, so a notification-derived meeting was good enough to recite out loud and not good enough to stop a nudge during it. Backwards: being wrong here costs one nudge. The loop gatherer now derives busy from the event facts themselves, so the expiry IS the meeting's span. No new level, no interval to choose, and no way for the suppression to outlive the meeting. calendar.FactSpan reads back what FactValue wrote; anything that does not parse says nothing about now.
125 lines
4.3 KiB
Go
125 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|