package models import ( "encoding/xml" "testing" ) // reporterXML is the /presets response captured from the speaker that // crashed the CLI in issue #308 (ST10 post factory reset, FW 27.0.6). // Two configured presets followed by three self-closing // placeholders. The original crash happened on the first : // GetDisplayName() handled the nil ContentItem, but the very next // line dereferenced ContentItem.Source unconditionally. const reporterXML = ` MDR JUMP SUNSHINE LIVE http://cdn-profiles.tunein.com/s10637/images/logog.png?t=637791086340000000 ` // invalidSourceXML is the second placeholder shape observed in the // wild (gesellix's ST10/ST20 on FW 27.0.6, never factory-reset). The // firmware here populates ContentItem with source="INVALID_SOURCE" // for unconfigured slots — non-nil but useless, so the old IsEmpty // (== nil only) returned false and the placeholders polluted listings. const invalidSourceXML = `` + `` + `` + `` + `Sand Castle Tapes` + `Unpluggedhttps://example.com/art.jpg` + `SMOOTH JAZZhttps://example.com/logo.png` + `` func TestIsEmpty_NoContentItem(t *testing.T) { // Shape A: — ContentItem == nil. This is the shape // behind the issue #308 crash. p := Preset{} if !p.IsEmpty() { t.Error("IsEmpty() should be true when ContentItem is nil") } } func TestIsEmpty_InvalidSourcePlaceholder(t *testing.T) { // Shape B: ContentItem present but Source == "INVALID_SOURCE". // Observed on devices that never had a factory reset. p := Preset{ ContentItem: &ContentItem{Source: "INVALID_SOURCE", IsPresetable: true}, } if !p.IsEmpty() { t.Error("IsEmpty() should be true when ContentItem has INVALID_SOURCE") } } func TestIsEmpty_EmptySource(t *testing.T) { // A ContentItem with no Source can't drive playback. Treat it // as empty too — defensive, not tied to a single observed shape. p := Preset{ContentItem: &ContentItem{}} if !p.IsEmpty() { t.Error("IsEmpty() should be true when ContentItem.Source is empty") } } func TestIsEmpty_RealPreset(t *testing.T) { p := Preset{ ContentItem: &ContentItem{ Source: "TUNEIN", ItemName: "MDR JUMP", }, } if p.IsEmpty() { t.Error("IsEmpty() should be false for a configured preset") } } func TestReporterXML_DoesNotPanicAndFiltersEmpty(t *testing.T) { // Reproducer for issue #308: simulate the loop that crashed the // CLI. The fix is two-fold: IsEmpty now recognises , // and callers use the nil-safe Get* accessors. Walking every // preset through the same paths the CLI uses must not panic on // any entry. var presets Presets if err := xml.Unmarshal([]byte(reporterXML), &presets); err != nil { t.Fatalf("Failed to unmarshal reporter XML: %v", err) } if got := len(presets.Preset); got != 5 { t.Fatalf("Expected 5 preset entries (2 configured + 3 empty), got %d", got) } emptyCount := 0 configuredCount := 0 for _, p := range presets.Preset { // The CLI now skips empty presets before dereferencing // anything on ContentItem. The IsEmpty call must catch all // three entries. if p.IsEmpty() { emptyCount++ continue } configuredCount++ // These calls would have panicked pre-fix on the empty // entries; here they exercise the still-printed paths for // the real ones. _ = p.GetDisplayName() _ = p.GetSource() _ = p.GetSourceAccount() _ = p.GetLocation() } if emptyCount != 3 { t.Errorf("Expected 3 empty presets, got %d", emptyCount) } if configuredCount != 2 { t.Errorf("Expected 2 configured presets, got %d", configuredCount) } // HasPresets should reflect "there are real presets" — not // confused by the placeholders. if !presets.HasPresets() { t.Error("HasPresets() should be true (2 real presets present)") } if got := presets.GetUsedPresetSlots(); len(got) != 2 { t.Errorf("GetUsedPresetSlots() = %v; want 2 entries", got) } } func TestInvalidSourceXML_PlaceholdersFilteredOut(t *testing.T) { // Second-shape reproducer: three INVALID_SOURCE placeholders // preceding three real presets. Before the IsEmpty extension, // listings printed "0. Preset 0 / Source: INVALID_SOURCE" three // times before the real entries — annoying, not crashing. var presets Presets if err := xml.Unmarshal([]byte(invalidSourceXML), &presets); err != nil { t.Fatalf("Failed to unmarshal invalid-source XML: %v", err) } if got := len(presets.Preset); got != 6 { t.Fatalf("Expected 6 preset entries, got %d", got) } configured := 0 for _, p := range presets.Preset { if !p.IsEmpty() { configured++ } } if configured != 3 { t.Errorf("Expected 3 configured presets (after filtering INVALID_SOURCE placeholders), got %d", configured) } // The three placeholders all carry id="0", so used-slot // reporting should ignore them and show only the real ids. used := presets.GetUsedPresetSlots() if len(used) != 3 { t.Fatalf("GetUsedPresetSlots() = %v; want 3 entries", used) } wantIDs := map[int]bool{1: true, 2: true, 6: true} for _, id := range used { if !wantIDs[id] { t.Errorf("Unexpected used slot id %d; want one of %v", id, []int{1, 2, 6}) } } }