Add whole-release embeds to FramePlayer and un-gate the release embed share affordance
The queue gains an armed-but-idle state (Arm/Start) so a release embed stages track 0 prerender-safe, then queues the full release on first play and auto-advances.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user