using TravelEar.Core;
using Xunit;
namespace TravelEar.Tests;
public class DissonanceFrameTests
{
/// Big-endian packet builder mirroring Dissonance's PacketWriter, per the documented protocol.
private sealed class PacketBuilder
{
private readonly List _bytes = new();
public PacketBuilder U8(byte b) { _bytes.Add(b); return this; }
public PacketBuilder U16(ushort v) { _bytes.Add((byte)(v >> 8)); _bytes.Add((byte)v); return this; }
public PacketBuilder U32(uint v) { U16((ushort)(v >> 16)); U16((ushort)v); return this; }
public PacketBuilder Bytes(ReadOnlySpan b) { _bytes.AddRange(b.ToArray()); return this; }
public byte[] Build() => _bytes.ToArray();
}
private static readonly byte[] Opus = { 0xFC, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09 };
private static byte[] VoiceDataPacket(
uint session = 0xDEADBEEF, ushort sender = 7, byte flags = 0x80 | 5, ushort sequence = 1234,
(ushort bitfield, ushort id)[]? channels = null, byte[]? payload = null)
{
channels ??= new (ushort, ushort)[] { (0x0001, 0xABCD), (0x0100, 0x0042) };
payload ??= Opus;
var b = new PacketBuilder()
.U16(DissonanceFrame.Magic)
.U8((byte)DissonanceMessageType.VoiceData)
.U32(session)
.U16(sender)
.U8(flags)
.U16(sequence)
.U16((ushort)channels.Length);
foreach (var (bitfield, id) in channels) b.U16(bitfield).U16(id);
b.U16((ushort)payload.Length).Bytes(payload);
return b.Build();
}
// [unit->REQ-VOICE-OUTBOUND-TAP]
[Fact]
public void Parses_hand_built_voice_data_packet_per_documented_protocol()
{
var packet = VoiceDataPacket();
Assert.True(DissonanceFrame.TryParse(packet, out var frame, out var error));
Assert.Equal(DissonanceFrameError.None, error);
Assert.Equal(0xDEADBEEFu, frame.SessionId);
Assert.Equal((ushort)7, frame.SenderId);
Assert.Equal((byte)5, frame.ChannelSession);
Assert.Equal((ushort)1234, frame.Sequence);
Assert.Equal(2, frame.Channels.Count);
Assert.Equal(new DissonanceChannel(0x0001, 0xABCD), frame.Channels[0]);
Assert.Equal(new DissonanceChannel(0x0100, 0x0042), frame.Channels[1]);
Assert.Equal(Opus, frame.Payload.ToArray());
}
[Fact]
public void Payload_is_a_slice_of_the_input_not_a_copy()
{
var packet = VoiceDataPacket();
Assert.True(DissonanceFrame.TryParse(packet, out var frame, out _));
packet[^1] = 0xEE;
Assert.Equal(0xEE, frame.Payload.Span[^1]);
}
[Fact]
public void Handles_empty_channel_list_and_empty_payload()
{
var packet = VoiceDataPacket(channels: Array.Empty<(ushort, ushort)>(), payload: Array.Empty());
Assert.True(DissonanceFrame.TryParse(packet, out var frame, out _));
Assert.Empty(frame.Channels);
Assert.Equal(0, frame.Payload.Length);
}
[Fact]
public void Channel_session_masks_off_the_always_set_msb()
{
var packet = VoiceDataPacket(flags: 0xFF);
Assert.True(DissonanceFrame.TryParse(packet, out var frame, out _));
Assert.Equal((byte)0x7F, frame.ChannelSession);
}
[Fact]
public void Rejects_wrong_magic()
{
var packet = VoiceDataPacket();
packet[0] = 0x00;
Assert.False(DissonanceFrame.TryParse(packet, out _, out var error));
Assert.Equal(DissonanceFrameError.BadMagic, error);
Assert.False(DissonanceFrame.HasMagic(packet));
}
[Theory]
[InlineData(DissonanceMessageType.ClientState)]
[InlineData(DissonanceMessageType.TextData)]
[InlineData(DissonanceMessageType.ServerRelayUnreliable)]
public void Rejects_non_voice_packet_types(DissonanceMessageType type)
{
var packet = VoiceDataPacket();
packet[2] = (byte)type;
Assert.False(DissonanceFrame.TryParse(packet, out _, out var error));
Assert.Equal(DissonanceFrameError.NotVoiceData, error);
Assert.True(DissonanceFrame.TryGetMessageType(packet, out var seen));
Assert.Equal(type, seen);
}
[Fact]
public void Rejects_flags_with_msb_clear()
{
var packet = VoiceDataPacket(flags: 0x05);
Assert.False(DissonanceFrame.TryParse(packet, out _, out var error));
Assert.Equal(DissonanceFrameError.BadFlags, error);
}
[Fact]
public void Rejects_packets_shorter_than_the_fixed_header()
{
var packet = VoiceDataPacket();
Assert.False(DissonanceFrame.TryParse(packet.AsMemory(0, DissonanceFrame.FixedHeaderSize - 1), out _, out var error));
Assert.Equal(DissonanceFrameError.TooShort, error);
Assert.False(DissonanceFrame.TryParse(ReadOnlyMemory.Empty, out _, out error));
Assert.Equal(DissonanceFrameError.TooShort, error);
}
[Fact]
public void Rejects_truncated_channel_list_and_truncated_payload()
{
var packet = VoiceDataPacket();
var channelListEnd = DissonanceFrame.FixedHeaderSize + 2 * 4;
Assert.False(DissonanceFrame.TryParse(packet.AsMemory(0, channelListEnd - 1), out _, out var error));
Assert.Equal(DissonanceFrameError.Truncated, error);
Assert.False(DissonanceFrame.TryParse(packet.AsMemory(0, packet.Length - 1), out _, out error));
Assert.Equal(DissonanceFrameError.Truncated, error);
}
[Fact]
public void Rejects_trailing_bytes_after_payload()
{
var packet = VoiceDataPacket().Concat(new byte[] { 0x00 }).ToArray();
Assert.False(DissonanceFrame.TryParse(packet, out _, out var error));
Assert.Equal(DissonanceFrameError.TrailingBytes, error);
}
}