using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using PLib.Application.Abstractions; using PLib.Application.Library; using PLib.Domain.Videos; using Shouldly; namespace PLib.Tests.Library; public sealed class DuplicateDetectionTests { private readonly InMemoryVideoRepository _videos = new(); [Fact] public void Distance_counts_the_bits_that_differ() { var left = Video("a", 0b1111); var right = Video("b", 0b1010); left.DistanceTo(right).ShouldBe(2); } [Fact] public void A_video_without_a_hash_has_no_distance_to_anything() { var hashed = Video("a", 1); var bare = new VideoItem(@"C:\videos\b.mp4", "b", 1, DateTimeOffset.UnixEpoch); hashed.DistanceTo(bare).ShouldBeNull(); bare.DistanceTo(hashed).ShouldBeNull(); } [Fact] public async Task Near_identical_videos_are_grouped_and_unrelated_ones_are_not() { _videos.Seed( Video("original", 0b0000_0000), Video("re-encode", 0b0000_0011), Video("unrelated", 0b1111_1111)); var groups = await CreateService().FindDuplicatesAsync(maxDistance: 4, Token); groups.ShouldHaveSingleItem(); groups[0].Select(x => x.Title).OrderBy(x => x).ShouldBe(["original", "re-encode"]); } [Fact] public async Task A_chain_of_near_matches_ends_up_in_one_group() { // Ends are 4 bits apart — further than the threshold — but the middle copy links // them, and all three are the same film. _videos.Seed( Video("a", 0b0000_0000), Video("b", 0b0000_0011), Video("c", 0b0000_1111)); var groups = await CreateService().FindDuplicatesAsync(maxDistance: 2, Token); groups.ShouldHaveSingleItem(); groups[0].Count.ShouldBe(3); } [Fact] public async Task Videos_that_were_never_hashed_are_left_out_entirely() { _videos.Seed( new VideoItem(@"C:\videos\x.mp4", "x", 1, DateTimeOffset.UnixEpoch), new VideoItem(@"C:\videos\y.mp4", "y", 1, DateTimeOffset.UnixEpoch)); var groups = await CreateService().FindDuplicatesAsync(maxDistance: 64, Token); // Two hashless videos are not evidence of anything, however wide the threshold. groups.ShouldBeEmpty(); } private static CancellationToken Token => TestContext.Current.CancellationToken; private static VideoItem Video(string title, ulong hash) { var item = new VideoItem($@"C:\videos\{title}.mp4", title, 1, DateTimeOffset.UnixEpoch); item.ApplyPerceptualHash(hash); return item; } private LibraryService CreateService() => new( _videos, new InMemoryLabelRepository(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), Options.Create(new LibraryOptions()), NullLogger.Instance); }