diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index b5f06ee..d260780 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -24,7 +24,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9
-
+
- name: Build
run: dotnet build Engine/Quokka.slnx --configuration Release
diff --git a/Engine/Directory.Build.props b/Engine/Directory.Build.props
index b0e18f5..ac66d0e 100644
--- a/Engine/Directory.Build.props
+++ b/Engine/Directory.Build.props
@@ -16,6 +16,6 @@
- 8.3.0
+ 8.4.0
diff --git a/Engine/Quokka.Core/Functions/Standard/Money/CurrencyAmountFormats.cs b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyAmountFormats.cs
new file mode 100644
index 0000000..bcc200e
--- /dev/null
+++ b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyAmountFormats.cs
@@ -0,0 +1,35 @@
+// // Copyright 2022 Mindbox Ltd
+// //
+// // Licensed under the Apache License, Version 2.0 (the "License");
+// // you may not use this file except in compliance with the License.
+// // You may obtain a copy of the License at
+// //
+// // http://www.apache.org/licenses/LICENSE-2.0
+// //
+// // Unless required by applicable law or agreed to in writing, software
+// // distributed under the License is distributed on an "AS IS" BASIS,
+// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// // See the License for the specific language governing permissions and
+// // limitations under the License.
+
+using System.Globalization;
+
+namespace Mindbox.Quokka
+{
+ internal static class CurrencyAmountFormats
+ {
+ public const int DefaultDecimalPlaces = 2;
+
+ private static readonly string[] formatsByDecimalPlaces = ["N0", "N1", "N2", "N3", "N4"];
+
+ public static readonly NumberFormatInfo NumberFormat = NumberFormatInfo.ReadOnly(
+ new NumberFormatInfo
+ {
+ NumberGroupSeparator = ",",
+ NumberDecimalSeparator = ".",
+ NumberGroupSizes = [3]
+ });
+
+ public static string ForDecimalPlaces(int decimalPlaces) => formatsByDecimalPlaces[decimalPlaces];
+ }
+}
diff --git a/Engine/Quokka.Core/Functions/Standard/Money/CurrencyDisplayMode.cs b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyDisplayMode.cs
new file mode 100644
index 0000000..f8ae804
--- /dev/null
+++ b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyDisplayMode.cs
@@ -0,0 +1,63 @@
+// // Copyright 2022 Mindbox Ltd
+// //
+// // Licensed under the Apache License, Version 2.0 (the "License");
+// // you may not use this file except in compliance with the License.
+// // You may obtain a copy of the License at
+// //
+// // http://www.apache.org/licenses/LICENSE-2.0
+// //
+// // Unless required by applicable law or agreed to in writing, software
+// // distributed under the License is distributed on an "AS IS" BASIS,
+// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// // See the License for the specific language governing permissions and
+// // limitations under the License.
+
+using System;
+
+namespace Mindbox.Quokka
+{
+ internal enum CurrencyDisplayMode
+ {
+ NarrowSymbol,
+ Symbol,
+ Code
+ }
+
+ internal static class CurrencyDisplayModes
+ {
+ public const string NarrowSymbolName = "narrowSymbol";
+ public const string SymbolName = "symbol";
+ public const string CodeName = "code";
+
+ public static bool TryParse(string value, out CurrencyDisplayMode mode)
+ {
+ var name = value?.Trim();
+ if (string.IsNullOrEmpty(name) || Matches(name, NarrowSymbolName))
+ {
+ mode = CurrencyDisplayMode.NarrowSymbol;
+ return true;
+ }
+
+ if (Matches(name, SymbolName))
+ {
+ mode = CurrencyDisplayMode.Symbol;
+ return true;
+ }
+
+ if (Matches(name, CodeName))
+ {
+ mode = CurrencyDisplayMode.Code;
+ return true;
+ }
+
+ mode = CurrencyDisplayMode.NarrowSymbol;
+ return false;
+ }
+
+ public static CurrencyDisplayMode ParseOrDefault(string value) =>
+ TryParse(value, out var mode) ? mode : CurrencyDisplayMode.NarrowSymbol;
+
+ private static bool Matches(string value, string name) =>
+ string.Equals(value, name, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormat.cs b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormat.cs
new file mode 100644
index 0000000..368b09c
--- /dev/null
+++ b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormat.cs
@@ -0,0 +1,38 @@
+// // Copyright 2022 Mindbox Ltd
+// //
+// // Licensed under the Apache License, Version 2.0 (the "License");
+// // you may not use this file except in compliance with the License.
+// // You may obtain a copy of the License at
+// //
+// // http://www.apache.org/licenses/LICENSE-2.0
+// //
+// // Unless required by applicable law or agreed to in writing, software
+// // distributed under the License is distributed on an "AS IS" BASIS,
+// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// // See the License for the specific language governing permissions and
+// // limitations under the License.
+
+namespace Mindbox.Quokka
+{
+ internal sealed class CurrencyFormat
+ {
+ public CurrencyFormat(string narrowSymbol, string symbol, int decimalPlaces, bool spaceAfterSymbol = false)
+ {
+ NarrowSymbol = narrowSymbol;
+ Symbol = symbol;
+ DecimalPlaces = decimalPlaces;
+ SpaceAfterSymbol = spaceAfterSymbol;
+ AmountFormat = CurrencyAmountFormats.ForDecimalPlaces(decimalPlaces);
+ }
+
+ public string NarrowSymbol { get; }
+
+ public string Symbol { get; }
+
+ public int DecimalPlaces { get; }
+
+ public bool SpaceAfterSymbol { get; }
+
+ public string AmountFormat { get; }
+ }
+}
diff --git a/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormats.cs b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormats.cs
new file mode 100644
index 0000000..ef5fed7
--- /dev/null
+++ b/Engine/Quokka.Core/Functions/Standard/Money/CurrencyFormats.cs
@@ -0,0 +1,42 @@
+// // Copyright 2022 Mindbox Ltd
+// //
+// // Licensed under the Apache License, Version 2.0 (the "License");
+// // you may not use this file except in compliance with the License.
+// // You may obtain a copy of the License at
+// //
+// // http://www.apache.org/licenses/LICENSE-2.0
+// //
+// // Unless required by applicable law or agreed to in writing, software
+// // distributed under the License is distributed on an "AS IS" BASIS,
+// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// // See the License for the specific language governing permissions and
+// // limitations under the License.
+
+using System;
+using System.Collections.Concurrent;
+using NodaMoney;
+
+namespace Mindbox.Quokka
+{
+ internal static class CurrencyFormats
+ {
+ private static readonly ConcurrentDictionary knownFormats =
+ new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+
+ public static CurrencyFormat TryGet(string currencyCode)
+ {
+ if (string.IsNullOrEmpty(currencyCode))
+ return null;
+
+ if (knownFormats.TryGetValue(currencyCode, out var knownFormat))
+ return knownFormat;
+
+ if (!CurrencyInfo.TryFromCode(currencyCode, out var currency))
+ return null;
+
+ var format = new CurrencyFormat(currency.Symbol, currency.InternationalSymbol, currency.DecimalDigits);
+ knownFormats[currencyCode] = format;
+ return format;
+ }
+ }
+}
diff --git a/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyTemplateFunction.cs b/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyTemplateFunction.cs
new file mode 100644
index 0000000..aab60a5
--- /dev/null
+++ b/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyTemplateFunction.cs
@@ -0,0 +1,34 @@
+// // Copyright 2022 Mindbox Ltd
+// //
+// // Licensed under the Apache License, Version 2.0 (the "License");
+// // you may not use this file except in compliance with the License.
+// // You may obtain a copy of the License at
+// //
+// // http://www.apache.org/licenses/LICENSE-2.0
+// //
+// // Unless required by applicable law or agreed to in writing, software
+// // distributed under the License is distributed on an "AS IS" BASIS,
+// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// // See the License for the specific language governing permissions and
+// // limitations under the License.
+
+using Mindbox.Quokka.Abstractions;
+
+namespace Mindbox.Quokka
+{
+ internal class FormatMoneyTemplateFunction : ScalarTemplateFunction
+ {
+ public FormatMoneyTemplateFunction()
+ : base(
+ "formatMoney",
+ new DecimalFunctionArgument("amount", allowsNull: true),
+ new StringFunctionArgument("currencyCode", allowsNull: true))
+ {
+ }
+
+ public override string Invoke(RenderSettings settings, decimal amount, string currencyCode)
+ {
+ return MoneyFormatter.Format(amount, currencyCode, CurrencyDisplayMode.NarrowSymbol);
+ }
+ }
+}
diff --git a/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyWithDisplayModeTemplateFunction.cs b/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyWithDisplayModeTemplateFunction.cs
new file mode 100644
index 0000000..83023a5
--- /dev/null
+++ b/Engine/Quokka.Core/Functions/Standard/Money/FormatMoneyWithDisplayModeTemplateFunction.cs
@@ -0,0 +1,45 @@
+// // Copyright 2022 Mindbox Ltd
+// //
+// // Licensed under the Apache License, Version 2.0 (the "License");
+// // you may not use this file except in compliance with the License.
+// // You may obtain a copy of the License at
+// //
+// // http://www.apache.org/licenses/LICENSE-2.0
+// //
+// // Unless required by applicable law or agreed to in writing, software
+// // distributed under the License is distributed on an "AS IS" BASIS,
+// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// // See the License for the specific language governing permissions and
+// // limitations under the License.
+
+using Mindbox.Quokka.Abstractions;
+
+namespace Mindbox.Quokka
+{
+ internal class FormatMoneyWithDisplayModeTemplateFunction : ScalarTemplateFunction
+ {
+ public FormatMoneyWithDisplayModeTemplateFunction()
+ : base(
+ "formatMoney",
+ new DecimalFunctionArgument("amount", allowsNull: true),
+ new StringFunctionArgument("currencyCode", allowsNull: true),
+ new StringFunctionArgument("displayMode", allowsNull: true, valueValidator: ValidateDisplayMode))
+ {
+ }
+
+ public override string Invoke(RenderSettings settings, decimal amount, string currencyCode, string displayMode)
+ {
+ return MoneyFormatter.Format(amount, currencyCode, CurrencyDisplayModes.ParseOrDefault(displayMode));
+ }
+
+ private static ArgumentValueValidationResult ValidateDisplayMode(string displayMode)
+ {
+ return CurrencyDisplayModes.TryParse(displayMode, out _)
+ ? ArgumentValueValidationResult.Valid
+ : new ArgumentValueValidationResult(
+ false,
+ $"Display mode should be one of: {CurrencyDisplayModes.NarrowSymbolName}, "
+ + $"{CurrencyDisplayModes.SymbolName}, {CurrencyDisplayModes.CodeName}");
+ }
+ }
+}
diff --git a/Engine/Quokka.Core/Functions/Standard/Money/MoneyFormatter.cs b/Engine/Quokka.Core/Functions/Standard/Money/MoneyFormatter.cs
new file mode 100644
index 0000000..17b37f3
--- /dev/null
+++ b/Engine/Quokka.Core/Functions/Standard/Money/MoneyFormatter.cs
@@ -0,0 +1,54 @@
+// // Copyright 2022 Mindbox Ltd
+// //
+// // Licensed under the Apache License, Version 2.0 (the "License");
+// // you may not use this file except in compliance with the License.
+// // You may obtain a copy of the License at
+// //
+// // http://www.apache.org/licenses/LICENSE-2.0
+// //
+// // Unless required by applicable law or agreed to in writing, software
+// // distributed under the License is distributed on an "AS IS" BASIS,
+// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// // See the License for the specific language governing permissions and
+// // limitations under the License.
+
+using System;
+
+namespace Mindbox.Quokka
+{
+ internal static class MoneyFormatter
+ {
+ private const string MinusSign = "-";
+ private const string Space = " ";
+
+ public static string Format(decimal amount, string currencyCode, CurrencyDisplayMode displayMode)
+ {
+ var code = currencyCode?.Trim();
+ var format = CurrencyFormats.TryGet(code);
+ var decimalPlaces = format?.DecimalPlaces ?? CurrencyAmountFormats.DefaultDecimalPlaces;
+ var absolute = Math.Round(Math.Abs(amount), decimalPlaces, MidpointRounding.AwayFromZero);
+
+ var formattedAmount = absolute.ToString(
+ format?.AmountFormat ?? CurrencyAmountFormats.ForDecimalPlaces(decimalPlaces),
+ CurrencyAmountFormats.NumberFormat);
+
+ var sign = amount < 0 && absolute != decimal.Zero ? MinusSign : string.Empty;
+
+ if (format == null)
+ return string.IsNullOrEmpty(code)
+ ? sign + formattedAmount
+ : sign + formattedAmount + Space + code.ToUpperInvariant();
+
+ if (displayMode == CurrencyDisplayMode.Code)
+ return sign + code.ToUpperInvariant() + Space + formattedAmount;
+
+ var symbol = displayMode == CurrencyDisplayMode.Symbol ? format.Symbol : format.NarrowSymbol;
+ var lastCharacter = symbol[symbol.Length - 1];
+ var separator = format.SpaceAfterSymbol || char.IsLetter(lastCharacter) || lastCharacter == '.'
+ ? Space
+ : string.Empty;
+
+ return sign + symbol + separator + formattedAmount;
+ }
+ }
+}
diff --git a/Engine/Quokka.Core/Mindbox.Quokka.csproj b/Engine/Quokka.Core/Mindbox.Quokka.csproj
index f0fb783..485ffad 100644
--- a/Engine/Quokka.Core/Mindbox.Quokka.csproj
+++ b/Engine/Quokka.Core/Mindbox.Quokka.csproj
@@ -18,6 +18,7 @@
+
diff --git a/Engine/Quokka.Core/Template.cs b/Engine/Quokka.Core/Template.cs
index 04d3e05..fe117f9 100644
--- a/Engine/Quokka.Core/Template.cs
+++ b/Engine/Quokka.Core/Template.cs
@@ -217,6 +217,8 @@ internal static IEnumerable GetStandardFunctions()
yield return new ToLowerTemplateFunction();
yield return new ReplaceIfEmptyTemplateFunction();
yield return new FormatDecimalTemplateFunction();
+ yield return new FormatMoneyTemplateFunction();
+ yield return new FormatMoneyWithDisplayModeTemplateFunction();
yield return new FormatDateTimeTemplateFunction();
yield return new FormatTimeTemplateFunction();
yield return new IfTemplateFunction();
diff --git a/Engine/Quokka.Tests/Functions/FormatMoneyTemplateFunctionTests.cs b/Engine/Quokka.Tests/Functions/FormatMoneyTemplateFunctionTests.cs
new file mode 100644
index 0000000..2df6907
--- /dev/null
+++ b/Engine/Quokka.Tests/Functions/FormatMoneyTemplateFunctionTests.cs
@@ -0,0 +1,243 @@
+// // Copyright 2022 Mindbox Ltd
+// //
+// // Licensed under the Apache License, Version 2.0 (the "License");
+// // you may not use this file except in compliance with the License.
+// // You may obtain a copy of the License at
+// //
+// // http://www.apache.org/licenses/LICENSE-2.0
+// //
+// // Unless required by applicable law or agreed to in writing, software
+// // distributed under the License is distributed on an "AS IS" BASIS,
+// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// // See the License for the specific language governing permissions and
+// // limitations under the License.
+
+using System.Globalization;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Mindbox.Quokka.Abstractions;
+
+namespace Mindbox.Quokka.Tests
+{
+ [TestClass]
+ public class FormatMoneyTemplateFunctionTests
+ {
+ [TestMethod]
+ [DataRow("USD", "$1,234,567.89")]
+ [DataRow("GBP", "£1,234,567.89")]
+ [DataRow("EUR", "€1,234,567.89")]
+ [DataRow("RUB", "₽1,234,567.89")]
+ [DataRow("INR", "₹1,234,567.89")]
+ [DataRow("CHF", "Fr. 1,234,567.89")]
+ [DataRow("KWD", "د.ك 1,234,567.890")]
+ [DataRow("NGN", "₦1,234,567.89")]
+ [DataRow("RON", "lei 1,234,567.89")]
+ [DataRow("PKR", "Rs 1,234,567.89")]
+ [DataRow("HUF", "Ft 1,234,567.89")]
+ [DataRow("IDR", "Rp 1,234,567.89")]
+ public void FormatMoney_PutsTheSymbolFirstAndGroupsInThousands(string currencyCode, string expected)
+ {
+ Assert.AreEqual(expected, RenderMoney("1234567.89", currencyCode));
+ }
+
+ [TestMethod]
+ [DataRow("JPY", "¥9,072")]
+ [DataRow("KRW", "₩9,072")]
+ [DataRow("VND", "₫9,072")]
+ public void FormatMoney_WithZeroDecimalCurrency_DropsDecimals(string currencyCode, string expected)
+ {
+ Assert.AreEqual(expected, RenderMoney("9072.00", currencyCode));
+ }
+
+ [TestMethod]
+ public void FormatMoney_WithThreeDecimalCurrency_KeepsThreeDecimals()
+ {
+ Assert.AreEqual("د.ك 1,234.560", RenderMoney("1234.56", "KWD"));
+ }
+
+ [TestMethod]
+ [DataRow("USD", "-$1,234.56")]
+ [DataRow("RUB", "-₽1,234.56")]
+ [DataRow("KWD", "-د.ك 1,234.560")]
+ public void FormatMoney_WithNegativeAmount_PutsTheSignFirst(string currencyCode, string expected)
+ {
+ Assert.AreEqual(expected, RenderMoney("-1234.56", currencyCode));
+ }
+
+ [TestMethod]
+ [DataRow("USD", "US$1,234.56")]
+ [DataRow("CAD", "CA$1,234.56")]
+ [DataRow("AUD", "A$1,234.56")]
+ [DataRow("SGD", "S$1,234.56")]
+ [DataRow("HKD", "HK$1,234.56")]
+ [DataRow("NZD", "NZ$1,234.56")]
+ [DataRow("TWD", "NT$1,234.56")]
+ public void FormatMoney_WithSymbolDisplayMode_DisambiguatesSharedSymbols(string currencyCode, string expected)
+ {
+ Assert.AreEqual(expected, RenderMoney("1234.56", currencyCode, "symbol"));
+ }
+
+ [TestMethod]
+ [DataRow("EUR", "€1,234.56")]
+ [DataRow("GBP", "£1,234.56")]
+ [DataRow("RUB", "₽1,234.56")]
+ [DataRow("THB", "฿1,234.56")]
+ public void FormatMoney_WithSymbolDisplayMode_KeepsUnambiguousSymbols(string currencyCode, string expected)
+ {
+ Assert.AreEqual(expected, RenderMoney("1234.56", currencyCode, "symbol"));
+ }
+
+ [TestMethod]
+ [DataRow("USD", "USD 1,234.56")]
+ [DataRow("usd", "USD 1,234.56")]
+ [DataRow("xyz", "1,234.56 XYZ")]
+ public void FormatMoney_WithCodeDisplayMode_PrefixesUppercasedIsoCode(string currencyCode, string expected)
+ {
+ Assert.AreEqual(expected, RenderMoney("1234.56", currencyCode, "code"));
+ }
+
+ [TestMethod]
+ [DataRow("jpy")]
+ [DataRow(" JPY ")]
+ public void FormatMoney_WithLowercaseOrPaddedCode_ResolvesTheSameFormat(string currencyCode)
+ {
+ Assert.AreEqual("¥9,072", RenderMoney("9072.00", currencyCode));
+ }
+
+ [TestMethod]
+ public void FormatMoney_WithUnknownCurrencyCode_FallsBackToAmountAndCode()
+ {
+ Assert.AreEqual("1,234.56 XYZ", RenderMoney("1234.56", "XYZ"));
+ }
+
+ [TestMethod]
+ [DataRow("TND", "د.ت 1,234.560")]
+ [DataRow("UGX", "USh 1,235")]
+ public void FormatMoney_WithNonTwoDecimalCurrency_UsesItsMinorUnits(string currencyCode, string expected)
+ {
+ Assert.AreEqual(expected, RenderMoney("1234.56", currencyCode));
+ }
+
+ [TestMethod]
+ [DataRow("-0.004", "USD", "$0.00")]
+ [DataRow("-0.4", "JPY", "¥0")]
+ [DataRow("0", "USD", "$0.00")]
+ public void FormatMoney_WithAmountRoundingToZero_DoesNotRenderSignedZero(
+ string amount,
+ string currencyCode,
+ string expected)
+ {
+ Assert.AreEqual(expected, RenderMoney(amount, currencyCode));
+ }
+
+ [TestMethod]
+ [DataRow("SYMBOL", "US$1,234.56")]
+ [DataRow(" code ", "USD 1,234.56")]
+ [DataRow("NarrowSymbol", "$1,234.56")]
+ public void FormatMoney_WithDifferentlyCasedDisplayMode_ResolvesTheSameMode(string displayMode, string expected)
+ {
+ Assert.AreEqual(expected, RenderMoney("1234.56", "USD", displayMode));
+ }
+
+ [TestMethod]
+ public void FormatMoney_WithNullAmount_RendersZero()
+ {
+ var template = new Template("${ formatMoney(Amount, 'USD') }");
+
+ var result = template.Render(
+ new CompositeModelValue(new ModelField("Amount", (string)null)));
+
+ Assert.AreEqual("$0.00", result);
+ }
+
+ [TestMethod]
+ public void FormatMoney_WithNullDisplayMode_FallsBackToNarrowSymbol()
+ {
+ var template = new Template("${ formatMoney(Amount, 'USD', DisplayMode) }");
+
+ var result = template.Render(
+ new CompositeModelValue(
+ new ModelField("Amount", 1234.56m),
+ new ModelField("DisplayMode", (string)null)));
+
+ Assert.AreEqual("$1,234.56", result);
+ }
+
+ [TestMethod]
+ public void FormatMoney_WithoutCurrencyCode_RendersBareAmount()
+ {
+ var template = new Template("${ formatMoney(Amount, Currency) }");
+
+ var result = template.Render(
+ new CompositeModelValue(
+ new ModelField("Amount", 1234.56m),
+ new ModelField("Currency", (string)null)));
+
+ Assert.AreEqual("1,234.56", result);
+ }
+
+ [TestMethod]
+ [DataRow("1.005", "$1.01")]
+ [DataRow("2.005", "$2.01")]
+ [DataRow("-1.005", "-$1.01")]
+ public void FormatMoney_RoundsHalfAwayFromZero(string amount, string expected)
+ {
+ Assert.AreEqual(expected, RenderMoney(amount, "USD"));
+ }
+
+ [TestMethod]
+ [DataRow("en-US")]
+ [DataRow("ru-RU")]
+ [DataRow("de-DE")]
+ public void FormatMoney_IsIndependentOfRenderCulture(string locale)
+ {
+ var template = new Template("${ formatMoney(Amount, 'USD') }${ formatMoney(Amount, 'EUR') }");
+
+ var result = template.Render(
+ new CompositeModelValue(new ModelField("Amount", 1234.56m)),
+ new RenderSettings { CultureInfo = new CultureInfo(locale) });
+
+ Assert.AreEqual("$1,234.56" + "€1,234.56", result);
+ }
+
+ [TestMethod]
+ public void FormatMoney_SeparatesWithPlainSpaces_SoSmsStaysInTheGsmAlphabet()
+ {
+ var result = RenderMoney("1234.56", "CHF");
+
+ Assert.AreEqual("Fr. 1,234.56", result);
+ Assert.IsFalse(result.Contains('\u00A0'));
+ }
+
+ [TestMethod]
+ public void FormatMoney_WithDisplayModeFromModel_FallsBackToNarrowSymbolWhenUnsupported()
+ {
+ var template = new Template("${ formatMoney(Amount, 'USD', DisplayMode) }");
+
+ var result = template.Render(
+ new CompositeModelValue(
+ new ModelField("Amount", 1234.56m),
+ new ModelField("DisplayMode", "fancy")));
+
+ Assert.AreEqual("$1,234.56", result);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(TemplateContainsErrorsException))]
+ public void FormatMoney_WithUnsupportedConstantDisplayMode_ReportsStaticError()
+ {
+ new Template("${ formatMoney(Amount, 'USD', 'fancy') }");
+ }
+
+ private static string RenderMoney(string amount, string currencyCode, string displayMode = null)
+ {
+ var displayModeArgument = displayMode == null ? string.Empty : $", '{displayMode}'";
+ var template = new Template($"${{ formatMoney(Amount, '{currencyCode}'{displayModeArgument}) }}");
+
+ return template.Render(
+ new CompositeModelValue(
+ new ModelField("Amount", decimal.Parse(amount, CultureInfo.InvariantCulture))));
+ }
+ }
+}