From bbabdf96234040d4f1424e09888d02e2eb8041f7 Mon Sep 17 00:00:00 2001 From: rameel Date: Fri, 14 Aug 2026 01:14:48 +0500 Subject: [PATCH 01/12] Make PendingEvents ctor private --- src/Ramstack.HtmxToolkit/PendingEvents.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/PendingEvents.cs b/src/Ramstack.HtmxToolkit/PendingEvents.cs index 38f9b9e..f24f0cf 100644 --- a/src/Ramstack.HtmxToolkit/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/PendingEvents.cs @@ -11,14 +11,22 @@ namespace Ramstack.HtmxToolkit; /// Accumulates htmx events per for a single request, /// deferring header serialization until the response is about to start. /// -internal sealed class PendingEvents(HttpResponse response) +internal sealed class PendingEvents { private const string ProxyEventName = "_r_proxy"; + private readonly HttpResponse _response; private Dictionary? _receive; private Dictionary? _afterSwap; private Dictionary? _afterSettle; + /// + /// Accumulates htmx events per for a single request, + /// deferring header serialization until the response is about to start. + /// + private PendingEvents(HttpResponse response) => + _response = response; + /// /// Adds the specified events to the pending set for the given . /// When a key already exists, the duplicate event is accumulated under a _r_proxy key and replayed client-side. @@ -135,6 +143,6 @@ public static PendingEvents GetOrCreate(HttpResponse response) private void SetHeader(string name, Dictionary? events) { if (events is not null) - response.Headers[name] = JsonSerializer.Serialize(events, JsonOptions.CamelCase); + _response.Headers[name] = JsonSerializer.Serialize(events, JsonOptions.CamelCase); } } From c1e0a41dd19d25a1312f5f74e6ebecfa22d4ce62 Mon Sep 17 00:00:00 2001 From: rameel Date: Fri, 14 Aug 2026 01:17:36 +0500 Subject: [PATCH 02/12] Skip emitting hx-headers attribute when no headers set --- .../TagHelpers/HtmxHeaderTagHelper.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs index 347b8b0..98f1f4a 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs @@ -25,16 +25,24 @@ public sealed class HtmxHeaderTagHelper : TagHelper /// Gets or sets the hx-header attribute values. /// [HtmlAttributeName(HeadersDictionaryName, DictionaryAttributePrefix = HeadersPrefix)] - public IDictionary Headers { get; set; } = new Dictionary(); + public IDictionary Headers + { + get => field ??= new Dictionary(); + set; + } /// public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { - var headers = new HtmlString( - JsonSerializer.Serialize(Headers, HtmxHeaderJsonSerializerContext.Default.IDictionaryStringString)); + if (Headers is { Count: > 0 }) + { + var info = HtmxHeaderJsonSerializerContext.Default.IDictionaryStringString; + var headers = new HtmlString(JsonSerializer.Serialize(Headers, info)); - output.Attributes.SetAttribute( - new TagHelperAttribute("hx-headers", headers, HtmlAttributeValueStyle.SingleQuotes)); + output.Attributes.SetAttribute( + new TagHelperAttribute("hx-headers", headers, HtmlAttributeValueStyle.SingleQuotes) + ); + } return Task.CompletedTask; } From 71511616abaf4bbf2fb215d9d3043728284298e2 Mon Sep 17 00:00:00 2001 From: rameel Date: Fri, 14 Aug 2026 01:20:14 +0500 Subject: [PATCH 03/12] Rename ResponseHandlingEntry to ResponseHandlingConfig --- README.md | 6 +- .../TagHelpers/HtmxConfigTagHelper.cs | 6 +- ...lingEntry.cs => ResponseHandlingConfig.cs} | 5 +- ...Helper.cs => ResponseHandlingTagHelper.cs} | 55 +++++++++++-------- 4 files changed, 39 insertions(+), 33 deletions(-) rename src/Ramstack.HtmxToolkit/TagHelpers/{ResponseHandlingEntry.cs => ResponseHandlingConfig.cs} (84%) rename src/Ramstack.HtmxToolkit/TagHelpers/{ResponseHandlingEntryTagHelper.cs => ResponseHandlingTagHelper.cs} (61%) diff --git a/README.md b/README.md index efba93c..37d8cc5 100644 --- a/README.md +++ b/README.md @@ -669,9 +669,9 @@ Alternatively, you can set the entire response handling configuration directly a ```html ``` diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs index 763834f..db3ef07 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs @@ -432,12 +432,12 @@ public string? TriggerSpecsCache /// /// Gets or sets the default response handling behavior for HTTP response status codes. - /// Accepts an array of objects that define + /// Accepts an array of objects that define /// how htmx should handle responses matching specific status code patterns. /// /// Supported only in HTMX 2.x. [HtmlAttributeName("response-handling")] - public IList? ResponseHandling + public IList? ResponseHandling { get => _config.ResponseHandling; set => _config.ResponseHandling = value; @@ -565,7 +565,7 @@ internal sealed class HtmxConfigData public bool? IgnoreTitle { get; set; } public bool? ScrollIntoViewOnBoost { get; set; } public string? TriggerSpecsCache { get; set; } - public IList? ResponseHandling { get; set; } + public IList? ResponseHandling { get; set; } public bool? AllowNestedOobSwaps { get; set; } public bool? HistoryRestoreAsHxRequest { get; set; } public bool? ReportValidityOfForms { get; set; } diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingEntry.cs b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingConfig.cs similarity index 84% rename from src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingEntry.cs rename to src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingConfig.cs index 193f16d..e7d2974 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingEntry.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingConfig.cs @@ -1,10 +1,9 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// -/// Represents a single entry in the htmx response handling configuration. -/// Each entry defines how htmx should handle responses matching a specific HTTP status code pattern. +/// Represents the response handling configuration for responses matching a specific HTTP status code pattern. /// -public sealed class ResponseHandlingEntry +public sealed class ResponseHandlingConfig { /// /// Gets or sets a regular expression that will be tested against response status codes. diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingEntryTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs similarity index 61% rename from src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingEntryTagHelper.cs rename to src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs index 29faa3d..4d3c44f 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingEntryTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs @@ -9,9 +9,9 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// /// Supported only in HTMX 2.x. [HtmlTargetElement("response-handling", ParentTag = "htmx-config", TagStructure = TagStructure.WithoutEndTag)] -public sealed class ResponseHandlingEntryTagHelper : TagHelper +public sealed class ResponseHandlingTagHelper : TagHelper { - private readonly ResponseHandlingEntry _entry = new(); + private readonly ResponseHandlingConfig _config = new(); /// /// Gets or sets a regular expression that will be tested against response status codes. @@ -19,8 +19,8 @@ public sealed class ResponseHandlingEntryTagHelper : TagHelper [HtmlAttributeName("code")] public string? Code { - get => _entry.Code; - set => _entry.Code = value; + get => _config.Code; + set => _config.Code = value; } /// @@ -29,8 +29,8 @@ public string? Code [HtmlAttributeName("swap")] public bool? Swap { - get => _entry.Swap; - set => _entry.Swap = value; + get => _config.Swap; + set => _config.Swap = value; } /// @@ -39,8 +39,8 @@ public bool? Swap [HtmlAttributeName("error")] public bool? Error { - get => _entry.Error; - set => _entry.Error = value; + get => _config.Error; + set => _config.Error = value; } /// @@ -49,8 +49,8 @@ public bool? Error [HtmlAttributeName("ignore-title")] public bool? IgnoreTitle { - get => _entry.IgnoreTitle; - set => _entry.IgnoreTitle = value; + get => _config.IgnoreTitle; + set => _config.IgnoreTitle = value; } /// @@ -59,8 +59,8 @@ public bool? IgnoreTitle [HtmlAttributeName("select")] public string? Select { - get => _entry.Select; - set => _entry.Select = value; + get => _config.Select; + set => _config.Select = value; } /// @@ -69,8 +69,8 @@ public string? Select [HtmlAttributeName("target")] public string? Target { - get => _entry.Target; - set => _entry.Target = value; + get => _config.Target; + set => _config.Target = value; } /// @@ -79,22 +79,29 @@ public string? Target [HtmlAttributeName("swap-override")] public string? SwapOverride { - get => _entry.SwapOverride; - set => _entry.SwapOverride = value; + get => _config.SwapOverride; + set => _config.SwapOverride = value; } /// - public override void Process(TagHelperContext context, TagHelperOutput output) + public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { - if (context.Items.TryGetValue(typeof(HtmxConfigTagHelper), out var value)) + if (context.Items.TryGetValue(typeof(HtmxConfigTagHelper), out var value) && value is HtmxConfigTagHelper config) { - if (value is HtmxConfigTagHelper config) - { - config.ResponseHandling ??= new List(); - config.ResponseHandling.Add(_entry); - } + config.ResponseHandling ??= new List(); + config.ResponseHandling.Add(_config); + + output.SuppressOutput(); + return Task.CompletedTask; } - output.SuppressOutput(); + Error_NotNested(); + return Task.CompletedTask; + } + + private static void Error_NotNested() + { + const string Message = "The 'response-handling' tag helper can only be used inside the 'htmx-config' tag helper."; + throw new InvalidOperationException(Message); } } From 853dd8fc2e21ce9e08440a9f890a3bb535b074d1 Mon Sep 17 00:00:00 2001 From: rameel Date: Fri, 14 Aug 2026 01:26:16 +0500 Subject: [PATCH 04/12] Fix PendingEvents xml-comments --- src/Ramstack.HtmxToolkit/PendingEvents.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/PendingEvents.cs b/src/Ramstack.HtmxToolkit/PendingEvents.cs index f24f0cf..6943f34 100644 --- a/src/Ramstack.HtmxToolkit/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/PendingEvents.cs @@ -21,9 +21,9 @@ internal sealed class PendingEvents private Dictionary? _afterSettle; /// - /// Accumulates htmx events per for a single request, - /// deferring header serialization until the response is about to start. + /// Initializes a new instance of the class. /// + /// The HTTP response to which the events belong. private PendingEvents(HttpResponse response) => _response = response; From d988de991480a4db9017c2dbbbd5c56d18a4ee76 Mon Sep 17 00:00:00 2001 From: rameel Date: Fri, 14 Aug 2026 01:36:22 +0500 Subject: [PATCH 05/12] Add EnumHelper tests --- Ramstack.HtmxToolkit.slnx | 1 + .../Ramstack.HtmxToolkit.csproj | 4 + .../EnumHelperTests.cs | 73 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs diff --git a/Ramstack.HtmxToolkit.slnx b/Ramstack.HtmxToolkit.slnx index 9f1b330..92f9a49 100644 --- a/Ramstack.HtmxToolkit.slnx +++ b/Ramstack.HtmxToolkit.slnx @@ -10,4 +10,5 @@ + diff --git a/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj b/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj index bfb969f..f4d69ca 100644 --- a/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj +++ b/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj @@ -55,6 +55,10 @@ + + + + diff --git a/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs new file mode 100644 index 0000000..36196ea --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs @@ -0,0 +1,73 @@ +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class EnumHelperTests +{ + [TestCase(HtmxSwap.InnerHtml, "innerHTML")] + [TestCase(HtmxSwap.OuterHtml, "outerHTML")] + [TestCase(HtmxSwap.BeforeBegin, "beforebegin")] + [TestCase(HtmxSwap.AfterBegin, "afterbegin")] + [TestCase(HtmxSwap.BeforeEnd, "beforeend")] + [TestCase(HtmxSwap.AfterEnd, "afterend")] + [TestCase(HtmxSwap.Delete, "delete")] + [TestCase(HtmxSwap.None, "none")] + public void GetSwapValue_ReturnsExpectedString(HtmxSwap value, string expected) => + Assert.That(value.GetSwapValue(), Is.EqualTo(expected)); + + [Test] + public void GetSwapValue_ReturnsNull_ForNullValue() + { + HtmxSwap? value = null; + Assert.That(value.GetSwapValue(), Is.Null); + } + + [TestCase("innerHTML", HtmxSwap.InnerHtml)] + [TestCase("outerHTML", HtmxSwap.OuterHtml)] + [TestCase("beforebegin", HtmxSwap.BeforeBegin)] + [TestCase("afterbegin", HtmxSwap.AfterBegin)] + [TestCase("beforeend", HtmxSwap.BeforeEnd)] + [TestCase("afterend", HtmxSwap.AfterEnd)] + [TestCase("delete", HtmxSwap.Delete)] + [TestCase("none", HtmxSwap.None)] + public void ParseHtmxSwap_ParsesValue(string expression, HtmxSwap expected) => + Assert.That(EnumHelper.ParseHtmxSwap(expression), Is.EqualTo(expected)); + + [Test] + public void ParseHtmxSwap_IgnoresModifiers() => + Assert.That(EnumHelper.ParseHtmxSwap("innerHTML show:#content"), Is.EqualTo(HtmxSwap.InnerHtml)); + + [Test] + public void ParseHtmxSwap_IsCaseInsensitive() => + Assert.That(EnumHelper.ParseHtmxSwap("OuterHTML"), Is.EqualTo(HtmxSwap.OuterHtml)); + + [Test] + public void ParseHtmxSwap_ReturnsNull_ForUnknownValue() => + Assert.That(EnumHelper.ParseHtmxSwap("bogus"), Is.Null); + + [Test] + public void ParseHtmxSwap_ReturnsNull_ForNullExpression() => + Assert.That(EnumHelper.ParseHtmxSwap(null), Is.Null); + + [TestCase(HttpVerb.Get)] + [TestCase(HttpVerb.Head)] + [TestCase(HttpVerb.Post)] + [TestCase(HttpVerb.Put)] + [TestCase(HttpVerb.Delete)] + [TestCase(HttpVerb.Connect)] + [TestCase(HttpVerb.Options)] + [TestCase(HttpVerb.Trace)] + [TestCase(HttpVerb.Patch)] + public void GetHttpVerbValue_ReturnsExpectedString(HttpVerb value) => + Assert.That(value.GetHttpVerbValue(), Is.EqualTo(value.ToString().ToLower())); + + [TestCase(HtmxBinaryType.Blob)] + [TestCase(HtmxBinaryType.ArrayBuffer)] + public void GetWsBinaryTypeValue_ReturnsExpectedString(HtmxBinaryType value) => + Assert.That(value.GetWsBinaryTypeValue(), Is.EqualTo(value.ToString().ToLower())); + + [TestCase(HtmxScrollBehavior.Auto)] + [TestCase(HtmxScrollBehavior.Smooth)] + [TestCase(HtmxScrollBehavior.Instant)] + public void GetScrollBehaviorValue_ReturnsExpectedString(HtmxScrollBehavior value) => + Assert.That(value.GetScrollBehaviorValue(), Is.EqualTo(value.ToString().ToLower())); +} From 6f33b8fbd62c3851f26e11c75d4855b4fea15d7b Mon Sep 17 00:00:00 2001 From: rameel Date: Fri, 14 Aug 2026 01:43:58 +0500 Subject: [PATCH 06/12] Add missing test project file --- .../Ramstack.HtmxToolkit.Tests.csproj | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/Ramstack.HtmxToolkit.Tests/Ramstack.HtmxToolkit.Tests.csproj diff --git a/tests/Ramstack.HtmxToolkit.Tests/Ramstack.HtmxToolkit.Tests.csproj b/tests/Ramstack.HtmxToolkit.Tests/Ramstack.HtmxToolkit.Tests.csproj new file mode 100644 index 0000000..d277217 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/Ramstack.HtmxToolkit.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + preview + false + + + + + + + + + + + + + + + + + + + + + + + From 4e079815c51fa89b172b587f6f06060ed8bb7c05 Mon Sep 17 00:00:00 2001 From: rameel Date: Fri, 14 Aug 2026 01:45:45 +0500 Subject: [PATCH 07/12] Add HttpVerbArray tests --- .../HttpVerbArrayTests.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HttpVerbArrayTests.cs diff --git a/tests/Ramstack.HtmxToolkit.Tests/HttpVerbArrayTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HttpVerbArrayTests.cs new file mode 100644 index 0000000..859e9f2 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HttpVerbArrayTests.cs @@ -0,0 +1,35 @@ +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HttpVerbArrayTests +{ + [Test] + public void EnumeratesVerbs_AsLowercaseStrings() + { + var verbs = new HttpVerbArray([HttpVerb.Get, HttpVerb.Post, HttpVerb.Delete]); + Assert.That(verbs, Is.EqualTo(new[] { "get", "post", "delete" })); + } + + [Test] + public void EnumeratesEmpty_ForNullArray() + { + var verbs = new HttpVerbArray(null); + Assert.That(verbs, Is.Empty); + } + + [Test] + public void EnumeratesEmpty_ForEmptyArray() + { + var verbs = new HttpVerbArray([]); + Assert.That(verbs, Is.Empty); + } + + [Test] + public void Values_ExposesUnderlyingArray() + { + var array = new[] { HttpVerb.Put }; + var verbs = new HttpVerbArray(array); + + Assert.That(verbs.Values, Is.SameAs(array)); + } +} From 4a0a6260d40008b9acee973f377f7f39f7e198a3 Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 15 Aug 2026 18:21:42 +0500 Subject: [PATCH 08/12] Improve error message readability --- .../TagHelpers/ResponseHandlingTagHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs index 4d3c44f..c3054cd 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs @@ -101,7 +101,7 @@ public override Task ProcessAsync(TagHelperContext context, TagHelperOutput outp private static void Error_NotNested() { - const string Message = "The 'response-handling' tag helper can only be used inside the 'htmx-config' tag helper."; + const string Message = "The '' tag helper can only be used inside the '' tag helper"; throw new InvalidOperationException(Message); } } From 5cbdb79bc00a2f5c8b4897c0689b3801bb20da97 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 16 Aug 2026 14:44:57 +0500 Subject: [PATCH 09/12] Clarify variable naming --- .../TagHelpers/ResponseHandlingTagHelper.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs index c3054cd..e514b22 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/ResponseHandlingTagHelper.cs @@ -86,10 +86,10 @@ public string? SwapOverride /// public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { - if (context.Items.TryGetValue(typeof(HtmxConfigTagHelper), out var value) && value is HtmxConfigTagHelper config) + if (context.Items.TryGetValue(typeof(HtmxConfigTagHelper), out var value) && value is HtmxConfigTagHelper parent) { - config.ResponseHandling ??= new List(); - config.ResponseHandling.Add(_config); + parent.ResponseHandling ??= new List(); + parent.ResponseHandling.Add(_config); output.SuppressOutput(); return Task.CompletedTask; From ec4dff4753c21e21e62755fb7a370540c609682c Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 17 Aug 2026 02:12:55 +0500 Subject: [PATCH 10/12] Add unit tests --- .../HtmxHeaderJsonSerializerContext.cs | 4 +- .../EnumHelperTests.cs | 56 ++-- .../HtmxConfigTagHelperTests.cs | 215 ++++++++++++++++ .../HtmxHeaderTagHelperTests.cs | 43 ++++ .../HtmxRequestAttributeTests.cs | 66 +++++ .../HtmxRequestHeadersTests.cs | 90 +++++++ .../HtmxResponseAttributeTests.cs | 100 ++++++++ .../HtmxResponseHeadersTests.cs | 116 +++++++++ .../HtmxResponseTests.cs | 240 ++++++++++++++++++ .../HtmxResultTests.cs | 74 ++++++ .../HtmxUrlTagHelperTests.cs | 220 ++++++++++++++++ .../HttpRequestExtensionsTests.cs | 67 +++++ .../HttpResponseExtensionsTests.cs | 67 +++++ .../Ramstack.HtmxToolkit.Tests/JsonHelper.cs | 19 ++ .../PendingEventsTests.cs | 162 ++++++++++++ .../ResponseHandlingTagHelperTests.cs | 74 ++++++ .../Ramstack.HtmxToolkit.Tests/TestHelper.cs | 58 +++++ 17 files changed, 1648 insertions(+), 23 deletions(-) create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HtmxResultTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HtmxUrlTagHelperTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HttpResponseExtensionsTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/JsonHelper.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/ResponseHandlingTagHelperTests.cs create mode 100644 tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderJsonSerializerContext.cs index a2fc086..b780e5a 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderJsonSerializerContext.cs @@ -3,7 +3,5 @@ namespace Ramstack.HtmxToolkit.TagHelpers; [JsonSerializable(typeof(IDictionary))] -[JsonSourceGenerationOptions( - WriteIndented = false, - GenerationMode = JsonSourceGenerationMode.Serialization)] +[JsonSourceGenerationOptions(WriteIndented = false, GenerationMode = JsonSourceGenerationMode.Serialization)] internal partial class HtmxHeaderJsonSerializerContext : JsonSerializerContext; diff --git a/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs index 36196ea..19c5160 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs @@ -48,26 +48,42 @@ public void ParseHtmxSwap_ReturnsNull_ForUnknownValue() => public void ParseHtmxSwap_ReturnsNull_ForNullExpression() => Assert.That(EnumHelper.ParseHtmxSwap(null), Is.Null); - [TestCase(HttpVerb.Get)] - [TestCase(HttpVerb.Head)] - [TestCase(HttpVerb.Post)] - [TestCase(HttpVerb.Put)] - [TestCase(HttpVerb.Delete)] - [TestCase(HttpVerb.Connect)] - [TestCase(HttpVerb.Options)] - [TestCase(HttpVerb.Trace)] - [TestCase(HttpVerb.Patch)] - public void GetHttpVerbValue_ReturnsExpectedString(HttpVerb value) => - Assert.That(value.GetHttpVerbValue(), Is.EqualTo(value.ToString().ToLower())); + [TestCase(HttpVerb.Get, "get")] + [TestCase(HttpVerb.Head, "head")] + [TestCase(HttpVerb.Post, "post")] + [TestCase(HttpVerb.Put, "put")] + [TestCase(HttpVerb.Delete, "delete")] + [TestCase(HttpVerb.Connect, "connect")] + [TestCase(HttpVerb.Options, "options")] + [TestCase(HttpVerb.Trace, "trace")] + [TestCase(HttpVerb.Patch, "patch")] + public void GetHttpVerbValue_ReturnsExpectedString(HttpVerb value, string expected) => + Assert.That(value.GetHttpVerbValue(), Is.EqualTo(expected)); - [TestCase(HtmxBinaryType.Blob)] - [TestCase(HtmxBinaryType.ArrayBuffer)] - public void GetWsBinaryTypeValue_ReturnsExpectedString(HtmxBinaryType value) => - Assert.That(value.GetWsBinaryTypeValue(), Is.EqualTo(value.ToString().ToLower())); + [TestCase(HtmxBinaryType.Blob, "blob")] + [TestCase(HtmxBinaryType.ArrayBuffer, "arraybuffer")] + public void GetWsBinaryTypeValue_ReturnsExpectedString(HtmxBinaryType value, string expected) => + Assert.That(value.GetWsBinaryTypeValue(), Is.EqualTo(expected)); - [TestCase(HtmxScrollBehavior.Auto)] - [TestCase(HtmxScrollBehavior.Smooth)] - [TestCase(HtmxScrollBehavior.Instant)] - public void GetScrollBehaviorValue_ReturnsExpectedString(HtmxScrollBehavior value) => - Assert.That(value.GetScrollBehaviorValue(), Is.EqualTo(value.ToString().ToLower())); + [TestCase(HtmxScrollBehavior.Auto, "auto")] + [TestCase(HtmxScrollBehavior.Smooth, "smooth")] + [TestCase(HtmxScrollBehavior.Instant, "instant")] + public void GetScrollBehaviorValue_ReturnsExpectedString(HtmxScrollBehavior value, string expected) => + Assert.That(value.GetScrollBehaviorValue(), Is.EqualTo(expected)); + + [Test] + public void GetSwapValue_ReturnsNone_ForUndefinedValue() => + Assert.That(((HtmxSwap)999).GetSwapValue(), Is.EqualTo("none")); + + [Test] + public void GetHttpVerbValue_ReturnsPatch_ForUndefinedValue() => + Assert.That(((HttpVerb)999).GetHttpVerbValue(), Is.EqualTo("patch")); + + [Test] + public void GetScrollBehaviorValue_ReturnsInstant_ForUndefinedValue() => + Assert.That(((HtmxScrollBehavior)999).GetScrollBehaviorValue(), Is.EqualTo("instant")); + + [Test] + public void GetWsBinaryTypeValue_ReturnsArrayBuffer_ForUndefinedValue() => + Assert.That(((HtmxBinaryType)999).GetWsBinaryTypeValue(), Is.EqualTo("arraybuffer")); } diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs new file mode 100644 index 0000000..0d73752 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxConfigTagHelperTests.cs @@ -0,0 +1,215 @@ +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxConfigTagHelperTests +{ + [Test] + public async Task ProcessAsync_RendersMetaTag() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = new HtmxConfigTagHelper(new StubAntiforgery()); + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(output.TagName, Is.EqualTo("meta")); + Assert.That(output.TagMode, Is.EqualTo(TagMode.SelfClosing)); + Assert.That(output.Attributes["name"]!.Value, Is.EqualTo("htmx-config")); + } + + [Test] + public async Task ProcessAsync_RemovesHtmxConfigAttribute_FromMetaTag() + { + var attributes = new TagHelperAttributeList + { + new TagHelperAttribute("htmx-config") + }; + + var output = TestHelper.CreateTagHelperOutput("meta", attributes); + var helper = new HtmxConfigTagHelper(new StubAntiforgery()); + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext("meta", attributes), output); + + Assert.That(output.Attributes["htmx-config"], Is.Null); + } + + [Test] + public async Task ProcessAsync_WithDefaults_SerializesEmptyObject() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = new HtmxConfigTagHelper(new StubAntiforgery()); + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(GetContent(output), Is.EqualTo("{}")); + } + + [Test] + public async Task ProcessAsync_SerializesConfiguredOptions() + { + var helper = new HtmxConfigTagHelper(new StubAntiforgery()) + { + HistoryEnabled = false, + HistoryCacheSize = 42, + RefreshOnHistoryMiss = true, + DefaultSwapStyle = HtmxSwap.OuterHtml, + DefaultSwapDelay = 250, + DefaultSettleDelay = 300, + IncludeIndicatorStyles = false, + IndicatorClass = "my-indicator", + RequestClass = "my-request", + AddedClass = "my-added", + SwappingClass = "my-swapping", + SettlingClass = "my-settling", + AllowEval = false, + AllowScriptTags = false, + InlineScriptNonce = "nonce", + InlineStyleNonce = "style-nonce", + AttributesToSettle = ["class", "style"], + UseTemplateFragments = true, + WsReconnectDelay = "exponential", + WsBinaryType = HtmxBinaryType.ArrayBuffer, + DisableSelector = "[data-disable]", + WithCredentials = true, + DisableInheritance = true, + Timeout = 5000, + ScrollBehavior = HtmxScrollBehavior.Instant, + DefaultFocusScroll = true, + GetCacheBusterParam = true, + GlobalViewTransitions = true, + MethodsThatUseUrlParams = [HttpVerb.Get, HttpVerb.Delete], + SelfRequestsOnly = true, + IgnoreTitle = true, + ScrollIntoViewOnBoost = false, + TriggerSpecsCache = "{}", + AllowNestedOobSwaps = true, + HistoryRestoreAsHxRequest = false, + ReportValidityOfForms = true + }; + + var output = TestHelper.CreateTagHelperOutput(); + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + var json = JsonHelper.ParseJson(GetContent(output)); + + Assert.That(json["historyEnabled"].GetBoolean(), Is.False); + Assert.That(json["historyCacheSize"].GetInt32(), Is.EqualTo(42)); + Assert.That(json["refreshOnHistoryMiss"].GetBoolean(), Is.True); + Assert.That(json["defaultSwapStyle"].GetString(), Is.EqualTo("outerHTML")); + Assert.That(json["defaultSwapDelay"].GetInt32(), Is.EqualTo(250)); + Assert.That(json["defaultSettleDelay"].GetInt32(), Is.EqualTo(300)); + Assert.That(json["includeIndicatorStyles"].GetBoolean(), Is.False); + Assert.That(json["indicatorClass"].GetString(), Is.EqualTo("my-indicator")); + Assert.That(json["requestClass"].GetString(), Is.EqualTo("my-request")); + Assert.That(json["addedClass"].GetString(), Is.EqualTo("my-added")); + Assert.That(json["swappingClass"].GetString(), Is.EqualTo("my-swapping")); + Assert.That(json["settlingClass"].GetString(), Is.EqualTo("my-settling")); + Assert.That(json["allowEval"].GetBoolean(), Is.False); + Assert.That(json["allowScriptTags"].GetBoolean(), Is.False); + Assert.That(json["inlineScriptNonce"].GetString(), Is.EqualTo("nonce")); + Assert.That(json["inlineStyleNonce"].GetString(), Is.EqualTo("style-nonce")); + Assert.That(json["attributesToSettle"].GetRawText(), Is.EqualTo("[\"class\",\"style\"]")); + Assert.That(json["useTemplateFragments"].GetBoolean(), Is.True); + Assert.That(json["wsReconnectDelay"].GetString(), Is.EqualTo("exponential")); + Assert.That(json["wsBinaryType"].GetString(), Is.EqualTo("arraybuffer")); + Assert.That(json["disableSelector"].GetString(), Is.EqualTo("[data-disable]")); + Assert.That(json["withCredentials"].GetBoolean(), Is.True); + Assert.That(json["disableInheritance"].GetBoolean(), Is.True); + Assert.That(json["timeout"].GetInt32(), Is.EqualTo(5000)); + Assert.That(json["scrollBehavior"].GetString(), Is.EqualTo("instant")); + Assert.That(json["defaultFocusScroll"].GetBoolean(), Is.True); + Assert.That(json["getCacheBusterParam"].GetBoolean(), Is.True); + Assert.That(json["globalViewTransitions"].GetBoolean(), Is.True); + Assert.That(json["methodsThatUseUrlParams"].GetRawText(), Is.EqualTo("[\"get\",\"delete\"]")); + Assert.That(json["selfRequestsOnly"].GetBoolean(), Is.True); + Assert.That(json["ignoreTitle"].GetBoolean(), Is.True); + Assert.That(json["scrollIntoViewOnBoost"].GetBoolean(), Is.False); + Assert.That(json["triggerSpecsCache"].GetString(), Is.EqualTo("{}")); + Assert.That(json["allowNestedOobSwaps"].GetBoolean(), Is.True); + Assert.That(json["historyRestoreAsHxRequest"].GetBoolean(), Is.False); + Assert.That(json["reportValidityOfForms"].GetBoolean(), Is.True); + } + + [Test] + public async Task ProcessAsync_IncludeAntiForgeryToken_SerializesTokens() + { + var antiforgery = new StubAntiforgery(); + var httpContext = new DefaultHttpContext(); + var helper = new HtmxConfigTagHelper(antiforgery) + { + IncludeAntiForgeryToken = true, + ViewContext = new ViewContext + { + HttpContext = httpContext + } + }; + + var output = TestHelper.CreateTagHelperOutput(); + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(antiforgery.StoredHttpContext, Is.SameAs(httpContext)); + + var json = JsonHelper.ParseJson(GetContent(output)); + var antiForgery = json["antiForgery"]; + + Assert.That(antiForgery.GetProperty("requestToken").GetString(), Is.EqualTo("request-token")); + Assert.That(antiForgery.GetProperty("headerName").GetString(), Is.EqualTo("RequestVerificationToken")); + Assert.That(antiForgery.GetProperty("cookieToken").GetString(), Is.EqualTo("cookie-token")); + } + + [Test] + public async Task ProcessAsync_ExecutesChildContent_CollectsResponseHandlingEntries() + { + var items = new Dictionary(); + var context = TestHelper.CreateTagHelperContext("htmx-config", null, items); + var child = new ResponseHandlingTagHelper + { + Code = "404", + Swap = false + }; + + var output = new TagHelperOutput("htmx-config", [], async (_, _) => + { + await child.ProcessAsync(context, TestHelper.CreateTagHelperOutput("response-handling")); + return new DefaultTagHelperContent(); + }); + + var helper = new HtmxConfigTagHelper(new StubAntiforgery()); + await helper.ProcessAsync(context, output); + + Assert.That(helper.ResponseHandling!.Count, Is.EqualTo(1)); + Assert.That(helper.ResponseHandling[0].Code, Is.EqualTo("404")); + Assert.That(helper.ResponseHandling[0].Swap, Is.False); + } + + private static string GetContent(TagHelperOutput output) => + output.Attributes["content"]!.Value!.ToString()!; + + #region Inner type: StubAntiforgery + + private sealed class StubAntiforgery : IAntiforgery + { + public HttpContext? StoredHttpContext { get; private set; } + + public AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext) + { + StoredHttpContext = httpContext; + return new AntiforgeryTokenSet( + requestToken: "request-token", + cookieToken: "cookie-token", + formFieldName: "__RequestVerificationToken", + headerName: "RequestVerificationToken"); + } + + public AntiforgeryTokenSet GetTokens(HttpContext httpContext) => throw new NotSupportedException(); + public Task IsRequestValidAsync(HttpContext httpContext) => throw new NotSupportedException(); + public void SetCookieTokenAndHeader(HttpContext httpContext) => throw new NotSupportedException(); + public Task ValidateRequestAsync(HttpContext httpContext) => throw new NotSupportedException(); + } + + #endregion +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs new file mode 100644 index 0000000..456763e --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxHeaderTagHelperTests.cs @@ -0,0 +1,43 @@ +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxHeaderTagHelperTests +{ + [Test] + public async Task ProcessAsync_SerializesHeaders() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = new HtmxHeaderTagHelper + { + Headers = new Dictionary + { + ["X-Requested-With"] = "XMLHttpRequest", + ["X-Custom"] = "value" + } + }; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-headers"]; + + Assert.That(attribute, Is.Not.Null); + + var json = JsonHelper.ParseJson(attribute!.Value.ToString()!); + Assert.That(json["X-Requested-With"].GetString(), Is.EqualTo("XMLHttpRequest")); + Assert.That(json["X-Custom"].GetString(), Is.EqualTo("value")); + } + + [Test] + public async Task ProcessAsync_SerializesEmptyDictionary() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = new HtmxHeaderTagHelper + { + Headers = new Dictionary() + }; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + var attribute = output.Attributes["hx-headers"]; + Assert.That(attribute, Is.Null); + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs new file mode 100644 index 0000000..e6761d8 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Routing; + +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxRequestAttributeTests +{ + [Test] + public void Accept_ReturnsFalse_ForNonHtmxRequest() + { + var req = new HtmxRequestAttribute(); + var ctx = CreateContext(TestHelper.CreateHttpContext()); + + Assert.That(req.Accept(ctx), Is.False); + } + + [Test] + public void Accept_ReturnsTrue_ForHtmxRequest_WhenBoostedIsNull() + { + var req = new HtmxRequestAttribute { Boosted = null }; + var ctx = CreateContext(TestHelper.CreateHtmxRequestContext()); + + Assert.That(req.Accept(ctx), Is.True); + } + + [Test] + public void Accept_ReturnsTrue_ForBoostedRequest_WhenBoostedIsTrue() + { + var req = new HtmxRequestAttribute { Boosted = true }; + var ctx = CreateContext(TestHelper.CreateHtmxRequestContext(boosted: true)); + + Assert.That(req.Accept(ctx), Is.True); + } + + [Test] + public void Accept_ReturnsFalse_ForNonBoostedRequest_WhenBoostedIsTrue() + { + var req = new HtmxRequestAttribute { Boosted = true }; + var ctx = CreateContext(TestHelper.CreateHtmxRequestContext()); + + Assert.That(req.Accept(ctx), Is.False); + } + + [Test] + public void Accept_ReturnsTrue_ForNonBoostedRequest_WhenBoostedIsFalse() + { + var req = new HtmxRequestAttribute { Boosted = false }; + var ctx = CreateContext(TestHelper.CreateHtmxRequestContext()); + + Assert.That(req.Accept(ctx), Is.True); + } + + [Test] + public void Accept_ReturnsFalse_ForBoostedRequest_WhenBoostedIsFalse() + { + var req = new HtmxRequestAttribute { Boosted = false }; + var ctx = CreateContext(TestHelper.CreateHtmxRequestContext(boosted: true)); + + Assert.That(req.Accept(ctx), Is.False); + } + + private static ActionConstraintContext CreateContext(HttpContext httpContext) => + new() { RouteContext = new RouteContext(httpContext) }; +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs new file mode 100644 index 0000000..f0a0f2d --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs @@ -0,0 +1,90 @@ +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxRequestHeadersTests +{ + [Test] + public void Boosted_IsTrue_WhenHeaderIsTrue() + { + var headers = CreateHeaders((HtmxRequestHeaderNames.Boosted, "true")); + Assert.That(headers.Boosted, Is.True); + } + + [Test] + public void Boosted_IsFalse_WhenHeaderIsFalse() + { + var headers = CreateHeaders((HtmxRequestHeaderNames.Boosted, "false")); + Assert.That(headers.Boosted, Is.False); + } + + [Test] + public void Boosted_IsFalse_WhenHeaderAbsent() + { + var headers = CreateHeaders(); + Assert.That(headers.Boosted, Is.False); + } + + [Test] + public void CurrentUrl_ReturnsHeaderValue() + { + var headers = CreateHeaders((HtmxRequestHeaderNames.CurrentUrl, "https://example.com/")); + Assert.That(headers.CurrentUrl, Is.EqualTo("https://example.com/")); + } + + [Test] + public void HistoryRestoreRequest_ReturnsHeaderValue() + { + var headers = CreateHeaders((HtmxRequestHeaderNames.HistoryRestoreRequest, "true")); + Assert.That(headers.HistoryRestoreRequest, Is.True); + } + + [Test] + public void Prompt_ReturnsHeaderValue() + { + var headers = CreateHeaders((HtmxRequestHeaderNames.Prompt, "yes")); + Assert.That(headers.Prompt, Is.EqualTo("yes")); + } + + [Test] + public void Request_ReturnsHeaderValue() + { + var headers = CreateHeaders((HtmxRequestHeaderNames.Request, "true")); + Assert.That(headers.Request, Is.True); + } + + [Test] + public void Target_ReturnsHeaderValue() + { + var headers = CreateHeaders((HtmxRequestHeaderNames.Target, "content")); + Assert.That(headers.Target, Is.EqualTo("content")); + } + + [Test] + public void TriggerName_ReturnsHeaderValue() + { + var headers = CreateHeaders((HtmxRequestHeaderNames.TriggerName, "btn")); + Assert.That(headers.TriggerName, Is.EqualTo("btn")); + } + + [Test] + public void Trigger_ReturnsHeaderValue() + { + var headers = CreateHeaders((HtmxRequestHeaderNames.Trigger, "btn-id")); + Assert.That(headers.Trigger, Is.EqualTo("btn-id")); + } + + [Test] + public void StringProperties_ReturnNull_WhenHeaderAbsent() + { + var headers = CreateHeaders(); + + Assert.That(headers.CurrentUrl, Is.Null); + Assert.That(headers.Prompt, Is.Null); + Assert.That(headers.Target, Is.Null); + Assert.That(headers.TriggerName, Is.Null); + Assert.That(headers.Trigger, Is.Null); + } + + private static HtmxRequestHeaders CreateHeaders(params (string Name, string Value)[] headers) => + TestHelper.CreateHttpContext(headers).Request.GetHtmxHeaders(); +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs new file mode 100644 index 0000000..cf711bb --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; + +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxResponseAttributeTests +{ + [Test] + public void OnResultExecuting_SetsHeaders_ForHtmxRequest() + { + var ctx = TestHelper.CreateHtmxRequestContext(); + var res = new HtmxResponseAttribute + { + Refresh = true, + Reswap = HtmxSwap.OuterHtml, + Retarget = "#content", + Reselect = "#list" + }; + + res.OnResultExecuting(CreateContext(ctx)); + + Assert.That(ctx.Response.Headers[HtmxResponseHeaderNames.Refresh], Is.EqualTo("true")); + Assert.That(ctx.Response.Headers[HtmxResponseHeaderNames.Reswap], Is.EqualTo("outerHTML")); + Assert.That(ctx.Response.Headers[HtmxResponseHeaderNames.Retarget], Is.EqualTo("#content")); + Assert.That(ctx.Response.Headers[HtmxResponseHeaderNames.Reselect], Is.EqualTo("#list")); + } + + [Test] + public void OnResultExecuting_SetsNoHeaders_ForNonHtmxRequest() + { + var ctx = TestHelper.CreateHttpContext(); + var res = new HtmxResponseAttribute { Retarget = "#content" }; + + res.OnResultExecuting(CreateContext(ctx)); + + Assert.That(ctx.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Retarget), Is.False); + } + + [Test] + public void OnResultExecuting_SetsStopPollingStatusCode_WhenStopPolling() + { + var ctx = TestHelper.CreateHtmxRequestContext(); + var res = new HtmxResponseAttribute { StopPolling = true }; + + res.OnResultExecuting(CreateContext(ctx)); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(HtmxResponse.StopPollingStatusCode)); + } + + [Test] + public void OnResultExecuting_DoesNotSetStatusCode_WhenNotStopPolling() + { + var ctx = TestHelper.CreateHtmxRequestContext(); + var res = new HtmxResponseAttribute(); + + res.OnResultExecuting(CreateContext(ctx)); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(200)); + Assert.That(ctx.Response.StatusCode, Is.Not.EqualTo(HtmxResponse.StopPollingStatusCode)); + } + + [Test] + public void Properties_RoundTrips() + { + var res = new HtmxResponseAttribute + { + Refresh = true, + Reswap = HtmxSwap.OuterHtml, + Retarget = "#content", + Reselect = "#list" + }; + + Assert.That(res.Refresh, Is.True); + Assert.That(res.Reswap, Is.EqualTo(HtmxSwap.OuterHtml)); + Assert.That(res.Retarget, Is.EqualTo("#content")); + Assert.That(res.Reselect, Is.EqualTo("#list")); + } + + [Test] + public void ReswapExpression_RoundTrips() + { + var res = new HtmxResponseAttribute { ReswapExpression = "outerHTML ignoreTitle:true" }; + + Assert.That(res.Reswap, Is.EqualTo(HtmxSwap.OuterHtml)); + Assert.That(res.ReswapExpression, Is.EqualTo("outerHTML ignoreTitle:true")); + } + + private static ResultExecutingContext CreateContext(HttpContext context) + { + return new ResultExecutingContext( + new ActionContext(context, new RouteData(), new ActionDescriptor()), + new List(), + new EmptyResult(), + new object()); + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs new file mode 100644 index 0000000..6695a65 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Http; + +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxResponseHeadersTests +{ + [Test] + public void Properties_RoundTrips() + { + var context = new DefaultHttpContext(); + var headers = context.Response.GetHtmxHeaders(); + + headers.Location = "/foo"; + headers.PushUrl = "/bar"; + headers.Redirect = "/baz"; + headers.ReplaceUrl = "/qux"; + headers.Retarget = "#target"; + headers.Reselect = "#select"; + headers.Reswap = HtmxSwap.OuterHtml; + headers.Trigger = new Dictionary { ["message"] = "hello" }; + headers.TriggerAfterSwap = new Dictionary { ["swapped"] = true }; + headers.TriggerAfterSettle = new Dictionary { ["settled"] = 1 }; + + Assert.That(headers.Location, Is.EqualTo("/foo")); + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Location], Is.EqualTo("/foo")); + Assert.That(headers.PushUrl, Is.EqualTo("/bar")); + Assert.That(headers.Redirect, Is.EqualTo("/baz")); + Assert.That(headers.ReplaceUrl, Is.EqualTo("/qux")); + Assert.That(headers.Retarget, Is.EqualTo("#target")); + Assert.That(headers.Reselect, Is.EqualTo("#select")); + Assert.That(headers.Reswap, Is.EqualTo(HtmxSwap.OuterHtml)); + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Reswap], Is.EqualTo("outerHTML")); + Assert.That(headers.Trigger.Count, Is.EqualTo(1)); + Assert.That(headers.Trigger["message"], Is.EqualTo("hello")); + Assert.That(headers.TriggerAfterSwap.Count, Is.EqualTo(1)); + Assert.That(headers.TriggerAfterSwap["swapped"], Is.True); + Assert.That(headers.TriggerAfterSettle.Count, Is.EqualTo(1)); + Assert.That(headers.TriggerAfterSettle["settled"], Is.EqualTo(1)); + } + + [Test] + public void Refresh_WhenTrue_SetsTrue() + { + var context = new DefaultHttpContext(); + var headers = context.Response.GetHtmxHeaders(); + + headers.Refresh = true; + + Assert.That(headers.Refresh, Is.True); + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Refresh], Is.EqualTo("true")); + } + + [Test] + public void Refresh_IsFalse_ByDefault() + { + var context = new DefaultHttpContext(); + var headers = context.Response.GetHtmxHeaders(); + + Assert.That(headers.Refresh, Is.False); + } + + [Test] + public void Reswap_IsNull_WhenHeaderAbsent() + { + var context = new DefaultHttpContext(); + var headers = context.Response.GetHtmxHeaders(); + + Assert.That(headers.Reswap, Is.Null); + } + + [Test] + public void Reswap_IsNull_WhenHeaderUnknown() + { + var context = new DefaultHttpContext(); + var headers = context.Response.GetHtmxHeaders(); + + context.Response.Headers[HtmxResponseHeaderNames.Reswap] = "bogus"; + + Assert.That(headers.Reswap, Is.Null); + } + + [Test] + public void ReswapExpression_KeepsFullExpression_WhileReswapParsesOnlyStyle() + { + var context = new DefaultHttpContext(); + var headers = context.Response.GetHtmxHeaders(); + + headers.ReswapExpression = "outerHTML show:top"; + + Assert.That(headers.Reswap, Is.EqualTo(HtmxSwap.OuterHtml)); + Assert.That(headers.ReswapExpression, Is.EqualTo("outerHTML show:top")); + } + + [Test] + public void SettingNull_DoesNotAddHeader() + { + var context = new DefaultHttpContext(); + var headers = context.Response.GetHtmxHeaders(); + + headers.Location = null!; + + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Location), Is.False); + } + + [Test] + public void Trigger_IsNull_WhenNotSet() + { + var context = new DefaultHttpContext(); + var headers = context.Response.GetHtmxHeaders(); + + Assert.That(headers.Trigger, Is.Null); + Assert.That(headers.TriggerAfterSwap, Is.Null); + Assert.That(headers.TriggerAfterSettle, Is.Null); + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs new file mode 100644 index 0000000..0adf4a3 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -0,0 +1,240 @@ +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxResponseTests +{ + [Test] + public void Location_SetsHeader() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Location("/foo")); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Location], Is.EqualTo("/foo")); + } + + [Test] + public void Location_WithContext_SerializesJson() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Location("/bar", new AjaxContext + { + Source = "button", + Event = "click", + Target = "#content", + Swap = HtmxSwap.OuterHtml, + Select = "#list" + })); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); + var json = JsonHelper.ParseJson(header); + + Assert.That(json["path"].GetString(), Is.EqualTo("/bar")); + Assert.That(json["source"].GetString(), Is.EqualTo("button")); + Assert.That(json["event"].GetString(), Is.EqualTo("click")); + Assert.That(json["target"].GetString(), Is.EqualTo("#content")); + Assert.That(json["swap"].GetString(), Is.EqualTo("outerHTML")); + Assert.That(json["select"].GetString(), Is.EqualTo("#list")); + } + + [Test] + public void Location_WithContext_OmitsNullProperties() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Location("/bar", new AjaxContext())); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); + var json = JsonHelper.ParseJson(header); + + Assert.That(json["path"].GetString(), Is.EqualTo("/bar")); + Assert.That(json.ContainsKey("source"), Is.False); + Assert.That(json.ContainsKey("swap"), Is.False); + } + + [Test] + public void Location_WithContext_SerializesHandlerValuesAndHeaders() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Location("/bar", new AjaxContext + { + Handler = "handleResponse", + Values = new Dictionary { ["id"] = 42 }, + Headers = new Dictionary { ["X-Test"] = "abc" } + })); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); + var json = JsonHelper.ParseJson(header); + + Assert.That(json["handler"].GetString(), Is.EqualTo("handleResponse")); + Assert.That(json["values"].GetProperty("id").GetInt32(), Is.EqualTo(42)); + Assert.That(json["headers"].GetProperty("X-Test").GetString(), Is.EqualTo("abc")); + } + + [Test] + public void PushUrl_SetsHeader() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.PushUrl("/foo")); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.PushUrl], Is.EqualTo("/foo")); + } + + [Test] + public void PreventPushUrl_SetsFalse() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.PreventPushUrl()); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.PushUrl], Is.EqualTo("false")); + } + + [Test] + public void Redirect_SetsHeader() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Redirect("/foo")); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Redirect], Is.EqualTo("/foo")); + } + + [Test] + public void Refresh_SetsTrue() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Refresh()); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Refresh], Is.EqualTo("true")); + } + + [Test] + public void ReplaceUrl_SetsHeader() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.ReplaceUrl("/foo")); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.ReplaceUrl], Is.EqualTo("/foo")); + } + + [Test] + public void PreventReplaceUrl_SetsFalse() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.PreventReplaceUrl()); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.ReplaceUrl], Is.EqualTo("false")); + } + + [Test] + public void Reswap_Enum_SetsHeader() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Reswap(HtmxSwap.BeforeBegin)); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Reswap], Is.EqualTo("beforebegin")); + } + + [Test] + public void Reswap_String_SetsHeader() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Reswap("innerHTML show:top")); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Reswap], Is.EqualTo("innerHTML show:top")); + } + + [Test] + public void Retarget_SetsHeader() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Retarget("#content")); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Retarget], Is.EqualTo("#content")); + } + + [Test] + public void Reselect_SetsHeader() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Reselect("#list")); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Reselect], Is.EqualTo("#list")); + } + + [Test] + public void StopPolling_SetsStatusCode286() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.StopPolling()); + + Assert.That(context.Response.StatusCode, Is.EqualTo(HtmxResponse.StopPollingStatusCode)); + } + + [Test] + public void StopPolling_WithFalseCondition_DoesNotChangeStatusCode() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.StopPolling(false)); + + Assert.That(context.Response.StatusCode, Is.Not.EqualTo(HtmxResponse.StopPollingStatusCode)); + } + + [Test] + public void StopPolling_WithTrueCondition_SetsStatusCode286() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.StopPolling(true)); + + Assert.That(context.Response.StatusCode, Is.EqualTo(HtmxResponse.StopPollingStatusCode)); + } + + [Test] + public void TriggerEvent_SetsReceiveTrigger() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.TriggerEvent("notify", "hello")); + + var events = context.Response.GetHtmxHeaders().Trigger!; + Assert.That(events.Count, Is.EqualTo(1)); + Assert.That(events["notify"], Is.EqualTo("hello")); + } + + [Test] + public void TriggerEvent_WithTiming_SetsAfterSettleTrigger() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.TriggerEvent("done", HtmxTriggerTiming.AfterSettle)); + + var events = context.Response.GetHtmxHeaders().TriggerAfterSettle!; + + Assert.That(events.Count, Is.EqualTo(1)); + Assert.That(events["done"], Is.EqualTo("")); + } + + [Test] + public void TriggerEvent_WithComplexDetail_SerializesJson() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.TriggerEvent("message", new { Id = 42, Text = "hi" })); + + PendingEvents.GetOrCreate(context.Response).Flush(); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(); + var json = JsonHelper.ParseJson(header); + + Assert.That(json["message"].GetProperty("id").GetInt32(), Is.EqualTo(42)); + Assert.That(json["message"].GetProperty("text").GetString(), Is.EqualTo("hi")); + } + + [Test] + public void TriggerEvents_SetsMultipleEvents() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.TriggerEvents( + new Dictionary { ["a"] = 1, ["b"] = 2 }, + HtmxTriggerTiming.AfterSwap)); + + var events = context.Response.GetHtmxHeaders().TriggerAfterSwap!; + Assert.That(events.Count, Is.EqualTo(2)); + Assert.That(events["a"], Is.EqualTo(1)); + Assert.That(events["b"], Is.EqualTo(2)); + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResultTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResultTests.cs new file mode 100644 index 0000000..eb59949 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResultTests.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxResultTests +{ + [Test] + public async Task ExecuteResultAsync_ConfiguresHeaders_ForHtmxRequest() + { + var context = TestHelper.CreateHtmxRequestContext(); + var recording = new RecordingResult(); + var result = new HtmxResult(recording, r => r.Retarget("#content")); + + await result.ExecuteResultAsync(CreateActionContext(context)); + + Assert.That(recording.Executed, Is.True); + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Retarget], Is.EqualTo("#content")); + } + + [Test] + public async Task ExecuteResultAsync_SkipsConfiguration_ForNonHtmxRequest() + { + var context = TestHelper.CreateHttpContext(); + var recording = new RecordingResult(); + var result = new HtmxResult(recording, r => r.Retarget("#content")); + + await result.ExecuteResultAsync(CreateActionContext(context)); + + Assert.That(recording.Executed, Is.True); + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Retarget), Is.False); + } + + [Test] + public async Task ExecuteResultAsync_PassesStateToConfigure() + { + var context = TestHelper.CreateHtmxRequestContext(); + var recording = new RecordingResult(); + var result = new HtmxResult(recording, (r, s) => r.Redirect(s), "/foo"); + + await result.ExecuteResultAsync(CreateActionContext(context)); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Redirect], Is.EqualTo("/foo")); + } + + [Test] + public async Task HtmxExtension_WithState_CallbackReceivesProvidedState() + { + var context = TestHelper.CreateHtmxRequestContext(); + var recording = new RecordingResult(); + var result = recording.Htmx((r, s) => r.Redirect(s), "/foo"); + + await result.ExecuteResultAsync(CreateActionContext(context)); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Redirect], + Is.EqualTo("/foo")); + } + + private static ActionContext CreateActionContext(HttpContext httpContext) => + new(httpContext, new Microsoft.AspNetCore.Routing.RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor()); + + private sealed class RecordingResult : IActionResult + { + public bool Executed { get; private set; } + + public Task ExecuteResultAsync(ActionContext context) + { + Executed = true; + return Task.CompletedTask; + } + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxUrlTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxUrlTagHelperTests.cs new file mode 100644 index 0000000..edf544f --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxUrlTagHelperTests.cs @@ -0,0 +1,220 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.AspNetCore.Routing; + +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxUrlTagHelperTests +{ + [Test] + public async Task ProcessAsync_Action_UsesActionLink() + { + var (urlHelper, output) = await RunAsync(t => + { + t.Action = "Index"; + t.Controller = "Home"; + }); + + Assert.That(urlHelper.ActionCalls, Is.EqualTo(1)); + Assert.That(urlHelper.RouteUrlCalls, Is.Zero); + Assert.That(urlHelper.ActionContextReceived!.Action, Is.EqualTo("Index")); + Assert.That(urlHelper.ActionContextReceived.Controller, Is.EqualTo("Home")); + + var attribute = output.Attributes["hx-get"]; + Assert.That(attribute, Is.Not.Null); + Assert.That(attribute!.Value, Is.EqualTo(TestUrlHelper.ActionUrl)); + } + + [Test] + public async Task ProcessAsync_Route_UsesRouteLink() + { + var (urlHelper, output) = await RunAsync(t => t.Route = "default"); + + Assert.That(urlHelper.RouteUrlCalls, Is.EqualTo(1)); + Assert.That(urlHelper.ActionCalls, Is.Zero); + Assert.That(urlHelper.RouteContextReceived!.RouteName, Is.EqualTo("default")); + + var attribute = output.Attributes["hx-get"]; + Assert.That(attribute, Is.Not.Null); + Assert.That(attribute!.Value, Is.EqualTo(TestUrlHelper.RouteUrlResult)); + } + + [Test] + public async Task ProcessAsync_Page_UsesPageLink() + { + var (urlHelper, output) = await RunAsync(t => + { + t.Page = "/Account/Login"; + t.PageHandler = "External"; + }); + + Assert.That(urlHelper.RouteUrlCalls, Is.EqualTo(1)); + Assert.That(urlHelper.ActionCalls, Is.Zero); + + var values = (RouteValueDictionary)urlHelper.RouteContextReceived!.Values!; + Assert.That(values["page"], Is.EqualTo("/Account/Login")); + Assert.That(values["handler"], Is.EqualTo("External")); + + var attribute = output.Attributes["hx-get"]; + Assert.That(attribute, Is.Not.Null); + Assert.That(attribute!.Value, Is.EqualTo(TestUrlHelper.RouteUrlResult)); + } + + [Test] + public async Task ProcessAsync_PassesRouteValuesAndArea_ToAction() + { + var (urlHelper, _) = await RunAsync(t => + { + t.Action = "Edit"; + t.Area = "Admin"; + t.RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["id"] = "42" + }; + }); + + var values = (RouteValueDictionary)urlHelper.ActionContextReceived!.Values!; + Assert.That(values["area"], Is.EqualTo("Admin")); + Assert.That(values["id"], Is.EqualTo("42")); + } + + [Test] + public async Task ProcessAsync_UsesExistingHtmxMethod() + { + var attributes = new TagHelperAttributeList + { + new TagHelperAttribute("hx-post") + }; + + var (_, output) = await RunAsync(attributes: attributes); + + Assert.That(output.Attributes["hx-get"], Is.Null); + Assert.That(output.Attributes["hx-post"]!.Value, Is.EqualTo(TestUrlHelper.ActionUrl)); + } + + [TestCase("hx-get", "hx-post")] + [TestCase("hx-get", "hx-delete")] + [TestCase("hx-get", "hx-put")] + [TestCase("hx-get", "hx-patch")] + [TestCase("hx-post", "hx-delete")] + [TestCase("hx-post", "hx-put")] + [TestCase("hx-post", "hx-patch")] + [TestCase("hx-delete", "hx-put")] + [TestCase("hx-delete", "hx-patch")] + [TestCase("hx-put", "hx-patch")] + public void ProcessAsync_MultipleMethods_Throws(string method1, string method2) + { + var attributes = new TagHelperAttributeList + { + new TagHelperAttribute(method1), + new TagHelperAttribute(method2) + }; + + Assert.ThrowsAsync( + async () => await RunAsync(attributes: attributes)); + } + + [TestCase("Route", "Action")] + [TestCase("Route", "Controller")] + [TestCase("Route", "Page")] + [TestCase("Route", "PageHandler")] + [TestCase("Action", "Page")] + [TestCase("Action", "PageHandler")] + [TestCase("Controller", "Page")] + [TestCase("Controller", "PageHandler")] + public void ProcessAsync_MutuallyExclusiveLinkTypes_Throws(string first, string second) + { + Assert.ThrowsAsync( + async () => await RunAsync(t => + { + SetLinkType(t, first); + SetLinkType(t, second); + })); + + static void SetLinkType(HtmxUrlTagHelper tagHelper, string linkType) + { + switch (linkType) + { + case "Route": tagHelper.Route = "route"; break; + case "Action": tagHelper.Action = "action"; break; + case "Controller": tagHelper.Controller = "controller"; break; + case "Page": tagHelper.Page = "page"; break; + case "PageHandler": tagHelper.PageHandler = "handler"; break; + } + } + } + + private static async Task<(TestUrlHelper UrlHelper, TagHelperOutput Output)> RunAsync(Action? configure = null, TagHelperAttributeList? attributes = null) + { + var urlHelper = new TestUrlHelper(); + var factory = new TestUrlHelperFactory(urlHelper); + + var tagHelper = new HtmxUrlTagHelper(factory) + { + ViewContext = new ViewContext() + }; + + configure?.Invoke(tagHelper); + + var output = TestHelper.CreateTagHelperOutput("div", attributes); + await tagHelper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + return (urlHelper, output); + } + + + private sealed class TestUrlHelperFactory(IUrlHelper helper) : IUrlHelperFactory + { + public IUrlHelper GetUrlHelper(ActionContext context) => + helper; + } + + #region Inner type: TestUrlHelper + + private sealed class TestUrlHelper : IUrlHelper + { + public const string ActionUrl = "/action"; + public const string RouteUrlResult = "/route"; + + public int ActionCalls { get; private set; } + public int RouteUrlCalls { get; private set; } + + public UrlActionContext? ActionContextReceived { get; private set; } + public UrlRouteContext? RouteContextReceived { get; private set; } + + public ActionContext ActionContext { get; } = new( + new DefaultHttpContext(), + new RouteData(), + new ActionDescriptor()); + + public string Action(UrlActionContext actionContext) + { + ActionCalls++; + ActionContextReceived = actionContext; + return ActionUrl; + } + + public string Content(string? contentPath) => + throw new NotSupportedException(); + + public bool IsLocalUrl(string? url) => + throw new NotSupportedException(); + + public string Link(string? routeName, object? values) => + throw new NotSupportedException(); + + public string RouteUrl(UrlRouteContext routeContext) + { + RouteUrlCalls++; + RouteContextReceived = routeContext; + return RouteUrlResult; + } + } + + #endregion +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs new file mode 100644 index 0000000..3ab08d6 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs @@ -0,0 +1,67 @@ +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HttpRequestExtensionsTests +{ + [Test] + public void IsHtmxRequest_ReturnsFalse_WhenHeaderAbsent() + { + var context = TestHelper.CreateHttpContext(); + Assert.That(context.Request.IsHtmxRequest(), Is.False); + } + + [Test] + public void IsHtmxRequest_ReturnsTrue_WhenHeaderPresent() + { + var context = TestHelper.CreateHttpContext((HtmxRequestHeaderNames.Request, "true")); + Assert.That(context.Request.IsHtmxRequest(), Is.True); + } + + [Test] + public void IsHtmxRequest_ReturnsTrue_RegardlessOfHeaderValue() + { + var context = TestHelper.CreateHttpContext((HtmxRequestHeaderNames.Request, "")); + Assert.That(context.Request.IsHtmxRequest(), Is.True); + } + + [Test] + public void IsHtmxRequest_WithOutParameter_ReturnsHeaders() + { + var context = TestHelper.CreateHttpContext( + (HtmxRequestHeaderNames.Request, "true"), + (HtmxRequestHeaderNames.Target, "foo")); + + Assert.That(context.Request.IsHtmxRequest(out var headers), Is.True); + Assert.That(headers.Target, Is.EqualTo("foo")); + } + + [Test] + public void IsHtmxBoosted_ReturnsFalse_WhenHeaderAbsent() + { + var context = TestHelper.CreateHttpContext((HtmxRequestHeaderNames.Request, "true")); + Assert.That(context.Request.IsHtmxBoosted(), Is.False); + } + + [Test] + public void IsHtmxBoosted_ReturnsTrue_WhenHeaderIsTrue() + { + var context = TestHelper.CreateHttpContext((HtmxRequestHeaderNames.Boosted, "true")); + Assert.That(context.Request.IsHtmxBoosted(), Is.True); + } + + [Test] + public void IsHtmxBoosted_ReturnsFalse_WhenHeaderIsFalse() + { + var context = TestHelper.CreateHttpContext((HtmxRequestHeaderNames.Boosted, "false")); + Assert.That(context.Request.IsHtmxBoosted(), Is.False); + } + + [Test] + public void IsHtmxBoosted_WithOutParameter_ReturnsHeaders() + { + var context = TestHelper.CreateHttpContext((HtmxRequestHeaderNames.Boosted, "true")); + + Assert.That(context.Request.IsHtmxBoosted(out var headers), Is.True); + Assert.That(headers.Boosted, Is.True); + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HttpResponseExtensionsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HttpResponseExtensionsTests.cs new file mode 100644 index 0000000..617653b --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HttpResponseExtensionsTests.cs @@ -0,0 +1,67 @@ +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HttpResponseExtensionsTests +{ + [Test] + public void Htmx_RunsConfigure_ForHtmxRequest() + { + var context = TestHelper.CreateHtmxRequestContext(); + var invoked = false; + + context.Response.Htmx(r => + { + invoked = true; + r.Retarget("#content"); + }); + + Assert.That(invoked, Is.True); + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Retarget], Is.EqualTo("#content")); + } + + [Test] + public void Htmx_DoesNotRunConfigure_ForNonHtmxRequest() + { + var context = TestHelper.CreateHttpContext(); + var invoked = false; + + context.Response.Htmx(r => + { + invoked = true; + r.Retarget("#content"); + }); + + Assert.That(invoked, Is.False); + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Retarget), Is.False); + } + + [Test] + public void Htmx_WithState_PassesStateToConfigure() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx((r, s) => r.Retarget(s), "/target"); + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Retarget], Is.EqualTo("/target")); + } + + [Test] + public void Htmx_WithState_DoesNotRunConfigure_ForNonHtmxRequest() + { + var invoked = false; + var context = TestHelper.CreateHttpContext(); + context.Response.Htmx((_, _) => invoked = true, 0); + + Assert.That(invoked, Is.False); + } + + [Test] + public void GetHtmxHeaders_ReturnsHeadersForResponse() + { + var context = TestHelper.CreateHtmxRequestContext(); + var headers = context.Response.GetHtmxHeaders(); + + headers.Redirect = "/foo"; + + Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Redirect], Is.EqualTo("/foo")); + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/JsonHelper.cs b/tests/Ramstack.HtmxToolkit.Tests/JsonHelper.cs new file mode 100644 index 0000000..c122c49 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/JsonHelper.cs @@ -0,0 +1,19 @@ +using System.Text.Json; + +namespace Ramstack.HtmxToolkit.Tests; + +/// +/// Represents a helper class providing methods for JSON manipulation in test scenarios. +/// +public static class JsonHelper +{ + /// + /// Deserializes a JSON object into a dictionary for key/value assertions. + /// + /// The JSON object to deserialize. + /// + /// The deserialized key/value pairs. + /// + public static Dictionary ParseJson(string json) => + JsonSerializer.Deserialize>(json)!; +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs new file mode 100644 index 0000000..da3d21e --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs @@ -0,0 +1,162 @@ +using System.Text.Json.Nodes; + +using Microsoft.AspNetCore.Http; + +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class PendingEventsTests +{ + [Test] + public void AddEvents_StoresDistinctKeys() + { + var context = new DefaultHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvents( + HtmxTriggerTiming.Receive, + CreateDictionary(("a", 1), ("b", 2))); + + var events = pending.GetEvents(HtmxTriggerTiming.Receive)!; + + Assert.That(events.Count, Is.EqualTo(2)); + Assert.That(events["a"], Is.EqualTo(1)); + Assert.That(events["b"], Is.EqualTo(2)); + } + + [Test] + public void AddEvents_AccumulatesDuplicateKeys_UnderProxy() + { + var context = new DefaultHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvents( + HtmxTriggerTiming.Receive, + CreateDictionary(("message", "first"))); + + pending.AddEvents( + HtmxTriggerTiming.Receive, + CreateDictionary(("message", "second"))); + + var events = pending.GetEvents(HtmxTriggerTiming.Receive)!; + + Assert.That(events.Count, Is.EqualTo(2)); + Assert.That(events["message"], Is.EqualTo("first")); + Assert.That(events["_r_proxy"], Is.EqualTo(new[] { KeyValuePair.Create("message", "second") })); + } + + [Test] + public void AddEvents_TracksTimingsIndependently() + { + var context = new DefaultHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("r", 1))); + pending.AddEvents(HtmxTriggerTiming.AfterSettle, CreateDictionary(("t", 3))); + pending.AddEvents(HtmxTriggerTiming.AfterSwap, CreateDictionary(("s", 2))); + + Assert.That(pending.GetEvents(HtmxTriggerTiming.Receive), Is.EqualTo(CreateDictionary(("r", 1)))); + Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSettle), Is.EqualTo(CreateDictionary(("t", 3)))); + Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSwap), Is.EqualTo(CreateDictionary(("s", 2)))); + } + + [Test] + public void GetEvents_ReturnsNull_WhenNothingRegistered() + { + var context = new DefaultHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + Assert.That(pending.GetEvents(HtmxTriggerTiming.Receive), Is.Null); + Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSettle), Is.Null); + Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSwap), Is.Null); + } + + [Test] + public void SetEvents_ReplacesExisting() + { + var context = new DefaultHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("old", 1))); + pending.SetEvents(HtmxTriggerTiming.Receive, CreateDictionary(("new", 2))); + + Assert.That( + pending.GetEvents(HtmxTriggerTiming.Receive), + Is.EqualTo(CreateDictionary(("new", 2)))); + } + + [Test] + public void Flush_WritesCamelCaseJsonToHeaders() + { + var context = new DefaultHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvents( + HtmxTriggerTiming.Receive, + CreateDictionary(("message", "hello"))); + + pending.Flush(); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger]; + + Assert.That(header.ToString(), Is.EqualTo("{\"message\":\"hello\"}")); + } + + [Test] + public void Flush_WritesOnlyRegisteredTimings() + { + var context = new DefaultHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvents( + HtmxTriggerTiming.AfterSwap, + CreateDictionary(("swapped", true))); + + pending.Flush(); + + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Trigger), Is.False); + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSwap), Is.True); + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSettle), Is.False); + } + + [Test] + public void Flush_SerializesNullEventDetail_AsNull() + { + var context = new DefaultHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("e", null!))); + pending.Flush(); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(); + Assert.That(header, Is.EqualTo("{\"e\":null}")); + } + + [Test] + public void GetOrCreate_ReturnsSameInstance() + { + var context = new DefaultHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + Assert.That(pending, Is.SameAs(PendingEvents.GetOrCreate(context.Response))); + Assert.That(PendingEvents.TryGet(context.Response), Is.SameAs(pending)); + } + + [Test] + public void GetOrCreate_RegistersInHttpContextItems() + { + var context = new DefaultHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + Assert.That(context.Items[typeof(PendingEvents)], Is.SameAs(pending)); + } + + private static Dictionary CreateDictionary(params (string, object)[] parameters) + { + var dictionary = new Dictionary(); + foreach (var (k, v) in parameters) + dictionary[k] = v; + + return dictionary; + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/ResponseHandlingTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/ResponseHandlingTagHelperTests.cs new file mode 100644 index 0000000..7678067 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/ResponseHandlingTagHelperTests.cs @@ -0,0 +1,74 @@ +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class ResponseHandlingTagHelperTests +{ + [Test] + public async Task ProcessAsync_AddsEntryToConfig() + { + var config = new HtmxConfigTagHelper(null!); + var items = new Dictionary + { + [typeof(HtmxConfigTagHelper)] = config + }; + + var entry = new ResponseHandlingTagHelper + { + Code = "4[0-9]{2}", + Swap = false, + Error = true, + IgnoreTitle = true, + Select = "#content", + Target = "#target", + SwapOverride = "innerHTML" + }; + + var output = TestHelper.CreateTagHelperOutput("response-handling"); + await entry.ProcessAsync( + TestHelper.CreateTagHelperContext("response-handling", null, items), + output); + + Assert.That(config.ResponseHandling!.Count, Is.EqualTo(1)); + + var actual = config.ResponseHandling![0]; + Assert.That(actual.Code, Is.EqualTo("4[0-9]{2}")); + Assert.That(actual.Swap, Is.False); + Assert.That(actual.Error, Is.True); + Assert.That(actual.IgnoreTitle, Is.True); + Assert.That(actual.Select, Is.EqualTo("#content")); + Assert.That(actual.Target, Is.EqualTo("#target")); + Assert.That(actual.SwapOverride, Is.EqualTo("innerHTML")); + } + + [Test] + public async Task ProcessAsync_SuppressesOutput() + { + var config = new HtmxConfigTagHelper(null!); + var items = new Dictionary + { + [typeof(HtmxConfigTagHelper)] = config + }; + + var entry = new ResponseHandlingTagHelper(); + var output = TestHelper.CreateTagHelperOutput("response-handling"); + + await entry.ProcessAsync( + TestHelper.CreateTagHelperContext("response-handling", null, items), + output); + + Assert.That(output.TagName, Is.Null); + Assert.That(output.IsContentModified, Is.True); + } + + [Test] + public void ProcessAsync_WithoutConfig_Throws() + { + var entry = new ResponseHandlingTagHelper(); + var output = TestHelper.CreateTagHelperOutput("response-handling"); + + Assert.ThrowsAsync( + async () => await entry.ProcessAsync( + TestHelper.CreateTagHelperContext("response-handling"), + output)); + } +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs b/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs new file mode 100644 index 0000000..ada0cee --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Ramstack.HtmxToolkit.Tests; + +/// +/// Represents a helper class for building instances in tests. +/// +internal static class TestHelper +{ + /// + /// Creates a with the given request headers. + /// + /// The request headers as key/value pairs. + /// + /// The configured . + /// + public static HttpContext CreateHttpContext(params (string Name, string Value)[] headers) + { + var context = new DefaultHttpContext(); + + foreach (var (name, value) in headers) + context.Request.Headers[name] = value; + + return context; + } + + /// + /// Creates a whose request is an htmx request. + /// + /// to mark the request as boosted; + /// otherwise, . + /// + /// The configured . + /// + public static HttpContext CreateHtmxRequestContext(bool boosted = false) + { + var context = new DefaultHttpContext(); + context.Request.Headers[HtmxRequestHeaderNames.Request] = "true"; + + if (boosted) + context.Request.Headers[HtmxRequestHeaderNames.Boosted] = "true"; + + return context; + } + + /// + /// Creates a with the given tag name, attributes and items. + /// + public static TagHelperContext CreateTagHelperContext(string tagName = "div", TagHelperAttributeList? attributes = null, IDictionary? items = null) => + new(tagName, attributes ?? [], items ?? new Dictionary(), "test"); + + /// + /// Creates a with empty child content. + /// + public static TagHelperOutput CreateTagHelperOutput(string tagName = "div", TagHelperAttributeList? attributes = null) => + new(tagName, attributes ?? [], (_, _) => Task.FromResult(new DefaultTagHelperContent())); +} From fbb74f07b45bf04fad77267773eff272bf8ade37 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 17 Aug 2026 02:19:19 +0500 Subject: [PATCH 11/12] Add tests to github workflow --- .github/workflows/{build.yml => test.yml} | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) rename .github/workflows/{build.yml => test.yml} (68%) diff --git a/.github/workflows/build.yml b/.github/workflows/test.yml similarity index 68% rename from .github/workflows/build.yml rename to .github/workflows/test.yml index 7516b48..0a903c3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Build +name: Build & Test on: pull_request: @@ -6,14 +6,14 @@ on: types: [opened, synchronize, reopened, ready_for_review] jobs: - publish: + build-and-test: name: Build packages runs-on: ubuntu-latest steps: - name: Install .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.x - name: Checkout uses: actions/checkout@v4 @@ -25,3 +25,9 @@ jobs: - name: Build (Release) run: dotnet build -c Release + + - name: Test (Debug) + run: dotnet test -c Debug --no-build + + - name: Test (Release) + run: dotnet test -c Release --no-build From d183f9e9d4b65048d24bee60cd89ca02e0124ef8 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 17 Aug 2026 02:23:13 +0500 Subject: [PATCH 12/12] Rename job name --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a903c3..0787c70 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: jobs: build-and-test: - name: Build packages + name: Build & Test runs-on: ubuntu-latest steps: - name: Install .NET