package main import ( "fmt" "path/filepath" "sync" "testing" "hamlog/internal/gridcache" ) // The grid cache used to drop EVERYTHING at its ceiling. That was survivable // while the only feed was this station's own decodes, which never reached it. // Fed by anything larger it is a cliff: every locator in the cluster list // disappears at once, periodically, and the operator sees the column empty // itself for no reason. // // Rotation keeps the previous generation, so a full cache costs the older half // and nothing more. func TestDecodeGridRotationKeepsThePreviousGeneration(t *testing.T) { a := &App{} // Fill exactly one generation. for i := 0; i < decodeGridsCap; i++ { a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36") } if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" { t.Fatalf("first entry lost before any rotation: %q", got) } if a.decodeGridsOld != nil { t.Fatal("rotated early — the cap is the ceiling of ONE generation") } // One more entry rotates. a.rememberDecodeGrid("NEWCALL", "IO91") if a.decodeGridsOld == nil { t.Fatal("did not rotate at the cap") } if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" { t.Errorf("the entry that caused the rotation was lost: %q", got) } // The whole previous generation is still readable — this is the point. if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" { t.Errorf("a locator from the previous generation was dropped: %q — that is the cliff again", got) } if got := a.lookupDecodeGrid("CALL099999"); got != "JN36" { t.Errorf("previous generation incomplete: %q", got) } // A second rotation is what finally retires the oldest half. for i := 0; i < decodeGridsCap; i++ { a.rememberDecodeGrid(fmt.Sprintf("SECOND%06d", i), "KP20") } if got := a.lookupDecodeGrid("CALL000000"); got != "" { t.Errorf("the cache is unbounded: %q survived two rotations", got) } if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" { t.Errorf("an entry one generation old was retired too early: %q", got) } } // Callsigns are normalised on the way in AND on the way out, or a spot for // "f4bpo" would miss a grid learnt as "F4BPO". func TestDecodeGridCaseAndBlanks(t *testing.T) { a := &App{} a.rememberDecodeGrid(" f4bpo ", "JN36") if got := a.lookupDecodeGrid("F4BPO"); got != "JN36" { t.Errorf("lookup of the upper-case form failed: %q", got) } a.rememberDecodeGrid("", "JN36") a.rememberDecodeGrid("K1ABC", "") if got := a.lookupDecodeGrid("K1ABC"); got != "" { t.Errorf("stored an empty grid: %q", got) } } // The write path runs on the decode goroutine while the cluster status builder // reads. Rotation swaps the map headers, so an unguarded read is a data race // rather than a stale value. // // This build is CGO-free, so -race is not available here; the test exercises the // interleaving and would fault on a concurrent map access even without it. func TestDecodeGridConcurrentAccess(t *testing.T) { a := &App{} var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() for i := 0; i < 20000; i++ { a.rememberDecodeGrid(fmt.Sprintf("W%05d", i), "FN31") } }() go func() { defer wg.Done() for i := 0; i < 20000; i++ { _ = a.lookupDecodeGrid(fmt.Sprintf("W%05d", i)) } }() wg.Wait() } // Only a CHANGE may reach the write batch. // // The feeds repeat themselves — the same station is reported by dozens of // receivers a minute — so queueing every report would put the whole stream in // the batch instead of the news in it, and turn a cache into a write amplifier. func TestOnlyChangesAreQueued(t *testing.T) { st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil) if err != nil { t.Fatal(err) } defer st.Close() a := &App{gridStore: st} a.rememberDecodeGrid("F4BPO", "JN36") if n := st.Pending(); n != 1 { t.Fatalf("a new locator queued %d writes, want 1", n) } if err := st.Flush(); err != nil { t.Fatal(err) } // The same report, a hundred times over, is not news. for i := 0; i < 100; i++ { a.rememberDecodeGrid("F4BPO", "JN36") } if n := st.Pending(); n != 0 { t.Errorf("unchanged reports queued %d writes — the batch would carry the whole feed", n) } // A station that moved is. a.rememberDecodeGrid("F4BPO", "KP30") if n := st.Pending(); n != 1 { t.Errorf("a changed locator queued %d writes, want 1", n) } if got := a.lookupDecodeGrid("F4BPO"); got != "KP30" { t.Errorf("the map kept the old locator: %q", got) } } // Rotation must be OFF while a store is attached: it would discard callsigns the // database still holds, and the lookup would then miss something we know. func TestNoRotationWhilePersisting(t *testing.T) { st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil) if err != nil { t.Fatal(err) } defer st.Close() a := &App{gridStore: st} for i := 0; i < decodeGridsCap+10; i++ { a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36") } if a.decodeGridsOld != nil { t.Error("rotated while a store was attached — locators the database holds would go missing") } if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" { t.Errorf("the first entry was dropped: %q", got) } }