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:
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
81
tests/ScadaLink.Commons.Tests/Types/ScriptArgsTests.cs
Normal file
81
tests/ScadaLink.Commons.Tests/Types/ScriptArgsTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
80
tests/ScadaLink.Commons.Tests/Types/ValueFormatterTests.cs
Normal file
80
tests/ScadaLink.Commons.Tests/Types/ValueFormatterTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user