Skip to content

Commit 511bbab

Browse files
author
Mykyta Zotov
committed
fix: validate debounce delay to ensure non-negative values in cache options
1 parent f40280d commit 511bbab

7 files changed

Lines changed: 98 additions & 88 deletions

File tree

src/SlidingWindowCache/Core/State/RuntimeCacheOptions.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ internal sealed class RuntimeCacheOptions
4040
/// <param name="rightCacheSize">The coefficient for the right cache size. Must be ≥ 0.</param>
4141
/// <param name="leftThreshold">The left no-rebalance threshold percentage. Must be in [0, 1] when not null.</param>
4242
/// <param name="rightThreshold">The right no-rebalance threshold percentage. Must be in [0, 1] when not null.</param>
43-
/// <param name="debounceDelay">The debounce delay applied before executing a rebalance.</param>
43+
/// <param name="debounceDelay">The debounce delay applied before executing a rebalance. Must be non-negative.</param>
4444
/// <exception cref="ArgumentOutOfRangeException">
4545
/// Thrown when <paramref name="leftCacheSize"/> or <paramref name="rightCacheSize"/> is less than 0,
46-
/// or when a threshold value is outside [0, 1].
46+
/// when a threshold value is outside [0, 1], or when <paramref name="debounceDelay"/> is negative.
4747
/// </exception>
4848
/// <exception cref="ArgumentException">
4949
/// Thrown when both thresholds are specified and their sum exceeds 1.0.
@@ -58,6 +58,12 @@ public RuntimeCacheOptions(
5858
RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(
5959
leftCacheSize, rightCacheSize, leftThreshold, rightThreshold);
6060

61+
if (debounceDelay < TimeSpan.Zero)
62+
{
63+
throw new ArgumentOutOfRangeException(nameof(debounceDelay),
64+
"DebounceDelay must be non-negative.");
65+
}
66+
6167
LeftCacheSize = leftCacheSize;
6268
RightCacheSize = rightCacheSize;
6369
LeftThreshold = leftThreshold;

src/SlidingWindowCache/Public/Configuration/RuntimeOptionsUpdateBuilder.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ public RuntimeOptionsUpdateBuilder ClearRightThreshold()
130130
/// <returns>This builder, for fluent chaining.</returns>
131131
public RuntimeOptionsUpdateBuilder WithDebounceDelay(TimeSpan value)
132132
{
133+
if (value < TimeSpan.Zero)
134+
{
135+
throw new ArgumentOutOfRangeException(nameof(value),
136+
"DebounceDelay must be non-negative.");
137+
}
138+
133139
_debounceDelay = value;
134140
return this;
135141
}

src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public sealed class WindowCacheOptions : IEquatable<WindowCacheOptions>
4141
/// </param>
4242
/// <exception cref="ArgumentOutOfRangeException">
4343
/// Thrown when LeftCacheSize, RightCacheSize, LeftThreshold, RightThreshold is less than 0,
44-
/// or when RebalanceQueueCapacity is less than or equal to 0.
44+
/// when DebounceDelay is negative, or when RebalanceQueueCapacity is less than or equal to 0.
4545
/// </exception>
4646
/// <exception cref="ArgumentException">
4747
/// Thrown when the sum of LeftThreshold and RightThreshold exceeds 1.0.
@@ -65,6 +65,12 @@ public WindowCacheOptions(
6565
"RebalanceQueueCapacity must be greater than 0 or null.");
6666
}
6767

68+
if (debounceDelay.HasValue && debounceDelay.Value < TimeSpan.Zero)
69+
{
70+
throw new ArgumentOutOfRangeException(nameof(debounceDelay),
71+
"DebounceDelay must be non-negative.");
72+
}
73+
6874
LeftCacheSize = leftCacheSize;
6975
RightCacheSize = rightCacheSize;
7076
ReadMode = readMode;

src/SlidingWindowCache/Public/Configuration/WindowCacheOptionsBuilder.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ public WindowCacheOptionsBuilder WithThresholds(double value)
182182
/// <returns>This builder instance, for fluent chaining.</returns>
183183
public WindowCacheOptionsBuilder WithDebounceDelay(TimeSpan value)
184184
{
185+
if (value < TimeSpan.Zero)
186+
{
187+
throw new ArgumentOutOfRangeException(nameof(value),
188+
"DebounceDelay must be non-negative.");
189+
}
190+
185191
_debounceDelay = value;
186192
return this;
187193
}

tests/SlidingWindowCache.Unit.Tests/Public/Configuration/RuntimeOptionsUpdateBuilderTests.cs

Lines changed: 38 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,15 @@ public class RuntimeOptionsUpdateBuilderTests
1111
{
1212
private static RuntimeCacheOptions BaseOptions() => new(1.0, 2.0, 0.1, 0.2, TimeSpan.FromMilliseconds(50));
1313

14-
// Helper to create an internal builder via WindowCache (public API) isn't needed here
15-
// because we can test ApplyTo directly via internal access.
16-
// However, RuntimeOptionsUpdateBuilder's ctor is internal, so we access it via WindowCache.
17-
// Instead we test the builder indirectly through WindowCache.UpdateRuntimeOptions.
18-
// For pure unit tests of the builder logic, we use InternalsVisibleTo.
19-
20-
// Since RuntimeOptionsUpdateBuilder constructor is internal but the tests are in a separate
21-
// assembly, we verify builder behaviour through ApplyTo by instantiating via reflection,
22-
// or test the full behaviour through WindowCache integration tests.
23-
// Here we use the WindowCache public API to exercise each builder method.
24-
2514
#region Builder Method Tests — WithLeftCacheSize
2615

2716
[Fact]
2817
public void ApplyTo_WithLeftCacheSizeSet_ChangesOnlyLeftCacheSize()
2918
{
30-
// We test ApplyTo indirectly: create builder through internal constructor via reflection.
31-
var builder = CreateBuilder();
19+
var builder = new RuntimeOptionsUpdateBuilder();
3220
builder.WithLeftCacheSize(5.0);
3321

34-
var result = InvokeApplyTo(builder, BaseOptions());
22+
var result = builder.ApplyTo(BaseOptions());
3523

3624
Assert.Equal(5.0, result.LeftCacheSize);
3725
Assert.Equal(2.0, result.RightCacheSize); // unchanged
@@ -47,10 +35,10 @@ public void ApplyTo_WithLeftCacheSizeSet_ChangesOnlyLeftCacheSize()
4735
[Fact]
4836
public void ApplyTo_WithRightCacheSizeSet_ChangesOnlyRightCacheSize()
4937
{
50-
var builder = CreateBuilder();
38+
var builder = new RuntimeOptionsUpdateBuilder();
5139
builder.WithRightCacheSize(7.0);
5240

53-
var result = InvokeApplyTo(builder, BaseOptions());
41+
var result = builder.ApplyTo(BaseOptions());
5442

5543
Assert.Equal(1.0, result.LeftCacheSize); // unchanged
5644
Assert.Equal(7.0, result.RightCacheSize);
@@ -66,10 +54,10 @@ public void ApplyTo_WithRightCacheSizeSet_ChangesOnlyRightCacheSize()
6654
[Fact]
6755
public void ApplyTo_WithLeftThresholdSet_UpdatesLeftThreshold()
6856
{
69-
var builder = CreateBuilder();
57+
var builder = new RuntimeOptionsUpdateBuilder();
7058
builder.WithLeftThreshold(0.3);
7159

72-
var result = InvokeApplyTo(builder, BaseOptions());
60+
var result = builder.ApplyTo(BaseOptions());
7361

7462
Assert.Equal(0.3, result.LeftThreshold);
7563
Assert.Equal(0.2, result.RightThreshold); // unchanged
@@ -78,10 +66,10 @@ public void ApplyTo_WithLeftThresholdSet_UpdatesLeftThreshold()
7866
[Fact]
7967
public void ApplyTo_ClearLeftThreshold_SetsLeftThresholdToNull()
8068
{
81-
var builder = CreateBuilder();
69+
var builder = new RuntimeOptionsUpdateBuilder();
8270
builder.ClearLeftThreshold();
8371

84-
var result = InvokeApplyTo(builder, BaseOptions());
72+
var result = builder.ApplyTo(BaseOptions());
8573

8674
Assert.Null(result.LeftThreshold);
8775
Assert.Equal(0.2, result.RightThreshold); // unchanged
@@ -91,10 +79,10 @@ public void ApplyTo_ClearLeftThreshold_SetsLeftThresholdToNull()
9179
public void ApplyTo_LeftThresholdNotSet_KeepsCurrentValue()
9280
{
9381
// No threshold method called on builder
94-
var builder = CreateBuilder();
82+
var builder = new RuntimeOptionsUpdateBuilder();
9583
builder.WithLeftCacheSize(3.0); // only set left cache size
9684

97-
var result = InvokeApplyTo(builder, BaseOptions());
85+
var result = builder.ApplyTo(BaseOptions());
9886

9987
Assert.Equal(0.1, result.LeftThreshold); // unchanged from base
10088
}
@@ -106,10 +94,10 @@ public void ApplyTo_LeftThresholdNotSet_KeepsCurrentValue()
10694
[Fact]
10795
public void ApplyTo_WithRightThresholdSet_UpdatesRightThreshold()
10896
{
109-
var builder = CreateBuilder();
97+
var builder = new RuntimeOptionsUpdateBuilder();
11098
builder.WithRightThreshold(0.35);
11199

112-
var result = InvokeApplyTo(builder, BaseOptions());
100+
var result = builder.ApplyTo(BaseOptions());
113101

114102
Assert.Equal(0.35, result.RightThreshold);
115103
Assert.Equal(0.1, result.LeftThreshold); // unchanged
@@ -118,10 +106,10 @@ public void ApplyTo_WithRightThresholdSet_UpdatesRightThreshold()
118106
[Fact]
119107
public void ApplyTo_ClearRightThreshold_SetsRightThresholdToNull()
120108
{
121-
var builder = CreateBuilder();
109+
var builder = new RuntimeOptionsUpdateBuilder();
122110
builder.ClearRightThreshold();
123111

124-
var result = InvokeApplyTo(builder, BaseOptions());
112+
var result = builder.ApplyTo(BaseOptions());
125113

126114
Assert.Null(result.RightThreshold);
127115
Assert.Equal(0.1, result.LeftThreshold); // unchanged
@@ -130,11 +118,11 @@ public void ApplyTo_ClearRightThreshold_SetsRightThresholdToNull()
130118
[Fact]
131119
public void ApplyTo_RightThresholdNotSet_KeepsCurrentValue()
132120
{
133-
var builder = CreateBuilder();
121+
var builder = new RuntimeOptionsUpdateBuilder();
134122
// Only change debounce
135123
builder.WithDebounceDelay(TimeSpan.Zero);
136124

137-
var result = InvokeApplyTo(builder, BaseOptions());
125+
var result = builder.ApplyTo(BaseOptions());
138126

139127
Assert.Equal(0.2, result.RightThreshold); // unchanged from base
140128
}
@@ -146,10 +134,10 @@ public void ApplyTo_RightThresholdNotSet_KeepsCurrentValue()
146134
[Fact]
147135
public void ApplyTo_WithDebounceDelaySet_ChangesOnlyDebounceDelay()
148136
{
149-
var builder = CreateBuilder();
137+
var builder = new RuntimeOptionsUpdateBuilder();
150138
builder.WithDebounceDelay(TimeSpan.FromSeconds(1));
151139

152-
var result = InvokeApplyTo(builder, BaseOptions());
140+
var result = builder.ApplyTo(BaseOptions());
153141

154142
Assert.Equal(TimeSpan.FromSeconds(1), result.DebounceDelay);
155143
Assert.Equal(1.0, result.LeftCacheSize); // unchanged
@@ -164,15 +152,15 @@ public void ApplyTo_WithDebounceDelaySet_ChangesOnlyDebounceDelay()
164152
public void ApplyTo_FluentChain_AppliesAllChanges()
165153
{
166154
var base_ = new RuntimeCacheOptions(1.0, 1.0, null, null, TimeSpan.Zero);
167-
var builder = CreateBuilder();
155+
var builder = new RuntimeOptionsUpdateBuilder();
168156
builder
169157
.WithLeftCacheSize(2.0)
170158
.WithRightCacheSize(3.0)
171159
.WithLeftThreshold(0.1)
172160
.WithRightThreshold(0.15)
173161
.WithDebounceDelay(TimeSpan.FromMilliseconds(75));
174162

175-
var result = InvokeApplyTo(builder, base_);
163+
var result = builder.ApplyTo(base_);
176164

177165
Assert.Equal(2.0, result.LeftCacheSize);
178166
Assert.Equal(3.0, result.RightCacheSize);
@@ -185,9 +173,9 @@ public void ApplyTo_FluentChain_AppliesAllChanges()
185173
public void ApplyTo_EmptyBuilder_ReturnsSnapshotWithAllCurrentValues()
186174
{
187175
var base_ = BaseOptions();
188-
var builder = CreateBuilder();
176+
var builder = new RuntimeOptionsUpdateBuilder();
189177

190-
var result = InvokeApplyTo(builder, base_);
178+
var result = builder.ApplyTo(base_);
191179

192180
Assert.Equal(base_.LeftCacheSize, result.LeftCacheSize);
193181
Assert.Equal(base_.RightCacheSize, result.RightCacheSize);
@@ -203,10 +191,10 @@ public void ApplyTo_EmptyBuilder_ReturnsSnapshotWithAllCurrentValues()
203191
[Fact]
204192
public void ApplyTo_WithInvalidMergedCacheSize_ThrowsArgumentOutOfRangeException()
205193
{
206-
var builder = CreateBuilder();
194+
var builder = new RuntimeOptionsUpdateBuilder();
207195
builder.WithLeftCacheSize(-1.0);
208196

209-
var exception = Record.Exception(() => InvokeApplyTo(builder, BaseOptions()));
197+
var exception = Record.Exception(() => builder.ApplyTo(BaseOptions()));
210198

211199
Assert.NotNull(exception);
212200
Assert.IsType<ArgumentOutOfRangeException>(exception);
@@ -216,43 +204,28 @@ public void ApplyTo_WithInvalidMergedCacheSize_ThrowsArgumentOutOfRangeException
216204
public void ApplyTo_WithThresholdSumExceedingOne_ThrowsArgumentException()
217205
{
218206
// Base has leftThreshold=0.1; set right=0.95 → sum=1.05
219-
var builder = CreateBuilder();
207+
var builder = new RuntimeOptionsUpdateBuilder();
220208
builder.WithRightThreshold(0.95);
221209

222-
var exception = Record.Exception(() => InvokeApplyTo(builder, BaseOptions()));
210+
var exception = Record.Exception(() => builder.ApplyTo(BaseOptions()));
223211

224212
Assert.NotNull(exception);
225213
Assert.IsType<ArgumentException>(exception);
226214
}
227215

228-
#endregion
229-
230-
// Helpers — create builder via internal constructor using reflection,
231-
// and invoke the internal ApplyTo method.
232-
private static RuntimeOptionsUpdateBuilder CreateBuilder()
216+
[Fact]
217+
public void WithDebounceDelay_WithNegativeValue_ThrowsArgumentOutOfRangeException()
233218
{
234-
var ctor = typeof(RuntimeOptionsUpdateBuilder)
235-
.GetConstructor(
236-
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
237-
null, Type.EmptyTypes, null)!;
238-
return (RuntimeOptionsUpdateBuilder)ctor.Invoke(null);
239-
}
219+
// ARRANGE
220+
var builder = new RuntimeOptionsUpdateBuilder();
240221

241-
private static RuntimeCacheOptions InvokeApplyTo(
242-
RuntimeOptionsUpdateBuilder builder,
243-
RuntimeCacheOptions current)
244-
{
245-
var method = typeof(RuntimeOptionsUpdateBuilder)
246-
.GetMethod("ApplyTo",
247-
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
248-
try
249-
{
250-
return (RuntimeCacheOptions)method.Invoke(builder, [current])!;
251-
}
252-
catch (System.Reflection.TargetInvocationException ex) when (ex.InnerException is not null)
253-
{
254-
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
255-
throw; // unreachable, satisfies compiler
256-
}
222+
// ACT
223+
var exception = Record.Exception(() => builder.WithDebounceDelay(TimeSpan.FromMilliseconds(-1)));
224+
225+
// ASSERT
226+
Assert.NotNull(exception);
227+
Assert.IsType<ArgumentOutOfRangeException>(exception);
257228
}
229+
230+
#endregion
258231
}

tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsBuilderTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,20 @@ public void Build_WithZeroDebounceDelay_SetsZero()
354354
Assert.Equal(TimeSpan.Zero, options.DebounceDelay);
355355
}
356356

357+
[Fact]
358+
public void WithDebounceDelay_WithNegativeValue_ThrowsArgumentOutOfRangeException()
359+
{
360+
// ARRANGE
361+
var builder = new WindowCacheOptionsBuilder().WithCacheSize(1.0);
362+
363+
// ACT
364+
var exception = Record.Exception(() => builder.WithDebounceDelay(TimeSpan.FromMilliseconds(-1)));
365+
366+
// ASSERT
367+
Assert.NotNull(exception);
368+
Assert.IsType<ArgumentOutOfRangeException>(exception);
369+
}
370+
357371
#endregion
358372

359373
#region WithRebalanceQueueCapacity Tests

tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,25 @@ public void Constructor_WithVerySmallNegativeRightCacheSize_ThrowsArgumentOutOfR
324324
Assert.Equal("rightCacheSize", exception.ParamName);
325325
}
326326

327+
[Fact]
328+
public void Constructor_WithNegativeDebounceDelay_ThrowsArgumentOutOfRangeException()
329+
{
330+
// ARRANGE, ACT & ASSERT
331+
var exception = Record.Exception(() =>
332+
new WindowCacheOptions(
333+
leftCacheSize: 1.0,
334+
rightCacheSize: 1.0,
335+
readMode: UserCacheReadMode.Snapshot,
336+
debounceDelay: TimeSpan.FromMilliseconds(-1)
337+
)
338+
);
339+
340+
Assert.NotNull(exception);
341+
Assert.IsType<ArgumentOutOfRangeException>(exception);
342+
var argException = (ArgumentOutOfRangeException)exception;
343+
Assert.Equal("debounceDelay", argException.ParamName);
344+
}
345+
327346
#endregion
328347

329348
#region Constructor - Threshold Sum Validation Tests
@@ -690,26 +709,6 @@ public void GetHashCode_WithSameValues_ReturnsSameHashCode()
690709
Assert.Equal(options1.GetHashCode(), options2.GetHashCode());
691710
}
692711

693-
[Fact]
694-
public void GetHashCode_WithDifferentValues_ReturnsDifferentHashCode()
695-
{
696-
// ARRANGE
697-
var options1 = new WindowCacheOptions(
698-
leftCacheSize: 1.0,
699-
rightCacheSize: 1.0,
700-
readMode: UserCacheReadMode.Snapshot
701-
);
702-
703-
var options2 = new WindowCacheOptions(
704-
leftCacheSize: 2.0,
705-
rightCacheSize: 1.0,
706-
readMode: UserCacheReadMode.Snapshot
707-
);
708-
709-
// ACT & ASSERT — hash codes should differ (not guaranteed but expected for distinct values)
710-
Assert.NotEqual(options1.GetHashCode(), options2.GetHashCode());
711-
}
712-
713712
#endregion
714713

715714
#region Edge Cases and Boundary Tests

0 commit comments

Comments
 (0)