Adversarial review, Medium: the "Problems" filter over-promised. The badge is
honestly named ("No playout"), but the filter claimed a taxonomy that does not
exist — decisions.md enumerates three fault classes this deliberately does NOT
compute (empty schedule behind a playout, broken source, origin). A user whose
Classic schedule is empty would read "Problems 0" as "lineup healthy" and ship
a dead channel. A false all-clear is worse than no affordance.
Renamed the filter to "No playout" so badge and filter both name exactly the
one fault the API can prove, leaving "Problems" free for when the taxonomy
behind it actually exists. Rationale recorded at the predicate so the next
person doesn't "improve" the label back.
Also from the review:
- The zero-playout test's comment claimed include coverage it does not provide
(it passes with or without the include — 0 == 0). Re-stated as what it is: a
mapper boundary check. Its two siblings are the include coverage.
- UpdateChannelHandler:179 is a fourth call site the "shared by all three"
framing excluded. Harmless (the controller discards the view model and
re-projects through GetChannelByIdForApi), but a trap: its query lacks the
MirrorSourceChannel include, so swapping in the shared helper would report 0
for a working mirror. Documented that the fix there is a QUERY change.
- Mirrored the rename into the design-system prototype.
The rename collided the badge and filter labels, so the screen tests now scope
the badge assertion to the table (spa-conventions §6). This also fixed a real
weakness: the mirror test's unscoped queryByText would have matched the filter
button and asserted nothing.
Review note: the reviewer's strongest hypothesis — that PUT and GET could
disagree on a mirror channel's count — was investigated and does NOT hold.
Refs #72
144 lines
5.5 KiB
C#
144 lines
5.5 KiB
C#
using ErsatzTV.Application.Channels;
|
|
using ErsatzTV.Core.Api.Channels;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Data.Repositories;
|
|
using ErsatzTV.Tests.Support;
|
|
using LanguageExt;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Infrastructure;
|
|
|
|
// #72: the channels API reports a per-channel playout count so the SPA can flag a channel that will
|
|
// never play. GetChannel omitted the Playouts include, and the read is AsNoTracking with no lazy-loading
|
|
// proxies, so the navigation came back empty and every caller's count collapsed to 0 — GET
|
|
// /api/v1/channels/{id} reported playoutCount: 0 for every channel on the system. That silently disabled
|
|
// the channel editor's playout-source guard (ChannelEditScreen "Cannot be changed once a generated
|
|
// channel has a playout"), which is gated on playoutCount > 0.
|
|
//
|
|
// These run the REAL repository against a real context on purpose: a handler test with a substituted
|
|
// IChannelRepository hands the mapper a channel whose Playouts the test itself populated, so it passes
|
|
// whether or not the query includes them. Only a repository-level read can catch a missing include.
|
|
[TestFixture]
|
|
public class ChannelRepositoryPlayoutIncludeTests
|
|
{
|
|
[SetUp]
|
|
public async Task SetUp()
|
|
{
|
|
_db = await InMemoryTvContext.CreateAsync();
|
|
_repository = new ChannelRepository(_db.Factory);
|
|
}
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
private InMemoryTvContext _db = null!;
|
|
private ChannelRepository _repository = null!;
|
|
|
|
// FFmpegProfileId is non-nullable, so GetChannel's Include(c => c.FFmpegProfile) is an INNER JOIN —
|
|
// a channel with no profile row is filtered out of the result entirely. Every fixture needs a real one.
|
|
private async Task<int> SeedFFmpegProfile()
|
|
{
|
|
var profile = new FFmpegProfile { Name = "Test Profile" };
|
|
await using TvContext context = _db.CreateContext();
|
|
context.FFmpegProfiles.Add(profile);
|
|
await context.SaveChangesAsync();
|
|
return profile.Id;
|
|
}
|
|
|
|
private async Task<int> SeedChannel(Channel channel)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
context.Channels.Add(channel);
|
|
await context.SaveChangesAsync();
|
|
return channel.Id;
|
|
}
|
|
|
|
private async Task<ChannelDetailResponseModel> GetDetail(int channelId)
|
|
{
|
|
var handler = new GetChannelByIdForApiHandler(_repository);
|
|
Option<ChannelDetailResponseModel> result =
|
|
await handler.Handle(new GetChannelByIdForApi(channelId), CancellationToken.None);
|
|
return result.IfNone(() => throw new InvalidOperationException($"channel {channelId} not found"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetChannel_Should_Include_Playouts_So_The_Detail_Api_Reports_A_Real_Count()
|
|
{
|
|
int profileId = await SeedFFmpegProfile();
|
|
int channelId = await SeedChannel(
|
|
new Channel(Guid.NewGuid())
|
|
{
|
|
Number = "1",
|
|
Name = "Generated",
|
|
PlayoutSource = ChannelPlayoutSource.Generated,
|
|
FFmpegProfileId = profileId,
|
|
Artwork = [],
|
|
Playouts = [new Playout(), new Playout()]
|
|
});
|
|
|
|
ChannelDetailResponseModel detail = await GetDetail(channelId);
|
|
|
|
detail.PlayoutCount.ShouldBe(2);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetChannel_Should_Count_The_Mirror_Sources_Playouts_For_A_Mirror_Channel()
|
|
{
|
|
// A mirror channel owns no playouts; it relays its source's. Counting only its own navigation
|
|
// would report 0 and wrongly flag a working mirror channel as "will never play".
|
|
int profileId = await SeedFFmpegProfile();
|
|
int sourceId = await SeedChannel(
|
|
new Channel(Guid.NewGuid())
|
|
{
|
|
Number = "1",
|
|
Name = "Source",
|
|
PlayoutSource = ChannelPlayoutSource.Generated,
|
|
FFmpegProfileId = profileId,
|
|
Artwork = [],
|
|
Playouts = [new Playout()]
|
|
});
|
|
|
|
int mirrorId = await SeedChannel(
|
|
new Channel(Guid.NewGuid())
|
|
{
|
|
Number = "2",
|
|
Name = "Mirror",
|
|
PlayoutSource = ChannelPlayoutSource.Mirror,
|
|
MirrorSourceChannelId = sourceId,
|
|
FFmpegProfileId = profileId,
|
|
Artwork = [],
|
|
Playouts = []
|
|
});
|
|
|
|
ChannelDetailResponseModel detail = await GetDetail(mirrorId);
|
|
|
|
detail.PlayoutCount.ShouldBe(1);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetChannel_Should_Report_Zero_For_A_Channel_With_No_Playouts()
|
|
{
|
|
// Boundary check on the mapper only — this one passes with or WITHOUT the Playouts include
|
|
// (0 == 0 either way), so unlike its two siblings it is NOT include coverage. It earns its
|
|
// place by pinning that the "will never play" signal stays 0 and never drifts vacuously
|
|
// non-zero, which would silently retire the badge.
|
|
int profileId = await SeedFFmpegProfile();
|
|
int channelId = await SeedChannel(
|
|
new Channel(Guid.NewGuid())
|
|
{
|
|
Number = "3",
|
|
Name = "Empty",
|
|
PlayoutSource = ChannelPlayoutSource.Generated,
|
|
FFmpegProfileId = profileId,
|
|
Artwork = [],
|
|
Playouts = []
|
|
});
|
|
|
|
ChannelDetailResponseModel detail = await GetDetail(channelId);
|
|
|
|
detail.PlayoutCount.ShouldBe(0);
|
|
}
|
|
}
|