Merge branch 'dev' into p16-w1-foundation

# Conflicts:
#	DeepDrftPublic.Client/Controls/SharePopover.razor.cs
This commit is contained in:
daniel-c-harvey
2026-06-19 13:28:50 -04:00
18 changed files with 593 additions and 58 deletions
+54
View File
@@ -0,0 +1,54 @@
using DeepDrftPublic.Client.Helpers;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the share-popover embed snippet (<see cref="EmbedSnippetBuilder"/>). The builder is
/// the mode-aware half of SharePopover: track mode targets FramePlayer's TrackEntryKey param, release
/// mode targets its ReleaseEntryKey param. The iframe chrome (dimensions, autoplay) must be identical
/// across both. Pure string composition, tested directly without rendering the component.
/// </summary>
[TestFixture]
public class EmbedSnippetBuilderTests
{
private const string BaseUri = "https://deepdrft.example/";
[Test]
public void ForTrack_EmitsTrackEntryKeySrc()
{
var snippet = EmbedSnippetBuilder.ForTrack(BaseUri, "abc123");
Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?TrackEntryKey=abc123"""));
Assert.That(snippet, Does.Not.Contain("ReleaseEntryKey"));
}
[Test]
public void ForRelease_EmitsReleaseEntryKeySrc()
{
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?ReleaseEntryKey=rel-xyz"""));
Assert.That(snippet, Does.Not.Contain("TrackEntryKey"));
}
[Test]
public void BothModes_ShareIdenticalIframeChrome()
{
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
Assert.Multiple(() =>
{
foreach (var snippet in new[] { track, release })
{
Assert.That(snippet, Does.StartWith("<iframe "));
Assert.That(snippet, Does.Contain(@"width=""656"""));
Assert.That(snippet, Does.Contain(@"height=""196"""));
Assert.That(snippet, Does.Contain(@"frameborder=""0"""));
Assert.That(snippet, Does.Contain(@"style=""border-radius:8px;"""));
Assert.That(snippet, Does.Contain(@"allow=""autoplay"""));
Assert.That(snippet, Does.EndWith("></iframe>"));
}
});
}
}
@@ -0,0 +1,159 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the release-embed track resolution (<see cref="FramePlayerViewModel"/>). The VM is
/// the data half of FramePlayer's release path: resolve a release EntryKey to its ordered track list,
/// which the page then stages (track 0) and arms into the queue. It is pure async logic over the two
/// data-service seams, so it is exercised here against recording fakes — no browser, no JS, no HTTP.
/// Coverage: ordered non-empty resolution, single-track release, release-not-found, and a release
/// that resolves to no tracks (the "leave the bar idle, don't throw" path).
/// </summary>
[TestFixture]
public class FramePlayerReleaseResolutionTests
{
private FakeReleaseData _releaseData = null!;
private FakeTrackData _trackData = null!;
private FramePlayerViewModel _vm = null!;
private const string ReleaseKey = "release-key-1";
private const long ReleaseId = 42;
[SetUp]
public void SetUp()
{
_releaseData = new FakeReleaseData();
_trackData = new FakeTrackData();
_vm = new FramePlayerViewModel(_releaseData, _trackData);
}
private static ReleaseDto Release() => new() { Id = ReleaseId, EntryKey = ReleaseKey, Title = "Album" };
private static List<TrackDto> Tracks(int count) =>
Enumerable.Range(1, count)
.Select(i => new TrackDto { EntryKey = $"track-{i}", TrackName = $"Track {i}", TrackNumber = i })
.ToList();
[Test]
public async Task Load_ResolvesOrderedNonEmptyTrackList_AndQueriesByResolvedReleaseId()
{
_releaseData.Release = Release();
_trackData.Page = Tracks(3);
await _vm.Load(ReleaseKey);
Assert.Multiple(() =>
{
Assert.That(_vm.Tracks.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
// The track page must be narrowed by the resolved release.Id (the int FK join), sorted by
// the explicit TrackNumber ordinal — the same resolution the Cut detail page uses.
Assert.That(_trackData.LastReleaseId, Is.EqualTo(ReleaseId));
Assert.That(_trackData.LastSortColumn, Is.EqualTo("TrackNumber"));
});
}
[Test]
public async Task Load_WithSingleTrackRelease_ResolvesAOneItemList()
{
_releaseData.Release = Release();
_trackData.Page = Tracks(1);
await _vm.Load(ReleaseKey);
Assert.That(_vm.Tracks, Has.Count.EqualTo(1));
Assert.That(_vm.Tracks[0].EntryKey, Is.EqualTo("track-1"));
}
[Test]
public async Task Load_WhenReleaseNotFound_LeavesTracksEmptyAndDoesNotQueryTracks()
{
_releaseData.Release = null; // GetByEntryKey returns a fail result.
await _vm.Load(ReleaseKey);
Assert.Multiple(() =>
{
Assert.That(_vm.Tracks, Is.Empty);
Assert.That(_trackData.WasQueried, Is.False);
});
}
[Test]
public async Task Load_WhenReleaseResolvesToNoTracks_LeavesTracksEmptyWithoutThrowing()
{
_releaseData.Release = Release();
_trackData.Page = new List<TrackDto>(); // empty page
await _vm.Load(ReleaseKey);
Assert.That(_vm.Tracks, Is.Empty);
}
[Test]
public async Task Load_ResetsTracksFromAPriorLoad()
{
_releaseData.Release = Release();
_trackData.Page = Tracks(3);
await _vm.Load(ReleaseKey);
Assert.That(_vm.Tracks, Has.Count.EqualTo(3));
// A second load whose release is not found must not retain the prior album's tracks.
_releaseData.Release = null;
await _vm.Load("another-key");
Assert.That(_vm.Tracks, Is.Empty);
}
private sealed class FakeReleaseData : IReleaseDataService
{
public ReleaseDto? Release { get; set; }
public Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
=> Task.FromResult(Release is null
? ApiResult<ReleaseDto>.CreateFailResult("not found")
: ApiResult<ReleaseDto>.CreatePassResult(Release));
public Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
string? medium, int page, int pageSize, string? sortColumn = null,
bool sortDescending = false, string? search = null, string? genre = null)
=> throw new NotSupportedException("FramePlayerViewModel does not page releases.");
}
private sealed class FakeTrackData : ITrackDataService
{
public List<TrackDto> Page { get; set; } = new();
public bool WasQueried { get; private set; }
public long? LastReleaseId { get; private set; }
public string? LastSortColumn { get; private set; }
public Task<ApiResult<PagedResult<TrackDto>>> GetPage(
int pageNumber, int pageSize, string? sortColumn = null, bool sortDescending = false,
string? searchText = null, string? album = null, string? genre = null, long? releaseId = null)
{
WasQueried = true;
LastReleaseId = releaseId;
LastSortColumn = sortColumn;
var paged = new PagedResult<TrackDto>
{
Items = Page,
TotalCount = Page.Count,
Page = pageNumber,
PageSize = pageSize,
};
return Task.FromResult(ApiResult<PagedResult<TrackDto>>.CreatePassResult(paged));
}
// Inert remainder — FramePlayerViewModel only calls GetPage.
public Task<ApiResult<List<ReleaseDto>>> GetAlbums() => throw new NotSupportedException();
public Task<ApiResult<List<GenreSummaryDto>>> GetGenres() => throw new NotSupportedException();
public Task<ApiResult<TrackDto>> GetTrack(string trackId) => throw new NotSupportedException();
public Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey) => throw new NotSupportedException();
public Task<ApiResult<TrackDto?>> GetRandomTrack() => throw new NotSupportedException();
}
}
+121
View File
@@ -144,6 +144,127 @@ public class QueueServiceTests
});
}
// --- Arm: prerender-safe load without streaming (release embed) ---
[Test]
public void Arm_LoadsTracksAtIndexZero_WithoutStreaming()
{
var tracks = Tracks(3);
_queue.Arm(tracks);
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.IsArmed, Is.True);
// Arming is interop-free state only: nothing must have been streamed yet.
Assert.That(_player.SelectedTracks, Is.Empty);
});
}
[Test]
public void Arm_WithSingleTrackRelease_ArmsAOneItemQueueWithoutError()
{
_queue.Arm(Tracks(1));
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Has.Count.EqualTo(1));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.IsArmed, Is.True);
Assert.That(_queue.HasNext, Is.False);
Assert.That(_player.SelectedTracks, Is.Empty);
});
}
[Test]
public void Arm_WithEmptyTracks_IsNoOpAndLeavesQueueDisarmed()
{
_queue.Arm(Enumerable.Empty<TrackDto>());
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Is.Empty);
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
Assert.That(_queue.IsArmed, Is.False);
});
}
[Test]
public async Task Start_OnArmedQueue_StreamsTrackZeroAndDisarms()
{
// Models the embed's first-gesture path: FramePlayer arms the queue (no stream), then the
// play click routes through Start().
_queue.Arm(Tracks(3));
await _queue.Start();
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.IsArmed, Is.False);
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"));
});
}
[Test]
public async Task Start_OnUnarmedQueue_IsNoOp()
{
// A non-embed flow (PlayRelease already streaming) must not be disturbed by a stray Start.
await _queue.PlayRelease(Tracks(3));
var streamedBefore = _player.SelectedTracks.Count;
await _queue.Start();
Assert.Multiple(() =>
{
Assert.That(_queue.IsArmed, Is.False);
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
"Start on an unarmed queue must not re-stream");
});
}
[Test]
public async Task ArmedQueue_StartedThenAdvancesThroughWholeReleaseOnTrackEnded()
{
_queue.Arm(Tracks(3));
await _queue.Start();
_player.RaiseTrackEnded(); // → track-2
_player.RaiseTrackEnded(); // → track-3
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
});
}
[Test]
public void Arm_RaisesQueueChanged()
{
var raised = false;
_queue.QueueChanged += () => raised = true;
_queue.Arm(Tracks(2));
Assert.That(raised, Is.True);
}
[Test]
public async Task Clear_DisarmsAnArmedQueue()
{
_queue.Arm(Tracks(2));
_queue.Clear();
Assert.That(_queue.IsArmed, Is.False);
await Task.CompletedTask;
}
// --- Next / Previous mechanics and bounds ---
[Test]