diff --git a/AGENTS.md b/AGENTS.md index 9d27e989..bb5b1d78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,8 @@ This is a C#/.NET 8 solution with a client-server architecture: ### Overview This repository uses a branch-based development workflow. Please follow the conventions below when working in this project, especially when creating branches, writing PR titles, and generating commits or documentation. -Agents must not create branches until explicitly instructed to do so by a human. +Agents must not create branches until they have received clear instructions to do so from a human or an explicitly +invoked skill. ### Branch Naming Use the following prefixes: diff --git a/src/ByteSync.Client/Business/Inventories/InventoryProcessData.cs b/src/ByteSync.Client/Business/Inventories/InventoryProcessData.cs index 44102652..db331777 100644 --- a/src/ByteSync.Client/Business/Inventories/InventoryProcessData.cs +++ b/src/ByteSync.Client/Business/Inventories/InventoryProcessData.cs @@ -13,6 +13,8 @@ public class InventoryProcessData : ReactiveObject { private readonly object _monitorDataLock = new object(); private readonly ConcurrentQueue _skippedEntries = new(); + private readonly ConcurrentDictionary _skippedCountsByReason = new(); + private int _skippedCount; public InventoryProcessData() { @@ -105,6 +107,8 @@ public List? Inventories public IReadOnlyCollection SkippedEntries => _skippedEntries.ToArray(); + public int SkippedCount => _skippedCount; + [Reactive] public DateTimeOffset InventoryStart { get; set; } @@ -141,8 +145,16 @@ public void Reset() public void RecordSkippedEntry(SkippedEntry entry) { _skippedEntries.Enqueue(entry); + _skippedCountsByReason.AddOrUpdate(entry.Reason, 1, (_, currentCount) => currentCount + 1); + Interlocked.Increment(ref _skippedCount); } - + + // should be used during issue 268 implementation + public int GetSkippedCountByReason(SkipReason reason) + { + return _skippedCountsByReason.GetValueOrDefault(reason, 0); + } + public void SetError(Exception exception) { LastException = exception; @@ -166,6 +178,8 @@ private void ClearSkippedEntries() while (_skippedEntries.TryDequeue(out _)) { } + + _skippedCountsByReason.Clear(); + Interlocked.Exchange(ref _skippedCount, 0); } -} - +} \ No newline at end of file diff --git a/src/ByteSync.Client/Models/Inventories/InventoryPart.cs b/src/ByteSync.Client/Models/Inventories/InventoryPart.cs index 98683bcb..c2ebbe6b 100644 --- a/src/ByteSync.Client/Models/Inventories/InventoryPart.cs +++ b/src/ByteSync.Client/Models/Inventories/InventoryPart.cs @@ -10,29 +10,40 @@ public InventoryPart() { FileDescriptions = new List(); DirectoryDescriptions = new List(); + SkippedCountsByReason = new Dictionary(); } - + public InventoryPart(Inventory inventory, string rootPath, FileSystemTypes inventoryPartType) : this() { Inventory = inventory; RootPath = rootPath; InventoryPartType = inventoryPartType; } - + public Inventory Inventory { get; set; } - + public string RootPath { get; set; } - + public FileSystemTypes InventoryPartType { get; set; } - + public string Code { get; set; } - + public List FileDescriptions { get; set; } - + public List DirectoryDescriptions { get; set; } - + public bool IsIncompleteDueToAccess { get; set; } - + + public Dictionary SkippedCountsByReason + { + get; + + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract + set => field = value ?? new Dictionary(); + } + + public int SkippedCount => SkippedCountsByReason.Values.Sum(); + public string RootName { get @@ -42,63 +53,77 @@ public string RootName { case OSPlatforms.Windows: directorySeparatorChar = "\\"; + break; case OSPlatforms.Linux: case OSPlatforms.MacOs: directorySeparatorChar = "/"; + break; default: throw new ArgumentOutOfRangeException(nameof(directorySeparatorChar)); } - + return RootPath.Substring(RootPath.LastIndexOf(directorySeparatorChar, StringComparison.Ordinal)); } } - + protected bool Equals(InventoryPart other) { return Equals(Inventory, other.Inventory) && RootPath == other.RootPath && InventoryPartType == other.InventoryPartType; } - + public override bool Equals(object obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != this.GetType()) return false; - return Equals((InventoryPart) obj); + + return Equals((InventoryPart)obj); } - + public override int GetHashCode() { unchecked { var hashCode = Inventory.GetHashCode(); hashCode = (hashCode * 397) ^ RootPath.GetHashCode(); - hashCode = (hashCode * 397) ^ (int) InventoryPartType; + hashCode = (hashCode * 397) ^ (int)InventoryPartType; + return hashCode; } } - + public override string ToString() { #if DEBUG return $"InventoryPart {RootName} {RootPath}"; #endif - + #pragma warning disable 162 return base.ToString(); #pragma warning restore 162 } - + public void AddFileSystemDescription(FileSystemDescription fileSystemDescription) { if (fileSystemDescription.FileSystemType == FileSystemTypes.File) { - FileDescriptions.Add((FileDescription) fileSystemDescription); + FileDescriptions.Add((FileDescription)fileSystemDescription); } else { - DirectoryDescriptions.Add((DirectoryDescription) fileSystemDescription); + DirectoryDescriptions.Add((DirectoryDescription)fileSystemDescription); } } -} + + public int GetSkippedCountByReason(SkipReason reason) + { + return SkippedCountsByReason.TryGetValue(reason, out var count) ? count : 0; + } + + public void RecordSkippedEntry(SkipReason reason) + { + SkippedCountsByReason[reason] = GetSkippedCountByReason(reason) + 1; + } +} \ No newline at end of file diff --git a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs index 3c03da85..0c3d1664 100644 --- a/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs +++ b/src/ByteSync.Client/Services/Inventories/InventoryBuilder.cs @@ -691,6 +691,7 @@ private void RecordSkippedEntry(InventoryPart inventoryPart, FileSystemInfo file DetectedKind = detectedKind }; + inventoryPart.RecordSkippedEntry(reason); InventoryProcessData.RecordSkippedEntry(entry); } diff --git a/tests/ByteSync.Client.UnitTests/Business/Inventories/InventoryProcessDataTests.cs b/tests/ByteSync.Client.UnitTests/Business/Inventories/InventoryProcessDataTests.cs index d0005a5b..b15cd755 100644 --- a/tests/ByteSync.Client.UnitTests/Business/Inventories/InventoryProcessDataTests.cs +++ b/tests/ByteSync.Client.UnitTests/Business/Inventories/InventoryProcessDataTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using ByteSync.Business.Inventories; +using ByteSync.Models.Inventories; using FluentAssertions; using NUnit.Framework; @@ -25,4 +26,40 @@ public void SetError_ShouldUpdateLastException_AndRaiseEvent() data.LastException.Should().Be(exception); values.Should().Contain(true); } + + [Test] + public void RecordSkippedEntry_ShouldUpdateGlobalAndReasonCounters() + { + // Arrange + var data = new InventoryProcessData(); + + // Act + data.RecordSkippedEntry(new SkippedEntry { Reason = SkipReason.Hidden }); + data.RecordSkippedEntry(new SkippedEntry { Reason = SkipReason.Hidden }); + data.RecordSkippedEntry(new SkippedEntry { Reason = SkipReason.NoiseEntry }); + + // Assert + data.SkippedCount.Should().Be(3); + data.GetSkippedCountByReason(SkipReason.Hidden).Should().Be(2); + data.GetSkippedCountByReason(SkipReason.NoiseEntry).Should().Be(1); + data.GetSkippedCountByReason(SkipReason.Offline).Should().Be(0); + } + + [Test] + public void Reset_ShouldClearSkippedEntriesAndCounters() + { + // Arrange + var data = new InventoryProcessData(); + data.RecordSkippedEntry(new SkippedEntry { Reason = SkipReason.Hidden }); + data.RecordSkippedEntry(new SkippedEntry { Reason = SkipReason.NoiseEntry }); + + // Act + data.Reset(); + + // Assert + data.SkippedEntries.Should().BeEmpty(); + data.SkippedCount.Should().Be(0); + data.GetSkippedCountByReason(SkipReason.Hidden).Should().Be(0); + data.GetSkippedCountByReason(SkipReason.NoiseEntry).Should().Be(0); + } } diff --git a/tests/ByteSync.Client.UnitTests/Models/Inventories/InventoryPartSkippedCountsTests.cs b/tests/ByteSync.Client.UnitTests/Models/Inventories/InventoryPartSkippedCountsTests.cs new file mode 100644 index 00000000..4899cfd0 --- /dev/null +++ b/tests/ByteSync.Client.UnitTests/Models/Inventories/InventoryPartSkippedCountsTests.cs @@ -0,0 +1,43 @@ +using ByteSync.Models.Inventories; +using FluentAssertions; +using NUnit.Framework; + +namespace ByteSync.Client.UnitTests.Models.Inventories; + +[TestFixture] +public class InventoryPartSkippedCountsTests +{ + [Test] + public void RecordSkippedEntry_ShouldUpdateTotalAndReasonCounts() + { + // Arrange + var part = new InventoryPart(); + + // Act + part.RecordSkippedEntry(SkipReason.Hidden); + part.RecordSkippedEntry(SkipReason.Hidden); + part.RecordSkippedEntry(SkipReason.NoiseEntry); + + // Assert + part.SkippedCount.Should().Be(3); + part.GetSkippedCountByReason(SkipReason.Hidden).Should().Be(2); + part.GetSkippedCountByReason(SkipReason.NoiseEntry).Should().Be(1); + part.GetSkippedCountByReason(SkipReason.Offline).Should().Be(0); + } + + [Test] + public void SkippedCountsByReason_WhenSetToNull_ShouldFallbackToEmptyDictionary() + { + // Arrange + var part = new InventoryPart(); + part.SkippedCountsByReason = null!; + + // Act + var skippedCount = part.SkippedCount; + var hiddenCount = part.GetSkippedCountByReason(SkipReason.Hidden); + + // Assert + skippedCount.Should().Be(0); + hiddenCount.Should().Be(0); + } +} diff --git a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs index 33d40338..b0ce26cf 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryBuilderInspectorTests.cs @@ -267,8 +267,12 @@ public async Task Noise_Child_File_Is_Recorded() var invPath = Path.Combine(TestDirectory.FullName, "inv_noise_child.zip"); await builder.BuildBaseInventoryAsync(invPath); + var part = builder.Inventory.InventoryParts.Single(); + processData.SkippedEntries.Should() .ContainSingle(e => e.Name == "thumbs.db" && e.Reason == SkipReason.NoiseEntry); + part.SkippedCount.Should().Be(1); + part.GetSkippedCountByReason(SkipReason.NoiseEntry).Should().Be(1); } [Test] @@ -306,6 +310,8 @@ public async Task Noise_Child_Directory_Is_Recorded_And_Not_Traversed() processData.SkippedEntries.Should() .ContainSingle(e => e.Name == "$RECYCLE.BIN" && e.Reason == SkipReason.NoiseEntry); + part.SkippedCount.Should().Be(1); + part.GetSkippedCountByReason(SkipReason.NoiseEntry).Should().Be(1); } [Test] @@ -387,6 +393,8 @@ public async Task Offline_Root_File_Is_Recorded() part.FileDescriptions.Should().BeEmpty(); processData.SkippedEntries.Should() .ContainSingle(e => e.Name == "offline.txt" && e.Reason == SkipReason.Offline); + part.SkippedCount.Should().Be(1); + part.GetSkippedCountByReason(SkipReason.Offline).Should().Be(1); } [Test] diff --git a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryLoaderIncompleteFlagTests.cs b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryLoaderIncompleteFlagTests.cs index 8f8b19cb..81638399 100644 --- a/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryLoaderIncompleteFlagTests.cs +++ b/tests/ByteSync.Client.UnitTests/Services/Inventories/InventoryLoaderIncompleteFlagTests.cs @@ -45,6 +45,28 @@ public void Load_ShouldMarkPartIncomplete_WhenInaccessibleDescriptionsExist() loadedInventory.InventoryParts[0].IsIncompleteDueToAccess.Should().BeTrue(); } + [Test] + public void Load_ShouldKeepSkippedCountsByReason() + { + // Arrange + var inventory = BuildInventoryWithInaccessibleDirectory(); + var part = inventory.InventoryParts[0]; + part.RecordSkippedEntry(SkipReason.Hidden); + part.RecordSkippedEntry(SkipReason.Hidden); + part.RecordSkippedEntry(SkipReason.NoiseEntry); + var zipPath = CreateInventoryZipFile(_tempDirectory, inventory); + + // Act + using var loader = new InventoryLoader(zipPath); + var loadedPart = loader.Inventory.InventoryParts[0]; + + // Assert + loadedPart.SkippedCount.Should().Be(3); + loadedPart.GetSkippedCountByReason(SkipReason.Hidden).Should().Be(2); + loadedPart.GetSkippedCountByReason(SkipReason.NoiseEntry).Should().Be(1); + loadedPart.GetSkippedCountByReason(SkipReason.Offline).Should().Be(0); + } + private static Inventory BuildInventoryWithInaccessibleDirectory() { var inventory = new Inventory