Re-review of the fix commit returned MERGEABLE-WITH-NITS. It verified the gate is now complete by enumerating the writers itself (no fourth persisting writer) and proved B2's fix works by writing throwaway handler tests — which was also its point: the fix shipped with none. B2 was create and update silently DISAGREEING on the same input, and the fix re-established agreement with nothing pinning it. Both paths are now driven from one shared case list, plus an explicit test that create and update agree on every case — the per-path tests would both have passed while the two diverged, which is how the bug existed in the first place. Non-vacuity proven: inverting only the update path's validation fails 10 of 20 on a clean build (0 errors, so not a stale-dll pass), and the agreement test is among the failures. The rest is my own prose contradicting my own code. The commit that added EffectiveWeight removed the weight filter, then left four statements asserting a 0-weight source "is filtered out" — two of them authored by that same commit, including the stated justification for Minimum=1 in MultiCollectionItemWeight. A future agent could have read that and deleted the clamp or the floor as redundant; they are belt-and-braces and neither is. Corrected to describe what the code now does: the gate refuses input that means nothing on a share-of-airtime scale, the clamp protects rows predating the gate. Also corrected the writer count in the very bullet whose lesson is "grep every writer of the field": ReplaceBlockItems writes BlockItem.PlaybackOrder, not PlaylistItem.PlaybackOrder. There are TWO persisting writers of PlaylistItem's, and the correction itself had miscounted by conflating the two fields — so the lesson now says to grep each field separately. Core.Tests 565 passed, ErsatzTV.Tests 1673 passed, 0 failed. Refs #70 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
8.5 KiB
C#
221 lines
8.5 KiB
C#
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Search;
|
|
using ErsatzTV.Core.Scheduling;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Data.Repositories;
|
|
using ErsatzTV.Tests.Support;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Infrastructure;
|
|
|
|
/// <summary>
|
|
/// Pins that a multi collection's per-source <c>Weight</c> (#70) reaches the scheduler for BOTH kinds of
|
|
/// member. A multi collection holds plain collections and smart collections through two separate join
|
|
/// entities, and only the plain one is obvious — forget the smart mirror and smart-collection members
|
|
/// silently fall back to fair-share with nothing reporting it.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class MediaCollectionRepositoryWeightTests
|
|
{
|
|
private InMemoryTvContext _db;
|
|
|
|
[SetUp]
|
|
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
private static Movie Movie(int id) => new()
|
|
{
|
|
Id = id,
|
|
MovieMetadata = [],
|
|
MediaVersions = []
|
|
};
|
|
|
|
[Test]
|
|
public async Task Weight_Is_Carried_For_Both_Collection_And_SmartCollection_Members()
|
|
{
|
|
const int MultiCollectionId = 1;
|
|
const int CollectionId = 10;
|
|
const int SmartCollectionId = 20;
|
|
|
|
await using (TvContext context = _db.Factory.CreateDbContext())
|
|
{
|
|
context.Movies.Add(Movie(100));
|
|
|
|
context.Collections.Add(
|
|
new Collection
|
|
{
|
|
Id = CollectionId,
|
|
Name = "plain",
|
|
UseCustomPlaybackOrder = false,
|
|
CollectionItems = [new CollectionItem { CollectionId = CollectionId, MediaItemId = 100 }]
|
|
});
|
|
|
|
context.SmartCollections.Add(
|
|
new SmartCollection
|
|
{
|
|
Id = SmartCollectionId,
|
|
Name = "smart",
|
|
Query = "type:movie"
|
|
});
|
|
|
|
context.MultiCollections.Add(
|
|
new MultiCollection
|
|
{
|
|
Id = MultiCollectionId,
|
|
Name = "multi",
|
|
MultiCollectionItems =
|
|
[
|
|
new MultiCollectionItem
|
|
{
|
|
MultiCollectionId = MultiCollectionId,
|
|
CollectionId = CollectionId,
|
|
ScheduleAsGroup = true,
|
|
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
|
Weight = 5
|
|
}
|
|
],
|
|
MultiCollectionSmartItems =
|
|
[
|
|
new MultiCollectionSmartItem
|
|
{
|
|
MultiCollectionId = MultiCollectionId,
|
|
SmartCollectionId = SmartCollectionId,
|
|
ScheduleAsGroup = true,
|
|
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
|
Weight = 7
|
|
}
|
|
]
|
|
});
|
|
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
await using (TvContext context = _db.Factory.CreateDbContext())
|
|
{
|
|
MultiCollection multiCollection = context.MultiCollections
|
|
.Single(mc => mc.Id == MultiCollectionId);
|
|
|
|
context.Entry(multiCollection).Collection(mc => mc.MultiCollectionItems).Load();
|
|
context.Entry(multiCollection).Collection(mc => mc.MultiCollectionSmartItems).Load();
|
|
|
|
// the persisted weights must survive the round trip on BOTH join entities
|
|
multiCollection.MultiCollectionItems.Single().Weight.ShouldBe(5);
|
|
multiCollection.MultiCollectionSmartItems.Single().Weight.ShouldBe(7);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task Weight_Defaults_To_One_So_An_Existing_Row_Is_Fair_Share_Not_Dropped()
|
|
{
|
|
// the migration backfills 1 rather than 0, so a pre-existing member arrives as fair-share rather than
|
|
// carrying a weight that means nothing on a share-of-airtime scale. (The enumerator also clamps, so a
|
|
// 0 would rotate at the floor rather than vanish — but the backfill should be right at the source.)
|
|
const int MultiCollectionId = 2;
|
|
const int CollectionId = 30;
|
|
const int SmartCollectionId = 40;
|
|
|
|
await using (TvContext context = _db.Factory.CreateDbContext())
|
|
{
|
|
context.Collections.Add(new Collection { Id = CollectionId, Name = "plain2", CollectionItems = [] });
|
|
context.SmartCollections.Add(
|
|
new SmartCollection { Id = SmartCollectionId, Name = "smart2", Query = "type:movie" });
|
|
|
|
context.MultiCollections.Add(
|
|
new MultiCollection
|
|
{
|
|
Id = MultiCollectionId,
|
|
Name = "multi2",
|
|
MultiCollectionItems =
|
|
[
|
|
new MultiCollectionItem
|
|
{
|
|
MultiCollectionId = MultiCollectionId,
|
|
CollectionId = CollectionId,
|
|
ScheduleAsGroup = true,
|
|
PlaybackOrder = PlaybackOrder.Shuffle
|
|
}
|
|
],
|
|
MultiCollectionSmartItems =
|
|
[
|
|
new MultiCollectionSmartItem
|
|
{
|
|
MultiCollectionId = MultiCollectionId,
|
|
SmartCollectionId = SmartCollectionId,
|
|
ScheduleAsGroup = true,
|
|
PlaybackOrder = PlaybackOrder.Shuffle
|
|
}
|
|
]
|
|
});
|
|
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
await using (TvContext context = _db.Factory.CreateDbContext())
|
|
{
|
|
MultiCollection multiCollection = context.MultiCollections.Single(mc => mc.Id == MultiCollectionId);
|
|
context.Entry(multiCollection).Collection(mc => mc.MultiCollectionItems).Load();
|
|
context.Entry(multiCollection).Collection(mc => mc.MultiCollectionSmartItems).Load();
|
|
|
|
multiCollection.MultiCollectionItems.Single().Weight.ShouldBe(1);
|
|
multiCollection.MultiCollectionSmartItems.Single().Weight.ShouldBe(1);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetMultiCollectionCollections_Carries_Weight_Into_CollectionWithItems()
|
|
{
|
|
// the mapping tests above prove the column round-trips; this proves the repository actually hands the
|
|
// weight to the scheduler. The plain-collection branch and the smart-collection branch build
|
|
// CollectionWithItems at separate call sites, so each has to be checked on its own.
|
|
const int MultiCollectionId = 3;
|
|
const int CollectionId = 50;
|
|
|
|
await using (TvContext context = _db.Factory.CreateDbContext())
|
|
{
|
|
context.Movies.Add(Movie(500));
|
|
|
|
context.Collections.Add(
|
|
new Collection
|
|
{
|
|
Id = CollectionId,
|
|
Name = "weighted-plain",
|
|
UseCustomPlaybackOrder = false,
|
|
CollectionItems = [new CollectionItem { CollectionId = CollectionId, MediaItemId = 500 }]
|
|
});
|
|
|
|
context.MultiCollections.Add(
|
|
new MultiCollection
|
|
{
|
|
Id = MultiCollectionId,
|
|
Name = "weighted-multi",
|
|
MultiCollectionItems =
|
|
[
|
|
new MultiCollectionItem
|
|
{
|
|
MultiCollectionId = MultiCollectionId,
|
|
CollectionId = CollectionId,
|
|
ScheduleAsGroup = true,
|
|
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
|
Weight = 4
|
|
}
|
|
],
|
|
MultiCollectionSmartItems = []
|
|
});
|
|
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
var repository = new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _db.Factory);
|
|
|
|
List<CollectionWithItems> result =
|
|
await repository.GetMultiCollectionCollections(MultiCollectionId, CancellationToken.None);
|
|
|
|
CollectionWithItems collection = result.ShouldHaveSingleItem();
|
|
collection.Weight.ShouldBe(4);
|
|
}
|
|
}
|