using System.Diagnostics; using ErsatzTV.Core.FFmpeg; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Core.Tests.FFmpeg; [TestFixture] public class ColdStartStartupSplitTests { // milestones are Stopwatch.GetTimestamp() values; build them from a base + millisecond offsets private const long Base = 1_000_000_000; private static long At(double milliseconds) => Base + (long)(milliseconds / 1000.0 * Stopwatch.Frequency); [Test] public void Should_Split_Three_Ways_When_All_Milestones_Present() { ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps( At(0), At(150), At(1200), At(1600)); split.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay); split.Prep.TotalMilliseconds.ShouldBe(150, 1); split.FFmpegInit.TotalMilliseconds.ShouldBe(1050, 1); split.FirstGop.TotalMilliseconds.ShouldBe(400, 1); } [Test] public void Sub_Phases_Should_Sum_To_Run_Entry_Through_Playlist() { // deliberately NOT "should sum to startup": the buckets span the worker's Run entry, which // begins before the request thread's startup stopwatch, so prep overlaps the tail of setup ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps( At(0), At(150), At(1200), At(1600)); (split.Prep + split.FFmpegInit + split.FirstGop).TotalMilliseconds.ShouldBe(1600, 1); } [Test] public void Should_Be_Unavailable_When_The_Playlist_Predates_The_Process_Launch() { // a stale live.m3u8 survives when the handler's pre-session folder wipe fails (EmptyFolder // swallows the failure into a warning). Every bucket would be meaningless, so report nothing // rather than a plausible-looking sample with a prep that exceeds the whole measured phase ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps( At(0), At(1600), 0, At(150)); split.ShouldBe(ColdStartStartupSplit.Unavailable); } [Test] public void Stale_Playlist_Guard_Should_Take_Precedence_Over_The_Progress_Branches() { // without the guard, this input would be classified TwoWayLateProgress; the guard must be // evaluated first. (It can never preempt a ThreeWay: that requires processLaunched <= // playlistExists, which is exactly the negation of the guard condition.) ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps( At(0), At(1600), At(1700), At(150)); split.ShouldBe(ColdStartStartupSplit.Unavailable); } [Test] public void Should_Fall_Back_To_Two_Way_Split_When_Progress_Predates_The_Process_Launch() { // a progress timestamp older than the launch cannot belong to this process ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps( At(100), At(150), At(120), At(1600)); split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWay); split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1); split.FirstGop.ShouldBe(TimeSpan.Zero); } [Test] public void Should_Stay_Three_Way_When_Progress_Coincides_With_A_Boundary() { ColdStartStartupSplit atLaunch = ColdStartStartupSplit.FromTimestamps(At(0), At(150), At(150), At(1600)); atLaunch.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay); atLaunch.FFmpegInit.ShouldBe(TimeSpan.Zero); atLaunch.FirstGop.TotalMilliseconds.ShouldBe(1450, 1); ColdStartStartupSplit atPlaylist = ColdStartStartupSplit.FromTimestamps(At(0), At(150), At(1600), At(1600)); atPlaylist.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay); atPlaylist.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1); atPlaylist.FirstGop.ShouldBe(TimeSpan.Zero); } [Test] public void Should_Fall_Back_To_Two_Way_Split_When_FFmpeg_Never_Reported_Progress() { // no -progress output before the playlist appeared: ffmpegInit must absorb the remainder // rather than the split inventing a firstGop boundary that was never observed ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps( At(0), At(150), 0, At(1600)); split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWay); split.Prep.TotalMilliseconds.ShouldBe(150, 1); split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1); split.FirstGop.ShouldBe(TimeSpan.Zero); } [Test] public void Should_Report_Late_Progress_Distinctly_When_Progress_Arrived_After_The_Playlist() { // the playlist is observed on the request thread while progress is recorded on the worker // thread; a progress milestone outside the phase must not produce a negative bucket ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps( At(0), At(150), At(1800), At(1600)); split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWayLateProgress); split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1); split.FirstGop.ShouldBe(TimeSpan.Zero); } [TestCase(0L, 150L, 1200L, 1600L, TestName = "Run never started")] [TestCase(100L, 0L, 0L, 1600L, TestName = "Process never launched")] [TestCase(100L, 150L, 1200L, 0L, TestName = "Playlist never appeared")] public void Should_Be_Unavailable_When_A_Required_Milestone_Is_Missing( long runStarted, long processLaunched, long firstProgress, long playlistExists) { ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps( runStarted == 0 ? 0 : At(runStarted), processLaunched == 0 ? 0 : At(processLaunched), firstProgress == 0 ? 0 : At(firstProgress), playlistExists == 0 ? 0 : At(playlistExists)); split.ShouldBe(ColdStartStartupSplit.Unavailable); } [Test] public void Should_Clamp_Rather_Than_Report_A_Negative_Prep() { // defensive: launch cannot precede Run entry, but telemetry must never show a negative ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps( At(500), At(150), At(1200), At(1600)); split.Prep.ShouldBe(TimeSpan.Zero); split.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay); split.FFmpegInit.TotalMilliseconds.ShouldBe(1050, 1); split.FirstGop.TotalMilliseconds.ShouldBe(400, 1); } }