Wire NowPlayingStats to live aggregates: add SQL track duration column, stats endpoint, and duration backfill
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user