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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
<ProjectReference Include="../ObjectSemantics.NET/ObjectSemantics.NET.csproj" />
</ItemGroup>
</Project>
12 changes: 12 additions & 0 deletions ObjectSemantics.NET.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using BenchmarkDotNet.Running;

namespace ObjectSemantics.NET.Benchmarks
{
public class Program
{
public static void Main(string[] args)
{
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
}
}
}
80 changes: 80 additions & 0 deletions ObjectSemantics.NET.Benchmarks/RenderingBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using BenchmarkDotNet.Attributes;
using ObjectSemantics.NET.Engine;
using System.IO;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

namespace ObjectSemantics.NET.Benchmarks
{
[MemoryDiagnoser]
public class RenderingBenchmarks
{
private readonly BenchmarkModel _model = new BenchmarkModel();
private string _manyValues;
private string[] _templates;
private int _templateIndex;
private CompiledTemplate _compiled;
private readonly TemplateMapperOptions _optimized = new TemplateMapperOptions { LazyPropertyAccess = true, UseStreamingEvaluation = true };
private const string ExpressionTemplate = "{{ #foreach(Items) }}{{ __calc(Quantity * Price):N2 }};{{ #endforeach }}";

[GlobalSetup]
public void Setup()
{
for (int i = 0; i < 100; i++)
_model.Items.Add(new BenchmarkItem { Quantity = i + 1, Price = 12.5m });
StringBuilder text = new StringBuilder();
for (int i = 0; i < 200; i++)
text.Append("Hello {{ Name }}! ");
_manyValues = text.ToString();
_templates = new string[4096];
for (int i = 0; i < _templates.Length; i++)
_templates[i] = "Template " + i + " {{ Name }}";
_model.Map(ExpressionTemplate);
_model.Map(_manyValues);
_compiled = TemplateMapper.Compile(_manyValues);
}

[Benchmark]
public string WarmValues() { return _model.Map(_manyValues); }

[Benchmark]
public string ExpressionLoop() { return _model.Map(ExpressionTemplate); }

[Benchmark]
public string Aggregate() { return _model.Map("{{ __sum(Items.Price) }}|{{ __avg(Items.Price) }}"); }

[Benchmark]
public string CacheChurn() { return _model.Map(_templates[_templateIndex++ & 4095]); }

[Benchmark]
public object ColdParse() { return EngineTemplateParser.Parse(_manyValues); }

[Benchmark]
public string CompiledValues() { return _compiled.Render(_model); }

[Benchmark]
public void WriterValues() { _compiled.RenderTo(TextWriter.Null, _model); }

[Benchmark]
public string StreamingAggregate() { return _model.Map("{{ __sum(Items.Price) }}|{{ __avg(Items.Price) }}", options: _optimized); }

[Benchmark(OperationsPerInvoke = 32)]
public void ConcurrentRendering()
{
Parallel.For(0, 32, i => _model.Map(ExpressionTemplate));
}
}

public class BenchmarkModel
{
public string Name { get; set; } = "Customer";
public List<BenchmarkItem> Items { get; set; } = new List<BenchmarkItem>();
}

public class BenchmarkItem
{
public int Quantity { get; set; }
public decimal Price { get; set; }
}
}
93 changes: 93 additions & 0 deletions ObjectSemantics.NET.Tests/CompiledTemplateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading.Tasks;
using Xunit;

namespace ObjectSemantics.NET.Tests
{
public class CompiledTemplateTests
{
[Fact]
public void CompiledTemplateSupportsConcurrentIndependentRenders()
{
CompiledTemplate template = TemplateMapper.Compile("{{ Name }}={{ __calc(Value * 2) }}");
Parallel.For(0, 250, i =>
{
Model model = new Model(i.ToString(), i);
Assert.Equal(i + "=" + (i * 2), template.Render(model));
});
}

[Fact]
public void WriterOutputMatchesStringAndWriterRemainsOpen()
{
CompiledTemplate template = TemplateMapper.Compile("{{ Name }}|{{ #foreach(Items) }}[{{ . }}]{{ #endforeach }}");
Model model = new Model("<Customer>", 3);
TemplateMapperOptions options = new TemplateMapperOptions { XmlCharEscaping = true };
using (StringWriter writer = new StringWriter(CultureInfo.InvariantCulture))
{
template.RenderTo(writer, model, options: options);
Assert.Equal(template.Render(model, options: options), writer.ToString());
writer.Write("!");
Assert.EndsWith("!", writer.ToString());
}
}

[Fact]
public void WriterLimitLeavesOnlyOutputWithinBudget()
{
CompiledTemplate template = TemplateMapper.Compile("A{{ Name }}");
using (StringWriter writer = new StringWriter())
{
Assert.Throws<TemplateLimitExceededException>(() => template.RenderTo(writer, new Model("Long", 1), options: new TemplateMapperOptions { MaximumOutputCharacters = 2 }));
Assert.Equal("A", writer.ToString());
}
}

[Fact]
public void DiagnosticsHaveLocationsAndCannotMutateCachedTemplate()
{
CompiledTemplate template = TemplateMapper.Compile("First\n {{ #endif }}");
IReadOnlyList<TemplateDiagnostic> diagnostics = template.Diagnostics;
Assert.Single(diagnostics);
Assert.Equal(2, diagnostics[0].Line);
Assert.Equal(3, diagnostics[0].Column);
diagnostics[0].Message = "changed";
Assert.StartsWith("Unexpected", template.Diagnostics[0].Message);
Assert.Throws<FormatException>(() => TemplateMapper.Compile("{{ #endif }}", new TemplateMapperOptions { StrictMode = true }));
}

[Fact]
public void LimitsApplyToCachedAndCompiledTemplates()
{
CompiledTemplate template = TemplateMapper.Compile("Hello {{ Name }}");
TemplateMapperOptions options = new TemplateMapperOptions { MaximumTemplateCharacters = 3 };
Assert.Throws<TemplateLimitExceededException>(() => TemplateMapper.Compile("Hello {{ Name }}", options));
Assert.Throws<TemplateLimitExceededException>(() => template.Render(new Model("Test", 1), options: options));
Assert.Throws<ArgumentOutOfRangeException>(() => template.Render(new Model("Test", 1), options: new TemplateMapperOptions { MaximumIterations = -1 }));
}

[Fact]
public void OverflowFollowsExpressionFailurePolicy()
{
CompiledTemplate template = TemplateMapper.Compile("A{{ __calc(79228162514264337593543950335 + 1) }}B");
Assert.Equal("AB", template.Render(new Model("Test", 1)));
Assert.Throws<FormatException>(() => template.Render(new Model("Test", 1), options: new TemplateMapperOptions { StrictMode = true }));
}

public class Model
{
public Model(string name, int value)
{
Name = name;
Value = value;
}

public string Name { get; set; }
public int Value { get; set; }
public int[] Items { get; set; } = new[] { 1, 2, 3 };
}
}
}
28 changes: 7 additions & 21 deletions ObjectSemantics.NET.Tests/ObjectSemantics.NET.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,37 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net10.0</TargetFramework>

<IsPackable>false</IsPackable>

<AssemblyVersion>3.0.1.1</AssemblyVersion>

<FileVersion>3.0.1.1</FileVersion>
</PropertyGroup>

<ItemGroup>
<None Remove="MoqFiles\PaymentTemplate.result.xml" />
<None Remove="MoqFiles\PaymentTemplate.xml" />
</ItemGroup>

<ItemGroup>
<Content Include="MoqFiles\PaymentTemplate.result.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="MoqFiles\PaymentTemplate.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<None Update="MoqFiles/*.xml" CopyToOutputDirectory="PreserveNewest" TargetPath="MoqFiles/%(Filename)%(Extension)" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PackageReference Include="coverlet.collector" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
86 changes: 86 additions & 0 deletions ObjectSemantics.NET.Tests/OptimizedEvaluationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Xunit;

namespace ObjectSemantics.NET.Tests
{
public class OptimizedEvaluationTests
{
[Fact]
public void LazyAccessSkipsUnusedGettersAndReadsOncePerScope()
{
GetterModel model = new GetterModel();
Assert.Throws<InvalidOperationException>(() => model.Map("{{ Name }}"));
model.Reads = 0;
Assert.Equal("Alice|Alice|Alice", model.Map("{{ Name }}|{{ Name }}|{{ #if(Name == Alice) }}{{ Name }}{{ #endif }}", options: new TemplateMapperOptions { LazyPropertyAccess = true }));
Assert.Equal(1, model.Reads);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public void AggregatesPreserveNullEmptyAndInvalidBehavior(bool streaming)
{
Model model = new Model { Items = new List<Item> { new Item { Value = 2 }, new Item { Value = null }, new Item { Value = 4 } } };
TemplateMapperOptions options = new TemplateMapperOptions { UseStreamingEvaluation = streaming };
string template = "{{ __sum(Items.Value) }}|{{ __avg(Items.Value) }}|{{ __count(Items.Value) }}|{{ __min(Items.Value) }}|{{ __max(Items.Value) }}";
Assert.Equal("6|3|2|2|4", model.Map(template, options: options));
model.Items.Clear();
Assert.Equal("||||", model.Map(template, options: options));
model.Items = null;
Assert.Equal("0|0|0|0|0", model.Map(template, options: options));
Assert.Equal("", model.Map("{{ __sum(Unknown.Value) }}", options: options));
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public void AggregateEnumerationHonorsLimits(bool streaming)
{
Model model = new Model { Items = new List<Item> { new Item(), new Item() } };
Assert.Throws<TemplateLimitExceededException>(() => model.Map("{{ __sum(Items.Value) }}", options: new TemplateMapperOptions { UseStreamingEvaluation = streaming, MaximumIterations = 1 }));
}

[Fact]
public void TypedFormattingPreservesDatePrecisionAndKind()
{
Model model = new Model { Date = new DateTime(2026, 9, 16, 12, 34, 56, DateTimeKind.Utc).AddTicks(1234567) };
Assert.Equal(model.Date.ToString("O", CultureInfo.InvariantCulture), model.Map("{{ Date:O }}"));
}

[Fact]
public void ConditionsHandleNullableValuesAndExactDecimals()
{
Model model = new Model { Flag = true, Number = 20, Amount = 9007199254740993m };
Assert.Equal("Y", model.Map("{{ #if(Flag == true) }}Y{{ #else }}N{{ #endif }}"));
Assert.Equal("Y", model.Map("{{ #if(Number >= 18) }}Y{{ #else }}N{{ #endif }}"));
Assert.Equal("N", model.Map("{{ #if(Amount == 9007199254740992) }}Y{{ #else }}N{{ #endif }}"));
model.Flag = null;
Assert.Equal("Y", model.Map("{{ #if(Flag == null) }}Y{{ #else }}N{{ #endif }}"));
Assert.Equal("Y", model.Map("{{ #if(Numbers > 0) }}Y{{ #else }}N{{ #endif }}"));
}

public class GetterModel
{
public int Reads { get; set; }
public string Name { get { Reads++; return "Alice"; } }
public string Unused { get { throw new InvalidOperationException("Unused getter"); } }
}

public class Model
{
public List<Item> Items { get; set; }
public DateTime Date { get; set; }
public bool? Flag { get; set; }
public int? Number { get; set; }
public decimal Amount { get; set; }
public int[] Numbers { get; set; } = new[] { 1, 2 };
}

public class Item
{
public decimal? Value { get; set; }
}
}
}
Loading
Loading