diff --git a/pkg/simpleradio/frame_test.go b/pkg/simpleradio/frame_test.go new file mode 100644 index 00000000..7487589c --- /dev/null +++ b/pkg/simpleradio/frame_test.go @@ -0,0 +1,75 @@ +package simpleradio + +import ( + "math" + "testing" + + "github.com/dharmab/skyeye/pkg/pcm/rate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/hraban/opus.v2" +) + +// testTone generates a frame of audio loud enough to survive a lossy codec round trip. +func testTone(samples int) []float32 { + tone := make([]float32, samples) + for i := range tone { + tone[i] = 0.5 * float32(math.Sin(2*math.Pi*440*float64(i)/rate.Wideband.Hertz())) + } + return tone +} + +func TestFrameRoundTrip(t *testing.T) { + t.Parallel() + encoder, err := opus.NewEncoder(int(rate.Wideband.Hertz()), channels, opusApplicationVoIP) + require.NoError(t, err) + decoder, err := opus.NewDecoder(int(rate.Wideband.Hertz()), channels) + require.NoError(t, err) + + original := testTone(int(frameSize)) + encoded, err := encodeFrame(encoder, original) + require.NoError(t, err) + assert.NotEmpty(t, encoded) + assert.Less(t, len(encoded), encodingBufferSize) + + decoded, err := decodeFrame(decoder, encoded) + require.NoError(t, err) + assert.Len(t, decoded, int(frameSize), "a decoded frame is always a whole 40ms frame") + + // Opus is lossy, so compare energy rather than samples. + var energy float64 + for _, sample := range decoded { + energy += float64(sample) * float64(sample) + } + assert.Positive(t, energy, "decoded frame should carry the tone, not silence") +} + +func TestDecodeFrameRejectsMalformedAudio(t *testing.T) { + t.Parallel() + decoder, err := opus.NewDecoder(int(rate.Wideband.Hertz()), channels) + require.NoError(t, err) + + tests := []struct { + name string + audio []byte + }{ + {name: "empty", audio: []byte{}}, + {name: "garbage", audio: []byte{0xDE, 0xAD, 0xBE, 0xEF}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, decodeErr := decodeFrame(decoder, test.audio) + assert.Error(t, decodeErr) + }) + } +} + +// frameSize is what ties the 40ms SRS frame to the wideband sample rate. If it drifts, every +// transmission's duration accounting drifts with it. +func TestFrameSize(t *testing.T) { + t.Parallel() + assert.Equal(t, int64(640), frameSize, "40ms of 16kHz mono audio") + assert.Equal(t, frameSize, frameLength.Milliseconds()*int64(rate.Wideband.Kilohertz())) +} diff --git a/pkg/simpleradio/frequency_test.go b/pkg/simpleradio/frequency_test.go index 9eb4b86f..f622f885 100644 --- a/pkg/simpleradio/frequency_test.go +++ b/pkg/simpleradio/frequency_test.go @@ -3,6 +3,7 @@ package simpleradio import ( "testing" + "github.com/dharmab/skyeye/pkg/coalitions" "github.com/dharmab/skyeye/pkg/simpleradio/types" "github.com/martinlindhe/unit" "github.com/stretchr/testify/assert" @@ -51,6 +52,8 @@ func TestParseFrequency(t *testing.T) { } } +// Regression: the format verb was transposed ("%f.3%s"), and the frequency is stored in hertz, so +// 251MHz AM rendered as "251000000.000000.3AM". func TestRadioFrequencyString(t *testing.T) { t.Parallel() tests := []struct { @@ -76,3 +79,61 @@ func TestRadioFrequencyString(t *testing.T) { }) } } + +func TestRadioFrequencyIsSameFrequency(t *testing.T) { + t.Parallel() + uhf := RadioFrequency{251 * unit.Megahertz, types.ModulationAM} + + assert.True(t, uhf.IsSameFrequency(RadioFrequency{251 * unit.Megahertz, types.ModulationAM})) + assert.False(t, uhf.IsSameFrequency(RadioFrequency{251 * unit.Megahertz, types.ModulationFM})) + assert.False(t, uhf.IsSameFrequency(RadioFrequency{133 * unit.Megahertz, types.ModulationAM})) +} + +func TestClientFrequencies(t *testing.T) { + t.Parallel() + c := &Client{ + clientInfo: types.ClientInfo{ + RadioInfo: types.RadioInfo{Radios: []types.Radio{testRadio, testOtherRadio}}, + }, + } + + frequencies := c.Frequencies() + + require.Len(t, frequencies, 2) + assert.InDelta(t, 251.0, frequencies[0].Frequency.Megahertz(), 0.001) + assert.Equal(t, types.Modulation(types.ModulationAM), frequencies[0].Modulation) + assert.InDelta(t, 133.0, frequencies[1].Frequency.Megahertz(), 0.001) +} + +// The on-frequency counts gate whether the bot broadcasts at all, and the human/bot split keeps it +// from talking to an audience of other bots. +func TestClientsOnFrequency(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + c.syncClients([]types.ClientInfo{ + testPeer("peer000000000000000001", "Eagle 1", coalitions.Blue, testRadio), + testPeer("peer000000000000000002", "Eagle 2", coalitions.Blue, testRadio), + testPeer("peer000000000000000003", "GCI Sniper [BOT]", coalitions.Blue, testRadio), + testPeer("peer000000000000000004", "Eagle 3", coalitions.Blue, testOtherRadio), + testPeer("peer000000000000000005", "Bandit 1", coalitions.Red, testRadio), + }) + + assert.Equal(t, 3, c.ClientsOnFrequency(), "off-frequency and opposing coalition peers excluded") + assert.Equal(t, 2, c.HumansOnFrequency()) + assert.Equal(t, 1, c.BotsOnFrequency()) + + assert.True(t, c.IsOnFrequency("Eagle 1")) + assert.False(t, c.IsOnFrequency("Eagle 3"), "tuned to a different frequency") + assert.False(t, c.IsOnFrequency("Bandit 1"), "opposing coalition is never tracked") + assert.False(t, c.IsOnFrequency("Nobody")) +} + +func TestClientsOnFrequencyWhenEmpty(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + + assert.Equal(t, 0, c.ClientsOnFrequency()) + assert.Equal(t, 0, c.HumansOnFrequency()) + assert.Equal(t, 0, c.BotsOnFrequency()) + assert.False(t, c.IsOnFrequency("Eagle 1")) +} diff --git a/pkg/simpleradio/message.go b/pkg/simpleradio/message.go index 25d0db19..6169807f 100644 --- a/pkg/simpleradio/message.go +++ b/pkg/simpleradio/message.go @@ -77,7 +77,7 @@ func (c *Client) handleMessage(message types.Message) { func (c *Client) updateServerSettings(message types.Message) { log.Debug().Any("serverSettings", message.ServerSettings).Msg("received server settings") if enabled, ok := message.ServerSettings[string(types.CoalitionAudioSecurity)]; ok { - if strings.ToLower(enabled) == "true" { + if strings.ToLower(enabled) == "true" { //nolint:goconst // common boolean value if !c.secureCoalitionRadios.Load() { log.Info().Msg("enabling secure coalition radios") } @@ -88,7 +88,7 @@ func (c *Client) updateServerSettings(message types.Message) { } } if enabled, ok := message.ServerSettings[string(types.ExternalAWACSMode)]; ok { - if strings.ToLower(enabled) == "true" { + if strings.ToLower(enabled) == "true" { //nolint:goconst // common boolean value log.Debug().Msg("SRS server has enabled external AWACS mode") } else { log.Error().Msg("unable to receive or transmit: SRS server has disabled external AWACS mode") diff --git a/pkg/simpleradio/message_test.go b/pkg/simpleradio/message_test.go new file mode 100644 index 00000000..0c464f1d --- /dev/null +++ b/pkg/simpleradio/message_test.go @@ -0,0 +1,134 @@ +package simpleradio + +import ( + "testing" + + "github.com/dharmab/skyeye/pkg/coalitions" + "github.com/dharmab/skyeye/pkg/simpleradio/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// SRS servers are inconsistent about the case of boolean settings - a live server sends both +// "true" and "True" in the same payload. +func TestUpdateServerSettings(t *testing.T) { + t.Parallel() + tests := []struct { + name string + settings map[string]string + initial bool + expected bool + }{ + { + name: "enabled lowercase", + settings: map[string]string{string(types.CoalitionAudioSecurity): "true"}, + expected: true, + }, + { + name: "enabled titlecase", + settings: map[string]string{string(types.CoalitionAudioSecurity): "True"}, + expected: true, + }, + { + name: "disabled", + settings: map[string]string{string(types.CoalitionAudioSecurity): "false"}, + initial: true, + expected: false, + }, + { + name: "disabled titlecase", + settings: map[string]string{string(types.CoalitionAudioSecurity): "False"}, + initial: true, + expected: false, + }, + { + name: "absent leaves the setting alone", + settings: map[string]string{string(types.ExternalAWACSMode): "True"}, + initial: true, + expected: true, + }, + { + name: "no settings at all", + settings: nil, + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + c := &Client{} + c.secureCoalitionRadios.Store(test.initial) + + c.updateServerSettings(types.Message{ServerSettings: test.settings}) + + assert.Equal(t, test.expected, c.secureCoalitionRadios.Load()) + }) + } +} + +func TestHandleMessage(t *testing.T) { + t.Parallel() + peer := testPeer("peer000000000000000001", "Eagle 1", coalitions.Blue, testRadio) + + t.Run("sync stores matching clients", func(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + c.handleMessage(types.Message{Type: types.MessageSync, Clients: []types.ClientInfo{peer}}) + assert.Contains(t, c.clients, peer.GUID) + }) + + t.Run("update stores a client", func(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + c.handleMessage(types.Message{Type: types.MessageUpdate, Client: &peer}) + assert.Contains(t, c.clients, peer.GUID) + }) + + t.Run("radio update stores a client", func(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + c.handleMessage(types.Message{Type: types.MessageRadioUpdate, Client: &peer}) + assert.Contains(t, c.clients, peer.GUID) + }) + + t.Run("disconnect removes a client", func(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + c.syncClient(peer) + require.Contains(t, c.clients, peer.GUID) + + c.handleMessage(types.Message{Type: types.MessageClientDisconnect, Client: &peer}) + assert.NotContains(t, c.clients, peer.GUID) + }) + + t.Run("server settings are applied", func(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + c.handleMessage(types.Message{ + Type: types.MessageServerSettings, + ServerSettings: map[string]string{string(types.CoalitionAudioSecurity): "true"}, + }) + assert.True(t, c.secureCoalitionRadios.Load()) + }) + + // Messages that carry no client must not panic. These arrive in practice - a ping message has + // no Client field at all. + t.Run("messages without a client are ignored", func(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + for _, messageType := range []types.MessageType{ + types.MessagePing, + types.MessageUpdate, + types.MessageRadioUpdate, + types.MessageClientDisconnect, + types.MessageVersionMismatch, + types.MessageExternalAWACSModeDisconnect, + types.MessageExternalAWACSModePassword, + types.MessageType(999), + } { + c.handleMessage(types.Message{Type: messageType}) + } + assert.Empty(t, c.clients) + }) +} diff --git a/pkg/simpleradio/sync_test.go b/pkg/simpleradio/sync_test.go new file mode 100644 index 00000000..47e0e36a --- /dev/null +++ b/pkg/simpleradio/sync_test.go @@ -0,0 +1,133 @@ +package simpleradio + +import ( + "testing" + + "github.com/dharmab/skyeye/pkg/coalitions" + "github.com/dharmab/skyeye/pkg/simpleradio/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testSelfGUID = types.GUID("SelfGUID00000000000000") + +var ( + testRadio = types.Radio{Frequency: 251_000_000, Modulation: types.ModulationAM} + testOtherRadio = types.Radio{Frequency: 133_000_000, Modulation: types.ModulationAM} +) + +// newSyncTestClient builds a blue client listening on 251.000AM, without touching the network. +func newSyncTestClient() *Client { + return &Client{ + clientInfo: types.ClientInfo{ + GUID: testSelfGUID, + Name: "GCI Test [BOT]", + Coalition: coalitions.Blue, + RadioInfo: types.RadioInfo{Radios: []types.Radio{testRadio}}, + }, + clients: make(map[types.GUID]types.ClientInfo), + } +} + +func testPeer(guid types.GUID, name string, coalition coalitions.Coalition, radios ...types.Radio) types.ClientInfo { + return types.ClientInfo{ + GUID: guid, + Name: name, + Coalition: coalition, + RadioInfo: types.RadioInfo{Radios: radios}, + } +} + +func TestSyncClient(t *testing.T) { + t.Parallel() + tests := []struct { + name string + peer types.ClientInfo + expected bool + }{ + { + name: "same coalition on frequency", + peer: testPeer("peer00000000000000000a", "Eagle 1", coalitions.Blue, testRadio), + expected: true, + }, + { + name: "opposing coalition", + peer: testPeer("peer00000000000000000b", "Bandit 1", coalitions.Red, testRadio), + expected: false, + }, + { + // Spectators are admitted regardless of coalition so that they can talk to the GCI. + name: "spectator", + peer: testPeer("peer00000000000000000c", "Spectator", coalitions.Neutrals, testRadio), + expected: true, + }, + { + name: "same coalition off frequency", + peer: testPeer("peer00000000000000000d", "Eagle 2", coalitions.Blue, testOtherRadio), + expected: false, + }, + { + // Observed on a live server: ATIS clients appear with no radios at all. + name: "no radios", + peer: testPeer("peer00000000000000000e", "ATIS Incirlik", coalitions.Blue), + expected: false, + }, + { + name: "self", + peer: testPeer(testSelfGUID, "GCI Test [BOT]", coalitions.Blue, testRadio), + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + c.syncClient(test.peer) + + _, ok := c.clients[test.peer.GUID] + assert.Equal(t, test.expected, ok) + }) + } +} + +// A peer that retunes away from our frequency must stop being tracked, otherwise it would keep +// counting towards the on-frequency totals that gate broadcasts. +func TestSyncClientForgetsPeerThatRetunes(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + guid := types.GUID("peer000000000000000001") + + c.syncClient(testPeer(guid, "Eagle 1", coalitions.Blue, testRadio)) + require.Contains(t, c.clients, guid) + + c.syncClient(testPeer(guid, "Eagle 1", coalitions.Blue, testOtherRadio)) + assert.NotContains(t, c.clients, guid) +} + +func TestSyncClients(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + c.syncClients([]types.ClientInfo{ + testPeer("peer000000000000000001", "Eagle 1", coalitions.Blue, testRadio), + testPeer("peer000000000000000002", "Bandit 1", coalitions.Red, testRadio), + testPeer("peer000000000000000003", "Eagle 2", coalitions.Blue, testRadio), + }) + + assert.Len(t, c.clients, 2) +} + +func TestRemoveClient(t *testing.T) { + t.Parallel() + c := newSyncTestClient() + peer := testPeer("peer000000000000000001", "Eagle 1", coalitions.Blue, testRadio) + + c.syncClient(peer) + require.Contains(t, c.clients, peer.GUID) + + c.removeClient(peer) + assert.NotContains(t, c.clients, peer.GUID) + + // Removing a peer that was never tracked is harmless. + c.removeClient(testPeer("peer000000000000000009", "Ghost", coalitions.Blue, testRadio)) +} diff --git a/pkg/simpleradio/types/info_test.go b/pkg/simpleradio/types/info_test.go new file mode 100644 index 00000000..294463a1 --- /dev/null +++ b/pkg/simpleradio/types/info_test.go @@ -0,0 +1,66 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRadioInfoIsOnFrequency(t *testing.T) { + t.Parallel() + var ( + uhf = Radio{Frequency: 251_000_000, Modulation: ModulationAM} + vhf = Radio{Frequency: 133_000_000, Modulation: ModulationAM} + fm = Radio{Frequency: 30_000_000, Modulation: ModulationFM} + guardVHF = Radio{Frequency: 121_500_000, Modulation: ModulationAM} + ) + + tests := []struct { + name string + this []Radio + other []Radio + expected bool + }{ + { + name: "single shared frequency", + this: []Radio{uhf}, + other: []Radio{uhf}, + expected: true, + }, + { + name: "matches on any radio in the inventory", + this: []Radio{uhf, vhf, fm}, + other: []Radio{guardVHF, fm}, + expected: true, + }, + { + name: "no shared frequency", + this: []Radio{uhf, vhf}, + other: []Radio{fm, guardVHF}, + expected: false, + }, + { + // Observed on a live server: ATIS clients appear with no radios at all. + name: "other has no radios", + this: []Radio{uhf}, + other: nil, + expected: false, + }, + { + name: "this has no radios", + this: nil, + other: []Radio{uhf}, + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + this := RadioInfo{Radios: test.this} + other := RadioInfo{Radios: test.other} + assert.Equal(t, test.expected, this.IsOnFrequency(other)) + assert.Equal(t, test.expected, other.IsOnFrequency(this), "must be symmetric") + }) + } +} diff --git a/pkg/simpleradio/types/message_test.go b/pkg/simpleradio/types/message_test.go new file mode 100644 index 00000000..b264af08 --- /dev/null +++ b/pkg/simpleradio/types/message_test.go @@ -0,0 +1,143 @@ +package types + +import ( + "encoding/json" + "testing" + + "github.com/dharmab/skyeye/pkg/coalitions" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// syncFixture is a sync message captured from a live SRS server, with client names and GUIDs +// replaced. It carries fields SkyEye does not model (Model, Name, Gateway, DISEntityId) on purpose, +// so that the test fails if unknown fields ever stop being tolerated. +const syncFixture = `{ + "Version": "2.1.0.2", + "Clients": [ + { + "ClientGuid": "0000000000000000000000", + "Name": "Example Client", + "Coalition": 2, + "AllowRecord": true, + "Seat": 0, + "RadioInfo": { + "ambient": {"abType": "", "vol": 1}, + "iff": {"control": 2, "mic": -1, "mode1": -1, "mode2": -1, "mode3": -1, "mode4": false, "status": 0}, + "radios": [ + {"Model": "", "Name": "", "enc": false, "encKey": 0, "freq": 136000000, "modulation": 0, "retransmit": true, "secFreq": 0}, + {"Model": "", "Name": "", "enc": false, "encKey": 0, "freq": 255000000, "modulation": 0, "retransmit": true, "secFreq": 0} + ], + "unit": "External AWACS", + "unitId": 100000002 + }, + "LatLngPosition": {"alt": 0, "lat": 0, "lng": 0}, + "Gateway": false, + "DISEntityId": -1 + }, + { + "ClientGuid": "1111111111111111111111", + "Name": "ATIS Example", + "Coalition": 2, + "AllowRecord": true, + "Seat": 0 + } + ], + "MsgType": 2 +}` + +func TestUnmarshalSyncMessage(t *testing.T) { + t.Parallel() + var message Message + require.NoError(t, json.Unmarshal([]byte(syncFixture), &message)) + + assert.Equal(t, "2.1.0.2", message.Version) + assert.Equal(t, MessageSync, message.Type) + require.Len(t, message.Clients, 2) + + client := message.Clients[0] + assert.Equal(t, GUID("0000000000000000000000"), client.GUID) + assert.Equal(t, "Example Client", client.Name) + assert.Equal(t, coalitions.Coalition(coalitions.Blue), client.Coalition) + assert.True(t, client.AllowRecording) + assert.Equal(t, "External AWACS", client.RadioInfo.Unit) + assert.Equal(t, uint64(100000002), client.RadioInfo.UnitID) + require.Len(t, client.RadioInfo.Radios, 2) + assert.InDelta(t, 136_000_000.0, client.RadioInfo.Radios[0].Frequency, 0.5) + assert.True(t, client.RadioInfo.Radios[0].ShouldRetransmit) + require.NotNil(t, client.Position) + + // A client with no RadioInfo at all is normal - ATIS clients appear this way. + assert.Empty(t, message.Clients[1].RadioInfo.Radios) +} + +func TestMessageRoundTrip(t *testing.T) { + t.Parallel() + tests := []struct { + name string + message Message + }{ + { + name: "ping carries only a version and type", + message: Message{Version: "2.1.0.2", Type: MessagePing}, + }, + { + name: "update carries one client", + message: Message{ + Version: "2.1.0.2", + Type: MessageUpdate, + Client: &ClientInfo{ + GUID: "0000000000000000000000", + Name: "Example Client", + Coalition: coalitions.Blue, + RadioInfo: RadioInfo{ + Radios: []Radio{{Frequency: 251_000_000, Modulation: ModulationAM}}, + Unit: "External AWACS", + UnitID: 100000002, + IFF: NewIFF(), + Ambient: NewAmbient(), + }, + Position: &Position{}, + }, + }, + }, + { + name: "external AWACS mode password", + message: Message{ + Version: "2.1.0.2", + Type: MessageExternalAWACSModePassword, + ExternalAWACSModePassword: "hunter2", + }, + }, + { + name: "server settings", + message: Message{ + Version: "2.1.0.2", + Type: MessageServerSettings, + ServerSettings: map[string]string{string(CoalitionAudioSecurity): "false"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + encoded, err := json.Marshal(test.message) + require.NoError(t, err) + + var decoded Message + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, test.message, decoded) + }) + } +} + +// Optional fields must stay absent rather than appearing as nulls, because the SRS server is +// strict about the messages it accepts. +func TestMessageOmitsEmptyFields(t *testing.T) { + t.Parallel() + encoded, err := json.Marshal(Message{Version: "2.1.0.2", Type: MessagePing}) + require.NoError(t, err) + + assert.JSONEq(t, `{"Version":"2.1.0.2","MsgType":1}`, string(encoded)) +} diff --git a/pkg/simpleradio/types/radio_test.go b/pkg/simpleradio/types/radio_test.go new file mode 100644 index 00000000..a7bfec18 --- /dev/null +++ b/pkg/simpleradio/types/radio_test.go @@ -0,0 +1,101 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRadioIsSameFrequency(t *testing.T) { + t.Parallel() + tests := []struct { + name string + this Radio + other Radio + expected bool + }{ + { + name: "identical", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM}, + other: Radio{Frequency: 251_000_000, Modulation: ModulationAM}, + expected: true, + }, + { + // SRS clients report frequencies with a little slop, so a tolerance is intended. + name: "within tolerance", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM}, + other: Radio{Frequency: 251_000_499, Modulation: ModulationAM}, + expected: true, + }, + { + name: "exactly at tolerance", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM}, + other: Radio{Frequency: 251_000_500, Modulation: ModulationAM}, + expected: true, + }, + { + name: "just outside tolerance", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM}, + other: Radio{Frequency: 251_000_501, Modulation: ModulationAM}, + expected: false, + }, + { + name: "tolerance applies below too", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM}, + other: Radio{Frequency: 250_999_500, Modulation: ModulationAM}, + expected: true, + }, + { + name: "different frequency", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM}, + other: Radio{Frequency: 133_000_000, Modulation: ModulationAM}, + expected: false, + }, + { + name: "same frequency different modulation", + this: Radio{Frequency: 30_000_000, Modulation: ModulationAM}, + other: Radio{Frequency: 30_000_000, Modulation: ModulationFM}, + expected: false, + }, + { + name: "both encrypted with the same key", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM, IsEncrypted: true, EncryptionKey: 3}, + other: Radio{Frequency: 251_000_000, Modulation: ModulationAM, IsEncrypted: true, EncryptionKey: 3}, + expected: true, + }, + { + name: "both encrypted with different keys", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM, IsEncrypted: true, EncryptionKey: 3}, + other: Radio{Frequency: 251_000_000, Modulation: ModulationAM, IsEncrypted: true, EncryptionKey: 4}, + expected: false, + }, + { + name: "only one encrypted", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM, IsEncrypted: true, EncryptionKey: 3}, + other: Radio{Frequency: 251_000_000, Modulation: ModulationAM}, + expected: false, + }, + { + // The key is only meaningful when encryption is on. + name: "neither encrypted but keys differ", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM, EncryptionKey: 3}, + other: Radio{Frequency: 251_000_000, Modulation: ModulationAM, EncryptionKey: 4}, + expected: true, + }, + { + // Only the primary frequency is matched. A guard frequency is not received. + name: "guard frequency is not matched", + this: Radio{Frequency: 251_000_000, Modulation: ModulationAM}, + other: Radio{Frequency: 133_000_000, Modulation: ModulationAM, GuardFrequency: 251_000_000}, + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, test.expected, test.this.IsSameFrequency(test.other)) + assert.Equal(t, test.expected, test.other.IsSameFrequency(test.this), "must be symmetric") + }) + } +}