Files
jarvis-home/cmd/homehub/command_control_test.go
jbergner 112c13f9f0
release-tag / release-image (push) Successful in 3m44s
init
2026-08-28 11:29:58 +02:00

373 lines
14 KiB
Go

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)
}
}