diff --git a/.github/workflows/interoperability-reference-guard.yml b/.github/workflows/interoperability-reference-guard.yml index dd5a9d0bc..9b1df9e8f 100644 --- a/.github/workflows/interoperability-reference-guard.yml +++ b/.github/workflows/interoperability-reference-guard.yml @@ -48,6 +48,7 @@ jobs: run: | $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json $target = Get-Content .\ARSAS\evidence\interoperability-reference-target.json -Raw | ConvertFrom-Json + $association = $target.postBaselineAcceptance.sclAssociationInteroperability if ($target.status -ne 'physical-retest-passed-merge-ready') { throw "Convergence status changed unexpectedly: $($target.status)" @@ -58,9 +59,31 @@ jobs: [int]$target.activeStack.consumerIntegration.pullRequest -ne 324) { throw 'Merged engine authority must remain PR #134 + PR #135 with ARSAS #324 provenance.' } - if ($lock.commit -ne $target.activeStack.engineModelAndScl.head -or - [int]$lock.sourcePullRequest -ne 135) { - throw 'Engine lock drifted from the convergence single source of truth.' + if ($lock.mergedMainCommit -ne $target.activeStack.engineModelAndScl.head -or + $lock.previousStablePin.commit -ne $lock.mergedMainCommit -or + [int]$lock.previousStablePin.sourcePullRequest -ne 135) { + throw 'Historical engine authority drifted from the R10 convergence single source of truth.' + } + + if ($association.contractId -ne 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -or + $association.status -ne 'physically-accepted-engine-merged-consumer-pending-merge' -or + -not [bool]$association.preservesR10Baseline -or + [int]$association.engine.pullRequest -ne 143 -or + $association.engine.testedHead -ne $lock.commit -or + $association.engine.mergedMain -ne $lock.sclAssociationInteroperability.mergedEngineCommit -or + $association.engine.tree -ne $lock.sclAssociationInteroperability.engineTree -or + [int]$lock.sourcePullRequest -ne 143 -or + $lock.sclAssociationInteroperability.testedEngineCommit -ne $lock.commit -or + -not [bool]$association.physicalAcceptance.fullDiscoverySkipped -or + [int]$association.physicalAcceptance.associationAttempts -ne 1 -or + [int]$association.physicalAcceptance.matchedDomains -ne 5 -or + [int]$association.physicalAcceptance.failedReads -ne 0 -or + [int]$association.physicalAcceptance.projectionErrors -ne 0 -or + [int]$association.physicalAcceptance.cacheLoss -ne 0 -or + [int]$association.physicalAcceptance.unresolvedRuntimePoints -ne 0 -or + [int]$association.physicalAcceptance.cyclicMmsProcessPolling -ne 0 -or + -not [bool]$association.physicalAcceptance.actualInformationReportObserved) { + throw 'Accepted post-baseline SCL association authority drifted or lost physical evidence.' } if ([bool]$target.promotion.productionPromoted -or [bool]$target.promotion.mergeAllowedBeforePhysicalRetest -or diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 1434dbc91..ec15bc0fe 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -48,33 +48,276 @@ jobs: } "ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - - name: Resolve isolated R7 engine pin + - name: Resolve current engine while preserving R7/R10 authority shell: pwsh run: | $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json $convergence = Get-Content .\ARSAS\evidence\interoperability-reference-target.json -Raw | ConvertFrom-Json + $association = $convergence.postBaselineAcceptance.sclAssociationInteroperability + if ($lock.repository -ne 'masarray/ARIEC61850' -or - $lock.commit -notmatch '^[0-9a-f]{40}$') { - throw 'Invalid ARIEC61850 R7 lock.' + $lock.commit -notmatch '^[0-9a-f]{40} + - name: Checkout exact ARIEC61850 engine revision + shell: pwsh + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT + git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() + if ($actual -ne $env:ARIEC61850_COMMIT) { + throw "Engine SHA mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." + } + + - name: Verify canonical SCL interoperability contracts + shell: pwsh + run: | + $canonical = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedCanonicalModel.cs -Raw + $exporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\CanonicalLiveIedSclExporter.cs -Raw + $association = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.AssociationEvidence.cs -Raw + $cotpClient = Get-Content .\ARIEC61850\src\AR.Iec61850\Osi\CotpClient.cs -Raw + $rcbProjector = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveRcbLogicalGroupProjector.cs -Raw + $smartDiscovery = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw + $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw + $smartGva = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartVariableAccessAttributes.cs -Raw + $referenceParts = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850ReferenceParts.cs -Raw + $liveExporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveIedSclExporter.cs -Raw + $standardRegistry = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850StandardModelRegistry.cs -Raw + $cdcInference = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\CdcInferenceEngine.cs -Raw + $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.CanonicalModel.cs -Raw + $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $convergence = Get-Content .\ARSAS\evidence\interoperability-reference-target.json -Raw | ConvertFrom-Json + $buildTargets = Get-Content .\ARSAS\Directory.Build.targets -Raw + $lifecycle = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw + $sclClient = Get-Content .\ARSAS\Services\NativeIec61850Client.SclAssisted.cs -Raw + $sclPreparation = Get-Content .\ARSAS\Services\SclAssistedConnectionPreparation.cs -Raw + $save = Get-Content .\ARSAS\MainWindow.xaml.cs -Raw + $reloadValidator = Get-Content .\ARSAS\Services\CanonicalSclReloadValidator.cs -Raw + $regression = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalLiveSclExportRegressionTests.cs -Raw + $reloadTests = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalSclReloadValidatorTests.cs -Raw + + foreach ($required in @( + 'class LiveIedCanonicalModel', + 'InstanceValues', + 'LiveIedCommunicationEvidence')) { + if ($canonical -notmatch [regex]::Escape($required)) { + throw "Canonical engine contract missing: $required" + } + } + + foreach ($required in @( + 'ValidateCanonicalCommunication', + 'PreserveRuntimeServiceCapacity', + 'ApplyCanonicalInstanceValues', + 'ValidateRoundTripAssociation')) { + if ($exporter -notmatch [regex]::Escape($required)) { + throw "Canonical SCL exporter contract missing: $required" + } + } + + if ($association -notmatch 'GetAcceptedCommunicationEvidence') { + throw 'Accepted association evidence API is missing.' + } + + if ($env:ARIEC61850_SOURCE_PR -in @('135', '143')) { + $wireReader = '.\ARIEC61850\src\AR.Iec61850\Acse\AcseAssociationRequestIdentityReader.cs' + if (!(Test-Path $wireReader) -or + $association -notmatch 'AcseAssociationRequestIdentityReader\.Read' -or + $association -notmatch '_cotp\.LastConnectParameters\?\.DestinationTsap' -or + $cotpClient -notmatch 'public CotpConnectParameters\? LastConnectParameters' -or + $cotpClient -notmatch 'LastConnectParameters = acceptedParameters' -or + $association -match 'ApTitle\s*=\s*"1,1,1,999,1"') { + throw 'PR #135 must derive canonical AP/AE/selectors from exact accepted ACSE and COTP session evidence, not duplicated profile constants.' + } + } + + if ($smartDiscovery -notmatch 'DiscoverSmartAsync' -or + $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or + $smartGva -notmatch 'GetVariableAccessAttributesSmartAsync' -or + $referenceParts -notmatch 'instanceStart' -or + $referenceParts -notmatch 'IsFourLetterLnClass' -or + $liveExporter -notmatch 'TryResolveStandardSubDataObjectCdc' -or + $liveExporter -notmatch 'DataObjectReferencePaths' -or + $liveExporter -notmatch 'IsEdition2ServiceTrackingCdc' -or + $standardRegistry -notmatch 'Key\("TCTR", "ARtg"\)' -or + $standardRegistry -notmatch 'Key\("TVTR", "VRtg"\)' -or + $standardRegistry -notmatch 'Key\("LTIM", "TmChgDT"\)' -or + $standardRegistry -notmatch 'Key\("LTRK", "BrcbTrk"\)' -or + $standardRegistry -notmatch 'Key\("XCBR", "EEName"\)' -or + $standardRegistry -notmatch 'Key\("LLN0", "MltLev"\)' -or + $cdcInference -notmatch '"CST".*"BTS".*"UTS".*"STS".*"CTS"' -or + $capture -match 'TryBuildSupplementalGetNameListSnapshotAsync' -or + $capture -match 'DiscoverDomainVariableTypeTreeNamesAsync' -or + $capture -match 'AddAdaptiveLogicalNodeSiblingProbeSignalsAsync' -or + $capture -match 'EnrichEngineeringUnitsAsync') { + throw 'external IEC 61850 reference convergence source contract regressed: smart structure-first discovery or semantic SCL authority is missing, or a forbidden legacy browse path returned.' + } + + if ([int]$convergence.physicalReference.externalReferenceCapture.associations -ne 1 -or + [int]$convergence.physicalReference.rejectedLegacyArsasCapture.associations -ne 2 -or + -not [bool]$convergence.discoveryAcceptance.exactlyOneAssociation -or + -not [bool]$convergence.discoveryAcceptance.supplementalLegacyAssociationForbidden -or + -not [bool]$convergence.sclAcceptance.reopenInArsasRequired -or + -not [bool]$convergence.sclAcceptance.physicalReconnectRequired) { + throw 'external IEC 61850 reference physical acceptance target was weakened.' + } + + if ($consumer -notmatch 'LiveIedCanonicalModelBuilder\.Build\(model, communication, initialRead\)' -or + $capture -match 'InitialFcReadPlanner\.FromSclModel' -or + $capture -match 'ExecuteInitialFcReadPlanSmartAsync' -or + $capture -notmatch 'initialFcRoots=deferred' -or + $buildTargets -notmatch 'VerifyPhysicalProvenSmartDiscoveryRoute' -or + $buildTargets -notmatch '\-VerifyOnly' -or + $buildTargets -match 'GITHUB_WORKFLOW' -or + $buildTargets -match 'SmartDiscoveryProductionPromoted' -or + $lifecycle -notmatch 'PublishCanonicalModel\(model, initialRead\)' -or + $save -notmatch 'CanonicalLiveIedSclExporter\.WriteFiles' -or + $save -notmatch 'profile:\s*"full-model"' -or + $save -notmatch 'CanonicalSclReloadValidator\.Validate' -or + $sclPreparation -notmatch 'MmsLocalAssociationProfile\.ExistingRuntimeDefault' -or + $sclClient -notmatch 'IsSafeTrustedSclInitialReadFc' -or + $sclClient -notmatch 'ExecuteInitialFcReadPlanSmartAsync' -or + $reloadValidator -notmatch 'workspaceService\.Open' -or + $reloadValidator -notmatch 'result\.Profile.*full-model' -or + $reloadValidator -notmatch 'changed the proven calling-side runtime identity' -or + $reloadValidator -notmatch 'reproduce accepted association profile' -or + $regression -notmatch 'SmartDiscovery_DefersEagerFcValueReadsFromStructuralScan' -or + $reloadTests -notmatch 'ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDrift' -or + $reloadTests -notmatch 'BuildDefaultAssociationPayload' -or + $reloadTests -notmatch 'GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhysicalCapacity' -or + $rcbProjector -match 'SameNumericText\(left\.IntegrityPeriodMs, right\.IntegrityPeriodMs\)' -or + $rcbProjector -match 'Same\(left\.OptionalFields, right\.OptionalFields\)') { + throw 'ARSAS canonical discovery -> full-model SCL -> exact native reconnect -> safe initial value-read contract is incomplete.' + } + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 8.0.x + + - name: Restore ARSAS solution + run: dotnet restore .\ARSAS\ArIED61850Tester.sln + + - name: Build ARSAS Release + run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + + - name: Verify R7 smart discovery route was compiled + shell: pwsh + run: | + $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.cs -Raw + if ($consumer -notmatch 'DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { + throw 'R7 build compiled without the smart discovery route; refusing legacy-path artifact.' + } + if ($consumer -notmatch 'if \(SmartDiscoveryCaptureModeEnabled\)') { + throw 'R7 build did not install the smart discovery gate at DiscoverSignalsAsync.' + } + Write-Host 'R7 smart discovery route is installed in the compiled source.' + + - name: Run ARSAS canonical-export regressions + run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults + + - name: Build engine and run engine tests + shell: pwsh + run: | + dotnet restore .\ARIEC61850\ARIEC61850.sln + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build .\ARIEC61850\ARIEC61850.sln -c Release --no-restore + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test .\ARIEC61850\ARIEC61850.sln -c Release --no-build --no-restore --logger "trx;LogFileName=ariec61850-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Publish R7 portable single EXE + shell: pwsh + run: | + .\ARSAS\scripts\publish-windows-portable.ps1 ` + -Version $env:ARSAS_VERSION ` + -Runtime win-x64 ` + -SingleFile $true ` + -SelfContained $true ` + -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + + - name: Smoke test R7 portable executable + shell: pwsh + run: | + $exe = ".\ARSAS\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" + if (!(Test-Path $exe -PathType Leaf)) { throw "Portable EXE missing: $exe" } + + $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP 'ARSAS-r7-scl-bundle-cache' + $process = Start-Process -FilePath $exe -ArgumentList @('--portable-smoke-test') -PassThru + if (-not $process.WaitForExit(30000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw 'Portable EXE smoke test timed out.' } - if ($lock.purpose -notmatch 'R7 physical SCL repair pin') { - throw 'Engine lock is not explicitly scoped as the R7 physical SCL repair.' + if ($process.ExitCode -ne 0) { + throw "Portable EXE smoke test failed: $($process.ExitCode)" + } + + $convergencePath = ".\ARSAS\evidence\interoperability-reference-target.json" + $convergenceHash = (Get-FileHash $convergencePath -Algorithm SHA256).Hash.ToLowerInvariant() + $convergence = Get-Content $convergencePath -Raw | ConvertFrom-Json + + @( + 'ARSAS R7 SCL interoperability field-test build', + "ARSAS commit: $env:ARSAS_COMMIT", + "ARIEC61850 commit: $env:ARIEC61850_COMMIT", + "Engine source PR: $env:ARIEC61850_SOURCE_PR", + "Convergence contract SHA256: $convergenceHash", + "Convergence status: $($convergence.status)", + 'Active stack: ARIEC61850 #134 -> #135 -> ARSAS #324', + '', + 'Acceptance target:', + 'IED -> fast structural Smart Discovery -> full-model SCL Ed1/Ed2 -> ARSAS Workspace Reload -> exact native calling/called association -> bounded safe FC-root snapshot.', + 'CI invariant: full-model export excludes no discovered attributes; SCL rebuilds the accepted native handshake; golden RCB shape is 34 runtime -> 32 logical with ConfReportControl max=34.', + 'Physical gate: one MMS association; no supplemental legacy browse; no recursive per-leaf GVA storm; same-relay PCAP + Ed2 IID + Ed1 ICD + diagnostic required before promotion.', + '', + 'This artifact is NOT production-promotion authority and does NOT replace P0-5e/P0-5f physical discovery-budget evidence.' + ) | Set-Content .\ARSAS\dist\R7-SCL-INTEROP-BUILD.txt -Encoding utf8 + + - name: Upload R7 SCL interoperability build + uses: actions/upload-artifact@v7 + with: + name: ARSAS-r7-scl-interoperability-win-x64 + path: | + ARSAS\dist\ARSAS-*-win-x64-portable.exe + ARSAS\dist\R7-SCL-INTEROP-BUILD.txt + ARSAS\evidence\interoperability-reference-target.json + ARSAS\docs\INTEROPERABILITY_REFERENCE_CONTRACT.md + ARSAS\TestResults\*.trx + if-no-files-found: error + retention-days: 14 + -or + $lock.ref -ne 'main') { + throw 'Invalid ARIEC61850 integration lock.' } if ($convergence.status -ne 'physical-retest-passed-merge-ready' -or [int]$convergence.activeStack.enginePerformance.pullRequest -ne 134 -or [int]$convergence.activeStack.engineModelAndScl.pullRequest -ne 135 -or [int]$convergence.activeStack.consumerIntegration.pullRequest -ne 324 -or - $convergence.activeStack.engineModelAndScl.head -ne $lock.commit -or + $convergence.activeStack.engineModelAndScl.head -ne $lock.mergedMainCommit -or + $lock.previousStablePin.commit -ne $lock.mergedMainCommit -or + [int]$lock.previousStablePin.sourcePullRequest -ne 135 -or [bool]$convergence.promotion.productionPromoted -or [bool]$convergence.promotion.mergeAllowedBeforePhysicalRetest -or [bool]$convergence.promotion.physicalRetestRequired -or -not [bool]$convergence.promotion.mergeAllowedAfterPhysicalRetest -or -not [bool]$convergence.promotion.physicalRetestPassed) { - throw 'external IEC 61850 reference convergence single-source-of-truth does not match the physically proven merged-engine + ARSAS #324 authority.' + throw 'Historical R7/R10 convergence authority was weakened.' } - if ([int]$lock.sourcePullRequest -notin @(134, 135)) { - throw "Unexpected R7 engine source PR: $($lock.sourcePullRequest)" + + if ($association.contractId -ne 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -or + $association.status -ne 'physically-accepted-engine-merged-consumer-pending-merge' -or + -not [bool]$association.preservesR10Baseline -or + $association.engine.testedHead -ne $lock.commit -or + [int]$association.engine.pullRequest -ne [int]$lock.sourcePullRequest -or + [int]$lock.sourcePullRequest -ne 143 -or + -not [bool]$association.physicalAcceptance.fullDiscoverySkipped -or + [int]$association.physicalAcceptance.failedReads -ne 0 -or + [int]$association.physicalAcceptance.projectionErrors -ne 0 -or + [int]$association.physicalAcceptance.cacheLoss -ne 0 -or + [int]$association.physicalAcceptance.unresolvedRuntimePoints -ne 0 -or + -not [bool]$association.physicalAcceptance.actualInformationReportObserved) { + throw 'Accepted post-baseline SCL association authority is incomplete or inconsistent.' } "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml index 9e4cb87bf..8291c1fb7 100644 --- a/.github/workflows/smart-discovery-mainline-readiness.yml +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -69,17 +69,35 @@ jobs: } } - if ($lock.commit -ne $target.activeStack.engineModelAndScl.mainMerge -or - $lock.commit -ne $r10.testedArtifact.engineMergedMain -or + if ($lock.mergedMainCommit -ne $target.activeStack.engineModelAndScl.mainMerge -or + $lock.mergedMainCommit -ne $r10.testedArtifact.engineMergedMain -or $lock.physicalTestedCommit -ne $r10.testedArtifact.engineTestedHead -or - $lock.mergedMainTree -ne $r10.testedArtifact.engineTree) { - throw 'ARSAS engine lock no longer matches the R10 physical authority.' + $lock.mergedMainTree -ne $r10.testedArtifact.engineTree -or + $lock.previousStablePin.commit -ne $lock.mergedMainCommit -or + [int]$lock.previousStablePin.sourcePullRequest -ne 135) { + throw 'Historical ARSAS engine baseline no longer matches the R10 physical authority.' + } + + $association = $target.postBaselineAcceptance.sclAssociationInteroperability + if ($association.contractId -ne 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -or + $association.status -ne 'physically-accepted-engine-merged-consumer-pending-merge' -or + -not [bool]$association.preservesR10Baseline -or + $association.engine.testedHead -ne $lock.commit -or + [int]$association.engine.pullRequest -ne [int]$lock.sourcePullRequest -or + [int]$lock.sourcePullRequest -ne 143 -or + -not [bool]$association.physicalAcceptance.fullDiscoverySkipped -or + [int]$association.physicalAcceptance.failedReads -ne 0 -or + [int]$association.physicalAcceptance.projectionErrors -ne 0 -or + [int]$association.physicalAcceptance.cacheLoss -ne 0 -or + [int]$association.physicalAcceptance.unresolvedRuntimePoints -ne 0 -or + -not [bool]$association.physicalAcceptance.actualInformationReportObserved) { + throw 'Post-baseline SCL association acceptance is missing or inconsistent.' } "ENGINE_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ENGINE_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - - name: Checkout exact merged ARIEC61850 authority + - name: Checkout exact accepted ARIEC61850 integration authority shell: powershell run: | git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ENGINE_REPOSITORY.git" ..\ARIEC61850 @@ -87,7 +105,7 @@ jobs: git -C ..\ARIEC61850 checkout --quiet --detach $env:ENGINE_COMMIT $actual = (git -C ..\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() if ($actual -ne $env:ENGINE_COMMIT) { - throw "R10 engine authority mismatch. Expected $env:ENGINE_COMMIT, got $actual." + throw "Accepted engine authority mismatch. Expected $env:ENGINE_COMMIT, got $actual." } - name: Setup .NET 8 @@ -95,7 +113,7 @@ jobs: with: dotnet-version: 8.0.x - - name: Validate and test merged engine authority + - name: Validate and test accepted engine authority shell: powershell run: | dotnet restore ..\ARIEC61850\ARIEC61850.sln diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs index b54a4d373..6dde40500 100644 --- a/Services/NativeIec61850Client.SclAssisted.cs +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -138,7 +138,7 @@ public async Task ConnectUsingSclAsync( _port, maximumVariableReferencesPerRead); if (!preparation.IsSuccess || - preparation.AssociationPlan is null || + preparation.AssociationResolution is null || preparation.InitialReadDesign is null || preparation.InitialReadPlan is null) { @@ -161,7 +161,7 @@ preparation.InitialReadDesign is null || { var associationWatch = Stopwatch.StartNew(); var online = await _session.ConnectSclAssistedAsync( - preparation.AssociationPlan, + preparation.AssociationResolution, preparation.DomainInventory, TimeSpan.FromSeconds(8), cancellationToken).ConfigureAwait(false); @@ -324,7 +324,9 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial || initialValueCacheLoss > 0; LastDiscoverySummary = - $"SCL-assisted MMS: domains={reconciledDomains.Count}, extraOnlineDomains={extraDomains}, " + + $"SCL-assisted MMS: associationCandidate={online.SelectedAssociationCandidateName}, " + + $"associationSource={online.SelectedAssociationCandidateSource}, associationAttempts={online.AssociationAttemptCount}, " + + $"domains={reconciledDomains.Count}, extraOnlineDomains={extraDomains}, " + $"initialTargets={initialRead.Plan.Targets.Count}, fcRootTargets={fcRootTargets}, doScopedTargets={dataObjectScopedTargets}, " + $"successfulReads={initialRead.SuccessfulTargetCount}, failedReads={initialRead.FailedTargetCount}, projectedLeaves={initialRead.ProjectedLeafCount}, " + $"projectedUniqueValues={projectedUniqueValues}, initialValueCache={_trustedSclInitialValues.Count}, cacheLoss={initialValueCacheLoss}, " + @@ -342,6 +344,7 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut : string.Empty; var warnings = preparation.Warnings + .Concat(online.AssociationResolutionNotes) .Concat(extraDomains > 0 ? new[] { $"IED exposes {extraDomains} extra online MMS domain(s); they remain evidence only and do not mutate the SCL model." } : Array.Empty()) diff --git a/Services/SclAssistedConnectionPreparation.cs b/Services/SclAssistedConnectionPreparation.cs index c328b884d..3646961c0 100644 --- a/Services/SclAssistedConnectionPreparation.cs +++ b/Services/SclAssistedConnectionPreparation.cs @@ -7,6 +7,7 @@ namespace ArIED61850Tester.Services; public sealed class SclAssistedConnectionPreparation { public ArScl.SclAssistedMmsAssociationPlan? AssociationPlan { get; init; } + public ArScl.SclAssistedMmsAssociationResolution? AssociationResolution { get; init; } public ArScl.SclMmsDomainInventory DomainInventory { get; init; } = new(); public ArScl.SclInitialFcReadDesign? InitialReadDesign { get; init; } public ArMms.InitialFcReadPlan? InitialReadPlan { get; init; } @@ -14,7 +15,7 @@ public sealed class SclAssistedConnectionPreparation public IReadOnlyList Warnings { get; init; } = Array.Empty(); public bool IsSuccess => Errors.Count == 0 && - AssociationPlan is not null && + AssociationResolution?.IsSuccess == true && DomainInventory.IsSuccess && InitialReadDesign?.IsSuccess == true && InitialReadPlan?.IsValid == true; @@ -126,28 +127,42 @@ public static SclAssistedConnectionPreparation Build( // association. The previous SclInteroperabilityDefault changed source TSEL // to 0000 and calling AE qualifier to 23, so a generated SCL could round-trip // its remote/called identity while still emitting a different wire handshake. - var association = ArScl.SclAssistedMmsAssociationPlanBuilder.BuildExact( + // Preserve the exact complete-SCL plan as a compatibility/round-trip contract, + // but do not treat an incomplete optional association identity as fatal. The + // engine resolver distinguishes missing from invalid/ambiguous fields and owns + // every interoperability candidate. + var exactAssociation = ArScl.SclAssistedMmsAssociationPlanBuilder.BuildExact( effectiveRemote, ArScl.MmsLocalAssociationProfile.ExistingRuntimeDefault); - warnings.AddRange(association.Warnings); - if (!association.IsSuccess || association.Plan is null) + warnings.AddRange(exactAssociation.Warnings); + + ArScl.SclAssistedMmsAssociationPlan? runtimePlan = null; + if (exactAssociation.IsSuccess && exactAssociation.Plan is not null) { - errors.AddRange(association.Errors); - return Fail(errors, warnings); + runtimePlan = new ArScl.SclAssistedMmsAssociationPlan + { + Host = normalizedHost, + Port = normalizedPort, + IedName = exactAssociation.Plan.IedName, + AccessPointName = exactAssociation.Plan.AccessPointName, + LocalProfileName = exactAssociation.Plan.LocalProfileName, + Cotp = exactAssociation.Plan.Cotp, + Association = exactAssociation.Plan.Association, + CotpConnectRequest = exactAssociation.Plan.CotpConnectRequest, + SessionPresentationAcseMmsRequest = exactAssociation.Plan.SessionPresentationAcseMmsRequest + }; } - var runtimePlan = new ArScl.SclAssistedMmsAssociationPlan + var associationResolution = ArScl.SclAssistedMmsAssociationCandidateResolver.Resolve( + effectiveRemote, + ArScl.MmsLocalAssociationProfile.ExistingRuntimeDefault, + normalizedPort); + warnings.AddRange(associationResolution.Warnings); + if (!associationResolution.IsSuccess) { - Host = normalizedHost, - Port = normalizedPort, - IedName = association.Plan.IedName, - AccessPointName = association.Plan.AccessPointName, - LocalProfileName = association.Plan.LocalProfileName, - Cotp = association.Plan.Cotp, - Association = association.Plan.Association, - CotpConnectRequest = association.Plan.CotpConnectRequest, - SessionPresentationAcseMmsRequest = association.Plan.SessionPresentationAcseMmsRequest - }; + errors.AddRange(associationResolution.Errors); + return Fail(errors, warnings, associationResolution); + } var domains = ArScl.SclMmsDomainInventoryReader.Read(sclXml, normalizedIed, normalizedAccessPoint); warnings.AddRange(domains.Warnings); @@ -174,6 +189,7 @@ public static SclAssistedConnectionPreparation Build( return new SclAssistedConnectionPreparation { AssociationPlan = runtimePlan, + AssociationResolution = associationResolution, DomainInventory = domains, InitialReadDesign = design, InitialReadPlan = initialReadPlan, @@ -184,9 +200,11 @@ public static SclAssistedConnectionPreparation Build( private static SclAssistedConnectionPreparation Fail( IReadOnlyCollection errors, - IReadOnlyCollection warnings) + IReadOnlyCollection warnings, + ArScl.SclAssistedMmsAssociationResolution? associationResolution = null) => new() { + AssociationResolution = associationResolution, Errors = errors.Where(message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray(), Warnings = warnings.Where(message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray() }; diff --git a/docs/INTEROPERABILITY_REFERENCE_CONTRACT.md b/docs/INTEROPERABILITY_REFERENCE_CONTRACT.md index 9d0b25d8f..f2dfbcd64 100644 --- a/docs/INTEROPERABILITY_REFERENCE_CONTRACT.md +++ b/docs/INTEROPERABILITY_REFERENCE_CONTRACT.md @@ -31,6 +31,39 @@ Merged engine provenance: - PR #135 → main merge `648124097621046f5f127ceb1cf853fea54db730`: canonical model, SCL interoperability, P1/P2 value pipeline. - ARSAS PR #324: consumer integration and physical R10 proof. +## Post-baseline SCL association interoperability — physically accepted + +A later, narrowly scoped interoperability layer extends trusted-SCL reconnect when a +ConnectedAP omits called AP-title and/or AE qualifier. It does **not** replace the +R10 Smart Discovery/model baseline above. + +Accepted source authority: + +- ARIEC61850 PR #143 tested head `84e9820e5a32690475960e49d5ef74e6637847fd`; +- engine main merge `e5deed1d8aa11d97991695c6e390baafea7ab797`; +- both engine commits have identical tree + `d92676dd6d6243ec997749dd0355aa8c36ad83bd`; +- ARSAS PR #425 physical merge-test tree + `9dc35d722b54872ed8c506eba062a80945761aa9`. + +The physical trusted-SCL session accepted the bounded `BalancedApTitle` candidate on +attempt 1, matched all 5 expected MMS domains, completed 395/395 bounded initial +reads, produced `projectionErrors=0` and `cacheLoss=0`, skipped full discovery, +kept cyclic MMS process polling at zero, and observed live InformationReport traffic +with final runtime unresolved count zero. + +The contract remains fail-closed: + +- explicit valid SCL association fields are immutable constraints; +- malformed or conflicting explicit values are not repaired by guessing; +- only genuinely unspecified fields may be completed by bounded engine-owned profiles; +- each candidate starts on a fresh transport; +- the source SCL is never rewritten by association negotiation; +- SCL-assisted success does not trigger NamedVariable/DataSet rediscovery or full discovery. + +The historical R10 baseline remains the discovery/model authority. This section is a +post-baseline accepted layer, not a rewrite of R10 evidence. + ## P0 — structural discovery freeze Contract: `P0-R9-STRUCTURAL`. diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 360e72a96..7c9c8aa31 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "648124097621046f5f127ceb1cf853fea54db730", - "sourcePullRequest": 135, - "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 648124097621046f5f127ceb1cf853fea54db730 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero. P1 trusted-SCL projection repair removes cross-DO positional dependence for multi-DO CF structures: SCL LNodeType order is not treated as MMS FC-structure order, so CF hydration is split into exact DO-scoped structured Reads and remains batched/bounded. P0 structural discovery remains unchanged. P2 makes instance-value identity exact-case end-to-end: ARSAS trusted-SCL caching uses StringComparer.Ordinal and reports projectedUniqueValues/cacheLoss; engine TypeSpecification member resolution and canonical DO/DA instance-value targeting are case-sensitive so legal paths such as tracking t/T cannot collapse or cross-resolve. R10 physical acceptance passed on AA1E1F06R4: Ed2 and Ed1 both reached projectionErrors=0 and cacheLoss=0, all planned reads succeeded, 58/58 runtime points were report-backed, and actual InformationReport traffic was observed. The exact tested commit 9935d6902d786cc69b299260fe36b835944d5e81 and merged main commit 648124097621046f5f127ceb1cf853fea54db730 have the identical source tree 1cf7e08f333f24994625e8fe8416dbd0a16195b1.", + "commit": "84e9820e5a32690475960e49d5ef74e6637847fd", + "sourcePullRequest": 143, + "purpose": "Physically accepted post-v1.6.40 SCL-assisted MMS association interoperability layer. Exact tested engine head 84e9820e5a32690475960e49d5ef74e6637847fd is preserved as the runtime pin; engine PR #143 is merged on main at e5deed1d8aa11d97991695c6e390baafea7ab797 with identical tree d92676dd6d6243ec997749dd0355aa8c36ad83bd. Missing ConnectedAP association fields remain unresolved rather than fabricated; explicit valid SCL fields are immutable constraints, malformed/conflicting declarations fail closed, candidates are bounded/serial with fresh transport, and successful SCL-assisted association performs Domain/VMD validation only with no full discovery fallback. The R10 discovery/model baseline remains independently frozen by physicalTestedCommit/mergedMainCommit/mergedMainTree and previousStablePin.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, @@ -22,5 +22,23 @@ }, "physicalTestedCommit": "9935d6902d786cc69b299260fe36b835944d5e81", "mergedMainCommit": "648124097621046f5f127ceb1cf853fea54db730", - "mergedMainTree": "1cf7e08f333f24994625e8fe8416dbd0a16195b1" + "mergedMainTree": "1cf7e08f333f24994625e8fe8416dbd0a16195b1", + "previousStablePin": { + "commit": "648124097621046f5f127ceb1cf853fea54db730", + "sourcePullRequest": 135, + "purpose": "ARSAS v1.6.40 merged/physically accepted Smart Discovery + canonical SCL/R10 baseline retained unchanged as historical discovery/model authority beneath the separately accepted SCL-association interoperability layer." + }, + "sclAssociationInteroperability": { + "contractId": "SCL-ASSOC-PARTIAL-ADDR-PHYSICAL", + "status": "physically-accepted-engine-merged-consumer-pending-merge", + "sourcePullRequest": 143, + "testedEngineCommit": "84e9820e5a32690475960e49d5ef74e6637847fd", + "mergedEngineCommit": "e5deed1d8aa11d97991695c6e390baafea7ab797", + "engineTree": "d92676dd6d6243ec997749dd0355aa8c36ad83bd", + "consumerPullRequest": 425, + "consumerPhysicalMergeTestCommit": "9dc35d722b54872ed8c506eba062a80945761aa9", + "preservesR10DiscoveryModelBaseline": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true + } } diff --git a/evidence/interoperability-reference-target.json b/evidence/interoperability-reference-target.json index 5c4f00426..2fbf5c650 100644 --- a/evidence/interoperability-reference-target.json +++ b/evidence/interoperability-reference-target.json @@ -496,5 +496,57 @@ }, "actualInformationReportObserved": true } + }, + "postBaselineAcceptance": { + "sclAssociationInteroperability": { + "contractId": "SCL-ASSOC-PARTIAL-ADDR-PHYSICAL", + "status": "physically-accepted-engine-merged-consumer-pending-merge", + "scope": "Trusted-SCL reconnect when ConnectedAP omits called AP-title and/or AE qualifier while explicit IP/selectors remain authoritative.", + "preservesR10Baseline": true, + "engine": { + "pullRequest": 143, + "testedHead": "84e9820e5a32690475960e49d5ef74e6637847fd", + "mergedMain": "e5deed1d8aa11d97991695c6e390baafea7ab797", + "tree": "d92676dd6d6243ec997749dd0355aa8c36ad83bd" + }, + "consumer": { + "pullRequest": 425, + "testedMergeTreeHead": "9dc35d722b54872ed8c506eba062a80945761aa9" + }, + "physicalAcceptance": { + "date": "2026-10-06", + "associationProfile": "BalancedApTitle", + "associationAttempts": 1, + "expectedDomains": 5, + "observedDomains": 5, + "matchedDomains": 5, + "missingDomains": 0, + "extraDomains": 0, + "initialTargets": 395, + "successfulReads": 395, + "failedReads": 0, + "projectedLeaves": 4938, + "projectedUniqueValues": 4938, + "initialValueCache": 4938, + "cacheLoss": 0, + "projectionErrors": 0, + "fullDiscoverySkipped": true, + "staticDataSets": 1, + "staticMembers": 31, + "reportBackedRuntimePoints": 29, + "unresolvedRuntimePoints": 0, + "actualInformationReportObserved": true, + "cyclicMmsProcessPolling": 0 + }, + "invariants": { + "explicitSclAssociationFieldsRemainConstraints": true, + "malformedOrConflictingExplicitFieldsFailClosed": true, + "candidateEnumerationBounded": true, + "freshTransportPerCandidate": true, + "sourceSclNotMutated": true, + "noNamedVariableOrDatasetRediscovery": true, + "noFullDiscoveryFallback": true + } + } } } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 745cf9c02..4905506d9 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -401,18 +401,20 @@ public void SourceClean_GuardsEveryTrackedFileWithoutWholeFileExceptions() [Fact] - public void EnginePin_MatchesPhysicalSclRepairHead() + public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCandidatePins() { var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); Assert.Contains( - "\"commit\": \"648124097621046f5f127ceb1cf853fea54db730\"", + "\"mergedMainCommit\": \"648124097621046f5f127ceb1cf853fea54db730\"", lockFile, StringComparison.Ordinal); Assert.Contains( "\"physicalTestedCommit\": \"9935d6902d786cc69b299260fe36b835944d5e81\"", lockFile, StringComparison.Ordinal); + Assert.Contains("\"sourcePullRequest\": 143", lockFile, StringComparison.Ordinal); + Assert.Contains("\"previousStablePin\"", lockFile, StringComparison.Ordinal); Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); Assert.Contains("exact association request bytes accepted by the IED", lockFile, StringComparison.Ordinal); Assert.Contains("accepted COTP destination selector", lockFile, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/DiscoveryStaticWorkflowParityRegressionTests.cs b/tests/ARSAS.Tests/DiscoveryStaticWorkflowParityRegressionTests.cs index fd103eafe..dcbe69b00 100644 --- a/tests/ARSAS.Tests/DiscoveryStaticWorkflowParityRegressionTests.cs +++ b/tests/ARSAS.Tests/DiscoveryStaticWorkflowParityRegressionTests.cs @@ -213,6 +213,37 @@ public void ExistingOpenedModelWorkflow_RemainsTaskFirstAndStaticReportOnly() Assert.Contains("cyclic MMS process polling and dynamic DataSet writes remain disabled", shared, StringComparison.Ordinal); } + [Fact] + public void OpenScl_Uses_EngineOwned_Bounded_Association_Resolution_Without_Discovery_Fallback() + { + var preparation = Read("Services/SclAssistedConnectionPreparation.cs"); + Assert.Contains( + "SclAssistedMmsAssociationCandidateResolver.Resolve", + preparation, + StringComparison.Ordinal); + Assert.Contains("AssociationResolution = associationResolution", preparation, StringComparison.Ordinal); + Assert.DoesNotContain("errors.AddRange(exactAssociation.Errors)", preparation, StringComparison.Ordinal); + + var client = Read("Services/NativeIec61850Client.SclAssisted.cs"); + var start = client.IndexOf( + "public async Task ConnectUsingSclAsync", + StringComparison.Ordinal); + var end = client.IndexOf( + "private static ArMms.MmsReportInventory BuildTrustedSclReportInventory", + start, + StringComparison.Ordinal); + + Assert.True(start >= 0 && end > start); + var connectFlow = client[start..end]; + + Assert.Contains("preparation.AssociationResolution", connectFlow, StringComparison.Ordinal); + Assert.Contains("_session.ConnectSclAssistedAsync", connectFlow, StringComparison.Ordinal); + Assert.Contains("online.SelectedAssociationCandidateName", connectFlow, StringComparison.Ordinal); + Assert.Contains("online.AssociationAttemptCount", connectFlow, StringComparison.Ordinal); + Assert.DoesNotContain(".DiscoverAsync(", connectFlow, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverSignals", connectFlow, StringComparison.Ordinal); + } + private static string Read(string relativePath) => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/SclAssistedConnectionPreparationInteroperabilityTests.cs b/tests/ARSAS.Tests/SclAssistedConnectionPreparationInteroperabilityTests.cs new file mode 100644 index 000000000..f1d115d02 --- /dev/null +++ b/tests/ARSAS.Tests/SclAssistedConnectionPreparationInteroperabilityTests.cs @@ -0,0 +1,92 @@ +using AR.Iec61850.Scl; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SclAssistedConnectionPreparationInteroperabilityTests +{ + [Fact] + public void Incomplete_Cid_Association_Identity_Remains_Connectable_Without_Discovery_Fallback() + { + const string xml = """ + + + + +
+

192.0.2.10

+

255.255.255.0

+

00000001

+

0001

+

0001

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ """; + + var preparation = SclAssistedConnectionPreparationBuilder.Build( + xml, + "IED1", + "P1", + "192.0.2.10", + 102); + + Assert.True(preparation.IsSuccess, string.Join(" | ", preparation.Errors)); + Assert.Null(preparation.AssociationPlan); + + var resolution = Assert.IsType( + preparation.AssociationResolution); + Assert.True(resolution.IsSuccess, string.Join(" | ", resolution.Errors)); + Assert.InRange( + resolution.Candidates.Count, + 1, + SclAssistedMmsAssociationCandidateResolver.MaximumCandidateCount); + Assert.Contains( + resolution.Fields, + field => + field.Name == "OSI-AP-Title" && + field.State == SclAssociationFieldState.Unspecified); + Assert.Contains( + resolution.Fields, + field => + field.Name == "OSI-AE-Qualifier" && + field.State == SclAssociationFieldState.Unspecified); + + Assert.Equal(new[] { "EXACT_APP_DOMAIN" }, preparation.DomainInventory.ExpectedDomains); + Assert.Single(preparation.InitialReadDesign!.Model.DataSets); + Assert.Single(preparation.InitialReadDesign.Model.ReportControls); + Assert.NotNull(preparation.InitialReadPlan); + + // This fixture proves preparation/model convergence only. No network or full + // discovery API is invoked from the pure preparation builder. + } +} diff --git a/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs b/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs index 35f55d202..ff22c61ba 100644 --- a/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs +++ b/tests/ARSAS.Tests/SclExportSemanticParityPatchTests.cs @@ -170,11 +170,11 @@ public void SaveHook_IsAfterCanonicalSerialization_AndBeforeReloadValidation() var engineLock = ReadRepoFile("engines/ARIEC61850.lock.json"); Assert.Contains( - "\"commit\": \"648124097621046f5f127ceb1cf853fea54db730\"", + "\"mergedMainCommit\": \"648124097621046f5f127ceb1cf853fea54db730\"", engineLock, StringComparison.Ordinal); Assert.Contains( - "\"sourcePullRequest\": 135", + "\"physicalTestedCommit\": \"9935d6902d786cc69b299260fe36b835944d5e81\"", engineLock, StringComparison.Ordinal); }