using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using LanguageExt.UnsafeValueAccess;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
///
/// Pins the weighted / fair-share distribution contract (#70). The sequence semantics here are the
/// product decision, so they are asserted exactly rather than statistically.
///
[TestFixture]
public class WeightedShuffleCollectionEnumeratorTests
{
// ids are allocated per source so an emitted item's source is identifiable from its id
private const int SourceAFirstId = 100;
private const int SourceBFirstId = 200;
private const int SourceCFirstId = 300;
private static CollectionWithItems Source(string key, int firstId, int itemCount, int weight) =>
new(
0,
0,
key,
Enumerable.Range(firstId, itemCount)
.Select(i => new Movie { Id = i, MovieMetadata = [] })
.Cast()
.ToList(),
true,
PlaybackOrder.WeightedShuffle,
false,
weight);
private static string SourceOf(int id) => id switch
{
>= SourceCFirstId => "C",
>= SourceBFirstId => "B",
_ => "A"
};
private static List TakeSourceSequence(WeightedShuffleCollectionEnumerator enumerator, int count)
{
var result = new List();
for (var i = 0; i < count; i++)
{
enumerator.Current.IsSome.ShouldBeTrue();
result.Add(SourceOf(enumerator.Current.ValueUnsafe().Id));
enumerator.MoveNext(Option.None);
}
return result;
}
[Test]
public void Weights_Three_To_One_Emit_A_A_B_A()
{
// the canonical smooth-WRR contract: 3:1 spreads B through the rotation (A A B A),
// rather than draining A first (A A A B) the way PlaylistItem.Count does
var collections = new List
{
Source("A", SourceAFirstId, 3, 3),
Source("B", SourceBFirstId, 1, 1)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
CancellationToken.None);
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "A", "B", "A"]);
}
[Test]
public void Equal_Weights_Are_Fair_Share_Regardless_Of_Collection_Size()
{
// the heart of goal (2): a 20-item source must air as often as a 2-item source.
// ShuffleInOrder cannot do this -- it plays every item once, so airtime tracks size.
var collections = new List
{
Source("A", SourceAFirstId, 20, 1),
Source("B", SourceBFirstId, 2, 1)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
CancellationToken.None);
List sequence = TakeSourceSequence(enumerator, 40);
// equal weights => strict alternation, so the small source loops rather than falling silent
sequence.Count(s => s == "A").ShouldBe(20);
sequence.Count(s => s == "B").ShouldBe(20);
}
[Test]
public void Ties_Break_To_The_Earliest_Source_In_List_Order()
{
var collections = new List
{
Source("A", SourceAFirstId, 4, 1),
Source("B", SourceBFirstId, 4, 1),
Source("C", SourceCFirstId, 4, 1)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 99, Index = 0 },
CancellationToken.None);
// all accumulators tie every round, so list order decides
TakeSourceSequence(enumerator, 6).ShouldBe(["A", "B", "C", "A", "B", "C"]);
}
[Test]
public void An_Unweighted_Collection_Is_Fair_Share()
{
// a source arriving without an explicit weight must rotate as fair-share, never fall out
var collections = new List
{
new(0, 0, "A", [new Movie { Id = SourceAFirstId, MovieMetadata = [] }], true, PlaybackOrder.WeightedShuffle, false),
new(0, 0, "B", [new Movie { Id = SourceBFirstId, MovieMetadata = [] }], true, PlaybackOrder.WeightedShuffle, false)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 7, Index = 0 },
CancellationToken.None);
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "B", "A", "B"]);
}
[Test]
[TestCase(0)]
[TestCase(-5)]
public void A_Non_Positive_Weight_Does_Not_Delete_The_Source(int weight)
{
// the write path bounds weight, but a row can predate that gate. Treating a 0/negative weight as a
// filter would remove the source from the channel silently -- the exact failure this order avoids
// everywhere else. It is clamped to the floor instead.
var collections = new List
{
Source("A", SourceAFirstId, 2, 1),
Source("B", SourceBFirstId, 2, weight)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 21, Index = 0 },
CancellationToken.None);
TakeSourceSequence(enumerator, 8).ShouldContain("B");
}
[Test]
public void An_Enormous_Weight_Does_Not_Overflow_The_Rotation()
{
// summing unclamped weights is checked arithmetic, so an out-of-range row would throw from inside a
// playout build rather than merely schedule oddly
var collections = new List
{
Source("A", SourceAFirstId, 2, int.MaxValue),
Source("B", SourceBFirstId, 2, int.MaxValue)
};
WeightedShuffleCollectionEnumerator enumerator = null;
Should.NotThrow(() => enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 5, Index = 0 },
CancellationToken.None));
// both clamp to the ceiling, so they tie and alternate by list order
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "B", "A", "B"]);
}
[Test]
[TestCase(12)]
[TestCase(13)]
[TestCase(20)]
[TestCase(37)]
public void Restoring_Past_A_Rotation_Wrap_Equals_Advancing_To_It(int target)
{
// the stateless claim has to hold ACROSS a wrap, not just within the first rotation: each wrap
// re-derives the rotation from the new seed alone, so (Seed, Index) still determines position.
// Cycle length here is 12, so every case but the first crosses at least one wrap.
List Collections() =>
[
Source("A", SourceAFirstId, 5, 3),
Source("B", SourceBFirstId, 3, 1)
];
const int Seed = 909;
var advanced = new WeightedShuffleCollectionEnumerator(
Collections(),
new CollectionEnumeratorState { Seed = Seed, Index = 0 },
CancellationToken.None);
for (var i = 0; i < target; i++)
{
advanced.MoveNext(Option.None);
}
var restored = new WeightedShuffleCollectionEnumerator(
Collections(),
new CollectionEnumeratorState { Seed = advanced.State.Seed, Index = advanced.State.Index },
CancellationToken.None);
restored.Current.ValueUnsafe().Id.ShouldBe(advanced.Current.ValueUnsafe().Id);
}
[Test]
public void Restoring_At_An_Index_Equals_Advancing_To_It()
{
// the stateless contract: (Seed, Index) fully determines position, which is what lets the
// existing CollectionEnumeratorState persistence carry this order with no per-source counters
List Collections() =>
[
Source("A", SourceAFirstId, 5, 3),
Source("B", SourceBFirstId, 3, 1)
];
const int Seed = 4242;
const int Target = 7;
var advanced = new WeightedShuffleCollectionEnumerator(
Collections(),
new CollectionEnumeratorState { Seed = Seed, Index = 0 },
CancellationToken.None);
for (var i = 0; i < Target; i++)
{
advanced.MoveNext(Option.None);
}
var restored = new WeightedShuffleCollectionEnumerator(
Collections(),
new CollectionEnumeratorState { Seed = Seed, Index = Target },
CancellationToken.None);
restored.State.Index.ShouldBe(advanced.State.Index);
restored.State.Seed.ShouldBe(advanced.State.Seed);
restored.Current.ValueUnsafe().Id.ShouldBe(advanced.Current.ValueUnsafe().Id);
}
[Test]
public void A_Single_Source_Emits_All_Of_Its_Items()
{
var collections = new List { Source("A", SourceAFirstId, 5, 3) };
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 11, Index = 0 },
CancellationToken.None);
var seen = new System.Collections.Generic.HashSet();
for (var i = 0; i < 5; i++)
{
seen.Add(enumerator.Current.ValueUnsafe().Id);
enumerator.MoveNext(Option.None);
}
seen.Count.ShouldBe(5);
}
[Test]
public void An_Empty_Source_Is_Ignored_Rather_Than_Emitting_Nothing()
{
var collections = new List
{
Source("A", SourceAFirstId, 2, 1),
new(0, 0, "empty", [], true, PlaybackOrder.WeightedShuffle, false, 5)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 3, Index = 0 },
CancellationToken.None);
// a heavily-weighted empty source must not starve the rotation or emit None
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "A", "A", "A"]);
}
[Test]
[Timeout(10_000)]
public void Single_Item_Sources_Do_Not_Hang_On_Rotation_Wrap()
{
// regression: the wrap retries a rebuild to avoid an immediate repeat, but this order's lead item is
// decided by weight, so the heaviest source always leads. With one item in it the lead is invariant and
// an unbounded retry never terminates -- a hung playout build, not a wrong one. Found by the
// non-vacuity control, which hung instead of failing.
var collections = new List
{
Source("A", SourceAFirstId, 1, 3),
Source("B", SourceBFirstId, 1, 1)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
CancellationToken.None);
// walk several full rotations so the wrap path is exercised repeatedly
List sequence = TakeSourceSequence(enumerator, 24);
sequence.ShouldContain("A");
sequence.ShouldContain("B");
}
[Test]
public void No_Sources_Yields_No_Current()
{
var enumerator = new WeightedShuffleCollectionEnumerator(
[],
new CollectionEnumeratorState { Seed = 1, Index = 0 },
CancellationToken.None);
enumerator.Current.IsNone.ShouldBeTrue();
enumerator.Count.ShouldBe(0);
Should.NotThrow(() => enumerator.MoveNext(Option.None));
}
[Test]
public void A_Custom_Ordered_Source_Keeps_Its_Order()
{
var collections = new List
{
new(
0,
0,
"A",
Enumerable.Range(SourceAFirstId, 4)
.Select(i => new Movie { Id = i, MovieMetadata = [] })
.Cast()
.ToList(),
true,
PlaybackOrder.WeightedShuffle,
true,
1)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 555, Index = 0 },
CancellationToken.None);
var ids = new List();
for (var i = 0; i < 4; i++)
{
ids.Add(enumerator.Current.ValueUnsafe().Id);
enumerator.MoveNext(Option.None);
}
ids.ShouldBe([SourceAFirstId, SourceAFirstId + 1, SourceAFirstId + 2, SourceAFirstId + 3]);
}
}