Skip to content

Commit 6bfce04

Browse files
marius-bughiuclaude
andcommitted
test: redistribute edge-case tests, reach 100% line coverage, drop report Floor column
- Delete the catch-all EdgeCaseCoverageTests and move every test next to the type it exercises, matching the repo's mirror-the-source layout: indexer misses and Clear()-on-empty into each *Tests.cs; the non-generic IEnumerable/IEnumerator + Reset paths into each *EnumerationTests.cs; the wrap-around backward-shift case into CelerityMultiMapCollisionTests. - Remove the dead table-sizing guard in FrozenCelerityDictionary (`if (size <= n) size <<= 1;`). NextPowerOfTwo(n + 1) already returns a power of two strictly greater than n for every constructible size (it caps at 2^30), so the guard was unreachable — and `size <<= 1` would overflow to a negative size if it ever ran. Dropping it takes the library to 100% line coverage. - Drop the "Floor" column from the coverage report's PR summary table. Full suite: 1881 tests pass; library coverage 100% line / ~99% branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 058c548 commit 6bfce04

15 files changed

Lines changed: 431 additions & 388 deletions

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ The next release rounds out the `Celerity.Collections` package with missing coll
6161
- Comprehensive benchmark suite: uniform, clustered, and adversarial key distributions. Status: `done``DistributionBenchmark` sweeps uniform/sequential/clustered shapes and `AdversarialHasherBenchmark` shows the naive hasher degrading to O(n) while Murmur3 recovers. Tracked in [#60](https://github.com/marius-bughiu/Celerity/issues/60).
6262
- Benchmark suite expansion: realistic workloads, memory-allocation, concurrent-access, cache-locality, large-dataset (millions), and `FrozenDictionary<,>` comparison benchmarks. Status: `done` — added as an extended, on-demand suite kept out of the per-PR CI regression run; see [`docs/performance.md`](docs/performance.md#extended-benchmark-suite). Tracked in [#26](https://github.com/marius-bughiu/Celerity/issues/26).
6363
- Cross-platform testing (Windows, Linux, macOS). Status: `done`.
64-
- Improve code coverage. Status: `done` — coverage reporting is gated in CI (`coverage.yml`, ~99.9% line coverage on the library, published to the [coverage dashboard](https://marius-bughiu.github.io/Celerity/coverage/)), edge-case tests close the non-generic enumerator / throw / backward-shift corners, property-based parity tests (CsCheck) and a seedable differential fuzzer (`Celerity.Fuzz`, nightly soak) check every collection against its BCL oracle, and the approach is written up in [`docs/testing.md`](docs/testing.md). Tracked in [#29](https://github.com/marius-bughiu/Celerity/issues/29).
64+
- Improve code coverage. Status: `done` — coverage reporting is gated in CI (`coverage.yml`, 100% line coverage on the library, rendered by an in-repo generator and published to the [coverage dashboard](https://marius-bughiu.github.io/Celerity/coverage/)), edge-case tests close the non-generic enumerator / throw / backward-shift corners, property-based parity tests (CsCheck) and a seedable differential fuzzer (`Celerity.Fuzz`, nightly soak) check every collection against its BCL oracle, and the approach is written up in [`docs/testing.md`](docs/testing.md). Tracked in [#29](https://github.com/marius-bughiu/Celerity/issues/29).
6565
- Improve documentation. Status: `done` — added a performance tuning guide, a BCL migration guide, a troubleshooting guide, and a FAQ, alongside the existing README usage examples, "choosing a collection" table, and API reference. Tracked in [#15](https://github.com/marius-bughiu/Celerity/issues/15).
6666
- Bump XML doc coverage; treat missing docs as warning-as-error. Status: `done``Celerity.csproj` promotes CS1591 to error.
6767

docs/testing.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Celerity's first guiding principle is *correctness first* — "a fast collection
77
| Layer | Project / file | What it proves | Run locally |
88
|---|---|---|---|
99
| Behavioural unit tests | `Celerity.Tests` | Each public method does the right thing on hand-picked inputs, including collisions, resizes, and the out-of-band default/zero/null key. | `dotnet test` |
10-
| Edge-case coverage tests | `Celerity.Tests/Collections/EdgeCaseCoverageTests.cs` | The corners example tests skip: non-generic `IEnumerable`/`IEnumerator` paths, `Reset()`, indexer misses, `Clear()` on empty, wrap-around backward-shift. | `dotnet test` |
10+
| Edge-case coverage | alongside each type's tests (`*Tests.cs`, `*EnumerationTests.cs`, `*CollisionTests.cs`) | The corners example tests skip: non-generic `IEnumerable`/`IEnumerator` paths, `Reset()`, indexer misses, `Clear()` on empty, wrap-around backward-shift. | `dotnet test` |
1111
| Property-based tests | `Celerity.Tests/Properties/` | Across thousands of randomized operation sequences, every collection stays observably equal to its BCL oracle. | `dotnet test` |
1212
| Differential fuzzer | `Celerity.Fuzz` | A long random walk finds no divergence from the BCL; failures replay deterministically from a seed. | `dotnet run -c Release` |
1313
| Native AOT smoke test | `Celerity.AotSmokeTest` | Every collection/hasher works in a trimmed, AOT-compiled native binary. | see [aot.md](aot.md) |
@@ -30,9 +30,9 @@ Both compare against a BCL oracle (`Dictionary<,>`, `HashSet<>`, or a `Dictionar
3030
The bulk of the suite lives in `Celerity.Tests`, mirroring the library's folder layout. Test names follow `Method_ShouldExpectedBehavior_WhenCondition`. Notable categories:
3131

3232
- **Collision tests** (`*CollisionTests.cs`) — force every key down one probe chain with a constant hasher, then verify lookups, removals, and backward-shift deletion keep every entry findable.
33-
- **Enumeration tests** (`*EnumerationTests.cs`) — the struct enumerators, `Keys`/`Values` views, and mid-enumeration mutation detection.
33+
- **Enumeration tests** (`*EnumerationTests.cs`) — the struct enumerators, `Keys`/`Values` views, mid-enumeration mutation detection, and the non-generic interface surface (`IEnumerable.GetEnumerator()`, `object IEnumerator.Current`, `IEnumerator.Reset()`).
3434
- **Load-factor / constructor validation** — boundary resizes and argument checking.
35-
- **Edge-case coverage** (`EdgeCaseCoverageTests.cs`) — the non-generic interface surface (`IEnumerable.GetEnumerator()`, `object IEnumerator.Current`, `IEnumerator.Reset()`), indexer misses on the out-of-band key, `Clear()` on an empty collection, and a hand-built wrap-around cluster that exercises the `bypassesGap` branch of backward-shift deletion.
35+
- **Edge cases** live next to the type they exercise rather than in a catch-all file: indexer misses on the out-of-band key and `Clear()` on an empty collection sit in `*Tests.cs`; the wrap-around cluster that exercises the `bypassesGap` branch of backward-shift deletion sits in `*CollisionTests.cs`.
3636

3737
Run them with:
3838

@@ -134,14 +134,10 @@ The report is rendered by [`scripts/coverage_report.py`](../scripts/coverage_rep
134134
The `coverage` workflow (`.github/workflows/coverage.yml`) runs on every PR and on `main`:
135135

136136
- Collects coverage, renders the report + badge with `scripts/coverage_report.py`, and uploads it as a build artifact.
137-
- **Fails the build** if line coverage drops below `MIN_LINE_COVERAGE` (95%) or branch coverage below `MIN_BRANCH_COVERAGE` (90%). The suite sits far above these (~99.9% line) — the floor guards against silent regressions; it is not the target.
137+
- **Fails the build** if line coverage drops below `MIN_LINE_COVERAGE` (95%) or branch coverage below `MIN_BRANCH_COVERAGE` (90%). The suite sits far above these (100% line, ~99% branch) — the floor guards against silent regressions; it is not the target.
138138
- Posts a coverage summary comment on the PR.
139139
- On `main`, publishes the HTML report to `gh-pages` under [`/coverage`](https://marius-bughiu.github.io/Celerity/coverage/) and refreshes the README badge.
140140

141-
### What is deliberately not covered
142-
143-
A single line — the integer-overflow guard in `FrozenCelerityDictionary`'s table sizing (`if (size <= n) size <<= 1;`) — is unreachable without ~2³⁰ keys, so it is left uncovered by design rather than tested with an impractically large input. It is defensive code, kept for safety.
144-
145141
## Continuous integration summary
146142

147143
| Workflow | Trigger | What it does |

scripts/coverage_report.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -349,10 +349,10 @@ def render_summary(overall, files, min_line, min_branch, passed) -> str:
349349
lines = [
350350
"### Coverage",
351351
"",
352-
"| Metric | Value | Floor |",
353-
"|---|---:|---:|",
354-
f'| Line | {pct(overall["line_rate"])} ({overall["lines_covered"]}/{overall["lines_valid"]}) | {min_line:.0f}% |',
355-
f'| Branch | {pct(overall["branch_rate"])} ({overall["branches_covered"]}/{overall["branches_valid"]}) | {min_branch:.0f}% |',
352+
"| Metric | Value |",
353+
"|---|---:|",
354+
f'| Line | {pct(overall["line_rate"])} ({overall["lines_covered"]}/{overall["lines_valid"]}) |',
355+
f'| Branch | {pct(overall["branch_rate"])} ({overall["branches_covered"]}/{overall["branches_valid"]}) |',
356356
"",
357357
]
358358
if not passed:

src/Celerity.Tests/Collections/CelerityDictionaryEnumerationTests.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections;
12
using System.Collections.Generic;
23
using Celerity.Collections;
34
using Celerity.Hashing;
@@ -391,4 +392,47 @@ public void Enumerator_ShouldBeReusableViaReset()
391392
Assert.Equal(2, firstPass);
392393
Assert.Equal(firstPass, secondPass);
393394
}
395+
396+
[Fact]
397+
public void KeyCollection_ShouldRoundTrip_ThroughNonGenericIEnumerable()
398+
{
399+
// The non-generic System.Collections.IEnumerable path (object Current,
400+
// Reset) is distinct from the generic IEnumerable<TKey> path above.
401+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
402+
for (int i = 1; i <= 4; i++) map[i] = i * 10;
403+
404+
var (first, second) = DrainNonGenericTwice<int>(map.Keys);
405+
406+
first.Sort();
407+
second.Sort();
408+
Assert.Equal(new[] { 1, 2, 3, 4 }, first);
409+
Assert.Equal(first, second);
410+
}
411+
412+
[Fact]
413+
public void ValueCollection_ShouldRoundTrip_ThroughNonGenericIEnumerable()
414+
{
415+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
416+
for (int i = 1; i <= 4; i++) map[i] = i * 10;
417+
418+
var (first, second) = DrainNonGenericTwice<int>(map.Values);
419+
420+
first.Sort();
421+
second.Sort();
422+
Assert.Equal(new[] { 10, 20, 30, 40 }, first);
423+
Assert.Equal(first, second);
424+
}
425+
426+
// Drives a struct view through its boxed non-generic IEnumerable/IEnumerator
427+
// surface (GetEnumerator -> object Current), then Reset()s and drains again.
428+
private static (List<T> first, List<T> second) DrainNonGenericTwice<T>(IEnumerable view)
429+
{
430+
IEnumerator e = view.GetEnumerator();
431+
var first = new List<T>();
432+
while (e.MoveNext()) first.Add((T)e.Current!);
433+
e.Reset();
434+
var second = new List<T>();
435+
while (e.MoveNext()) second.Add((T)e.Current!);
436+
return (first, second);
437+
}
394438
}

src/Celerity.Tests/Collections/CelerityDictionaryTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,26 @@ public void Indexer_ShouldHandleGuidEmptyKey()
199199
Assert.Equal(2, map.Count);
200200
}
201201

202+
[Fact]
203+
public void Indexer_ShouldThrowKeyNotFound_ForAbsentDefaultKey()
204+
{
205+
// default(int) == 0 takes the out-of-band default-key path; with no
206+
// default key stored the getter must throw, not return default(TValue).
207+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
208+
209+
Assert.Throws<KeyNotFoundException>(() => _ = map[0]);
210+
}
211+
212+
[Fact]
213+
public void Clear_ShouldBeNoOp_WhenAlreadyEmpty()
214+
{
215+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
216+
217+
map.Clear(); // _count == 0 early-return path
218+
219+
Assert.Empty(map);
220+
}
221+
202222
// A test-only hasher used to exercise the Guid path. Not part of the
203223
// public library — lives next to the tests that need it. Uses the low
204224
// 32 bits of the GUID's hash code.

src/Celerity.Tests/Collections/CelerityMultiMapCollisionTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,24 @@ public void NullKeyGroup_ShouldCoexistWithCollidingStringKeys()
152152
for (int i = 1; i <= 5; i++)
153153
Assert.Equal(new[] { i }, map[$"k{i}"].ToArray());
154154
}
155+
156+
[Fact]
157+
public void RemoveAll_OnWrapAroundCluster_ShouldKeepHomedKey()
158+
{
159+
// With an identity hasher and capacity 8, keys 1, 2, 9 form one probe
160+
// cluster: 1->slot1, 2->slot2, 9(home 1)->slot3. Removing key 1 forces the
161+
// backward shift to *skip* key 2 (already at its home slot — the bypassesGap
162+
// branch) while relocating key 9 into the freed slot.
163+
var map = new CelerityMultiMap<int, int, IdentityIntHasher>(8);
164+
map.Add(1, 10);
165+
map.Add(2, 20);
166+
map.Add(9, 90);
167+
168+
Assert.True(map.RemoveAll(1));
169+
170+
Assert.False(map.ContainsKey(1));
171+
Assert.Equal(new[] { 20 }, map[2].ToArray());
172+
Assert.Equal(new[] { 90 }, map[9].ToArray());
173+
Assert.Equal(2, map.Count);
174+
}
155175
}

src/Celerity.Tests/Collections/CelerityMultiMapEnumerationTests.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,4 +208,110 @@ public void ValueGroup_BoxedEnumeration_ShouldYieldValues()
208208

209209
Assert.Equal(new[] { 1, 2 }, values.ToArray());
210210
}
211+
212+
[Fact]
213+
public void Enumerator_ShouldRoundTrip_AfterReset()
214+
{
215+
var map = new CelerityMultiMap<int, int, Int32WangNaiveHasher>();
216+
map.Add(0, 100); // default key exercises the BeforeDefaultKey state
217+
map.Add(1, 10);
218+
map.Add(2, 20);
219+
220+
IEnumerator e = ((IEnumerable)map).GetEnumerator();
221+
var firstKeys = new List<int>();
222+
while (e.MoveNext()) firstKeys.Add(((IGrouping<int, int>)e.Current!).Key);
223+
e.Reset();
224+
var secondKeys = new List<int>();
225+
while (e.MoveNext()) secondKeys.Add(((IGrouping<int, int>)e.Current!).Key);
226+
227+
firstKeys.Sort();
228+
secondKeys.Sort();
229+
Assert.Equal(new[] { 0, 1, 2 }, firstKeys);
230+
Assert.Equal(firstKeys, secondKeys);
231+
}
232+
233+
[Fact]
234+
public void EnumeratorReset_ShouldThrow_WhenMapMutated()
235+
{
236+
var map = new CelerityMultiMap<int, int, Int32WangNaiveHasher>();
237+
map.Add(1, 10);
238+
239+
var e = map.GetEnumerator();
240+
e.MoveNext();
241+
map.Add(2, 20); // bumps the version
242+
243+
Assert.Throws<InvalidOperationException>(() => e.Reset());
244+
}
245+
246+
[Fact]
247+
public void ValueGroup_ShouldEnumerate_ViaPublicStructEnumerator()
248+
{
249+
var map = new CelerityMultiMap<int, int, Int32WangNaiveHasher>();
250+
map.Add(1, 10);
251+
map.Add(1, 11);
252+
253+
// foreach over the ValueGroup struct binds to its public GetEnumerator(),
254+
// distinct from the boxed IEnumerable<TValue> path tested above.
255+
int sum = 0;
256+
foreach (int? v in map[1])
257+
sum += v ?? 0;
258+
259+
Assert.Equal(21, sum);
260+
}
261+
262+
[Fact]
263+
public void ValueGroup_ShouldRoundTrip_ThroughNonGenericIEnumerable()
264+
{
265+
var map = new CelerityMultiMap<int, int, Int32WangNaiveHasher>();
266+
map.Add(1, 10);
267+
map.Add(1, 11);
268+
269+
var (first, second) = DrainNonGenericTwice<int>(map[1]);
270+
271+
Assert.Equal(new[] { 10, 11 }, first);
272+
Assert.Equal(first, second);
273+
}
274+
275+
[Fact]
276+
public void Grouping_ShouldRoundTrip_ThroughNonGenericIEnumerable()
277+
{
278+
var map = new CelerityMultiMap<int, int, Int32WangNaiveHasher>();
279+
map.Add(5, 50);
280+
map.Add(5, 51);
281+
282+
foreach (var grouping in map)
283+
{
284+
var (first, second) = DrainNonGenericTwice<int>((IEnumerable)grouping);
285+
Assert.Equal(new[] { 50, 51 }, first);
286+
Assert.Equal(first, second);
287+
}
288+
}
289+
290+
[Fact]
291+
public void Keys_ShouldRoundTrip_ThroughNonGenericIEnumerable()
292+
{
293+
var map = new CelerityMultiMap<int, int, Int32WangNaiveHasher>();
294+
map.Add(1, 10);
295+
map.Add(2, 20);
296+
297+
var (first, second) = DrainNonGenericTwice<int>(map.Keys);
298+
299+
first.Sort();
300+
second.Sort();
301+
Assert.Equal(new[] { 1, 2 }, first);
302+
Assert.Equal(first, second);
303+
}
304+
305+
// Drives a struct view through its boxed non-generic IEnumerable/IEnumerator
306+
// surface (GetEnumerator -> object Current), then Reset()s and drains again.
307+
private static (List<T> first, List<T> second) DrainNonGenericTwice<T>(IEnumerable view)
308+
{
309+
IEnumerator e = view.GetEnumerator();
310+
var first = new List<T>();
311+
while (e.MoveNext()) first.Add((T)e.Current!);
312+
e.Reset();
313+
var second = new List<T>();
314+
while (e.MoveNext()) second.Add((T)e.Current!);
315+
return (first, second);
316+
}
211317
}

src/Celerity.Tests/Collections/CelerityMultiMapTests.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,4 +380,48 @@ public void GrowthUnderManyKeys_ShouldPreserveAllGroups()
380380
for (int i = 0; i < 500; i++)
381381
Assert.Equal(new[] { i, i + 1000 }, map[i].ToArray());
382382
}
383+
384+
[Fact]
385+
public void Remove_ShouldReturnFalse_ForAbsentDefaultKey()
386+
{
387+
var map = new CelerityMultiMap<int, int, Int32WangNaiveHasher>();
388+
389+
// default key (0) group was never created.
390+
Assert.False(map.Remove(0, 123));
391+
Assert.False(map.RemoveAll(0));
392+
}
393+
394+
[Fact]
395+
public void Remove_ShouldReturnFalse_ForAbsentValue_UnderDefaultKey()
396+
{
397+
var map = new CelerityMultiMap<int, int, Int32WangNaiveHasher>();
398+
map.Add(0, 1);
399+
map.Add(0, 2);
400+
401+
Assert.False(map.Remove(0, 999)); // value not in the default-key group
402+
Assert.True(map.Remove(0, 1));
403+
Assert.True(map.Remove(0, 2)); // empties the group -> drops the key
404+
Assert.False(map.ContainsKey(0));
405+
}
406+
407+
[Fact]
408+
public void Remove_ShouldReturnFalse_ForAbsentValue_UnderPresentKey()
409+
{
410+
var map = new CelerityMultiMap<int, int, Int32WangNaiveHasher>();
411+
map.Add(7, 1);
412+
413+
Assert.False(map.Remove(7, 999)); // key present, value absent
414+
Assert.True(map.ContainsKey(7));
415+
}
416+
417+
[Fact]
418+
public void Clear_ShouldBeNoOp_WhenAlreadyEmpty()
419+
{
420+
var map = new CelerityMultiMap<int, int, Int32WangNaiveHasher>();
421+
422+
map.Clear(); // _count == 0 early-return path
423+
424+
Assert.Empty(map.Keys);
425+
Assert.Equal(0, map.ValueCount);
426+
}
383427
}

0 commit comments

Comments
 (0)