package main import ( "fmt" "sync" "testing" ) // 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() }