Files
qbtmud/Lantean.QBTMud.Test/Services/TorrentDataManagerContentsListTests.cs

395 lines
16 KiB
C#

using AwesomeAssertions;
using Lantean.QBitTorrentClient.Models;
using Lantean.QBTMud.Models;
using Lantean.QBTMud.Services;
using MudPriority = Lantean.QBTMud.Models.Priority;
using QbtPriority = Lantean.QBitTorrentClient.Models.Priority;
namespace Lantean.QBTMud.Test.Services
{
public class TorrentDataManagerContentsListTests
{
private readonly TorrentDataManager _target = new TorrentDataManager();
// ---------------------------
// CreateContentsList tests
// ---------------------------
[Fact]
public void GIVEN_NoFiles_WHEN_CreateContentsList_THEN_ReturnsEmpty()
{
// arrange
var files = Array.Empty<FileData>();
// act
var result = _target.CreateContentsList(files);
// assert
result.Should().NotBeNull();
result.Count.Should().Be(0);
}
[Fact]
public void GIVEN_SingleRootFile_WHEN_CreateContentsList_THEN_CreatesSingleFileNode()
{
// arrange
var files = new[]
{
new FileData(
index: 5,
name: "file1.mkv",
size: 100,
progress: 0.5f,
priority: QbtPriority.Normal,
isSeed: false,
pieceRange: Array.Empty<int>(),
availability: 3.2f)
};
// act
var result = _target.CreateContentsList(files);
// assert
result.Count.Should().Be(1);
result.ContainsKey("file1.mkv").Should().BeTrue();
var file = result["file1.mkv"];
file.IsFolder.Should().BeFalse();
file.Name.Should().Be("file1.mkv");
file.DisplayName.Should().Be("file1.mkv");
file.Index.Should().Be(5);
file.Path.Should().Be(""); // root
file.Level.Should().Be(0);
file.Size.Should().Be(100);
file.Progress.Should().Be(0.5f);
file.Availability.Should().Be(3.2f);
file.Priority.Should().Be(MudPriority.Normal);
}
[Fact]
public void GIVEN_NestedFiles_AND_UnwantedFolder_WHEN_CreateContentsList_THEN_Skips_Unwanted_And_Aggregates()
{
// arrange
var files = new[]
{
// ".unwanted" folder is skipped as a directory, but the file remains under "a"
new FileData(10, "a/.unwanted/skip.bin", 10, 0.8f, QbtPriority.Normal, false, Array.Empty<int>(), 2.0f),
new FileData(11, "a/b/c1.txt", 30, 0.4f, QbtPriority.High, false, Array.Empty<int>(), 1.0f),
new FileData(12, "a/b/c2.txt", 70, 0.9f, QbtPriority.DoNotDownload, false, Array.Empty<int>(), 1.5f),
};
// act
var result = _target.CreateContentsList(files);
// assert: keys present
result.ContainsKey("a").Should().BeTrue();
result.ContainsKey("a/b").Should().BeTrue();
result.ContainsKey("a/.unwanted/skip.bin").Should().BeTrue();
result.ContainsKey("a/b/c1.txt").Should().BeTrue();
result.ContainsKey("a/b/c2.txt").Should().BeTrue();
// NOTE: CreateContentsList aggregates using TOTAL size as denominator (not "active" size).
// For "a/b":
// size = 30 + 70 = 100
// progressSum = 0.4*30 (DND child excluded from sum) = 12
// availabilitySum = 1.0*30 = 30
// => progress = 12 / 100 = 0.12
// => availability = 30 / 100 = 0.3
// priority = Mixed (High vs DoNotDownload)
var ab = result["a/b"];
ab.IsFolder.Should().BeTrue();
ab.Level.Should().Be(1);
ab.Size.Should().Be(100);
ab.Progress.Should().BeApproximately(0.12f, 1e-6f);
ab.Availability.Should().BeApproximately(0.3f, 1e-6f);
ab.Priority.Should().Be(MudPriority.Mixed);
// For "a":
// children: "a/.unwanted/skip.bin" (size=10, p=0.8, avail=2.0, Normal)
// "a/b" (size=100, p=0.12, avail=0.3, Mixed)
// size = 110
// progressSum = 0.8*10 + 0.12*100 = 8 + 12 = 20 => progress = 20/110 ≈ 0.181818...
// availabilitySum = 2.0*10 + 0.3*100 = 20 + 30 = 50 => availability = 50/110 ≈ 0.454545...
// priority = Mixed (Normal vs Mixed)
var a = result["a"];
a.IsFolder.Should().BeTrue();
a.Level.Should().Be(0);
a.Size.Should().Be(110);
a.Progress.Should().BeApproximately(20f / 110f, 1e-6f);
a.Availability.Should().BeApproximately(50f / 110f, 1e-6f);
a.Priority.Should().Be(MudPriority.Mixed);
// folder indices are less than min file index (10); deeper folder created later => smaller index
a.Index.Should().BeLessThan(10);
ab.Index.Should().BeLessThan(10);
ab.Index.Should().BeLessThan(a.Index);
}
[Fact]
public void GIVEN_AllChildrenDoNotDownload_WHEN_CreateContentsList_THEN_FolderProgressAndAvailabilityAreZero_AndPriorityDND()
{
// arrange
var files = new[]
{
new FileData(4, "d/x.bin", 50, 0.5f, QbtPriority.DoNotDownload, false, Array.Empty<int>(), 0.9f),
new FileData(5, "d/y.bin", 50, 1.2f, QbtPriority.DoNotDownload, false, Array.Empty<int>(), 1.1f)
};
// act
var result = _target.CreateContentsList(files);
// assert
result.ContainsKey("d").Should().BeTrue();
var d = result["d"];
d.IsFolder.Should().BeTrue();
d.Size.Should().Be(100);
d.Progress.Should().Be(0f); // activeSize == 0
d.Availability.Should().Be(0f); // activeSize == 0
d.Priority.Should().Be(MudPriority.DoNotDownload);
}
// ---------------------------
// MergeContentsList tests
// ---------------------------
[Fact]
public void GIVEN_NoFiles_AND_EmptyContents_WHEN_MergeContentsList_THEN_ReturnsFalseAndNoChange()
{
// arrange
var files = Array.Empty<FileData>();
var contents = new Dictionary<string, ContentItem>();
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeFalse();
contents.Count.Should().Be(0);
}
[Fact]
public void GIVEN_NoFiles_AND_ExistingContents_WHEN_MergeContentsList_THEN_ClearsAndReturnsTrue()
{
// arrange
var contents = new Dictionary<string, ContentItem>
{
["folder"] = new ContentItem("folder", "folder", -1, MudPriority.Normal, 0, 0, 0, true, 0),
["folder/file.txt"] = new ContentItem("folder/file.txt", "file.txt", 10, MudPriority.Normal, 0.5f, 100, 1.0f, false, 1)
};
// act
var changed = _target.MergeContentsList(Array.Empty<FileData>(), contents);
// assert
changed.Should().BeTrue();
contents.Count.Should().Be(0);
}
[Fact]
public void GIVEN_NewFilesOnly_WHEN_MergeContentsList_THEN_AddsFilesAndDirectoriesAndAggregates()
{
// arrange
var files = new[]
{
new FileData(7, "root1.txt", 10, 0.6f, QbtPriority.Normal, false, Array.Empty<int>(), 2.0f),
new FileData(8, "folder1/file1.bin", 30, 0.4f, QbtPriority.High, false, Array.Empty<int>(), 1.0f),
new FileData(9, "folder1/file2.bin", 70, 0.9f, QbtPriority.DoNotDownload, false, Array.Empty<int>(), 1.5f)
};
var contents = new Dictionary<string, ContentItem>();
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
// keys present
contents.ContainsKey("root1.txt").Should().BeTrue();
contents.ContainsKey("folder1").Should().BeTrue();
contents.ContainsKey("folder1/file1.bin").Should().BeTrue();
contents.ContainsKey("folder1/file2.bin").Should().BeTrue();
// directory indexes should be < min file index (7)
contents["folder1"].Index.Should().BeLessThan(7);
// aggregation on folder1 (same math as earlier)
var folder = contents["folder1"];
folder.IsFolder.Should().BeTrue();
folder.Size.Should().Be(100);
folder.Progress.Should().BeApproximately(0.4f, 1e-6f);
folder.Availability.Should().BeApproximately(1.0f, 1e-6f);
folder.Priority.Should().Be(MudPriority.Mixed);
}
[Fact]
public void GIVEN_ExistingFile_WHEN_MergeContentsList_WithChanges_THEN_UpdatesInPlaceAndReturnsTrue()
{
// arrange
var contents = new Dictionary<string, ContentItem>
{
// pre-existing directory with expected rollup for the prior file state
["folder"] = new ContentItem("folder", "folder", -100, MudPriority.Normal, 0.50f, 100, 1.20f, true, 0),
// existing file which will be updated (progress delta > tolerance, size changed, availability delta > tolerance)
["folder/file.txt"] = new ContentItem("folder/file.txt", "file.txt", 10, MudPriority.Normal, 0.50f, 100, 1.20f, false, 1),
// an extra entry that should be removed (not seen)
["old.bin"] = new ContentItem("old.bin", "old.bin", 5, MudPriority.Normal, 0.1f, 10, 1.0f, false, 0),
};
var files = new[]
{
// same path but changed values
new FileData(10, "folder/file.txt", 120, 0.5003f /* > tol from 0.50 by 0.0003 */, QbtPriority.Normal, false, Array.Empty<int>(), 1.2003f)
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
contents.ContainsKey("folder/file.txt").Should().BeTrue();
contents.ContainsKey("folder").Should().BeTrue();
contents.ContainsKey("old.bin").Should().BeFalse(); // removed
var file = contents["folder/file.txt"];
file.Size.Should().Be(120);
file.Progress.Should().Be(0.5003f);
file.Availability.Should().Be(1.2003f);
// folder roll-up should update too:
var folder = contents["folder"];
folder.Size.Should().Be(120);
folder.Progress.Should().BeApproximately(0.5003f, 1e-6f);
folder.Availability.Should().BeApproximately(1.2003f, 1e-6f);
}
[Fact]
public void GIVEN_ExistingItemsWithNoMaterialChangeWithinTolerance_WHEN_MergeContentsList_THEN_ReturnsFalse()
{
// arrange
// file defines a folder rollup: size 100, progress 0.5000, availability 1.2000, Normal
var contents = new Dictionary<string, ContentItem>
{
["folder"] = new ContentItem("folder", "folder", -1, MudPriority.Normal, 0.5000f, 100, 1.2000f, true, 0),
["folder/file.txt"] = new ContentItem("folder/file.txt", "file.txt", 10, MudPriority.Normal, 0.5000f, 100, 1.2000f, false, 1)
};
var files = new[]
{
// diffs are within tolerance (0.0001f) -> should NOT update
new FileData(10, "folder/file.txt", 100, 0.50005f, QbtPriority.Normal, false, Array.Empty<int>(), 1.20005f)
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeFalse();
// nothing should have moved
contents["folder/file.txt"].Progress.Should().Be(0.5000f);
contents["folder/file.txt"].Availability.Should().Be(1.2000f);
contents["folder/file.txt"].Size.Should().Be(100);
contents["folder"].Progress.Should().Be(0.5000f);
contents["folder"].Availability.Should().Be(1.2000f);
contents["folder"].Size.Should().Be(100);
}
[Fact]
public void GIVEN_FileUnderUnwantedDirectory_WHEN_MergeContentsList_THEN_CreatesParentButSkipsUnwantedFolder()
{
// arrange
var contents = new Dictionary<string, ContentItem>();
var files = new[]
{
new FileData(20, "x/.unwanted/y.bin", 10, 0.8f, QbtPriority.Normal, false, Array.Empty<int>(), 2.0f)
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
contents.ContainsKey("x").Should().BeTrue();
contents.ContainsKey("x/.unwanted/y.bin").Should().BeTrue();
contents.Keys.Any(k => k.Contains(".unwanted") && k.EndsWith('/')).Should().BeFalse(); // no explicit ".unwanted" directory key
}
[Fact]
public void GIVEN_ProgressAboveOne_WHEN_MergeContentsList_THEN_DirectoryProgressIsClampedToOne()
{
// arrange
var contents = new Dictionary<string, ContentItem>();
var files = new[]
{
new FileData(2, "c/fileA", 10, 2.5f /* > 1 */, QbtPriority.Normal, false, Array.Empty<int>(), 3.0f)
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
var dir = contents["c"];
dir.Progress.Should().Be(1.0f); // clamped
dir.Size.Should().Be(10);
}
[Fact]
public void GIVEN_ProgressBelowZero_WHEN_MergeContentsList_THEN_DirectoryProgressIsClampedToZero()
{
// arrange
var contents = new Dictionary<string, ContentItem>();
var files = new[]
{
new FileData(3, "d/fileB", 10, -0.5f /* < 0 */, QbtPriority.Normal, false, Array.Empty<int>(), 3.0f)
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
var dir = contents["d"];
dir.Progress.Should().Be(0.0f); // clamped
dir.Size.Should().Be(10);
}
[Fact]
public void GIVEN_ExistingDirectories_THEN_NewFolderIndicesAreLessThanMinOfExistingAndIncoming()
{
// arrange
// existing directory with index -2 and an existing independent file with index 50
var contents = new Dictionary<string, ContentItem>
{
["exist"] = new ContentItem("exist", "exist", -2, MudPriority.Normal, 0f, 0L, 0f, true, 0),
["exist/keep.txt"] = new ContentItem("exist/keep.txt", "keep.txt", 50, MudPriority.Normal, 0.1f, 1L, 0.2f, false, 1)
};
// incoming files have min index 5
var files = new[]
{
new FileData(5, "new/f1", 10, 0.3f, QbtPriority.Normal, false, Array.Empty<int>(), 1.0f),
new FileData(9, "new/f2", 15, 0.6f, QbtPriority.Normal, false, Array.Empty<int>(), 1.0f),
new FileData(11, "exist/keep.txt", 1, 0.1f, QbtPriority.Normal, false, Array.Empty<int>(), 0.2f) // matches existing
};
// act
var changed = _target.MergeContentsList(files, contents);
// assert
changed.Should().BeTrue();
contents.ContainsKey("new").Should().BeTrue();
var minExistingIndex = contents.Values.Where(c => !c.IsFolder).Select(c => c.Index).DefaultIfEmpty(int.MaxValue).Min();
var newFolder = contents["new"];
newFolder.IsFolder.Should().BeTrue();
// index is computed from nextFolderIndex = Min(minExistingIndex, minFileIndex) - 1
// minExistingIndex (files) = min(50, 11) = 11 → min with minFileIndex(=5) => 5, so folder index < 5
newFolder.Index.Should().BeLessThan(5);
}
}
}