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);