20392cf246
Interlocking changes across the gateway server (shared GatewaySession.cs / SessionManager.cs), committed together: - GWC-01 (Critical): alarm monitor now attaches as an internal (non-counted) distributor subscriber instead of a second raw drain of the single worker event channel; WorkerClient._events -> SingleReader with a claimed-once guard so a future dual-consumer regression throws loudly. - GWC-02 (High): faulted sessions are swept in CloseExpiredLeasesAsync (IsFaultedReapable + FaultedReason); new FaultedGraceSeconds (default 0). - GWC-03 (High): configurable MaxSparseArrayLength (default 1_000_000) enforced before allocation. - TST-02 (High, security): StreamEvents attach now enforces the opening key id -> PermissionDenied on owner mismatch. - TST-12 (Medium): CLAUDE.md retention-defaults sentence corrected. Verified: NonWindows build clean; targeted tests 135/135 on macOS, plus WorkerClientTests 18/18 on the Windows host.
257 lines
10 KiB
C#
257 lines
10 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using Grpc.Core;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
|
|
|
public sealed class SparseArrayExpanderTests
|
|
{
|
|
private static MxValue SparseValue(
|
|
MxDataType elementType,
|
|
uint totalLength,
|
|
params (uint Index, MxValue Value)[] elements)
|
|
{
|
|
MxSparseArray sparse = new()
|
|
{
|
|
ElementDataType = elementType,
|
|
TotalLength = totalLength,
|
|
};
|
|
|
|
foreach ((uint index, MxValue value) in elements)
|
|
{
|
|
sparse.Elements.Add(new MxSparseElement { Index = index, Value = value });
|
|
}
|
|
|
|
return new MxValue { SparseArrayValue = sparse };
|
|
}
|
|
|
|
/// <summary>Verifies that expanding an Int32 sparse array fills unset indices with the default and applies the given element.</summary>
|
|
[Fact]
|
|
public void Expand_Int32_FillsDefaultsAndSetsElement()
|
|
{
|
|
MxValue value = SparseValue(
|
|
MxDataType.Integer,
|
|
4,
|
|
(1, new MxValue { Int32Value = 7 }));
|
|
|
|
SparseArrayExpander.Expand(value);
|
|
|
|
Assert.Equal(MxValue.KindOneofCase.ArrayValue, value.KindCase);
|
|
Assert.Equal(MxDataType.Integer, value.ArrayValue.ElementDataType);
|
|
Assert.Equal(new uint[] { 4 }, value.ArrayValue.Dimensions);
|
|
Assert.Equal(MxArray.ValuesOneofCase.Int32Values, value.ArrayValue.ValuesCase);
|
|
Assert.Equal(new[] { 0, 7, 0, 0 }, value.ArrayValue.Int32Values.Values);
|
|
}
|
|
|
|
/// <summary>Verifies that expanding a Boolean sparse array with no elements fills every index with the default value <c>false</c>.</summary>
|
|
[Fact]
|
|
public void Expand_Boolean_EmptyElements_AllDefaultFalse()
|
|
{
|
|
MxValue value = SparseValue(MxDataType.Boolean, 3);
|
|
|
|
SparseArrayExpander.Expand(value);
|
|
|
|
Assert.Equal(MxArray.ValuesOneofCase.BoolValues, value.ArrayValue.ValuesCase);
|
|
Assert.Equal(new[] { false, false, false }, value.ArrayValue.BoolValues.Values);
|
|
}
|
|
|
|
/// <summary>Verifies that expanding a sparse array with a zero total length throws an invalid-argument <see cref="RpcException"/>.</summary>
|
|
[Fact]
|
|
public void Expand_ZeroTotalLength_Throws()
|
|
{
|
|
MxValue value = SparseValue(MxDataType.Integer, 0);
|
|
|
|
RpcException ex = Assert.Throws<RpcException>(() => SparseArrayExpander.Expand(value));
|
|
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that an element index at or beyond the total length throws an invalid-argument <see cref="RpcException"/>.</summary>
|
|
[Fact]
|
|
public void Expand_IndexOutOfRange_Throws()
|
|
{
|
|
MxValue value = SparseValue(
|
|
MxDataType.Integer,
|
|
2,
|
|
(5, new MxValue { Int32Value = 1 }));
|
|
|
|
RpcException ex = Assert.Throws<RpcException>(() => SparseArrayExpander.Expand(value));
|
|
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that two sparse elements sharing the same index throw an invalid-argument <see cref="RpcException"/>.</summary>
|
|
[Fact]
|
|
public void Expand_DuplicateIndex_Throws()
|
|
{
|
|
MxValue value = SparseValue(
|
|
MxDataType.Integer,
|
|
4,
|
|
(1, new MxValue { Int32Value = 1 }),
|
|
(1, new MxValue { Int32Value = 2 }));
|
|
|
|
RpcException ex = Assert.Throws<RpcException>(() => SparseArrayExpander.Expand(value));
|
|
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that an unspecified element data type throws an invalid-argument <see cref="RpcException"/>.</summary>
|
|
[Fact]
|
|
public void Expand_UnsupportedElementType_Throws()
|
|
{
|
|
MxValue value = SparseValue(MxDataType.Unspecified, 2);
|
|
|
|
RpcException ex = Assert.Throws<RpcException>(() => SparseArrayExpander.Expand(value));
|
|
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that an element value whose kind does not match the declared element data type throws an invalid-argument <see cref="RpcException"/>.</summary>
|
|
[Fact]
|
|
public void Expand_ElementValueKindMismatch_Throws()
|
|
{
|
|
MxValue value = SparseValue(
|
|
MxDataType.Integer,
|
|
2,
|
|
(0, new MxValue { StringValue = "nope" }));
|
|
|
|
RpcException ex = Assert.Throws<RpcException>(() => SparseArrayExpander.Expand(value));
|
|
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that expanding a String sparse array fills unset indices with an empty string default.</summary>
|
|
[Fact]
|
|
public void Expand_String_FillsEmptyStringDefault()
|
|
{
|
|
MxValue value = SparseValue(
|
|
MxDataType.String,
|
|
2,
|
|
(0, new MxValue { StringValue = "a" }));
|
|
|
|
SparseArrayExpander.Expand(value);
|
|
|
|
Assert.Equal(MxArray.ValuesOneofCase.StringValues, value.ArrayValue.ValuesCase);
|
|
Assert.Equal(new[] { "a", string.Empty }, value.ArrayValue.StringValues.Values);
|
|
}
|
|
|
|
/// <summary>Verifies that expanding a Time sparse array fills unset indices with the epoch timestamp default.</summary>
|
|
[Fact]
|
|
public void Expand_Time_FillsEpochDefault()
|
|
{
|
|
MxValue value = SparseValue(
|
|
MxDataType.Time,
|
|
2,
|
|
(1, new MxValue { TimestampValue = new Timestamp { Seconds = 5 } }));
|
|
|
|
SparseArrayExpander.Expand(value);
|
|
|
|
Assert.Equal(MxArray.ValuesOneofCase.TimestampValues, value.ArrayValue.ValuesCase);
|
|
Assert.Equal(2, value.ArrayValue.TimestampValues.Values.Count);
|
|
Assert.Equal(0, value.ArrayValue.TimestampValues.Values[0].Seconds);
|
|
Assert.Equal(0, value.ArrayValue.TimestampValues.Values[0].Nanos);
|
|
Assert.Equal(5, value.ArrayValue.TimestampValues.Values[1].Seconds);
|
|
}
|
|
|
|
/// <summary>Verifies that expanding a Double sparse array fills defaults and applies the given element.</summary>
|
|
[Fact]
|
|
public void Expand_Double_HappyPath()
|
|
{
|
|
MxValue value = SparseValue(
|
|
MxDataType.Double,
|
|
3,
|
|
(2, new MxValue { DoubleValue = 1.5 }));
|
|
|
|
SparseArrayExpander.Expand(value);
|
|
|
|
Assert.Equal(MxArray.ValuesOneofCase.DoubleValues, value.ArrayValue.ValuesCase);
|
|
Assert.Equal(new[] { 0d, 0d, 1.5 }, value.ArrayValue.DoubleValues.Values);
|
|
}
|
|
|
|
/// <summary>Verifies that expanding a Float sparse array fills defaults and applies the given element.</summary>
|
|
[Fact]
|
|
public void Expand_Float_HappyPath()
|
|
{
|
|
MxValue value = SparseValue(
|
|
MxDataType.Float,
|
|
2,
|
|
(0, new MxValue { FloatValue = 2.5f }));
|
|
|
|
SparseArrayExpander.Expand(value);
|
|
|
|
Assert.Equal(MxArray.ValuesOneofCase.FloatValues, value.ArrayValue.ValuesCase);
|
|
Assert.Equal(new[] { 2.5f, 0f }, value.ArrayValue.FloatValues.Values);
|
|
}
|
|
|
|
/// <summary>Verifies that an Integer sparse array whose element is a 64-bit value expands to the Int64 array representation.</summary>
|
|
[Fact]
|
|
public void Expand_Int64_WhenElementIsInt64()
|
|
{
|
|
MxValue value = SparseValue(
|
|
MxDataType.Integer,
|
|
3,
|
|
(2, new MxValue { Int64Value = 9_000_000_000L }));
|
|
|
|
SparseArrayExpander.Expand(value);
|
|
|
|
Assert.Equal(MxArray.ValuesOneofCase.Int64Values, value.ArrayValue.ValuesCase);
|
|
Assert.Equal(new[] { 0L, 0L, 9_000_000_000L }, value.ArrayValue.Int64Values.Values);
|
|
}
|
|
|
|
/// <summary>Verifies that expanding a non-sparse-array value is a no-op, leaving the original value unchanged.</summary>
|
|
[Fact]
|
|
public void Expand_NonSparseValue_NoOps()
|
|
{
|
|
MxValue value = new() { Int32Value = 42 };
|
|
|
|
SparseArrayExpander.Expand(value);
|
|
|
|
Assert.Equal(MxValue.KindOneofCase.Int32Value, value.KindCase);
|
|
Assert.Equal(42, value.Int32Value);
|
|
}
|
|
|
|
/// <summary>Verifies that expanding a null value throws <see cref="ArgumentNullException"/>.</summary>
|
|
[Fact]
|
|
public void Expand_NullValue_ThrowsArgumentNull()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => SparseArrayExpander.Expand(null!));
|
|
}
|
|
|
|
/// <summary>Verifies that a total length exceeding the maximum supported array length throws an invalid-argument <see cref="RpcException"/>.</summary>
|
|
[Fact]
|
|
public void Expand_TotalLengthExceedsMaxArrayLength_Throws()
|
|
{
|
|
MxValue value = SparseValue(MxDataType.Integer, 2_147_483_648u);
|
|
|
|
RpcException ex = Assert.Throws<RpcException>(() => SparseArrayExpander.Expand(value));
|
|
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that a total length above the configured cap throws <see cref="StatusCode.InvalidArgument"/> before the full array is allocated.</summary>
|
|
[Fact]
|
|
public void Expand_TotalLengthExceedsConfiguredCap_ThrowsBeforeAllocation()
|
|
{
|
|
// A total_length that would force a huge allocation, but well below Array.MaxLength,
|
|
// so only the configured cap can reject it (the Array.MaxLength backstop would not).
|
|
MxValue value = SparseValue(MxDataType.Integer, 500_000_000u);
|
|
|
|
RpcException ex = Assert.Throws<RpcException>(() => SparseArrayExpander.Expand(value, maxSparseArrayLength: 1_000_000));
|
|
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
|
Assert.Contains("MaxSparseArrayLength", ex.Status.Detail, StringComparison.Ordinal);
|
|
|
|
// The value must be untouched — expansion (allocation) never ran.
|
|
Assert.Equal(MxValue.KindOneofCase.SparseArrayValue, value.KindCase);
|
|
}
|
|
|
|
/// <summary>Verifies that a total length at or below the configured cap still expands normally.</summary>
|
|
[Fact]
|
|
public void Expand_TotalLengthAtConfiguredCap_Expands()
|
|
{
|
|
MxValue value = SparseValue(
|
|
MxDataType.Integer,
|
|
4,
|
|
(1, new MxValue { Int32Value = 7 }));
|
|
|
|
SparseArrayExpander.Expand(value, maxSparseArrayLength: 4);
|
|
|
|
Assert.Equal(MxValue.KindOneofCase.ArrayValue, value.KindCase);
|
|
Assert.Equal(new[] { 0, 7, 0, 0 }, value.ArrayValue.Int32Values.Values);
|
|
}
|
|
}
|