diff --git a/CLAUDE.md b/CLAUDE.md index 6c1b693..a6cf077 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -292,7 +292,7 @@ _watcher.EnableRaisingEvents = true; | No incremental change notification | FileSystemWatcher triggers full scan | Planned: `NotifyLocalChangeAsync()` | | No virtual file awareness | Can't track placeholder vs downloaded | Planned: `VirtualFileCallback` | | Single-threaded engine | One sync at a time per instance | By design - create separate instances if needed | -| No bandwidth throttling | Can saturate network | Planned: `SyncOptions.MaxBytesPerSecond` | +| ~~No bandwidth throttling~~ | ~~Can saturate network~~ | ✅ `SyncOptions.MaxBytesPerSecond` IMPLEMENTED | | OCIS TUS not implemented | Falls back to generic upload | Planned for v1.0 | ### Required SharpSync API Additions (v1.0) @@ -308,7 +308,7 @@ These APIs are required for v1.0 release to support Nimbus desktop client: 4. OCIS TUS protocol implementation (`WebDavStorage.cs:547` currently falls back) **Sync Control:** -5. `SyncOptions.MaxBytesPerSecond` - Built-in bandwidth throttling +5. ~~`SyncOptions.MaxBytesPerSecond`~~ ✅ Built-in bandwidth throttling (IMPLEMENTED) 6. `PauseAsync()` / `ResumeAsync()` - Pause and resume long-running syncs 7. `GetPendingOperationsAsync()` - Inspect sync queue for UI display @@ -500,7 +500,7 @@ Desktop Client APIs (for Nimbus): - [ ] `SyncFilesAsync(IEnumerable paths)` - Sync specific files on demand - [ ] `NotifyLocalChangeAsync(string path, ChangeType type)` - Accept FileSystemWatcher events for incremental sync - [ ] OCIS TUS protocol implementation (currently falls back to generic upload at `WebDavStorage.cs:547`) -- [ ] `SyncOptions.MaxBytesPerSecond` - Built-in bandwidth throttling +- [x] `SyncOptions.MaxBytesPerSecond` - Built-in bandwidth throttling ✅ - [ ] `PauseAsync()` / `ResumeAsync()` - Pause and resume long-running syncs - [ ] `GetPendingOperationsAsync()` - Inspect sync queue for UI display - [ ] Per-file progress events (currently only per-sync-operation) diff --git a/src/SharpSync/Core/SyncOptions.cs b/src/SharpSync/Core/SyncOptions.cs index cbf8ba6..05534f3 100644 --- a/src/SharpSync/Core/SyncOptions.cs +++ b/src/SharpSync/Core/SyncOptions.cs @@ -64,6 +64,20 @@ public class SyncOptions { /// public List ExcludePatterns { get; set; } = new List(); + /// + /// Gets or sets the maximum transfer rate in bytes per second. + /// Set to 0 or null for unlimited bandwidth. + /// + /// + /// This setting applies to both upload and download operations. + /// Useful for preventing network saturation on shared connections. + /// Example values: + /// - 1_048_576 (1 MB/s) + /// - 10_485_760 (10 MB/s) + /// - 104_857_600 (100 MB/s) + /// + public long? MaxBytesPerSecond { get; set; } + /// /// Creates a copy of the sync options /// @@ -81,7 +95,8 @@ public SyncOptions Clone() { UpdateExisting = UpdateExisting, ConflictResolution = ConflictResolution, TimeoutSeconds = TimeoutSeconds, - ExcludePatterns = new List(ExcludePatterns) + ExcludePatterns = new List(ExcludePatterns), + MaxBytesPerSecond = MaxBytesPerSecond }; } } diff --git a/src/SharpSync/Storage/ThrottledStream.cs b/src/SharpSync/Storage/ThrottledStream.cs new file mode 100644 index 0000000..f93b23b --- /dev/null +++ b/src/SharpSync/Storage/ThrottledStream.cs @@ -0,0 +1,156 @@ +using System.Diagnostics; + +namespace Oire.SharpSync.Storage; + +/// +/// Stream wrapper that throttles read and write operations to limit bandwidth usage. +/// Uses a token bucket algorithm for smooth rate limiting. +/// +internal sealed class ThrottledStream: Stream { + private readonly Stream _innerStream; + private readonly long _maxBytesPerSecond; + private readonly Stopwatch _stopwatch; + private long _totalBytesTransferred; + private readonly object _lock = new(); + + /// + /// Creates a new ThrottledStream wrapping the specified stream. + /// + /// The stream to wrap. + /// Maximum bytes per second (must be positive). + /// Thrown when innerStream is null. + /// Thrown when maxBytesPerSecond is not positive. + public ThrottledStream(Stream innerStream, long maxBytesPerSecond) { + _innerStream = innerStream ?? throw new ArgumentNullException(nameof(innerStream)); + + if (maxBytesPerSecond <= 0) { + throw new ArgumentOutOfRangeException(nameof(maxBytesPerSecond), "Max bytes per second must be positive."); + } + + _maxBytesPerSecond = maxBytesPerSecond; + _stopwatch = Stopwatch.StartNew(); + _totalBytesTransferred = 0; + } + + public override bool CanRead => _innerStream.CanRead; + public override bool CanSeek => _innerStream.CanSeek; + public override bool CanWrite => _innerStream.CanWrite; + public override long Length => _innerStream.Length; + + public override long Position { + get => _innerStream.Position; + set => _innerStream.Position = value; + } + + public override void Flush() => _innerStream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => + _innerStream.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) { + ThrottleSync(count); + var bytesRead = _innerStream.Read(buffer, offset, count); + RecordBytesTransferred(bytesRead); + return bytesRead; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { + await ThrottleAsync(count, cancellationToken); + var bytesRead = await _innerStream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken); + RecordBytesTransferred(bytesRead); + return bytesRead; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { + await ThrottleAsync(buffer.Length, cancellationToken); + var bytesRead = await _innerStream.ReadAsync(buffer, cancellationToken); + RecordBytesTransferred(bytesRead); + return bytesRead; + } + + public override void Write(byte[] buffer, int offset, int count) { + ThrottleSync(count); + _innerStream.Write(buffer, offset, count); + RecordBytesTransferred(count); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { + await ThrottleAsync(count, cancellationToken); + await _innerStream.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + RecordBytesTransferred(count); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { + await ThrottleAsync(buffer.Length, cancellationToken); + await _innerStream.WriteAsync(buffer, cancellationToken); + RecordBytesTransferred(buffer.Length); + } + + public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin); + + public override void SetLength(long value) => _innerStream.SetLength(value); + + /// + /// Synchronously waits if the transfer rate would exceed the limit. + /// + private void ThrottleSync(int requestedBytes) { + var delay = CalculateDelay(requestedBytes); + if (delay > TimeSpan.Zero) { + Thread.Sleep(delay); + } + } + + /// + /// Asynchronously waits if the transfer rate would exceed the limit. + /// + private async Task ThrottleAsync(int requestedBytes, CancellationToken cancellationToken) { + var delay = CalculateDelay(requestedBytes); + if (delay > TimeSpan.Zero) { + await Task.Delay(delay, cancellationToken); + } + } + + /// + /// Calculates the delay needed to maintain the target transfer rate. + /// + private TimeSpan CalculateDelay(int requestedBytes) { + lock (_lock) { + var elapsedSeconds = _stopwatch.Elapsed.TotalSeconds; + if (elapsedSeconds <= 0) { + return TimeSpan.Zero; + } + + // Calculate the expected time for the bytes already transferred plus the new bytes + var expectedBytes = _totalBytesTransferred + requestedBytes; + var expectedTimeSeconds = (double)expectedBytes / _maxBytesPerSecond; + + // If we're ahead of schedule, delay + if (expectedTimeSeconds > elapsedSeconds) { + var delaySeconds = expectedTimeSeconds - elapsedSeconds; + // Cap delay to a reasonable maximum to prevent extremely long waits + delaySeconds = Math.Min(delaySeconds, 5.0); + return TimeSpan.FromSeconds(delaySeconds); + } + + return TimeSpan.Zero; + } + } + + /// + /// Records that bytes have been transferred. + /// + private void RecordBytesTransferred(int bytes) { + if (bytes > 0) { + lock (_lock) { + _totalBytesTransferred += bytes; + } + } + } + + protected override void Dispose(bool disposing) { + if (disposing) { + _innerStream?.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index fffeb16..7aec035 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using Oire.SharpSync.Core; +using Oire.SharpSync.Storage; namespace Oire.SharpSync.Sync; @@ -24,6 +25,7 @@ public class SyncEngine: ISyncEngine { private bool _disposed; private readonly SemaphoreSlim _syncSemaphore; private CancellationTokenSource? _currentSyncCts; + private long? _currentMaxBytesPerSecond; /// /// Gets whether the engine is currently synchronizing @@ -113,6 +115,7 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc try { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _currentSyncCts = linkedCts; + _currentMaxBytesPerSecond = options?.MaxBytesPerSecond; var syncToken = linkedCts.Token; var result = new SyncResult(); var sw = Stopwatch.StartNew(); @@ -157,6 +160,7 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc return result; } finally { _currentSyncCts = null; + _currentMaxBytesPerSecond = null; _syncSemaphore.Release(); } } @@ -830,7 +834,8 @@ private async Task DownloadFileAsync(SyncAction action, ThreadSafeSyncResult res await _localStorage.CreateDirectoryAsync(action.Path, cancellationToken); } else { using var remoteStream = await _remoteStorage.ReadFileAsync(action.Path, cancellationToken); - await _localStorage.WriteFileAsync(action.Path, remoteStream, cancellationToken); + var streamToRead = WrapWithThrottling(remoteStream); + await _localStorage.WriteFileAsync(action.Path, streamToRead, cancellationToken); } result.IncrementFilesSynchronized(); @@ -841,7 +846,8 @@ private async Task UploadFileAsync(SyncAction action, ThreadSafeSyncResult resul await _remoteStorage.CreateDirectoryAsync(action.Path, cancellationToken); } else { using var localStream = await _localStorage.ReadFileAsync(action.Path, cancellationToken); - await _remoteStorage.WriteFileAsync(action.Path, localStream, cancellationToken); + var streamToRead = WrapWithThrottling(localStream); + await _remoteStorage.WriteFileAsync(action.Path, streamToRead, cancellationToken); } result.IncrementFilesSynchronized(); @@ -1091,6 +1097,19 @@ private async Task UpdateDatabaseStateAsync(ChangeSet changes, CancellationToken _ => SyncOperation.Unknown }; + /// + /// Wraps the provided stream with a throttled stream if bandwidth limiting is enabled. + /// + /// The stream to potentially wrap. + /// The original stream or a throttled wrapper. + private Stream WrapWithThrottling(Stream stream) { + if (_currentMaxBytesPerSecond.HasValue && _currentMaxBytesPerSecond.Value > 0) { + return new ThrottledStream(stream, _currentMaxBytesPerSecond.Value); + } + + return stream; + } + private void RaiseProgress(SyncProgress progress, SyncOperation operation) { ProgressChanged?.Invoke(this, new SyncProgressEventArgs(progress, progress.CurrentItem, operation)); } diff --git a/tests/SharpSync.Tests/Storage/ThrottledStreamTests.cs b/tests/SharpSync.Tests/Storage/ThrottledStreamTests.cs new file mode 100644 index 0000000..076e375 --- /dev/null +++ b/tests/SharpSync.Tests/Storage/ThrottledStreamTests.cs @@ -0,0 +1,325 @@ +using System.Diagnostics; +using System.Reflection; + +namespace Oire.SharpSync.Tests.Storage; + +public class ThrottledStreamTests { + [Fact] + public void Constructor_ValidParameters_InitializesCorrectly() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + + // Act + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Assert + Assert.NotNull(throttledStream); + } + + [Fact] + public void Constructor_NullInnerStream_ThrowsArgumentNullException() { + // Act & Assert - Reflection wraps in TargetInvocationException + var exception = Assert.Throws(() => + CreateThrottledStream(null!, 1_000_000)); + Assert.IsType(exception.InnerException); + } + + [Fact] + public void Constructor_ZeroMaxBytesPerSecond_ThrowsArgumentOutOfRangeException() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + + // Act & Assert - Reflection wraps in TargetInvocationException + var exception = Assert.Throws(() => + CreateThrottledStream(innerStream, 0)); + Assert.IsType(exception.InnerException); + } + + [Fact] + public void Constructor_NegativeMaxBytesPerSecond_ThrowsArgumentOutOfRangeException() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + + // Act & Assert - Reflection wraps in TargetInvocationException + var exception = Assert.Throws(() => + CreateThrottledStream(innerStream, -100)); + Assert.IsType(exception.InnerException); + } + + [Fact] + public void Read_ReturnsCorrectData() { + // Arrange + var data = new byte[] { 1, 2, 3, 4, 5 }; + using var innerStream = new MemoryStream(data); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + var buffer = new byte[5]; + + // Act + var bytesRead = throttledStream.Read(buffer, 0, 5); + + // Assert + Assert.Equal(5, bytesRead); + Assert.Equal(data, buffer); + } + + [Fact] + public async Task ReadAsync_ReturnsCorrectData() { + // Arrange + var data = new byte[] { 1, 2, 3, 4, 5 }; + using var innerStream = new MemoryStream(data); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + var buffer = new byte[5]; + + // Act + var bytesRead = await throttledStream.ReadAsync(buffer.AsMemory(0, 5)); + + // Assert + Assert.Equal(5, bytesRead); + Assert.Equal(data, buffer); + } + + [Fact] + public void Write_WritesCorrectData() { + // Arrange + using var innerStream = new MemoryStream(); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + var data = new byte[] { 1, 2, 3, 4, 5 }; + + // Act + throttledStream.Write(data, 0, 5); + + // Assert + innerStream.Position = 0; + var result = new byte[5]; + innerStream.Read(result, 0, 5); + Assert.Equal(data, result); + } + + [Fact] + public async Task WriteAsync_WritesCorrectData() { + // Arrange + using var innerStream = new MemoryStream(); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + var data = new byte[] { 1, 2, 3, 4, 5 }; + + // Act + await throttledStream.WriteAsync(data.AsMemory(0, 5)); + + // Assert + innerStream.Position = 0; + var result = new byte[5]; + await innerStream.ReadAsync(result.AsMemory(0, 5)); + Assert.Equal(data, result); + } + + [Fact] + public async Task ReadAsync_WithThrottling_DelaysTransfer() { + // Arrange - Set a very low rate: 100 bytes/second for 500 bytes = 5 seconds minimum + // But we'll use 500 bytes/second for 1000 bytes = 2 seconds minimum + var data = new byte[1000]; + for (int i = 0; i < data.Length; i++) { + data[i] = (byte)(i % 256); + } + + using var innerStream = new MemoryStream(data); + // 500 bytes per second - should take at least 2 seconds for 1000 bytes + using var throttledStream = CreateThrottledStream(innerStream, 500); + var buffer = new byte[1000]; + + // Act + var sw = Stopwatch.StartNew(); + var bytesRead = await throttledStream.ReadAsync(buffer.AsMemory(0, 1000)); + sw.Stop(); + + // Assert - Should take at least 1 second (with some tolerance for timing) + Assert.Equal(1000, bytesRead); + Assert.True(sw.ElapsedMilliseconds >= 800, $"Expected at least 800ms, but took {sw.ElapsedMilliseconds}ms"); + } + + [Fact] + public void CanRead_ReflectsInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Assert + Assert.Equal(innerStream.CanRead, throttledStream.CanRead); + } + + [Fact] + public void CanSeek_ReflectsInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Assert + Assert.Equal(innerStream.CanSeek, throttledStream.CanSeek); + } + + [Fact] + public void CanWrite_ReflectsInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Assert + Assert.Equal(innerStream.CanWrite, throttledStream.CanWrite); + } + + [Fact] + public void Length_ReflectsInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Assert + Assert.Equal(5, throttledStream.Length); + } + + [Fact] + public void Position_GetAndSet_ReflectsInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Act + throttledStream.Position = 3; + + // Assert + Assert.Equal(3, throttledStream.Position); + Assert.Equal(3, innerStream.Position); + } + + [Fact] + public void Seek_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Act + var newPosition = throttledStream.Seek(2, SeekOrigin.Begin); + + // Assert + Assert.Equal(2, newPosition); + Assert.Equal(2, innerStream.Position); + } + + [Fact] + public void Flush_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Act & Assert - should not throw + throttledStream.Flush(); + } + + [Fact] + public async Task FlushAsync_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Act & Assert - should not throw + await throttledStream.FlushAsync(); + } + + [Fact] + public void SetLength_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream(); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Act + throttledStream.SetLength(100); + + // Assert + Assert.Equal(100, innerStream.Length); + Assert.Equal(100, throttledStream.Length); + } + + [Fact] + public void Dispose_DisposesInnerStream() { + // Arrange + var innerStream = new MemoryStream([1, 2, 3]); + var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + + // Act + throttledStream.Dispose(); + + // Assert + Assert.Throws(() => innerStream.ReadByte()); + } + + [Fact] + public async Task ReadAsync_WithCancellation_RespectsCancellationToken() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + // Use a very low rate to ensure we hit the delay + using var throttledStream = CreateThrottledStream(innerStream, 1); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var buffer = new byte[5]; + + // Act & Assert +#pragma warning disable CA2022, CA1835 // Testing cancellation behavior + await Assert.ThrowsAnyAsync(async () => + await throttledStream.ReadAsync(buffer, 0, buffer.Length, cts.Token)); +#pragma warning restore CA2022, CA1835 + } + + [Fact] + public void Read_WithHighBandwidth_NoSignificantDelay() { + // Arrange - High bandwidth should not cause significant delays + var data = new byte[1000]; + using var innerStream = new MemoryStream(data); + // 100 MB/s - should be essentially instant + using var throttledStream = CreateThrottledStream(innerStream, 100_000_000); + var buffer = new byte[1000]; + + // Act + var sw = Stopwatch.StartNew(); + var bytesRead = throttledStream.Read(buffer, 0, 1000); + sw.Stop(); + + // Assert + Assert.Equal(1000, bytesRead); + // Should complete in under 100ms for such a high bandwidth + Assert.True(sw.ElapsedMilliseconds < 100, $"Expected < 100ms, but took {sw.ElapsedMilliseconds}ms"); + } + + [Fact] + public void Read_MultipleSmallReads_AccumulatesCorrectly() { + // Arrange + var data = new byte[100]; + for (int i = 0; i < data.Length; i++) { + data[i] = (byte)i; + } + + using var innerStream = new MemoryStream(data); + using var throttledStream = CreateThrottledStream(innerStream, 1_000_000); + var allBytesRead = new List(); + var buffer = new byte[10]; + + // Act - Read in 10-byte chunks + int bytesRead; + while ((bytesRead = throttledStream.Read(buffer, 0, 10)) > 0) { + allBytesRead.AddRange(buffer.Take(bytesRead)); + } + + // Assert + Assert.Equal(data, allBytesRead.ToArray()); + } + + // Helper method to create ThrottledStream using reflection since it's internal + private static Stream CreateThrottledStream(Stream innerStream, long maxBytesPerSecond) { + var assembly = typeof(LocalFileStorage).Assembly; + var throttledStreamType = assembly.GetType("Oire.SharpSync.Storage.ThrottledStream"); + if (throttledStreamType == null) { + throw new InvalidOperationException("ThrottledStream type not found"); + } + + return (Stream)Activator.CreateInstance(throttledStreamType, innerStream, maxBytesPerSecond)!; + } +} diff --git a/tests/SharpSync.Tests/SyncOptionsTests.cs b/tests/SharpSync.Tests/SyncOptionsTests.cs index 048b4fa..be83abf 100644 --- a/tests/SharpSync.Tests/SyncOptionsTests.cs +++ b/tests/SharpSync.Tests/SyncOptionsTests.cs @@ -20,6 +20,7 @@ public void Constructor_ShouldSetDefaultValues() { Assert.Equal(0, options.TimeoutSeconds); Assert.NotNull(options.ExcludePatterns); Assert.Empty(options.ExcludePatterns); + Assert.Null(options.MaxBytesPerSecond); } [Fact] @@ -109,4 +110,72 @@ public void TimeoutSeconds_CanBeSet() { // Assert Assert.Equal(600, options.TimeoutSeconds); } + + [Fact] + public void MaxBytesPerSecond_CanBeSet() { + // Arrange + var options = new SyncOptions(); + + // Act + options.MaxBytesPerSecond = 1_048_576; // 1 MB/s + + // Assert + Assert.Equal(1_048_576, options.MaxBytesPerSecond); + } + + [Fact] + public void MaxBytesPerSecond_CanBeSetToNull() { + // Arrange + var options = new SyncOptions { MaxBytesPerSecond = 1000 }; + + // Act + options.MaxBytesPerSecond = null; + + // Assert + Assert.Null(options.MaxBytesPerSecond); + } + + [Theory] + [InlineData(1_048_576)] // 1 MB/s + [InlineData(10_485_760)] // 10 MB/s + [InlineData(104_857_600)] // 100 MB/s + [InlineData(1)] // 1 byte/s (minimum) + [InlineData(long.MaxValue)] // Maximum possible + public void MaxBytesPerSecond_AcceptsVariousValues(long bytesPerSecond) { + // Arrange & Act + var options = new SyncOptions { MaxBytesPerSecond = bytesPerSecond }; + + // Assert + Assert.Equal(bytesPerSecond, options.MaxBytesPerSecond); + } + + [Fact] + public void Clone_CopiesMaxBytesPerSecond() { + // Arrange + var original = new SyncOptions { + MaxBytesPerSecond = 5_242_880 // 5 MB/s + }; + + // Act + var clone = original.Clone(); + + // Assert + Assert.NotSame(original, clone); + Assert.Equal(original.MaxBytesPerSecond, clone.MaxBytesPerSecond); + } + + [Fact] + public void Clone_CopiesNullMaxBytesPerSecond() { + // Arrange + var original = new SyncOptions { + MaxBytesPerSecond = null + }; + + // Act + var clone = original.Clone(); + + // Assert + Assert.NotSame(original, clone); + Assert.Null(clone.MaxBytesPerSecond); + } }