aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Controller.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Jellyfin.Controller.Tests')
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs60
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs113
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs97
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs91
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs60
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/TranscodingJobTests.cs56
6 files changed, 477 insertions, 0 deletions
diff --git a/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs
new file mode 100644
index 0000000000..272c434fe9
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Entities;
+
+[Collection("LibraryManagerTests")]
+public class AggregateFolderTests
+{
+ [Fact]
+ public void Children_ClearedAfterALibraryWasAdded_ListsTheNewLibrary()
+ {
+ var existing = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" };
+ var added = new Folder { Id = Guid.NewGuid(), Path = "/libraries/collections" };
+
+ // What the repository holds grows once the new library has been resolved and stored.
+ var stored = new List<BaseItem> { existing };
+
+ var itemRepository = new Mock<IItemRepository>();
+ itemRepository.Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(() => stored.ToList());
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetItemById(It.IsAny<Guid>()))
+ .Returns((Guid id) => stored.Find(i => i.Id.Equals(id)));
+
+ BaseItem.ItemRepository = itemRepository.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" };
+
+ Assert.Equal([existing.Id], root.Children.Select(i => i.Id));
+
+ stored.Add(added);
+ root.Children = null;
+
+ // Null-forgiving: the setter takes null to mean "drop the cache", the getter reloads.
+ Assert.Equal([existing.Id, added.Id], root.Children!.Select(i => i.Id));
+ }
+
+ [Fact]
+ public void Children_AssignedASet_KeepsThatSet()
+ {
+ var itemRepository = new Mock<IItemRepository>(MockBehavior.Strict);
+ BaseItem.ItemRepository = itemRepository.Object;
+
+ var assigned = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" };
+ var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" };
+
+ root.Children = [assigned];
+
+ // Never goes to the repository, so the strict mock stays unused.
+ Assert.Equal([assigned.Id], root.Children.Select(i => i.Id));
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index 86bac4256a..c67717e2fa 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -26,8 +27,70 @@ using Xunit;
namespace Jellyfin.Controller.Tests.Entities;
+[Collection("LibraryManagerTests")]
public class BaseItemTests
{
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ [InlineData(true, true)]
+ public async Task ValidateChildren_FailedEnumeration_DoesNotReconcileOrDeleteChildren(bool failAfterFirstChild, bool accessDenied)
+ {
+ var previousLibrary = BaseItem.LibraryManager;
+ var previousRepository = BaseItem.ItemRepository;
+ var previousLogger = BaseItem.Logger;
+ var previousMediaSourceManager = BaseItem.MediaSourceManager;
+ var library = new Mock<ILibraryManager>(MockBehavior.Strict);
+ var repository = new Mock<MediaBrowser.Controller.Persistence.IItemRepository>(MockBehavior.Strict);
+ var directory = new Mock<IDirectoryService>();
+ directory.Setup(d => d.IsAccessible(It.IsAny<string>())).Returns(true);
+
+ // IsLibraryFolderAccessible reads FileNameWithoutExtension, which resolves the path protocol
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ try
+ {
+ BaseItem.LibraryManager = library.Object;
+ BaseItem.ItemRepository = repository.Object;
+ BaseItem.Logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<BaseItem>.Instance;
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ var folder = new FailingEnumerationFolder(failAfterFirstChild, accessDenied)
+ {
+ Id = Guid.NewGuid(),
+ Path = "/media/review-folder"
+ };
+ await folder.ValidateChildren(new Progress<double>(), new MetadataRefreshOptions(directory.Object), recursive: false, cancellationToken: TestContext.Current.CancellationToken).ConfigureAwait(true);
+ Assert.True(folder.EnumerationAttempted);
+ repository.VerifyNoOtherCalls();
+ library.VerifyNoOtherCalls();
+ }
+ finally
+ {
+ BaseItem.LibraryManager = previousLibrary;
+ BaseItem.ItemRepository = previousRepository;
+ BaseItem.Logger = previousLogger;
+ BaseItem.MediaSourceManager = previousMediaSourceManager;
+ }
+ }
+
+ [Fact]
+ public void SetPrimaryVersionId_Null_RestoresTheItemsOwnPresentationKey()
+ {
+ var primaryId = Guid.NewGuid();
+ var video = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv" };
+
+ // While it is a version, it presents as the primary so lists collapse the two together.
+ video.SetPrimaryVersionId(primaryId);
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey);
+
+ // Promoting it back has to restore its own key, or it keeps collapsing onto - and staying
+ // hidden behind - a primary it no longer belongs to.
+ video.SetPrimaryVersionId(null);
+ Assert.Null(video.PrimaryVersionId);
+ Assert.Equal(video.Id.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey);
+ }
+
[Fact]
public void GetItemByNameFolderName_ShortName_IsKeptAsIs()
{
@@ -185,6 +248,17 @@ public class BaseItemTests
"Blade Runner (1982) [EE by ADM] [480p HEVC AAC]",
"[Final Cut] [1080p HEVC AAC]",
"[EE by ADM] [480p HEVC AAC]")]
+ // Numeric version labels: the dot between the digits is a decimal point, not a delimiter, so the
+ // prefix retreats past it to the '-' instead of leaving "0" / "11".
+ [InlineData(
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0",
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11",
+ "1.0",
+ "1.11")]
+ // Numeric labels with no structural delimiter at all fall back to the space boundary.
+ [InlineData("Movie (2007) 1.0", "Movie (2007) 1.11", "1.0", "1.11")]
+ // A dot followed by a non-digit is still a delimiter, even after a digit.
+ [InlineData("Movie - Part 1.HDR", "Movie - Part 1.SDR", "HDR", "SDR")]
public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt)
{
var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv";
@@ -216,6 +290,24 @@ public class BaseItemTests
}
[Fact]
+ public void GetCommonVersionPrefix_NumericLabels_KeepsWholeNumber()
+ {
+ // Three versions labelled "1.0", "1.01" and "1.11": the common prefix stops inside the version
+ // number, so it must retreat past the decimal point to the '-' delimiter.
+ string[] fileNames =
+ [
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0",
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.01",
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11"
+ ];
+
+ var prefix = BaseItem.GetCommonVersionPrefix(fileNames);
+
+ Assert.Equal("Evangelion 1.0 You Are (Not) Alone (2007) -", prefix);
+ Assert.Equal(["1.0", "1.01", "1.11"], fileNames.Select(n => n[prefix.Length..].TrimStart(' ')));
+ }
+
+ [Fact]
public void GetAlternateVersion_ReturnsMatchingLocalVersion()
{
var (primary, alt1, alt2) = SetupVersionGroup();
@@ -644,4 +736,25 @@ public class BaseItemTests
Assert.Equal([primary.Id, alt1.Id, alt2.Id], ids);
}
+
+ private sealed class FailingEnumerationFolder(bool failAfterFirstChild, bool accessDenied) : Folder
+ {
+ public bool EnumerationAttempted { get; private set; }
+
+ protected override IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService)
+ {
+ EnumerationAttempted = true;
+ if (failAfterFirstChild)
+ {
+ yield return new Movie { Id = Guid.NewGuid(), Path = "/media/review-folder/movie.mkv" };
+ }
+
+ if (accessDenied)
+ {
+ throw new System.Security.SecurityException("Simulated access failure");
+ }
+
+ throw new IOException("Simulated directory read failure");
+ }
+ }
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs
new file mode 100644
index 0000000000..705238317a
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs
@@ -0,0 +1,97 @@
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Entities;
+
+/// <summary>
+/// Covers <see cref="Folder.ReleaseCachedChildren"/>, which a recursive scan calls as it unwinds so
+/// the folders it walked do not keep the whole item graph of the library alive behind it.
+/// </summary>
+public class FolderChildCacheTests
+{
+ [Fact]
+ public void ReleaseCachedChildren_MakesTheNextAccessReload()
+ {
+ var folder = new TrackingFolder();
+
+ Assert.Empty(folder.Children);
+ Assert.Equal(1, folder.LoadCount);
+
+ // Second access is served from the cache on the instance.
+ Assert.Empty(folder.Children);
+ Assert.Equal(1, folder.LoadCount);
+
+ folder.ReleaseCachedChildren();
+
+ Assert.Empty(folder.Children);
+ Assert.Equal(2, folder.LoadCount);
+ }
+
+ [Fact]
+ public void ReleaseCachedChildren_ReachesEveryLevelBelow()
+ {
+ var leaf = new TrackingFolder();
+ var middle = new TrackingFolder { Source = [leaf] };
+ var root = new TrackingFolder { Source = [middle] };
+
+ // Walk the whole tree, as a recursive scan does, so every level holds its children.
+ Assert.Single(root.Children);
+ Assert.Single(middle.Children);
+ Assert.Empty(leaf.Children);
+ Assert.Equal(1, root.LoadCount);
+ Assert.Equal(1, middle.LoadCount);
+ Assert.Equal(1, leaf.LoadCount);
+
+ root.ReleaseCachedChildren();
+
+ Assert.Single(root.Children);
+ Assert.Single(middle.Children);
+ Assert.Empty(leaf.Children);
+ Assert.Equal(2, root.LoadCount);
+ Assert.Equal(2, middle.LoadCount);
+ Assert.Equal(2, leaf.LoadCount);
+ }
+
+ [Fact]
+ public void ReleaseCachedChildren_LoadsNothingThatIsNotAlreadyHeld()
+ {
+ var leaf = new TrackingFolder();
+ var root = new TrackingFolder { Source = [leaf] };
+
+ root.ReleaseCachedChildren();
+
+ Assert.Equal(0, root.LoadCount);
+ Assert.Equal(0, leaf.LoadCount);
+ }
+
+ [Fact]
+ public void ReleaseCachedChildren_TerminatesOnACycle()
+ {
+ var first = new TrackingFolder();
+ var second = new TrackingFolder { Source = [first] };
+ first.Source = [second];
+
+ Assert.Single(first.Children);
+ Assert.Single(second.Children);
+
+ // Clearing before descending is what stops this from recursing forever.
+ first.ReleaseCachedChildren();
+
+ Assert.Equal(1, first.LoadCount);
+ Assert.Equal(1, second.LoadCount);
+ }
+
+ private sealed class TrackingFolder : Folder
+ {
+ public int LoadCount { get; private set; }
+
+ public IReadOnlyList<BaseItem> Source { get; set; } = [];
+
+ protected override IReadOnlyList<BaseItem> LoadChildren()
+ {
+ LoadCount++;
+ return Source;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs
new file mode 100644
index 0000000000..1794469359
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Linq;
+using Jellyfin.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Entities;
+
+[Collection("LibraryManagerTests")]
+public sealed class PlaylistTests : IDisposable
+{
+ private readonly ILibraryManager? _previousLibraryManager = BaseItem.LibraryManager;
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager;
+ }
+
+ [Fact]
+ public void IsVisible_PlaylistWithNothingLeftInIt_IsHidden()
+ {
+ // The SQL parental filter hides a container whose every member is blocked, so a listing
+ // built in memory has to reach the same answer.
+ var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" };
+ SetupLibrary(blocked);
+
+ Assert.False(BuildPlaylist(blocked).IsVisible(BuildRestrictedUser()));
+ }
+
+ [Fact]
+ public void IsVisible_PlaylistWithOneAllowedItem_StaysVisible()
+ {
+ var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" };
+ var allowed = new Audio { Id = Guid.NewGuid(), Name = "Song" };
+ SetupLibrary(blocked, allowed);
+
+ Assert.True(BuildPlaylist(blocked, allowed).IsVisible(BuildRestrictedUser()));
+ }
+
+ [Fact]
+ public void IsVisible_UnrestrictedUser_LeavesTheItemsUnresolved()
+ {
+ var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" };
+ var libraryManager = SetupLibrary(blocked);
+ var user = new User("user", "auth-provider", "reset-provider");
+
+ Assert.True(BuildPlaylist(blocked).IsVisible(user));
+
+ // Resolving a playlist's items is a query per playlist; nothing may run it for a user no
+ // rating keeps anything from.
+ libraryManager.Verify(x => x.GetItemList(It.IsAny<InternalItemsQuery>()), Times.Never);
+ }
+
+ private static Mock<ILibraryManager> SetupLibrary(params BaseItem[] items)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager
+ .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(items);
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ return libraryManager;
+ }
+
+ private static Playlist BuildPlaylist(params BaseItem[] items)
+ {
+ // An empty path keeps the playlist out of the shared-playlist branch.
+ return new Playlist
+ {
+ Id = Guid.NewGuid(),
+ Name = "Playlist",
+ LinkedChildren = items.Select(LinkedChild.Create).ToArray()
+ };
+ }
+
+ private static User BuildRestrictedUser()
+ {
+ var user = new User("user", "auth-provider", "reset-provider") { MaxParentalRatingScore = 5 };
+ user.SetPreference(PreferenceKind.BlockUnratedItems, new[] { UnratedItem.Movie });
+
+ return user;
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
index 2b009b4673..aca55fbcc1 100644
--- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
@@ -261,6 +261,66 @@ public class EncodingHelperTests
Assert.Contains("-ar 48000", args, StringComparison.Ordinal);
}
+ [Fact]
+ public void GetProgressiveAudioFullCommandLine_StereoDownmix_AppliesDownMixAlgorithm()
+ {
+ // Issue #18194: -ac 2 alone drops the LFE channel, the configured downmix filter must be applied.
+ var state = BuildAudioState("aac", 48000);
+ state.AudioStream.Channels = 6;
+ state.AudioStream.ChannelLayout = "5.1";
+ state.OutputAudioChannels = 2;
+ var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750, DownMixAudioBoost = 1 };
+ var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, options, "/tmp/out");
+
+ Assert.Contains(
+ "-af \"" + DownMixAlgorithmsHelper.AlgorithmFilterStrings[(DownMixStereoAlgorithms.Dave750, "5.1")] + "\"",
+ args,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetProgressiveAudioFullCommandLine_NoDownmix_EmitsNoAudioFilter()
+ {
+ var state = BuildAudioState("aac", 48000);
+ state.AudioStream.Channels = 2;
+ state.OutputAudioChannels = 2;
+ var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 };
+ var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, options, "/tmp/out");
+
+ Assert.DoesNotContain("-af", args, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(6)]
+ public void GetProgressiveVideoAudioArguments_NonStereoOutput_KeepsChannelCount(int outputChannels)
+ {
+ // The downmix filter only applies to stereo output, so -ac must not be dropped otherwise.
+ var state = BuildAudioState("aac", 48000);
+ state.AudioStream.Channels = 6;
+ state.AudioStream.ChannelLayout = "5.1";
+ state.OutputAudioChannels = outputChannels;
+ var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 };
+ var args = CreateHelper().GetProgressiveVideoAudioArguments(state, options);
+
+ Assert.Contains("-ac " + outputChannels, args, StringComparison.Ordinal);
+ Assert.DoesNotContain("pan=", args, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetProgressiveVideoAudioArguments_StereoDownmix_UsesFilterInsteadOfChannelCount()
+ {
+ var state = BuildAudioState("aac", 48000);
+ state.AudioStream.Channels = 6;
+ state.AudioStream.ChannelLayout = "5.1";
+ state.OutputAudioChannels = 2;
+ var options = new EncodingOptions { DownMixStereoAlgorithm = DownMixStereoAlgorithms.Dave750 };
+ var args = CreateHelper().GetProgressiveVideoAudioArguments(state, options);
+
+ Assert.DoesNotContain("-ac ", args, StringComparison.Ordinal);
+ Assert.Contains("pan=stereo", args, StringComparison.Ordinal);
+ }
+
private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate, string? outputContainer = null)
{
var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 };
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/TranscodingJobTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/TranscodingJobTests.cs
new file mode 100644
index 0000000000..9dc279a5b2
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/TranscodingJobTests.cs
@@ -0,0 +1,56 @@
+using System;
+using MediaBrowser.Controller.MediaEncoding;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.MediaEncoding;
+
+public class TranscodingJobTests
+{
+ private static TranscodingJob CreateJob()
+ => new TranscodingJob(NullLogger<TranscodingJob>.Instance);
+
+ [Fact]
+ public void GetHighestServedSegmentIndexEndingAtOrBefore_NoSegmentsServed_ReturnsNull()
+ {
+ using var job = CreateJob();
+
+ Assert.Null(job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromHours(1).Ticks));
+ }
+
+ [Fact]
+ public void GetHighestServedSegmentIndexEndingAtOrBefore_InitSegment_IsIgnored()
+ {
+ using var job = CreateJob();
+ job.ReportSegmentDownloaded(-1, 0);
+
+ Assert.Null(job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromHours(1).Ticks));
+ }
+
+ [Fact]
+ public void GetHighestServedSegmentIndexEndingAtOrBefore_SegmentEndingExactlyAtPosition_IsIncluded()
+ {
+ using var job = CreateJob();
+ job.ReportSegmentDownloaded(0, TimeSpan.FromSeconds(6).Ticks);
+ job.ReportSegmentDownloaded(1, TimeSpan.FromSeconds(12).Ticks);
+
+ Assert.Equal(1, job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromSeconds(12).Ticks));
+ Assert.Equal(0, job.GetHighestServedSegmentIndexEndingAtOrBefore(TimeSpan.FromSeconds(11.9).Ticks));
+ }
+
+ [Fact]
+ public void GetHighestServedSegmentIndexEndingAtOrBefore_SegmentsLongerThanDesired_ReturnsServedIndex()
+ {
+ // Keyframe-based playlists average longer segments than the desired 6s.
+ const double SegmentSeconds = 6.6;
+ using var job = CreateJob();
+ for (var i = 0; i < 520; i++)
+ {
+ job.ReportSegmentDownloaded(i, TimeSpan.FromSeconds((i + 1) * SegmentSeconds).Ticks);
+ }
+
+ var keepFrom = TimeSpan.FromSeconds((520 * SegmentSeconds) - 120);
+
+ Assert.Equal(500, job.GetHighestServedSegmentIndexEndingAtOrBefore(keepFrom.Ticks));
+ }
+}