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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user