fix(commons): resolve Commons-005..007,009..012 — OPC UA parse status, TryConvert correctness, Result null guard, invariant formatting, doc refresh

This commit is contained in:
Joseph Doherty
2026-05-16 22:04:21 -04:00
parent 746ab90444
commit c07f524ca4
12 changed files with 602 additions and 99 deletions

View File

@@ -135,19 +135,47 @@ public class OpcUaEndpointConfigSerializerTests
Assert.Equal(1000, config.PublishingIntervalMs);
}
[Fact]
public void Deserialize_LegacyParsed_StatusIsLegacy()
{
var (_, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize("""{"endpoint":"opc.tcp://x:4840"}""");
Assert.True(isLegacy);
var result = OpcUaEndpointConfigSerializer.Deserialize("""{"endpoint":"opc.tcp://x:4840"}""");
Assert.Equal(OpcUaConfigParseStatus.Legacy, result.Status);
}
[Theory]
[InlineData("not json at all")]
[InlineData("[1,2,3]")]
[InlineData("{\"foo\":123}")]
[InlineData("\"just a string\"")]
public void Deserialize_Malformed_ReturnsDefaultsAsLegacy(string input)
public void Deserialize_Malformed_ReportsMalformedNotLegacy(string input)
{
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(input);
var result = OpcUaEndpointConfigSerializer.Deserialize(input);
Assert.True(isLegacy);
Assert.Equal("", config.EndpointUrl);
Assert.Equal(60000, config.SessionTimeoutMs);
Assert.Null(config.Heartbeat);
// Genuinely unparseable input must NOT be reported as a recoverable legacy row.
Assert.Equal(OpcUaConfigParseStatus.Malformed, result.Status);
Assert.False(result.IsLegacy);
Assert.Equal("", result.Config.EndpointUrl);
}
[Fact]
public void Deserialize_ObjectWithoutEndpointUrl_ParsesAsLegacy()
{
// A flat object with unrecognized keys is still a parseable legacy row, not malformed.
var result = OpcUaEndpointConfigSerializer.Deserialize("{\"foo\":123}");
Assert.Equal(OpcUaConfigParseStatus.Legacy, result.Status);
Assert.True(result.IsLegacy);
}
[Fact]
public void Deserialize_TwoElementDeconstruction_StillWorks()
{
// Backward-compat: existing callers deconstruct into (Config, IsLegacy).
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(
"""{"endpointUrl":"opc.tcp://x:4840"}""");
Assert.False(isLegacy);
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
}
[Fact]

View File

@@ -1,3 +1,4 @@
using System.Dynamic;
using System.Text.Json;
using ScadaLink.Commons.Types;
@@ -100,4 +101,47 @@ public class DynamicJsonElementTests
Assert.Throws<Microsoft.CSharp.RuntimeBinder.RuntimeBinderException>(
() => { var _ = obj.doesNotExist; });
}
// ── Commons-006 regression: TryConvert(object) must never null out a present value ──
[Fact]
public void TryConvert_ObjectTarget_OnPresentValue_ReturnsNonNull()
{
// Directly exercise the DynamicObject.TryConvert contract for an `object`
// target: a present JSON object/array/string must not convert to null.
using var objDoc = JsonDocument.Parse("""{ "x": 1 }""");
var objWrapper = new DynamicJsonElement(objDoc.RootElement);
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(object), typeof(DynamicJsonElementTests));
Assert.True(objWrapper.TryConvert(convBinder, out var result));
Assert.NotNull(result);
}
[Fact]
public void TryConvert_ObjectTarget_OnJsonNull_ReturnsNull()
{
// Only a genuinely null JSON value converts to a null object.
using var doc = JsonDocument.Parse("""{ "v": null }""");
var nullWrapper = new DynamicJsonElement(doc.RootElement.GetProperty("v"));
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(object), typeof(DynamicJsonElementTests));
Assert.True(nullWrapper.TryConvert(convBinder, out var result));
Assert.Null(result);
}
[Fact]
public void TryConvert_NonObjectTarget_OnUnconvertibleValue_ReportsFailure()
{
// Requesting int from a JSON string is genuinely unconvertible: TryConvert
// must report false rather than a null success.
using var doc = JsonDocument.Parse("""{ "s": "not-a-number" }""");
var strWrapper = new DynamicJsonElement(doc.RootElement.GetProperty("s"));
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(int), typeof(DynamicJsonElementTests));
Assert.False(strWrapper.TryConvert(convBinder, out var result));
Assert.Null(result);
}
}

View File

@@ -0,0 +1,50 @@
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.Commons.Types.Scripts;
namespace ScadaLink.Commons.Tests.Types;
/// <summary>
/// Commons-010: coverage for the small computed-property logic on
/// <see cref="ConfigurationDiff"/> and <see cref="ScriptScope"/>.
/// </summary>
public class FlatteningAndScriptScopeTests
{
[Fact]
public void ConfigurationDiff_NoChanges_HasChangesIsFalse()
{
var diff = new ConfigurationDiff { InstanceUniqueName = "inst-1" };
Assert.False(diff.HasChanges);
}
[Fact]
public void ConfigurationDiff_WithAttributeChange_HasChangesIsTrue()
{
var diff = new ConfigurationDiff
{
InstanceUniqueName = "inst-1",
AlarmChanges = new[]
{
new DiffEntry<ResolvedAlarm> { CanonicalName = "HiAlarm", ChangeType = DiffChangeType.Added }
}
};
Assert.True(diff.HasChanges);
}
[Fact]
public void ScriptScope_Root_HasNoParent()
{
Assert.False(ScriptScope.Root.HasParent);
Assert.Null(ScriptScope.Root.ParentPath);
}
[Fact]
public void ScriptScope_WithParentPath_HasParentIsTrue()
{
var scope = new ScriptScope("Pump1.Motor", "Pump1");
Assert.True(scope.HasParent);
Assert.Equal("Pump1", scope.ParentPath);
}
}

View File

@@ -72,4 +72,20 @@ public class ResultTests
Assert.True(result.IsSuccess);
Assert.Equal("hello", result.Value);
}
// ── Commons-011 regression: a failed Result must always carry a message ──
[Fact]
public void Failure_WithNullError_ShouldThrow()
{
Assert.Throws<ArgumentNullException>(() => Result<int>.Failure(null!));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Failure_WithBlankError_ShouldThrow(string error)
{
Assert.Throws<ArgumentException>(() => Result<int>.Failure(error));
}
}

View File

@@ -0,0 +1,81 @@
using System.Collections;
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Tests.Types;
/// <summary>
/// Commons-010: coverage for <see cref="ScriptArgs.Normalize"/> — the script-call
/// parameter normalizer (dictionary / anonymous-object / primitive-rejection paths).
/// </summary>
public class ScriptArgsTests
{
[Fact]
public void Normalize_Null_ReturnsNull()
{
Assert.Null(ScriptArgs.Normalize(null));
}
[Fact]
public void Normalize_ReadOnlyDictionary_ReturnedAsIs()
{
IReadOnlyDictionary<string, object?> input =
new Dictionary<string, object?> { ["a"] = 1 };
var result = ScriptArgs.Normalize(input);
Assert.Same(input, result);
}
[Fact]
public void Normalize_PlainDictionary_ReturnedAsIs()
{
// Dictionary<string,object?> implements IReadOnlyDictionary, so it matches the
// first switch arm and is returned by reference (no defensive copy).
var input = new Dictionary<string, object?> { ["a"] = 1 };
var result = ScriptArgs.Normalize(input);
Assert.Same(input, result);
Assert.Equal(1, result!["a"]);
}
[Fact]
public void Normalize_NonGenericDictionary_KeysStringified()
{
IDictionary raw = new Hashtable { [42] = "answer" };
var result = ScriptArgs.Normalize(raw);
Assert.Equal("answer", result!["42"]);
}
[Fact]
public void Normalize_AnonymousObject_PropertiesBecomeEntries()
{
var result = ScriptArgs.Normalize(new { name = "Bob", count = 3 });
Assert.Equal("Bob", result!["name"]);
Assert.Equal(3, result["count"]);
}
[Theory]
[InlineData(42)]
[InlineData(true)]
[InlineData(3.14)]
public void Normalize_Primitive_Throws(object primitive)
{
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize(primitive));
}
[Fact]
public void Normalize_String_Throws()
{
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize("hello"));
}
[Fact]
public void Normalize_Decimal_Throws()
{
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize(9.99m));
}
}

View File

@@ -0,0 +1,80 @@
using System.Globalization;
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Tests.Types;
/// <summary>
/// Tests for <see cref="ValueFormatter"/>. Includes the Commons-012 regression:
/// formatting must be culture-invariant because the formatter feeds non-UI contexts
/// (gRPC stream events, logs) where locale-dependent output would be inconsistent.
/// </summary>
public class ValueFormatterTests
{
[Fact]
public void FormatDisplayValue_Null_ReturnsEmptyString()
{
Assert.Equal("", ValueFormatter.FormatDisplayValue(null));
}
[Fact]
public void FormatDisplayValue_String_ReturnsValueUnchanged()
{
Assert.Equal("hello", ValueFormatter.FormatDisplayValue("hello"));
}
[Fact]
public void FormatDisplayValue_Collection_JoinsWithComma()
{
Assert.Equal("1,2,3", ValueFormatter.FormatDisplayValue(new[] { 1, 2, 3 }));
}
// ── Commons-012 regression: culture-invariant numeric/date formatting ──
[Fact]
public void FormatDisplayValue_Double_UsesInvariantCulture_RegardlessOfThreadCulture()
{
var original = CultureInfo.CurrentCulture;
try
{
// German uses a comma as the decimal separator; invariant uses a dot.
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
Assert.Equal("3.14", ValueFormatter.FormatDisplayValue(3.14));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
[Fact]
public void FormatDisplayValue_DateTime_UsesInvariantCulture_RegardlessOfThreadCulture()
{
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
var dt = new DateTime(2026, 5, 16, 0, 0, 0, DateTimeKind.Utc);
var invariant = dt.ToString(CultureInfo.InvariantCulture);
Assert.Equal(invariant, ValueFormatter.FormatDisplayValue(dt));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
[Fact]
public void FormatDisplayValue_CollectionOfDoubles_UsesInvariantCulture()
{
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
Assert.Equal("1.5,2.5", ValueFormatter.FormatDisplayValue(new[] { 1.5, 2.5 }));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
}