🎉 live server seems to be working now
This commit is contained in:
336
third_party/sendspin-go/pkg/protocol/messages_test.go
vendored
Normal file
336
third_party/sendspin-go/pkg/protocol/messages_test.go
vendored
Normal file
@@ -0,0 +1,336 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user