Skip to content
Merged
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 17 additions & 3 deletions src/ByteSync.Client/Business/Inventories/InventoryProcessData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public class InventoryProcessData : ReactiveObject
{
private readonly object _monitorDataLock = new object();
private readonly ConcurrentQueue<SkippedEntry> _skippedEntries = new();
private readonly ConcurrentDictionary<SkipReason, int> _skippedCountsByReason = new();
private int _skippedCount;

public InventoryProcessData()
{
Expand Down Expand Up @@ -105,6 +107,8 @@ public List<Inventory>? Inventories

public IReadOnlyCollection<SkippedEntry> SkippedEntries => _skippedEntries.ToArray();

public int SkippedCount => _skippedCount;

[Reactive]
public DateTimeOffset InventoryStart { get; set; }

Expand Down Expand Up @@ -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;
Expand All @@ -166,6 +178,8 @@ private void ClearSkippedEntries()
while (_skippedEntries.TryDequeue(out _))
{
}

_skippedCountsByReason.Clear();
Interlocked.Exchange(ref _skippedCount, 0);
}
}

}
67 changes: 46 additions & 21 deletions src/ByteSync.Client/Models/Inventories/InventoryPart.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,40 @@ public InventoryPart()
{
FileDescriptions = new List<FileDescription>();
DirectoryDescriptions = new List<DirectoryDescription>();
SkippedCountsByReason = new Dictionary<SkipReason, int>();
}

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<FileDescription> FileDescriptions { get; set; }

public List<DirectoryDescription> DirectoryDescriptions { get; set; }

public bool IsIncompleteDueToAccess { get; set; }


public Dictionary<SkipReason, int> SkippedCountsByReason
{
get;

// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
set => field = value ?? new Dictionary<SkipReason, int>();
}

public int SkippedCount => SkippedCountsByReason.Values.Sum();

public string RootName
{
get
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,7 @@ private void RecordSkippedEntry(InventoryPart inventoryPart, FileSystemInfo file
DetectedKind = detectedKind
};

inventoryPart.RecordSkippedEntry(reason);
InventoryProcessData.RecordSkippedEntry(entry);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using ByteSync.Business.Inventories;
using ByteSync.Models.Inventories;
using FluentAssertions;
using NUnit.Framework;

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading