diff --git a/CHANGELOG.md b/CHANGELOG.md index 46783a2..ae165b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to Celerity are documented here. This project follows [Keep ### Added +- `ContainsValue(TValue? value)` on `IntDictionary`, `LongDictionary`, and `CelerityDictionary` — BCL-parity `O(n)` linear scan that returns `true` if any entry's value equals `value` under `EqualityComparer.Default`, matching `Dictionary.ContainsValue(TValue)`. The scan walks the probe table (skipping `EMPTY_KEY` / `default(TKey)` slots so the empty `default(TValue)` payload there is not mistaken for a real entry) and, when present, the out-of-band zero-key / default-key slot. No allocation on the hot path beyond the cached `EqualityComparer.Default` access. Closes #73. +- `ContainsValueTests` — coverage on all three dictionaries: empty-map false return, match in a regular slot, match found only in the zero-key / default-key / null-string-key slot, missing-value false return, default-`TValue` (`0`) lookup on both empty and populated dictionaries (regression check that `EMPTY_KEY` slots are skipped and not reported as `0` matches), `null`-`TValue` lookup on a reference-type value, duplicate values short-circuiting, post-resize correctness across a 100-entry insert from a tiny initial capacity, and post-`Remove` / post-`Clear` invalidation. - `IEnumerable` constructor on `IntSet`, `IntSet`, and `CeleritySet` (and the `IntSet` convenience subclass), mirroring the dictionary `IEnumerable>` ctor shipped in 1.1.2. Throws `ArgumentNullException` on a null source. Unlike the dictionary ctor, duplicate elements (including duplicate `default(T)` / zero entries) are silently deduplicated to match BCL `HashSet(IEnumerable)` semantics — sets do not have a duplicate-key contract. When the source implements `ICollection`, its `Count` is used to size the backing storage; otherwise the caller-supplied `capacity` parameter is used. The out-of-band `default(T)` / zero slot is populated correctly when the source contains it. Closes the last set-side API-parity gap for milestone 1.1.0 and unblocks future `IReadOnlySet` work. Closes #69. - `SetIEnumerableConstructorTests` — coverage for both sets: null-source and invalid-load-factor validation, empty sources, array / list / non-collection enumerable sources, duplicate-element silent dedupe (including duplicate zero / `null` / `default(T)` entries), zero-element / null-reference-element capture, 500-entry large-source round-trip, source-independence after construction, caller-specified capacity dominating the source count, cross-set copy (`CeleritySet` from an `IntSet` enumeration), and an open-generic `IntSet` smoke test. - `README.md` — new "Choosing a collection" section: a decision table mapping common workloads (`int`-keyed, `long`-keyed, `Guid` / `string` / other-keyed dictionaries, the two set shapes) onto the right Celerity type, plus a short note on picking a hasher and an honest "where Celerity is not the right answer today" list (concurrent access, mutable `IDictionary<,>` consumers, `FrozenDictionary`-style build-once lookups). Sits between the Quick start and Benchmarks sections so a reader who has scanned the API surface can pick the right type without spelunking `docs/api/`. Implements the "Document when to use which collection" item from issue #15. diff --git a/src/Celerity.Tests/Collections/ContainsValueTests.cs b/src/Celerity.Tests/Collections/ContainsValueTests.cs new file mode 100644 index 0000000..a42159a --- /dev/null +++ b/src/Celerity.Tests/Collections/ContainsValueTests.cs @@ -0,0 +1,288 @@ +using Celerity.Collections; +using Celerity.Hashing; + +namespace Celerity.Tests.Collections; + +public class ContainsValueTests +{ + // ---------------- IntDictionary ---------------- + + [Fact] + public void IntDictionary_EmptyMap_ReturnsFalse() + { + var map = new IntDictionary(); + Assert.False(map.ContainsValue(0)); + Assert.False(map.ContainsValue(42)); + } + + [Fact] + public void IntDictionary_FindsValueInRegularSlot() + { + var map = new IntDictionary { [1] = 100, [2] = 200, [3] = 300 }; + Assert.True(map.ContainsValue(200)); + } + + [Fact] + public void IntDictionary_ReturnsFalseForMissingValue() + { + var map = new IntDictionary { [1] = 100, [2] = 200 }; + Assert.False(map.ContainsValue(999)); + } + + [Fact] + public void IntDictionary_FindsValueOnlyInZeroKeySlot() + { + var map = new IntDictionary(); + map[0] = 777; + Assert.True(map.ContainsValue(777)); + } + + [Fact] + public void IntDictionary_DefaultValueLookup_ZeroValue() + { + // After insert, value 0 must be reachable via ContainsValue. + var map = new IntDictionary { [5] = 0, [6] = 1 }; + Assert.True(map.ContainsValue(0)); + + // But an empty dictionary must NOT report 0 — the EMPTY_KEY slots + // are filled with default(TValue) and must be skipped by the scan. + var empty = new IntDictionary(); + Assert.False(empty.ContainsValue(0)); + } + + [Fact] + public void IntDictionary_DefaultValueLookup_OnlyZeroKeyHasDefaultValue() + { + // Same trap as above but with the value sitting only in the + // out-of-band zero-key slot. + var map = new IntDictionary(); + map[0] = 0; + Assert.True(map.ContainsValue(0)); + } + + [Fact] + public void IntDictionary_NullValueLookup_ReferenceType() + { + var map = new IntDictionary(); + map[1] = "one"; + map[2] = null; + Assert.True(map.ContainsValue(null)); + + var noNulls = new IntDictionary { [1] = "one", [2] = "two" }; + Assert.False(noNulls.ContainsValue(null)); + } + + [Fact] + public void IntDictionary_DuplicateValues_ReturnsTrue() + { + var map = new IntDictionary + { + [1] = 42, + [2] = 42, + [3] = 42, + }; + Assert.True(map.ContainsValue(42)); + } + + [Fact] + public void IntDictionary_SurvivesResize() + { + var map = new IntDictionary(capacity: 4); + for (int i = 1; i <= 100; i++) + map[i] = i * 10; + + Assert.True(map.ContainsValue(770)); + Assert.False(map.ContainsValue(-1)); + } + + [Fact] + public void IntDictionary_AfterRemove_ReturnsFalse() + { + var map = new IntDictionary { [1] = 100, [2] = 200 }; + map.Remove(1); + Assert.False(map.ContainsValue(100)); + Assert.True(map.ContainsValue(200)); + } + + [Fact] + public void IntDictionary_AfterZeroKeyRemove_ReturnsFalse() + { + var map = new IntDictionary(); + map[0] = 555; + map[1] = 100; + map.Remove(0); + Assert.False(map.ContainsValue(555)); + Assert.True(map.ContainsValue(100)); + } + + // ---------------- LongDictionary ---------------- + + [Fact] + public void LongDictionary_EmptyMap_ReturnsFalse() + { + var map = new LongDictionary(); + Assert.False(map.ContainsValue(0)); + Assert.False(map.ContainsValue(42)); + } + + [Fact] + public void LongDictionary_FindsValueInRegularSlot() + { + var map = new LongDictionary { [1L] = 100, [2L] = 200, [3L] = 300 }; + Assert.True(map.ContainsValue(200)); + } + + [Fact] + public void LongDictionary_ReturnsFalseForMissingValue() + { + var map = new LongDictionary { [1L] = 100, [2L] = 200 }; + Assert.False(map.ContainsValue(999)); + } + + [Fact] + public void LongDictionary_FindsValueOnlyInZeroKeySlot() + { + var map = new LongDictionary(); + map[0L] = 777; + Assert.True(map.ContainsValue(777)); + } + + [Fact] + public void LongDictionary_DefaultValueLookup_ZeroValue() + { + var empty = new LongDictionary(); + Assert.False(empty.ContainsValue(0)); + + var map = new LongDictionary { [5L] = 0, [6L] = 1 }; + Assert.True(map.ContainsValue(0)); + } + + [Fact] + public void LongDictionary_NullValueLookup_ReferenceType() + { + var map = new LongDictionary(); + map[1L] = "one"; + map[2L] = null; + Assert.True(map.ContainsValue(null)); + } + + [Fact] + public void LongDictionary_SurvivesResize() + { + var map = new LongDictionary(capacity: 4); + for (long i = 1; i <= 100; i++) + map[i] = (int)(i * 10); + + Assert.True(map.ContainsValue(770)); + Assert.False(map.ContainsValue(-1)); + } + + // ---------------- CelerityDictionary ---------------- + + [Fact] + public void CelerityDictionary_EmptyMap_ReturnsFalse() + { + var map = new CelerityDictionary(); + Assert.False(map.ContainsValue(0)); + Assert.False(map.ContainsValue(42)); + } + + [Fact] + public void CelerityDictionary_FindsValueInRegularSlot() + { + var map = new CelerityDictionary { [1] = 100, [2] = 200, [3] = 300 }; + Assert.True(map.ContainsValue(200)); + } + + [Fact] + public void CelerityDictionary_ReturnsFalseForMissingValue() + { + var map = new CelerityDictionary { [1] = 100, [2] = 200 }; + Assert.False(map.ContainsValue(999)); + } + + [Fact] + public void CelerityDictionary_FindsValueOnlyInDefaultKeySlot_IntKey() + { + var map = new CelerityDictionary(); + map[0] = 777; + Assert.True(map.ContainsValue(777)); + } + + [Fact] + public void CelerityDictionary_FindsValueOnlyInDefaultKeySlot_NullStringKey() + { + var map = new CelerityDictionary(); + map[null!] = 777; + Assert.True(map.ContainsValue(777)); + } + + [Fact] + public void CelerityDictionary_DefaultValueLookup_ZeroValue() + { + // EMPTY_KEY slots in the probe array are populated with default(TKey) + // and default(TValue). ContainsValue must skip those. + var empty = new CelerityDictionary(); + Assert.False(empty.ContainsValue(0)); + + var map = new CelerityDictionary { [5] = 0, [6] = 1 }; + Assert.True(map.ContainsValue(0)); + } + + [Fact] + public void CelerityDictionary_NullValueLookup_ReferenceType() + { + var map = new CelerityDictionary(); + map[1] = "one"; + map[2] = null; + Assert.True(map.ContainsValue(null)); + + var noNulls = new CelerityDictionary { [1] = "one", [2] = "two" }; + Assert.False(noNulls.ContainsValue(null)); + } + + [Fact] + public void CelerityDictionary_DuplicateValues_ReturnsTrue() + { + var map = new CelerityDictionary + { + [1] = 42, + [2] = 42, + [3] = 42, + }; + Assert.True(map.ContainsValue(42)); + } + + [Fact] + public void CelerityDictionary_SurvivesResize() + { + var map = new CelerityDictionary(capacity: 4); + for (int i = 1; i <= 100; i++) + map[i] = i * 10; + + Assert.True(map.ContainsValue(770)); + Assert.False(map.ContainsValue(-1)); + } + + [Fact] + public void CelerityDictionary_AfterDefaultKeyRemove_ReturnsFalse() + { + var map = new CelerityDictionary(); + map[0] = 555; + map[1] = 100; + map.Remove(0); + Assert.False(map.ContainsValue(555)); + Assert.True(map.ContainsValue(100)); + } + + [Fact] + public void CelerityDictionary_AfterClear_ReturnsFalse() + { + var map = new CelerityDictionary { [1] = 100, [2] = 200 }; + map[0] = 300; + map.Clear(); + Assert.False(map.ContainsValue(100)); + Assert.False(map.ContainsValue(200)); + Assert.False(map.ContainsValue(300)); + } +} diff --git a/src/Celerity/Collections/CelerityDictionary.cs b/src/Celerity/Collections/CelerityDictionary.cs index 757cae9..f523859 100644 --- a/src/Celerity/Collections/CelerityDictionary.cs +++ b/src/Celerity/Collections/CelerityDictionary.cs @@ -186,6 +186,42 @@ public bool ContainsKey(TKey key) return ProbeForKey(key) >= 0; } + /// + /// Determines whether the dictionary contains the specified value. + /// + /// + /// The value to locate. Equality is determined via + /// , matching BCL + /// semantics. + /// + /// true if a matching value is found; otherwise, false. + /// + /// This operation is O(n) in the dictionary's count: it scans the + /// probe table (skipping empty slots) and, when present, the out-of-band + /// default-key slot. + /// + public bool ContainsValue(TValue? value) + { + var valueComparer = EqualityComparer.Default; + + if (_hasDefaultKey && valueComparer.Equals(_defaultKeyValue, value)) + return true; + + var keyComparer = EqualityComparer.Default; + TKey?[] keys = _keys; + TValue?[] values = _values; + for (int i = 0; i < keys.Length; i++) + { + if (!keyComparer.Equals(keys[i], default(TKey)) && + valueComparer.Equals(values[i], value)) + { + return true; + } + } + + return false; + } + /// /// Attempts to get the value associated with the specified key. /// diff --git a/src/Celerity/Collections/IntDictionary.cs b/src/Celerity/Collections/IntDictionary.cs index 5a7a0da..0ceec9c 100644 --- a/src/Celerity/Collections/IntDictionary.cs +++ b/src/Celerity/Collections/IntDictionary.cs @@ -241,6 +241,38 @@ public bool ContainsKey(int key) return ProbeForKey(key) >= 0; } + /// + /// Determines whether the dictionary contains the specified value. + /// + /// + /// The value to locate. Equality is determined via + /// , matching BCL + /// semantics. + /// + /// true if a matching value is found; otherwise, false. + /// + /// This operation is O(n) in the dictionary's count: it scans the + /// probe table (skipping empty slots) and, when present, the out-of-band + /// zero-key slot. + /// + public bool ContainsValue(TValue? value) + { + var comparer = EqualityComparer.Default; + + if (_hasZeroKey && comparer.Equals(_zeroValue, value)) + return true; + + int[] keys = _keys; + TValue?[] values = _values; + for (int i = 0; i < keys.Length; i++) + { + if (keys[i] != EMPTY_KEY && comparer.Equals(values[i], value)) + return true; + } + + return false; + } + /// /// Attempts to get the value associated with the specified key. /// diff --git a/src/Celerity/Collections/LongDictionary.cs b/src/Celerity/Collections/LongDictionary.cs index ee071e7..628ccd7 100644 --- a/src/Celerity/Collections/LongDictionary.cs +++ b/src/Celerity/Collections/LongDictionary.cs @@ -241,6 +241,38 @@ public bool ContainsKey(long key) return ProbeForKey(key) >= 0; } + /// + /// Determines whether the dictionary contains the specified value. + /// + /// + /// The value to locate. Equality is determined via + /// , matching BCL + /// semantics. + /// + /// true if a matching value is found; otherwise, false. + /// + /// This operation is O(n) in the dictionary's count: it scans the + /// probe table (skipping empty slots) and, when present, the out-of-band + /// zero-key slot. + /// + public bool ContainsValue(TValue? value) + { + var comparer = EqualityComparer.Default; + + if (_hasZeroKey && comparer.Equals(_zeroValue, value)) + return true; + + long[] keys = _keys; + TValue?[] values = _values; + for (int i = 0; i < keys.Length; i++) + { + if (keys[i] != EMPTY_KEY && comparer.Equals(values[i], value)) + return true; + } + + return false; + } + /// /// Attempts to get the value associated with the specified key. ///