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
141 changes: 141 additions & 0 deletions Coder.Test/Serialization/CultureInvariantDeserializationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Coder.Test.Serialization;

using System.Globalization;
using ktsu.Coder.Ast;
using ktsu.Coder.Serialization;

/// <summary>
/// Tests that YAML documents round-trip identically regardless of the current culture.
///
/// The serializer emits scalars through YamlDotNet, which writes numbers invariantly
/// (dot-decimal). The deserializer must therefore read them back invariantly too; parsing
/// against the current culture turns "3.14" into 314 on a culture that groups with "."
/// (de-DE, fr-FR, es-ES, ...), silently corrupting the loaded AST.
/// </summary>
[TestClass]
[DoNotParallelize]
public class CultureInvariantDeserializationTests
{
/// <summary>
/// A culture whose decimal separator is "," and whose group separator is ".", like de-DE,
/// fr-FR, es-ES and the rest of continental Europe. That pairing is what makes a dot-decimal
/// literal parse to a wrong value rather than fail outright: "3.14" reads as three thousand
/// one hundred and four tenths of nothing — 314.
///
/// It is built from the invariant culture rather than named, because the test project runs in
/// globalization-invariant mode where <c>new CultureInfo("de-DE")</c> throws. Building it this
/// way also keeps the test hermetic: it asserts against a fixed number format rather than
/// whatever ICU data the host happens to carry.
/// </summary>
private static CultureInfo DotGroupingCulture()
{
CultureInfo culture = (CultureInfo)CultureInfo.InvariantCulture.Clone();
culture.NumberFormat.NumberDecimalSeparator = ",";
culture.NumberFormat.NumberGroupSeparator = ".";
culture.NumberFormat.NegativeSign = "−";
return culture;
}

[TestMethod]
public void DeserializeFractionLiteral_UnderDotGroupingCulture_RoundTripsValue()
{
LiteralExpression<double> literal = Literal.DecimalValue(3.14);

LiteralExpression<double> roundTripped = RoundTripUnder<LiteralExpression<double>>(DotGroupingCulture(), literal);

Assert.AreEqual(3.14, roundTripped.Value);
}

[TestMethod]
public void DeserializeNegativeFractionLiteral_UnderDotGroupingCulture_RoundTripsValue()
{
LiteralExpression<double> literal = Literal.DecimalValue(-0.125);

LiteralExpression<double> roundTripped = RoundTripUnder<LiteralExpression<double>>(DotGroupingCulture(), literal);

Assert.AreEqual(-0.125, roundTripped.Value);
}

[TestMethod]
public void DeserializeIntegerLiteral_UnderDotGroupingCulture_RoundTripsValue()
{
LiteralExpression<int> literal = Literal.Number(-42);

LiteralExpression<int> roundTripped = RoundTripUnder<LiteralExpression<int>>(DotGroupingCulture(), literal);

Assert.AreEqual(-42, roundTripped.Value);
}

[TestMethod]
public void DeserializeBooleanLiteral_UnderDotGroupingCulture_RoundTripsValue()
{
LiteralExpression<bool> literal = Literal.Bool(true);

LiteralExpression<bool> roundTripped = RoundTripUnder<LiteralExpression<bool>>(DotGroupingCulture(), literal);

Assert.IsTrue(roundTripped.Value);
}

[TestMethod]
public void SerializeFractionLiteral_UnderDotGroupingCulture_WritesDotDecimal()
{
// The deserializer reads invariantly, so the serializer must write invariantly too;
// this pins the half of the contract the fix depends on.
string yaml = UnderCulture(DotGroupingCulture(), () => new YamlSerializer().Serialize(Literal.DecimalValue(3.14)));

Assert.Contains("3.14", yaml);
}

[TestMethod]
public void DeserializeFunctionBody_UnderDotGroupingCulture_PreservesReturnedFraction()
{
FunctionDeclaration function = new("Rate") { ReturnType = "double" };
function.Body.Add(new ReturnStatement(Literal.DecimalValue(0.5)));

FunctionDeclaration roundTripped = RoundTripUnder<FunctionDeclaration>(DotGroupingCulture(), function);

ReturnStatement returned = (ReturnStatement)roundTripped.Body[0];
LiteralExpression<double> value = (LiteralExpression<double>)returned.Expression!;
Assert.AreEqual(0.5, value.Value);
}

/// <summary>
/// Serializes and deserializes <paramref name="node"/> with <paramref name="culture"/> installed
/// as the current culture for both halves of the trip.
/// </summary>
private static T RoundTripUnder<T>(CultureInfo culture, AstNode node) where T : AstNode
{
AstNode? deserialized = UnderCulture(culture, () =>
{
string yaml = new YamlSerializer().Serialize(node);
return new YamlDeserializer().Deserialize(yaml);
});

Assert.IsNotNull(deserialized);
Assert.IsInstanceOfType<T>(deserialized);
return (T)deserialized;
}

/// <summary>
/// Runs <paramref name="action"/> on a dedicated thread with <paramref name="culture"/> as the
/// current culture, so the ambient culture of the test host is never mutated.
/// </summary>
private static TResult UnderCulture<TResult>(CultureInfo culture, Func<TResult> action)
{
// LongRunning gets a dedicated thread rather than a pool one, so the culture is discarded
// with the thread instead of outliving the test on a thread someone else will be handed.
// The task carries any failure back to this thread, so the round trip needs no catch of its own.
return Task.Factory.StartNew(
() =>
{
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
return action();
},
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default).GetAwaiter().GetResult();
}
}
7 changes: 4 additions & 3 deletions Coder/Serialization/YamlDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using ktsu.Coder.Ast;
Expand Down Expand Up @@ -118,7 +119,7 @@
return valueType switch
{
"String" => new AstLeafNode<string>(nodeData?.ToString() ?? string.Empty),
"Int32" when int.TryParse(nodeData?.ToString(), out int intValue) => new AstLeafNode<int>(intValue),
"Int32" when int.TryParse(nodeData?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int intValue) => new AstLeafNode<int>(intValue),
"Boolean" when bool.TryParse(nodeData?.ToString(), out bool boolValue) => new AstLeafNode<bool>(boolValue),
_ => null,
};
Expand Down Expand Up @@ -539,7 +540,7 @@
}
}

if (dict.TryGetValue("expectedType", out object? typeObj))

Check warning on line 543 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal 'expectedType' 6 times.

Check warning on line 543 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal 'expectedType' 6 times.

Check warning on line 543 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal 'expectedType' 6 times.

Check warning on line 543 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal 'expectedType' 6 times.
{
callExpr.ExpectedType = typeObj?.ToString();
}
Expand Down Expand Up @@ -1146,7 +1147,7 @@
return returnStmt;
}

private BinaryExpression DeserializeBinaryExpression(object? nodeData)

Check warning on line 1150 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 1150 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.
{
BinaryExpression binaryExpr = new();
if (nodeData is Dictionary<object, object> dict)
Expand Down Expand Up @@ -1258,9 +1259,9 @@
Expression? result = valueType switch
{
"String" => new LiteralExpression<string>(value?.ToString() ?? string.Empty),
"Int32" when int.TryParse(value?.ToString(), out int intValue) => new LiteralExpression<int>(intValue),
"Int32" when int.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int intValue) => new LiteralExpression<int>(intValue),
"Boolean" when bool.TryParse(value?.ToString(), out bool boolValue) => new LiteralExpression<bool>(boolValue),
"Double" when double.TryParse(value?.ToString(), out double doubleValue) => new LiteralExpression<double>(doubleValue),
"Double" when double.TryParse(value?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double doubleValue) => new LiteralExpression<double>(doubleValue),
_ => null,
};

Expand Down Expand Up @@ -1293,7 +1294,7 @@
return varRef;
}

private VariableDeclaration DeserializeVariableDeclaration(object? nodeData)

Check warning on line 1297 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

Check warning on line 1297 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
{
VariableDeclaration varDecl = new();
if (nodeData is Dictionary<object, object> dict)
Expand Down Expand Up @@ -1339,7 +1340,7 @@
return varDecl;
}

private AssignmentStatement DeserializeAssignmentStatement(object? nodeData)

Check warning on line 1343 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 1343 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
{
AssignmentStatement assignment = new();
if (nodeData is Dictionary<object, object> dict)
Expand Down