5b7480ddaf
entityReferenceText returned the longest Latin run in the utterance, which is a guess dressed as a rule. "перезапусти nginx на muzick-indexer" holds two names, the target is not the longer one, and docs/ecosystem.md already says what to do instead: ambiguous resolution asks the owner, it does not pick. Nexus owns which names it knows. So entityReferences returns every Latin run, in the order he said them, capped at four so one utterance cannot fan out into a dozen HTTP calls. resolveEntityCandidates asks about each and stops as soon as the answer is decided: a Nexus failure ends it and reports degradation, Nexus calling one name ambiguous ends it with its candidates, and two names resolving to different entities is our own clarify listing the names Nexus spells. One resolving is the target, none resolving falls through as before. The transliteration signal is unchanged — the recovery still fires only when the utterance carries a Latin run and the model's Text slot carries none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
152 lines
5.3 KiB
Go
152 lines
5.3 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func userVersion(t *testing.T, s *Store) int {
|
||
t.Helper()
|
||
var v int
|
||
if err := s.db.QueryRowContext(context.Background(), "PRAGMA user_version").Scan(&v); err != nil {
|
||
t.Fatalf("read user_version: %v", err)
|
||
}
|
||
return v
|
||
}
|
||
|
||
func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
// The 1 migration in the built-in slice (tools scope) was applied on Open.
|
||
startVer := len(migrations)
|
||
if v := userVersion(t, s); v != startVer {
|
||
t.Fatalf("fresh DB user_version = %d, want %d", v, startVer)
|
||
}
|
||
|
||
// Append a fake migration and run it: creates a throwaway table, bumps by 1.
|
||
migrations = append(migrations, `CREATE TABLE migrate_probe (id INTEGER PRIMARY KEY)`)
|
||
t.Cleanup(func() { migrations = migrations[:len(migrations)-1] })
|
||
|
||
if err := migrate(ctx, s.db); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
want := startVer + 1
|
||
if v := userVersion(t, s); v != want {
|
||
t.Fatalf("after migrate user_version = %d, want %d", v, want)
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, "INSERT INTO migrate_probe DEFAULT VALUES"); err != nil {
|
||
t.Fatalf("probe table not created: %v", err)
|
||
}
|
||
|
||
// Second run is a no-op — re-running the CREATE would error (no IF NOT EXISTS).
|
||
if err := migrate(ctx, s.db); err != nil {
|
||
t.Fatalf("migrate second run not idempotent: %v", err)
|
||
}
|
||
if v := userVersion(t, s); v != want {
|
||
t.Fatalf("after re-migrate user_version = %d, want %d", v, want)
|
||
}
|
||
}
|
||
|
||
// Migration #18 clears the calendar keys written while safeKey dropped
|
||
// Cyrillic. Those rows are indistinguishable from real events on read, so
|
||
// leaving them would recite one meeting as several (Vikunja #443).
|
||
func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
|
||
rows := []string{
|
||
"calendar_event_20260804_--", // "Встреча с Аней" under the old rule
|
||
"calendar_event_20260804_", // a one-word Russian summary
|
||
"calendar_event_20260804_Встреча-с-Аней", // the new format
|
||
"calendar_event_20260804_Standup", // an ASCII summary, always fine
|
||
}
|
||
for _, key := range rows {
|
||
if _, err := s.db.ExecContext(ctx,
|
||
`INSERT INTO facts (ts, kind, key, value, source, confidence) VALUES (0, 'env', ?, 'x', 'poll:caldav', 1.0)`,
|
||
key); err != nil {
|
||
t.Fatalf("seed %q: %v", key, err)
|
||
}
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, migrations[17]); err != nil {
|
||
t.Fatalf("migration 18: %v", err)
|
||
}
|
||
|
||
var got int
|
||
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM facts WHERE key LIKE 'calendar_event_%'`).Scan(&got); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got != 2 {
|
||
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
|
||
}
|
||
}
|
||
|
||
// TestStuckRoutinesAreBackfilled — routines accepted before the fire-forever
|
||
// fix have accepted_ts NULL and a live reminder, so the tick loop skips them
|
||
// and they have been silent ever since (Vikunja #377). The migration touches
|
||
// live reminders, which is why it is tested against a real store.
|
||
func TestStuckRoutinesAreBackfilled(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := newTestStore(t)
|
||
created := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
|
||
|
||
rem, err := s.CreateReminder(ctx, created.Add(time.Hour), "полить цветы", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
healthy, err := s.CreateReminder(ctx, created.Add(2*time.Hour), "не трогать", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s.db.ExecContext(ctx,
|
||
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, reminder_id, accepted_ts)
|
||
VALUES ('water', 'plants', 7, 'accepted', ?, ?, NULL)`,
|
||
created.UnixMilli(), rem); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// An already-healthy accepted row, and a still-open proposal: neither is
|
||
// this migration's business.
|
||
if _, err := s.db.ExecContext(ctx,
|
||
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, accepted_ts)
|
||
VALUES ('feed', 'cat', 1, 'accepted', ?, ?)`,
|
||
created.UnixMilli(), created.UnixMilli()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// Index 19, version 20: standing lists landed on the same number first
|
||
// (Vikunja #453), so this one moved down one.
|
||
if _, err := s.db.ExecContext(ctx, migrations[19]); err != nil {
|
||
t.Fatalf("migration 20: %v", err)
|
||
}
|
||
|
||
accepted, err := s.ListAcceptedRoutines(ctx)
|
||
if err != nil || len(accepted) != 2 {
|
||
t.Fatalf("ListAcceptedRoutines = %d rows, err=%v, want 2", len(accepted), err)
|
||
}
|
||
stuck := accepted[0]
|
||
if stuck.Object != "plants" {
|
||
stuck = accepted[1]
|
||
}
|
||
if stuck.AcceptedTs == nil || !stuck.AcceptedTs.Equal(created) {
|
||
t.Fatalf("accepted_ts = %v, want the creation time", stuck.AcceptedTs)
|
||
}
|
||
if stuck.ReminderID != nil {
|
||
t.Fatalf("reminder_id = %v, want it let go", stuck.ReminderID)
|
||
}
|
||
// The reminder it was holding is cancelled, and nothing else is.
|
||
var status string
|
||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, rem).Scan(&status); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if status != ReminderCancelled {
|
||
t.Fatalf("linked reminder status = %q, want cancelled", status)
|
||
}
|
||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, healthy).Scan(&status); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if status != "pending" {
|
||
t.Fatalf("unrelated reminder status = %q, want it untouched", status)
|
||
}
|
||
}
|