Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions pkg/simpleradio/frame_test.go
Original file line number Diff line number Diff line change
@@ -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()))
}
61 changes: 61 additions & 0 deletions pkg/simpleradio/frequency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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"))
}
4 changes: 2 additions & 2 deletions pkg/simpleradio/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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")
Expand Down
134 changes: 134 additions & 0 deletions pkg/simpleradio/message_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading
Loading