From 0a670eb381f08bfcf1516497c0e8e67815a60abb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:47:05 -0400 Subject: [PATCH] Issue #35: add parity fixture matrix --- .../parity/parity-fixture-matrix.json | 469 ++++++++++++++++++ docs/GatewayTesting.md | 8 + docs/ParityFixtureMatrix.md | 102 ++++ .../Contracts/ParityFixtureMatrixTests.cs | 293 +++++++++++ 4 files changed, 872 insertions(+) create mode 100644 clients/proto/fixtures/parity/parity-fixture-matrix.json create mode 100644 docs/ParityFixtureMatrix.md create mode 100644 src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs diff --git a/clients/proto/fixtures/parity/parity-fixture-matrix.json b/clients/proto/fixtures/parity/parity-fixture-matrix.json new file mode 100644 index 0000000..14b9fd0 --- /dev/null +++ b/clients/proto/fixtures/parity/parity-fixture-matrix.json @@ -0,0 +1,469 @@ +{ + "schemaVersion": 1, + "fixtureSet": "mxaccess-gateway-parity-fixture-matrix", + "contractName": "mxaccess-gateway", + "gatewayProtocolVersion": 1, + "workerProtocolVersion": 1, + "sourceCaptureRoot": "C:/Users/dohertj2/Desktop/mxaccess/captures", + "sourceDocs": [ + "C:/Users/dohertj2/Desktop/mxaccess/docs/MXAccess-Public-API.md", + "C:/Users/dohertj2/Desktop/mxaccess/docs/Current-Sprint-State.md" + ], + "comparisonFormat": { + "description": "Each parity run records the same command against direct MXAccess and the gateway-backed worker, then compares raw parity fields instead of client wrapper behavior.", + "directMxAccess": { + "requiredFields": [ + "method", + "arguments", + "returnedValue", + "hresult", + "exceptionType", + "statuses", + "events" + ] + }, + "gatewayResult": { + "requiredFields": [ + "kind", + "protocolStatus", + "returnValue", + "hresult", + "statuses", + "diagnosticMessage", + "events" + ] + }, + "eventFields": [ + "family", + "serverHandle", + "itemHandle", + "value", + "quality", + "sourceTimestamp", + "statuses", + "workerSequence", + "workerTimestamp", + "gatewayReceiveTimestamp", + "hresult", + "rawStatus" + ], + "comparisonKeys": [ + "hresult", + "exceptionType", + "returnedValue", + "statusArrayShape", + "statusRawFields", + "eventFamilyOrder", + "eventPayloadShape", + "valueProjection", + "rawFallbackMetadata" + ] + }, + "methodFixtures": [ + { + "id": "method.register.basic", + "method": "Register", + "commandKind": "MX_COMMAND_KIND_REGISTER", + "status": "planned_fixture", + "captureReferences": [ + "captures/001-register/harness.log", + "captures/047-frida-com-proxy-register/harness.log" + ], + "assertions": [ + "preserve returned server handle in returnValue and RegisterReply", + "preserve success HRESULT as 0", + "do not emit MXAccess events for register" + ] + }, + { + "id": "method.unregister.basic", + "method": "Unregister", + "commandKind": "MX_COMMAND_KIND_UNREGISTER", + "status": "planned_fixture", + "captureReferences": [ + "captures/001-register/harness.log", + "captures/109-native-post-remove-errors/harness.log" + ], + "assertions": [ + "preserve void return shape with explicit protocol success", + "preserve HRESULT or COM exception details for invalid server handle", + "close registered handle only after MXAccess succeeds" + ] + }, + { + "id": "method.add-item.scalar", + "method": "AddItem", + "commandKind": "MX_COMMAND_KIND_ADD_ITEM", + "status": "planned_fixture", + "captureReferences": [ + "captures/002-add-remove-scalar/harness.log", + "captures/006-add-invalid/harness.log" + ], + "assertions": [ + "preserve returned item handle in returnValue and AddItemReply", + "preserve invalid item reference HRESULT/status details", + "do not prevalidate item definition in the gateway" + ] + }, + { + "id": "method.add-item2.context", + "method": "AddItem2", + "commandKind": "MX_COMMAND_KIND_ADD_ITEM2", + "status": "planned_fixture", + "captureReferences": [ + "captures/mxaccess-additem2-testint-context.log", + "captures/121-frida-buffered-history-testhistoryvalue-context/harness.log" + ], + "assertions": [ + "pass item_definition and item_context exactly as supplied", + "preserve returned item handle in returnValue and AddItem2Reply", + "compare context-bearing reference resolution against direct MXAccess" + ] + }, + { + "id": "method.remove-item.basic", + "method": "RemoveItem", + "commandKind": "MX_COMMAND_KIND_REMOVE_ITEM", + "status": "planned_fixture", + "captureReferences": [ + "captures/002-add-remove-scalar/harness.log", + "captures/109-native-post-remove-errors/harness.log" + ], + "assertions": [ + "preserve void return shape with explicit protocol success", + "preserve post-remove and invalid-handle HRESULT/status behavior", + "remove diagnostic handle state only after MXAccess succeeds" + ] + }, + { + "id": "method.advise.supervisory-data-change", + "method": "Advise", + "commandKind": "MX_COMMAND_KIND_ADVISE", + "status": "planned_fixture", + "captureReferences": [ + "captures/003-subscribe-scalars/harness.log", + "captures/058-frida-subscribe-testint/harness.log" + ], + "assertions": [ + "preserve successful command reply shape", + "forward OnDataChange with value, quality, timestamp, and status array", + "preserve per-worker event order" + ] + }, + { + "id": "method.unadvise.basic", + "method": "UnAdvise", + "commandKind": "MX_COMMAND_KIND_UN_ADVISE", + "status": "planned_fixture", + "captureReferences": [ + "captures/058-frida-subscribe-testint/harness.log", + "captures/007-subscribe-invalid/harness.log" + ], + "assertions": [ + "preserve void return shape with explicit protocol success", + "preserve invalid item handle HRESULT/status behavior", + "do not distinguish plain and supervisory cleanup beyond MXAccess behavior" + ] + }, + { + "id": "method.advise-supervisory.basic", + "method": "AdviseSupervisory", + "commandKind": "MX_COMMAND_KIND_ADVISE_SUPERVISORY", + "status": "planned_fixture", + "captureReferences": [ + "captures/058-frida-subscribe-testint/harness.log", + "captures/105-frida-advise-shortdesc-prebound-fixed/harness.log" + ], + "assertions": [ + "keep AdviseSupervisory distinct from plain Advise in command kind", + "forward native OnDataChange only when MXAccess emits it", + "compare supervisory item status arrays without normalization" + ] + }, + { + "id": "method.add-buffered-item.context", + "method": "AddBufferedItem", + "commandKind": "MX_COMMAND_KIND_ADD_BUFFERED_ITEM", + "status": "planned_fixture", + "captureReferences": [ + "captures/079-frida-add-buffered-advise-testint/harness.log", + "captures/120-frida-buffered-history-testhistoryvalue/harness.log", + "captures/121-frida-buffered-history-testhistoryvalue-context/harness.log" + ], + "assertions": [ + "pass item_definition and item_context exactly as supplied", + "preserve returned buffered item handle in returnValue and AddBufferedItemReply", + "keep buffered registration distinct from normal AddItem2" + ] + }, + { + "id": "method.set-buffered-update-interval.basic", + "method": "SetBufferedUpdateInterval", + "commandKind": "MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL", + "status": "planned_fixture", + "captureReferences": [ + "captures/mxaccess-set-buffered-interval-1000.log", + "captures/079-frida-add-buffered-advise-testint/harness.log" + ], + "assertions": [ + "preserve requested update interval without clamping in the gateway", + "preserve void return shape with explicit protocol success", + "compare buffered event cadence only in opt-in live runs" + ] + }, + { + "id": "method.suspend.scan-state", + "method": "Suspend", + "commandKind": "MX_COMMAND_KIND_SUSPEND", + "status": "planned_fixture", + "captureReferences": [ + "captures/077-frida-suspend-advised-scanstate/harness.log", + "captures/118-frida-suspend-advised-scanstate-long/harness.log" + ], + "assertions": [ + "preserve out MxStatus in SuspendReply and repeated statuses", + "preserve HRESULT separately from status detail", + "do not synthesize OperationComplete if native MXAccess does not raise it" + ] + }, + { + "id": "method.activate.scan-state", + "method": "Activate", + "commandKind": "MX_COMMAND_KIND_ACTIVATE", + "status": "planned_fixture", + "captureReferences": [ + "captures/078-frida-activate-advised-scanstate/harness.log", + "captures/119-frida-activate-advised-scanstate-long/harness.log" + ], + "assertions": [ + "preserve out MxStatus in ActivateReply and repeated statuses", + "preserve HRESULT separately from status detail", + "do not synthesize OperationComplete if native MXAccess does not raise it" + ] + }, + { + "id": "method.write.value-status-matrix", + "method": "Write", + "commandKind": "MX_COMMAND_KIND_WRITE", + "status": "planned_fixture", + "captureReferences": [ + "captures/023-frida-write-test-int-sequence-109-111/harness.log", + "captures/024-frida-write-test-bool-sequence/harness.log", + "captures/089-frida-write-testint-wrong-type/harness.log", + "captures/090-frida-write-invalid-reference/harness.log", + "captures/107-native-write-testint-current/harness.log" + ], + "assertions": [ + "preserve scalar and array value projections plus raw fallback metadata", + "preserve wrong-type and invalid-reference HRESULT/status arrays", + "forward OnWriteComplete only when native MXAccess emits it" + ] + }, + { + "id": "method.write2.timestamped", + "method": "Write2", + "commandKind": "MX_COMMAND_KIND_WRITE2", + "status": "planned_fixture", + "captureReferences": [ + "captures/042-frida-write2-test-int-timestamp/harness.log", + "captures/066-frida-write2-test-bool-timestamp/harness.log", + "captures/075-frida-write2-test-datetime-array-timestamp/harness.log" + ], + "assertions": [ + "preserve timestamp_value as an MXAccess VARIANT projection", + "preserve write value shape and HRESULT/status arrays", + "compare timestamped write completion events against direct MXAccess" + ] + }, + { + "id": "method.write-secured.rejection-gap", + "method": "WriteSecured", + "commandKind": "MX_COMMAND_KIND_WRITE_SECURED", + "status": "documented_gap", + "captureReferences": [ + "captures/036-frida-write-secured-test-int/harness.log", + "captures/111-frida-write-secured-auth-protectedvalue/harness.log", + "captures/112-frida-write-secured-auth-verified-protectedvalue1/harness.log" + ], + "assertions": [ + "preserve observed 0x80004021 rejection before a value-bearing NMX body", + "preserve current_user_id and verifier_user_id only as command inputs, not logs", + "upgrade this gap to planned_fixture when a successful direct WriteSecured path is observed" + ] + }, + { + "id": "method.write-secured2.authenticated", + "method": "WriteSecured2", + "commandKind": "MX_COMMAND_KIND_WRITE_SECURED2", + "status": "planned_fixture", + "captureReferences": [ + "captures/113-frida-write-secured2-auth-protectedvalue/harness.log", + "captures/116-frida-write-secured2-auth-verified-protectedvalue1/harness.log", + "captures/117-frida-write-secured2-auth-testint/harness.log" + ], + "assertions": [ + "preserve authenticated timestamped secured write body shape", + "preserve HRESULT/status arrays without logging credential-bearing values", + "do not synthesize OnWriteComplete when direct MXAccess does not emit it" + ] + }, + { + "id": "method.authenticate-user.basic", + "method": "AuthenticateUser", + "commandKind": "MX_COMMAND_KIND_AUTHENTICATE_USER", + "status": "planned_fixture", + "captureReferences": [ + "captures/087-frida-authenticate-administrator-empty/harness.log", + "captures/088-frida-authenticate-invalid-empty/harness.log" + ], + "assertions": [ + "preserve returned user id in returnValue and AuthenticateUserReply", + "preserve invalid credential HRESULT/status behavior", + "redact verify_user_password from logs and diagnostics" + ] + }, + { + "id": "method.archestra-user-to-id.basic", + "method": "ArchestrAUserToId", + "commandKind": "MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID", + "status": "planned_fixture", + "captureReferences": [ + "captures/mxaccess-user-map-administrator.log", + "captures/mxaccess-user-map-invalid.log" + ], + "assertions": [ + "preserve returned user id in returnValue and ArchestrAUserToIdReply", + "preserve invalid user GUID HRESULT/status behavior", + "compare raw mapping behavior without normalizing unknown users" + ] + } + ], + "eventFixtures": [ + { + "id": "event.on-data-change.scalar", + "family": "MX_EVENT_FAMILY_ON_DATA_CHANGE", + "status": "planned_fixture", + "captureReferences": [ + "captures/003-subscribe-scalars/harness.log", + "captures/106-native-subscribe-testint-current/harness.log" + ], + "assertions": [ + "preserve value, quality, timestamp, status array, and worker sequence" + ] + }, + { + "id": "event.on-write-complete.status", + "family": "MX_EVENT_FAMILY_ON_WRITE_COMPLETE", + "status": "planned_fixture", + "captureReferences": [ + "captures/008-write-test-int-same-value/harness.log", + "captures/107-native-write-testint-current/harness.log" + ], + "assertions": [ + "preserve write-complete status array and optional HRESULT" + ] + }, + { + "id": "event.operation-complete.native-trigger-gap", + "family": "MX_EVENT_FAMILY_OPERATION_COMPLETE", + "status": "documented_gap", + "captureReferences": [ + "captures/077-frida-suspend-advised-scanstate/harness.log", + "captures/118-frida-suspend-advised-scanstate-long/harness.log" + ], + "assertions": [ + "do not synthesize OperationComplete from Write or OnWriteComplete", + "upgrade this gap when a public MXAccess trigger emits event family 3" + ] + }, + { + "id": "event.on-buffered-data-change.batch-gap", + "family": "MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE", + "status": "documented_gap", + "captureReferences": [ + "captures/120-frida-buffered-history-testhistoryvalue/harness.log", + "captures/122-frida-buffered-history-testhistoryvalue-plainadvise/harness.log" + ], + "assertions": [ + "preserve raw buffered metadata until a public multi-sample event payload is observed", + "upgrade this gap when OnBufferedDataChange batches are captured from MXAccess" + ] + } + ], + "scenarioGroups": [ + { + "id": "invalid_handles", + "description": "Invalid server, item, post-remove, and invalid-reference cases keep MXAccess-owned HRESULT and status behavior.", + "fixtureIds": [ + "method.add-item.scalar", + "method.remove-item.basic", + "method.unadvise.basic", + "method.write.value-status-matrix", + "method.unregister.basic" + ], + "captureReferences": [ + "captures/006-add-invalid/harness.log", + "captures/007-subscribe-invalid/harness.log", + "captures/109-native-post-remove-errors/harness.log", + "captures/110-native-invalid-handle-errors/harness.log" + ] + }, + { + "id": "write_statuses", + "description": "Write success, wrong type, invalid reference, scalar arrays, and completion-status cases compare HRESULT, status array, value projection, and event shape.", + "fixtureIds": [ + "method.write.value-status-matrix", + "method.write2.timestamped", + "event.on-write-complete.status" + ], + "captureReferences": [ + "captures/089-frida-write-testint-wrong-type/harness.log", + "captures/090-frida-write-invalid-reference/harness.log", + "captures/091-frida-write-testint-double-type/harness.log", + "captures/097-frida-write-bool-array-pattern/harness.log", + "captures/107-native-write-testint-current/harness.log" + ] + }, + { + "id": "secured_writes", + "description": "Secured writes include observed WriteSecured rejection and authenticated WriteSecured2 success paths without logging credential-bearing values.", + "fixtureIds": [ + "method.write-secured.rejection-gap", + "method.write-secured2.authenticated", + "method.authenticate-user.basic" + ], + "captureReferences": [ + "captures/036-frida-write-secured-test-int/harness.log", + "captures/111-frida-write-secured-auth-protectedvalue/harness.log", + "captures/113-frida-write-secured2-auth-protectedvalue/harness.log", + "captures/117-frida-write-secured2-auth-testint/harness.log" + ] + }, + { + "id": "add_item_context", + "description": "Context-bearing item registration compares AddItem2 and buffered AddBufferedItem argument preservation.", + "fixtureIds": [ + "method.add-item2.context", + "method.add-buffered-item.context" + ], + "captureReferences": [ + "captures/mxaccess-additem2-testint-context.log", + "captures/121-frida-buffered-history-testhistoryvalue-context/harness.log" + ] + }, + { + "id": "buffered_registration", + "description": "Buffered registration and interval setup are tracked separately from normal advice until a public buffered data-change batch is captured.", + "fixtureIds": [ + "method.add-buffered-item.context", + "method.set-buffered-update-interval.basic", + "event.on-buffered-data-change.batch-gap" + ], + "captureReferences": [ + "captures/079-frida-add-buffered-advise-testint/harness.log", + "captures/120-frida-buffered-history-testhistoryvalue/harness.log", + "captures/122-frida-buffered-history-testhistoryvalue-plainadvise/harness.log" + ] + } + ] +} diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md index e34edf4..ddb78c1 100644 --- a/docs/GatewayTesting.md +++ b/docs/GatewayTesting.md @@ -76,6 +76,13 @@ stdout/stderr lines emitted during the run. ## Focused Commands +Run the parity fixture matrix tests after changing the integration parity +scenario list: + +```bash +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~ParityFixtureMatrixTests +``` + Run the fake worker tests after changing gateway worker IPC, session startup, or event streaming behavior: @@ -95,6 +102,7 @@ dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj ## Related Documentation +- [Parity Fixture Matrix](./ParityFixtureMatrix.md) - [Gateway Process Design](./gateway-process-design.md) - [Worker Frame Protocol](./WorkerFrameProtocol.md) - [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md) diff --git a/docs/ParityFixtureMatrix.md b/docs/ParityFixtureMatrix.md new file mode 100644 index 0000000..6360c57 --- /dev/null +++ b/docs/ParityFixtureMatrix.md @@ -0,0 +1,102 @@ +# Parity Fixture Matrix + +The parity fixture matrix defines the live-test scenarios used to compare +direct MXAccess behavior with the gateway-backed worker. It is a planning and +validation fixture, not a source of synthetic MXAccess behavior. + +The matrix lives in +`clients/proto/fixtures/parity/parity-fixture-matrix.json`. It references the +local MXAccess capture set under +`C:/Users/dohertj2/Desktop/mxaccess/captures` and keeps capture paths relative +to that root so the repository does not copy raw capture artifacts. + +## Scope + +The matrix covers every public `LMXProxyServerClass` method represented by the +gateway contract: + +- `Register` +- `Unregister` +- `AddItem` +- `AddItem2` +- `RemoveItem` +- `Advise` +- `UnAdvise` +- `AdviseSupervisory` +- `AddBufferedItem` +- `SetBufferedUpdateInterval` +- `Suspend` +- `Activate` +- `Write` +- `Write2` +- `WriteSecured` +- `WriteSecured2` +- `AuthenticateUser` +- `ArchestrAUserToId` + +Each entry is either a `planned_fixture` or a `documented_gap`. +`WriteSecured` remains a documented gap because the current captures show +`0x80004021` before MXAccess emits a value-bearing write body. +`OperationComplete` and public `OnBufferedDataChange` batches also remain +documented gaps because no capture in the current set proves those public event +payloads from native MXAccess. + +## Required Scenario Groups + +The matrix pins the high-risk parity scenarios from the integration milestone: + +| Scenario | Purpose | +|----------|---------| +| `invalid_handles` | Preserves invalid server, item, post-remove, and invalid-reference HRESULT/status behavior. | +| `write_statuses` | Compares successful writes, wrong-type writes, invalid references, arrays, and write-complete status arrays. | +| `secured_writes` | Covers observed `WriteSecured` rejection and authenticated `WriteSecured2` paths without logging credential-bearing values. | +| `add_item_context` | Ensures `AddItem2` and buffered registration pass context strings exactly as supplied. | +| `buffered_registration` | Tracks buffered item registration and interval setup separately from normal advice. | + +## Comparison Format + +Each live parity fixture should record one direct MXAccess result and one +gateway result for the same operation. + +Direct MXAccess records include: + +- method name, +- arguments after redaction, +- returned value, +- HRESULT, +- exception type, +- `MXSTATUS_PROXY[]` values, +- native event records in observed order. + +Gateway records include: + +- `MxCommandKind`, +- `ProtocolStatus`, +- `MxCommandReply.ReturnValue`, +- `MxCommandReply.Hresult`, +- repeated `MxCommandReply.Statuses`, +- safe diagnostic message, +- streamed `MxEvent` records in worker-sequence order. + +Compare HRESULT, exception type, returned value, status array shape, raw status +fields, event family order, event payload shape, value projection, and raw +fallback metadata. The gateway must not convert an MXAccess command failure +into a transport failure when the worker captured HRESULT or status details. + +## Validation + +Run the parity fixture matrix tests after changing the matrix: + +```bash +dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~ParityFixtureMatrixTests +``` + +Live MXAccess execution remains opt-in. The matrix defines which scenarios to +run when the installed MXAccess COM component and provider state are available; +normal unit tests only validate the repository fixture shape. + +## Related Documentation + +- [Gateway Testing](./GatewayTesting.md) +- [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md) +- [Protobuf Contracts](./Contracts.md) diff --git a/src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs b/src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs new file mode 100644 index 0000000..0a2f546 --- /dev/null +++ b/src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs @@ -0,0 +1,293 @@ +using System.Text.Json; +using MxGateway.Contracts; + +namespace MxGateway.Tests.Contracts; + +public sealed class ParityFixtureMatrixTests +{ + [Fact] + public void Matrix_DeclaresCurrentProtocolVersionsAndComparisonFields() + { + using JsonDocument matrix = LoadParityMatrix(); + JsonElement root = matrix.RootElement; + + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("mxaccess-gateway-parity-fixture-matrix", root.GetProperty("fixtureSet").GetString()); + Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, root.GetProperty("gatewayProtocolVersion").GetUInt32()); + Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, root.GetProperty("workerProtocolVersion").GetUInt32()); + + JsonElement comparisonFormat = root.GetProperty("comparisonFormat"); + AssertRequiredFields( + comparisonFormat.GetProperty("directMxAccess").GetProperty("requiredFields"), + "method", + "arguments", + "returnedValue", + "hresult", + "statuses", + "events"); + AssertRequiredFields( + comparisonFormat.GetProperty("gatewayResult").GetProperty("requiredFields"), + "kind", + "protocolStatus", + "returnValue", + "hresult", + "statuses", + "events"); + AssertRequiredFields( + comparisonFormat.GetProperty("eventFields"), + "family", + "value", + "quality", + "sourceTimestamp", + "statuses", + "workerSequence"); + AssertRequiredFields( + comparisonFormat.GetProperty("comparisonKeys"), + "hresult", + "statusArrayShape", + "statusRawFields", + "eventFamilyOrder", + "eventPayloadShape", + "valueProjection", + "rawFallbackMetadata"); + } + + [Fact] + public void Matrix_CoversEveryPublicMxAccessMethod() + { + using JsonDocument matrix = LoadParityMatrix(); + JsonElement methodFixtures = matrix.RootElement.GetProperty("methodFixtures"); + + Dictionary fixturesByMethod = []; + HashSet ids = new(StringComparer.Ordinal); + + foreach (JsonElement fixture in methodFixtures.EnumerateArray()) + { + string id = fixture.GetProperty("id").GetString()!; + string method = fixture.GetProperty("method").GetString()!; + string commandKind = fixture.GetProperty("commandKind").GetString()!; + string status = fixture.GetProperty("status").GetString()!; + + Assert.True(ids.Add(id), $"Duplicate parity fixture id '{id}'."); + Assert.True(fixturesByMethod.TryAdd(method, fixture), $"Duplicate parity method '{method}'."); + Assert.StartsWith("MX_COMMAND_KIND_", commandKind, StringComparison.Ordinal); + Assert.Contains(status, KnownFixtureStatuses); + Assert.NotEmpty(fixture.GetProperty("assertions").EnumerateArray()); + AssertCaptureReferencesAreRelative(fixture.GetProperty("captureReferences")); + } + + Assert.Equal(ExpectedPublicMethods.Order(StringComparer.Ordinal), fixturesByMethod.Keys.Order(StringComparer.Ordinal)); + + foreach (string method in ExpectedPublicMethods) + { + JsonElement fixture = fixturesByMethod[method]; + string status = fixture.GetProperty("status").GetString()!; + + Assert.True( + status == "planned_fixture" || status == "documented_gap", + $"Method '{method}' must have a planned parity fixture or documented gap."); + } + } + + [Fact] + public void Matrix_CoversRequiredParityScenarioGroups() + { + using JsonDocument matrix = LoadParityMatrix(); + HashSet knownFixtureIds = GetFixtureIds(matrix.RootElement); + Dictionary groupsById = []; + + foreach (JsonElement group in matrix.RootElement.GetProperty("scenarioGroups").EnumerateArray()) + { + string id = group.GetProperty("id").GetString()!; + + Assert.True(groupsById.TryAdd(id, group), $"Duplicate parity scenario group '{id}'."); + Assert.NotEmpty(group.GetProperty("description").GetString()!); + Assert.NotEmpty(group.GetProperty("fixtureIds").EnumerateArray()); + AssertCaptureReferencesAreRelative(group.GetProperty("captureReferences")); + + foreach (JsonElement fixtureIdElement in group.GetProperty("fixtureIds").EnumerateArray()) + { + string fixtureId = fixtureIdElement.GetString()!; + Assert.Contains(fixtureId, knownFixtureIds); + } + } + + foreach (string requiredGroup in RequiredScenarioGroups) + { + Assert.True(groupsById.ContainsKey(requiredGroup), $"Missing required parity scenario group '{requiredGroup}'."); + } + + AssertScenarioCovers(groupsById["invalid_handles"], "method.remove-item.basic", "method.write.value-status-matrix"); + AssertScenarioCovers(groupsById["write_statuses"], "method.write.value-status-matrix", "event.on-write-complete.status"); + AssertScenarioCovers(groupsById["secured_writes"], "method.write-secured.rejection-gap", "method.write-secured2.authenticated"); + AssertScenarioCovers(groupsById["add_item_context"], "method.add-item2.context", "method.add-buffered-item.context"); + AssertScenarioCovers(groupsById["buffered_registration"], "method.add-buffered-item.context", "event.on-buffered-data-change.batch-gap"); + } + + [Fact] + public void Matrix_CoversEveryPublicMxAccessEventFamily() + { + using JsonDocument matrix = LoadParityMatrix(); + Dictionary fixturesByFamily = []; + + foreach (JsonElement fixture in matrix.RootElement.GetProperty("eventFixtures").EnumerateArray()) + { + string family = fixture.GetProperty("family").GetString()!; + string status = fixture.GetProperty("status").GetString()!; + + Assert.True(fixturesByFamily.TryAdd(family, fixture), $"Duplicate parity event family '{family}'."); + Assert.Contains(status, KnownFixtureStatuses); + Assert.NotEmpty(fixture.GetProperty("assertions").EnumerateArray()); + AssertCaptureReferencesAreRelative(fixture.GetProperty("captureReferences")); + } + + foreach (string eventFamily in ExpectedEventFamilies) + { + Assert.True(fixturesByFamily.ContainsKey(eventFamily), $"Missing parity fixture for event family '{eventFamily}'."); + } + + Assert.Equal("documented_gap", fixturesByFamily["MX_EVENT_FAMILY_OPERATION_COMPLETE"].GetProperty("status").GetString()); + Assert.Equal("documented_gap", fixturesByFamily["MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE"].GetProperty("status").GetString()); + } + + private static readonly string[] ExpectedPublicMethods = + [ + "Register", + "Unregister", + "AddItem", + "AddItem2", + "RemoveItem", + "Advise", + "UnAdvise", + "AdviseSupervisory", + "AddBufferedItem", + "SetBufferedUpdateInterval", + "Suspend", + "Activate", + "Write", + "Write2", + "WriteSecured", + "WriteSecured2", + "AuthenticateUser", + "ArchestrAUserToId", + ]; + + private static readonly string[] ExpectedEventFamilies = + [ + "MX_EVENT_FAMILY_ON_DATA_CHANGE", + "MX_EVENT_FAMILY_ON_WRITE_COMPLETE", + "MX_EVENT_FAMILY_OPERATION_COMPLETE", + "MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE", + ]; + + private static readonly string[] RequiredScenarioGroups = + [ + "invalid_handles", + "write_statuses", + "secured_writes", + "add_item_context", + "buffered_registration", + ]; + + private static readonly string[] KnownFixtureStatuses = + [ + "planned_fixture", + "documented_gap", + ]; + + private static void AssertRequiredFields( + JsonElement fields, + params string[] expectedFields) + { + HashSet declared = fields + .EnumerateArray() + .Select(field => field.GetString()!) + .ToHashSet(StringComparer.Ordinal); + + foreach (string expectedField in expectedFields) + { + Assert.Contains(expectedField, declared); + } + } + + private static void AssertCaptureReferencesAreRelative(JsonElement captureReferences) + { + int count = 0; + + foreach (JsonElement captureReference in captureReferences.EnumerateArray()) + { + string path = captureReference.GetString()!; + + Assert.StartsWith("captures/", path, StringComparison.Ordinal); + Assert.DoesNotContain("\\", path, StringComparison.Ordinal); + Assert.False(Path.IsPathRooted(path), $"Capture reference '{path}' must be relative."); + count++; + } + + Assert.True(count > 0, "Each parity fixture must reference at least one MXAccess capture."); + } + + private static void AssertScenarioCovers( + JsonElement group, + params string[] fixtureIds) + { + HashSet declared = group + .GetProperty("fixtureIds") + .EnumerateArray() + .Select(fixtureId => fixtureId.GetString()!) + .ToHashSet(StringComparer.Ordinal); + + foreach (string fixtureId in fixtureIds) + { + Assert.Contains(fixtureId, declared); + } + } + + private static HashSet GetFixtureIds(JsonElement root) + { + HashSet ids = new(StringComparer.Ordinal); + + foreach (JsonElement fixture in root.GetProperty("methodFixtures").EnumerateArray()) + { + ids.Add(fixture.GetProperty("id").GetString()!); + } + + foreach (JsonElement fixture in root.GetProperty("eventFixtures").EnumerateArray()) + { + ids.Add(fixture.GetProperty("id").GetString()!); + } + + return ids; + } + + private static JsonDocument LoadParityMatrix() + { + return JsonDocument.Parse(File.ReadAllText(Path.Combine(GetParityFixtureRoot().FullName, "parity-fixture-matrix.json"))); + } + + private static DirectoryInfo GetParityFixtureRoot() + { + DirectoryInfo repositoryRoot = FindRepositoryRoot(); + + return new DirectoryInfo(Path.Combine(repositoryRoot.FullName, "clients", "proto", "fixtures", "parity")); + } + + private static DirectoryInfo FindRepositoryRoot() + { + DirectoryInfo? current = new(AppContext.BaseDirectory); + + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "AGENTS.md")) + && Directory.Exists(Path.Combine(current.FullName, "src")) + && Directory.Exists(Path.Combine(current.FullName, "clients"))) + { + return current; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root from the test output directory."); + } +}