diff --git a/src/Ramstack.HtmxToolkit/Collections/SmallDictionary.cs b/src/Ramstack.HtmxToolkit/Collections/SmallDictionary.cs new file mode 100644 index 0000000..6e21d11 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/Collections/SmallDictionary.cs @@ -0,0 +1,787 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Ramstack.HtmxToolkit.Collections; + +/// +/// Represents the implementation optimized for a small number of entries. +/// +/// +/// This type is intended for scenarios in which a dictionary typically contains only a few entries. +/// It uses compact array-based storage to reduce memory usage and the overhead of creating, populating, +/// and searching the dictionary. +/// +/// For up to entries, keys are located using a linear search. +/// When the number of entries exceeds this threshold, the entries are sorted by key and subsequent +/// lookups use a binary search. Insertions then preserve the key order. +/// +/// +/// The type of keys in the dictionary. +/// The type of values in the dictionary. +[DebuggerDisplay("Count = {Count}")] +[DebuggerTypeProxy(typeof(SmallDictionaryDebugView<,>))] +internal sealed class SmallDictionary : IDictionary, IReadOnlyDictionary where TKey : notnull +{ + /// + /// The maximum number of entries for which the dictionary uses a linear search. + /// + internal const int LinearSearchThreshold = 5; + + private readonly IComparer _comparer; + private KeyValuePair[] _items = []; + private int _count; + + /// + public int Count => _count; + + /// + public KeyCollection Keys => [with(this)]; + + /// + public ValueCollection Values => [with(this)]; + + /// + public TValue this[TKey key] + { + get + { + ref var item = ref Find(key); + if (!Unsafe.IsNullRef(ref item)) + return item.Value; + + Error_KeyNotFound(); + return default!; + } + set + { + var items = _items; + var index = IndexOf(key); + + if ((uint)index < (uint)items.Length) + { + items[index] = new KeyValuePair(items[index].Key, value); + } + else + { + Insert(~index, key, value); + } + } + } + + /// + /// Initializes a new instance of the class using the specified key comparer. + /// + /// The comparer to use when comparing keys. + /// is . + public SmallDictionary(IComparer comparer) + { + ArgumentNullException.ThrowIfNull(comparer); + _comparer = comparer; + } + + /// + public bool ContainsKey(TKey key) => + IndexOf(key) >= 0; + + /// + public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) + { + ref var item = ref Find(key); + if (!Unsafe.IsNullRef(ref item)) + { + value = item.Value; + return true; + } + + value = default!; + return false; + } + + /// + public void Add(TKey key, TValue value) + { + var index = IndexOf(key); + if (index >= 0) + { + Error_DuplicateKey(key); + return; + } + + Insert(~index, key, value); + } + + /// + public bool Remove(TKey key) + { + var index = IndexOf(key); + if (index < 0) + return false; + + RemoveAt(index); + return true; + } + + /// + public void Clear() + { + if (RuntimeHelpers.IsReferenceOrContainsReferences>()) + { + var count = _count; + if (count != 0) + Array.Clear(_items, 0, count); + + } + _count = 0; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Enumerator GetEnumerator() => + new(this); + + /// + /// Gets the underlying array used to store the dictionary entries. + /// + /// + /// The internal backing array. + /// + internal KeyValuePair[] GetUnderlyingArray() => + _items; + + #region ICollection: explicit interface implementations + + /// + bool ICollection>.IsReadOnly => false; + + /// + ICollection IDictionary.Keys => new KeyCollection(this); + + /// + ICollection IDictionary.Values => new ValueCollection(this); + + /// + IEnumerable IReadOnlyDictionary.Keys => Keys; + + /// + IEnumerable IReadOnlyDictionary.Values => Values; + + /// + void ICollection>.Add(KeyValuePair item) => + Add(item.Key, item.Value); + + /// + bool ICollection>.Contains(KeyValuePair item) + { + var index = IndexOf(item.Key); + if (index >= 0 && EqualityComparer.Default.Equals(_items[index].Value, item.Value)) + return true; + + return false; + } + + /// + void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) => + _items.AsSpan(0, _count).CopyTo(array.AsSpan(arrayIndex)); + + /// + bool ICollection>.Remove(KeyValuePair item) + { + var items = _items; + var index = IndexOf(item.Key); + + if ((uint)index >= (uint)items.Length) + return false; + + if (!EqualityComparer.Default.Equals(items[index].Value, item.Value)) + return false; + + RemoveAt(index); + return true; + } + + #endregion + + #region IEnumerable: explicit interface implementations + + /// + IEnumerator> IEnumerable>.GetEnumerator() => + GetEnumerator(); + + /// + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); + + #endregion + + /// + /// Ensures that the dictionary can hold the specified number of entries without resizing. + /// + /// The minimum number of entries that the dictionary must be able to hold. + private void EnsureCapacity(int capacity) + { + const int DefaultCapacity = 4; + + var items = _items; + var required = items.Length != 0 + ? items.Length * 2 + : DefaultCapacity; + + if ((uint)required < (uint)capacity) + required = capacity; + + if ((uint)required > Array.MaxLength) + required = Array.MaxLength; + + var destination = new KeyValuePair[required]; + var count = _count; + + if (count != 0 && (uint)count <= (uint)items.Length) + items.AsSpan(0, count).CopyTo(destination); + + _items = destination; + } + + /// + /// Inserts the specified key and value at the specified index. + /// + /// The zero-based index at which to insert the entry. + /// The key of the entry to insert. + /// The value of the entry to insert. + private void Insert(int index, TKey key, TValue value) + { + if (key == null!) + Error_NullKey(); + + var count = _count; + var items = _items; + + if (count == items.Length) + { + EnsureCapacity(count + 1); + items = _items; + } + + if (index < count) + Array.Copy(items, index, items, index + 1, count - index); + + if ((uint)index < (uint)items.Length) + items[index] = new KeyValuePair(key, value); + + // + // Once the entry count exceeds the threshold, the dictionary switches from a linear search over + // unsorted storage to a binary search over sorted storage. Subsequent insertions preserve key order. + // + count++; + if (count == LinearSearchThreshold + 1) + Array.Sort(items, 0, count, new KeyValuePairComparer(_comparer)); + + _count = count; + } + + /// + /// Finds the entry associated with the specified key. + /// + /// The key of the entry to find. + /// + /// A reference to the entry associated with , or a reference if the key is not found. + /// + private ref KeyValuePair Find(TKey key) + { + var array = _items; + var count = _count; + var comparer = _comparer; + + if (count <= LinearSearchThreshold) + { + if ((uint)count <= (uint)array.Length) + { + var items = array.AsSpan(0, count); + for (var i = 0; i < items.Length; i++) + if (comparer.Compare(items[i].Key, key) == 0) + return ref items[i]; + } + } + else + { + var lo = 0; + var hi = count - 1; + + while (lo <= hi) + { + var mi = (lo + hi) >> 1; + if ((uint)mi >= (uint)array.Length) + break; + + var result = comparer.Compare(array[mi].Key, key); + if (result == 0) + return ref array[mi]; + + if (result < 0) + { + lo = mi + 1; + } + else + { + hi = mi - 1; + } + } + } + + return ref Unsafe.NullRef>(); + } + + /// + /// Searches for the specified key and determines where it is or should be located. + /// + /// The key to locate. + /// + /// The zero-based index of if it is found; otherwise, the bitwise complement of the index + /// at which the key should be inserted. + /// + private int IndexOf(TKey key) + { + var array = _items; + var count = _count; + var comparer = _comparer; + + if (count <= LinearSearchThreshold) + { + if ((uint)count <= (uint)array.Length) + { + var items = array.AsSpan(0, count); + for (var i = 0; i < items.Length; i++) + if (comparer.Compare(items[i].Key, key) == 0) + return i; + } + + return ~count; + } + + var lo = 0; + var hi = count - 1; + + while (lo <= hi) + { + var mi = lo + (hi - lo >>> 1); + if ((uint)mi >= (uint)array.Length) + break; + + var result = comparer.Compare(array[mi].Key, key); + if (result == 0) + return mi; + + if (result < 0) + { + lo = mi + 1; + } + else + { + hi = mi - 1; + } + } + + return ~lo; + } + + /// + /// Removes the entry at the specified index. + /// + /// The zero-based index of the entry to remove. + private void RemoveAt(int index) + { + var items = _items; + var count = _count; + count--; + + if (index < count) + Array.Copy(items, index + 1, items, index, count - index); + + if (RuntimeHelpers.IsReferenceOrContainsReferences>()) + items[count] = default; + + _count = count; + } + + /// + /// Throws an exception indicating that the requested key was not found. + /// + /// Always thrown. + [DoesNotReturn] + private static void Error_KeyNotFound() => + throw new KeyNotFoundException(); + + /// + /// Throws an exception indicating that a dictionary key cannot be . + /// + /// Always thrown. + [DoesNotReturn] + private static void Error_NullKey() => + throw new ArgumentNullException(); + + /// + /// Throws an exception indicating that the specified key already exists in the dictionary. + /// + /// The duplicate key. + /// Always thrown. + [DoesNotReturn] + private static void Error_DuplicateKey(TKey key) => + throw new ArgumentException($"An item with the same key has already been added. Key: {key}", nameof(key)); + + /// + /// Throws an exception indicating that the requested operation is not supported. + /// + /// Always thrown. + [DoesNotReturn] + private static void Error_NotSupported() => + throw new NotSupportedException(); + + #region Inner type: KeyValuePairComparer + + /// + /// Represents a comparer that compares dictionary entries by key. + /// + /// The comparer to use when comparing keys. + private sealed class KeyValuePairComparer(IComparer comparer) : IComparer> + { + /// + public int Compare(KeyValuePair x, KeyValuePair y) => + comparer.Compare(x.Key, y.Key); + } + + #endregion + + #region Inner type: KeyCollection + + /// + /// Represents the collection of keys in a . + /// + /// The dictionary whose keys are exposed by the collection. + public sealed class KeyCollection(SmallDictionary dictionary) : ICollection + { + /// + public int Count => dictionary.Count; + + /// + public bool Contains(TKey item) => + dictionary.ContainsKey(item); + + /// + public Enumerator GetEnumerator() => + new(dictionary); + + #region ICollection: explicit interface implementations + + /// + bool ICollection.IsReadOnly => true; + + /// + void ICollection.Add(TKey item) => + Error_NotSupported(); + + /// + void ICollection.Clear() => + Error_NotSupported(); + + /// + bool ICollection.Remove(TKey item) + { + Error_NotSupported(); + return false; + } + + /// + void ICollection.CopyTo(TKey[] array, int arrayIndex) + { + var items = dictionary._items.AsSpan(0, dictionary._count); + var destination = array.AsSpan(arrayIndex); + + for (var index = 0; index < items.Length; index++) + destination[index] = items[index].Key; + } + + #endregion + + #region IEnumerable: explicit interface implementations + + /// + IEnumerator IEnumerable.GetEnumerator() => + GetEnumerator(); + + /// + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); + + #endregion + + #region Inner type: Enumerator + + /// + /// Represents an enumerator for the keys in a . + /// + public struct Enumerator : IEnumerator + { + private readonly KeyValuePair[] _items; + private readonly int _count; + private int _index; + + /// + public TKey Current + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _items[_index].Key; + } + + /// + /// Initializes a new instance of the structure for the specified dictionary. + /// + /// The dictionary whose keys are to be enumerated. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Enumerator(SmallDictionary dictionary) + { + _index = -1; + _count = dictionary._count; + _items = dictionary._items; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() + { + // + // JIT generates redundant mov for inline ++i (pre-increment) + // https://github.com/dotnet/runtime/issues/129532 + // + + _index++; + if (_index < _count && (uint)_index < (uint)_items.Length) + return true; + + return false; + } + + /// + public void Dispose() + { + } + + #region IEnumerator: Explicit interface implementations + + /// + object System.Collections.IEnumerator.Current => Current!; + + /// + void System.Collections.IEnumerator.Reset() => + Error_NotSupported(); + + #endregion + } + + #endregion + } + + #endregion + + #region Inner type: ValueCollection + + /// + /// Represents the collection of values in a . + /// + /// The dictionary whose values are exposed by the collection. + public sealed class ValueCollection(SmallDictionary dictionary) : ICollection + { + /// + public int Count => dictionary.Count; + + /// + public bool Contains(TValue item) + { + var items = dictionary._items.AsSpan(0, dictionary._count); + foreach (var (_, v) in items) + if (EqualityComparer.Default.Equals(v, item)) + return true; + + return false; + } + + /// + public Enumerator GetEnumerator() => + new(dictionary); + + #region ICollection: explicit interface implementations + + /// + bool ICollection.IsReadOnly => true; + + /// + void ICollection.Add(TValue item) => + Error_NotSupported(); + + /// + void ICollection.Clear() => + Error_NotSupported(); + + /// + bool ICollection.Remove(TValue item) + { + Error_NotSupported(); + return false; + } + + /// + void ICollection.CopyTo(TValue[] array, int arrayIndex) + { + var items = dictionary._items.AsSpan(0, dictionary._count); + var destination = array.AsSpan(arrayIndex); + + for (var index = 0; index < items.Length; index++) + destination[index] = items[index].Value; + } + + #endregion + + #region IEnumerable: explicit interface implementations + + /// + IEnumerator IEnumerable.GetEnumerator() => + GetEnumerator(); + + /// + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); + + #endregion + + #region Inner type: Enumerator + + /// + /// Represents an enumerator for the values in a . + /// + public struct Enumerator : IEnumerator + { + private readonly KeyValuePair[] _items; + private readonly int _count; + private int _index; + + /// + public TValue Current + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _items[_index].Value; + } + + /// + /// Initializes a new instance of the structure for the specified dictionary. + /// + /// The dictionary whose values are to be enumerated. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Enumerator(SmallDictionary dictionary) + { + _index = -1; + _count = dictionary._count; + _items = dictionary._items; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() + { + // + // JIT generates redundant mov for inline ++i (pre-increment) + // https://github.com/dotnet/runtime/issues/129532 + // + + _index++; + if (_index < _count && (uint)_index < (uint)_items.Length) + return true; + + return false; + } + + /// + public void Dispose() + { + } + + #region IEnumerator: Explicit interface implementations + + /// + object System.Collections.IEnumerator.Current => Current!; + + /// + void System.Collections.IEnumerator.Reset() => + Error_NotSupported(); + + #endregion + } + + #endregion + } + + #endregion + + #region Inner type: Enumerator + + /// + /// Represents an enumerator for the entries in a . + /// + public struct Enumerator : IEnumerator> + { + private readonly KeyValuePair[] _items; + private readonly int _count; + private int _index; + + /// + public KeyValuePair Current + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _items[_index]; + } + + /// + /// Initializes a new instance of the structure for the specified dictionary. + /// + /// The dictionary whose entries are to be enumerated. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Enumerator(SmallDictionary dictionary) + { + _index = -1; + _count = dictionary._count; + _items = dictionary._items; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MoveNext() + { + // + // JIT generates redundant mov for inline ++i (pre-increment) + // https://github.com/dotnet/runtime/issues/129532 + // + + _index++; + if (_index < _count && (uint)_index < (uint)_items.Length) + return true; + + return false; + } + + /// + public void Dispose() + { + } + + #region IEnumerator: Explicit interface implementations + + /// + object System.Collections.IEnumerator.Current => Current; + + /// + void System.Collections.IEnumerator.Reset() => + Error_NotSupported(); + + #endregion + } + + #endregion +} diff --git a/src/Ramstack.HtmxToolkit/Collections/SmallDictionaryDebugView.cs b/src/Ramstack.HtmxToolkit/Collections/SmallDictionaryDebugView.cs new file mode 100644 index 0000000..176c98f --- /dev/null +++ b/src/Ramstack.HtmxToolkit/Collections/SmallDictionaryDebugView.cs @@ -0,0 +1,57 @@ +using System.Diagnostics; + +namespace Ramstack.HtmxToolkit.Collections; + +/// +/// Represents a debugger view for the class, allowing inspection of its contents. +/// +/// The type of the keys in the dictionary. +/// The type of the values in the dictionary. +internal sealed class SmallDictionaryDebugView(SmallDictionary? dictionary) where TKey : notnull +{ + /// + /// Gets the array of key-value pairs contained in the for debugging purposes. + /// + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public DictionaryEntry[] Items + { + get + { + var pairs = dictionary?.ToArray() ?? []; + var array = new DictionaryEntry[pairs.Length]; + + for (var i = 0; i < pairs.Length; i++) + { + var (key, value) = pairs[i]; + array[i] = new DictionaryEntry(key, value); + } + + return array; + } + } + + #region Inner type: DictionaryEntry + + /// + /// Represents a class that contains the key/value pairs of the dictionary entry for displaying by a debugger. + /// + /// The key of the entry. + /// The value of the entry. + [DebuggerDisplay("{Value}", Name = "[{Key}]")] + public readonly struct DictionaryEntry(TKey key, TValue value) + { + /// + /// Gets the key of the dictionary entry. + /// + [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] + public TKey Key => key; + + /// + /// Gets the value of the dictionary entry. + /// + [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] + public TValue Value => value; + } + + #endregion +} diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs index fcdbc4e..9204d43 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs @@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Razor.TagHelpers; +using Ramstack.HtmxToolkit.Collections; + namespace Ramstack.HtmxToolkit.TagHelpers; /// @@ -27,7 +29,7 @@ public sealed class HtmxHeaderTagHelper : TagHelper [HtmlAttributeName(HeadersDictionaryName, DictionaryAttributePrefix = HeadersPrefix)] public IDictionary Headers { - get => field ??= new Dictionary(); + get => field ??= new SmallDictionary(StringComparer.OrdinalIgnoreCase); set; } diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs index 2633317..6a4503a 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs @@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Razor.TagHelpers; +using Ramstack.HtmxToolkit.Collections; + namespace Ramstack.HtmxToolkit.TagHelpers; /// @@ -27,7 +29,7 @@ public sealed class HtmxValsTagHelper : TagHelper [HtmlAttributeName(ValuesDictionaryName, DictionaryAttributePrefix = ValuesPrefix)] public IDictionary Values { - get => field ??= new Dictionary(); + get => field ??= new SmallDictionary(StringComparer.Ordinal); set; } diff --git a/tests/Ramstack.HtmxToolkit.Tests/Collections/SmallDictionaryTests.cs b/tests/Ramstack.HtmxToolkit.Tests/Collections/SmallDictionaryTests.cs new file mode 100644 index 0000000..3fe0de2 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/Collections/SmallDictionaryTests.cs @@ -0,0 +1,764 @@ +using Ramstack.HtmxToolkit.Collections; + +namespace Ramstack.HtmxToolkit.Tests.Collections; + +[TestFixture] +public class SmallDictionaryTests +{ + [Test] + public void Constructor_NewInstance_IsEmpty_Writable() + { + var dictionary = CreateDictionary(); + var collection = AsCollection(dictionary); + + Assert.Multiple(() => + { + Assert.That(dictionary.Count, Is.Zero); + Assert.That(dictionary.Keys, Is.Empty); + Assert.That(dictionary.Values, Is.Empty); + Assert.That(collection.IsReadOnly, Is.False); + Assert.That(collection, Is.Empty); + }); + } + + [Test] + public void Add_NewKey_StoresEntry_UpdatesAllViews() + { + var dictionary = CreateDictionary(); + dictionary.Add(7, "seven"); + + Assert.Multiple(() => + { + Assert.That(dictionary.Count, Is.EqualTo(1)); + Assert.That(dictionary[7], Is.EqualTo("seven")); + Assert.That(dictionary.ContainsKey(7), Is.True); + Assert.That(dictionary.Keys, Is.EquivalentTo([7])); + Assert.That(dictionary.Values, Is.EquivalentTo(["seven"])); + }); + } + + [Test] + public void Add_DuplicateKey_ThrowsArgumentException_PreservesExistingEntry() + { + var dictionary = CreateDictionary(); + dictionary.Add(7, "original"); + + Assert.Throws(() => dictionary.Add(7, "replacement")); + + Assert.Multiple(() => + { + Assert.That(dictionary.Count, Is.EqualTo(1)); + Assert.That(dictionary[7], Is.EqualTo("original")); + }); + } + + [Test] + public void Add_NullKey_ThrowsArgumentNullException_PreservesState() + { + IDictionary dictionary = new SmallDictionary(StringComparer.Ordinal); + dictionary.Add("existing", 1); + + Assert.Throws(() => dictionary.Add(null!, 0)); + + Assert.Multiple(() => + { + Assert.That(dictionary.Count, Is.EqualTo(1)); + Assert.That(dictionary["existing"], Is.EqualTo(1)); + }); + } + + [Test] + public void Indexer_Get_MissingKey_ThrowsKeyNotFoundException() + { + var dictionary = CreateDictionary(); + + Assert.Throws(() => _ = dictionary[42]); + } + + [Test] + public void Indexer_Set_MissingKey_AddsEntry() + { + var dictionary = CreateDictionary(); + dictionary[7] = "first"; + + Assert.Multiple(() => + { + Assert.That(dictionary.Count, Is.EqualTo(1)); + Assert.That(dictionary[7], Is.EqualTo("first")); + Assert.That(dictionary, Is.EquivalentTo([KeyValuePair.Create(7, "first")])); + }); + } + + [Test] + public void Indexer_Set_ExistingKey_UpdatesEntry() + { + var dictionary = CreateDictionary(); + dictionary.Add(7, "first"); + + dictionary[7] = "updated"; + + Assert.Multiple(() => + { + Assert.That(dictionary.Count, Is.EqualTo(1)); + Assert.That(dictionary[7], Is.EqualTo("updated")); + Assert.That(dictionary, Is.EquivalentTo([KeyValuePair.Create(7, "updated")])); + }); + } + + [Test] + public void Indexer_Set_NullKey_ThrowsArgumentNullException_PreservesState() + { + IDictionary dictionary = new SmallDictionary(StringComparer.Ordinal); + dictionary.Add("existing", 1); + + Assert.Throws(() => dictionary[null!] = 0); + + Assert.Multiple(() => + { + Assert.That(dictionary.Count, Is.EqualTo(1)); + Assert.That(dictionary["existing"], Is.EqualTo(1)); + }); + } + + [Test] + public void ContainsKey_ExistingKey_ReturnsTrue() + { + var dictionary = CreateDictionary(); + dictionary.Add(7, "seven"); + + Assert.That(dictionary.ContainsKey(7), Is.True); + } + + [Test] + public void ContainsKey_MissingKey_ReturnsFalse() + { + var dictionary = CreateDictionary(); + dictionary.Add(7, "seven"); + + Assert.That(dictionary.ContainsKey(42), Is.False); + } + + [Test] + public void TryGetValue_ExistingKey_ReturnsTrue() + { + var dictionary = CreateDictionary(); + dictionary.Add(7, "seven"); + + var found = dictionary.TryGetValue(7, out var existingValue); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(existingValue, Is.EqualTo("seven")); + }); + } + + [Test] + public void TryGetValue_MissingKey_ReturnsFalse() + { + var dictionary = CreateDictionary(); + dictionary.Add(7, "seven"); + + var missing = dictionary.TryGetValue(42, out var missingValue); + + Assert.Multiple(() => + { + Assert.That(missing, Is.False); + Assert.That(missingValue, Is.Null); + }); + } + + [Test] + public void Remove_ByKey_ExistingKey_ReturnsTrue_RemovesEntry() + { + var dictionary = CreateDictionary(); + dictionary.Add(1, "one"); + dictionary.Add(2, "two"); + + var removed = dictionary.Remove(1); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(dictionary.Count, Is.EqualTo(1)); + Assert.That(dictionary.ContainsKey(1), Is.False); + Assert.That(dictionary[2], Is.EqualTo("two")); + }); + } + + [Test] + public void Remove_ByKey_MissingKey_ReturnsFalse_PreservesState() + { + var dictionary = CreateDictionary(); + dictionary.Add(2, "two"); + + var removed = dictionary.Remove(1); + + Assert.Multiple(() => + { + Assert.That(removed, Is.False); + Assert.That(dictionary.Count, Is.EqualTo(1)); + Assert.That(dictionary[2], Is.EqualTo("two")); + }); + } + + [Test] + public void Remove_LinearSearchMode_ClearsUnusedReferenceSlots() + { + var dictionaryWithReferenceKeys = new SmallDictionary(StringComparer.Ordinal); + var dictionaryWithReferenceValues = new SmallDictionary(Comparer.Default); + + for (var index = 0; index < SmallDictionary.LinearSearchThreshold; index++) + { + dictionaryWithReferenceKeys.Add($"key-{index}", index); + dictionaryWithReferenceValues.Add(index, $"value-{index}"); + } + + Assert.That(dictionaryWithReferenceKeys.Remove("key-2"), Is.True); + Assert.That(dictionaryWithReferenceValues.Remove(2), Is.True); + + AssertUnusedSlotsAreCleared(dictionaryWithReferenceKeys); + AssertUnusedSlotsAreCleared(dictionaryWithReferenceValues); + } + + [Test] + public void Remove_AfterLinearSearchThreshold_ClearsUnusedReferenceSlots() + { + var dictionaryWithReferenceKeys = new SmallDictionary(StringComparer.Ordinal); + var dictionaryWithReferenceValues = new SmallDictionary(Comparer.Default); + + for (var index = 0; index < SmallDictionary.LinearSearchThreshold + 2; index++) + { + dictionaryWithReferenceKeys.Add($"key-{index}", index); + dictionaryWithReferenceValues.Add(index, $"value-{index}"); + } + + Assert.That(dictionaryWithReferenceKeys.Remove("key-3"), Is.True); + Assert.That(dictionaryWithReferenceValues.Remove(3), Is.True); + + AssertUnusedSlotsAreCleared(dictionaryWithReferenceKeys); + AssertUnusedSlotsAreCleared(dictionaryWithReferenceValues); + } + + [Test] + public void Clear_PopulatedDictionary_RemovesAllEntries_AllowsReuse() + { + var dictionary = CreateDictionary(); + for (var key = 0; key < 50; key++) + dictionary.Add(key, $"value-{key}"); + + dictionary.Clear(); + + Assert.Multiple(() => + { + Assert.That(dictionary.Count, Is.Zero); + Assert.That(dictionary.Keys, Is.Empty); + Assert.That(dictionary.Values, Is.Empty); + Assert.That(dictionary.ContainsKey(10), Is.False); + }); + + dictionary.Add(100, "reused"); + + Assert.That(dictionary, Is.EquivalentTo([KeyValuePair.Create(100, "reused")])); + } + + [Test] + public void Collection_Add_KeyValuePair_AddsEntry() + { + var dictionary = CreateDictionary(); + var collection = AsCollection(dictionary); + var pair = KeyValuePair.Create(7, "seven"); + + collection.Add(pair); + + Assert.Multiple(() => + { + Assert.That(dictionary.Count, Is.EqualTo(1)); + Assert.That(dictionary[7], Is.EqualTo("seven")); + }); + } + + [Test] + public void Collection_Contains_KeyValuePair_RequiresMatchingKeyAndValue() + { + var dictionary = CreateDictionary(); + dictionary.Add(7, "seven"); + var collection = AsCollection(dictionary); + + Assert.Multiple(() => + { + Assert.That(collection.Contains(KeyValuePair.Create(7, "seven")), Is.True); + Assert.That(collection.Contains(KeyValuePair.Create(7, "different")), Is.False); + Assert.That(collection.Contains(KeyValuePair.Create(42, "seven")), Is.False); + }); + } + + [Test] + public void Collection_Remove_MatchingKeyAndValue_ReturnsTrue_RemovesEntry() + { + var dictionary = CreateDictionary(); + var collection = AsCollection(dictionary); + + dictionary.Add(1, "one"); + dictionary.Add(2, "two"); + + var removed = collection.Remove(KeyValuePair.Create(1, "one")); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(dictionary, Is.EquivalentTo([KeyValuePair.Create(2, "two")])); + }); + } + + [Test] + public void Collection_Remove_MismatchedValue_ReturnsFalse_PreservesEntry() + { + var dictionary = CreateDictionary(); + var collection = AsCollection(dictionary); + + dictionary.Add(1, "one"); + + var removed = collection.Remove(KeyValuePair.Create(1, "different")); + + Assert.Multiple(() => + { + Assert.That(removed, Is.False); + Assert.That(dictionary, Is.EquivalentTo([KeyValuePair.Create(1, "one")])); + }); + } + + [Test] + public void Collection_Remove_MissingKey_ReturnsFalse_PreservesState() + { + var dictionary = CreateDictionary(); + var collection = AsCollection(dictionary); + + dictionary.Add(1, "one"); + + var removed = collection.Remove(KeyValuePair.Create(3, "three")); + + Assert.Multiple(() => + { + Assert.That(removed, Is.False); + Assert.That(dictionary, Is.EquivalentTo([KeyValuePair.Create(1, "one")])); + }); + } + + [Test] + public void Collection_CopyTo_WithOffset_CopiesEveryEntry() + { + var dictionary = CreateDictionary(); + dictionary.Add(3, "three"); + dictionary.Add(1, "one"); + dictionary.Add(2, "two"); + var destination = new KeyValuePair[5]; + + AsCollection(dictionary).CopyTo(destination, 1); + + Assert.Multiple(() => + { + Assert.That(destination[0], Is.EqualTo(default(KeyValuePair))); + Assert.That(destination[1..4], Is.EquivalentTo(dictionary)); + Assert.That(destination[4], Is.EqualTo(default(KeyValuePair))); + }); + } + + [Test] + public void Keys_DictionaryChanges_AreReflectedInLiveCollection() + { + var dictionary = CreateDictionary(); + var keys = dictionary.Keys; + + dictionary.Add(3, "three"); + dictionary.Add(1, "one"); + + Assert.Multiple(() => + { + Assert.That(keys.Count, Is.EqualTo(2)); + Assert.That(keys.Contains(1), Is.True); + Assert.That(keys.Contains(2), Is.False); + Assert.That(keys, Is.EquivalentTo([1, 3])); + }); + } + + [Test] + public void Keys_CopyTo_WithOffset_CopiesAllKeys() + { + var dictionary = CreateDictionary(); + dictionary.Add(3, "three"); + dictionary.Add(1, "one"); + var destination = new[] { -1, -1, -1, -1 }; + + dictionary.Keys.CopyTo(destination, 1); + + Assert.Multiple(() => + { + Assert.That(destination[0], Is.EqualTo(-1)); + Assert.That(destination[1..3], Is.EquivalentTo([1, 3])); + Assert.That(destination[3], Is.EqualTo(-1)); + }); + } + + [Test] + public void Keys_Mutation_ThrowsNotSupportedException() + { + var keys = CreateDictionary().Keys; + + Assert.That(keys.IsReadOnly, Is.True); + AssertReadOnlyCollection(keys, 5); + } + + [Test] + public void Values_DictionaryChanges_AreReflectedInLiveCollectionIncludingDuplicates() + { + var dictionary = CreateDictionary(); + var values = dictionary.Values; + dictionary.Add(3, "same"); + dictionary.Add(1, "same"); + dictionary.Add(2, "other"); + + Assert.Multiple(() => + { + Assert.That(values.Count, Is.EqualTo(3)); + Assert.That(values.Contains("same"), Is.True); + Assert.That(values.Contains("missing"), Is.False); + Assert.That(values, Is.EquivalentTo(["same", "same", "other"])); + }); + } + + [Test] + public void Values_CopyTo_WithOffset_CopiesAllValuesIncludingDuplicates() + { + var dictionary = CreateDictionary(); + dictionary.Add(3, "same"); + dictionary.Add(1, "same"); + dictionary.Add(2, "other"); + var destination = new[] { "sentinel", "sentinel", "sentinel", "sentinel", "sentinel" }; + + dictionary.Values.CopyTo(destination, 1); + + Assert.Multiple(() => + { + Assert.That(destination[0], Is.EqualTo("sentinel")); + Assert.That(destination[1..4], Is.EquivalentTo(["same", "same", "other"])); + Assert.That(destination[4], Is.EqualTo("sentinel")); + }); + } + + [Test] + public void Values_Mutation_ThrowsNotSupportedException() + { + var values = CreateDictionary().Values; + + Assert.That(values.IsReadOnly, Is.True); + AssertReadOnlyCollection(values, "new"); + } + + [Test] + public void Enumeration_Generic_ReturnsEveryEntry() + { + var dictionary = CreateDictionary(); + dictionary.Add(3, "three"); + dictionary.Add(1, "one"); + dictionary.Add(2, "two"); + + var expected = new[] + { + KeyValuePair.Create(1, "one"), + KeyValuePair.Create(2, "two"), + KeyValuePair.Create(3, "three") + }; + + Assert.That(dictionary, Is.EquivalentTo(expected)); + } + + [Test] + public void Enumeration_NonGeneric_ReturnsEveryEntry() + { + var dictionary = CreateDictionary(); + dictionary.Add(3, "three"); + dictionary.Add(1, "one"); + dictionary.Add(2, "two"); + + var expected = new[] + { + KeyValuePair.Create(1, "one"), + KeyValuePair.Create(2, "two"), + KeyValuePair.Create(3, "three") + }; + + var entries = dictionary.ToArray(); + Assert.That(entries, Is.EquivalentTo(expected)); + } + + [Test] + public void Enumerators_Reset_ThrowsNotSupportedException() + { + var dictionary = CreateDictionary(); + dictionary.Add(1, "one"); + + Assert.Multiple(() => + { + Assert.Throws(dictionary.GetEnumerator().Reset); + Assert.Throws(dictionary.Keys.GetEnumerator().Reset); + Assert.Throws(dictionary.Values.GetEnumerator().Reset); + }); + } + + [Test] + public void ConfiguredComparer_AllOperations_UseComparerForKeyIdentity() + { + IDictionary dictionary = new SmallDictionary(StringComparer.OrdinalIgnoreCase); + dictionary.Add("Alpha", 1); + + var found = dictionary.TryGetValue("ALPHA", out var value); + dictionary["alpha"] = 2; + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(value, Is.EqualTo(1)); + Assert.That(dictionary.Count, Is.EqualTo(1)); + Assert.That(dictionary.ContainsKey("aLpHa"), Is.True); + Assert.That(dictionary["ALPHA"], Is.EqualTo(2)); + Assert.That(dictionary.Keys.Contains("alpha"), Is.True); + Assert.That(AsCollection(dictionary).Contains(KeyValuePair.Create("ALPHA", 2)), Is.True); + Assert.That(() => dictionary.Add("ALPHA", 3), Throws.ArgumentException); + }); + + Assert.That(dictionary.Remove("aLpHa"), Is.True); + Assert.That(dictionary, Is.Empty); + } + + [Test] + public void DictionaryOperations_AtLinearSearchThreshold_RemainCorrect() + { + var dictionary = CreateDictionary(); + var expected = new Dictionary(); + + for (var key = SmallDictionary.LinearSearchThreshold - 1; key >= 0; key--) + { + var value = $"value-{key}"; + dictionary.Add(key, value); + expected.Add(key, value); + } + + AssertDictionaryState(dictionary, expected); + Assert.Multiple(() => + { + Assert.That(dictionary.ContainsKey(-1), Is.False); + Assert.That(dictionary.TryGetValue(100, out _), Is.False); + Assert.That(() => _ = dictionary[100], Throws.TypeOf()); + }); + + dictionary[2] = "updated"; + expected[2] = "updated"; + + Assert.That(dictionary.Remove(0), Is.True); + expected.Remove(0); + + AssertDictionaryState(dictionary, expected); + } + + [Test] + public void Add_ExceedingLinearSearchThreshold_PreservesEntriesDuringSearchTransition() + { + var dictionary = CreateDictionary(); + var expected = new Dictionary(); + var threshold = SmallDictionary.LinearSearchThreshold; + + for (var index = 0; index < threshold; index++) + { + var key = (threshold - index) * 10; + dictionary.Add(key, $"value-{key}"); + expected.Add(key, $"value-{key}"); + } + + var transitionKey = 25; + dictionary.Add(transitionKey, $"value-{transitionKey}"); + expected.Add(transitionKey, $"value-{transitionKey}"); + + Assert.That(dictionary.Count, Is.EqualTo(threshold + 1)); + AssertDictionaryState(dictionary, expected); + } + + [Test] + public void DictionaryOperations_AfterSearchTransition_RemainCorrect() + { + var dictionary = CreateDictionary(); + var expected = new Dictionary(); + var initialKeys = new[] { 50, 10, 40, 20, 30, 25 }; + + foreach (var key in initialKeys) + { + dictionary.Add(key, $"value-{key}"); + expected.Add(key, $"value-{key}"); + } + + foreach (var key in new[] { -10, 15, 35, 60 }) + { + dictionary.Add(key, $"value-{key}"); + expected.Add(key, $"value-{key}"); + } + + dictionary[30] = "updated"; + expected[30] = "updated"; + Assert.That(dictionary.Remove(10), Is.True); + + expected.Remove(10); + Assert.That(AsCollection(dictionary).Remove(KeyValuePair.Create(35, "value-35")), Is.True); + + expected.Remove(35); + AssertDictionaryState(dictionary, expected); + } + + [Test] + public void DictionaryOperations_AfterFallingBelowThreshold_RemainCorrectThroughSecondTransition() + { + var (dictionary, expected) = CreateDictionaryAfterFallingBelowThreshold(); + var threshold = SmallDictionary.LinearSearchThreshold; + + Assert.That(dictionary.Count, Is.EqualTo(threshold - 2)); + Assert.That(dictionary.Count, Is.GreaterThan(1)); + AssertDictionaryState(dictionary, expected); + + foreach (var key in new[] { 55, 15 }) + { + dictionary.Add(key, $"value-{key}"); + expected.Add(key, $"value-{key}"); + AssertDictionaryState(dictionary, expected); + } + + Assert.That(dictionary.Count, Is.EqualTo(threshold)); + + dictionary.Add(35, "value-35"); + expected.Add(35, "value-35"); + + Assert.That(dictionary.Count, Is.EqualTo(threshold + 1)); + AssertDictionaryState(dictionary, expected); + } + + [Test] + public void Add_DuplicateKeyAfterFallingBelowThreshold_ThrowsArgumentException_PreservesState() + { + var (dictionary, expected) = CreateDictionaryAfterFallingBelowThreshold(); + dictionary.Add(55, "value-55"); + expected.Add(55, "value-55"); + + Assert.That( + dictionary.Count, + Is.GreaterThan(1).And.LessThan(SmallDictionary.LinearSearchThreshold)); + + Assert.Throws(() => dictionary.Add(55, "duplicate")); + Assert.Throws(() => dictionary.Add(40, "duplicate")); + + AssertDictionaryState(dictionary, expected); + } + + [Test] + public void Resize_GrowingToOneHundredEntries_PreservesStateAcrossRepeatedResizes() + { + const int EntryCount = 100; + + var dictionary = CreateDictionary(); + var expected = new Dictionary(); + + for (var index = 0; index < EntryCount; index++) + { + var key = index * 37 % EntryCount; + var value = $"value-{key}"; + + dictionary.Add(key, value); + expected.Add(key, value); + } + + AssertDictionaryState(dictionary, expected); + + for (var key = 0; key < EntryCount; key += 3) + { + dictionary[key] = $"updated-{key}"; + expected[key] = $"updated-{key}"; + } + + for (var key = 0; key < EntryCount; key += 4) + { + Assert.That(dictionary.Remove(key), Is.True); + expected.Remove(key); + } + + AssertDictionaryState(dictionary, expected); + } + + private static IDictionary CreateDictionary() => + new SmallDictionary(Comparer.Default); + + private static (IDictionary Dictionary, Dictionary Expected) CreateDictionaryAfterFallingBelowThreshold() + { + var dictionary = CreateDictionary(); + var expected = new Dictionary(); + var threshold = SmallDictionary.LinearSearchThreshold; + + for (var index = 1; index <= threshold + 2; index++) + { + var key = index * 10; + dictionary.Add(key, $"value-{key}"); + expected.Add(key, $"value-{key}"); + } + + for (var key = 10; dictionary.Count > threshold - 2; key += 20) + { + dictionary.Remove(key); + expected.Remove(key); + } + + return (dictionary, expected); + } + + private static ICollection> AsCollection(IDictionary dictionary) where TKey : notnull => + dictionary; + + private static void AssertUnusedSlotsAreCleared(SmallDictionary dictionary) where TKey : notnull + { + var items = dictionary.GetUnderlyingArray(); + for (var index = dictionary.Count; index < items.Length; index++) + { + Assert.That( + items[index], + Is.EqualTo(default(KeyValuePair)), + $"Backing array slot {index} must be cleared when Count is {dictionary.Count}."); + } + } + + private static void AssertReadOnlyCollection(ICollection collection, T value) + { + Assert.Multiple(() => + { + Assert.That(() => collection.Add(value), Throws.TypeOf()); + Assert.That(() => collection.Remove(value), Throws.TypeOf()); + Assert.That(collection.Clear, Throws.TypeOf()); + }); + } + + private static void AssertDictionaryState(IDictionary actual, IReadOnlyDictionary expected) + { + Assert.Multiple(() => + { + Assert.That(actual.Count, Is.EqualTo(expected.Count)); + Assert.That(actual, Is.EquivalentTo(expected)); + Assert.That(actual.Keys, Is.EquivalentTo(expected.Keys)); + Assert.That(actual.Values, Is.EquivalentTo(expected.Values)); + }); + + foreach (var (key, expectedValue) in expected) + { + Assert.Multiple(() => + { + Assert.That(actual.ContainsKey(key), Is.True, $"Key {key} must be present."); + Assert.That(actual.TryGetValue(key, out var actualValue), Is.True, $"Key {key} must be found."); + Assert.That(actualValue, Is.EqualTo(expectedValue), $"TryGetValue returned a wrong value for key {key}."); + Assert.That(actual[key], Is.EqualTo(expectedValue), $"Indexer returned a wrong value for key {key}."); + }); + } + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs index 456763e..70956a2 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs @@ -7,14 +7,9 @@ public class HtmxHeaderTagHelperTests public async Task ProcessAsync_SerializesHeaders() { var output = TestHelper.CreateTagHelperOutput(); - var helper = new HtmxHeaderTagHelper - { - Headers = new Dictionary - { - ["X-Requested-With"] = "XMLHttpRequest", - ["X-Custom"] = "value" - } - }; + var helper = new HtmxHeaderTagHelper(); + helper.Headers["X-Requested-With"] = "XMLHttpRequest"; + helper.Headers["X-Custom"] = "value"; await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); var attribute = output.Attributes["hx-headers"]; @@ -26,14 +21,22 @@ public async Task ProcessAsync_SerializesHeaders() Assert.That(json["X-Custom"].GetString(), Is.EqualTo("value")); } + [Test] + public void Headers_TreatsNamesAsCaseInsensitive() + { + var helper = new HtmxHeaderTagHelper(); + helper.Headers["X-Custom"] = "first"; + helper.Headers["x-custom"] = "second"; + + Assert.That(helper.Headers, Has.Count.EqualTo(1)); + Assert.That(helper.Headers["X-CUSTOM"], Is.EqualTo("second")); + } + [Test] public async Task ProcessAsync_SerializesEmptyDictionary() { var output = TestHelper.CreateTagHelperOutput(); - var helper = new HtmxHeaderTagHelper - { - Headers = new Dictionary() - }; + var helper = new HtmxHeaderTagHelper(); await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxValsTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxValsTagHelperTests.cs index 2dec58d..33c27a9 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxValsTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxValsTagHelperTests.cs @@ -7,14 +7,9 @@ public class HtmxValsTagHelperTests public async Task ProcessAsync_SerializesValues() { var output = TestHelper.CreateTagHelperOutput(); - var helper = new HtmxValsTagHelper - { - Values = new Dictionary - { - ["category"] = "books", - ["sort"] = "title" - } - }; + var helper = new HtmxValsTagHelper(); + helper.Values["category"] = "books"; + helper.Values["sort"] = "title"; await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); var attribute = output.Attributes["hx-vals"]; @@ -30,10 +25,7 @@ public async Task ProcessAsync_SerializesValues() public async Task ProcessAsync_OmitsEmptyDictionary() { var output = TestHelper.CreateTagHelperOutput(); - var helper = new HtmxValsTagHelper - { - Values = new Dictionary() - }; + var helper = new HtmxValsTagHelper(); await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);