package gridcache import ( "path/filepath" "testing" "time" ) func open(t *testing.T) (*Store, string) { t.Helper() path := filepath.Join(t.TempDir(), "grids.db") s, err := Open(path, nil) if err != nil { t.Fatalf("open: %v", err) } return s, path } // The whole reason the store exists: what was learnt is still there after a // restart, so the cluster's locator column is full in the first second instead // of after an hour of listening. func TestSurvivesRestart(t *testing.T) { s, path := open(t) s.Put("F4BPO", "JN36", SourceDecode) s.Put("OH5CX", "KP30", SourceDecode) if err := s.Flush(); err != nil { t.Fatalf("flush: %v", err) } if err := s.Close(); err != nil { t.Fatalf("close: %v", err) } again, err := Open(path, nil) if err != nil { t.Fatalf("reopen: %v", err) } defer again.Close() got, err := again.LoadAll() if err != nil { t.Fatalf("load: %v", err) } if got["F4BPO"] != "JN36" || got["OH5CX"] != "KP30" { t.Errorf("locators lost across a restart: %v", got) } } // A callsign has ONE grid and the newest report wins. Operators move, go // portable, go on expedition — and a stale locator is worse than none for grid // chasing, because it reads as a square already worked. func TestNewestReportWins(t *testing.T) { s, _ := open(t) defer s.Close() s.Put("F4BPO", "JN36", SourceDecode) if err := s.Flush(); err != nil { t.Fatal(err) } s.Put("F4BPO", "KP30", SourceDecode) // moved if err := s.Flush(); err != nil { t.Fatal(err) } got, err := s.LoadAll() if err != nil { t.Fatal(err) } if got["F4BPO"] != "KP30" { t.Errorf("grid = %q, want the newer KP30", got["F4BPO"]) } if len(got) != 1 { t.Errorf("a callsign must hold one row, got %d: %v", len(got), got) } } // Nothing reaches the disk until a flush, and a flush with nothing pending is // not an error — that is most minutes on a quiet band. func TestBatching(t *testing.T) { s, _ := open(t) defer s.Close() for _, c := range []string{"A1AA", "B2BB", "C3CC"} { s.Put(c, "JN36", SourceDecode) } if n := s.Pending(); n != 3 { t.Errorf("pending = %d, want 3 queued and unwritten", n) } if got, _ := s.LoadAll(); len(got) != 0 { t.Errorf("wrote before the flush: %v", got) } if err := s.Flush(); err != nil { t.Fatal(err) } if n := s.Pending(); n != 0 { t.Errorf("pending = %d after a flush, want 0", n) } if got, _ := s.LoadAll(); len(got) != 3 { t.Errorf("flush wrote %d rows, want 3", len(got)) } if err := s.Flush(); err != nil { t.Errorf("empty flush must be a no-op, got %v", err) } } // Age is what bounds a store that would otherwise only grow. A callsign not // heard in two years is likely reassigned, and carrying its previous holder's // square is the one way this cache can be actively wrong rather than empty. func TestPruneOnOpen(t *testing.T) { s, path := open(t) s.Put("FRESH", "JN36", SourceDecode) s.Put("STALE", "IO91", SourceDecode) if err := s.Flush(); err != nil { t.Fatal(err) } // Backdate one row past the retention window. old := time.Now().Add(-Retention - 24*time.Hour).Unix() if _, err := s.db.Exec(`UPDATE grids SET updated_at = ? WHERE call = 'STALE'`, old); err != nil { t.Fatal(err) } s.Close() again, err := Open(path, nil) if err != nil { t.Fatal(err) } defer again.Close() got, _ := again.LoadAll() if _, ok := got["STALE"]; ok { t.Error("an entry past the retention window survived — the store is unbounded") } if got["FRESH"] != "JN36" { t.Error("pruning took a live entry with it") } } // Close has to write what the last minute learnt: a restart is exactly when the // cache is worth the most, so losing it to a clean shutdown would be a poor // trade. func TestCloseFlushes(t *testing.T) { s, path := open(t) s.Put("LATE", "JN36", SourceDecode) if err := s.Close(); err != nil { t.Fatalf("close: %v", err) } again, err := Open(path, nil) if err != nil { t.Fatal(err) } defer again.Close() got, _ := again.LoadAll() if got["LATE"] != "JN36" { t.Error("what was pending at shutdown was dropped") } }