package main import ( "context" "path/filepath" "strings" "testing" "time" "homehub/internal/store" ) func testApp(t *testing.T) *app { t.Helper() db, err := store.New(filepath.Join(t.TempDir(), "store.json")) if err != nil { t.Fatal(err) } return &app{db: db, timezone: berlin(t)} } func TestTemporalCorrectionUsesRelativeDayAndExactClock(t *testing.T) { a := testApp(t) now := time.Now().In(a.timezone) ac := commandAction{Type: "add_event", Title: "Zahnarzt", Start: now.AddDate(0, 0, 1).Format("2006-01-02T15:00:00-07:00")} got := a.correctTemporalFromMessage(ac, "Ich habe morgen einen Zahnarzt Termin um 15:30 Uhr") tm, err := time.Parse(time.RFC3339, got.Start) if err != nil { t.Fatal(err) } tm = tm.In(a.timezone) wantDay := now.AddDate(0, 0, 1) if tm.Year() != wantDay.Year() || tm.YearDay() != wantDay.YearDay() || tm.Hour() != 15 || tm.Minute() != 30 { t.Fatalf("corrected start=%s, want tomorrow at 15:30", got.Start) } } func TestDuplicateEventIsIdempotent(t *testing.T) { a := testApp(t) start := time.Now().In(a.timezone).Add(24 * time.Hour).Truncate(time.Minute).Format(time.RFC3339) ac := commandAction{Type: "add_event", Title: "Zahnarzttermin", Start: start, Confidence: .95} first, ok := a.applyCommandAction(ac) if !ok || first.Type != "add_event" { t.Fatalf("first=%+v ok=%v", first, ok) } second, ok := a.applyCommandAction(ac) if !ok || second.Type != "event_exists" { t.Fatalf("second=%+v ok=%v", second, ok) } if got := len(a.db.Snapshot().Events); got != 1 { t.Fatalf("events=%d want 1", got) } } func TestDeleteEventConversationBoth(t *testing.T) { a := testApp(t) now := time.Now().In(a.timezone) e1 := store.CalendarEvent{ID: "e1", Title: "Zahnarzt", Start: now.Add(24 * time.Hour).Format(time.RFC3339), CreatedAt: store.Now()} e2 := store.CalendarEvent{ID: "e2", Title: "Fische füttern", Start: now.Add(48 * time.Hour).Format(time.RFC3339), CreatedAt: store.Now()} _ = a.db.Update(func(s *store.State) error { s.Events = []store.CalendarEvent{e1, e2} s.Tasks = []store.Task{{ID: "t1", Title: "Steuer", Status: "open", Priority: "normal", CreatedAt: store.Now()}} return nil }) reply, actions, mode, handled := a.tryHandleControlCommand("lösche den termin") if !handled || mode != "clarification" || len(actions) != 0 || !strings.Contains(reply, "1.") || !strings.Contains(reply, "2.") { t.Fatalf("unexpected first response: %q %+v %s %v", reply, actions, mode, handled) } if len(a.db.Snapshot().Events) != 2 { t.Fatal("events were deleted before selection") } reply, actions, mode, handled = a.tryHandleControlCommand("beide") if !handled || mode != "action" || len(actions) != 2 || !strings.Contains(reply, "2 Kalendertermin") { t.Fatalf("unexpected selection response: %q %+v %s", reply, actions, mode) } st := a.db.Snapshot() if len(st.Events) != 0 { t.Fatalf("events=%d want 0", len(st.Events)) } if len(st.Tasks) != 1 { t.Fatalf("tasks=%d want 1", len(st.Tasks)) } } func TestDeleteAllTasksLeavesCalendarAndReminders(t *testing.T) { a := testApp(t) now := time.Now().In(a.timezone) _ = a.db.Update(func(s *store.State) error { s.Tasks = []store.Task{{ID: "t1", Title: "A", Status: "open", Priority: "normal", CreatedAt: store.Now()}, {ID: "t2", Title: "B", Status: "open", Priority: "normal", CreatedAt: store.Now()}} s.Events = []store.CalendarEvent{{ID: "e1", Title: "Termin", Start: now.Add(24 * time.Hour).Format(time.RFC3339), CreatedAt: store.Now()}} s.Reminders = []store.Reminder{{ID: "r1", Title: "Termin: Termin", At: now.Add(23*time.Hour + 30*time.Minute).Format(time.RFC3339), LinkedType: "event", LinkedID: "e1", AIManaged: true, CreatedAt: store.Now()}} s.Kanban = []store.KanbanCard{{ID: "c1", Title: "A", TaskID: "t1", AIManaged: false, Column: "next", CreatedAt: store.Now()}, {ID: "c2", Title: "Standalone", AIManaged: false, Column: "inbox", CreatedAt: store.Now()}} return nil }) reply, _, mode, handled := a.tryHandleControlCommand("Lösche alle Aufgaben") if !handled || mode != "action" || !strings.Contains(reply, "Kalendertermine") { t.Fatalf("reply=%q", reply) } st := a.db.Snapshot() if len(st.Tasks) != 0 { t.Fatalf("tasks=%d", len(st.Tasks)) } if len(st.Events) != 1 || len(st.Reminders) != 1 { t.Fatalf("event/reminder changed: %+v", st) } if len(st.Kanban) != 1 || st.Kanban[0].ID != "c2" { t.Fatalf("kanban=%+v", st.Kanban) } } func TestScoutContextIgnoresAssistantAndCalendarCommands(t *testing.T) { st := store.State{ Tasks: []store.Task{}, Events: []store.CalendarEvent{}, Reminders: []store.Reminder{}, Groceries: []store.GroceryItem{}, Recipes: []store.Recipe{}, MealPlans: []store.MealPlan{}, Kanban: []store.KanbanCard{}, Documents: []store.Document{}, Chat: []store.ChatTurn{ {Role: "assistant", Content: "Du solltest den Zahnarzttermin vorbereiten"}, {Role: "user", Content: "Ich habe morgen einen Zahnarzt Termin um 15:30 Uhr"}, {Role: "user", Content: "Ich muss die Steuerunterlagen bis Freitag abgeben"}, }, } ctx := buildScoutContext(st) if strings.Contains(ctx, "vorbereiten") || strings.Contains(ctx, "Zahnarzt Termin") { t.Fatalf("calendar/assistant leaked into scout context: %s", ctx) } if !strings.Contains(ctx, "Steuerunterlagen") { t.Fatalf("implicit task missing: %s", ctx) } } func TestCalendarDerivedScoutCandidateRejected(t *testing.T) { st := store.State{Events: []store.CalendarEvent{{ID: "e", Title: "Zahnarzttermin", Start: time.Now().Add(24 * time.Hour).Format(time.RFC3339)}}} c := scoutTask{Title: "Zahnarzttermin vorbereiten", Source: "chat", Confidence: .95} if !candidateLooksCalendarDerived(c, st) { t.Fatal("calendar-derived task should be rejected") } } func TestEventReminderIsCreatedAndUpdated(t *testing.T) { a := testApp(t) start := time.Now().In(a.timezone).Add(24 * time.Hour).Truncate(time.Minute) _ = a.db.Update(func(s *store.State) error { s.Events = []store.CalendarEvent{{ID: "e1", Title: "Zahnarzt", Start: start.Format(time.RFC3339), CreatedAt: store.Now()}} return nil }) if n := a.syncEventReminders(); n != 1 { t.Fatalf("created=%d", n) } st := a.db.Snapshot() if len(st.Reminders) != 1 { t.Fatalf("reminders=%d", len(st.Reminders)) } want := start.Add(-30 * time.Minute).Format(time.RFC3339) if st.Reminders[0].At != want { t.Fatalf("at=%s want=%s", st.Reminders[0].At, want) } start2 := start.Add(time.Hour) _ = a.db.Update(func(s *store.State) error { s.Events[0].Start = start2.Format(time.RFC3339); return nil }) if n := a.syncEventReminders(); n != 1 { t.Fatalf("updated=%d", n) } st = a.db.Snapshot() if len(st.Reminders) != 1 { t.Fatalf("reminders=%d", len(st.Reminders)) } want = start2.Add(-30 * time.Minute).Format(time.RFC3339) if st.Reminders[0].At != want { t.Fatalf("updated at=%s want=%s", st.Reminders[0].At, want) } } func TestSyncTaskCardsPrunesOrphanAIOnly(t *testing.T) { a := testApp(t) _ = a.db.Update(func(s *store.State) error { s.Kanban = []store.KanbanCard{{ID: "ai", Title: "Ghost", TaskID: "gone", AIManaged: true, Column: "next", CreatedAt: store.Now()}, {ID: "manual", Title: "Keep", TaskID: "gone2", AIManaged: false, Column: "next", CreatedAt: store.Now()}} return nil }) if n := a.syncTaskCards(); n != 1 { t.Fatalf("changed=%d want 1", n) } st := a.db.Snapshot() if len(st.Kanban) != 1 || st.Kanban[0].ID != "manual" { t.Fatalf("kanban=%+v", st.Kanban) } } func TestCalendarStateQueryIsDeterministic(t *testing.T) { a := testApp(t) now := time.Now().In(a.timezone) _ = a.db.Update(func(s *store.State) error { s.Events = []store.CalendarEvent{{ID: "e1", Title: "Zahnarzttermin", Start: now.Add(24 * time.Hour).Format(time.RFC3339), CreatedAt: store.Now()}} return nil }) reply, actions, mode, handled := a.tryHandleControlCommand("Wann ist mein Zahnarzttermin?") if !handled || mode != "state" || len(actions) != 0 || !strings.Contains(reply, "Zahnarzttermin") { t.Fatalf("reply=%q actions=%+v mode=%s handled=%v", reply, actions, mode, handled) } } func TestMutatingQuestionIsNotInterceptedAsStateQuery(t *testing.T) { a := testApp(t) _, _, _, handled := a.tryHandleControlCommand("Kannst du morgen um 15 Uhr einen Termin anlegen?") if handled { t.Fatal("mutating question must continue to the action planner") } } func TestScoutContextEmptyWithoutEligibleMaterial(t *testing.T) { st := store.State{Chat: []store.ChatTurn{{Role: "assistant", Content: "Vorschlag"}, {Role: "user", Content: "Trage morgen einen Termin ein"}}} if got := buildScoutContext(st); got != "" { t.Fatalf("expected empty scout context, got %q", got) } } func TestWeekdayResolutionFromThursday(t *testing.T) { loc := berlin(t) now := time.Date(2026, 8, 27, 21, 30, 0, 0, loc) // Donnerstag got, ok := explicitOrRelativeDate("erstelle einen termin für montag mit Urlaub", now) if !ok { t.Fatal("weekday not recognized") } want := time.Date(2026, 8, 31, 0, 0, 0, 0, loc) if got.Year() != want.Year() || got.YearDay() != want.YearDay() { t.Fatalf("got %s want %s", got, want) } } func TestNextSameWeekdayMeansNextWeekWhenExplicitlyNext(t *testing.T) { loc := berlin(t) now := time.Date(2026, 8, 31, 10, 0, 0, 0, loc) // Montag got, ok := explicitOrRelativeDate("nächsten Montag", now) if !ok { t.Fatal("weekday not recognized") } want := time.Date(2026, 9, 7, 0, 0, 0, 0, loc) if got.Year() != want.Year() || got.YearDay() != want.YearDay() { t.Fatalf("got %s want %s", got, want) } } func TestFallbackEventTitleAndDate(t *testing.T) { a := testApp(t) title := extractFallbackEventTitle("erstelle einen termin für montag mit Urlaub") if title != "Urlaub" { t.Fatalf("title=%q", title) } title = extractFallbackEventTitle("Ich habe morgen einen Zahnarzt termin um 15:30 Uhr") if title != "Zahnarzt" { t.Fatalf("title=%q", title) } _ = a } func TestMealPlanWeekdayAndMealAreCorrectedDeterministically(t *testing.T) { a := testApp(t) now := time.Date(2026, 8, 27, 21, 55, 0, 0, a.timezone) // Donnerstag badModelOutput := commandAction{Type: "add_meal_plan", Title: "Nudeln", RecipeTitle: "Nudeln", Date: "2026-08-30", Meal: "lunch", Confidence: .95} got := a.correctTemporalFromMessageAt(badModelOutput, "montag bitte nudeln zum abendessen eintragen", now) if got.Date != "2026-08-31" { t.Fatalf("date=%s want 2026-08-31", got.Date) } if got.Meal != "dinner" { t.Fatalf("meal=%s want dinner", got.Meal) } } func TestRecentMealCorrectionTargetsLastCommittedObject(t *testing.T) { a := testApp(t) m := store.MealPlan{ID: "m1", Date: "2026-08-30", Meal: "dinner", Title: "Nudeln", Servings: 2, Source: "jarvis-command", CreatedAt: store.Now(), UpdatedAt: store.Now()} _ = a.db.Update(func(s *store.State) error { s.MealPlans = []store.MealPlan{m}; return nil }) a.recent = &recentMutation{Kind: "meal_plan", ID: "m1", At: time.Now()} reply, _, mode, handled := a.tryHandleRecentCorrection("nein, Montag den 31.08.") if !handled || mode != "action" || !strings.Contains(reply, "31.08.2026") { t.Fatalf("reply=%q mode=%s handled=%v", reply, mode, handled) } if got := a.db.Snapshot().MealPlans[0].Date; got != "2026-08-31" { t.Fatalf("date=%s", got) } } func TestMealFallbackUnderstandsMondayDinner(t *testing.T) { a := testApp(t) // Use an explicit date here for the action parser itself; weekday resolution is // covered separately with a fixed Thursday above. ac, ok := a.deterministicMealPlanFallback("31.08.2026 bitte Nudeln zum Abendessen eintragen") if !ok { t.Fatal("meal fallback not recognized") } if ac.Date != "2026-08-31" || ac.Meal != "dinner" || ac.RecipeTitle != "Nudeln" { t.Fatalf("action=%+v", ac) } } func TestMealSlotIsUpdatedInsteadOfDuplicated(t *testing.T) { a := testApp(t) first, ok := a.applyCommandAction(commandAction{Type: "add_meal_plan", Date: "2026-08-31", Meal: "dinner", RecipeTitle: "Pizza", Servings: 2, Confidence: 1}) if !ok || first.Type != "add_meal_plan" { t.Fatalf("first=%+v", first) } second, ok := a.applyCommandAction(commandAction{Type: "add_meal_plan", Date: "2026-08-31", Meal: "dinner", RecipeTitle: "Nudeln", Servings: 2, Confidence: 1}) if !ok || second.Type != "meal_updated" { t.Fatalf("second=%+v", second) } st := a.db.Snapshot() if len(st.MealPlans) != 1 || st.MealPlans[0].Title != "Nudeln" { t.Fatalf("meals=%+v", st.MealPlans) } } func TestMealFallbackLinksExistingRecipeFromNaturalText(t *testing.T) { a := testApp(t) _ = a.db.Update(func(s *store.State) error { s.Recipes = []store.Recipe{{ID: "r1", Title: "Nudeln mit Thomatensauce", Servings: 2, Ingredients: []store.Ingredient{{Name: "Nudeln", Quantity: "150", Unit: "Gramm"}}, CreatedAt: store.Now()}} return nil }) ac, ok := a.deterministicMealPlanFallback("31.08.2026 zum Abendessen Nudeln thomatensauce einplanen") if !ok { t.Fatal("fallback not recognized") } if ac.RecipeID != "r1" || ac.RecipeTitle != "Nudeln mit Thomatensauce" { t.Fatalf("action=%+v", ac) } } func TestMealPlanDeterministicFastPathPersistsAndBuildsGroceries(t *testing.T) { a := testApp(t) _ = a.db.Update(func(s *store.State) error { s.Recipes = []store.Recipe{{ ID: "r1", Title: "Nudeln mit Thomatensauce", Servings: 2, Ingredients: []store.Ingredient{{Name: "Nudeln", Quantity: "150", Unit: "Gramm"}, {Name: "Thomatensauce", Quantity: "50", Unit: "ml"}}, CreatedAt: store.Now(), }} return nil }) results, err := a.planAndApplyExplicitActions(context.Background(), "31.08.2026 bitte Nudeln thomatensauce zum Abendessen einplanen") if err != nil { t.Fatal(err) } if len(results) != 1 || results[0].Type != "add_meal_plan" { t.Fatalf("results=%+v", results) } st := a.db.Snapshot() if len(st.MealPlans) != 1 { t.Fatalf("meal plans=%+v", st.MealPlans) } if st.MealPlans[0].RecipeID != "r1" || st.MealPlans[0].Meal != "dinner" { t.Fatalf("meal=%+v", st.MealPlans[0]) } if len(st.Groceries) != 2 { t.Fatalf("groceries=%+v", st.Groceries) } } func TestNaturalMealWishFastPathPersistsWithoutLLM(t *testing.T) { a := testApp(t) results, err := a.planAndApplyExplicitActions(context.Background(), "30.08.2026 wären Pommes Currywurst toll. Also zu Mittag") if err != nil { t.Fatal(err) } if len(results) != 1 || results[0].Type != "add_meal_plan" { t.Fatalf("results=%+v", results) } st := a.db.Snapshot() if len(st.MealPlans) != 1 { t.Fatalf("meal plans=%+v", st.MealPlans) } m := st.MealPlans[0] if m.Date != "2026-08-30" || m.Meal != "lunch" || m.Title != "Pommes Currywurst" { t.Fatalf("meal=%+v", m) } }