Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Agent instructions

## Test organization

Name test files and classes after the **capability or use case** under test (e.g.
`PartialTests`, `DynamicTests`, `HelperTests`, `HandlebarsSpecCoverageTests`,
`CustomConfigurationTests`) — not after the GitHub issue that prompted them.

- Do not create `IssueNNNTests.cs` files or an `Issues/` folder.
- When a test is added because of a bug report, put it in the existing capability
file that owns that behavior. If none fits, create a new capability-named file
rather than an issue-numbered one.
- If the originating issue is worth referencing, link it in a `//` comment above
the test method or class — not in the file or class name.

Issue numbers are meaningless once the bug is fixed; the capability is what
matters long-term, and grouping by capability keeps related coverage
discoverable together instead of scattered across one-off files.
60 changes: 60 additions & 0 deletions source/Handlebars.Test/BasicIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Dynamic;
using System.IO;
using System.Linq;
using System.Reflection;
using HandlebarsDotNet.Compiler;
Expand Down Expand Up @@ -2232,6 +2233,65 @@ public void HtmlEncoderCompatibilityIntegration_LateChangeConfig(bool useLegacyH
Assert.Equal(expected, actual);
}

[Fact]
public void BasicCompileAndRenderWithoutByRefDelegate()
{
// Validates the scenario that fails on Mono when byref delegates are used
var h = Handlebars.Create();
var render = h.Compile("{{input}}");
var result = render(new { input = 42 });
Assert.Equal("42", result);
}

[Fact]
public void BlockHelperCompileAndRenderWithoutByRefDelegate()
{
// Block helpers also exercise TemplateDelegate compilation
var h = Handlebars.Create();
h.RegisterHelper("loud", (writer, options, context, arguments) =>
{
options.Template(writer, context);
});
var render = h.Compile("{{#loud}}hello{{/loud}}");
var result = render(new { });
Assert.Equal("hello", result);
}

[Fact]
public void CompileWithTextReaderProducesOutput()
{
var h = Handlebars.Create();
using var reader = new StringReader("Hello {{name}}!");
var template = h.Compile(reader);
using var writer = new StringWriter();
template(writer, new { name = "World" }, null);
Assert.Equal("Hello World!", writer.ToString());
}

[Fact]
public void SharedEnvironmentCompileAndRenderNeverSilentlyFails()
{
// Regression: broad exception swallowing in observable collection publish paths
// could prevent helper/template registrations from propagating in restricted
// runtime environments (e.g. .NET 8 Windows Service), causing silent empty output.
var h = Handlebars.Create();
var shared = h.CreateSharedEnvironment();

var template = shared.Compile("Hello {{name}}!");
var result = template(new { name = "World" });
Assert.Equal("Hello World!", result);
}

[Fact]
public void EmptyTemplateProducesEmptyStringNotNull()
{
var h = Handlebars.Create();
var template = h.Compile("");
var result = template(new { });
Assert.NotNull(result);
Assert.Equal("", result);
}

[Fact]
public void ChainedPathIteratorHelper()
{
Expand Down
77 changes: 77 additions & 0 deletions source/Handlebars.Test/CustomConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,83 @@ public void SnakeCaseInputModelNaming()
Assert.Equal(ExpectedOutput, output);
}

[Fact]
public void UpperCamelCaseResolverDoesNotBreakEachIteration()
{
var template = HandlebarsInstance.Compile("{{#each items}}{{name}} {{/each}}");
var data = new { items = new[] { new { name = "Alice" }, new { name = "Bob" } } };
Assert.Equal("Alice Bob ", template(data));
}

[Fact]
public void UpperCamelCaseResolverDoesNotBreakEachWithList()
{
var template = HandlebarsInstance.Compile("{{#each items}}{{name}} {{/each}}");
var data = new
{
items = new List<object>
{
new { name = "Alice" },
new { name = "Bob" }
}
};
Assert.Equal("Alice Bob ", template(data));
}

[Fact]
public void UpperCamelCaseResolverDoesNotBreakEachWithAtIndex()
{
var template = HandlebarsInstance.Compile("{{#each items}}{{@index}}:{{name}} {{/each}}");
var data = new { items = new[] { new { name = "Alice" }, new { name = "Bob" } } };
Assert.Equal("0:Alice 1:Bob ", template(data));
}

[Fact]
public void UpperCamelCaseResolverDoesNotBreakEachWithAtFirst()
{
var template = HandlebarsInstance.Compile("{{#each items}}{{#if @first}}first:{{/if}}{{name}} {{/each}}");
var data = new { items = new[] { new { name = "Alice" }, new { name = "Bob" } } };
Assert.Equal("first:Alice Bob ", template(data));
}

[Fact]
public void UpperCamelCaseResolverWorksWithNestedPropertyAccess()
{
var template = HandlebarsInstance.Compile("{{#each items}}{{address.city}} {{/each}}");
var data = new
{
items = new[]
{
new { address = new { city = "New York" } },
new { address = new { city = "London" } }
}
};
Assert.Equal("New York London ", template(data));
}

[Fact]
public void UpperCamelCaseResolverWorksWithStringArray()
{
var template = HandlebarsInstance.Compile("{{#each items}}{{this}} {{/each}}");
var data = new { items = new[] { "Alice", "Bob" } };
Assert.Equal("Alice Bob ", template(data));
}

[Fact]
public void UpperCamelCaseResolverWorksWithNestedEach()
{
var template = HandlebarsInstance.Compile("{{#each groups}}{{#each members}}{{name}} {{/each}}{{/each}}");
var data = new
{
groups = new[]
{
new { members = new[] { new { name = "Alice" }, new { name = "Bob" } } },
new { members = new[] { new { name = "Carol" } } }
}
};
Assert.Equal("Alice Bob Carol ", template(data));
}

#endregion

#region Custom IOutputEncoding
Expand Down
123 changes: 123 additions & 0 deletions source/Handlebars.Test/DynamicTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

Expand Down Expand Up @@ -52,6 +53,18 @@ public void DynamicObjectBasicIterationTest()
Assert.Equal("foo: 1\nbar: hello world\n", output);
}

[Fact]
public void DynamicObjectCaseSensitiveLookupWithSameSpellingVariables()
{
var h = Handlebars.Create();
var template = h.Compile("{{TEST}} {{test}}");
dynamic data = new ExpandoObject();
data.TEST = "Upper";
data.test = "Lower";
var result = template(data);
Assert.Equal("Upper Lower", result);
}

[Fact]
public void JsonTestIfTruthy()
{
Expand Down Expand Up @@ -288,6 +301,116 @@ public void WithParentIndexJsonNet(IHandlebars handlebars)
Assert.Equal( makeFlat( expected ), makeFlat( result ) );
}

// System.Text.Json's JsonElement (the result of System.Text.Json.JsonSerializer.Deserialize<object>) has no
// built-in support for member access/iteration in .NET, unlike Newtonsoft's JObject/JToken
// above. These tests mirror the JObject coverage to ensure feature parity between the two.
[Fact]
public void JsonElementResolvesNestedProperty()
{
var json = "{\"A\": {\"B\": \"b\"}}";
object data = System.Text.Json.JsonSerializer.Deserialize<object>(json)!;

var template = Handlebars.Create().Compile("{{A.B}}");

Assert.Equal("b", template(data));
}

[Fact]
public void JsonElementRendersScalarPropertyValues()
{
var json = "{\"str\": \"hello\", \"num\": 42, \"nothing\": null}";
object data = System.Text.Json.JsonSerializer.Deserialize<object>(json)!;

var template = Handlebars.Create().Compile("{{str}}|{{num}}|{{nothing}}");

Assert.Equal("hello|42|", template(data));
}

[Fact]
public void JsonElementIteratesArray()
{
var json = "{\"items\": [1, 2, 3]}";
object data = System.Text.Json.JsonSerializer.Deserialize<object>(json)!;

var template = Handlebars.Create().Compile("{{#each items}}{{this}},{{/each}}");

Assert.Equal("1,2,3,", template(data));
}

[Fact]
public void JsonElementIteratesArrayOfObjects()
{
var json = "{\"items\": [{\"Name\": \"a\"}, {\"Name\": \"b\"}]}";
object data = System.Text.Json.JsonSerializer.Deserialize<object>(json)!;

var template = Handlebars.Create().Compile("{{#each items}}{{Name}},{{/each}}");

Assert.Equal("a,b,", template(data));
}

[Fact]
public void JsonElementIteratesObjectProperties()
{
var json = "{\"A\": \"1\", \"B\": \"2\"}";
object data = System.Text.Json.JsonSerializer.Deserialize<object>(json)!;

var template = Handlebars.Create().Compile("{{#each this}}{{@key}}={{this}} {{/each}}");

Assert.Equal("A=1 B=2 ", template(data));
}

[Theory]
[InlineData("{\"flag\": true}", "yes")]
[InlineData("{\"flag\": false}", "no")]
public void JsonElementBooleanRespectsTruthiness(string json, string expected)
{
object data = System.Text.Json.JsonSerializer.Deserialize<object>(json)!;

var template = Handlebars.Create().Compile("{{#if flag}}yes{{else}}no{{/if}}");

Assert.Equal(expected, template(data));
}

[Fact]
public void JsonElementTreatsEmptyStringNullAndZeroAsFalsy()
{
var json = "{\"empty\": \"\", \"nothing\": null, \"zero\": 0}";
object data = System.Text.Json.JsonSerializer.Deserialize<object>(json)!;

var template = Handlebars.Create().Compile(
"{{#if empty}}T{{else}}F{{/if}}" +
"{{#if nothing}}T{{else}}F{{/if}}" +
"{{#if zero}}T{{else}}F{{/if}}"
);

Assert.Equal("FFF", template(data));
}

[Fact]
public void JsonElementTreatsEmptyArrayAndObjectAsFalsy()
{
var json = "{\"emptyArr\": [], \"emptyObj\": {}}";
object data = System.Text.Json.JsonSerializer.Deserialize<object>(json)!;

var template = Handlebars.Create().Compile(
"{{#if emptyArr}}T{{else}}F{{/if}}" +
"{{#if emptyObj}}T{{else}}F{{/if}}"
);

Assert.Equal("FF", template(data));
}

[Fact]
public void JsonElementReturnsEmptyForMissingProperty()
{
var json = "{\"A\": \"a\"}";
object data = System.Text.Json.JsonSerializer.Deserialize<object>(json)!;

var template = Handlebars.Create().Compile("{{Missing.Nested}}");

Assert.Equal("", template(data));
}

#if NET452 || NET46 || NET461 || NET472

[Fact]
Expand Down
70 changes: 70 additions & 0 deletions source/Handlebars.Test/HandlebarsSpecCoverageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,76 @@ public void If_WhitespaceStringIsTruthy(IHandlebars hbs)
Assert.Equal("yes", template(new { val = " " }));
}

// ─────────────────────────────────────────────────────────────
// 5b. #if includeZero=true hash argument
// https://handlebarsjs.com/guide/builtin-helpers.html#if
// ─────────────────────────────────────────────────────────────

[Theory, ClassData(typeof(HandlebarsEnvGenerator))]
public void If_IncludeZeroTrue_ZeroInt_RendersBlock(IHandlebars hbs)
{
var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}");
Assert.Equal("yes", template(new { value = 0 }));
}

[Theory, ClassData(typeof(HandlebarsEnvGenerator))]
public void If_IncludeZeroTrue_ZeroDouble_RendersBlock(IHandlebars hbs)
{
var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}");
Assert.Equal("yes", template(new { value = 0.0 }));
}

[Theory, ClassData(typeof(HandlebarsEnvGenerator))]
public void If_IncludeZeroTrue_NonZeroInt_RendersBlock(IHandlebars hbs)
{
var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}");
Assert.Equal("yes", template(new { value = 1 }));
}

[Theory, ClassData(typeof(HandlebarsEnvGenerator))]
public void If_IncludeZeroFalse_ZeroInt_DoesNotRenderBlock(IHandlebars hbs)
{
var template = hbs.Compile("{{#if value includeZero=false}}yes{{else}}no{{/if}}");
Assert.Equal("no", template(new { value = 0 }));
}

[Theory, ClassData(typeof(HandlebarsEnvGenerator))]
public void If_IncludeZeroTrue_NullValue_StillTreatedAsFalsy(IHandlebars hbs)
{
var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}");
Assert.Equal("no", template(new { value = (object?)null }));
}

[Theory, ClassData(typeof(HandlebarsEnvGenerator))]
public void If_IncludeZeroTrue_EmptyString_StillTreatedAsFalsy(IHandlebars hbs)
{
var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}");
Assert.Equal("no", template(new { value = string.Empty }));
}

[Theory, ClassData(typeof(HandlebarsEnvGenerator))]
public void If_IncludeZeroTrue_FalseBool_StillTreatedAsFalsy(IHandlebars hbs)
{
var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}");
Assert.Equal("no", template(new { value = false }));
}

[Theory, ClassData(typeof(HandlebarsEnvGenerator))]
public void If_IncludeZeroTrue_TrueBool_RendersBlock(IHandlebars hbs)
{
var template = hbs.Compile("{{#if value includeZero=true}}yes{{else}}no{{/if}}");
Assert.Equal("yes", template(new { value = true }));
}

[Theory, ClassData(typeof(HandlebarsEnvGenerator))]
public void If_WithHashArgument_DoesNotCrash(IHandlebars hbs)
{
// Regression: passing any hash arg to #if previously threw
// InvalidOperationException: "Sequence contains more than one element".
var template = hbs.Compile("{{#if value includeZero=true}}yes{{/if}}");
Assert.Equal("yes", template(new { value = 42 }));
}

// ─────────────────────────────────────────────────────────────
// 6. #unless
// ─────────────────────────────────────────────────────────────
Expand Down
Loading
Loading