From 96f5c9765b8fc295bae6a3325378da25b8a7792b Mon Sep 17 00:00:00 2001 From: David Seto <67933197+dtseto@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:47:48 -0700 Subject: [PATCH] Add async station refresh regression tests --- Libs/PandoraSharp/Pandora.cs | 111 ++++++----- UnitTestProject1/BassAudioEngineTest.csproj | 8 + .../PandoraStationRefreshTests.cs | 187 ++++++++++++++++++ 3 files changed, 254 insertions(+), 52 deletions(-) create mode 100644 UnitTestProject1/PandoraStationRefreshTests.cs diff --git a/Libs/PandoraSharp/Pandora.cs b/Libs/PandoraSharp/Pandora.cs index 301591e..63c1725 100644 --- a/Libs/PandoraSharp/Pandora.cs +++ b/Libs/PandoraSharp/Pandora.cs @@ -282,7 +282,7 @@ protected internal async Task CallRPC_Internal(string method, JObject re return response; } - protected internal async Task CallRPC(string method, JObject request = null, bool isAuth = false, bool useSSL = false) + protected internal virtual async Task CallRPC(string method, JObject request = null, bool isAuth = false, bool useSSL = false) { try { @@ -343,77 +343,81 @@ protected internal object CallRPC(string method, object[] args, bool b_url_args public async Task> RefreshStationsAsync() { Log.O("RefreshStations"); - if (StationsUpdatingEvent != null) - StationsUpdatingEvent(this); + StationsUpdatingEvent?.Invoke(this); + + JObject req = new JObject + { + ["includeStationArtUrl"] = true + }; - JObject req = new JObject(); - req["includeStationArtUrl"] = true; var stationList = await CallRPC("user.getStationList", req); - // Use this local variable throughout the method. - List localStations = new List(); - // Lock all modifications to the station lists + List quickMixes; + List normalStations; + lock (_stationListLock) { - QuickMixStationIDs.Clear(); - Stations = new List(); - var stations = stationList.Result["stations"]; - foreach (JToken d in stations) + var fetchedStations = new List(); + var stationsToken = stationList?.Result?["stations"] as JArray; + + if (stationsToken != null) { - Stations.Add(new Station(this, d)); + foreach (JToken d in stationsToken) + { + fetchedStations.Add(new Station(this, d)); + } } - //foreach (PDict s in stationList) - // Stations.Add(new Station(this, s)); if (QuickMixStationIDs.Count > 0) { - foreach (Station s in Stations) + foreach (Station s in fetchedStations) { if (QuickMixStationIDs.Contains(s.ID)) s.UseQuickMix = true; } } - List quickMixes = Stations.FindAll(x => x.IsQuickMix); - Stations = Stations.FindAll(x => !x.IsQuickMix); + quickMixes = fetchedStations.Where(x => x.IsQuickMix).ToList(); + normalStations = fetchedStations.Where(x => !x.IsQuickMix).ToList(); + } - switch (StationSortOrder) - { - case SortOrder.DateDesc: - //Stations = Stations.OrderByDescending(x => x.ID).ToList(); - Stations = Stations.OrderByDescending(x => Convert.ToInt64(x.ID)).ToList(); - break; - case SortOrder.DateAsc: - //Stations = Stations.OrderBy(x => x.ID).ToList(); - Stations = Stations.OrderBy(x => Convert.ToInt64(x.ID)).ToList(); - break; - case SortOrder.AlphaDesc: - Stations = Stations.OrderByDescending(x => x.Name).ToList(); - break; - case SortOrder.AlphaAsc: - Stations = Stations.OrderBy(x => x.Name).ToList(); - break; - case SortOrder.RatingAsc: - GetStationMetaData(); - Stations = Stations.OrderBy(x => x.ThumbsUp).ToList(); - break; - case SortOrder.RatingDesc: - GetStationMetaData(); - Stations = Stations.OrderByDescending(x => x.ThumbsUp).ToList(); - break; + switch (StationSortOrder) + { + case SortOrder.DateDesc: + normalStations = normalStations.OrderByDescending(x => Convert.ToInt64(x.ID)).ToList(); + break; + case SortOrder.DateAsc: + normalStations = normalStations.OrderBy(x => Convert.ToInt64(x.ID)).ToList(); + break; + case SortOrder.AlphaDesc: + normalStations = normalStations.OrderByDescending(x => x.Name).ToList(); + break; + case SortOrder.AlphaAsc: + normalStations = normalStations.OrderBy(x => x.Name).ToList(); + break; + case SortOrder.RatingAsc: + await GetStationMetaData(normalStations); + normalStations = normalStations.OrderBy(x => x.ThumbsUp).ToList(); + break; + case SortOrder.RatingDesc: + await GetStationMetaData(normalStations); + normalStations = normalStations.OrderByDescending(x => x.ThumbsUp).ToList(); + break; + } - } + var orderedStations = new List(quickMixes.Count + normalStations.Count); + orderedStations.AddRange(quickMixes); + orderedStations.AddRange(normalStations); - localStations.InsertRange(0, quickMixes); - // Also update the public property for other parts of your app - this.Stations = localStations; + lock (_stationListLock) + { + Stations = orderedStations; + } - } // end of lock - if (StationUpdateEvent != null) - StationUpdateEvent(this); - return localStations; + StationUpdateEvent?.Invoke(this); + return orderedStations; } //private string getSyncKey() @@ -677,11 +681,14 @@ private async Task CreateStation(Song song, string type) return station; } - private async Task GetStationMetaData() + private async Task GetStationMetaData(IEnumerable stationsToProcess) { Log.O("RetrieveStationMetaData"); - foreach (var station in Stations) + if (stationsToProcess == null) + return; + + foreach (var station in stationsToProcess) { JObject req = new JObject { diff --git a/UnitTestProject1/BassAudioEngineTest.csproj b/UnitTestProject1/BassAudioEngineTest.csproj index f4fb994..f0e6c25 100644 --- a/UnitTestProject1/BassAudioEngineTest.csproj +++ b/UnitTestProject1/BassAudioEngineTest.csproj @@ -46,10 +46,14 @@ ..\packages\MSTest.TestFramework.2.2.10\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll + + ..\packages\Newtonsoft.Json.7.0.1\lib\net40\Newtonsoft.Json.dll + + @@ -61,6 +65,10 @@ {d054f1ed-6a9c-4c4d-b500-7c1af01f16c9} BassPlayer + + {64DD2B7D-9069-41FF-A4E4-732F6DF82F09} + PandoraSharp + diff --git a/UnitTestProject1/PandoraStationRefreshTests.cs b/UnitTestProject1/PandoraStationRefreshTests.cs new file mode 100644 index 0000000..27866f5 --- /dev/null +++ b/UnitTestProject1/PandoraStationRefreshTests.cs @@ -0,0 +1,187 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; +using PandoraSharp; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace UnitTestProject1 +{ + [TestClass] + public class PandoraStationRefreshTests + { + private sealed class StubPandora : Pandora + { + private readonly Dictionary>>> _handlers; + private readonly object _handlerLock = new object(); + + public StubPandora(Dictionary>>> handlers) + { + _handlers = handlers; + } + + protected internal override Task CallRPC(string method, JObject request = null, bool isAuth = false, bool useSSL = false) + { + Func> handler = null; + + lock (_handlerLock) + { + if (_handlers.TryGetValue(method, out var queue) && queue.Count > 0) + { + handler = queue.Dequeue(); + } + } + + if (handler == null) + { + throw new InvalidOperationException($"No handler registered for method {method}."); + } + + return handler(request ?? new JObject()); + } + } + + private static JSONResult BuildStationListResponse(params JObject[] stations) + { + var payload = new JObject + { + ["stat"] = "ok", + ["result"] = new JObject + { + ["stations"] = new JArray(stations) + } + }; + + return new JSONResult(payload.ToString()); + } + + private static JObject BuildStationPayload(string id, string name, bool isQuickMix = false, string[] quickMixStationIds = null) + { + var obj = new JObject + { + ["stationId"] = id, + ["stationToken"] = $"token-{id}", + ["isShared"] = false, + ["isQuickMix"] = isQuickMix, + ["stationName"] = name, + ["stationDetailUrl"] = "http://station" + }; + + if (isQuickMix) + { + quickMixStationIds = quickMixStationIds ?? Array.Empty(); + obj["quickMixStationIds"] = new JArray(quickMixStationIds); + } + + return obj; + } + + private static JSONResult BuildStationMetadataResponse(int thumbsUp, int thumbsDown) + { + var payload = new JObject + { + ["stat"] = "ok", + ["result"] = new JObject + { + ["feedback"] = new JObject + { + ["totalThumbsUp"] = thumbsUp, + ["totalThumbsDown"] = thumbsDown + } + } + }; + + return new JSONResult(payload.ToString()); + } + + [TestMethod] + public async Task RefreshStationsAsync_WaitsForMetadataBeforePublishing() + { + var quickMix = BuildStationPayload("1", "Quick Mix", true, new[] { "2", "3" }); + var betaStation = BuildStationPayload("2", "Beta Station"); + var alphaStation = BuildStationPayload("3", "Alpha Station"); + + var metadataTcs = new TaskCompletionSource(); + + var handlers = new Dictionary>>> + { + ["user.getStationList"] = new Queue>>(new[] + { + new Func>(async _ => + { + await Task.Delay(10).ConfigureAwait(false); + return BuildStationListResponse(quickMix, betaStation, alphaStation); + }) + }), + ["station.getStation"] = new Queue>>(new[] + { + new Func>(_ => metadataTcs.Task), + new Func>(_ => Task.FromResult(BuildStationMetadataResponse(1, 0))) + }) + }; + + var pandora = new StubPandora(handlers) + { + StationSortOrder = Pandora.SortOrder.RatingDesc + }; + + var updateEvents = 0; + pandora.StationUpdateEvent += _ => Interlocked.Increment(ref updateEvents); + + var refreshTask = pandora.RefreshStationsAsync(); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.AreEqual(0, updateEvents, "Station updates should wait for metadata fetches to complete."); + Assert.IsNull(pandora.Stations, "Stations must not be published before metadata resolves."); + + metadataTcs.SetResult(BuildStationMetadataResponse(3, 0)); + + var stations = await refreshTask.ConfigureAwait(false); + + Assert.AreEqual(1, updateEvents, "Station update event should fire exactly once."); + Assert.IsNotNull(stations, "Refresh should return a station list."); + Assert.AreEqual(3, stations.Count, "Quick mix and normal stations should be included."); + CollectionAssert.AreEqual( + new[] { "Quick Mix", "Beta Station", "Alpha Station" }, + stations.Select(s => s.Name).ToArray(), + "Stations should keep quick mix first and sort by rating thereafter."); + } + + [TestMethod] + public async Task RefreshStationsAsync_ReturnsAllStationsAfterAsyncDelay() + { + var quickMix = BuildStationPayload("10", "Quick Mix", true, new[] { "20", "30" }); + var firstStation = BuildStationPayload("20", "First Station"); + var secondStation = BuildStationPayload("30", "Second Station"); + + var handlers = new Dictionary>>> + { + ["user.getStationList"] = new Queue>>(new[] + { + new Func>(async _ => + { + await Task.Delay(25).ConfigureAwait(false); + return BuildStationListResponse(quickMix, firstStation, secondStation); + }) + }) + }; + + var pandora = new StubPandora(handlers) + { + StationSortOrder = Pandora.SortOrder.DateDesc + }; + + var stations = await pandora.RefreshStationsAsync().ConfigureAwait(false); + + Assert.AreEqual(3, stations.Count, "Refresh should publish every station returned by the API."); + Assert.IsNotNull(pandora.Stations, "Stations property should be populated after refresh."); + Assert.AreEqual(3, pandora.Stations.Count, "Stations property should hold the combined list."); + Assert.IsTrue(stations.Any(s => s.IsQuickMix), "Quick mix entry should be present."); + Assert.IsTrue(stations.Any(s => !s.IsQuickMix), "Non quick-mix stations should be present."); + Assert.AreEqual("Quick Mix", stations.First().Name, "Quick mix station should remain at the top of the list."); + } + } +}