diff --git a/.github/workflows/scl-export-only-semantic-candidate.yml b/.github/workflows/scl-export-only-semantic-candidate.yml new file mode 100644 index 000000000..efa210f01 --- /dev/null +++ b/.github/workflows/scl-export-only-semantic-candidate.yml @@ -0,0 +1,179 @@ +name: SCL Export-Only Semantic Candidate + +on: + pull_request: + paths: + - "engines/ARIEC61850.lock.json" + - "evidence/scl-export-only-semantic-candidate.json" + - "tests/ARSAS.Tests/P07ReleaseCandidateLockRegressionTests.cs" + - "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" + - "tests/ARSAS.Tests/SclExportOnlySemanticIsolationRegressionTests.cs" + - ".github/workflows/scl-export-only-semantic-candidate.yml" + workflow_dispatch: + +concurrency: + group: scl-export-only-semantic-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build-export-only-candidate: + runs-on: windows-latest + steps: + - name: Checkout exact ARSAS candidate + shell: pwsh + env: + ARSAS_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:GITHUB_REPOSITORY.git" ARSAS + git -C .\ARSAS fetch --quiet --depth 1 origin $env:ARSAS_SOURCE_SHA + git -C .\ARSAS checkout --quiet --detach $env:ARSAS_SOURCE_SHA + $actual = (git -C .\ARSAS rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -ne $env:ARSAS_SOURCE_SHA.ToLowerInvariant()) { throw "ARSAS SHA mismatch." } + "ARSAS_COMMIT=$actual" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Validate candidate contract + shell: pwsh + run: | + $baseline = Get-Content .\ARSAS\evidence\p0.7-release-candidate-lock.json -Raw | ConvertFrom-Json + $candidate = Get-Content .\ARSAS\evidence\scl-export-only-semantic-candidate.json -Raw | ConvertFrom-Json + $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json + + if ($baseline.physicalReference.engineBaseline -ne '648124097621046f5f127ceb1cf853fea54db730' -or + [int]$baseline.acceptedRuntime.staticMembers -ne 58 -or + [int]$baseline.acceptedRuntime.selectedLiveRows -ne 58 -or + [int]$baseline.acceptedRuntime.analogRows -ne 22 -or + [int]$baseline.acceptedRuntime.digitalRows -ne 36 -or + [int]$baseline.acceptedRuntime.reportBackedRows -ne 58 -or + [int]$baseline.acceptedRuntime.cyclicMmsProcessPolling -ne 0) { + throw 'Frozen P0.7 baseline changed.' + } + + if ($candidate.contractId -ne 'SCL-EXPORT-ONLY-SEMANTIC-P0' -or + [int]$candidate.replacementCandidate.enginePullRequest -ne 140 -or + $candidate.replacementCandidate.engineBase -ne '648124097621046f5f127ceb1cf853fea54db730' -or + $candidate.replacementCandidate.engineCommit -ne $lock.commit -or + -not [bool]$candidate.replacementCandidate.discoveryRuntimeSourceMustMatchPhysicalBaseline -or + [bool]$candidate.replacementCandidate.liveDiscoveryModelMutationAllowed -or + [bool]$candidate.replacementCandidate.acquisitionBehaviorChangeAllowed) { + throw 'Export-only semantic candidate contract invalid.' + } + + if ([int]$lock.sourcePullRequest -ne 140) { throw 'Only engine PR #140 is permitted.' } + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + $version = (Get-Content .\ARSAS\VERSION -Raw).Trim() + "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Checkout exact engine candidate and baseline + 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 fetch --quiet --depth 1 origin 648124097621046f5f127ceb1cf853fea54db730 + git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT + + - name: Prove engine is export-only + shell: pwsh + run: | + $base = '648124097621046f5f127ceb1cf853fea54db730' + $head = $env:ARIEC61850_COMMIT + $changed = @(git -C .\ARIEC61850 diff --name-only $base $head) + $expected = @( + 'src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs', + 'tests/AR.Iec61850.Tests/Scl/LiveIedSclExportOnlySemanticAuthorityTests.cs', + 'src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs' + ) + $unexpected = @($changed | Where-Object { $_ -notin $expected }) + $missing = @($expected | Where-Object { $_ -notin $changed }) + if ($unexpected.Count -gt 0 -or $missing.Count -gt 0 -or $changed.Count -ne 3) { + $changed | ForEach-Object { Write-Host $_ } + throw 'Engine candidate is not export-only plus the approved comment cleanup.' + } + + $registryDiff = @(git -C .\ARIEC61850 diff --unified=0 $base $head -- src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs) + $registryChangedLines = @($registryDiff | Where-Object { + $_ -match '^[+-]' -and $_ -notmatch '^(---|\+\+\+)' + }) + $registryCodeDelta = @($registryChangedLines | Where-Object { + $_ -notmatch '^[+-]\s*//' + }) + if ($registryCodeDelta.Count -ne 0) { + $registryCodeDelta | ForEach-Object { Write-Host $_ } + throw 'StandardModelRegistry contains executable code drift; only source-clean comment wording is allowed.' + } + + git -C .\ARIEC61850 diff --exit-code $base $head -- src/AR.Iec61850/Mms src/AR.Iec61850/Osi src/AR.Iec61850/Acse + if ($LASTEXITCODE -ne 0) { throw 'Wire/MMS source differs from physical baseline.' } + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Build and test engine + 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=engine-export-only.trx" --results-directory .\ARSAS\TestResults + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Build and test ARSAS + shell: pwsh + run: | + dotnet restore .\ARSAS\ArIED61850Tester.sln + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-export-only.trx" --results-directory .\ARSAS\TestResults + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Publish portable candidate + 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 and manifest + shell: pwsh + run: | + $exe = ".\ARSAS\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" + if (!(Test-Path $exe -PathType Leaf)) { throw "Portable EXE missing." } + $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP 'ARSAS-export-only-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 ($process.ExitCode -ne 0) { throw "Portable smoke test failed." } + + @( + 'ARSAS SCL export-only semantic physical-retest candidate', + "ARSAS commit: $env:ARSAS_COMMIT", + "Engine candidate: $env:ARIEC61850_COMMIT", + 'Physical engine baseline: 648124097621046f5f127ceb1cf853fea54db730', + 'Engine executable diff is LiveIedSclExporter.cs only; one regression test plus two source-clean comment lines are the only additional changes.', + 'Discovery executable logic plus MMS/Osi/Acse source matches the physical baseline; the registry delta is comment-only.', + 'Acceptance: discovery path/speed baseline, 58/58, analog 22, digital 36, report-backed 58, actual InformationReport, polling 0.', + 'SCL target: CBClsCounter live runtime unchanged; saved/reopened SCL INS/INT32.' + ) | Set-Content .\ARSAS\dist\SCL-EXPORT-ONLY-CANDIDATE.txt -Encoding utf8 + + - name: Upload candidate + uses: actions/upload-artifact@v4 + with: + name: ARSAS-scl-export-only-semantic-win-x64 + path: | + ARSAS\dist\ARSAS-*-win-x64-portable.exe + ARSAS\dist\SCL-EXPORT-ONLY-CANDIDATE.txt + ARSAS\evidence\p0.7-release-candidate-lock.json + ARSAS\evidence\scl-export-only-semantic-candidate.json + ARSAS\TestResults\*.trx + if-no-files-found: error + retention-days: 14 diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 360e72a96..054314b48 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": "32fae3a56b1e21874895c7e781923f324cd1e54e", + "sourcePullRequest": 140, + "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. SCL export-only semantic candidate PR #140 is pinned only on an isolated ARSAS candidate branch. It starts from the exact physical engine baseline 648124097621046f5f127ceb1cf853fea54db730 and changes no Discovery/MMS/reporting/runtime source; only LiveIedSclExporter plus export-only regression tests differ. Exact live stVal TypeSpecification may refine generic SPS/INS/ENS only while writing SCL; the live discovery model bound to runtime/reporting remains unchanged. PR #139 was physically rejected and closed after AA1E1F06R4 showed slow discovery and BIT STRING/Boolean report rejection with only 23 report-backed rows. The only additional Discovery-tree delta is two source-clean wording comments in Iec61850StandardModelRegistry.cs; executable registry logic is unchanged.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, diff --git a/evidence/scl-export-only-semantic-candidate.json b/evidence/scl-export-only-semantic-candidate.json new file mode 100644 index 000000000..34b2f547c --- /dev/null +++ b/evidence/scl-export-only-semantic-candidate.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 1, + "contractId": "SCL-EXPORT-ONLY-SEMANTIC-P0", + "status": "ci-candidate-physical-retest-pending", + "physicalBaseline": { + "arsasHead": "0d0b9204d6637e3d62e2eee94000386ae43cd0e9", + "arsasLockHead": "6d6c5f6f022b66d93141ace361ed88d4a47ecbb9", + "engineCommit": "648124097621046f5f127ceb1cf853fea54db730", + "relay": "AA1E1F06R4", + "acceptedRuntime": { + "staticMembers": 58, + "liveRows": 58, + "analogRows": 22, + "digitalRows": 36, + "reportBackedRows": 58, + "cyclicMmsProcessPolling": 0 + } + }, + "rejectedCandidate": { + "enginePullRequest": 139, + "engineCommit": "090d81944791be5b690d24342f9262495eda1a09", + "arsasPullRequest": 342, + "arsasCommit": "32dc8542914d069e8fa49fe6ea6574fdaf950564", + "physicalObserved": { + "discoveryRegression": true, + "reportBackedRowsObserved": 23, + "repeatedBooleanBitStringRejections": true + }, + "disposition": "physically-rejected-closed" + }, + "replacementCandidate": { + "enginePullRequest": 140, + "engineCommit": "32fae3a56b1e21874895c7e781923f324cd1e54e", + "engineBase": "648124097621046f5f127ceb1cf853fea54db730", + "allowedEngineDiff": [ + "src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs", + "tests/AR.Iec61850.Tests/Scl/LiveIedSclExportOnlySemanticAuthorityTests.cs", + "src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs" + ], + "discoveryRuntimeSourceMustMatchPhysicalBaseline": true, + "liveDiscoveryModelMutationAllowed": false, + "acquisitionBehaviorChangeAllowed": false, + "commentOnlyDiscoveryDelta": "Iec61850StandardModelRegistry.cs has only two comment wording replacements required by source-clean; executable registry logic is unchanged." + }, + "semanticTarget": { + "object": "CBClsCounter", + "liveRuntimeCdcRemains": "SPS", + "exportedSclCdc": "INS", + "exportedStValBType": "INT32", + "reopenedSclCdc": "INS", + "reopenedStValBType": "INT32" + }, + "physicalAcceptance": { + "discoveryMustMatchAcceptedBaseline": true, + "staticMembers": 58, + "liveRows": 58, + "analogRows": 22, + "digitalRows": 36, + "reportBackedRows": 58, + "actualInformationReportRequired": true, + "cyclicMmsProcessPolling": 0, + "saveSclWhileMonitoringMustRemainNonDisruptive": true, + "reopenedSclLiveRows": 58, + "noBooleanBitStringRejectionRegression": true + } +} diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index 67364653b..6d4c8d742 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -396,39 +396,54 @@ public void SourceClean_GuardsApprovedFirstPartyConvergenceAuthorities() [Fact] - public void EnginePin_MatchesPhysicalSclRepairHead() + public void EnginePin_UsesExportOnlyCandidateWhileRetainingPhysicalBaselineProvenance() { var lockFile = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); + var candidate = File.ReadAllText( + FindRepoFile("evidence/scl-export-only-semantic-candidate.json")); Assert.Contains( - "\"commit\": \"648124097621046f5f127ceb1cf853fea54db730\"", + "\"commit\": \"32fae3a56b1e21874895c7e781923f324cd1e54e\"", lockFile, StringComparison.Ordinal); + Assert.Contains("\"sourcePullRequest\": 140", lockFile, StringComparison.Ordinal); + + Assert.Contains( + "\"engineCommit\": \"648124097621046f5f127ceb1cf853fea54db730\"", + candidate, + StringComparison.Ordinal); + Assert.Contains( + "\"engineBase\": \"648124097621046f5f127ceb1cf853fea54db730\"", + candidate, + StringComparison.Ordinal); + Assert.Contains( + "\"discoveryRuntimeSourceMustMatchPhysicalBaseline\": true", + candidate, + StringComparison.Ordinal); + Assert.Contains( + "\"liveDiscoveryModelMutationAllowed\": false", + candidate, + StringComparison.Ordinal); + Assert.Contains( + "\"acquisitionBehaviorChangeAllowed\": false", + candidate, + StringComparison.Ordinal); + Assert.Contains( "\"physicalTestedCommit\": \"9935d6902d786cc69b299260fe36b835944d5e81\"", lockFile, StringComparison.Ordinal); - Assert.Contains("\"sourcePullRequest\": 135", lockFile, StringComparison.Ordinal); + Assert.Contains( + "\"mergedMainCommit\": \"648124097621046f5f127ceb1cf853fea54db730\"", + 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); - Assert.Contains("runtime-mutable", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("DataSet/ConfRev/domain/LN/buffered identity", lockFile, StringComparison.Ordinal); Assert.Contains("full-model SCL", lockFile, StringComparison.Ordinal); - Assert.Contains("CDC-aware WYE/DEL/SEQ SDO", lockFile, StringComparison.Ordinal); - Assert.Contains("FC ownership", lockFile, StringComparison.Ordinal); - Assert.Contains("TCTR/TVTR/LTIM/EEName/MltLev", lockFile, StringComparison.Ordinal); - Assert.Contains("LTRK service-tracking", lockFile, StringComparison.Ordinal); - Assert.Contains("Edition-1 schema downgrade protection", lockFile, StringComparison.Ordinal); Assert.Contains("TypeSpecification declaration order", lockFile, StringComparison.Ordinal); - Assert.Contains("SG/SE as setting data", lockFile, StringComparison.Ordinal); - Assert.Contains("MHAI THD phase groups as WYE/CMV", lockFile, StringComparison.Ordinal); Assert.Contains("bounded FC-read policy", lockFile, StringComparison.Ordinal); - Assert.Contains("complete PR #134 smart-discovery performance head", lockFile, StringComparison.Ordinal); Assert.Contains("Production promotion remains fail-closed", lockFile, StringComparison.Ordinal); - Assert.Contains( - "\"commit\": \"4467124775d8d9d76f3db194f9fbfd97144767a8\"", - lockFile, - StringComparison.Ordinal); + Assert.Contains("SCL export-only semantic candidate PR #140", lockFile, StringComparison.Ordinal); } diff --git a/tests/ARSAS.Tests/P07ReleaseCandidateLockRegressionTests.cs b/tests/ARSAS.Tests/P07ReleaseCandidateLockRegressionTests.cs index fc57090c3..fe1de2c41 100644 --- a/tests/ARSAS.Tests/P07ReleaseCandidateLockRegressionTests.cs +++ b/tests/ARSAS.Tests/P07ReleaseCandidateLockRegressionTests.cs @@ -72,19 +72,32 @@ public void LockFile_FreezesThePhysicallyAcceptedRuntimeBaselineAndOpenSclSemant } [Fact] - public void CurrentEngineLock_RemainsOnThePhysicalBaselineUntilSemanticCandidateIsExplicitlyIntegrated() + public void ExportOnlyCandidate_PreservesPhysicalEngineAuthorityAndPinsOnlyTheIsolatedExporterRevision() { using var baseline = JsonDocument.Parse( File.ReadAllText(FindRepoFile("evidence/p0.7-release-candidate-lock.json"))); + using var candidate = JsonDocument.Parse( + File.ReadAllText(FindRepoFile("evidence/scl-export-only-semantic-candidate.json"))); using var engine = JsonDocument.Parse( File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json"))); - var expected = baseline.RootElement + var physical = baseline.RootElement .GetProperty("physicalReference") .GetProperty("engineBaseline") .GetString(); + var replacement = candidate.RootElement.GetProperty("replacementCandidate"); - Assert.Equal(expected, engine.RootElement.GetProperty("commit").GetString()); + Assert.Equal("648124097621046f5f127ceb1cf853fea54db730", physical); + Assert.Equal(140, replacement.GetProperty("enginePullRequest").GetInt32()); + Assert.Equal( + "32fae3a56b1e21874895c7e781923f324cd1e54e", + replacement.GetProperty("engineCommit").GetString()); + Assert.Equal( + replacement.GetProperty("engineCommit").GetString(), + engine.RootElement.GetProperty("commit").GetString()); + Assert.True(replacement.GetProperty("discoveryRuntimeSourceMustMatchPhysicalBaseline").GetBoolean()); + Assert.False(replacement.GetProperty("liveDiscoveryModelMutationAllowed").GetBoolean()); + Assert.False(replacement.GetProperty("acquisitionBehaviorChangeAllowed").GetBoolean()); } [Theory] diff --git a/tests/ARSAS.Tests/SclExportOnlySemanticIsolationRegressionTests.cs b/tests/ARSAS.Tests/SclExportOnlySemanticIsolationRegressionTests.cs new file mode 100644 index 000000000..f74b8c71c --- /dev/null +++ b/tests/ARSAS.Tests/SclExportOnlySemanticIsolationRegressionTests.cs @@ -0,0 +1,159 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Engineering; +using AR.Iec61850.Scl.Export; + +namespace ARSAS.Tests; + +public sealed class SclExportOnlySemanticIsolationRegressionTests +{ + [Fact] + public void SaveScl_CorrectsCounterMetadataWithoutMutatingTheLiveRuntimeModel() + { + var model = BuildModel(); + + var liveCounter = Assert.Single( + model.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .SelectMany(node => node.DataObjects)); + Assert.Equal("SPS", liveCounter.InferredCdc); + + var document = LiveIedSclExporter.BuildDocument( + model, + new LiveIedSclExportOptions { Profile = "full-model" }); + + var ns = document.Root!.Name.Namespace; + var lNodeType = Assert.Single( + document.Descendants(ns + "LNodeType"), + element => (string?)element.Attribute("lnClass") == "GGIO"); + var dataObject = Assert.Single( + lNodeType.Elements(ns + "DO"), + element => (string?)element.Attribute("name") == "CBClsCounter"); + var doTypeId = (string?)dataObject.Attribute("type") ?? string.Empty; + var doType = Assert.Single( + document.Descendants(ns + "DOType"), + element => (string?)element.Attribute("id") == doTypeId); + + Assert.Equal("INS", (string?)doType.Attribute("cdc")); + Assert.Equal( + "INT32", + (string?)Assert.Single( + doType.Elements(ns + "DA"), + element => (string?)element.Attribute("name") == "stVal") + .Attribute("bType")); + + Assert.Equal("SPS", liveCounter.InferredCdc); + + var reopened = SclLiveModelProjectionBuilder.Build(document, "generated.iid"); + var reopenedCounter = Assert.Single( + reopened.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .SelectMany(node => node.DataObjects), + item => item.Name == "CBClsCounter"); + + Assert.Equal("INS", reopenedCounter.InferredCdc); + Assert.Equal( + "INT32", + Assert.Single( + reopenedCounter.Attributes, + attribute => attribute.AttributePath == "stVal").SclBType); + } + + [Fact] + public void CandidateEvidence_RequiresZeroDiscoveryRuntimeSourceDrift() + { + using var document = System.Text.Json.JsonDocument.Parse( + File.ReadAllText(FindRepoFile("evidence/scl-export-only-semantic-candidate.json"))); + var replacement = document.RootElement.GetProperty("replacementCandidate"); + + Assert.True(replacement.GetProperty("discoveryRuntimeSourceMustMatchPhysicalBaseline").GetBoolean()); + Assert.False(replacement.GetProperty("liveDiscoveryModelMutationAllowed").GetBoolean()); + Assert.False(replacement.GetProperty("acquisitionBehaviorChangeAllowed").GetBoolean()); + + var allowed = replacement.GetProperty("allowedEngineDiff") + .EnumerateArray() + .Select(item => item.GetString()) + .ToArray(); + Assert.Equal(3, allowed.Length); + Assert.Contains("src/AR.Iec61850/Scl/Export/LiveIedSclExporter.cs", allowed); + Assert.Contains("tests/AR.Iec61850.Tests/Scl/LiveIedSclExportOnlySemanticAuthorityTests.cs", allowed); + Assert.Contains("src/AR.Iec61850/Discovery/Iec61850StandardModelRegistry.cs", allowed); + } + + private static LiveIedModelDiscoveryDocument BuildModel() + => new() + { + IedName = "IED", + AccessPointName = "AP1", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDADD", + Inst = "ADD", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + ProposedLnTypeId = "LN_GGIO_GGIO1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDADD/GGIO1.CBClsCounter", + Name = "CBClsCounter", + ProposedDoTypeId = "DO_SPS_GGIO_CBClsCounter", + InferredCdc = "SPS", + CdcConfidence = 0.78, + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Medium, + Attributes = + [ + Attribute("stVal", "INT32", "integer"), + Attribute("q", "Quality", "bit-string"), + Attribute("t", "Timestamp", "utc-time") + ] + } + ] + } + ] + } + ] + }; + + private static LiveIedDataAttributeModel Attribute( + string path, + string sclBType, + string mmsType) + => new() + { + ObjectReference = "IEDADD/GGIO1.CBClsCounter." + path, + AttributePath = path, + FunctionalConstraint = "ST", + MmsReference = "IEDADD/GGIO1$ST$CBClsCounter$" + path, + MmsItemName = "GGIO1$ST$CBClsCounter$" + path, + Source = "GetVariableAccessAttributes", + SclBType = sclBType, + MmsType = mmsType, + TypeDiscoveryStatus = "Exact", + TypeSource = "GetVariableAccessAttributes", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact, + FunctionalConstraintConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +}