diff --git a/.gitignore b/.gitignore
index ce89292..fccde6b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -416,3 +416,4 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp
+/.github/copilot-instructions.md
diff --git a/README.md b/README.md
index c96328d..6ce9d70 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+
+
+
+
# TemporalCollections
[](https://opensource.org/licenses/MIT)
@@ -60,14 +64,15 @@ At the heart of all collections lies the `TemporalItem` struct:
| TemporalSegmentedArray | Thread-safe time-ordered segmented array optimized for append-in-order workloads and retention. | Yes | Sorted by timestamp (global) | Amortized O(1) append in-order, segment-based range queries and cleanup. |
| TemporalPriorityQueue | Thread-safe priority queue with timestamped items. Supports priority-based dequeueing and queries. | Yes | Priority order | Priority-based ordering with time queries. |
| TemporalIntervalTree | Thread-safe interval tree for timestamped intervals. Efficient overlap queries and interval removals. | Yes | Interval-based | Efficient interval overlap queries and removals. |
-| TemporalDictionary | Thread-safe dictionary where each key maps to a timestamped value. Supports add/update, remove, queries.| Yes | Unordered | Key-based access with timestamp tracking and queries. |
+| TemporalDictionary | Thread-safe dictionary where each key maps to multiple timestamped values. Supports add, remove, and efficient binary-search range queries.| Yes | Per-key chronological | Key-based access, binary-search range queries and cleanup. |
| TemporalMultimap | Thread-safe multimap where each key maps to multiple timestamped values with global time-based queries. | Yes | Per-key chronological | Multiple values per key, per-key range queries and global time view. |
| TemporalCircularBuffer | Thread-safe fixed-size circular buffer with timestamped items. Overwrites oldest items on overflow. | Yes | FIFO (circular) | Fixed size, efficient overwriting and time queries. |
+| TemporalCache | Thread-safe key/value cache with per-entry TTL. Supports absolute or sliding expiration and `TimeProvider`. | Yes | Unordered (by key) | TTL expiration, `GetOrAdd`, `Touch`, time queries over live entries. |
## Usage Guidance
| Collection Name | When to Use | When Not to Use |
|----------------------------------|-------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|
-| TemporalQueue | When you need a thread-safe FIFO queue with time-based retrieval and cleanup. | If you need priority ordering or random access. |
+| TemporalQueue | When you need a thread-safe FIFO queue with time-based retrieval and cleanup. Supports `TryDequeue`/`TryPeek` for safe non-throwing access. | If you need priority ordering or random access. |
| TemporalStack | When you want a thread-safe LIFO stack with timestamp tracking and time-range queries. | If you require fast arbitrary removal or sorting by timestamp. |
| TemporalSet | When you need unique timestamped items with efficient membership checks and time-based removal. | If you require ordering of elements or priority queues. |
| TemporalSlidingWindowSet | When you want to automatically retain only recent items within a fixed time window. | If your window size is highly variable or if you need sorted access. |
@@ -75,9 +80,10 @@ At the heart of all collections lies the `TemporalItem` struct:
| TemporalSegmentedArray | When you ingest events in (mostly) non-decreasing timestamp order and need fast range queries and retention. | If you frequently insert heavily out-of-order, need random removals in the middle, or key lookups. |
| TemporalPriorityQueue | When priority-based ordering with timestamp tracking is required for dequeueing. | If you only need FIFO or LIFO semantics without priorities. |
| TemporalIntervalTree | When you need efficient interval overlap queries and interval-based time operations. | If your data are single points rather than intervals. |
-| TemporalDictionary | When key-based access combined with timestamp tracking and querying is needed. | If ordering or range queries by timestamp are required. |
+| TemporalDictionary | When key-based access combined with timestamp tracking and efficient range queries are needed. Uses binary search internally for O(log n) range operations.| If you store a single value per key per time point (use TemporalSortedList) or need global sorted access. |
| TemporalMultimap | When each key can have multiple timestamped values and you need per-key queries and/or a global time-ordered view.| If you store a single value per key (use TemporalDictionary) or need ordering by non-time fields.|
| TemporalCircularBuffer | When you want a fixed-size buffer that overwrites oldest items with timestamp tracking. | If you need unbounded storage or complex queries. |
+| TemporalCache | When you need a key/value cache whose entries expire after a TTL (absolute or sliding), with time-based queries. | If you need multiple values per key (use TemporalDictionary) or size-based eviction (LRU). |
## ITimeQueryable Interface
@@ -121,6 +127,24 @@ All temporal collections implement the `ITimeQueryable` interface, which prov
- **GetNearest(DateTimeOffset time)**
Retrieves the item whose timestamp is closest to the specified `time`.
+- **GetAtOrBefore(DateTimeOffset time)**
+ Retrieves the latest item whose timestamp is less than or equal to the specified `time`, or `null` if none exists.
+
+- **TryGetAtOrBefore(DateTimeOffset time, out TemporalItem? item)**
+ Attempts to retrieve the latest item whose timestamp is less than or equal to the specified `time`.
+
+- **GetAtOrAfter(DateTimeOffset time)**
+ Retrieves the earliest item whose timestamp is greater than or equal to the specified `time`, or `null` if none exists.
+
+- **TryGetAtOrAfter(DateTimeOffset time, out TemporalItem? item)**
+ Attempts to retrieve the earliest item whose timestamp is greater than or equal to the specified `time`.
+
+- **CountBefore(DateTimeOffset time)**
+ Counts the number of items whose timestamp is strictly before the specified `time`.
+
+- **CountAfter(DateTimeOffset time)**
+ Counts the number of items whose timestamp is strictly after the specified `time`.
+
These methods collectively support efficient and thread-safe temporal queries and cleanups, allowing each collection to manage its items according to their timestamps while exposing a unified API.
## 🚀 Getting Started with TemporalCollections
@@ -145,14 +169,22 @@ var queue = new TemporalQueue();
queue.Enqueue("event-1");
queue.Enqueue("event-2");
-// Peek oldest (does not remove)
+// Peek oldest without removing (throws if empty)
var oldest = queue.Peek();
Console.WriteLine($"Oldest: {oldest.Value} @ {oldest.Timestamp}");
-// Dequeue oldest (removes)
+// Try-peek: safe non-throwing variant
+if (queue.TryPeek(out var peeked))
+ Console.WriteLine($"Peek: {peeked!.Value} @ {peeked.Timestamp}");
+
+// Dequeue oldest (throws if empty)
var dequeued = queue.Dequeue();
Console.WriteLine($"Dequeued: {dequeued.Value} @ {dequeued.Timestamp}");
+// Try-dequeue: safe non-throwing variant
+while (queue.TryDequeue(out var item))
+ Console.WriteLine($"Dequeued: {item!.Value} @ {item.Timestamp}");
+
// Query by time range (inclusive)
var from = DateTime.UtcNow.AddMinutes(-5);
var to = DateTime.UtcNow;
@@ -392,6 +424,32 @@ Console.WriteLine($"Overlaps: {string.Join(", ", overlapValues)}");
tree.RemoveOlderThan(now.AddMinutes(9));
```
+**TemporalCache**
+
+```csharp
+using System;
+using TemporalCollections.Collections;
+
+// Entries expire 5 minutes after write; use slidingExpiration: true to extend on read
+var cache = new TemporalCache(TimeSpan.FromMinutes(5));
+
+cache.Set("user:1", 42);
+cache.Set("token", 7, TimeSpan.FromSeconds(30)); // per-entry TTL
+
+if (cache.TryGetValue("user:1", out var value))
+ Console.WriteLine($"user:1 = {value}");
+
+var computed = cache.GetOrAdd("user:2", _ => 99);
+var ttl = cache.GetTimeToLive("token"); // remaining lifetime or null
+cache.Touch("user:1"); // reset lifetime
+
+// Eager cleanup of expired entries
+int removed = cache.RemoveExpired();
+
+// Time queries on write timestamps of live entries
+var recent = cache.GetAfter(DateTime.UtcNow.AddMinutes(-1));
+```
+
### Common queries via `ITimeQueryable`
```csharp
@@ -422,6 +480,22 @@ This approach ensures:
By enforcing this monotonic timestamp ordering, the temporal collections can rely on consistent time-based queries and maintain correct chronological order of items.
+## Custom Clock (`TimeProvider`)
+All collections accept an optional `TimeProvider` (.NET 8+) used for timestamps and expiration. Existing constructors keep using `TimeProvider.System`, so current code is unaffected.
+
+```csharp
+using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (optional, for tests)
+
+var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
+var window = new TemporalSlidingWindowSet(TimeSpan.FromMinutes(5), clock);
+
+window.Add("a");
+clock.Advance(TimeSpan.FromMinutes(6)); // no real waiting
+window.RemoveExpired(); // "a" is removed
+```
+
+`TemporalItem.Create(value, timeProvider)` is also available. Monotonicity is guaranteed per closed generic type and per `TimeProvider` instance.
+
## 📈 Performance Benchmarks
We provide detailed performance measurements for all temporal data structures, including insertion, range queries, and removal operations.
The full benchmark results are available here: [docs/benchmarks/benchmarks.md](docs/benchmarks/benchmarks.md)
@@ -440,9 +514,10 @@ All collections are thread-safe. Locking granularity and common operations (amor
| TemporalSegmentedArray | single lock; segmented storage | O(1) amortized (in-order append) | **O(log n + m)** | O(n) |
| TemporalPriorityQueue | single lock; SortedSet by (priority,time) | O(log n) | O(n) | O(n) |
| TemporalIntervalTree | single lock; interval overlap pruning | O(log n) avg | **O(log n + m)** | O(n) |
-| TemporalDictionary | concurrent dict + per-list lock | O(1) avg | O(n) | O(n) |
+| TemporalDictionary | concurrent dict + per-list lock | O(1) avg | **O(log n + m)** | O(log k) |
| TemporalMultimap | single lock; per-key ordered lists | O(1) avg | O(n + m log m) | O(n) |
| TemporalCircularBuffer | single lock; ring overwrite | O(1) | O(n) | O(n) |
+| TemporalCache | concurrent dict; atomic conditional updates| O(1) avg | O(n + m log m) | O(n) |
`n` = items, `m` = matches, `k` = removed.
@@ -450,6 +525,8 @@ All collections are thread-safe. Locking granularity and common operations (amor
- **Deterministic ordering**: query results are returned in ascending timestamp order.
- **Snapshot semantics**: methods that return enumerables/lists provide a stable snapshot at call time.
- **Thread-safety**: all operations are designed to be thread-safe per collection.
+- **Binary-search range queries**: `TemporalDictionary` and other sorted collections use `LowerBound`/`UpperBound` binary search internally, reducing range queries, `RemoveOlderThan`, `RemoveRange`, `CountInRange`, and `CountSince` from O(n) to O(log n + m).
+- **Try-pattern**: `TemporalQueue` exposes `TryDequeue`/`TryPeek` for non-throwing access, consistent with `TemporalPriorityQueue` and standard .NET collection conventions.
- **Intervals**: for interval-based collections, the Timestamp used by this interface refers to the interval start.
⚠️ **Since v1.1.0, internal timestamp storage has been migrated from DateTime to DateTimeOffset (UTC). Public APIs remain DateTime for backward compatibility, but internal semantics are now strictly UTC-aware.**
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..e209e10
Binary files /dev/null and b/assets/icon.png differ
diff --git a/assets/logo.svg b/assets/logo.svg
new file mode 100644
index 0000000..5a1e129
--- /dev/null
+++ b/assets/logo.svg
@@ -0,0 +1,20 @@
+
diff --git a/docs/benchmarks/benchmarks.md b/docs/benchmarks/benchmarks.md
index c7460b9..47d78cc 100644
--- a/docs/benchmarks/benchmarks.md
+++ b/docs/benchmarks/benchmarks.md
@@ -1,4 +1,73 @@
-# Performance Testing Summary (Updated – 2025-08-19)
+# Performance Testing Summary
+
+## Latest Run (Fast job)
+
+### Environment
+- **CPU**: Intel Core Ultra 7 155H, 22 logical / 16 physical cores
+- **OS**: Windows 11 25H2
+- **Runtime**: .NET 9.0.21, X64 RyuJIT x86-64-v3
+- **BenchmarkDotNet**: v0.15.8, `Fast` job (`LaunchCount=1`, `WarmupCount=3`, `IterationCount=10`)
+- **Dataset sizes**: 5,000 items per structure; `TemporalSegmentedArray` 20,000 items (`SegmentCapacity` = 256, 1024); `TemporalMultimap` 500 values per key (`KeyCount` = 10, 100)
+
+> ⚠️ These numbers are **not directly comparable** with the 2025 results below (dataset size halved, different job and hardware).
+> Run `dotnet run -c Release -- --full` for the full BenchmarkDotNet default job.
+
+All times are **mean in microseconds (µs)**.
+
+### Core structures (5,000 items)
+
+| Structure | AddItems (µs) | QueryRange (µs) | RemoveOlderThan (µs) |
+|---|---:|---:|---:|
+| TemporalSet | 271.5 | 0.30 | 0.08 |
+| TemporalSlidingWindowSet | 292.9 | 0.32 | 0.09 |
+| TemporalCircularBuffer | 287.5 | 0.08 | 0.04 |
+| TemporalStack | 455.7 | 0.07 | 0.04 |
+| TemporalSortedList | 477.6 | 0.07 | 0.04 |
+| TemporalIntervalTree | 530.2 | 0.07 | 0.04 |
+| TemporalDictionary | 611.7 | 0.11 | 0.08 |
+| TemporalPriorityQueue | 5,085.7 | 0.14 | 0.06 |
+
+**TemporalQueue**: `Enqueue` 0.08 µs, `Dequeue` 0.07 µs, `Peek` 0.02 µs, `RemoveOlderThan` 0.01 µs, `GetInRange_Count` 20.5 µs.
+
+### TemporalCache (5,000 items)
+
+| Method | Mean (µs) | Allocated |
+|---|---:|---:|
+| RemoveExpired scan | 47.0 | 64 B |
+| TryGetValue (absolute) | 128.7 | 0 B |
+| GetOrAdd | 134.7 | 0 B |
+| QueryRange (last 10 minutes) | 196.4 | 131,584 B |
+| TryGetValue (sliding) | 265.5 | 200,000 B |
+| Set | 484.7 | 440,000 B |
+
+### TemporalSegmentedArray (20,000 items, range across `SegmentCapacity` 256 / 1024)
+
+| Method | Mean (µs) |
+|---|---:|
+| Clear / Extremes / CountInRange / GetNearest / RemoveRange | 0.8 – 4.5 |
+| TrimExcess / ToArray | 11 – 16 |
+| GetBefore / GetAfter (midpoint) | 51 – 54 |
+| AddSorted (sorted input) | 129 – 134 |
+| Add pre-built (sorted) | 441 – 480 |
+| AddValue | 1,496 – 1,546 |
+| AddRange / Add pre-built (**unsorted** input) | 4,825 – 5,133 |
+
+> Unsorted input is **~35× slower** than `AddSorted`: prefer sorted batches when possible.
+
+### TemporalMultimap (indicative only)
+
+Results show high variance (up to ±60% StdDev on global queries) because of per-iteration setup; they are **not reported as reference values**.
+Order of magnitude: per-key removals/queries 1–30 µs, global counts/removals 4–65 µs, inserts 0.2–5 ms, global range/nearest queries 0.4–11 ms.
+
+### Takeaways
+- `QueryRange` and `RemoveOlderThan` remain **sub-microsecond** across all core structures.
+- `TemporalPriorityQueue` is still the most expensive on inserts (~10× the other structures).
+- `TemporalCache` sliding expiration costs ~2× absolute expiration and allocates on every read.
+- Extrapolating to 10,000 items (linear scaling, indicative only), `TemporalDictionary`, `TemporalIntervalTree` and `TemporalSortedList` inserts appear faster than in the 2025 run.
+
+---
+
+# Historical Results (2025-08-19)
### Overview
The following benchmarks summarize the performance of different temporal data structures across core operations: **AddItems**, **QueryRange**, and **RemoveOlderThan**.
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..61a3822
--- /dev/null
+++ b/global.json
@@ -0,0 +1 @@
+{ "test": { "runner": "Microsoft.Testing.Platform" } }
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs
new file mode 100644
index 0000000..a2606f8
--- /dev/null
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs
@@ -0,0 +1,85 @@
+// (c) 2025 Francesco Del Re
+// This code is licensed under MIT license (see LICENSE.txt for details)
+using BenchmarkDotNet.Attributes;
+using TemporalCollections.Collections;
+
+namespace TemporalCollections.PerformanceTests.Benchmarks
+{
+ [MemoryDiagnoser]
+ public class TemporalCacheBenchmarks
+ {
+ private TemporalCache _cache;
+ private TemporalCache _slidingCache;
+ private List _testData;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _cache = new TemporalCache(TimeSpan.FromMinutes(10));
+ _slidingCache = new TemporalCache(TimeSpan.FromMinutes(10), slidingExpiration: true);
+ _testData = Enumerable.Range(0, 5000).ToList();
+
+ foreach (var item in _testData)
+ {
+ _cache.Set(item, item);
+ _slidingCache.Set(item, item);
+ }
+ }
+
+ [Benchmark(Description = "Set 5,000 items")]
+ public void SetItems()
+ {
+ foreach (var item in _testData)
+ {
+ _cache.Set(item, item);
+ }
+ }
+
+ [Benchmark(Description = "TryGetValue 5,000 items (absolute)")]
+ public int TryGetValueAbsolute()
+ {
+ int sum = 0;
+ foreach (var item in _testData)
+ {
+ if (_cache.TryGetValue(item, out var v)) sum += v;
+ }
+ return sum;
+ }
+
+ [Benchmark(Description = "TryGetValue 5,000 items (sliding)")]
+ public int TryGetValueSliding()
+ {
+ int sum = 0;
+ foreach (var item in _testData)
+ {
+ if (_slidingCache.TryGetValue(item, out var v)) sum += v;
+ }
+ return sum;
+ }
+
+ [Benchmark(Description = "GetOrAdd 5,000 items")]
+ public int GetOrAddItems()
+ {
+ int sum = 0;
+ foreach (var item in _testData)
+ {
+ sum += _cache.GetOrAdd(item, k => k);
+ }
+ return sum;
+ }
+
+ [Benchmark(Description = "Query items in last 10 minutes")]
+ public int QueryRange()
+ {
+ var from = DateTime.UtcNow.AddMinutes(-10);
+ var to = DateTime.UtcNow;
+ return _cache.GetInRange(from, to).Count();
+ }
+
+ [Benchmark(Description = "RemoveExpired scan")]
+ public int RemoveExpired()
+ {
+ return _cache.RemoveExpired();
+ }
+ }
+}
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCircularBufferBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCircularBufferBenchmarks.cs
index c891c5e..301e715 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCircularBufferBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCircularBufferBenchmarks.cs
@@ -1,4 +1,4 @@
-// (c) 2025 Francesco Del Re
+// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using BenchmarkDotNet.Attributes;
using TemporalCollections.Collections;
@@ -14,11 +14,11 @@ public class TemporalCircularBufferBenchmarks
[GlobalSetup]
public void Setup()
{
- _buffer = new TemporalCircularBuffer(capacity: 20000);
- _testData = Enumerable.Range(0, 10000).ToList();
+ _buffer = new TemporalCircularBuffer(capacity: 10000);
+ _testData = Enumerable.Range(0, 5000).ToList();
}
- [Benchmark(Description = "Add 10,000 items")]
+ [Benchmark(Description = "Add 5,000 items")]
public void AddItems()
{
foreach (var item in _testData)
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalDictionaryBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalDictionaryBenchmarks.cs
index 240fb1f..a2a208a 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalDictionaryBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalDictionaryBenchmarks.cs
@@ -1,4 +1,4 @@
-// (c) 2025 Francesco Del Re
+// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using BenchmarkDotNet.Attributes;
using TemporalCollections.Collections;
@@ -15,10 +15,10 @@ public class TemporalDictionaryBenchmarks
public void Setup()
{
_dict = new TemporalDictionary();
- _testData = Enumerable.Range(0, 10000).ToList();
+ _testData = Enumerable.Range(0, 5000).ToList();
}
- [Benchmark(Description = "Add 10,000 items")]
+ [Benchmark(Description = "Add 5,000 items")]
public void AddItems()
{
foreach (var item in _testData)
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalIntervalTreeBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalIntervalTreeBenchmarks.cs
index ffc76b4..1cf2ae0 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalIntervalTreeBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalIntervalTreeBenchmarks.cs
@@ -1,4 +1,4 @@
-// (c) 2025 Francesco Del Re
+// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using BenchmarkDotNet.Attributes;
using TemporalCollections.Collections;
@@ -16,7 +16,7 @@ public class TemporalIntervalTreeBenchmarks
public void Setup()
{
_tree = new TemporalIntervalTree();
- _testData = Enumerable.Range(0, 10000).ToList();
+ _testData = Enumerable.Range(0, 5000).ToList();
var now = DateTime.UtcNow;
_intervals = new List<(DateTime, DateTime)>(_testData.Count);
@@ -29,7 +29,7 @@ public void Setup()
}
}
- [Benchmark(Description = "Insert 10,000 intervals")]
+ [Benchmark(Description = "Insert 5,000 intervals")]
public void AddItems()
{
for (int i = 0; i < _testData.Count; i++)
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs
index 0660a63..d1625fb 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs
@@ -30,7 +30,7 @@ public class TemporalMultimapBenchmarks
public int KeyCount { get; set; }
/// Number of values per key.
- [Params(1_000)]
+ [Params(500)]
public int ValuesPerKey { get; set; }
// ---------- Prepared data ----------
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalPriorityQueueBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalPriorityQueueBenchmarks.cs
index a7fbdc4..6800136 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalPriorityQueueBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalPriorityQueueBenchmarks.cs
@@ -1,4 +1,4 @@
-// (c) 2025 Francesco Del Re
+// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using BenchmarkDotNet.Attributes;
using TemporalCollections.Collections;
@@ -16,13 +16,13 @@ public class TemporalPriorityQueueBenchmarks
public void Setup()
{
_queue = new TemporalPriorityQueue();
- _testData = Enumerable.Range(0, 10000).ToList();
+ _testData = Enumerable.Range(0, 5000).ToList();
var rand = new Random(42);
_priorities = _testData.Select(_ => rand.Next(0, 1000)).ToList();
}
- [Benchmark(Description = "Enqueue 10,000 items")]
+ [Benchmark(Description = "Enqueue 5,000 items")]
public void AddItems()
{
for (int i = 0; i < _testData.Count; i++)
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalQueueBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalQueueBenchmarks.cs
index 8e93799..38c0351 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalQueueBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalQueueBenchmarks.cs
@@ -1,4 +1,4 @@
-// (c) 2025 Francesco Del Re
+// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using BenchmarkDotNet.Attributes;
using TemporalCollections.Collections;
@@ -19,10 +19,10 @@ public class TemporalQueueBenchmarks
public void Setup()
{
_queue = new TemporalQueue();
- _values = Enumerable.Range(0, 10000).ToArray();
+ _values = Enumerable.Range(0, 5000).ToArray();
_now = DateTime.UtcNow;
- // Pre-fill the queue with 10k items with timestamps spread in the past 10000 seconds
+ // Pre-fill the queue with 5k items with timestamps spread in the past 10000 seconds
for (int i = 0; i < _values.Length; i++)
{
var item = new TemporalItem(_values[i], _now.AddSeconds(-_values.Length + i));
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs
index 7e5d39a..b6cab48 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs
@@ -26,7 +26,7 @@ public class TemporalSegmentedArrayBenchmarks
// ---------- Parameters ----------
/// Total number of items to insert for the test datasets.
- [Params(50_000)]
+ [Params(20_000)]
public int ItemCount { get; set; }
/// Per-segment capacity used by TemporalSegmentedArray.
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSetBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSetBenchmarks.cs
index 5e22b40..1c2943e 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSetBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSetBenchmarks.cs
@@ -1,4 +1,4 @@
-// (c) 2025 Francesco Del Re
+// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using BenchmarkDotNet.Attributes;
using TemporalCollections.Collections;
@@ -15,10 +15,10 @@ public class TemporalSetBenchmarks
public void Setup()
{
_set = new TemporalSet();
- _testData = Enumerable.Range(0, 10000).ToList();
+ _testData = Enumerable.Range(0, 5000).ToList();
}
- [Benchmark(Description = "Add 10,000 items")]
+ [Benchmark(Description = "Add 5,000 items")]
public void AddItems()
{
foreach (var item in _testData)
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSlidingWindowSetBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSlidingWindowSetBenchmarks.cs
index aeb978a..2510ed6 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSlidingWindowSetBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSlidingWindowSetBenchmarks.cs
@@ -1,4 +1,4 @@
-// (c) 2025 Francesco Del Re
+// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using BenchmarkDotNet.Attributes;
using TemporalCollections.Collections;
@@ -15,10 +15,10 @@ public class TemporalSlidingWindowSetBenchmarks
public void Setup()
{
_set = new TemporalSlidingWindowSet(TimeSpan.FromMinutes(10));
- _testData = Enumerable.Range(0, 10000).ToList();
+ _testData = Enumerable.Range(0, 5000).ToList();
}
- [Benchmark(Description = "Add 10,000 items")]
+ [Benchmark(Description = "Add 5,000 items")]
public void AddItems()
{
foreach (var item in _testData)
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSortedListBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSortedListBenchmarks.cs
index 8ea8d15..87ad5de 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSortedListBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSortedListBenchmarks.cs
@@ -1,4 +1,4 @@
-// (c) 2025 Francesco Del Re
+// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using BenchmarkDotNet.Attributes;
using TemporalCollections.Collections;
@@ -15,10 +15,10 @@ public class TemporalSortedListBenchmarks
public void Setup()
{
_list = new TemporalSortedList();
- _testData = Enumerable.Range(0, 10000).ToList();
+ _testData = Enumerable.Range(0, 5000).ToList();
}
- [Benchmark(Description = "Add 10,000 items")]
+ [Benchmark(Description = "Add 5,000 items")]
public void AddItems()
{
foreach (var item in _testData)
diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalStackBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalStackBenchmarks.cs
index 08699cc..1202ace 100644
--- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalStackBenchmarks.cs
+++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalStackBenchmarks.cs
@@ -1,4 +1,4 @@
-// (c) 2025 Francesco Del Re
+// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using BenchmarkDotNet.Attributes;
using TemporalCollections.Collections;
@@ -15,10 +15,10 @@ public class TemporalStackBenchmarks
public void Setup()
{
_stack = new TemporalStack();
- _testData = Enumerable.Range(0, 10000).ToList();
+ _testData = Enumerable.Range(0, 5000).ToList();
}
- [Benchmark(Description = "Add 10,000 items")]
+ [Benchmark(Description = "Add 5,000 items")]
public void AddItems()
{
foreach (var item in _testData)
diff --git a/src/TemporalCollections.PerformanceTests/Program.cs b/src/TemporalCollections.PerformanceTests/Program.cs
index 97c9c32..889e851 100644
--- a/src/TemporalCollections.PerformanceTests/Program.cs
+++ b/src/TemporalCollections.PerformanceTests/Program.cs
@@ -3,17 +3,32 @@
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
+using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Loggers;
using BenchmarkDotNet.Order;
using BenchmarkDotNet.Reports;
using BenchmarkDotNet.Running;
+using System.Globalization;
using TemporalCollections.PerformanceTests.Benchmarks;
class Program
{
static void Main(string[] args)
{
+ bool fullRun = args.Contains("--full", StringComparer.OrdinalIgnoreCase);
+
+ // Fast job: fewer warmup/measurement iterations, single launch.
+ // Use --full to run the BenchmarkDotNet default (more accurate but much slower) job.
+ var job = fullRun
+ ? Job.Default
+ : Job.Default
+ .WithLaunchCount(1)
+ .WithWarmupCount(3)
+ .WithIterationCount(10)
+ .WithId("Fast");
+
var config = ManualConfig.Create(DefaultConfig.Instance)
+ .AddJob(job.AsDefault())
.AddLogger(ConsoleLogger.Default)
.AddColumn(TargetMethodColumn.Method)
.AddColumn(StatisticColumn.Mean)
@@ -37,17 +52,13 @@ static void Main(string[] args)
typeof(TemporalStackBenchmarks),
typeof(TemporalSlidingWindowSetBenchmarks),
typeof(TemporalCircularBufferBenchmarks),
+ typeof(TemporalMultimapBenchmarks),
+ typeof(TemporalSegmentedArrayBenchmarks),
+ typeof(TemporalCacheBenchmarks),
};
- var allSummaries = new List();
-
- foreach (var type in benchmarkTypes)
- {
- Console.WriteLine($"Running benchmarks for {type.Name}...");
- var summary = BenchmarkRunner.Run(type, config);
- allSummaries.Add(summary);
- Console.WriteLine();
- }
+ // Run all types in a single invocation so the benchmark project is generated and built only once.
+ var allSummaries = BenchmarkRunner.Run(benchmarkTypes.ToArray(), config).ToList();
PrintCombinedSummaryTable(allSummaries);
@@ -58,38 +69,39 @@ static void PrintCombinedSummaryTable(List summaries)
{
Console.WriteLine();
Console.WriteLine("Combined Benchmark Results Summary:");
- Console.WriteLine(new string('-', 100));
- Console.WriteLine($"| {"Structure",-25} | {"Method",-30} | {"Mean (ms)",10} | {"StdDev (ms)",12} | {"Min (ms)",10} | {"Max (ms)",10} | {"Ops/sec",10} |");
- Console.WriteLine(new string('-', 100));
+ Console.WriteLine();
+ Console.WriteLine("| Structure | Method | Params | Mean (µs) | StdDev (µs) | Min (µs) | Max (µs) | Ops/sec |");
+ Console.WriteLine("|---|---|---|---:|---:|---:|---:|---:|");
+
+ var inv = CultureInfo.InvariantCulture;
foreach (var summary in summaries)
{
- int reportsCount = 0;
- if (summary != null)
- {
- reportsCount = summary.Reports.Count();
- }
-
- string structureName = reportsCount > 0
- ? summary.Reports.First().BenchmarkCase.Descriptor.Type.Name
- : "Unknown";
+ if (summary == null) continue;
foreach (var report in summary.Reports)
{
var stats = report.ResultStatistics;
if (stats == null) continue;
- string method = report.BenchmarkCase.Descriptor.WorkloadMethod.Name;
- string mean = (stats.Mean / 1_000_000).ToString("F4"); // ns to ms
- string stddev = (stats.StandardDeviation / 1_000_000).ToString("F4");
- string min = (stats.Min / 1_000_000).ToString("F4");
- string max = (stats.Max / 1_000_000).ToString("F4");
- string opsPerSec = (1_000_000_000 / stats.Mean).ToString("F0");
+ var descriptor = report.BenchmarkCase.Descriptor;
+ string structureName = descriptor.Type.Name.Replace("Benchmarks", string.Empty);
+ string method = descriptor.WorkloadMethod.Name;
+ string parameters = report.BenchmarkCase.HasParameters
+ ? string.Join(", ", report.BenchmarkCase.Parameters.Items.Select(p => $"{p.Name}={p.Value}"))
+ : "-";
+
+ // ns to µs
+ string mean = (stats.Mean / 1_000).ToString("N3", inv);
+ string stddev = (stats.StandardDeviation / 1_000).ToString("N3", inv);
+ string min = (stats.Min / 1_000).ToString("N3", inv);
+ string max = (stats.Max / 1_000).ToString("N3", inv);
+ string opsPerSec = (1_000_000_000 / stats.Mean).ToString("N0", inv);
- Console.WriteLine($"| {structureName,-25} | {method,-30} | {mean,10} | {stddev,12} | {min,10} | {max,10} | {opsPerSec,10} |");
+ Console.WriteLine($"| {structureName} | {method} | {parameters} | {mean} | {stddev} | {min} | {max} | {opsPerSec} |");
}
}
- Console.WriteLine(new string('-', 100));
+ Console.WriteLine();
}
}
\ No newline at end of file
diff --git a/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj b/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj
index 2a3d0df..3c0aa9a 100644
--- a/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj
+++ b/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/src/TemporalCollections.Tests/Collections/TemporalCacheTests.cs b/src/TemporalCollections.Tests/Collections/TemporalCacheTests.cs
new file mode 100644
index 0000000..7375fa4
--- /dev/null
+++ b/src/TemporalCollections.Tests/Collections/TemporalCacheTests.cs
@@ -0,0 +1,516 @@
+// (c) 2025 Francesco Del Re
+// This code is licensed under MIT license (see LICENSE.txt for details)
+using TemporalCollections.Collections;
+
+namespace TemporalCollections.Tests.Collections
+{
+ public class TemporalCacheTests
+ {
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private DateTimeOffset _now;
+ public ManualTimeProvider(DateTimeOffset start) => _now = start;
+ public override DateTimeOffset GetUtcNow() => _now;
+ public void Advance(TimeSpan delta) => _now += delta;
+ }
+
+ private static (TemporalCache cache, ManualTimeProvider clock) Create(
+ TimeSpan? ttl = null, bool sliding = false)
+ {
+ var clock = new ManualTimeProvider(DateTimeOffset.UtcNow);
+ var cache = new TemporalCache(ttl ?? TimeSpan.FromMinutes(1), sliding, clock);
+ return (cache, clock);
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNonPositiveTtl()
+ {
+ Assert.Throws(() => new TemporalCache(TimeSpan.Zero));
+ Assert.Throws(() => new TemporalCache(TimeSpan.FromSeconds(-1)));
+ }
+
+ [Fact]
+ public void Constructor_SetsProperties()
+ {
+ var cache = new TemporalCache(TimeSpan.FromSeconds(5), slidingExpiration: true);
+ Assert.Equal(TimeSpan.FromSeconds(5), cache.DefaultTtl);
+ Assert.True(cache.SlidingExpiration);
+ Assert.Equal(0, cache.Count);
+ }
+
+ [Fact]
+ public void Constructor_UsesCustomComparer()
+ {
+ var cache = new TemporalCache(TimeSpan.FromMinutes(1), comparer: StringComparer.OrdinalIgnoreCase);
+ cache.Set("Key", 1);
+ Assert.True(cache.TryGetValue("KEY", out var v));
+ Assert.Equal(1, v);
+ }
+
+ [Fact]
+ public void Set_And_TryGetValue_ReturnsValue()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ Assert.True(cache.TryGetValue("a", out var v));
+ Assert.Equal(1, v);
+ }
+
+ [Fact]
+ public void TryGetValue_MissingKey_ReturnsFalse()
+ {
+ var (cache, _) = Create();
+ Assert.False(cache.TryGetValue("missing", out var v));
+ Assert.Equal(default, v);
+ }
+
+ [Fact]
+ public void Set_OverwritesExistingValue()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ cache.Set("a", 2);
+ Assert.True(cache.TryGetValue("a", out var v));
+ Assert.Equal(2, v);
+ Assert.Equal(1, cache.Count);
+ }
+
+ [Fact]
+ public void Set_ThrowsOnNonPositiveTtl()
+ {
+ var (cache, _) = Create();
+ Assert.Throws(() => cache.Set("a", 1, TimeSpan.Zero));
+ }
+
+ [Fact]
+ public void Entry_ExpiresAfterDefaultTtl()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(10));
+ cache.Set("a", 1);
+
+ clock.Advance(TimeSpan.FromSeconds(9));
+ Assert.True(cache.ContainsKey("a"));
+
+ clock.Advance(TimeSpan.FromSeconds(1));
+ Assert.False(cache.ContainsKey("a"));
+ Assert.False(cache.TryGetValue("a", out _));
+ Assert.Equal(0, cache.Count);
+ }
+
+ [Fact]
+ public void Entry_RespectsPerEntryTtl()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(10));
+ cache.Set("short", 1, TimeSpan.FromSeconds(2));
+ cache.Set("long", 2);
+
+ clock.Advance(TimeSpan.FromSeconds(3));
+ Assert.False(cache.ContainsKey("short"));
+ Assert.True(cache.ContainsKey("long"));
+ }
+
+ [Fact]
+ public void AbsoluteExpiration_ReadDoesNotExtendLifetime()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(10));
+ cache.Set("a", 1);
+
+ clock.Advance(TimeSpan.FromSeconds(8));
+ Assert.True(cache.TryGetValue("a", out _));
+
+ clock.Advance(TimeSpan.FromSeconds(3));
+ Assert.False(cache.TryGetValue("a", out _));
+ }
+
+ [Fact]
+ public void SlidingExpiration_ReadExtendsLifetime()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(10), sliding: true);
+ cache.Set("a", 1);
+
+ clock.Advance(TimeSpan.FromSeconds(8));
+ Assert.True(cache.TryGetValue("a", out _));
+
+ clock.Advance(TimeSpan.FromSeconds(8));
+ Assert.True(cache.TryGetValue("a", out _));
+
+ clock.Advance(TimeSpan.FromSeconds(10));
+ Assert.False(cache.TryGetValue("a", out _));
+ }
+
+ [Fact]
+ public void SlidingExpiration_ContainsKeyDoesNotExtendLifetime()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(10), sliding: true);
+ cache.Set("a", 1);
+
+ clock.Advance(TimeSpan.FromSeconds(8));
+ Assert.True(cache.ContainsKey("a"));
+
+ clock.Advance(TimeSpan.FromSeconds(3));
+ Assert.False(cache.ContainsKey("a"));
+ }
+
+ [Fact]
+ public void TryAdd_AddsOnlyWhenAbsent()
+ {
+ var (cache, _) = Create();
+ Assert.True(cache.TryAdd("a", 1));
+ Assert.False(cache.TryAdd("a", 2));
+ Assert.True(cache.TryGetValue("a", out var v));
+ Assert.Equal(1, v);
+ }
+
+ [Fact]
+ public void TryAdd_ReplacesExpiredEntry()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(5));
+ cache.TryAdd("a", 1);
+ clock.Advance(TimeSpan.FromSeconds(6));
+
+ Assert.True(cache.TryAdd("a", 2));
+ Assert.True(cache.TryGetValue("a", out var v));
+ Assert.Equal(2, v);
+ }
+
+ [Fact]
+ public void TryAdd_ThrowsOnNonPositiveTtl()
+ {
+ var (cache, _) = Create();
+ Assert.Throws(() => cache.TryAdd("a", 1, TimeSpan.FromSeconds(-1)));
+ }
+
+ [Fact]
+ public void GetOrAdd_InvokesFactoryOnlyWhenMissing()
+ {
+ var (cache, _) = Create();
+ int calls = 0;
+
+ var v1 = cache.GetOrAdd("a", _ => { calls++; return 42; });
+ var v2 = cache.GetOrAdd("a", _ => { calls++; return 99; });
+
+ Assert.Equal(42, v1);
+ Assert.Equal(42, v2);
+ Assert.Equal(1, calls);
+ }
+
+ [Fact]
+ public void GetOrAdd_RecomputesAfterExpiration()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(5));
+ cache.GetOrAdd("a", _ => 1);
+ clock.Advance(TimeSpan.FromSeconds(5));
+
+ var v = cache.GetOrAdd("a", _ => 2);
+ Assert.Equal(2, v);
+ }
+
+ [Fact]
+ public void GetOrAdd_ThrowsOnNullFactory()
+ {
+ var (cache, _) = Create();
+ Assert.Throws(() => cache.GetOrAdd("a", null!));
+ }
+
+ [Fact]
+ public void TryGetItem_ReturnsTemporalItemWithKeyAndValue()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 7);
+
+ Assert.True(cache.TryGetItem("a", out var item));
+ Assert.NotNull(item);
+ Assert.Equal("a", item!.Value.Key);
+ Assert.Equal(7, item.Value.Value);
+
+ Assert.False(cache.TryGetItem("b", out var missing));
+ Assert.Null(missing);
+ }
+
+ [Fact]
+ public void TryRemove_RemovesLiveEntry()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+
+ Assert.True(cache.TryRemove("a", out var v));
+ Assert.Equal(1, v);
+ Assert.False(cache.ContainsKey("a"));
+ Assert.False(cache.TryRemove("a", out _));
+ }
+
+ [Fact]
+ public void TryRemove_ExpiredEntry_ReturnsFalse()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(1));
+ cache.Set("a", 1);
+ clock.Advance(TimeSpan.FromSeconds(2));
+ Assert.False(cache.TryRemove("a", out _));
+ }
+
+ [Fact]
+ public void GetTimeToLive_ReturnsRemainingTime()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(10));
+ cache.Set("a", 1);
+ clock.Advance(TimeSpan.FromSeconds(4));
+
+ Assert.Equal(TimeSpan.FromSeconds(6), cache.GetTimeToLive("a"));
+ Assert.Null(cache.GetTimeToLive("missing"));
+
+ clock.Advance(TimeSpan.FromSeconds(6));
+ Assert.Null(cache.GetTimeToLive("a"));
+ }
+
+ [Fact]
+ public void Touch_ResetsLifetime()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(10));
+ cache.Set("a", 1);
+ clock.Advance(TimeSpan.FromSeconds(8));
+
+ Assert.True(cache.Touch("a"));
+ Assert.Equal(TimeSpan.FromSeconds(10), cache.GetTimeToLive("a"));
+
+ clock.Advance(TimeSpan.FromSeconds(11));
+ Assert.False(cache.Touch("a"));
+ Assert.False(cache.Touch("missing"));
+ }
+
+ [Fact]
+ public void RemoveExpired_RemovesOnlyExpiredEntries()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(10));
+ cache.Set("a", 1, TimeSpan.FromSeconds(2));
+ cache.Set("b", 2, TimeSpan.FromSeconds(3));
+ cache.Set("c", 3);
+
+ clock.Advance(TimeSpan.FromSeconds(5));
+ Assert.Equal(2, cache.RemoveExpired());
+ Assert.Equal(1, cache.Count);
+ Assert.Equal(new[] { "c" }, cache.Keys);
+ Assert.Equal(0, cache.RemoveExpired());
+ }
+
+ [Fact]
+ public void Keys_And_Count_ExcludeExpired()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(10));
+ cache.Set("a", 1, TimeSpan.FromSeconds(1));
+ cache.Set("b", 2);
+
+ clock.Advance(TimeSpan.FromSeconds(2));
+ Assert.Equal(1, cache.Count);
+ Assert.Single(cache.Keys);
+ Assert.Contains("b", cache.Keys);
+ }
+
+ [Fact]
+ public void Clear_RemovesAll()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ cache.Set("b", 2);
+ cache.Clear();
+ Assert.Equal(0, cache.Count);
+ Assert.Null(cache.GetLatest());
+ }
+
+ [Fact]
+ public void GetInRange_ReturnsItemsOrderedByTimestamp()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ cache.Set("b", 2);
+ cache.Set("c", 3);
+
+ var all = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToList();
+ Assert.Equal(new[] { "a", "b", "c" }, all.Select(i => i.Value.Key));
+
+ var bTs = all[1].Timestamp;
+ var only = cache.GetInRange(bTs, bTs).ToList();
+ Assert.Single(only);
+ Assert.Equal("b", only[0].Value.Key);
+ }
+
+ [Fact]
+ public void GetInRange_SwapsReversedBounds()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ var ts = cache.GetLatest()!.Timestamp;
+
+ var items = cache.GetInRange(ts.AddSeconds(1), ts.AddSeconds(-1)).ToList();
+ Assert.Single(items);
+ }
+
+ [Fact]
+ public void GetInRange_DateTimeOverload_Works()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ var ts = cache.GetLatest()!.Timestamp.UtcDateTime;
+
+ Assert.Single(cache.GetInRange(ts.AddSeconds(-1), ts.AddSeconds(1)));
+ }
+
+ [Fact]
+ public void TimeQueries_ExcludeExpiredEntries()
+ {
+ var (cache, clock) = Create(TimeSpan.FromSeconds(10));
+ cache.Set("a", 1, TimeSpan.FromSeconds(1));
+ cache.Set("b", 2);
+ clock.Advance(TimeSpan.FromSeconds(2));
+
+ var all = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToList();
+ Assert.Single(all);
+ Assert.Equal("b", all[0].Value.Key);
+ Assert.Equal(1, cache.CountInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue));
+ Assert.Equal("b", cache.GetEarliest()!.Value.Key);
+ }
+
+ [Fact]
+ public void CountInRange_And_CountSince()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ cache.Set("b", 2);
+ cache.Set("c", 3);
+
+ var items = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToList();
+ Assert.Equal(2, cache.CountInRange(items[0].Timestamp, items[1].Timestamp));
+ Assert.Equal(2, cache.CountSince(items[1].Timestamp));
+ Assert.Equal(0, cache.CountSince(items[2].Timestamp.AddTicks(1)));
+ }
+
+ [Fact]
+ public void GetBefore_And_GetAfter_AreExclusive()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ cache.Set("b", 2);
+ cache.Set("c", 3);
+
+ var mid = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ElementAt(1).Timestamp;
+
+ Assert.Equal(new[] { "a" }, cache.GetBefore(mid).Select(i => i.Value.Key));
+ Assert.Equal(new[] { "c" }, cache.GetAfter(mid).Select(i => i.Value.Key));
+ }
+
+ [Fact]
+ public void RemoveOlderThan_RemovesStrictlyOlder()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ cache.Set("b", 2);
+ cache.Set("c", 3);
+
+ var mid = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ElementAt(1).Timestamp;
+ cache.RemoveOlderThan(mid);
+
+ Assert.False(cache.ContainsKey("a"));
+ Assert.True(cache.ContainsKey("b"));
+ Assert.True(cache.ContainsKey("c"));
+ }
+
+ [Fact]
+ public void RemoveRange_RemovesInclusiveRange()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ cache.Set("b", 2);
+ cache.Set("c", 3);
+
+ var items = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToList();
+ cache.RemoveRange(items[1].Timestamp, items[2].Timestamp);
+
+ Assert.Equal(new[] { "a" }, cache.Keys);
+ }
+
+ [Fact]
+ public void GetNearest_ReturnsClosestItem()
+ {
+ var (cache, _) = Create();
+ Assert.Null(cache.GetNearest(DateTimeOffset.UtcNow));
+
+ cache.Set("a", 1);
+ cache.Set("b", 2);
+ var items = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToList();
+
+ Assert.Equal("a", cache.GetNearest(items[0].Timestamp.AddDays(-1))!.Value.Key);
+ Assert.Equal("b", cache.GetNearest(items[1].Timestamp.AddDays(1))!.Value.Key);
+ }
+
+ [Fact]
+ public void GetEarliest_GetLatest_GetTimeSpan()
+ {
+ var (cache, _) = Create();
+ Assert.Null(cache.GetEarliest());
+ Assert.Null(cache.GetLatest());
+ Assert.Equal(TimeSpan.Zero, cache.GetTimeSpan());
+
+ cache.Set("a", 1);
+ Assert.Equal(TimeSpan.Zero, cache.GetTimeSpan());
+
+ cache.Set("b", 2);
+ var earliest = cache.GetEarliest()!;
+ var latest = cache.GetLatest()!;
+ Assert.Equal("a", earliest.Value.Key);
+ Assert.Equal("b", latest.Value.Key);
+ Assert.Equal(latest.Timestamp - earliest.Timestamp, cache.GetTimeSpan());
+ Assert.True(cache.GetTimeSpan() > TimeSpan.Zero);
+ }
+
+ [Fact]
+ public void Set_RefreshesWriteTimestamp()
+ {
+ var (cache, _) = Create();
+ cache.Set("a", 1);
+ cache.Set("b", 2);
+ cache.Set("a", 3);
+
+ Assert.Equal("a", cache.GetLatest()!.Value.Key);
+ Assert.Equal(3, cache.GetLatest()!.Value.Value);
+ }
+
+ [Fact]
+ public void LargeTtl_DoesNotOverflow()
+ {
+ var (cache, _) = Create(TimeSpan.MaxValue);
+ cache.Set("a", 1);
+ Assert.True(cache.TryGetValue("a", out _));
+ Assert.NotNull(cache.GetTimeToLive("a"));
+ }
+
+ [Fact]
+ public void ConcurrentOperations_AreThreadSafe()
+ {
+ var cache = new TemporalCache(TimeSpan.FromMinutes(1), slidingExpiration: true);
+
+ Parallel.For(0, 10_000, i =>
+ {
+ int key = i % 100;
+ cache.Set(key, i);
+ cache.TryGetValue(key, out _);
+ cache.GetOrAdd(key + 100, k => k);
+ if (i % 7 == 0) cache.TryRemove(key, out _);
+ if (i % 50 == 0) cache.RemoveExpired();
+ });
+
+ Assert.InRange(cache.Count, 100, 200);
+ for (int k = 100; k < 200; k++)
+ Assert.True(cache.ContainsKey(k));
+ }
+
+ [Fact]
+ public void ConcurrentGetOrAdd_StoresSingleValue()
+ {
+ var cache = new TemporalCache(TimeSpan.FromMinutes(1));
+ var results = new System.Collections.Concurrent.ConcurrentBag();
+
+ Parallel.For(0, 1_000, _ => results.Add(cache.GetOrAdd("k", _ => Guid.NewGuid())));
+
+ Assert.Single(results.Distinct());
+ }
+ }
+}
diff --git a/src/TemporalCollections.Tests/Collections/TemporalCircularBufferTests.cs b/src/TemporalCollections.Tests/Collections/TemporalCircularBufferTests.cs
index 0d8a387..3102234 100644
--- a/src/TemporalCollections.Tests/Collections/TemporalCircularBufferTests.cs
+++ b/src/TemporalCollections.Tests/Collections/TemporalCircularBufferTests.cs
@@ -354,5 +354,25 @@ public void TemporalCircularBuffer_GetNearest_WorksAndTiesPreferLater()
Assert.Equal("C", nearCeil!.Value);
}
}
+
+ [Fact]
+ public async Task Add_Concurrent_KeepsChronologicalOrder()
+ {
+ const int threads = 8, perThread = 5_000;
+ var buffer = new TemporalCircularBuffer(threads * perThread);
+ using var gate = new Barrier(threads);
+
+ var tasks = Enumerable.Range(0, threads).Select(t => Task.Factory.StartNew(() =>
+ {
+ gate.SignalAndWait();
+ for (int i = 0; i < perThread; i++) buffer.Add(t * perThread + i);
+ }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default));
+ await Task.WhenAll(tasks);
+
+ var items = buffer.GetSnapshot();
+ Assert.Equal(threads * perThread, items.Count);
+ for (int i = 1; i < items.Count; i++)
+ Assert.True(items[i - 1].Timestamp < items[i].Timestamp, $"Out of order at index {i}");
+ }
}
}
\ No newline at end of file
diff --git a/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs b/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs
index 36d41d3..3ba89e4 100644
--- a/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs
+++ b/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs
@@ -588,5 +588,175 @@ public void TemporalDictionary_GetNearest_WorksAndTiesPreferLater()
Assert.Equal(c.Value.Key, nearCeil.Value.Key);
}
}
+
+ // -----------------------------------------------------------------------
+ // Binary-search optimizations — boundary semantics
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void GetBefore_ExactBoundary_IsStrictlyExclusive()
+ {
+ var dict = new TemporalDictionary();
+ dict.Add("k", 1);
+ Thread.Sleep(5);
+ dict.Add("k", 2);
+
+ // Capture the exact timestamp of item "2"
+ var ts2 = dict.GetLatest()!.Timestamp;
+
+ // GetBefore(ts2) must NOT include item with ts == ts2
+ var before = dict.GetBefore(ts2).ToList();
+ Assert.DoesNotContain(before, i => i.Value.Value == 2 && i.Timestamp == ts2);
+ Assert.Contains(before, i => i.Value.Value == 1);
+ }
+
+ [Fact]
+ public void GetAfter_ExactBoundary_IsStrictlyExclusive()
+ {
+ var dict = new TemporalDictionary();
+ dict.Add("k", 1);
+ Thread.Sleep(5);
+ dict.Add("k", 2);
+
+ // Capture the exact timestamp of item "1"
+ var ts1 = dict.GetEarliest()!.Timestamp;
+
+ // GetAfter(ts1) must NOT include item with ts == ts1
+ var after = dict.GetAfter(ts1).ToList();
+ Assert.DoesNotContain(after, i => i.Value.Value == 1 && i.Timestamp == ts1);
+ Assert.Contains(after, i => i.Value.Value == 2);
+ }
+
+ [Fact]
+ public void CountSince_ExactBoundary_IsInclusive()
+ {
+ var dict = new TemporalDictionary();
+ dict.Add("k", 1);
+ Thread.Sleep(5);
+ dict.Add("k", 2);
+ Thread.Sleep(5);
+ dict.Add("k", 3);
+
+ // Get the timestamp of the middle item
+ var all = dict.GetInRange(DateTime.MinValue, DateTime.MaxValue)
+ .OrderBy(i => i.Timestamp).ToList();
+ var tsMiddle = all[1].Timestamp;
+
+ // CountSince(tsMiddle) must include items with ts >= tsMiddle (items 2 and 3)
+ int count = dict.CountSince(tsMiddle);
+ Assert.Equal(2, count);
+ }
+
+ [Fact]
+ public void CountInRange_BinarySearch_MatchesGetInRangeCount()
+ {
+ var dict = new TemporalDictionary();
+
+ dict.Add("a", 1);
+ Thread.Sleep(5);
+ var tFrom = DateTime.UtcNow;
+ Thread.Sleep(5);
+ dict.Add("a", 2);
+ Thread.Sleep(5);
+ dict.Add("b", 3);
+ Thread.Sleep(5);
+ var tTo = DateTime.UtcNow;
+ Thread.Sleep(5);
+ dict.Add("b", 4);
+
+ int expected = dict.GetInRange(tFrom, tTo).Count();
+ int actual = dict.CountInRange(tFrom, tTo);
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Fact]
+ public void RemoveRange_BinarySearch_LeavesExactlyOutsideElements()
+ {
+ var dict = new TemporalDictionary();
+
+ dict.Add("k", 1); // outside (before)
+ Thread.Sleep(5);
+ var tFrom = DateTime.UtcNow;
+ Thread.Sleep(5);
+ dict.Add("k", 2); // inside
+ Thread.Sleep(5);
+ dict.Add("k", 3); // inside
+ Thread.Sleep(5);
+ var tTo = DateTime.UtcNow;
+ Thread.Sleep(5);
+ dict.Add("k", 4); // outside (after)
+
+ dict.RemoveRange(tFrom, tTo);
+
+ var remaining = dict.GetInRange(DateTime.MinValue, DateTime.MaxValue)
+ .Select(i => i.Value.Value).ToList();
+
+ Assert.Equal(2, remaining.Count);
+ Assert.Contains(1, remaining);
+ Assert.Contains(4, remaining);
+ Assert.DoesNotContain(2, remaining);
+ Assert.DoesNotContain(3, remaining);
+ }
+
+ [Fact]
+ public void GetEarliest_AfterRemoveOlderThan_ReflectsNewMinimum()
+ {
+ var dict = new TemporalDictionary();
+
+ dict.Add("k", 1);
+ Thread.Sleep(5);
+ dict.Add("k", 2);
+ Thread.Sleep(5);
+ var cutoff = DateTime.UtcNow;
+ Thread.Sleep(5);
+ dict.Add("k", 3);
+
+ dict.RemoveOlderThan(cutoff);
+
+ var earliest = dict.GetEarliest();
+ Assert.NotNull(earliest);
+ Assert.Equal(3, earliest!.Value.Value);
+ }
+
+ [Fact]
+ public void GetLatest_AfterRemoveRange_ReflectsNewMaximum()
+ {
+ var dict = new TemporalDictionary();
+
+ dict.Add("k", 1);
+ Thread.Sleep(5);
+ dict.Add("k", 2);
+ Thread.Sleep(5);
+ var tFrom = DateTime.UtcNow;
+ Thread.Sleep(5);
+ dict.Add("k", 3); // will be removed
+
+ dict.RemoveRange(tFrom, DateTime.UtcNow.AddMinutes(1));
+
+ var latest = dict.GetLatest();
+ Assert.NotNull(latest);
+ Assert.Equal(2, latest!.Value.Value);
+ }
+
+ [Fact]
+ public void RemoveOlderThan_BinarySearch_ExactCutoffIsKept()
+ {
+ // Verify the strict-less-than semantics: items with ts == cutoff must NOT be removed
+ var dict = new TemporalDictionary();
+
+ dict.Add("k", 1);
+ Thread.Sleep(5);
+ dict.Add("k", 2);
+
+ // Use the exact timestamp of item "2" as the cutoff
+ var tsCutoff = dict.GetLatest()!.Timestamp;
+
+ dict.RemoveOlderThan(tsCutoff);
+
+ var remaining = dict.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ // Item with ts == tsCutoff must still be present (RemoveOlderThan is strictly <)
+ Assert.Contains(remaining, i => i.Value.Value == 2);
+ }
}
}
\ No newline at end of file
diff --git a/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs b/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs
index de3c17d..5143aaa 100644
--- a/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs
+++ b/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs
@@ -660,5 +660,147 @@ public void TemporalIntervalTree_GetNearest_WorksAndTiesPreferLater()
var tie = tree.GetNearest(mid);
Assert.Equal("C", tie!.Value);
}
+
+ /// Distinct values that always produce the same hash code.
+ private sealed record Colliding(string Name)
+ {
+ public override int GetHashCode() => 42;
+ }
+
+ [Fact]
+ public void Insert_DistinctValuesWithSameHash_AreAllStored()
+ {
+ var tree = new TemporalIntervalTree();
+ var s = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var e = s.AddMinutes(10);
+
+ tree.Insert(s, e, new Colliding("a"));
+ tree.Insert(s, e, new Colliding("b"));
+ tree.Insert(s, e, new Colliding("c"));
+
+ var values = tree.Query(s, e).Select(v => v.Name).OrderBy(x => x).ToList();
+ Assert.Equal(new[] { "a", "b", "c" }, values);
+ Assert.Equal(3, tree.CountInRange(s, e));
+ }
+
+ [Fact]
+ public void Remove_WithCollidingHash_RemovesOnlyMatchingValue()
+ {
+ var tree = new TemporalIntervalTree();
+ var s = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var e = s.AddMinutes(10);
+
+ for (int i = 0; i < 50; i++)
+ tree.Insert(s, e, new Colliding("v" + i));
+
+ Assert.True(tree.Remove(s, e, new Colliding("v25")));
+ Assert.False(tree.Remove(s, e, new Colliding("v25")));
+ Assert.False(tree.Remove(s, e, new Colliding("missing")));
+
+ var values = tree.Query(s, e).Select(v => v.Name).ToList();
+ Assert.Equal(49, values.Count);
+ Assert.DoesNotContain("v25", values);
+ }
+
+ [Fact]
+ public void Insert_ExactDuplicate_IsIgnored()
+ {
+ var tree = new TemporalIntervalTree();
+ var s = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var e = s.AddMinutes(1);
+
+ tree.Insert(s, e, new Colliding("x"));
+ tree.Insert(s, e, new Colliding("x"));
+
+ Assert.Single(tree.Query(s, e));
+ }
+
+ [Fact]
+ public void InsertAndRemove_NullValues_AreSupported()
+ {
+ var tree = new TemporalIntervalTree();
+ var s = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var e = s.AddMinutes(1);
+
+ tree.Insert(s, e, null);
+ tree.Insert(s, e, null);
+ Assert.Single(tree.Query(s, e));
+ Assert.True(tree.Remove(s, e, null));
+ Assert.Empty(tree.Query(s, e));
+ }
+
+ private static TemporalIntervalTree BuildSemanticsTree(out DateTimeOffset t0)
+ {
+ // A: [t0, t0+10m] B: [t0+5m, t0+6m] C: [t0+20m, t0+30m]
+ t0 = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var tree = new TemporalIntervalTree();
+ tree.Insert(t0, t0.AddMinutes(10), "A");
+ tree.Insert(t0.AddMinutes(5), t0.AddMinutes(6), "B");
+ tree.Insert(t0.AddMinutes(20), t0.AddMinutes(30), "C");
+ return tree;
+ }
+
+ [Fact]
+ public void GetOverlapping_And_CountOverlapping_AreConsistent()
+ {
+ var tree = BuildSemanticsTree(out var t0);
+ var from = t0.AddMinutes(8);
+ var to = t0.AddMinutes(25);
+
+ var overlapping = tree.GetOverlapping(from, to).Select(i => i.Value).OrderBy(v => v).ToList();
+ Assert.Equal(new[] { "A", "C" }, overlapping);
+ Assert.Equal(overlapping.Count, tree.CountOverlapping(from, to));
+ Assert.Equal(overlapping.Count, tree.CountOverlapping(to, from));
+ }
+
+ [Fact]
+ public void GetInRange_UsesOverlap_WhileCountInRange_UsesStart()
+ {
+ var tree = BuildSemanticsTree(out var t0);
+ var from = t0.AddMinutes(8);
+ var to = t0.AddMinutes(25);
+
+ // Pins current 1.x semantics: overlap vs Start-based.
+ Assert.Equal(2, tree.GetInRange(from, to).Count()); // A (overlaps) + C
+ Assert.Equal(1, tree.CountInRange(from, to)); // only C starts in range
+ }
+
+ [Fact]
+ public void RemoveEndedBefore_KeepsActiveIntervals_AndMatchesRemoveOlderThan()
+ {
+ var t1 = BuildSemanticsTree(out var t0);
+ var t2 = BuildSemanticsTree(out _);
+ var cutoff = t0.AddMinutes(8); // B ended (6m), A still active (10m), C future
+
+ t1.RemoveEndedBefore(cutoff);
+ t2.RemoveOlderThan(cutoff);
+
+ var r1 = t1.GetOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).Select(i => i.Value).OrderBy(v => v).ToList();
+ var r2 = t2.GetOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).Select(i => i.Value).OrderBy(v => v).ToList();
+ Assert.Equal(new[] { "A", "C" }, r1);
+ Assert.Equal(r1, r2);
+ }
+
+ [Fact]
+ public void RemoveEndedBefore_EndEqualToCutoff_IsKept()
+ {
+ var t0 = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var tree = new TemporalIntervalTree();
+ tree.Insert(t0, t0.AddMinutes(1), "X");
+
+ tree.RemoveEndedBefore(t0.AddMinutes(1));
+ Assert.Equal(1, tree.CountOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue));
+
+ tree.RemoveEndedBefore(t0.AddMinutes(1).AddTicks(1));
+ Assert.Equal(0, tree.CountOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue));
+ }
+
+ [Fact]
+ public void CountOverlapping_Empty_ReturnsZero()
+ {
+ var tree = new TemporalIntervalTree();
+ Assert.Equal(0, tree.CountOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue));
+ Assert.Empty(tree.GetOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue));
+ }
}
-}
\ No newline at end of file
+}
diff --git a/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs b/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs
index dfc08ac..e22e301 100644
--- a/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs
+++ b/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs
@@ -1,5 +1,6 @@
// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
+using System.Globalization;
using TemporalCollections.Collections;
using TemporalCollections.Models;
@@ -195,8 +196,8 @@ public void Global_Counts()
Assert.Equal(2, map.CountSince(b[0].Timestamp)); // >= 40 → 40,50
}
- [Fact(DisplayName = "GetNearest picks the nearest by ticks; on tie prefers the earlier")]
- public void Global_GetNearest_TiePrefersEarlier()
+ [Fact(DisplayName = "GetNearest picks the nearest by ticks; on tie prefers the later (>= time)")]
+ public void Global_GetNearest_TiePrefersLater()
{
var map = new TemporalMultimap();
var items = AddValuesForKey(map, "A", 100, 200, 300);
@@ -204,12 +205,21 @@ public void Global_GetNearest_TiePrefersEarlier()
var mid = Mid(items[1].Timestamp, items[2].Timestamp); // between 200 and 300
var nearest = map.GetNearest(mid);
Assert.NotNull(nearest);
- Assert.Equal(200, nearest!.Value.Value); // earlier on tie
+
+ long dBefore = Math.Abs(mid.UtcTicks - items[1].Timestamp.UtcTicks);
+ long dAfter = Math.Abs(items[2].Timestamp.UtcTicks - mid.UtcTicks);
+ // If true tie, prefer later; otherwise prefer closer
+ int expected1 = (dAfter <= dBefore) ? 300 : 200;
+ Assert.Equal(expected1, nearest!.Value.Value);
var mid2 = Mid(items[0].Timestamp, items[1].Timestamp);
nearest = map.GetNearest(mid2);
Assert.NotNull(nearest);
- Assert.Equal(100, nearest!.Value.Value);
+
+ long dBefore2 = Math.Abs(mid2.UtcTicks - items[0].Timestamp.UtcTicks);
+ long dAfter2 = Math.Abs(items[1].Timestamp.UtcTicks - mid2.UtcTicks);
+ int expected2 = (dAfter2 <= dBefore2) ? 200 : 100;
+ Assert.Equal(expected2, nearest!.Value.Value);
}
[Fact(DisplayName = "GetLatest/GetEarliest/ GetTimeSpan are correct")]
@@ -300,5 +310,241 @@ public void Concurrency_AddValue_MultiKey()
for (int i = 1; i < all.Length; i++)
Assert.True(all[i - 1].Timestamp.UtcTicks <= all[i].Timestamp.UtcTicks);
}
+
+ [Fact(DisplayName = "Empty collection: queries return empty / counts 0 / nearest null")]
+ public void Empty_Behavior_IsConsistent()
+ {
+ var map = new TemporalMultimap();
+
+ Assert.Equal(0, map.Count);
+ Assert.Equal(0, map.KeyCount);
+
+ Assert.Empty(map.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue));
+ Assert.Empty(map.GetBefore(DateTimeOffset.UtcNow));
+ Assert.Empty(map.GetAfter(DateTimeOffset.UtcNow));
+ Assert.Equal(0, map.CountInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue));
+ Assert.Equal(0, map.CountSince(DateTimeOffset.UtcNow.AddDays(-1)));
+ Assert.Null(map.GetNearest(DateTimeOffset.UtcNow));
+ Assert.Null(map.GetLatest());
+ Assert.Null(map.GetEarliest());
+ Assert.Equal(TimeSpan.Zero, map.GetTimeSpan());
+ }
+
+ [Fact(DisplayName = "GetValuesInRange is inclusive on boundaries (from/to)")]
+ public void PerKey_GetValuesInRange_InclusiveBoundaries()
+ {
+ var map = new TemporalMultimap();
+ var items = AddValuesForKey(map, "A", 10, 20, 30);
+
+ var exactFirst = map.GetValuesInRange("A", items[0].Timestamp, items[0].Timestamp).ToArray();
+ Assert.Single(exactFirst);
+ Assert.Equal(10, exactFirst[0].Value);
+
+ var exactLast = map.GetValuesInRange("A", items[2].Timestamp, items[2].Timestamp).ToArray();
+ Assert.Single(exactLast);
+ Assert.Equal(30, exactLast[0].Value);
+
+ var middle = map.GetValuesInRange("A", items[1].Timestamp, items[1].Timestamp).ToArray();
+ Assert.Single(middle);
+ Assert.Equal(20, middle[0].Value);
+ }
+
+ [Fact(DisplayName = "Range arguments can be swapped: GetInRange/RemoveRange/CountInRange behave same")]
+ public void RangeArguments_Swapped_AreHandled()
+ {
+ var map = new TemporalMultimap();
+ var a = AddValuesForKey(map, "A", 1, 2, 3, 4, 5);
+
+ // Same results even if from > to
+ var r1 = map.GetInRange(a[1].Timestamp, a[3].Timestamp).Select(x => x.Value.Value).ToArray();
+ var r2 = map.GetInRange(a[3].Timestamp, a[1].Timestamp).Select(x => x.Value.Value).ToArray();
+ Assert.Equal(r1, r2);
+
+ var c1 = map.CountInRange(a[1].Timestamp, a[3].Timestamp);
+ var c2 = map.CountInRange(a[3].Timestamp, a[1].Timestamp);
+ Assert.Equal(c1, c2);
+
+ // RemoveRange swapped should remove the same number of items (inclusive)
+ var map2 = new TemporalMultimap();
+ var b = AddValuesForKey(map2, "A", 1, 2, 3, 4, 5);
+ map2.RemoveRange(b[3].Timestamp, b[1].Timestamp); // swapped
+ var left = map2.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).Select(x => x.Value).ToArray();
+ Assert.Equal(new[] { 1, 5 }, left);
+ }
+
+ [Fact(DisplayName = "Per-key RemoveOlderThan is strictly older: cutoff timestamp remains")]
+ public void RemoveOlderThan_StrictlyOlder_CutoffStays()
+ {
+ var map = new TemporalMultimap();
+ var items = AddValuesForKey(map, "A", 10, 20, 30);
+
+ // cutoff = timestamp of 20, must remove only 10
+ int removed = map.RemoveOlderThan("A", items[1].Timestamp);
+ Assert.Equal(1, removed);
+
+ var left = map.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray();
+ Assert.Equal(new[] { 20, 30 }, left.Select(x => x.Value).ToArray());
+ }
+
+ [Fact(DisplayName = "Global RemoveOlderThan is strictly older: cutoff timestamp remains (across keys)")]
+ public void Global_RemoveOlderThan_StrictlyOlder_CutoffStays()
+ {
+ var map = new TemporalMultimap();
+ var a = AddValuesForKey(map, "A", 1, 2, 3);
+ var b = AddValuesForKey(map, "B", 10, 20);
+
+ // cutoff = A:2 timestamp => remove only A:1 (strictly older)
+ map.RemoveOlderThan(a[1].Timestamp);
+
+ var all = map.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray();
+ Assert.DoesNotContain(all, it => it.Value.Key == "A" && it.Value.Value == 1);
+ Assert.Contains(all, it => it.Value.Key == "A" && it.Value.Value == 2);
+ Assert.Contains(all, it => it.Value.Key == "B" && it.Value.Value == 10);
+ }
+
+ [Fact(DisplayName = "CountForKey/ContainsKey reflect key removal after last item deleted")]
+ public void KeyBookkeeping_AfterDeletions_IsCorrect()
+ {
+ var map = new TemporalMultimap();
+ var a = AddValuesForKey(map, "A", 1, 2, 3);
+
+ Assert.True(map.ContainsKey("A"));
+ Assert.Equal(3, map.CountForKey("A"));
+ Assert.Equal(1, map.KeyCount);
+
+ // Remove all via per-key range
+ int removed = map.RemoveRange("A", a[0].Timestamp, a[2].Timestamp);
+ Assert.Equal(3, removed);
+
+ Assert.False(map.ContainsKey("A"));
+ Assert.Equal(0, map.CountForKey("A"));
+ Assert.Equal(0, map.KeyCount);
+ Assert.Equal(0, map.Count);
+ }
+
+ [Fact(DisplayName = "GetNearest tie across keys prefers later timestamp (global tie-break rule)")]
+ public void GetNearest_TieAcrossKeys_PrefersLater()
+ {
+ var map = new TemporalMultimap();
+
+ // Build two items around a midpoint by crafting a manual timestamp between two AddValue calls
+ var a1 = map.AddValue("A", 100);
+ var a2 = map.AddValue("A", 200);
+
+ // Create B item exactly at midpoint between a1 and a2 (manual add)
+ var mid = Mid(a1.Timestamp, a2.Timestamp);
+ map.Add(new TemporalItem>(new KeyValuePair("B", 999), mid));
+
+ // Query at same midpoint: nearest should be the midpoint item itself (diff=0)
+ var nearest = map.GetNearest(mid);
+ Assert.NotNull(nearest);
+ Assert.Equal("B", nearest!.Value.Key);
+ Assert.Equal(999, nearest.Value.Value);
+
+ // Now query at a time equidistant between a1 and mid:
+ // nearest should be the later one on tie (consistent with all other collections)
+ var mid2 = Mid(a1.Timestamp, mid);
+ nearest = map.GetNearest(mid2);
+ Assert.NotNull(nearest);
+
+ // Tie scenario depends on integer tick division; enforce the intended rule:
+ // if equal diff, implementation prefers later timestamp (>= time).
+ var cand1 = map.GetInRange(a1.Timestamp, a1.Timestamp).Single(); // A:100
+ var cand2 = map.GetInRange(mid, mid).Single(); // B:999
+
+ long d1 = Math.Abs(cand1.Timestamp.UtcTicks - mid2.UtcTicks);
+ long d2 = Math.Abs(cand2.Timestamp.UtcTicks - mid2.UtcTicks);
+
+ if (d1 == d2)
+ Assert.Equal(cand2.Timestamp.UtcTicks, nearest.Timestamp.UtcTicks); // later wins
+ else
+ Assert.Equal(d1 < d2 ? cand1.Timestamp.UtcTicks : cand2.Timestamp.UtcTicks, nearest.Timestamp.UtcTicks);
+ }
+
+ [Fact(DisplayName = "DateTime overloads: Unspecified is treated as UTC (AssumeUtc policy)")]
+ public void DateTimeOverloads_Unspecified_AssumeUtc()
+ {
+ var map = new TemporalMultimap();
+ var items = AddValuesForKey(map, "A", 1, 2, 3);
+
+ // Use DateTime overloads with Kind=Unspecified but same ticks as UTC DateTime
+ DateTime fromUnspec = DateTime.SpecifyKind(items[0].Timestamp.UtcDateTime, DateTimeKind.Unspecified);
+ DateTime toUnspec = DateTime.SpecifyKind(items[2].Timestamp.UtcDateTime, DateTimeKind.Unspecified);
+
+ var res = map.GetInRange(fromUnspec, toUnspec).ToArray();
+ Assert.Equal(3, res.Length);
+
+ // RemoveOlderThan via DateTime overload: cutoff stays (strictly older)
+ DateTime cutoffUnspec = DateTime.SpecifyKind(items[1].Timestamp.UtcDateTime, DateTimeKind.Unspecified);
+ map.RemoveOlderThan(cutoffUnspec);
+
+ var left = map.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).Select(x => x.Value).ToArray();
+ Assert.Equal(new[] { 2, 3 }, left);
+ }
+
+ [Fact(DisplayName = "Global ordering: GetInRange returns results globally sorted even with many keys")]
+ public void Global_GetInRange_IsGloballySorted_ManyKeys()
+ {
+ var map = new TemporalMultimap();
+ for (int k = 0; k < 20; k++)
+ {
+ string key = "K" + k.ToString(CultureInfo.InvariantCulture);
+ for (int i = 0; i < 15; i++)
+ map.AddValue(key, i);
+ }
+
+ var all = map.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray();
+ Assert.Equal(20 * 15, all.Length);
+
+ for (int i = 1; i < all.Length; i++)
+ Assert.True(all[i - 1].Timestamp.UtcTicks <= all[i].Timestamp.UtcTicks);
+ }
+
+ [Fact(DisplayName = "AddRange(items): supports mixed keys and preserves order within each key")]
+ public void AddRange_Items_MixedKeys_PreservesPerKeyOrder()
+ {
+ var map = new TemporalMultimap();
+
+ // Create a bunch of items with explicit timestamps (some interleaving)
+ var baseTicks = DateTimeOffset.UtcNow.UtcTicks;
+ var items = new[]
+ {
+ new TemporalItem>(new("A", 1), new DateTimeOffset(baseTicks + 10, TimeSpan.Zero)),
+ new TemporalItem>(new("B", 1), new DateTimeOffset(baseTicks + 11, TimeSpan.Zero)),
+ new TemporalItem>(new("A", 2), new DateTimeOffset(baseTicks + 12, TimeSpan.Zero)),
+ // Out of order within A to force binary insert
+ new TemporalItem>(new("A", 99), new DateTimeOffset(baseTicks + 9, TimeSpan.Zero)),
+ new TemporalItem>(new("B", 2), new DateTimeOffset(baseTicks + 13, TimeSpan.Zero)),
+ };
+
+ map.AddRange(items);
+
+ var a = map.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray();
+ AssertStrictlyIncreasing(a);
+ Assert.Equal(new[] { 99, 1, 2 }, a.Select(x => x.Value).ToArray());
+
+ var b = map.GetValuesInRange("B", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray();
+ AssertStrictlyIncreasing(b);
+ Assert.Equal(new[] { 1, 2 }, b.Select(x => x.Value).ToArray());
+ }
+
+ [Fact(DisplayName = "GetTimeSpan: single item returns zero, multiple items returns (max-min) across keys")]
+ public void GetTimeSpan_SingleAndMulti()
+ {
+ var map = new TemporalMultimap();
+ map.AddValue("A", 1);
+ Assert.Equal(TimeSpan.Zero, map.GetTimeSpan());
+
+ var a2 = map.AddValue("A", 2);
+ var b1 = map.AddValue("B", 10);
+
+ var earliest = map.GetEarliest()!;
+ var latest = map.GetLatest()!;
+ Assert.Equal(latest.Timestamp - earliest.Timestamp, map.GetTimeSpan());
+
+ // sanity: latest should be >= both
+ Assert.True(latest.Timestamp.UtcTicks >= a2.Timestamp.UtcTicks);
+ Assert.True(latest.Timestamp.UtcTicks >= b1.Timestamp.UtcTicks);
+ }
}
}
\ No newline at end of file
diff --git a/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs b/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs
index c94298e..1b7cdca 100644
--- a/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs
+++ b/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs
@@ -1,6 +1,7 @@
// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using TemporalCollections.Collections;
+using TemporalCollections.Models;
namespace TemporalCollections.Tests.Collections
{
@@ -359,5 +360,259 @@ public void TemporalPriorityQueue_GetNearest_WorksAndTiesPreferLater()
Assert.Equal("C", nearCeil!.Value);
}
}
+
+ // --- Helpers (local to this test class) ---
+ private static void AssertSortedByTimestamp(IReadOnlyList> items)
+ {
+ for (int i = 1; i < items.Count; i++)
+ {
+ Assert.True(items[i - 1].Timestamp.UtcTicks <= items[i].Timestamp.UtcTicks,
+ $"Not sorted by timestamp at {i - 1}->{i}: {items[i - 1].Timestamp:o} > {items[i].Timestamp:o}");
+ }
+ }
+
+ private static DateTimeOffset Mid(DateTimeOffset a, DateTimeOffset b)
+ {
+ long m = (a.UtcTicks + b.UtcTicks) / 2;
+ return new DateTimeOffset(m, TimeSpan.Zero);
+ }
+
+ [Fact(DisplayName = "Empty queue: TryPeek/TryDequeue return false and default/null value")]
+ public void EmptyQueue_TryPeek_TryDequeue_ReturnFalse()
+ {
+ var q = new TemporalPriorityQueue();
+
+ Assert.False(q.TryPeek(out var p));
+ Assert.Null(p);
+
+ Assert.False(q.TryDequeue(out var d));
+ Assert.Null(d);
+
+ Assert.Equal(0, q.Count);
+ Assert.Empty(q.GetInRange(DateTime.MinValue, DateTime.MaxValue));
+ Assert.Null(q.GetEarliest());
+ Assert.Null(q.GetLatest());
+ Assert.Equal(TimeSpan.Zero, q.GetTimeSpan());
+ }
+
+ [Fact(DisplayName = "Dequeue: stable ordering for same priority (FIFO by insertion timestamp)")]
+ public void TryDequeue_SamePriority_IsStableByInsertionTime()
+ {
+ var q = new TemporalPriorityQueue();
+
+ // Same priority for all: should be dequeued by insertion timestamp (stable FIFO).
+ q.Enqueue("A", 5);
+ q.Enqueue("B", 5);
+ q.Enqueue("C", 5);
+
+ Assert.True(q.TryDequeue(out var v1));
+ Assert.True(q.TryDequeue(out var v2));
+ Assert.True(q.TryDequeue(out var v3));
+
+ Assert.Equal("A", v1);
+ Assert.Equal("B", v2);
+ Assert.Equal("C", v3);
+ Assert.Equal(0, q.Count);
+ }
+
+ [Fact(DisplayName = "Time queries are ordered by timestamp (not by priority)")]
+ public void GetInRange_IsOrderedByTimestamp_NotByPriority()
+ {
+ var q = new TemporalPriorityQueue();
+
+ // Enqueue with priorities that would reorder if we were sorting by priority:
+ // but GetInRange must return ordered by timestamp.
+ q.Enqueue("first-lowprio", 100); // inserted 1st
+ q.Enqueue("second-highprio", 1); // inserted 2nd
+ q.Enqueue("third-midprio", 50); // inserted 3rd
+
+ var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ Assert.Equal(3, all.Count);
+ AssertSortedByTimestamp(all);
+
+ Assert.Equal("first-lowprio", all[0].Value);
+ Assert.Equal("second-highprio", all[1].Value);
+ Assert.Equal("third-midprio", all[2].Value);
+ }
+
+ [Fact(DisplayName = "RemoveOlderThan is strictly older: cutoff item remains")]
+ public void RemoveOlderThan_StrictlyOlder_CutoffRemains()
+ {
+ var q = new TemporalPriorityQueue();
+
+ q.Enqueue("A", 2);
+ q.Enqueue("B", 1);
+ q.Enqueue("C", 3);
+
+ var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).OrderBy(x => x.Timestamp.UtcTicks).ToList();
+ Assert.True(all.Count >= 3);
+
+ // cutoff = timestamp of the second item => must remove only the first (strictly older)
+ var cutoff = all[1].Timestamp.UtcDateTime;
+ q.RemoveOlderThan(cutoff);
+
+ var left = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).OrderBy(x => x.Timestamp.UtcTicks).ToList();
+ Assert.Equal(2, left.Count);
+ Assert.Equal(all[1].Value, left[0].Value); // cutoff item remains
+ Assert.Equal(all[2].Value, left[1].Value);
+ }
+
+ [Fact(DisplayName = "RemoveRange is inclusive on boundaries [from,to]")]
+ public void RemoveRange_InclusiveBoundaries_RemovesEndpoints()
+ {
+ var q = new TemporalPriorityQueue();
+
+ q.Enqueue("A", 3);
+ q.Enqueue("B", 2);
+ q.Enqueue("C", 1);
+
+ var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).OrderBy(x => x.Timestamp.UtcTicks).ToList();
+ Assert.True(all.Count == 3);
+
+ // Remove exactly item B by using same timestamp as start/end
+ var tsB = all[1].Timestamp.UtcDateTime;
+ q.RemoveRange(tsB, tsB);
+
+ var left = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).Select(x => x.Value).ToList();
+ Assert.Equal(2, left.Count);
+ Assert.DoesNotContain("B", left);
+ }
+
+ [Fact(DisplayName = "Range arguments can be swapped: GetInRange/CountInRange/RemoveRange behave same")]
+ public void RangeArguments_Swapped_AreHandled()
+ {
+ var q1 = new TemporalPriorityQueue();
+ q1.Enqueue("A", 3);
+ q1.Enqueue("B", 2);
+ q1.Enqueue("C", 1);
+
+ var all1 = q1.GetInRange(DateTime.MinValue, DateTime.MaxValue).OrderBy(x => x.Timestamp.UtcTicks).ToList();
+ var from = all1[0].Timestamp.UtcDateTime;
+ var to = all1[2].Timestamp.UtcDateTime;
+
+ var r1 = q1.GetInRange(from, to).Select(x => x.Value).ToArray();
+ var r2 = q1.GetInRange(to, from).Select(x => x.Value).ToArray();
+ Assert.Equal(r1, r2);
+
+ var c1 = q1.CountInRange(from, to);
+ var c2 = q1.CountInRange(to, from);
+ Assert.Equal(c1, c2);
+
+ // RemoveRange swapped removes same items
+ var q2 = new TemporalPriorityQueue();
+ q2.Enqueue("A", 3);
+ q2.Enqueue("B", 2);
+ q2.Enqueue("C", 1);
+
+ var all2 = q2.GetInRange(DateTime.MinValue, DateTime.MaxValue).OrderBy(x => x.Timestamp.UtcTicks).ToList();
+ var midTs = all2[1].Timestamp.UtcDateTime;
+
+ // Remove only middle item but with swapped args around same point (still inclusive)
+ q2.RemoveRange(midTs, midTs);
+ var left = q2.GetInRange(DateTime.MinValue, DateTime.MaxValue).Select(x => x.Value).ToList();
+ Assert.Equal(2, left.Count);
+ Assert.DoesNotContain(all2[1].Value, left);
+ }
+
+ [Fact(DisplayName = "DateTime overloads: Unspecified is treated as UTC (AssumeUtc policy from base)")]
+ public void DateTimeOverloads_Unspecified_AssumeUtc()
+ {
+ var q = new TemporalPriorityQueue();
+ q.Enqueue("A", 3);
+ q.Enqueue("B", 2);
+ q.Enqueue("C", 1);
+
+ var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue)
+ .OrderBy(x => x.Timestamp.UtcTicks)
+ .ToList();
+
+ // Build DateTime range with Kind=Unspecified but same clock values as UTC
+ var fromUnspec = DateTime.SpecifyKind(all[0].Timestamp.UtcDateTime, DateTimeKind.Unspecified);
+ var toUnspec = DateTime.SpecifyKind(all[2].Timestamp.UtcDateTime, DateTimeKind.Unspecified);
+
+ var res = q.GetInRange(fromUnspec, toUnspec).ToList();
+ Assert.Equal(3, res.Count);
+ }
+
+ [Fact(DisplayName = "GetNearest: tie prefers later item (>= time) as documented")]
+ public void GetNearest_TiePrefersLater_WhenExactMidpointExists()
+ {
+ // Wide range for retrieving all items
+ DateTime wideFrom = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ DateTime wideTo = new(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ var q = new TemporalPriorityQueue();
+ q.Enqueue("A", 10);
+ q.Enqueue("B", 10);
+ q.Enqueue("C", 10);
+
+ var all = q.GetInRange(wideFrom, wideTo).OrderBy(x => x.Timestamp.UtcTicks).ToList();
+ Assert.True(all.Count >= 3);
+
+ var b = all[1];
+ var c = all[2];
+
+ long dt = c.Timestamp.UtcTicks - b.Timestamp.UtcTicks;
+ Assert.True(dt > 0);
+
+ if ((dt & 1L) == 0L)
+ {
+ // Exact midpoint exists -> tie case
+ var mid = Mid(b.Timestamp, c.Timestamp).UtcDateTime;
+
+ var nearest = q.GetNearest(mid);
+ Assert.NotNull(nearest);
+ Assert.Equal("C", nearest!.Value); // later preferred on tie
+ }
+ else
+ {
+ // No exact midpoint; test both sides: one tick closer to B, next tick closer to C
+ long midFloorTicks = b.Timestamp.UtcTicks + (dt / 2);
+ long midCeilTicks = midFloorTicks + 1;
+
+ var nearB = q.GetNearest(new DateTimeOffset(midFloorTicks, TimeSpan.Zero).UtcDateTime);
+ var nearC = q.GetNearest(new DateTimeOffset(midCeilTicks, TimeSpan.Zero).UtcDateTime);
+
+ Assert.NotNull(nearB);
+ Assert.NotNull(nearC);
+ Assert.Equal("B", nearB!.Value);
+ Assert.Equal("C", nearC!.Value);
+ }
+ }
+
+ [Fact(DisplayName = "Concurrency: timestamps remain strictly increasing in time queries")]
+ public void Concurrency_Enqueue_ProducesMonotonicTimestamps_InQueries()
+ {
+ var q = new TemporalPriorityQueue();
+
+ Parallel.For(0, 2000, i =>
+ {
+ q.Enqueue(i, i % 7);
+ });
+
+ Assert.Equal(2000, q.Count);
+
+ var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ Assert.Equal(2000, all.Count);
+ AssertSortedByTimestamp(all);
+
+ // Extra safety: no duplicates dropped (SortedSet strict ordering)
+ var seen = new HashSet(all.Select(x => x.Value));
+ Assert.Equal(2000, seen.Count);
+ }
+
+ [Fact]
+ public void Enqueue_NullPriority_DoesNotThrow_AndSortsFirst()
+ {
+ var pq = new TemporalPriorityQueue();
+ pq.Enqueue(1, "b");
+ pq.Enqueue(2, null!);
+ pq.Enqueue(3, "a");
+
+ Assert.True(pq.TryDequeue(out var first));
+ Assert.Equal(2, first);
+ Assert.True(pq.TryDequeue(out var second));
+ Assert.Equal(3, second);
+ }
}
}
\ No newline at end of file
diff --git a/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs b/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs
index 2a899e1..25f0e3f 100644
--- a/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs
+++ b/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs
@@ -563,5 +563,382 @@ public void TemporalQueue_GetNearest_WorksAndTiesPreferLater()
Assert.Equal("C", nearCeil!.Value);
}
}
+
+ [Fact]
+ public void GetInRange_WhenBoundsAreReversed_ShouldStillWork()
+ {
+ var q = new TemporalQueue();
+ q.Enqueue(1);
+ q.Enqueue(2);
+ q.Enqueue(3);
+
+ var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ Assert.True(all.Count >= 3);
+
+ // Pick a middle window using actual timestamps (deterministic)
+ var from = all[2].Timestamp.UtcDateTime; // newer
+ var to = all[1].Timestamp.UtcDateTime; // older (reversed)
+
+ var res = q.GetInRange(from, to).Select(x => x.Value).ToList();
+
+ // Bounds are swapped internally -> should include items 2..3? Actually window [ts2, ts3]
+ Assert.Contains(2, res);
+ Assert.Contains(3, res);
+ Assert.DoesNotContain(1, res);
+ }
+
+ [Fact]
+ public void CountInRange_WhenBoundsAreReversed_ShouldMatchGetInRange()
+ {
+ var q = new TemporalQueue();
+ for (int i = 0; i < 10; i++) q.Enqueue(i);
+
+ var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var a = all[7].Timestamp.UtcDateTime;
+ var b = all[3].Timestamp.UtcDateTime;
+
+ var expected = q.GetInRange(a, b).Count(); // reversed
+ var counted = q.CountInRange(a, b);
+
+ Assert.Equal(expected, counted);
+ }
+
+ [Fact]
+ public void CountSince_ShouldReturnZero_WhenCutoffAfterLatest()
+ {
+ var q = new TemporalQueue();
+ q.Enqueue(1);
+ q.Enqueue(2);
+
+ var latest = q.GetLatest()!;
+ var cutoff = latest.Timestamp.UtcDateTime.AddTicks(1);
+
+ Assert.Equal(0, q.CountSince(cutoff));
+ }
+
+ [Fact]
+ public void CountSince_ShouldReturnAll_WhenCutoffBeforeEarliest()
+ {
+ var q = new TemporalQueue();
+ for (int i = 0; i < 5; i++) q.Enqueue(i);
+
+ var earliest = q.GetEarliest()!;
+ var cutoff = earliest.Timestamp.UtcDateTime.AddTicks(-1);
+
+ Assert.Equal(q.Count, q.CountSince(cutoff));
+ }
+
+ [Fact]
+ public void GetNearest_EmptyQueue_ShouldReturnNull()
+ {
+ var q = new TemporalQueue();
+ Assert.Null(q.GetNearest(DateTime.UtcNow));
+ }
+
+ [Fact]
+ public void GetNearest_BeforeAll_ShouldReturnEarliest()
+ {
+ var q = new TemporalQueue();
+ q.Enqueue(1);
+ q.Enqueue(2);
+ q.Enqueue(3);
+
+ var earliest = q.GetEarliest()!;
+ var target = earliest.Timestamp.UtcDateTime.AddTicks(-10);
+
+ var nearest = q.GetNearest(target);
+ Assert.NotNull(nearest);
+ Assert.Equal(earliest.Value, nearest!.Value);
+ }
+
+ [Fact]
+ public void GetNearest_AfterAll_ShouldReturnLatest()
+ {
+ var q = new TemporalQueue();
+ q.Enqueue(1);
+ q.Enqueue(2);
+ q.Enqueue(3);
+
+ var latest = q.GetLatest()!;
+ var target = latest.Timestamp.UtcDateTime.AddTicks(10);
+
+ var nearest = q.GetNearest(target);
+ Assert.NotNull(nearest);
+ Assert.Equal(latest.Value, nearest!.Value);
+ }
+
+ [Fact]
+ public void GetNearest_Tie_ShouldPreferLaterItem()
+ {
+ var q = new TemporalQueue();
+ q.Enqueue("A");
+ q.Enqueue("B");
+ q.Enqueue("C");
+
+ var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ Assert.True(all.Count >= 3);
+
+ var b = all[1];
+ var c = all[2];
+
+ // Choose a target exactly midway between B and C (only if even delta)
+ long dt = c.Timestamp.UtcTicks - b.Timestamp.UtcTicks;
+ Assert.True(dt > 0);
+
+ if ((dt % 2) != 0)
+ {
+ // If odd, shift to the closer boundary by constructing two near-mid points
+ var midFloor = new DateTimeOffset(b.Timestamp.UtcTicks + (dt / 2), TimeSpan.Zero).UtcDateTime;
+ var midCeil = new DateTimeOffset(b.Timestamp.UtcTicks + (dt / 2) + 1, TimeSpan.Zero).UtcDateTime;
+
+ Assert.Equal("B", q.GetNearest(midFloor)!.Value);
+ Assert.Equal("C", q.GetNearest(midCeil)!.Value);
+ }
+ else
+ {
+ var mid = new DateTimeOffset(b.Timestamp.UtcTicks + (dt / 2), TimeSpan.Zero).UtcDateTime;
+
+ // Tie-break rule: prefer later (>= time) => should be C
+ var nearest = q.GetNearest(mid);
+ Assert.NotNull(nearest);
+ Assert.Equal("C", nearest!.Value);
+ }
+ }
+
+ [Fact]
+ public void RemoveOlderThan_WhenCutoffBeforeEarliest_ShouldBeNoOp()
+ {
+ var q = new TemporalQueue();
+ q.Enqueue(1);
+ q.Enqueue(2);
+ q.Enqueue(3);
+
+ var earliest = q.GetEarliest()!;
+ var cutoff = earliest.Timestamp.UtcDateTime.AddTicks(-1);
+
+ q.RemoveOlderThan(cutoff);
+
+ Assert.Equal(3, q.Count);
+ Assert.Equal(1, q.Peek().Value);
+ }
+
+ [Fact]
+ public void RemoveOlderThan_WhenCutoffAfterLatest_ShouldClearQueue()
+ {
+ var q = new TemporalQueue();
+ q.Enqueue(1);
+ q.Enqueue(2);
+ q.Enqueue(3);
+
+ var latest = q.GetLatest()!;
+ var cutoff = latest.Timestamp.UtcDateTime.AddTicks(1);
+
+ q.RemoveOlderThan(cutoff);
+
+ Assert.Equal(0, q.Count);
+ Assert.Empty(q.GetInRange(DateTime.MinValue, DateTime.MaxValue));
+ }
+
+ [Fact]
+ public void RemoveRange_WhenBoundsAreReversed_ShouldStillRemove()
+ {
+ var q = new TemporalQueue();
+ q.Enqueue(1);
+ q.Enqueue(2);
+ q.Enqueue(3);
+ q.Enqueue(4);
+
+ var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var t2 = all[1].Timestamp.UtcDateTime;
+ var t3 = all[2].Timestamp.UtcDateTime;
+
+ // reversed input [t3, t2] -> internally swapped -> should remove 2 and 3
+ q.RemoveRange(t3, t2);
+
+ var remaining = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).Select(x => x.Value).ToList();
+ Assert.DoesNotContain(2, remaining);
+ Assert.DoesNotContain(3, remaining);
+ Assert.Contains(1, remaining);
+ Assert.Contains(4, remaining);
+ }
+
+ [Fact]
+ public void GetEarliest_ShouldRemainFront_AfterRemoveRange()
+ {
+ var q = new TemporalQueue();
+ q.Enqueue(1);
+ q.Enqueue(2);
+ q.Enqueue(3);
+ q.Enqueue(4);
+
+ var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var t2 = all[1].Timestamp.UtcDateTime;
+ var t3 = all[2].Timestamp.UtcDateTime;
+
+ q.RemoveRange(t2, t3); // remove 2 and 3
+
+ var earliest = q.GetEarliest()!;
+ Assert.Equal(1, earliest.Value);
+
+ var firstDeq = q.Dequeue();
+ Assert.Equal(1, firstDeq.Value);
+ }
+
+ [Fact]
+ public void SnapshotSemantics_GetInRange_ShouldNotChangeDuringConcurrentEnqueue()
+ {
+ var q = new TemporalQueue();
+
+ // Seed
+ for (int i = 0; i < 100; i++) q.Enqueue(i);
+
+ // Take a snapshot while we enqueue concurrently
+ var t = Task.Run(() =>
+ {
+ for (int i = 100; i < 500; i++) q.Enqueue(i);
+ });
+
+ var snapshot = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+
+ t.GetAwaiter().GetResult();
+
+ // Snapshot must be internally consistent: timestamps non-decreasing and count <= final count
+ Assert.True(snapshot.Count <= q.Count);
+
+ for (int i = 1; i < snapshot.Count; i++)
+ Assert.True(snapshot[i - 1].Timestamp <= snapshot[i].Timestamp);
+ }
+
+ [Fact]
+ public void TemporalItem_Create_ShouldBeStrictlyMonotonic_UnderHighConcurrency()
+ {
+ // This test targets TemporalItem.Create invariants indirectly via queue enqueue.
+ var q = new TemporalQueue();
+
+ Parallel.For(0, 10_000, i => q.Enqueue(i));
+
+ var items = q.GetInRange(DateTime.MinValue, DateTime.MaxValue)
+ .OrderBy(x => x.Timestamp.UtcTicks)
+ .ToList();
+
+ Assert.Equal(10_000, items.Count);
+
+ for (int i = 1; i < items.Count; i++)
+ {
+ Assert.True(items[i - 1].Timestamp.UtcTicks < items[i].Timestamp.UtcTicks,
+ $"Non-monotonic ticks at index {i}");
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // TryDequeue / TryPeek
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void TryDequeue_ReturnsItem_WhenQueueIsNotEmpty()
+ {
+ var queue = new TemporalQueue();
+ queue.Enqueue(42);
+
+ bool result = queue.TryDequeue(out var item);
+
+ Assert.True(result);
+ Assert.NotNull(item);
+ Assert.Equal(42, item!.Value);
+ Assert.Equal(0, queue.Count);
+ }
+
+ [Fact]
+ public void TryDequeue_ReturnsFalse_WhenQueueIsEmpty()
+ {
+ var queue = new TemporalQueue();
+
+ bool result = queue.TryDequeue(out var item);
+
+ Assert.False(result);
+ Assert.Null(item);
+ }
+
+ [Fact]
+ public void TryDequeue_RespectsTimestampOrder()
+ {
+ var queue = new TemporalQueue();
+ queue.Enqueue(1);
+ queue.Enqueue(2);
+ queue.Enqueue(3);
+
+ queue.TryDequeue(out var first);
+ queue.TryDequeue(out var second);
+ queue.TryDequeue(out var third);
+
+ Assert.Equal(1, first!.Value);
+ Assert.Equal(2, second!.Value);
+ Assert.Equal(3, third!.Value);
+ Assert.True(first.Timestamp < second!.Timestamp);
+ Assert.True(second.Timestamp < third!.Timestamp);
+ }
+
+ [Fact]
+ public void TryPeek_ReturnsItem_WithoutRemoving()
+ {
+ var queue = new TemporalQueue();
+ queue.Enqueue("hello");
+
+ bool result = queue.TryPeek(out var item);
+
+ Assert.True(result);
+ Assert.NotNull(item);
+ Assert.Equal("hello", item!.Value);
+ Assert.Equal(1, queue.Count); // item must still be in the queue
+ }
+
+ [Fact]
+ public void TryPeek_ReturnsFalse_WhenQueueIsEmpty()
+ {
+ var queue = new TemporalQueue();
+
+ bool result = queue.TryPeek(out var item);
+
+ Assert.False(result);
+ Assert.Null(item);
+ }
+
+ [Fact]
+ public void TryPeek_ReturnsSameItemAsDequeue()
+ {
+ var queue = new TemporalQueue();
+ queue.Enqueue(99);
+
+ queue.TryPeek(out var peeked);
+ var dequeued = queue.Dequeue();
+
+ Assert.Equal(peeked!.Value, dequeued.Value);
+ Assert.Equal(peeked.Timestamp, dequeued.Timestamp);
+ }
+
+ [Fact]
+ public async Task Enqueue_Concurrent_KeepsChronologicalOrder()
+ {
+ const int threads = 8, perThread = 5_000;
+ var queue = new TemporalQueue();
+ using var gate = new Barrier(threads);
+
+ var tasks = Enumerable.Range(0, threads).Select(t => Task.Factory.StartNew(() =>
+ {
+ gate.SignalAndWait();
+ for (int i = 0; i < perThread; i++) queue.Enqueue(t * perThread + i);
+ }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default));
+ await Task.WhenAll(tasks);
+
+ var previous = DateTimeOffset.MinValue;
+ int count = 0;
+ while (queue.TryDequeue(out var item))
+ {
+ Assert.True(previous < item!.Timestamp, $"Out of order at index {count}");
+ previous = item.Timestamp;
+ count++;
+ }
+ Assert.Equal(threads * perThread, count);
+ }
}
}
\ No newline at end of file
diff --git a/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs b/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs
index 2048049..9a192b8 100644
--- a/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs
+++ b/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs
@@ -165,23 +165,32 @@ public void RemoveRange_InclusiveAndDropsSegments()
AssertStrictlyIncreasing(all);
}
- [Fact(DisplayName = "GetNearest returns nearest by ticks; in tie it prefers the earlier item")]
- public void GetNearest_TiePrefersEarlier()
+ [Fact(DisplayName = "GetNearest returns nearest by ticks; in tie it prefers the later item (>= time)")]
+ public void GetNearest_TiePrefersLater()
{
var col = new TemporalSegmentedArray(segmentCapacity: 3);
var items = AddValues(col, 100, 200, 300);
- // Midpoint between items[1] and items[2] → tie, expect earlier (items[1])
+ // Midpoint between items[1] and items[2]
var mid = Mid(items[1].Timestamp, items[2].Timestamp);
var nearest = col.GetNearest(mid);
Assert.NotNull(nearest);
- Assert.Equal(200, nearest!.Value);
- // Midpoint between items[0] and items[1] → earlier (items[0])
+ long dBefore = Math.Abs(mid.UtcTicks - items[1].Timestamp.UtcTicks);
+ long dAfter = Math.Abs(items[2].Timestamp.UtcTicks - mid.UtcTicks);
+ // If true tie, prefer later; otherwise prefer closer
+ int expected1 = (dAfter <= dBefore) ? 300 : 200;
+ Assert.Equal(expected1, nearest!.Value);
+
+ // Midpoint between items[0] and items[1]
var mid2 = Mid(items[0].Timestamp, items[1].Timestamp);
nearest = col.GetNearest(mid2);
Assert.NotNull(nearest);
- Assert.Equal(100, nearest!.Value);
+
+ long dBefore2 = Math.Abs(mid2.UtcTicks - items[0].Timestamp.UtcTicks);
+ long dAfter2 = Math.Abs(items[1].Timestamp.UtcTicks - mid2.UtcTicks);
+ int expected2 = (dAfter2 <= dBefore2) ? 200 : 100;
+ Assert.Equal(expected2, nearest!.Value);
}
[Fact(DisplayName = "GetLatest and GetEarliest return correct items")]
@@ -262,5 +271,36 @@ public void Concurrency_AddValue_Works()
var arr = col.ToArray();
AssertStrictlyIncreasing(arr); // TemporalItem.Create ensures monotonic UTC ticks per T
}
+
+ [Fact]
+ public void Add_AfterTrimExcess_DoesNotThrow_AndKeepsOrder()
+ {
+ var col = new TemporalSegmentedArray(segmentCapacity: 8);
+ for (int i = 0; i < 3; i++) col.AddValue(i);
+
+ col.TrimExcess(); // shrinks the last segment to Count (3)
+
+ for (int i = 3; i < 20; i++) col.AddValue(i);
+
+ var arr = col.ToArray();
+ Assert.Equal(20, arr.Length);
+ Assert.Equal(Enumerable.Range(0, 20), arr.Select(x => x.Value));
+ AssertStrictlyIncreasing(arr);
+ }
+
+ [Fact]
+ public void AddOutOfOrder_AfterTrimExcess_DoesNotThrow()
+ {
+ var col = new TemporalSegmentedArray(segmentCapacity: 8);
+ var a = col.AddValue(1);
+ col.AddValue(2);
+ col.AddValue(3);
+ col.TrimExcess();
+
+ // Positional insert into a trimmed segment
+ col.Add(new TemporalItem(0, a.Timestamp.AddTicks(-1)));
+
+ Assert.Equal(new[] { 0, 1, 2, 3 }, col.ToArray().Select(x => x.Value));
+ }
}
}
\ No newline at end of file
diff --git a/src/TemporalCollections.Tests/Collections/TemporalSortedListTests.cs b/src/TemporalCollections.Tests/Collections/TemporalSortedListTests.cs
index 8313dc6..8e81e7e 100644
--- a/src/TemporalCollections.Tests/Collections/TemporalSortedListTests.cs
+++ b/src/TemporalCollections.Tests/Collections/TemporalSortedListTests.cs
@@ -192,5 +192,291 @@ public void TemporalSortedList_GetNearest_WorksAndTiesPreferLater()
Assert.Equal(3, nearCeil!.Value);
}
}
+
+ [Fact]
+ public void GetInRange_WhenFromGreaterThanTo_SwapsAndReturnsSameResults()
+ {
+ var list = new TemporalSortedList();
+ list.Add("a");
+ list.Add("b");
+ list.Add("c");
+
+ var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ Assert.Equal(3, all.Count);
+
+ var min = all.First().Timestamp.UtcDateTime;
+ var max = all.Last().Timestamp.UtcDateTime;
+
+ var normal = list.GetInRange(min, max).Select(i => i.Value).ToList();
+ var swapped = list.GetInRange(max, min).Select(i => i.Value).ToList();
+
+ Assert.Equal(normal, swapped);
+ }
+
+ [Fact]
+ public void GetInRange_OnEmptyList_ReturnsEmpty()
+ {
+ var list = new TemporalSortedList();
+ var items = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ Assert.Empty(items);
+ }
+
+ [Fact]
+ public void CountInRange_OnEmptyList_ReturnsZero()
+ {
+ var list = new TemporalSortedList();
+ Assert.Equal(0, list.CountInRange(DateTime.MinValue, DateTime.MaxValue));
+ }
+
+ [Fact]
+ public void GetLatest_And_GetEarliest_OnEmptyList_ReturnNull()
+ {
+ var list = new TemporalSortedList();
+ Assert.Null(list.GetLatest());
+ Assert.Null(list.GetEarliest());
+ }
+
+ [Fact]
+ public void GetLatest_And_GetEarliest_ReturnCorrectItems()
+ {
+ var list = new TemporalSortedList();
+ list.Add("first");
+ list.Add("second");
+ list.Add("third");
+
+ var earliest = list.GetEarliest();
+ var latest = list.GetLatest();
+
+ Assert.NotNull(earliest);
+ Assert.NotNull(latest);
+
+ Assert.Equal("first", earliest!.Value);
+ Assert.Equal("third", latest!.Value);
+
+ Assert.True(earliest.Timestamp <= latest.Timestamp);
+ }
+
+ [Fact]
+ public void GetBefore_IsStrictlyBefore_Cutoff()
+ {
+ var list = new TemporalSortedList();
+ list.Add(1);
+ list.Add(2);
+ list.Add(3);
+
+ var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var t2 = all.Single(i => i.Value == 2).Timestamp.UtcDateTime;
+
+ var before = list.GetBefore(t2).Select(i => i.Value).ToList();
+
+ Assert.Contains(1, before);
+ Assert.DoesNotContain(2, before); // Strictly before
+ Assert.DoesNotContain(3, before);
+ }
+
+ [Fact]
+ public void GetAfter_IsStrictlyAfter_Cutoff()
+ {
+ var list = new TemporalSortedList();
+ list.Add(1);
+ list.Add(2);
+ list.Add(3);
+
+ var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var t2 = all.Single(i => i.Value == 2).Timestamp.UtcDateTime;
+
+ var after = list.GetAfter(t2).Select(i => i.Value).ToList();
+
+ Assert.Contains(3, after);
+ Assert.DoesNotContain(2, after); // Strictly after
+ Assert.DoesNotContain(1, after);
+ }
+
+ [Fact]
+ public void RemoveRange_RemovesInclusiveBounds()
+ {
+ var list = new TemporalSortedList();
+ list.Add(1);
+ list.Add(2);
+ list.Add(3);
+ list.Add(4);
+
+ var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var t2 = all.Single(i => i.Value == 2).Timestamp.UtcDateTime;
+ var t3 = all.Single(i => i.Value == 3).Timestamp.UtcDateTime;
+
+ // Remove [2,3] inclusive
+ list.RemoveRange(t2, t3);
+
+ var remaining = list.GetInRange(DateTime.MinValue, DateTime.MaxValue)
+ .Select(i => i.Value)
+ .ToList();
+
+ Assert.Equal(new[] { 1, 4 }, remaining);
+ }
+
+ [Fact]
+ public void RemoveRange_WhenOutsideBounds_DoesNothing()
+ {
+ var list = new TemporalSortedList();
+ list.Add(1);
+ list.Add(2);
+ list.Add(3);
+
+ var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var first = all.First().Timestamp.UtcDateTime;
+ var last = all.Last().Timestamp.UtcDateTime;
+
+ // A range strictly before the first item
+ var beforeFrom = first.AddSeconds(-10);
+ var beforeTo = first.AddSeconds(-1);
+
+ list.RemoveRange(beforeFrom, beforeTo);
+
+ Assert.Equal(3, list.Count);
+
+ // A range strictly after the last item
+ var afterFrom = last.AddSeconds(1);
+ var afterTo = last.AddSeconds(10);
+
+ list.RemoveRange(afterFrom, afterTo);
+
+ Assert.Equal(3, list.Count);
+ }
+
+ [Fact]
+ public void Clear_RemovesAllItems()
+ {
+ var list = new TemporalSortedList();
+ list.Add("a");
+ list.Add("b");
+
+ Assert.True(list.Count > 0);
+
+ list.Clear();
+
+ Assert.Equal(0, list.Count);
+ Assert.Empty(list.GetInRange(DateTime.MinValue, DateTime.MaxValue));
+ Assert.Null(list.GetLatest());
+ Assert.Null(list.GetEarliest());
+ }
+
+ [Fact]
+ public void CountInRange_MatchesGetInRangeCount()
+ {
+ var list = new TemporalSortedList();
+ list.Add(10);
+ list.Add(20);
+ list.Add(30);
+
+ var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var t1 = all[0].Timestamp.UtcDateTime;
+ var t3 = all[2].Timestamp.UtcDateTime;
+
+ var items = list.GetInRange(t1, t3).ToList();
+ var count = list.CountInRange(t1, t3);
+
+ Assert.Equal(items.Count, count);
+ Assert.Equal(3, count);
+ }
+
+ [Fact]
+ public void GetTimeSpan_ReturnsZero_ForLessThanTwoItems()
+ {
+ var list = new TemporalSortedList();
+ Assert.Equal(TimeSpan.Zero, list.GetTimeSpan());
+
+ list.Add(1);
+ Assert.Equal(TimeSpan.Zero, list.GetTimeSpan());
+ }
+
+ [Fact]
+ public void GetTimeSpan_IsNonNegative_AndConsistentWithEarliestLatest()
+ {
+ var list = new TemporalSortedList();
+ list.Add(1);
+ list.Add(2);
+ list.Add(3);
+
+ var span = list.GetTimeSpan();
+ Assert.True(span >= TimeSpan.Zero);
+
+ var earliest = list.GetEarliest()!;
+ var latest = list.GetLatest()!;
+ var expected = latest.Timestamp - earliest.Timestamp;
+
+ // Per implementation, negative spans are clamped to zero; here it must be non-negative.
+ Assert.Equal(expected, span);
+ }
+
+ [Fact]
+ public void GetNearest_OnEmptyList_ReturnsNull()
+ {
+ var list = new TemporalSortedList();
+ Assert.Null(list.GetNearest(DateTime.UtcNow));
+ }
+
+ [Fact]
+ public void GetNearest_BeforeFirst_ReturnsFirst_AfterLast_ReturnsLast()
+ {
+ var list = new TemporalSortedList();
+ list.Add(1);
+ list.Add(2);
+ list.Add(3);
+
+ var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var first = all.First();
+ var last = all.Last();
+
+ var before = list.GetNearest(first.Timestamp.UtcDateTime.AddTicks(-10));
+ var after = list.GetNearest(last.Timestamp.UtcDateTime.AddTicks(10));
+
+ Assert.NotNull(before);
+ Assert.NotNull(after);
+
+ Assert.Equal(first.Value, before!.Value);
+ Assert.Equal(last.Value, after!.Value);
+ }
+
+ [Fact]
+ public void RemoveOlderThan_IsExclusive_RemovesStrictlyOlderOnly()
+ {
+ var list = new TemporalSortedList();
+ list.Add(1);
+ list.Add(2);
+ list.Add(3);
+
+ var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var t2 = all.Single(i => i.Value == 2).Timestamp.UtcDateTime;
+
+ // Remove items with ts < t2 (exclusive), so item "2" must remain.
+ list.RemoveOlderThan(t2);
+
+ var remaining = list.GetInRange(DateTime.MinValue, DateTime.MaxValue)
+ .Select(i => i.Value)
+ .ToList();
+
+ Assert.Equal(new[] { 2, 3 }, remaining);
+ }
+
+ [Fact]
+ public void Add_ManyItems_CountMatches_AndTimestampsAreStrictlyIncreasing()
+ {
+ var list = new TemporalSortedList();
+
+ for (int i = 0; i < 500; i++)
+ list.Add(i);
+
+ Assert.Equal(500, list.Count);
+
+ var items = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ Assert.Equal(500, items.Count);
+
+ for (int i = 1; i < items.Count; i++)
+ {
+ // TemporalItem.Create guarantees strictly increasing UTC ticks per closed generic type.
+ Assert.True(items[i].Timestamp.UtcTicks > items[i - 1].Timestamp.UtcTicks);
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/TemporalCollections.Tests/Collections/TemporalStackTests.cs b/src/TemporalCollections.Tests/Collections/TemporalStackTests.cs
index 991d6cd..b3a8bfd 100644
--- a/src/TemporalCollections.Tests/Collections/TemporalStackTests.cs
+++ b/src/TemporalCollections.Tests/Collections/TemporalStackTests.cs
@@ -496,5 +496,215 @@ public void TemporalStack_GetNearest_WorksAndTiesPreferLater()
Assert.Equal("C", nearCeil!.Value);
}
}
+
+ [Fact]
+ public void GetInRange_WhenFromIsGreaterThanTo_ShouldSwapBounds()
+ {
+ var stack = new TemporalStack();
+ stack.Push(1);
+ Thread.Sleep(2);
+ var t1 = DateTime.UtcNow;
+ Thread.Sleep(2);
+ stack.Push(2);
+ Thread.Sleep(2);
+ var t2 = DateTime.UtcNow;
+ Thread.Sleep(2);
+ stack.Push(3);
+
+ // normal
+ var normal = stack.GetInRange(t1, t2).Select(x => x.Value).ToList();
+ // swapped
+ var swapped = stack.GetInRange(t2, t1).Select(x => x.Value).ToList();
+
+ Assert.Equal(normal, swapped);
+ }
+
+ [Fact]
+ public void CountInRange_WhenFromIsGreaterThanTo_ShouldSwapBounds()
+ {
+ var stack = new TemporalStack();
+ stack.Push(1);
+ Thread.Sleep(2);
+ var t1 = DateTime.UtcNow;
+ Thread.Sleep(2);
+ stack.Push(2);
+ Thread.Sleep(2);
+ var t2 = DateTime.UtcNow;
+ Thread.Sleep(2);
+ stack.Push(3);
+
+ var a = stack.CountInRange(t1, t2);
+ var b = stack.CountInRange(t2, t1);
+
+ Assert.Equal(a, b);
+ }
+
+ [Fact]
+ public void RemoveRange_WhenFromIsGreaterThanTo_ShouldSwapBounds()
+ {
+ var stack = new TemporalStack();
+ stack.Push(1);
+ Thread.Sleep(2);
+ var t1 = DateTime.UtcNow;
+ Thread.Sleep(2);
+ stack.Push(2);
+ Thread.Sleep(2);
+ var t2 = DateTime.UtcNow;
+ Thread.Sleep(2);
+ stack.Push(3);
+
+ // Remove item "2" by range [t1, t2] (or swapped)
+ stack.RemoveRange(t2, t1);
+
+ var remaining = stack.GetInRange(DateTime.MinValue, DateTime.MaxValue).Select(x => x.Value).ToList();
+ Assert.Contains(1, remaining);
+ Assert.Contains(3, remaining);
+ Assert.DoesNotContain(2, remaining);
+ }
+
+ [Fact]
+ public void GetNearest_WhenTimeIsBeforeEarliest_ShouldReturnEarliest()
+ {
+ var stack = new TemporalStack();
+ stack.Push("A");
+ Thread.Sleep(2);
+ stack.Push("B");
+
+ var earliest = stack.GetEarliest();
+ Assert.NotNull(earliest);
+
+ var beforeAll = earliest!.Timestamp.UtcDateTime.AddMinutes(-10);
+
+ var nearest = stack.GetNearest(beforeAll);
+
+ Assert.NotNull(nearest);
+ Assert.Equal(earliest.Value, nearest!.Value);
+ Assert.Equal(earliest.Timestamp, nearest.Timestamp);
+ }
+
+ [Fact]
+ public void GetNearest_WhenTimeIsAfterLatest_ShouldReturnLatest()
+ {
+ var stack = new TemporalStack();
+ stack.Push("A");
+ Thread.Sleep(2);
+ stack.Push("B");
+
+ var latest = stack.GetLatest();
+ Assert.NotNull(latest);
+
+ var afterAll = latest!.Timestamp.UtcDateTime.AddMinutes(+10);
+
+ var nearest = stack.GetNearest(afterAll);
+
+ Assert.NotNull(nearest);
+ Assert.Equal(latest.Value, nearest!.Value);
+ Assert.Equal(latest.Timestamp, nearest.Timestamp);
+ }
+
+ [Fact]
+ public void GetBefore_And_GetAfter_ShouldReturnResultsSortedByAscendingTimestamp()
+ {
+ var stack = new TemporalStack();
+ stack.Push(1);
+ Thread.Sleep(2);
+ stack.Push(2);
+ Thread.Sleep(2);
+ var split = DateTime.UtcNow;
+ Thread.Sleep(2);
+ stack.Push(3);
+ Thread.Sleep(2);
+ stack.Push(4);
+
+ var before = stack.GetBefore(split).ToList();
+ var after = stack.GetAfter(split).ToList();
+
+ static bool IsSortedAscending(IReadOnlyList> items)
+ {
+ for (int i = 1; i < items.Count; i++)
+ if (items[i - 1].Timestamp.UtcTicks > items[i].Timestamp.UtcTicks)
+ return false;
+ return true;
+ }
+
+ Assert.True(IsSortedAscending(before));
+ Assert.True(IsSortedAscending(after));
+ }
+
+ [Fact]
+ public void GetEarliest_And_GetLatest_ShouldUpdateAfterRemoveOlderThan()
+ {
+ var stack = new TemporalStack();
+
+ stack.Push("old");
+ Thread.Sleep(5);
+ var cutoff = DateTime.UtcNow;
+ Thread.Sleep(5);
+ stack.Push("new1");
+ Thread.Sleep(5);
+ stack.Push("new2");
+
+ stack.RemoveOlderThan(cutoff);
+
+ var earliest = stack.GetEarliest();
+ var latest = stack.GetLatest();
+
+ Assert.NotNull(earliest);
+ Assert.NotNull(latest);
+
+ Assert.Equal("new1", earliest!.Value);
+ Assert.Equal("new2", latest!.Value);
+ }
+
+ [Fact]
+ public void Operations_OnEmptyStack_ShouldBeSafeForQueriesAndMutations()
+ {
+ var stack = new TemporalStack();
+
+ // Query APIs should not throw and should return empty/zero/null
+ Assert.Empty(stack.GetInRange(DateTime.MinValue, DateTime.MaxValue));
+ Assert.Empty(stack.GetBefore(DateTime.UtcNow));
+ Assert.Empty(stack.GetAfter(DateTime.UtcNow));
+ Assert.Equal(0, stack.CountInRange(DateTime.MinValue, DateTime.MaxValue));
+ Assert.Equal(0, stack.CountSince(DateTime.UtcNow));
+ Assert.Equal(TimeSpan.Zero, stack.GetTimeSpan());
+ Assert.Null(stack.GetEarliest());
+ Assert.Null(stack.GetLatest());
+ Assert.Null(stack.GetNearest(DateTime.UtcNow));
+
+ // Mutation APIs should not throw
+ stack.RemoveOlderThan(DateTime.UtcNow);
+ stack.RemoveRange(DateTime.MinValue, DateTime.MaxValue);
+ stack.Clear();
+ }
+
+ [Fact]
+ public void Concurrent_Reads_DuringPushes_ShouldNotThrow_AndCountShouldMatchAllItemsAtEnd()
+ {
+ var stack = new TemporalStack();
+
+ var reader = Task.Run(() =>
+ {
+ // read a bunch while writers are pushing; should never throw
+ for (int i = 0; i < 2000; i++)
+ {
+ _ = stack.Count;
+ _ = stack.GetInRange(DateTime.MinValue, DateTime.MaxValue).Count();
+ _ = stack.GetEarliest();
+ _ = stack.GetLatest();
+ }
+ });
+
+ Parallel.For(0, 1000, i => stack.Push(i));
+
+ reader.Wait();
+
+ var all = stack.GetInRange(DateTime.MinValue, DateTime.MaxValue).Select(x => x.Value).ToList();
+ Assert.Equal(1000, stack.Count);
+ Assert.Equal(1000, all.Count);
+
+ // no duplicates / no losses
+ Assert.Equal(1000, all.Distinct().Count());
+ }
}
}
\ No newline at end of file
diff --git a/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs b/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs
index 39b6b47..8ee73a7 100644
--- a/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs
+++ b/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs
@@ -15,6 +15,18 @@ private static TemporalQueue MakeIntQueue(params int[] values)
return q;
}
+ private static TemporalQueue MakeSpacedIntQueue(params int[] values)
+ {
+ var q = new TemporalQueue();
+ foreach (var v in values)
+ {
+ q.Enqueue(v);
+ Thread.Sleep(2);
+ }
+
+ return q;
+ }
+
[Fact]
public void ToValueList_ReturnsAllValuesInChronologicalOrder()
{
@@ -115,6 +127,143 @@ public void ToReadOnlyValueCollection_MaterializesAndIsReadOnly()
Assert.Throws(() => asCollection.Add(42));
}
+ [Fact]
+ public void GetAtOrBefore_And_GetAtOrAfter_ReturnInclusiveDirectionalMatches()
+ {
+ var source = MakeSpacedIntQueue(10, 20, 30);
+ var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+
+ var exactMiddle = items[1].Timestamp;
+ var betweenMiddleAndLast = exactMiddle.AddMilliseconds(1);
+
+ Assert.Equal(20, source.GetAtOrBefore(exactMiddle)!.Value);
+ Assert.Equal(20, source.GetAtOrAfter(exactMiddle)!.Value);
+ Assert.Equal(20, source.GetAtOrBefore(betweenMiddleAndLast)!.Value);
+ Assert.Equal(30, source.GetAtOrAfter(betweenMiddleAndLast)!.Value);
+ Assert.Null(source.GetAtOrBefore(items[0].Timestamp.AddMilliseconds(-1)));
+ Assert.Null(source.GetAtOrAfter(items[^1].Timestamp.AddMilliseconds(1)));
+ }
+
+ [Fact]
+ public void GetAtOrBefore_And_GetAtOrAfter_WorkWithDateTimeOverloads()
+ {
+ var source = MakeSpacedIntQueue(1, 2, 3);
+ var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var exactMiddle = items[1].Timestamp.UtcDateTime;
+
+ Assert.Equal(2, source.GetAtOrBefore(exactMiddle)!.Value);
+ Assert.Equal(2, source.GetAtOrAfter(exactMiddle)!.Value);
+ }
+
+ [Fact]
+ public void GetAtOrBefore_And_GetAtOrAfter_WorkForTemporalDictionaryValues()
+ {
+ var source = new TemporalDictionary();
+ source.Add("a", 100);
+ Thread.Sleep(2);
+ source.Add("b", 200);
+ Thread.Sleep(2);
+ source.Add("c", 300);
+
+ var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var exactMiddle = items[1].Timestamp;
+
+ Assert.Equal("b", source.GetAtOrBefore(exactMiddle)!.Value.Key);
+ Assert.Equal(200, source.GetAtOrBefore(exactMiddle)!.Value.Value);
+ Assert.Equal("b", source.GetAtOrAfter(exactMiddle)!.Value.Key);
+ Assert.Equal(200, source.GetAtOrAfter(exactMiddle)!.Value.Value);
+ }
+
+ [Fact]
+ public void TryGetAtOrBefore_And_TryGetAtOrAfter_ReturnTrueAndMatchedItem()
+ {
+ var source = MakeSpacedIntQueue(10, 20, 30);
+ var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var exactMiddle = items[1].Timestamp;
+ var betweenMiddleAndLast = exactMiddle.AddMilliseconds(1);
+
+ var foundBefore = source.TryGetAtOrBefore(betweenMiddleAndLast, out var beforeItem);
+ var foundAfter = source.TryGetAtOrAfter(betweenMiddleAndLast, out var afterItem);
+
+ Assert.True(foundBefore);
+ Assert.NotNull(beforeItem);
+ Assert.Equal(20, beforeItem!.Value);
+ Assert.True(foundAfter);
+ Assert.NotNull(afterItem);
+ Assert.Equal(30, afterItem!.Value);
+ }
+
+ [Fact]
+ public void TryGetAtOrBefore_And_TryGetAtOrAfter_ReturnFalseAndNullWhenNoMatchExists()
+ {
+ var source = MakeSpacedIntQueue(10, 20, 30);
+ var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+
+ var foundBefore = source.TryGetAtOrBefore(items[0].Timestamp.AddMilliseconds(-1), out var beforeItem);
+ var foundAfter = source.TryGetAtOrAfter(items[^1].Timestamp.AddMilliseconds(1), out var afterItem);
+
+ Assert.False(foundBefore);
+ Assert.Null(beforeItem);
+ Assert.False(foundAfter);
+ Assert.Null(afterItem);
+ }
+
+ [Fact]
+ public void TryGetAtOrBefore_And_TryGetAtOrAfter_WorkWithDateTimeOverloads()
+ {
+ var source = MakeSpacedIntQueue(1, 2, 3);
+ var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var exactMiddle = items[1].Timestamp.UtcDateTime;
+
+ Assert.True(source.TryGetAtOrBefore(exactMiddle, out var beforeItem));
+ Assert.NotNull(beforeItem);
+ Assert.Equal(2, beforeItem!.Value);
+ Assert.True(source.TryGetAtOrAfter(exactMiddle, out var afterItem));
+ Assert.NotNull(afterItem);
+ Assert.Equal(2, afterItem!.Value);
+ }
+
+ [Fact]
+ public void CountBefore_And_CountAfter_ReturnExpectedExclusiveCounts()
+ {
+ var source = MakeSpacedIntQueue(10, 20, 30);
+ var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var split = items[1].Timestamp;
+
+ Assert.Equal(1, source.CountBefore(split));
+ Assert.Equal(1, source.CountAfter(split));
+ Assert.Equal(0, source.CountBefore(items[0].Timestamp));
+ Assert.Equal(0, source.CountAfter(items[^1].Timestamp));
+ }
+
+ [Fact]
+ public void CountBefore_And_CountAfter_WorkWithDateTimeOverloads()
+ {
+ var source = MakeSpacedIntQueue(1, 2, 3);
+ var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var split = items[1].Timestamp.UtcDateTime;
+
+ Assert.Equal(1, source.CountBefore(split));
+ Assert.Equal(1, source.CountAfter(split));
+ }
+
+ [Fact]
+ public void CountBefore_And_CountAfter_WorkForTemporalDictionaryValues()
+ {
+ var source = new TemporalDictionary();
+ source.Add("a", 100);
+ Thread.Sleep(2);
+ source.Add("b", 200);
+ Thread.Sleep(2);
+ source.Add("c", 300);
+
+ var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList();
+ var split = items[1].Timestamp;
+
+ Assert.Equal(1, source.CountBefore(split));
+ Assert.Equal(1, source.CountAfter(split));
+ }
+
[Fact]
public void BucketBy_GroupsIntoSingleDailyBucket_AndAppliesAggregator()
{
diff --git a/src/TemporalCollections.Tests/Collections/TimeProviderSupportTests.cs b/src/TemporalCollections.Tests/Collections/TimeProviderSupportTests.cs
new file mode 100644
index 0000000..fc3f944
--- /dev/null
+++ b/src/TemporalCollections.Tests/Collections/TimeProviderSupportTests.cs
@@ -0,0 +1,465 @@
+// (c) 2025 Francesco Del Re
+// This code is licensed under MIT license (see LICENSE.txt for details)
+using TemporalCollections.Collections;
+using TemporalCollections.Models;
+
+namespace TemporalCollections.Tests.Collections
+{
+ public class TimeProviderSupportTests
+ {
+ private sealed class ManualTimeProvider : TimeProvider
+ {
+ private DateTimeOffset _now;
+ public ManualTimeProvider(DateTimeOffset start) => _now = start;
+ public override DateTimeOffset GetUtcNow() => _now;
+ public void Advance(TimeSpan delta) => _now += delta;
+ }
+
+ private static readonly DateTimeOffset Start = new(2020, 1, 1, 12, 0, 0, TimeSpan.Zero);
+
+ // ---------- TemporalItem ----------
+
+ [Fact]
+ public void TemporalItem_Create_UsesProvidedClock()
+ {
+ var clock = new ManualTimeProvider(Start);
+ var item = TemporalItem.Create("a", clock);
+ Assert.Equal(Start, item.Timestamp);
+ Assert.Equal(TimeSpan.Zero, item.Timestamp.Offset);
+ }
+
+ [Fact]
+ public void TemporalItem_Create_IsStrictlyMonotonicWithFrozenClock()
+ {
+ var clock = new ManualTimeProvider(Start);
+ var a = TemporalItem.Create(1, clock);
+ var b = TemporalItem.Create(2, clock);
+ var c = TemporalItem.Create(3, clock);
+
+ Assert.Equal(Start.UtcTicks, a.Timestamp.UtcTicks);
+ Assert.Equal(Start.UtcTicks + 1, b.Timestamp.UtcTicks);
+ Assert.Equal(Start.UtcTicks + 2, c.Timestamp.UtcTicks);
+ }
+
+ [Fact]
+ public void TemporalItem_Create_FollowsClockAdvance()
+ {
+ var clock = new ManualTimeProvider(Start);
+ TemporalItem.Create(1, clock);
+ clock.Advance(TimeSpan.FromHours(1));
+ var later = TemporalItem.Create(2, clock);
+ Assert.Equal(Start.AddHours(1), later.Timestamp);
+ }
+
+ [Fact]
+ public void TemporalItem_Create_ClockGoingBackwards_StaysMonotonic()
+ {
+ var clock = new ManualTimeProvider(Start);
+ var a = TemporalItem.Create(1, clock);
+ clock.Advance(TimeSpan.FromMinutes(-5));
+ var b = TemporalItem.Create(2, clock);
+ Assert.True(b.Timestamp > a.Timestamp);
+ }
+
+ [Fact]
+ public void TemporalItem_Create_PastClock_IsNotAffectedBySystemClock()
+ {
+ // System-clock items must not push a custom (past) clock forward.
+ TemporalItem.Create(0);
+ var clock = new ManualTimeProvider(Start);
+ var item = TemporalItem.Create(1, clock);
+ Assert.Equal(Start, item.Timestamp);
+ }
+
+ [Fact]
+ public void TemporalItem_Create_IndependentClocks_DoNotInterfere()
+ {
+ var c1 = new ManualTimeProvider(Start);
+ var c2 = new ManualTimeProvider(Start);
+ var a = TemporalItem.Create(1, c1);
+ var b = TemporalItem.Create(2, c2);
+ Assert.Equal(Start, a.Timestamp);
+ Assert.Equal(Start, b.Timestamp);
+ }
+
+ [Fact]
+ public void TemporalItem_Create_SystemProvider_EquivalentToDefault()
+ {
+ var before = DateTimeOffset.UtcNow;
+ var a = TemporalItem.Create(1, TimeProvider.System);
+ var b = TemporalItem.Create(2);
+ Assert.True(a.Timestamp >= before.AddSeconds(-1));
+ Assert.True(b.Timestamp > a.Timestamp);
+ }
+
+ [Fact]
+ public void TemporalItem_Create_NullProvider_Throws()
+ {
+ Assert.Throws(() => TemporalItem.Create(1, null!));
+ }
+
+ [Fact]
+ public void TemporalItem_Create_ConcurrentWithFrozenClock_ProducesUniqueTimestamps()
+ {
+ var clock = new ManualTimeProvider(Start);
+ var bag = new System.Collections.Concurrent.ConcurrentBag();
+ Parallel.For(0, 5_000, i => bag.Add(TemporalItem.Create(Guid.Empty, clock).Timestamp.UtcTicks));
+ Assert.Equal(5_000, bag.Distinct().Count());
+ }
+
+ // ---------- Backward compatibility ----------
+
+ [Fact]
+ public void DefaultConstructors_StillUseSystemClock()
+ {
+ var before = DateTimeOffset.UtcNow.AddSeconds(-1);
+
+ var q = new TemporalQueue(); q.Enqueue(1);
+ var s = new TemporalStack(); s.Push(1);
+ var set = new TemporalSet(); set.Add(1);
+ var sl = new TemporalSortedList(); sl.Add(1);
+ var cb = new TemporalCircularBuffer(2); cb.Add(1);
+ var sa = new TemporalSegmentedArray(); sa.AddValue(1);
+ var pq = new TemporalPriorityQueue(); pq.Enqueue(1, 1);
+ var d = new TemporalDictionary(); d.Add(1, 1);
+ var mm = new TemporalMultimap(); mm.AddValue(1, 1);
+ var sw = new TemporalSlidingWindowSet(TimeSpan.FromMinutes(1)); sw.Add(1);
+
+ Assert.True(q.GetLatest()!.Timestamp >= before);
+ Assert.True(s.GetLatest()!.Timestamp >= before);
+ Assert.True(set.GetLatest()!.Timestamp >= before);
+ Assert.True(sl.GetLatest()!.Timestamp >= before);
+ Assert.True(cb.GetLatest()!.Timestamp >= before);
+ Assert.True(sa.GetLatest()!.Timestamp >= before);
+ Assert.True(pq.GetLatest()!.Timestamp >= before);
+ Assert.True(d.GetLatest()!.Timestamp >= before);
+ Assert.True(mm.GetLatest()!.Timestamp >= before);
+ Assert.True(sw.GetLatest()!.Timestamp >= before);
+ }
+
+ [Fact]
+ public void TemporalSet_ExistingComparerConstructor_StillWorks()
+ {
+ var set = new TemporalSet(StringComparer.OrdinalIgnoreCase);
+ Assert.True(set.Add("A"));
+ Assert.False(set.Add("a"));
+ }
+
+ [Fact]
+ public void Constructors_NullProvider_Throw()
+ {
+ Assert.Throws(() => new TemporalQueue(null!));
+ Assert.Throws(() => new TemporalStack(null!));
+ Assert.Throws(() => new TemporalSet((TimeProvider)null!));
+ Assert.Throws(() => new TemporalSortedList(null!));
+ Assert.Throws(() => new TemporalCircularBuffer(2, null!));
+ Assert.Throws(() => new TemporalSegmentedArray((TimeProvider)null!));
+ Assert.Throws(() => new TemporalPriorityQueue(null!));
+ Assert.Throws(() => new TemporalDictionary(null!));
+ Assert.Throws(() => new TemporalMultimap(null!));
+ Assert.Throws(() => new TemporalSlidingWindowSet(TimeSpan.FromMinutes(1), null!));
+ }
+
+ // ---------- Clock isolation ----------
+
+ [Fact]
+ public void TemporalItem_Create_FutureClock_DoesNotAdvanceSystemClock()
+ {
+ var future = new ManualTimeProvider(DateTimeOffset.UtcNow.AddYears(10));
+ var f = TemporalItem.Create(1m, future);
+
+ var sys = TemporalItem.Create(2m);
+
+ Assert.True(sys.Timestamp < f.Timestamp);
+ Assert.True(sys.Timestamp < DateTimeOffset.UtcNow.AddMinutes(1));
+ }
+
+ [Fact]
+ public void Collection_FutureClock_DoesNotAffectSystemClockCollection()
+ {
+ var future = new ManualTimeProvider(DateTimeOffset.UtcNow.AddYears(10));
+ var fq = new TemporalQueue