Files

337 lines
10 KiB
Go

// ABOUTME: Tests for Sendspin Protocol message types
// ABOUTME: Verifies JSON marshaling/unmarshaling of protocol messages
package protocol
import (
"encoding/json"
"testing"
)
func TestClientHelloMarshaling(t *testing.T) {
hello := ClientHello{
ClientID: "test-id",
Name: "Test Player",
Version: 1,
SupportedRoles: []string{"player@v1"},
DeviceInfo: &DeviceInfo{
ProductName: "Test Product",
Manufacturer: "Test Mfg",
SoftwareVersion: "0.1.0",
},
PlayerV1Support: &PlayerV1Support{
SupportedFormats: []AudioFormat{
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
{Codec: "flac", Channels: 2, SampleRate: 48000, BitDepth: 16},
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16},
},
BufferCapacity: 1048576,
SupportedCommands: []string{"volume", "mute"},
},
}
msg := Message{
Type: "client/hello",
Payload: hello,
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
err = json.Unmarshal(data, &decoded)
if err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if decoded.Type != "client/hello" {
t.Errorf("expected type client/hello, got %s", decoded.Type)
}
}
func TestClientStateMarshaling(t *testing.T) {
state := ClientStateMessage{
Player: &PlayerState{
State: "synchronized",
Volume: 80,
Muted: false,
},
}
msg := Message{
Type: "client/state",
Payload: state,
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
err = json.Unmarshal(data, &decoded)
if err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if decoded.Type != "client/state" {
t.Errorf("expected type client/state, got %s", decoded.Type)
}
}
func TestServerHelloMarshaling(t *testing.T) {
hello := ServerHello{
ServerID: "server-123",
Name: "Test Server",
Version: 1,
ActiveRoles: []string{"player@v1", "metadata@v1"},
ConnectionReason: "playback",
}
msg := Message{
Type: "server/hello",
Payload: hello,
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
err = json.Unmarshal(data, &decoded)
if err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if decoded.Type != "server/hello" {
t.Errorf("expected type server/hello, got %s", decoded.Type)
}
}
// TestStreamStartArtwork_RoundTrip verifies the StreamStart message can carry
// an artwork channel list alongside (or instead of) the player field. The
// wire format must use width/height (not media_width/media_height, which is
// the hello-message convention).
func TestStreamStartArtwork_RoundTrip(t *testing.T) {
original := StreamStart{
Artwork: &StreamStartArtwork{
Channels: []ArtworkStreamChannel{
{Source: "album", Format: "jpeg", Width: 256, Height: 256},
},
},
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Spot-check the wire shape matches what peers expect.
want := `{"artwork":{"channels":[{"source":"album","format":"jpeg","width":256,"height":256}]}}`
if string(data) != want {
t.Errorf("marshal output = %s, want %s", data, want)
}
var decoded StreamStart
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.Artwork == nil {
t.Fatal("decoded.Artwork is nil")
}
if len(decoded.Artwork.Channels) != 1 {
t.Fatalf("channels = %d, want 1", len(decoded.Artwork.Channels))
}
ch := decoded.Artwork.Channels[0]
if ch.Source != "album" || ch.Format != "jpeg" || ch.Width != 256 || ch.Height != 256 {
t.Errorf("decoded channel = %+v", ch)
}
}
// TestStreamStart_PlayerOmittedWhenNil confirms that existing callers who
// only set Player (never Artwork) still produce the same wire shape.
func TestStreamStart_PlayerOnlyUnchanged(t *testing.T) {
msg := StreamStart{
Player: &StreamStartPlayer{
Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24,
},
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Artwork field must be omitted entirely when nil.
want := `{"player":{"codec":"pcm","sample_rate":48000,"channels":2,"bit_depth":24}}`
if string(data) != want {
t.Errorf("marshal output = %s, want %s", data, want)
}
}
// TestMetadataState_TristateUnmarshal verifies the wire-protocol contract:
// an omitted JSON key, an explicit null, and a value must each produce a
// distinguishable receiver-side signal. The merge layer in
// pkg/sendspin.Receiver depends on this.
func TestMetadataState_TristateUnmarshal(t *testing.T) {
cases := []struct {
name string
json string
wantPtrSet bool // is Title pointer non-nil?
wantTitle string // value if non-nil
wantHasField bool // HasField("title")?
}{
{"omitted", `{"timestamp": 1}`, false, "", false},
{"null", `{"timestamp": 1, "title": null}`, false, "", true},
{"value", `{"timestamp": 1, "title": "Hello"}`, true, "Hello", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var m MetadataState
if err := json.Unmarshal([]byte(tc.json), &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if (m.Title != nil) != tc.wantPtrSet {
t.Errorf("Title pointer non-nil = %v, want %v", m.Title != nil, tc.wantPtrSet)
}
if m.Title != nil && *m.Title != tc.wantTitle {
t.Errorf("Title value = %q, want %q", *m.Title, tc.wantTitle)
}
if got := m.HasField("title"); got != tc.wantHasField {
t.Errorf("HasField(\"title\") = %v, want %v", got, tc.wantHasField)
}
// Timestamp must always decode.
if m.Timestamp != 1 {
t.Errorf("Timestamp = %d, want 1", m.Timestamp)
}
// HasField("timestamp") should be true in every case
// because the wire JSON always carries it here.
if !m.HasField("timestamp") {
t.Error("HasField(\"timestamp\") = false, want true")
}
})
}
}
// TestMetadataState_HasFieldInGoConstruction confirms that messages built
// in process via field assignment (the conformance adapter and any
// server-side helpers do this) report HasField == true for every field.
// This is the backwards-compat guarantee callers can rely on.
func TestMetadataState_HasFieldInGoConstruction(t *testing.T) {
title := "T"
m := MetadataState{Timestamp: 42, Title: &title}
for _, key := range []string{"title", "artist", "album", "progress", "shuffle", "anything"} {
if !m.HasField(key) {
t.Errorf("HasField(%q) = false on Go-constructed value, want true", key)
}
}
}
// TestMetadataState_TristateAcrossAllFields exercises every tristate field
// at once to make sure the alias-based decoder did not accidentally drop a
// field type (pointer-to-int, pointer-to-bool, pointer-to-struct).
func TestMetadataState_TristateAcrossAllFields(t *testing.T) {
raw := `{
"timestamp": 100,
"title": "T",
"artist": null,
"album": "A",
"album_artist": null,
"artwork_url": "http://x",
"year": 2020,
"track": null,
"progress": {"track_progress": 1000, "track_duration": 5000, "playback_speed": 1000},
"shuffle": true
}`
var m MetadataState
if err := json.Unmarshal([]byte(raw), &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// Set fields decode to non-nil pointers.
if m.Title == nil || *m.Title != "T" {
t.Errorf("Title = %v, want pointer to \"T\"", m.Title)
}
if m.Album == nil || *m.Album != "A" {
t.Errorf("Album = %v, want pointer to \"A\"", m.Album)
}
if m.ArtworkURL == nil || *m.ArtworkURL != "http://x" {
t.Errorf("ArtworkURL = %v", m.ArtworkURL)
}
if m.Year == nil || *m.Year != 2020 {
t.Errorf("Year = %v, want pointer to 2020", m.Year)
}
if m.Shuffle == nil || *m.Shuffle != true {
t.Errorf("Shuffle = %v, want pointer to true", m.Shuffle)
}
if m.Progress == nil {
t.Error("Progress = nil, want non-nil")
} else if m.Progress.TrackDuration != 5000 {
t.Errorf("Progress.TrackDuration = %d, want 5000", m.Progress.TrackDuration)
}
// Null fields decode to nil pointers but HasField is true.
for _, key := range []string{"artist", "album_artist", "track"} {
if !m.HasField(key) {
t.Errorf("HasField(%q) = false, want true (null is present)", key)
}
}
if m.Artist != nil {
t.Errorf("Artist = %v, want nil (null on wire)", m.Artist)
}
if m.AlbumArtist != nil {
t.Errorf("AlbumArtist = %v, want nil (null on wire)", m.AlbumArtist)
}
if m.Track != nil {
t.Errorf("Track = %v, want nil (null on wire)", m.Track)
}
// Omitted fields: HasField false.
if m.HasField("repeat") {
t.Error("HasField(\"repeat\") = true, want false (omitted)")
}
if m.Repeat != nil {
t.Errorf("Repeat = %v, want nil (omitted)", m.Repeat)
}
}
// TestMetadataState_RoundTripPreservesValues ensures encoding then
// decoding a populated MetadataState preserves every value. The wire
// shape must be unchanged by the new decoder.
func TestMetadataState_RoundTripPreservesValues(t *testing.T) {
title := "Song"
artist := "Artist"
album := "Album"
original := MetadataState{
Timestamp: 12345,
Title: &title,
Artist: &artist,
Album: &album,
Progress: &ProgressState{
TrackProgress: 1000, TrackDuration: 60000, PlaybackSpeed: 1000,
},
}
data, err := json.Marshal(&original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded MetadataState
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.Timestamp != original.Timestamp {
t.Errorf("Timestamp = %d, want %d", decoded.Timestamp, original.Timestamp)
}
if decoded.Title == nil || *decoded.Title != title {
t.Errorf("Title = %v", decoded.Title)
}
if decoded.Progress == nil || decoded.Progress.TrackDuration != 60000 {
t.Errorf("Progress = %+v", decoded.Progress)
}
// Round-trip preserves presence: the original was constructed in Go
// (presentKeys nil → HasField always true), then re-marshaled (omitempty
// drops nil pointers), then re-decoded (presentKeys captures only the
// fields that survived omitempty). HasField must report present for the
// fields we set.
if !decoded.HasField("title") || !decoded.HasField("progress") {
t.Error("decoded.HasField missed a set field after round-trip")
}
}