Checkpoint web-player design-system refactor and backend track genre tagging

Advances the ongoing token/componentization cleanup (anchored player dock,
border-based panels instead of floating shadows, corrected light-mode
accent/gradient tokens) alongside in-progress backend support for track
genre tags.
This commit is contained in:
2026-09-17 17:43:37 +02:00
parent 5ae9955bd0
commit c14db5a711
83 changed files with 1872 additions and 248 deletions
@@ -95,6 +95,16 @@ namespace Musify.Application.Tests.TestSupport
.HasIndex(like => new { like.UserId, like.TrackId })
.IsUnique();
modelBuilder.Entity<TrackTag>()
.HasOne(trackTag => trackTag.Track)
.WithMany(track => track.Tags)
.HasForeignKey(trackTag => trackTag.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackTag>()
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
.IsUnique();
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Follower)
.WithMany()
@@ -138,6 +148,8 @@ namespace Musify.Application.Tests.TestSupport
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
@@ -55,8 +55,10 @@ namespace Musify.Application.Tests.TestSupport
double durationSeconds = 180,
TrackPictures? pictures = null,
TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
new()
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null)
{
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
@@ -68,6 +70,13 @@ namespace Musify.Application.Tests.TestSupport
Audio = audio ?? ProcessedAudio()
};
track.Tags = (tags ?? [Genre.Pop])
.Select(tag => new TrackTag { TrackId = track.Id, Tag = tag })
.ToList();
return track;
}
public static PlayListPictures PlayListPictures() => new()
{
OriginalName = "original.webp",
@@ -42,7 +42,7 @@ namespace Musify.Application.Tests.Tracks
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -50,6 +50,7 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal("My Song", result.Value.Title);
var localResponse = Assert.IsType<TrackApplicationResponse>(result.Value);
Assert.Equal(user.Id, localResponse.OwnerUserId);
Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
@@ -57,10 +58,38 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
}
[Fact]
public async Task Handle_NoTags_ReturnsValidationError()
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
[Fact]
public async Task Handle_IncompatibleTags_ReturnsValidationError()
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Classical, Genre.Metal]);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid());
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -75,7 +104,7 @@ namespace Musify.Application.Tests.Tracks
var (picture, audio) = SeedIntents(otherUser.Id);
await SeedAsync(user, otherUser, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -91,7 +120,7 @@ namespace Musify.Application.Tests.Tracks
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
await SeedAsync(user, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
@@ -0,0 +1,51 @@
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Domain.Tests.ValueObjects
{
public sealed class GenreCompatibilityTests
{
[Theory]
[InlineData(Genre.Classical, Genre.Metal)]
[InlineData(Genre.Classical, Genre.Punk)]
[InlineData(Genre.Ambient, Genre.Dubstep)]
[InlineData(Genre.Lofi, Genre.DrumAndBass)]
public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second)
{
Assert.False(GenreCompatibility.AreCompatible(first, second));
Assert.False(GenreCompatibility.AreCompatible(second, first));
}
[Theory]
[InlineData(Genre.Pop, Genre.Rock)]
[InlineData(Genre.HipHop, Genre.RnB)]
[InlineData(Genre.Classical, Genre.Jazz)]
public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second)
{
Assert.True(GenreCompatibility.AreCompatible(first, second));
}
[Fact]
public void AreCompatible_SameGenre_ReturnsTrue()
{
Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop));
}
[Fact]
public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty()
{
var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]);
Assert.Empty(conflicts);
}
[Fact]
public void FindConflicts_IncompatiblePairPresent_ReturnsIt()
{
var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]);
var conflict = Assert.Single(conflicts);
Assert.Equal((Genre.Classical, Genre.Metal), conflict);
}
}
}
@@ -41,6 +41,31 @@ namespace Musify.Infrastructure.Tests.Persistence
Assert.Equal(user.Id, stored.OwnerUserId);
}
[Fact]
public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum()
{
await using var database = fixture.CreateDatabase();
var userName = "tags-roundtrip-user";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() };
var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}");
track.Tags =
[
new TrackTag { TrackId = track.Id, Tag = Genre.Pop },
new TrackTag { TrackId = track.Id, Tag = Genre.Rock }
];
await database.AddRangeAsync(user, track);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var reloaded = fixture.CreateDatabase();
var stored = await reloaded.Tracks.AsNoTracking()
.Include(t => t.Tags)
.SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t));
}
[Fact]
public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows()
{