Enhance GridScheduleGenerator and related components for improved slot handling and cursor management
ci / build-backend (push) Successful in 1m21s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 1m40s
ci / sonar (push) Successful in 3m46s

Updated the GridScheduleGenerator to support rebuilding future schedules while correctly managing aired positions. Introduced a new AiredPosition record to track the last aired state of slots, ensuring that the cursor rewinds to the correct position during rebuilds. Refactored the BuildInputAsync method to accept aired positions, and modified the LoadAiredPositionsAsync method for accurate retrieval of past entries. Enhanced the SchedulePlanner to utilize shared cursor states between main and background loops, preventing duplicate series plays. Updated documentation to reflect these changes and added integration tests to verify the correct behavior of the new functionality.
This commit is contained in:
Leonid Pershin
2026-07-29 08:57:46 +03:00
parent c67d1f7752
commit fbfc6e677e
11 changed files with 568 additions and 117 deletions
@@ -368,6 +368,22 @@ public class SchedulePlannerTests
);
}
/// <summary>Источник нашёлся, но слот короче — это другая беда, и молчать о ней нельзя.</summary>
[Fact]
public void RepeatSlot_WithSourceTooLongForSlot_Warns()
{
var evening = Slot(T0, 60, [Element(1, 60)]);
var morning = Slot(T0.AddHours(2), 30, []) with
{
SlotKind = SlotKind.Repeat,
RepeatWindow = new PlanningRepeatWindow(T0, T0.AddHours(1)),
};
var result = Run(Input([evening, morning], horizonHours: 4));
Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty);
}
/// <summary>
/// Старты эластичные: фильм законно начинается раньше объявленного времени, и окно повтора
/// обязано ловить его по пересечению, а не по попаданию старта внутрь.
@@ -391,6 +407,96 @@ public class SchedulePlannerTests
);
}
/// <summary>
/// Слот приходит по экземпляру на каждые свои сутки, и курсор у всех этих экземпляров один —
/// снимок из базы. Без общей позиции вторник переигрывал бы понедельник.
/// </summary>
[Fact]
public void SameSlot_ContinuesAcrossItsDays_WithinOneRun()
{
var element = Element(4, 30);
var monday = Slot(T0, 60, [element]);
var tuesday = monday with { TargetStartUtc = T0.AddHours(3) };
var result = Run(Input([monday, tuesday], horizonHours: 6));
var played = result
.Items.Where(i => i.Kind == PlannedItemKind.Program && i.SlotId == monday.SlotId)
.Select(i => i.MediaAssetId)
.ToList();
Assert.Equal(4, played.Count);
Assert.Equal(4, played.Distinct().Count());
}
/// <summary>
/// Тот же слот бывает перекрыт в одни сутки и свободен в другие: фон и основной цикл ведут его
/// вместе, и второй счётчик позиции переигрывал бы уже поставленные серии.
/// </summary>
[Fact]
public void Background_AndMainLoop_ShareOneSlotPosition()
{
var element = Element(8, 30);
var background = Slot(T0, 360, [element]);
var main = background with { TargetStartUtc = T0.AddHours(2), TargetDurationMinutes = 60 };
var result = Run(Input([main], horizonHours: 4, fallback: [], background: [background]));
var played = result
.Items.Where(i => i.Kind == PlannedItemKind.Program)
.Select(i => i.MediaAssetId)
.ToList();
Assert.Equal(8, played.Count);
Assert.Equal(8, played.Distinct().Count());
}
/// <summary>Остывание обязано видеть то, что прогон поставил сам: горизонт строится разом.</summary>
[Fact]
public void Cooldown_CountsPlaysFromTheSameRun()
{
var first = Slot(
T0,
60,
[Element(1, 60, position: 0), Element(1, 60, position: 1)],
strategy: SlotStrategyKind.RandomWithCooldown,
cooldownDays: 14
);
var second = first with { SlotId = Guid.NewGuid(), TargetStartUtc = T0.AddHours(2) };
var result = Run(Input([first, second], horizonHours: 4));
var shows = result
.Items.Where(i => i.Kind == PlannedItemKind.Program)
.Select(i => i.ShowId)
.ToList();
Assert.Equal(2, shows.Count);
Assert.Equal(2, shows.Distinct().Count());
}
/// <summary>Потолок повторов — тот же случай: показы прогона считаются наравне с записанными.</summary>
[Fact]
public void RepeatLimit_CountsPlaysFromTheSameRun()
{
var first = Slot(
T0,
60,
[Element(1, 60, position: 0), Element(1, 60, position: 1)],
strategy: SlotStrategyKind.RandomWithCooldown
) with
{
RepeatLimit = new RepeatLimit(WindowDays: 7, Max: 1),
};
var second = first with { SlotId = Guid.NewGuid(), TargetStartUtc = T0.AddHours(2) };
var result = Run(Input([first, second], horizonHours: 4));
var shows = result
.Items.Where(i => i.Kind == PlannedItemKind.Program)
.Select(i => i.ShowId)
.ToList();
Assert.Equal(2, shows.Count);
Assert.Equal(2, shows.Distinct().Count());
}
[Fact]
public void Trace_IsWrittenWithSlotAndStrategy()
{
@@ -115,6 +115,54 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
Assert.Equal(past.Count, survived.Count);
}
/// <summary>
/// Пересборка выбрасывает хвост, который курсор уже прошёл. Без отмотки к отыгранному каждое
/// применение проматывало бы библиотеку на горизонт вперёд, теряя невышедшие серии.
/// </summary>
[SkippableFact]
public async Task Generate_Rebuild_RewindsCursorToWhatActuallyAired()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
// Серий заведомо больше, чем влезет в горизонт: иначе сериал успеет пойти по кругу,
// и «продолжает с той же серии» проверять станет нечем.
var world = await SeedAsync(seedDb, episodes: 100);
// Первый прогон строит сутки вперёд от момента двухчасовой давности.
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
await using var beforeDb = fixture.CreateContext();
var aired = beforeDb
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc < Now
)
.OrderByDescending(e => e.StartsAtUtc)
.First();
var beforeRebuild = beforeDb.SlotStates.Single(s => s.SlotId == world.SlotId);
// Курсор ушёл за эфир: он прошёл весь построенный хвост, а не только вышедшее.
Assert.True(beforeRebuild.NextUnitIndex > aired.EpisodeIndex + 1);
await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext();
var firstAfterRebuild = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc >= Now
)
.OrderBy(e => e.StartsAtUtc)
.First();
// Пересобранный эфир продолжает ровно с той серии, что шла следующей по отыгранному.
Assert.Equal(aired.EpisodeIndex + 1, firstAfterRebuild.EpisodeIndex);
}
[SkippableFact]
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
{
@@ -121,6 +121,7 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
[SkippableFact]
public async Task DeleteShow_WithSchedule_CutsFutureEntries()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
var channel = Channel.Create("Канал", $"cut-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
var show = Show.Create("Шоу", ShowKind.Single);
var asset = MediaAsset.Register("live.mkv", ".mkv", MediaSource.Upload);
@@ -181,6 +182,7 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
[SkippableFact]
public async Task DeleteShow_WithMedia_RemovesEverything()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
var channel = Channel.Create("Канал", $"del-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
var show = Show.Create("Шоу", ShowKind.Single);
var asset = MediaAsset.Register("movie.mkv", ".mkv", MediaSource.Upload);