Compare commits

...

6 Commits

Author SHA1 Message Date
Joseph Doherty 627c17fae1 fix(gateway): reject oversized sparse array total_length with InvalidArgument
Guard against proto uint32 total_length values that exceed Array.MaxLength
before casting; the previous checked cast threw OverflowException (gRPC
Internal) instead of the intended InvalidArgument. Adds tests for the new
guard, for the null-value ArgumentNullException path, and removes the
checked keyword (redundant after the guard).
2026-06-18 02:58:03 -04:00
Joseph Doherty 34a99c783b feat(gateway): add SparseArrayExpander for default-fill partial array writes 2026-06-18 02:52:33 -04:00
Joseph Doherty 52cd0da9f5 feat(gateway): add ArrayAddressNormalizer for bare-name array AddItem 2026-06-18 02:51:37 -04:00
Joseph Doherty 8ac9a33d91 feat(contracts): add MxSparseArray write-only value for default-fill partial writes 2026-06-18 02:48:18 -04:00
Joseph Doherty dd35ae1fe6 docs(plans): implementation plan for array write ergonomics + default-fill partial writes 2026-06-18 02:46:09 -04:00
Joseph Doherty 4a6a79d02e docs(plans): design for array write ergonomics and default-fill partial writes 2026-06-18 02:39:00 -04:00
9 changed files with 1921 additions and 148 deletions
@@ -0,0 +1,193 @@
# Array Write Ergonomics & Default-Fill Partial Writes — Design
Date: 2026-06-18
## Problem
Writing array-typed MXAccess attributes through the gateway has two ergonomic
shortfalls:
1. **Asymmetric addressing.** An array attribute reads fine by its bare name
(`Obj.Arr`), but writes require the `[]` body suffix (`Obj.Arr[]`). The
handle registered from the bare name is read-capable but not cleanly
write-capable.
2. **Whole-array writes only.** Every write replaces the entire array; to change
a few elements the client must marshal and send the full array. This is a
native MXAccess COM constraint (there is no element-wise write API), but it
pushes avoidable cost onto clients for large arrays.
This design removes both frictions without breaking MXAccess parity. The worker
is not modified — it continues to perform an honest whole-array COM write. All
new behavior lives in the gateway and the contract.
## Why MXAccess forces whole-array writes
The native MXAccess COM `Write` takes a complete VARIANT (`SAFEARRAY` for
arrays). There is no `WriteArrayElement(index, value)`. Confirmed in the worker:
`VariantConverter.ConvertToComArray` marshals the entire CLR array in one shot,
and `MxAccessSession.Write` forwards it verbatim to the COM proxy. Any "partial
write" feature must therefore reconstruct a full array before the COM call.
We deliberately do **not** reconstruct it from current state (no
read-modify-write merge): that would add latency, cache-staleness, and a race
window against other writers, and would paper over MXAccess semantics. Instead
partial writes are **stateless default-fill** (see below).
## Goals
- Writing an array attribute by its bare name works like reading does — the
gateway appends `[]` automatically when it knows the attribute is an array.
- A client can send only the indices it wants plus a total length, instead of
the full array.
## Non-goals
- **No preserve-unchanged merge.** Unmentioned indices are written as the
element type's default, **not** kept at their current value.
- No element-wise COM write — MXAccess has no such API; every write is
whole-array and we keep it that way.
- No change to `ReadBulk` string addressing.
- The gateway does **not** infer total length; the client supplies it.
## Decisions (resolved during brainstorming)
| Question | Decision |
|---|---|
| Scope | Both: suffix ergonomics **and** partial writes |
| Partial-write semantics | Stateless **default-fill**: unmentioned indices = type default (reset, not preserved) |
| Total length | **Client specifies** `total_length` explicitly |
| Time/timestamp default | **Unix epoch** |
| Suffix fix location/actor | **Gateway**, using in-memory Galaxy `is_array` metadata, at **AddItem** time |
| Suffix fallback when metadata unavailable | **Pass through unchanged** (no regression) |
| Partial-write contract shape | New `MxSparseArray` as a `oneof` arm on `MxValue` |
| Per-client helpers | **Included** in this change |
## Contract changes (`mxaccess_gateway.proto`)
A write-only sparse representation, added as a `oneof kind` arm on `MxValue` so
every write command (`Write`, `Write2`, `WriteSecured`, `WriteSecured2`,
`WriteBulkEntry`) accepts it without new RPCs:
```proto
message MxSparseArray {
MxDataType element_data_type = 1;
uint32 total_length = 2;
repeated MxSparseElement elements = 3;
}
message MxSparseElement {
uint32 index = 1;
MxValue value = 2; // scalar
}
// added to MxValue oneof kind:
// MxSparseArray sparse_array_value = 19;
```
`sparse_array_value` is **write-only**: the worker never produces it, and the
gateway rejects it on any read/event path. Regenerate `Generated/` and commit
the generated `.cs` (the net48 worker build needs the checked-in types — see the
proto-codegen-regen rule).
## Suffix normalization — at `AddItem`, in the gateway
The item handle binds to the literal address string at `AddItem` and is reused
for both reads and writes; at write time only the integer handle is available,
which is too late to change the address. So normalization happens at
registration.
In the gateway's `AddItemCommand` / `AddItem2Command` handling
(`GatewaySession`), before forwarding to the worker:
1. If `item_definition` already ends with `[]` → leave unchanged.
2. Else look up `item_definition + "[]"` in the in-memory Galaxy hierarchy cache
(`IGalaxyHierarchyCache` → `GalaxyTagLookup.Attribute.IsArray`). The index is
keyed by `FullTagReference`, which already carries the `[]` suffix for
arrays, so the lookup key must include `[]`. If found and `is_array` →
rewrite `item_definition` to the `[]` form.
3. **Fallback:** metadata unavailable or address not found as an array →
forward verbatim (current behavior).
Store the **normalized** address in `SessionItemRegistration.TagAddress` so
write-time constraint checks (`ConstraintEnforcer`) and readback resolve
consistently against the `[]`-keyed index.
This is safe for reads: both the bare and `[]` forms return the array on read,
so promoting a registration to the `[]` form does not change read behavior — it
only makes the handle write-capable.
`AddItem2Command` (with `item_context`) normalizes `item_definition` the same
way. `ReadBulk` is unaffected — it uses raw address strings with its own
ephemeral registration, so bare-name reads continue to work unchanged.
## Partial-write expansion — at the gateway, worker untouched
In the gateway write path, before forwarding any write command to the worker, if
`MxValue.KindCase == SparseArrayValue`:
1. Allocate a full array of `total_length`, element type `element_data_type`.
2. Initialize every slot to the type default:
- `bool` → `false`
- `int32` / `int64` → `0`
- `float` / `double` → `0`
- `string` → `""`
- `time` / `timestamp` → Unix epoch
3. For each `MxSparseElement`, set `array[index]` from the scalar `value`.
4. Replace the `MxValue` with a normal `array_value` (full `MxArray`).
The worker then receives an ordinary whole-array `MxValue`;
`VariantConverter.ConvertToComArray` and the COM `Write` are unchanged. Parity
preserved — it really is a whole-array write.
Expansion is applied uniformly to every write variant by normalizing the
`MxValue` of each command (`Write`, `Write2`, `WriteSecured`, `WriteSecured2`,
and each `WriteBulkEntry`) before it leaves the gateway.
## Validation & errors (gateway, `InvalidArgument`)
- `total_length == 0`, or any `index >= total_length` → reject.
- Duplicate indices → reject (no silent last-wins).
- `element_data_type` must be a supported scalar element type (not `Raw` /
`Unspecified`); each element `value` must match it.
- Empty `elements` with `total_length = N` → valid: writes an all-defaults array
of length N (explicit reset).
- A sparse value arriving on a read/event path → reject (guard; the worker never
produces one).
## Clients (all five) & docs — same change
Per the repo rule that docs change with the source:
- Regenerate proto types for dotnet, go, python, rust, java. Watch the Java
generated-file churn — revert spurious protobuf-version diffs when no `.proto`
semantics changed beyond the new messages; commit the net48-relevant regen.
- Add a thin per-client helper to build a sparse write, e.g.
`WriteArrayElements(handle, totalLength, {index → value})`.
- Update the **"Array writes replace the whole array"** section in all five
client READMEs: document default-fill semantics (unmentioned = reset to
default, not preserved), the `total_length` requirement, and that bare-name
array writes now auto-normalize to the `[]` form.
- Update `gateway.md` (command/value surface) and the value-conversion doc.
## Testing
- **Gateway (FakeWorkerHarness):**
- Sparse → full expansion per element type; default-fill sizing; correct
placement of specified indices.
- `total_length == 0`, index-out-of-range, and duplicate-index rejection.
- Empty-elements all-defaults case.
- Suffix normalization: bare array → `[]`; bare scalar → unchanged;
already-`[]` → unchanged; metadata-cold → pass-through.
- **Clients:** helper + round-trip serialization per language.
- **Live MXAccess (opt-in, windev):** one default-fill write and one bare-name
array write against real COM.
## Affected components
- Contracts: `mxaccess_gateway.proto` + regenerated `Generated/`.
- Gateway: `GatewaySession` (AddItem normalization, write expansion),
`SessionItemRegistration` (store normalized address), interaction with
`IGalaxyHierarchyCache` / `GalaxyTagLookup`.
- Worker: **unchanged.**
- Clients: dotnet, go, python, rust, java (regenerated types + helper + README).
- Docs: `gateway.md`, value-conversion doc, five client READMEs.
@@ -0,0 +1,329 @@
# Array Write Ergonomics & Default-Fill Partial Writes — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Let clients write array attributes by their bare name (gateway auto-appends `[]` at AddItem), and write a sparse, default-filled array (only the indices they care about + a total length) instead of marshalling the whole array.
**Architecture:** All new behavior lives in the **contract** and the **gateway**; the worker is untouched and keeps doing an honest whole-array COM write. The gateway intercepts outbound commands at the single choke point `GatewaySession.InvokeAsync(WorkerCommand)`: it (a) normalizes `AddItem`/`AddItem2` `item_definition` to the `[]` form when Galaxy metadata says the attribute is an array, and (b) expands an `MxSparseArray` write value into a full default-filled `MxArray` before it leaves the gateway. Partial writes are **stateless default-fill** — unmentioned indices are the type default (reset), never preserved.
**Tech Stack:** .NET 10 (gateway) / .NET Framework 4.8 x86 (worker, unchanged), protobuf + Grpc.Tools, xUnit + FakeWorkerHarness; clients in C#, Go, Python, Rust, Java.
**Design doc:** `docs/plans/2026-06-18-array-write-ergonomics-design.md`
**Key references for the implementer:**
- Choke point for all outbound commands: `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs:947-955` (`InvokeAsync(WorkerCommand command, ...)`). `command.Command` is the `MxCommand`.
- Handle→address tracking: `GatewaySession.TrackCommandReply` (lines 975-1014, AddItem at 989, AddItem2 at 992) → `TrackItem` (1826-1837) → `SessionItemRegistration` record (`Sessions/SessionItemRegistration.cs`). Tracking reads the **same** `MxCommand` instance that passed through `InvokeAsync`, so mutating `item_definition` there flows through automatically.
- Galaxy metadata lookup: `IGalaxyHierarchyCache.Current.Index.TagsByAddress.TryGetValue(addr, out GalaxyTagLookup)`, then `lookup.Attribute?.IsArray`. The index is keyed by `FullTagReference`, which **already contains** `[]` for arrays — look up `addr + "[]"`. See `Security/Authorization/ConstraintEnforcer.cs:15-17,197-204` for the injection + lookup pattern.
- Proto: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` — `MxValue` (1026-1044), `MxArray` (1046-1063), `WriteCommand` (244-249), `Write2Command` (251-257), `WriteSecuredCommand`/`WriteSecured2Command`, `WriteBulk*`, `AddItemCommand` (192-195), `AddItem2Command` (197-201). Generated into `Contracts/Generated/MxaccessGateway.cs` (Compile-Removed + regenerated by Grpc.Tools).
- Gateway proto regen + commit rule (memory `project_proto_codegen_regen`): after a `.proto` edit, delete `Generated/*.cs`, rebuild contracts to regenerate, and **commit** `Generated/` or the net48 worker build fails CS0246.
- Java client gotcha (memory `project_java_generated_churn`): gradle regenerates a tracked 64k-line file with spurious protobuf-version churn — revert that churn; build/test Java on **windev** (memory `project_java_build_host`), Mac has no JRE.
---
## Task 0: Contract — add `MxSparseArray` and regenerate
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blocks all other tasks)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (MxValue oneof ~line 1043; new messages after MxArray ~line 1063)
- Regenerate + commit: `src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs`
**Step 1: Add the messages to the proto.** After the `MxArray` message (line 1063), add:
```proto
// Write-only sparse array value. The gateway expands this into a full,
// default-filled MxArray before forwarding to the worker; the worker never
// receives or produces it. Unmentioned indices take the element type's
// default (reset, NOT preserved).
message MxSparseArray {
MxDataType element_data_type = 1;
uint32 total_length = 2;
repeated MxSparseElement elements = 3;
}
message MxSparseElement {
uint32 index = 1;
MxValue value = 2; // scalar
}
```
**Step 2: Add the oneof arm to `MxValue`.** Inside the `oneof kind { ... }` block, after `bytes raw_value = 18;`:
```proto
MxSparseArray sparse_array_value = 19;
```
**Step 3: Regenerate generated code.**
Run (PowerShell on windev, or locally on Mac — .NET builds fine):
```
del src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
```
Expected: build succeeds; `Generated/MxaccessGateway.cs` now contains `MxSparseArray`, `MxSparseElement`, and `MxValue.SparseArrayValue`.
**Step 4: Verify net10 + net48 both compile** (the worker consumes these types via net48):
```
dotnet build src/ZB.MOM.WW.MxGateway.slnx
```
Expected: PASS (no CS0246 on the new types).
**Step 5: Commit** (include regenerated `Generated/`):
```bash
git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto \
src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs
git commit -m "feat(contracts): add MxSparseArray write-only value for default-fill partial writes"
```
---
## Task 1: Gateway — `SparseArrayExpander` (pure expansion + validation)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Tasks 4-9
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Server/Sessions/SparseArrayExpander.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SparseArrayExpanderTests.cs`
**Step 1: Write failing tests.** Cover: default-fill sizing + placement (one per element type is enough for two types here, rest in step 4); `total_length == 0` → `InvalidArgument`; index `>= total_length` → `InvalidArgument`; duplicate index → `InvalidArgument`; `Raw`/`Unspecified` element type → `InvalidArgument`; empty `elements` → all-defaults array of length N; timestamp default == Unix epoch.
```csharp
using Grpc.Core;
using Mxaccess.Gateway.V1; // adjust to the generated namespace
using ZB.MOM.WW.MxGateway.Server.Sessions;
using Xunit;
public sealed class SparseArrayExpanderTests
{
private static MxValue Sparse(MxDataType type, uint length, params (uint Index, MxValue Value)[] els)
{
MxSparseArray sparse = new() { ElementDataType = type, TotalLength = length };
foreach ((uint index, MxValue value) in els)
sparse.Elements.Add(new MxSparseElement { Index = index, Value = value });
return new MxValue { SparseArrayValue = sparse };
}
[Fact]
public void Expand_Int32_FillsDefaultsAndPlacesValues()
{
MxValue v = Sparse(MxDataType.Integer, 4, (1, new MxValue { Int32Value = 7 }));
SparseArrayExpander.Expand(v);
Assert.Equal(MxValue.KindOneofCase.ArrayValue, v.KindCase);
Assert.Equal(new[] { 0, 7, 0, 0 }, v.ArrayValue.Int32Values.Values);
Assert.Equal((uint)4, v.ArrayValue.Dimensions[0]);
}
[Fact]
public void Expand_EmptyElements_ProducesAllDefaults()
{
MxValue v = Sparse(MxDataType.Boolean, 3);
SparseArrayExpander.Expand(v);
Assert.Equal(new[] { false, false, false }, v.ArrayValue.BoolValues.Values);
}
[Theory]
[InlineData(0u, 0u)] // total_length == 0
[InlineData(2u, 5u)] // index >= total_length
public void Expand_InvalidShape_Throws(uint length, uint badIndex)
{
MxValue v = Sparse(MxDataType.Integer, length, (badIndex, new MxValue { Int32Value = 1 }));
RpcException ex = Assert.Throws<RpcException>(() => SparseArrayExpander.Expand(v));
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
[Fact]
public void Expand_DuplicateIndex_Throws()
{
MxValue v = Sparse(MxDataType.Integer, 4,
(1, new MxValue { Int32Value = 1 }), (1, new MxValue { Int32Value = 2 }));
Assert.Throws<RpcException>(() => SparseArrayExpander.Expand(v));
}
}
```
**Step 2: Run, confirm fail.** `dotnet test src/ZB.MOM.WW.MxGateway.Tests/... --filter FullyQualifiedName~SparseArrayExpanderTests` → FAIL (type not defined).
**Step 3: Implement `SparseArrayExpander`.** Mutates the passed `MxValue` in place, replacing `SparseArrayValue` with `ArrayValue`. Throw `RpcException(new Status(StatusCode.InvalidArgument, msg))` on any validation failure. Element-type switch must cover the supported scalar element types (`Boolean`, `Integer` → int32 or int64, `Float`, `Double`, `String`, `Time`); default/timestamp default = Unix epoch (`new Timestamp { Seconds = 0, Nanos = 0 }`); reject `Raw`/`Unknown`/`Unspecified`. Set `MxArray.Dimensions = { total_length }` and `ElementDataType`. Validate: `total_length > 0`, every index `< total_length`, no duplicate indices, each element `value` scalar kind matches `element_data_type`.
(Mirror the typed sub-array shapes from `VariantConverter.ConvertToComArray` in the worker so the worker's existing read path is satisfied: `Int32Values`/`Int64Values`/`BoolValues`/`FloatValues`/`DoubleValues`/`StringValues`/`TimestampValues` with their `Values` repeated fields.)
**Step 4: Add remaining element-type tests** (int64, float, double, string, time/epoch, type-mismatch element → throws). Run filter → PASS.
**Step 5: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Server/Sessions/SparseArrayExpander.cs \
src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SparseArrayExpanderTests.cs
git commit -m "feat(gateway): add SparseArrayExpander for default-fill partial array writes"
```
---
## Task 2: Gateway — `ArrayAddressNormalizer` (suffix normalization)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Tasks 4-9
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Server/Sessions/ArrayAddressNormalizer.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/ArrayAddressNormalizerTests.cs`
**Step 1: Write failing tests** using a fake/in-memory `IGalaxyHierarchyCache` whose `Current.Index.TagsByAddress` contains `"Obj.Arr[]"` (array) and `"Obj.Scalar"` (non-array). Cases:
- `"Obj.Arr"` (bare, is array) → `"Obj.Arr[]"`.
- `"Obj.Arr[]"` (already suffixed) → unchanged.
- `"Obj.Scalar"` (non-array) → unchanged.
- `"Obj.Unknown"` (not in cache / metadata cold) → unchanged (pass-through fallback).
```csharp
[Fact]
public void Normalize_BareArrayName_AppendsSuffix()
{
ArrayAddressNormalizer normalizer = new(FakeCacheWith("Obj.Arr[]", isArray: true));
Assert.Equal("Obj.Arr[]", normalizer.Normalize("Obj.Arr"));
}
[Theory]
[InlineData("Obj.Arr[]")] // already suffixed
[InlineData("Obj.Scalar")] // non-array
[InlineData("Obj.Unknown")] // not in cache → fallback pass-through
public void Normalize_LeavesUnchanged(string address) =>
Assert.Equal(address, new ArrayAddressNormalizer(FakeCacheWith("Obj.Arr[]", true)).Normalize(address));
```
**Step 2: Run, confirm fail.**
**Step 3: Implement.** Constructor injects `IGalaxyHierarchyCache cache`. `Normalize(string)`:
1. If `string.IsNullOrWhiteSpace(address)` or `address.EndsWith("[]", StringComparison.Ordinal)` → return unchanged.
2. Look up `address + "[]"` in `cache.Current.Index.TagsByAddress`. If found and `lookup.Attribute?.IsArray == true` → return `address + "[]"`.
3. Otherwise return `address` unchanged. Never throw (best-effort convenience).
**Step 4: Run filter → PASS.**
**Step 5: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Server/Sessions/ArrayAddressNormalizer.cs \
src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/ArrayAddressNormalizerTests.cs
git commit -m "feat(gateway): add ArrayAddressNormalizer for bare-name array AddItem"
```
---
## Task 3: Gateway — wire normalization + expansion into the outbound path
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Tasks 1, 2)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs` (constructor — inject `ArrayAddressNormalizer`; `InvokeAsync` at 947-955)
- Modify: DI registration wherever `ArrayAddressNormalizer`/`GatewaySession` deps are registered (search `Security/Authorization/ConstraintEnforcer` registration for the pattern; register `ArrayAddressNormalizer` as scoped/singleton consistent with `IGalaxyHierarchyCache`)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewayArrayWriteWiringTests.cs`
**Step 1: Write a failing integration test** with `FakeWorkerHarness` (pattern: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs:51-69` — `CreateConnectedPairAsync`, `ReadCommandAsync`, `ReplyToCommandAsync`). Two assertions:
1. Client sends `AddItemCommand{ item_definition = "Obj.Arr" }` (array per the test's Galaxy cache) → the `WorkerEnvelope` the fake worker reads has `item_definition == "Obj.Arr[]"`.
2. Client sends `WriteCommand{ value = MxSparseArray(Integer, 4, {1:7}) }` → the worker receives `value.ArrayValue.Int32Values.Values == [0,7,0,0]` (no `SparseArrayValue` reaches the worker).
**Step 2: Run, confirm fail.**
**Step 3: Implement.** At the top of `InvokeAsync(WorkerCommand command, ...)`, before forwarding, transform `command.Command` (the `MxCommand`) by `PayloadCase`:
- `AddItem` → `command.Command.AddItem.ItemDefinition = _addressNormalizer.Normalize(command.Command.AddItem.ItemDefinition);`
- `AddItem2` → same on `AddItem2.ItemDefinition`.
- `Write`/`WriteSecured` → if `cmd.Value?.KindCase == SparseArrayValue` call `SparseArrayExpander.Expand(cmd.Value)`.
- `Write2`/`WriteSecured2` → expand `Value` only (not `TimestampValue`).
- `WriteBulk`/`Write2Bulk`/`WriteSecuredBulk`/`WriteSecured2Bulk` → expand each `entry.Value`.
Keep it a single private helper `NormalizeOutbound(MxCommand)` called once at the choke point. Because `TrackCommandReply` later reads the **same** `MxCommand` instance, the normalized `item_definition` flows into `SessionItemRegistration` with no extra change — add an assertion in the test that `TryGetItemRegistration(...).TagAddress == "Obj.Arr[]"` to lock that in.
**Step 4: Run the wiring test + Tasks 1/2 filters → PASS.** Then run the AddItem/Write fake-worker regression group once:
```
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~ArrayWrite|FullyQualifiedName~SparseArray|FullyQualifiedName~ArrayAddressNormalizer"
```
**Step 5: Commit.**
```bash
git add src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs \
src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/GatewayArrayWriteWiringTests.cs
git commit -m "feat(gateway): normalize array AddItem suffix and expand sparse writes at the worker boundary"
```
---
## Tasks 4-8: Client helpers + READMEs (one task per client, parallelizable)
Each client task does the same four things; only paths/idioms differ. **Depends on Task 0** (needs regenerated proto types). All five are parallelizable with each other and with Tasks 1-3, 9.
Common helper contract: `WriteArrayElements(serverHandle, itemHandle, elementDataType, totalLength, elements /* index→scalar MxValue */, userId)` builds an `MxValue { SparseArrayValue = MxSparseArray{...} }` and calls the existing raw write. Add a unit test that the built command carries `sparse_array_value` with the right `total_length`/indices (serialization round-trip; no live gateway). Update the **"Array writes replace the whole array"** README section to document: default-fill semantics (unmentioned = reset to default, not preserved), the required `total_length`, and that bare-name array writes now auto-normalize.
### Task 4: .NET client
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 5-9, 1-3
- Regenerate types: `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx`.
- Add helper next to `WriteAsync` in `clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewaySession.cs:678-688`.
- Test alongside existing client tests; README `clients/dotnet/README.md:162-170`.
- Verify: build slnx + `dotnet test` the client test project.
### Task 5: Go client
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4,6-9, 1-3
- Regenerate per `clients/go` README; helper next to `Write`/`WriteRaw` in `clients/go/mxgateway/session.go:559-581`.
- README `clients/go/README.md:139-147`.
- Verify: `gofmt`, `go build ./...`, `go test ./...` from `clients/go`.
### Task 6: Python client
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4-5,7-9, 1-3
- Regenerate per `clients/python` README; helper next to `write` in `clients/python/src/zb_mom_ww_mxgateway/session.py:469-490`.
- README `clients/python/README.md:142-150`.
- Verify: `python -m pytest` from `clients/python`.
### Task 7: Rust client
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4-6,8-9, 1-3
- Helper next to `write` in `clients/rust/src/session.rs:530-548`; conversion helpers in `clients/rust/src/value.rs`.
- README `clients/rust/README.md:162-170`.
- Verify: `cargo fmt`, `cargo check --workspace`, `cargo test --workspace`, `cargo clippy --all-targets -- -D warnings` from `clients/rust`.
### Task 8: Java client
**Classification:** standard · **~5 min** · **Parallelizable with:** Tasks 4-7,9, 1-3
- Helper next to `write`/`writeRaw` in `clients/java/.../client/MxGatewaySession.java:581-604`.
- README `clients/java/README.md:118-126`.
- **Build/test on windev (JDK 21) via an isolated `origin/<branch>` worktree — Mac has no JRE** (memory `project_java_build_host`). After gradle regen, **revert the spurious protobuf-version churn** in `clients/java/src/main/generated/.../MxaccessGateway.java` if no proto semantics beyond the new messages changed (memory `project_java_generated_churn`); keep only the real `MxSparseArray` additions.
- Verify: `gradle test` on windev.
(Each client task ends with its own commit, e.g. `feat(client-<lang>): add WriteArrayElements default-fill helper and document semantics`.)
---
## Task 9: Docs — gateway.md + value conversion
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Tasks 1-8 (depends on Task 0 only)
**Files:**
- Modify: `gateway.md` (command/value surface — document `MxSparseArray` as a write-only value and bare-name AddItem normalization)
- Modify: the value-conversion doc under `docs/` (search for where `MxArray`/value conversion is described) — add the default-fill + epoch-default note and the parity statement (worker still does a whole-array COM write)
**Step 1:** Add a subsection describing: `sparse_array_value` is write-only and gateway-expanded; default-fill semantics (epoch for time); `total_length` required; bare-name array writes auto-normalize to `[]` at AddItem with metadata pass-through fallback; non-goal: no preserve-unchanged merge, no element-wise COM write.
**Step 2: Commit.**
```bash
git add gateway.md docs/
git commit -m "docs: document MxSparseArray default-fill writes and bare-name array AddItem"
```
---
## Dependency summary
- **Task 0** blocks everything.
- **Tasks 1, 2** depend on 0; parallel with each other.
- **Task 3** depends on 1 and 2.
- **Tasks 4-8** depend on 0; parallel with each other and with 1-3, 9.
- **Task 9** depends on 0; parallel with all.
## Verification gates (per CLAUDE.md targeted-tests rule)
- Run only the `--filter` for the task you touched; run the array-write fake-worker group once after Task 3.
- Java verified on windev only. .NET/Go/Rust/Python verified locally.
- Live MXAccess (opt-in, windev): after merge, one default-fill write and one bare-name array write against real COM (`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`).
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-06-18-array-write-ergonomics.md",
"tasks": [
{"id": 0, "subject": "Task 0: Contract — add MxSparseArray + regenerate", "status": "pending"},
{"id": 1, "subject": "Task 1: Gateway SparseArrayExpander", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Gateway ArrayAddressNormalizer", "status": "pending", "blockedBy": [0]},
{"id": 3, "subject": "Task 3: Wire normalization + expansion into GatewaySession", "status": "pending", "blockedBy": [1, 2]},
{"id": 4, "subject": "Task 4: .NET client WriteArrayElements + README", "status": "pending", "blockedBy": [0]},
{"id": 5, "subject": "Task 5: Go client WriteArrayElements + README", "status": "pending", "blockedBy": [0]},
{"id": 6, "subject": "Task 6: Python client write_array_elements + README", "status": "pending", "blockedBy": [0]},
{"id": 7, "subject": "Task 7: Rust client write_array_elements + README", "status": "pending", "blockedBy": [0]},
{"id": 8, "subject": "Task 8: Java client writeArrayElements + README (windev)", "status": "pending", "blockedBy": [0]},
{"id": 9, "subject": "Task 9: Docs — gateway.md + value conversion", "status": "pending", "blockedBy": [0]}
],
"lastUpdated": "2026-06-18"
}
File diff suppressed because it is too large Load Diff
@@ -1040,6 +1040,7 @@ message MxValue {
google.protobuf.Timestamp timestamp_value = 16;
MxArray array_value = 17;
bytes raw_value = 18;
MxSparseArray sparse_array_value = 19;
}
}
@@ -1062,6 +1063,21 @@ message MxArray {
}
}
// Write-only sparse array value. The gateway expands this into a full,
// default-filled MxArray before forwarding to the worker; the worker never
// receives or produces it. Unmentioned indices take the element type's
// default (reset, NOT preserved).
message MxSparseArray {
MxDataType element_data_type = 1;
uint32 total_length = 2;
repeated MxSparseElement elements = 3;
}
message MxSparseElement {
uint32 index = 1;
MxValue value = 2; // scalar
}
message BoolArray {
repeated bool values = 1;
}
@@ -0,0 +1,43 @@
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// <summary>
/// Rewrites a bare MXAccess attribute address to its writable array form by appending the
/// trailing <c>[]</c> suffix when Galaxy Repository metadata reports the attribute as an array.
/// MXAccess requires the <c>[]</c> suffix on the AddItem address for an array attribute to be
/// writable; the bare name registers a read-only-ish handle. This is best-effort: when metadata
/// is cold, the address is unknown, or the attribute is not an array, the address is returned
/// unchanged and no exception is thrown.
/// </summary>
public sealed class ArrayAddressNormalizer(IGalaxyHierarchyCache cache)
{
private const string ArraySuffix = "[]";
/// <summary>
/// Returns <paramref name="address"/> with a trailing <c>[]</c> appended when Galaxy metadata
/// reports it as an array attribute; otherwise returns it unchanged. Never throws.
/// </summary>
/// <param name="address">The MXAccess attribute address to normalize.</param>
/// <returns>The normalized address, or the original address when no rewrite applies.</returns>
public string Normalize(string address)
{
if (string.IsNullOrWhiteSpace(address))
{
return address;
}
if (address.EndsWith(ArraySuffix, StringComparison.Ordinal))
{
return address;
}
// Galaxy SQL keys array attributes by their suffixed FullTagReference (e.g. "Obj.Arr[]"),
// so probe for the suffixed form to decide whether the bare name is an array.
string suffixed = address + ArraySuffix;
return cache.Current.Index.TagsByAddress.TryGetValue(suffixed, out GalaxyTagLookup? lookup)
&& lookup.Attribute?.IsArray == true
? suffixed
: address;
}
}
@@ -0,0 +1,285 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// <summary>
/// Expands a client-supplied sparse array write (<see cref="MxSparseArray"/>) into a
/// full, default-filled <see cref="MxArray"/> in place.
/// </summary>
/// <remarks>
/// MXAccess has no partial-array write primitive: a write replaces the whole array.
/// Clients that only care about a few indices send an <see cref="MxSparseArray"/> with
/// the total length plus the indices they want to set; this expander materializes the
/// full array so the worker can do an ordinary whole-array COM write. Indices the client
/// did not mention are reset to the element type's default (they are NOT preserved from
/// the live value); this is intentional, because the gateway cannot read-modify-write
/// without racing the provider.
///
/// For the MXAccess <c>Integer</c> element type the worker's COM-array converter (see
/// <c>VariantConverter.ConvertArray</c>) chooses between a 32-bit and 64-bit sub-array
/// based on the CLR element type. The sparse value carries no CLR array, so this expander
/// mirrors that choice by inspecting the supplied element value kinds: if any element is an
/// <see cref="MxValue.KindOneofCase.Int64Value"/> the whole array is emitted as
/// <see cref="MxArray.Int64Values"/>; otherwise it is emitted as
/// <see cref="MxArray.Int32Values"/> (matching a default Integer array).
/// </remarks>
internal static class SparseArrayExpander
{
/// <summary>
/// Replaces <paramref name="value"/>'s <see cref="MxValue.SparseArrayValue"/> with an
/// equivalent full <see cref="MxValue.ArrayValue"/>. If <paramref name="value"/> is not
/// a sparse array this is a no-op, so callers may invoke it unconditionally.
/// </summary>
/// <param name="value">The value to expand in place.</param>
/// <exception cref="RpcException">
/// <see cref="StatusCode.InvalidArgument"/> when the sparse payload is invalid: zero
/// total length, an index at or beyond the total length, a duplicate index, an
/// unsupported element type, or an element value whose kind does not match the declared
/// element type.
/// </exception>
public static void Expand(MxValue value)
{
ArgumentNullException.ThrowIfNull(value);
if (value.KindCase != MxValue.KindOneofCase.SparseArrayValue)
{
return;
}
MxSparseArray sparse = value.SparseArrayValue;
MxDataType elementType = sparse.ElementDataType;
uint totalLength = sparse.TotalLength;
if (totalLength == 0)
{
throw Invalid("Sparse array total_length must be greater than zero.");
}
if (!IsSupportedElementType(elementType))
{
throw Invalid($"Sparse array element_data_type '{elementType}' is not a supported scalar element type.");
}
if (totalLength > (uint)Array.MaxLength)
{
throw Invalid(
$"Sparse array total_length {totalLength} exceeds the maximum supported array length {Array.MaxLength}.");
}
int length = (int)totalLength;
HashSet<uint> seenIndices = new();
foreach (MxSparseElement element in sparse.Elements)
{
if (element.Index >= totalLength)
{
throw Invalid(
$"Sparse array index {element.Index} is out of range for total_length {totalLength}.");
}
if (!seenIndices.Add(element.Index))
{
throw Invalid($"Sparse array has a duplicate index {element.Index}.");
}
ValidateElementKind(elementType, element);
}
MxArray array = BuildArray(elementType, length, sparse.Elements);
array.ElementDataType = elementType;
array.Dimensions.Add(totalLength);
// Assigning ArrayValue switches the oneof and clears SparseArrayValue.
value.ArrayValue = array;
}
private static MxArray BuildArray(
MxDataType elementType,
int length,
IReadOnlyList<MxSparseElement> elements)
{
MxArray array = new();
switch (elementType)
{
case MxDataType.Boolean:
{
BoolArray values = new();
for (int i = 0; i < length; i++)
{
values.Values.Add(false);
}
foreach (MxSparseElement element in elements)
{
values.Values[(int)element.Index] = element.Value.BoolValue;
}
array.BoolValues = values;
break;
}
case MxDataType.Integer when UsesInt64(elements):
{
Int64Array values = new();
for (int i = 0; i < length; i++)
{
values.Values.Add(0L);
}
foreach (MxSparseElement element in elements)
{
values.Values[(int)element.Index] = ReadInt64(element.Value);
}
array.Int64Values = values;
break;
}
case MxDataType.Integer:
{
Int32Array values = new();
for (int i = 0; i < length; i++)
{
values.Values.Add(0);
}
foreach (MxSparseElement element in elements)
{
values.Values[(int)element.Index] = element.Value.Int32Value;
}
array.Int32Values = values;
break;
}
case MxDataType.Float:
{
FloatArray values = new();
for (int i = 0; i < length; i++)
{
values.Values.Add(0f);
}
foreach (MxSparseElement element in elements)
{
values.Values[(int)element.Index] = element.Value.FloatValue;
}
array.FloatValues = values;
break;
}
case MxDataType.Double:
{
DoubleArray values = new();
for (int i = 0; i < length; i++)
{
values.Values.Add(0d);
}
foreach (MxSparseElement element in elements)
{
values.Values[(int)element.Index] = element.Value.DoubleValue;
}
array.DoubleValues = values;
break;
}
case MxDataType.String:
{
StringArray values = new();
for (int i = 0; i < length; i++)
{
values.Values.Add(string.Empty);
}
foreach (MxSparseElement element in elements)
{
values.Values[(int)element.Index] = element.Value.StringValue;
}
array.StringValues = values;
break;
}
case MxDataType.Time:
{
TimestampArray values = new();
for (int i = 0; i < length; i++)
{
values.Values.Add(new Timestamp { Seconds = 0, Nanos = 0 });
}
foreach (MxSparseElement element in elements)
{
values.Values[(int)element.Index] = element.Value.TimestampValue;
}
array.TimestampValues = values;
break;
}
default:
// Unreachable: IsSupportedElementType gates the element type before BuildArray.
throw Invalid($"Sparse array element_data_type '{elementType}' is not supported.");
}
return array;
}
private static bool IsSupportedElementType(MxDataType elementType) => elementType switch
{
MxDataType.Boolean => true,
MxDataType.Integer => true,
MxDataType.Float => true,
MxDataType.Double => true,
MxDataType.String => true,
MxDataType.Time => true,
_ => false,
};
private static void ValidateElementKind(MxDataType elementType, MxSparseElement element)
{
MxValue.KindOneofCase kind = element.Value?.KindCase ?? MxValue.KindOneofCase.None;
bool matches = elementType switch
{
MxDataType.Boolean => kind == MxValue.KindOneofCase.BoolValue,
MxDataType.Integer => kind is MxValue.KindOneofCase.Int32Value or MxValue.KindOneofCase.Int64Value,
MxDataType.Float => kind == MxValue.KindOneofCase.FloatValue,
MxDataType.Double => kind == MxValue.KindOneofCase.DoubleValue,
MxDataType.String => kind == MxValue.KindOneofCase.StringValue,
MxDataType.Time => kind == MxValue.KindOneofCase.TimestampValue,
_ => false,
};
if (!matches)
{
throw Invalid(
$"Sparse array element at index {element.Index} has value kind '{kind}' which does not match element_data_type '{elementType}'.");
}
}
private static bool UsesInt64(IReadOnlyList<MxSparseElement> elements)
{
foreach (MxSparseElement element in elements)
{
if (element.Value.KindCase == MxValue.KindOneofCase.Int64Value)
{
return true;
}
}
return false;
}
private static long ReadInt64(MxValue value) =>
value.KindCase == MxValue.KindOneofCase.Int64Value ? value.Int64Value : value.Int32Value;
private static RpcException Invalid(string message) =>
new(new Status(StatusCode.InvalidArgument, message));
}
@@ -0,0 +1,105 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Sessions;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
public sealed class ArrayAddressNormalizerTests
{
/// <summary>Verifies a bare array attribute name gains the trailing array suffix.</summary>
[Fact]
public void Normalize_BareArrayName_AppendsArraySuffix()
{
ArrayAddressNormalizer normalizer = CreateNormalizer();
Assert.Equal("Obj.Arr[]", normalizer.Normalize("Obj.Arr"));
}
/// <summary>Verifies an already-suffixed address is returned unchanged.</summary>
[Fact]
public void Normalize_AlreadySuffixed_ReturnsUnchanged()
{
ArrayAddressNormalizer normalizer = CreateNormalizer();
Assert.Equal("Obj.Arr[]", normalizer.Normalize("Obj.Arr[]"));
}
/// <summary>Verifies a scalar attribute is returned unchanged.</summary>
[Fact]
public void Normalize_ScalarAttribute_ReturnsUnchanged()
{
ArrayAddressNormalizer normalizer = CreateNormalizer();
Assert.Equal("Obj.Scalar", normalizer.Normalize("Obj.Scalar"));
}
/// <summary>Verifies an address absent from the cache is returned unchanged.</summary>
[Fact]
public void Normalize_UnknownAddress_ReturnsUnchanged()
{
ArrayAddressNormalizer normalizer = CreateNormalizer();
Assert.Equal("Obj.Unknown", normalizer.Normalize("Obj.Unknown"));
}
/// <summary>Verifies null, empty, and whitespace addresses are returned unchanged.</summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Normalize_BlankAddress_ReturnsUnchanged(string address)
{
ArrayAddressNormalizer normalizer = CreateNormalizer();
Assert.Equal(address, normalizer.Normalize(address));
}
private static ArrayAddressNormalizer CreateNormalizer()
{
IReadOnlyList<GalaxyObject> objects =
[
new GalaxyObject
{
GobjectId = 1,
TagName = "Obj",
ContainedName = "Obj",
Attributes =
{
new GalaxyAttribute
{
AttributeName = "Arr",
// Galaxy SQL already appends "[]" to array attribute references.
FullTagReference = "Obj.Arr[]",
IsArray = true,
},
new GalaxyAttribute
{
AttributeName = "Scalar",
FullTagReference = "Obj.Scalar",
IsArray = false,
},
},
},
];
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
};
return new ArrayAddressNormalizer(new StubGalaxyHierarchyCache(entry));
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
/// <summary>Gets the current cache entry.</summary>
public GalaxyHierarchyCacheEntry Current { get; } = current;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -0,0 +1,210 @@
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 };
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[Fact]
public void Expand_NullValue_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() => SparseArrayExpander.Expand(null!));
}
[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);
}
}