fix(clients): resolve 2026-06-18 array-write review findings
- Client.Dotnet-030: add advise-supervisory to IsKnownGatewayCommand (was dead/unreachable, exit 2) - Client.Go-035/036/037: usage+README list advise-supervisory; add session-id guard test; fix write2 README wording - Client.Python-037/038: drop regressed 'scaffold' from pyproject; add advise-supervisory CLI tests - Client.Rust-039/040: document write_array_elements/advise-supervisory in design doc; pin outer MxValue data_type==0 - Client.Java-049/050/051: sync CLIENT_VERSION to 0.1.2; add advise-supervisory test; guard negative uint32 inputs (pending windev gradle verification) Client READMEs also updated for Server-057 add-family normalization wording. .NET/Go/Python/Rust verified green locally; Java pending windev.
This commit is contained in:
@@ -189,8 +189,9 @@ array before forwarding to the worker. Indices not listed in `elements` are
|
||||
written as the element type's default — this is a **reset**, not a preserve;
|
||||
current values at those positions are discarded. `totalLength` is required and
|
||||
must match the declared length of the array attribute. Bare-name array items
|
||||
(`Area001.Pump001.Speed`) are auto-normalized to the `[]` form at `AddItem`
|
||||
so the array attribute accepts the write.
|
||||
(`Area001.Pump001.Speed`) are auto-normalized to the `[]` form across the whole
|
||||
add family — `AddItem`, `AddItem2`, `AddItemBulk`, and `AddBufferedItem` — so the
|
||||
array attribute accepts the write.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
|
||||
@@ -2028,6 +2028,7 @@ public static class MxGatewayClientCli
|
||||
or "register"
|
||||
or "add-item"
|
||||
or "advise"
|
||||
or "advise-supervisory"
|
||||
or "subscribe-bulk"
|
||||
or "unsubscribe-bulk"
|
||||
or "read-bulk"
|
||||
|
||||
@@ -82,6 +82,53 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client.Dotnet-030: <c>advise-supervisory</c> was present in the command
|
||||
/// dispatch table but absent from <see cref="MxGatewayClientCli"/>'s
|
||||
/// <c>IsKnownGatewayCommand</c> guard, so the guard intercepted it first and
|
||||
/// returned exit code 2 "Unknown command" before dispatch could run. This
|
||||
/// test asserts the command is recognized (exit ≠ 2, stderr contains no
|
||||
/// "Unknown command") and reaches the dispatch handler (exit 0, reply written
|
||||
/// to stdout).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AdviseSupervisory_IsRecognizedAndReachesDispatch()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.AdviseSupervisory,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"advise-supervisory",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--session-id",
|
||||
"session-fixture",
|
||||
"--server-handle",
|
||||
"12",
|
||||
"--item-handle",
|
||||
"34",
|
||||
"--json",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.DoesNotContain("Unknown command", error.ToString());
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("MX_COMMAND_KIND_ADVISE_SUPERVISORY", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
||||
|
||||
@@ -133,8 +133,8 @@ _, err := client.Invoke(ctx, &pb.MxCommandRequest{
|
||||
err = session.Write(ctx, serverHandle, itemHandle, value, userID)
|
||||
```
|
||||
|
||||
The CLI exposes the same command as `advise-supervisory`, and `write` /
|
||||
`write2` take `--user-id`.
|
||||
The CLI exposes the same command as `advise-supervisory`, and `write`
|
||||
takes `-user-id`.
|
||||
|
||||
### Array writes replace the whole array
|
||||
|
||||
@@ -167,9 +167,10 @@ err = session.WriteArrayElements(
|
||||
)
|
||||
```
|
||||
|
||||
`AddItem` (and `AddItem2`) now auto-normalize a bare attribute name to the `[]`
|
||||
array address form expected by MXAccess, so callers do not need to append `[]`
|
||||
themselves. Both forms are accepted; duplicates are deduplicated by the gateway.
|
||||
`AddItem`, `AddItem2`, `AddItemBulk`, and `AddBufferedItem` auto-normalize a
|
||||
bare array attribute name to the `[]` array address form expected by MXAccess,
|
||||
so callers do not need to append `[]` themselves. Both forms are accepted;
|
||||
duplicates are deduplicated by the gateway.
|
||||
|
||||
## Galaxy Repository browse
|
||||
|
||||
@@ -334,6 +335,7 @@ Every subcommand wired into the CLI. All accept the common flags
|
||||
| `register` | Register a client name on a session (`-session-id`, `-client-name`). |
|
||||
| `add-item` | Add an item handle (`-session-id`, `-server-handle`, `-item`). |
|
||||
| `advise` | Advise (subscribe) one item (`-session-id`, `-server-handle`, `-item-handle`). |
|
||||
| `advise-supervisory` | Advise one item supervisory — required before a user-id-attributed plain `write`. |
|
||||
| `subscribe-bulk` | Advise many items in one call. |
|
||||
| `unsubscribe-bulk` | Unadvise many item handles in one call. |
|
||||
| `read-bulk` | Read snapshots for many item handles in one call. |
|
||||
|
||||
@@ -1295,7 +1295,7 @@ type protojsonMessage interface {
|
||||
}
|
||||
|
||||
func writeUsage(writer io.Writer) {
|
||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
|
||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
|
||||
}
|
||||
|
||||
// batchEOR is the end-of-result sentinel emitted to stdout after every command
|
||||
|
||||
@@ -558,6 +558,16 @@ func TestRunStreamEventsRequiresSessionID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAdviseSupervisoryRequiresSessionID pins the session-id guard so
|
||||
// advise-supervisory fails fast before dialing when no session id is supplied.
|
||||
func TestRunAdviseSupervisoryRequiresSessionID(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{"advise-supervisory", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
|
||||
if err == nil || !strings.Contains(err.Error(), "session-id is required") {
|
||||
t.Fatalf("advise-supervisory without -session-id error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
|
||||
// guard so a write-bulk with unequal item-handles / values counts fails fast
|
||||
// before any dial.
|
||||
|
||||
@@ -144,8 +144,9 @@ array before forwarding to the worker. Indices not listed in the map are
|
||||
written as the element type's default — this is a **reset**, not a preserve;
|
||||
current values at those positions are discarded. `totalLength` is required and
|
||||
must match the declared length of the array attribute. Bare-name array items
|
||||
(`Area001.Pump001.Speed`) are auto-normalized to the `[]` form at `AddItem` so
|
||||
the array attribute accepts the write.
|
||||
(`Area001.Pump001.Speed`) are auto-normalized to the `[]` form across the whole
|
||||
add family — `AddItem`, `AddItem2`, `AddItemBulk`, and `AddBufferedItem` — so the
|
||||
array attribute accepts the write.
|
||||
|
||||
## Galaxy Repository Browse
|
||||
|
||||
@@ -396,7 +397,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.1'
|
||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.2'
|
||||
}
|
||||
````
|
||||
|
||||
|
||||
+24
-3
@@ -56,7 +56,7 @@ final class MxGatewayCliTests {
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("", run.errors());
|
||||
assertTrue(run.output().contains("mxgateway-java 0.1.1"));
|
||||
assertTrue(run.output().contains("mxgateway-java 0.1.2"));
|
||||
assertTrue(run.output().contains("gatewayProtocolVersion=3"));
|
||||
assertTrue(run.output().contains("workerProtocolVersion=1"));
|
||||
}
|
||||
@@ -86,7 +86,7 @@ final class MxGatewayCliTests {
|
||||
CliRun run = execute(new FakeClientFactory(), "version", "--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertTrue(run.output().contains("\"clientVersion\":\"0.1.1\""));
|
||||
assertTrue(run.output().contains("\"clientVersion\":\"0.1.2\""));
|
||||
assertTrue(run.output().contains("\"gatewayProtocolVersion\":3"));
|
||||
}
|
||||
|
||||
@@ -148,6 +148,26 @@ final class MxGatewayCliTests {
|
||||
assertTrue(run.output().contains("\"itemHandle\":7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adviseSupervisoryCommandCallsAdviseSupervisoryRaw() {
|
||||
// Client.Java-050: dedicated test for advise-supervisory, using a
|
||||
// separate adviseSupervisoryCalled flag so it cannot be masked by the
|
||||
// plain advise path that shares adviseCalled.
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"advise-supervisory",
|
||||
"--session-id", "session-cli",
|
||||
"--server-handle", "12",
|
||||
"--item-handle", "34",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertTrue(factory.client.session.adviseSupervisoryCalled);
|
||||
assertFalse(factory.client.session.adviseCalled, "plain advise must not be called");
|
||||
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\""));
|
||||
}
|
||||
|
||||
// ---- ping subcommand (D4) ----
|
||||
|
||||
@Test
|
||||
@@ -1235,6 +1255,7 @@ final class MxGatewayCliTests {
|
||||
private boolean registerCalled;
|
||||
private boolean addItemCalled;
|
||||
private boolean adviseCalled;
|
||||
private boolean adviseSupervisoryCalled;
|
||||
private MxValue lastWriteValue;
|
||||
private String lastPingMessage;
|
||||
private long lastReadBulkTimeoutMs;
|
||||
@@ -1304,7 +1325,7 @@ final class MxGatewayCliTests {
|
||||
|
||||
@Override
|
||||
public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
|
||||
adviseCalled = true;
|
||||
adviseSupervisoryCalled = true;
|
||||
return MxCommandReply.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
|
||||
.setProtocolStatus(ok())
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ package com.zb.mom.ww.mxgateway.client;
|
||||
public final class MxGatewayClientVersion {
|
||||
private static final int GATEWAY_PROTOCOL_VERSION = 3;
|
||||
private static final int WORKER_PROTOCOL_VERSION = 1;
|
||||
private static final String CLIENT_VERSION = "0.1.1";
|
||||
private static final String CLIENT_VERSION = "0.1.2";
|
||||
|
||||
private MxGatewayClientVersion() {
|
||||
}
|
||||
|
||||
+22
-3
@@ -623,13 +623,22 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
* supplied indices must be within {@code [0, totalLength)}. Elements are iterated in
|
||||
* ascending index order so the produced command is deterministic.
|
||||
*
|
||||
* <p>Because the proto fields {@code MxSparseArray.total_length} and
|
||||
* {@code MxSparseElement.index} are {@code uint32}, passing a negative Java {@code int}
|
||||
* would silently sign-extend to a large unsigned value on the wire. This method
|
||||
* therefore rejects negative {@code totalLength} and negative element indices with
|
||||
* {@link IllegalArgumentException} rather than allowing a hard-to-diagnose gateway error.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the item
|
||||
* @param itemHandle the {@code ItemHandle} to write
|
||||
* @param elementDataType the {@link MxDataType} of the array's elements
|
||||
* @param totalLength the total length of the expanded array
|
||||
* @param elements the indices to write mapped to their scalar values; unmentioned
|
||||
* indices are reset to the element type default
|
||||
* @param totalLength the total length of the expanded array; must be > 0
|
||||
* @param elements the indices to write mapped to their scalar values; each index must
|
||||
* be in {@code [0, totalLength)}; unmentioned indices are reset to the element
|
||||
* type default
|
||||
* @param userId the MXAccess user id used for security checks
|
||||
* @throws IllegalArgumentException if {@code totalLength} is not positive, or if any
|
||||
* element index is negative or ≥ {@code totalLength}
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public void writeArrayElements(
|
||||
@@ -641,6 +650,16 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
int userId) {
|
||||
Objects.requireNonNull(elementDataType, "elementDataType");
|
||||
Objects.requireNonNull(elements, "elements");
|
||||
if (totalLength <= 0) {
|
||||
throw new IllegalArgumentException("totalLength must be > 0, got " + totalLength);
|
||||
}
|
||||
for (Map.Entry<Integer, MxValue> entry : elements.entrySet()) {
|
||||
int idx = entry.getKey();
|
||||
if (idx < 0 || idx >= totalLength) {
|
||||
throw new IllegalArgumentException(
|
||||
"element index " + idx + " is out of range [0, " + totalLength + ")");
|
||||
}
|
||||
}
|
||||
MxSparseArray.Builder sparse = MxSparseArray.newBuilder()
|
||||
.setElementDataType(elementDataType)
|
||||
.setTotalLength(totalLength);
|
||||
|
||||
+58
@@ -451,6 +451,64 @@ final class MxGatewayClientSessionTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeArrayElementsRejectsNonPositiveTotalLength() throws Exception {
|
||||
// Client.Java-051: negative/zero totalLength silently sign-extends to a
|
||||
// large uint32 on the wire; the client must reject it with
|
||||
// IllegalArgumentException before building the proto message (before any
|
||||
// network call is issued).
|
||||
try (InProcessGateway gateway = InProcessGateway.start(
|
||||
new TestGatewayService() {}, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "guard-session");
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> session.writeArrayElements(
|
||||
1, 2, MxDataType.MX_DATA_TYPE_INTEGER, -1, Map.of(), 0),
|
||||
"negative totalLength must throw");
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> session.writeArrayElements(
|
||||
1, 2, MxDataType.MX_DATA_TYPE_INTEGER, 0, Map.of(), 0),
|
||||
"zero totalLength must throw");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeArrayElementsRejectsOutOfRangeIndex() throws Exception {
|
||||
// Client.Java-051: a negative index silently sign-extends to a large
|
||||
// uint32 on the wire; an index >= totalLength exceeds the declared
|
||||
// array bounds. Both must be caught before the network call.
|
||||
try (InProcessGateway gateway = InProcessGateway.start(
|
||||
new TestGatewayService() {}, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "guard-session");
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> session.writeArrayElements(
|
||||
1, 2, MxDataType.MX_DATA_TYPE_INTEGER, 5,
|
||||
Map.of(-1, MxValues.int32Value(7)), 0),
|
||||
"negative index must throw");
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> session.writeArrayElements(
|
||||
1, 2, MxDataType.MX_DATA_TYPE_INTEGER, 5,
|
||||
Map.of(5, MxValues.int32Value(7)), 0),
|
||||
"index equal to totalLength must throw");
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> session.writeArrayElements(
|
||||
1, 2, MxDataType.MX_DATA_TYPE_INTEGER, 5,
|
||||
Map.of(10, MxValues.int32Value(7)), 0),
|
||||
"index above totalLength must throw");
|
||||
}
|
||||
}
|
||||
|
||||
private static ProtocolStatus ok() {
|
||||
return ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||
|
||||
@@ -170,8 +170,9 @@ await session.write_array_elements(
|
||||
```
|
||||
|
||||
Bare-name array items (e.g. `Object.ArrayAttr` without an index suffix) added
|
||||
via `add_item` auto-normalize to `[]` — they refer to the whole array, not a
|
||||
single element. Writes through such handles must cover the full array or use
|
||||
via `add_item`, `add_item2`, `add_item_bulk`, or `add_buffered_item`
|
||||
auto-normalize to `[]` — they refer to the whole array, not a single element.
|
||||
Writes through such handles must cover the full array or use
|
||||
`write_array_elements` to supply `total_length` and let the gateway fill
|
||||
defaults for the rest.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "zb-mom-ww-mxaccess-gateway-client"
|
||||
version = "0.1.2"
|
||||
description = "Async Python client scaffold for MXAccess Gateway."
|
||||
description = "Async Python client for MXAccess Gateway."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Regression tests for Client.Python-037 and Client.Python-038.
|
||||
|
||||
Client.Python-037: ``pyproject.toml`` description must not contain "scaffold".
|
||||
Client.Python-038: ``advise-supervisory`` CLI subcommand must have coverage
|
||||
(registration smoke test + happy-path command-shape test).
|
||||
|
||||
Tests are TDD-first — written before the fix and expected to pass once the
|
||||
source change lands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from zb_mom_ww_mxgateway_cli.commands import main
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-037 — pyproject.toml description must not contain "scaffold".
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pyproject_description_does_not_contain_scaffold() -> None:
|
||||
"""The ``description`` field in ``pyproject.toml`` must not include the
|
||||
word "scaffold" — a regression of Client.Python-001 that re-entered the
|
||||
file at the package-rename commit.
|
||||
"""
|
||||
|
||||
pyproject = (
|
||||
Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
# Find the description line and assert "scaffold" is absent.
|
||||
for line in pyproject.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("description"):
|
||||
assert "scaffold" not in stripped.lower(), (
|
||||
f"pyproject.toml description must not contain 'scaffold': {stripped!r}"
|
||||
)
|
||||
return
|
||||
|
||||
raise AssertionError("pyproject.toml has no 'description' line")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-038 — advise-supervisory must be registered + have a happy path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_advise_supervisory_is_registered() -> None:
|
||||
"""``advise-supervisory`` must be a registered subcommand of ``main``.
|
||||
|
||||
A ``--help`` invocation must exit 0 and the help text must include the
|
||||
required options (--server-handle and --item-handle).
|
||||
"""
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["advise-supervisory", "--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--server-handle" in result.output
|
||||
assert "--item-handle" in result.output
|
||||
|
||||
|
||||
# --------------- fake-stub infrastructure (mirrors test_review_findings_022_to_026) ----
|
||||
|
||||
|
||||
class _FakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = replies
|
||||
self.requests: list[Any] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
async def __call__(self, request: Any, *, metadata: tuple[tuple[str, str], ...]) -> Any:
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
class _FakeStub:
|
||||
def __init__(self) -> None:
|
||||
self.open_session = _FakeUnary(
|
||||
[
|
||||
pb.OpenSessionReply(
|
||||
session_id="session-1",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
),
|
||||
],
|
||||
)
|
||||
self.invoke = _FakeUnary([])
|
||||
self.OpenSession = self.open_session
|
||||
self.Invoke = self.invoke
|
||||
|
||||
def set_invoke_replies(self, replies: list[Any]) -> None:
|
||||
self.invoke.replies = replies
|
||||
|
||||
|
||||
def _install_fake_connect(monkeypatch: Any, stub: _FakeStub) -> None:
|
||||
"""Patch ``GatewayClient.connect`` so the CLI uses the supplied fake stub."""
|
||||
|
||||
real_connect = GatewayClient.connect
|
||||
|
||||
@classmethod # type: ignore[misc]
|
||||
async def _spy_connect(cls: Any, options: ClientOptions, **kwargs: Any) -> GatewayClient:
|
||||
return await real_connect(options, stub=stub)
|
||||
|
||||
monkeypatch.setattr(GatewayClient, "connect", _spy_connect)
|
||||
|
||||
|
||||
def test_cli_advise_supervisory_happy_path(monkeypatch: Any) -> None:
|
||||
"""``advise-supervisory`` must forward server_handle and item_handle in an
|
||||
``MX_COMMAND_KIND_ADVISE_SUPERVISORY`` ``MxCommand``.
|
||||
|
||||
Pattern mirrors ``test_cli_acknowledge_alarm_happy_path`` in
|
||||
``test_review_findings_022_to_026.py``.
|
||||
"""
|
||||
|
||||
stub = _FakeStub()
|
||||
stub.set_invoke_replies(
|
||||
[
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
),
|
||||
],
|
||||
)
|
||||
_install_fake_connect(monkeypatch, stub)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"advise-supervisory",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--plaintext",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--server-handle",
|
||||
"7",
|
||||
"--item-handle",
|
||||
"42",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["ok"] is True
|
||||
|
||||
# Verify the MxCommand shape forwarded to the gateway.
|
||||
assert len(stub.invoke.requests) == 1
|
||||
cmd = stub.invoke.requests[0].command
|
||||
assert cmd.kind == pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY
|
||||
assert cmd.advise_supervisory.server_handle == 7
|
||||
assert cmd.advise_supervisory.item_handle == 42
|
||||
@@ -196,10 +196,10 @@ the existing attribute value.
|
||||
|
||||
#### Bare-name array AddItem normalisation
|
||||
|
||||
`AddItem` for a bare array attribute name (e.g. `Tank01.Temperature`) is
|
||||
automatically normalised to `Tank01.Temperature[]` by the gateway so the
|
||||
worker can resolve the full array. You do not need to append `[]` in client
|
||||
code; the gateway handles it.
|
||||
Adding a bare array attribute name (e.g. `Tank01.Temperature`) via `AddItem`,
|
||||
`AddItem2`, `AddItemBulk`, or `AddBufferedItem` is automatically normalised to
|
||||
`Tank01.Temperature[]` by the gateway so the worker can resolve the full array.
|
||||
You do not need to append `[]` in client code; the gateway handles it.
|
||||
|
||||
## Galaxy Repository browse
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ impl Session {
|
||||
pub async fn read_bulk<S: AsRef<str>>(&self, server_handle: i32, tag_addresses: &[S], timeout_ms: u32) -> Result<Vec<BulkReadResult>, Error>;
|
||||
pub async fn write(&self, server_handle: i32, item_handle: i32, value: MxValue, user_id: i32) -> Result<(), Error>;
|
||||
pub async fn write2(&self, server_handle: i32, item_handle: i32, value: MxValue, timestamp_value: MxValue, user_id: i32) -> Result<(), Error>;
|
||||
pub async fn write_array_elements(&self, server_handle: i32, item_handle: i32, element_data_type: MxDataType, total_length: u32, elements: impl IntoIterator<Item = (u32, MxValue)>, user_id: i32) -> Result<(), Error>;
|
||||
pub async fn write_bulk(&self, server_handle: i32, entries: Vec<WriteBulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
pub async fn write2_bulk(&self, server_handle: i32, entries: Vec<Write2BulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
pub async fn write_secured_bulk(&self, server_handle: i32, entries: Vec<WriteSecuredBulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
@@ -333,6 +334,7 @@ mxgw close-session --session-id <id>
|
||||
mxgw register --session-id <id>
|
||||
mxgw add-item --session-id <id> --server-handle <h> --item <tag>
|
||||
mxgw advise --session-id <id> --server-handle <h> --item-handle <h>
|
||||
mxgw advise-supervisory --session-id <id> --server-handle <h> --item-handle <h>
|
||||
mxgw subscribe-bulk --session-id <id> --server-handle <h> --items <csv>
|
||||
mxgw unsubscribe-bulk --session-id <id> --server-handle <h> --item-handles <csv>
|
||||
mxgw read-bulk --session-id <id> --server-handle <h> --items <csv> [--timeout-ms <ms>]
|
||||
|
||||
@@ -1212,7 +1212,9 @@ fn sparse_int32_value(
|
||||
.collect();
|
||||
|
||||
MxValue {
|
||||
data_type: MxDataType::Integer as i32,
|
||||
// outer data_type must be 0 (Unspecified); the element type lives only
|
||||
// inside MxSparseArray.element_data_type, matching the
|
||||
// `..ProtoMxValue::default()` used in Session::write_array_elements.
|
||||
variant_type: String::new(),
|
||||
kind: Some(Kind::SparseArrayValue(MxSparseArray {
|
||||
element_data_type: MxDataType::Integer as i32,
|
||||
@@ -1227,6 +1229,11 @@ fn sparse_int32_value(
|
||||
fn write_array_elements_proto_shape_has_sparse_oneof_kind() {
|
||||
let proto = sparse_int32_value(5, [(0, 10), (3, 30)]);
|
||||
|
||||
assert_eq!(
|
||||
proto.data_type, 0,
|
||||
"outer MxValue.data_type must be 0 (Unspecified); element type lives in element_data_type"
|
||||
);
|
||||
|
||||
let Kind::SparseArrayValue(ref sparse) = proto.kind.as_ref().unwrap() else {
|
||||
panic!("expected SparseArrayValue kind, got {:?}", proto.kind);
|
||||
};
|
||||
@@ -1253,6 +1260,10 @@ fn write_array_elements_proto_shape_has_sparse_oneof_kind() {
|
||||
#[test]
|
||||
fn write_array_elements_empty_elements_is_valid_all_defaults() {
|
||||
let proto = sparse_int32_value(8, []);
|
||||
assert_eq!(
|
||||
proto.data_type, 0,
|
||||
"outer MxValue.data_type must be 0 (Unspecified) even with no elements"
|
||||
);
|
||||
let Kind::SparseArrayValue(ref sparse) = proto.kind.as_ref().unwrap() else {
|
||||
panic!("expected SparseArrayValue kind");
|
||||
};
|
||||
@@ -1269,6 +1280,10 @@ fn sparse_array_value_round_trips_through_client_mx_value_projection_as_unset()
|
||||
// (e.g. a future version bug), the projection should degrade to Unset
|
||||
// rather than panic, because the enum variant is not readable.
|
||||
let proto = sparse_int32_value(4, [(1, 99)]);
|
||||
assert_eq!(
|
||||
proto.data_type, 0,
|
||||
"outer MxValue.data_type must be 0 (Unspecified)"
|
||||
);
|
||||
let client_value = ClientMxValue::from_proto(proto);
|
||||
assert_eq!(
|
||||
client_value.projection(),
|
||||
|
||||
Reference in New Issue
Block a user