Wire NowPlayingStats to live aggregates: add SQL track duration column, stats endpoint, and duration backfill

This commit is contained in:
daniel-c-harvey
2026-06-18 11:53:49 -04:00
parent 8ddecb4acc
commit 5f0422a263
26 changed files with 1089 additions and 9 deletions
+152
View File
@@ -0,0 +1,152 @@
using Data.Data.Repositories;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace DeepDrftTests;
/// <summary>
/// Aggregate-query tests for the public home hero stat row, exercising
/// <see cref="TrackRepository.GetHomeStatsAsync"/>: the Cut track count, the per-ReleaseType Cut
/// release breakdown (zero-count types absent), the Mix release count, and the Mix-runtime sum
/// (null durations contributing 0). Runs on the EF in-memory provider like
/// <see cref="ReleaseBrowseQueryTests"/> — every predicate here (Count, GroupBy, Sum with a
/// null-coalesce) translates in process.
/// </summary>
[TestFixture]
public class HomeStatsQueryTests
{
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown() => _context.Dispose();
private TrackRepository CreateRepository()
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
private static ReleaseEntity Release(
string title, ReleaseMedium medium, ReleaseType releaseType = ReleaseType.Single)
=> new()
{
EntryKey = Guid.NewGuid().ToString("N"),
Title = title,
Artist = "A",
Medium = medium,
ReleaseType = releaseType,
};
private static TrackEntity Track(ReleaseEntity release, double? duration = null)
=> new()
{
EntryKey = Guid.NewGuid().ToString("N"),
TrackName = "T",
Release = release,
DurationSeconds = duration,
};
private async Task SeedAsync(IEnumerable<ReleaseEntity> releases, IEnumerable<TrackEntity> tracks)
{
_context.Releases.AddRange(releases);
_context.Tracks.AddRange(tracks);
await _context.SaveChangesAsync();
}
// Cut track count reflects only tracks whose release is the Cut medium — Session and Mix tracks
// are excluded — given a mix of all three media.
[Test]
public async Task GetHomeStatsAsync_CutTrackCount_CountsOnlyCutMediumTracks()
{
var cut = Release("Cut", ReleaseMedium.Cut, ReleaseType.Album);
var session = Release("Session", ReleaseMedium.Session);
var mix = Release("Mix", ReleaseMedium.Mix);
await SeedAsync(
new[] { cut, session, mix },
new[] { Track(cut), Track(cut), Track(session), Track(mix) });
var stats = await CreateRepository().GetHomeStatsAsync();
Assert.That(stats.CutTrackCount, Is.EqualTo(2));
}
// The Cut release-type breakdown groups by ReleaseType, and a type with zero Cut releases is absent
// from the result entirely (not present-with-zero).
[Test]
public async Task GetHomeStatsAsync_CutReleaseTypeBreakdown_OmitsZeroCountTypes()
{
await SeedAsync(
new[]
{
Release("Cut Single 1", ReleaseMedium.Cut, ReleaseType.Single),
Release("Cut Single 2", ReleaseMedium.Cut, ReleaseType.Single),
Release("Cut Album", ReleaseMedium.Cut, ReleaseType.Album),
// A Mix release with ReleaseType.EP must not leak into the Cut breakdown.
Release("Mix EP", ReleaseMedium.Mix, ReleaseType.EP),
},
Array.Empty<TrackEntity>());
var stats = await CreateRepository().GetHomeStatsAsync();
Assert.That(stats.CutReleaseTypeCounts.Any(c => c.ReleaseType == ReleaseType.EP), Is.False,
"EP has zero Cut releases and must be absent, not present-with-zero");
Assert.That(stats.CutReleaseTypeCounts.Single(c => c.ReleaseType == ReleaseType.Single).Count, Is.EqualTo(2));
Assert.That(stats.CutReleaseTypeCounts.Single(c => c.ReleaseType == ReleaseType.Album).Count, Is.EqualTo(1));
}
// Mix release count counts Mix-medium releases, and the runtime sum tolerates null durations:
// not-yet-backfilled tracks contribute 0 rather than throwing or skewing the total.
[Test]
public async Task GetHomeStatsAsync_MixRuntime_TreatsNullDurationsAsZero()
{
var mixA = Release("Mix A", ReleaseMedium.Mix);
var mixB = Release("Mix B", ReleaseMedium.Mix);
var cut = Release("Cut", ReleaseMedium.Cut);
await SeedAsync(
new[] { mixA, mixB, cut },
new[]
{
Track(mixA, duration: 600d),
Track(mixB, duration: null), // not yet backfilled — contributes 0
Track(cut, duration: 120d), // Cut track must not count toward mix runtime
});
var stats = await CreateRepository().GetHomeStatsAsync();
Assert.That(stats.MixReleaseCount, Is.EqualTo(2));
Assert.That(stats.MixRuntimeSeconds, Is.EqualTo(600d));
}
// Soft-deleted releases and tracks never count toward any figure.
[Test]
public async Task GetHomeStatsAsync_ExcludesSoftDeletedRowsFromAllFigures()
{
var liveCut = Release("Live Cut", ReleaseMedium.Cut, ReleaseType.Album);
var deletedCut = Release("Dead Cut", ReleaseMedium.Cut, ReleaseType.Album);
deletedCut.IsDeleted = true;
var mix = Release("Mix", ReleaseMedium.Mix);
var deletedMixTrack = Track(mix, duration: 999d);
deletedMixTrack.IsDeleted = true;
await SeedAsync(
new[] { liveCut, deletedCut, mix },
new[] { Track(liveCut), deletedMixTrack });
var stats = await CreateRepository().GetHomeStatsAsync();
Assert.That(stats.CutTrackCount, Is.EqualTo(1));
Assert.That(stats.CutReleaseTypeCounts.Single(c => c.ReleaseType == ReleaseType.Album).Count, Is.EqualTo(1));
Assert.That(stats.MixRuntimeSeconds, Is.EqualTo(0d), "the only mix track is soft-deleted");
}
}
+40
View File
@@ -0,0 +1,40 @@
using DeepDrftPublic.Client.Helpers;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the home-stat-row runtime formatter (<see cref="RuntimeFormat.ToHoursMinutes"/>):
/// the hh:mm shape, hour rollover past 60 minutes, multi-hour totals, and the clamp on non-positive /
/// non-finite input.
/// </summary>
[TestFixture]
public class RuntimeFormatTests
{
// 12h34m -> "12:34": the brief's worked example, hours not zero-padded, minutes two digits.
[Test]
public void ToHoursMinutes_TwelveHoursThirtyFour_FormatsHhMm()
=> Assert.That(RuntimeFormat.ToHoursMinutes((12 * 3600) + (34 * 60)), Is.EqualTo("12:34"));
// 90 minutes rolls into 1 hour 30 minutes — minutes never exceed 59.
[Test]
public void ToHoursMinutes_NinetyMinutes_RollsIntoHours()
=> Assert.That(RuntimeFormat.ToHoursMinutes(90 * 60), Is.EqualTo("1:30"));
// Sub-hour totals show 0 hours with zero-padded minutes.
[Test]
public void ToHoursMinutes_UnderOneHour_ShowsZeroHours()
=> Assert.That(RuntimeFormat.ToHoursMinutes(5 * 60), Is.EqualTo("0:05"));
// Totals beyond 99h are not truncated — hours simply take more than two digits (mixes are few).
[Test]
public void ToHoursMinutes_BeyondNinetyNineHours_DoesNotTruncate()
=> Assert.That(RuntimeFormat.ToHoursMinutes((123 * 3600) + (45 * 60)), Is.EqualTo("123:45"));
// Zero / negative / non-finite inputs clamp to "0:00" rather than producing a negative or NaN render.
[TestCase(0d)]
[TestCase(-10d)]
[TestCase(double.NaN)]
[TestCase(double.PositiveInfinity)]
public void ToHoursMinutes_NonPositiveOrNonFinite_ClampsToZero(double seconds)
=> Assert.That(RuntimeFormat.ToHoursMinutes(seconds), Is.EqualTo("0:00"));
}