Compare commits
12 Commits
phase-7-st
...
phase-7-fu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b31e240e | ||
| 78f388b761 | |||
|
|
d78741cfdf | ||
| c08ae0d032 | |||
|
|
82e4e8c8de | ||
| 4e41f196b2 | |||
|
|
f0851af6b5 | ||
| 6df069b083 | |||
|
|
0687bb2e2d | ||
| 4d4f08af0d | |||
|
|
f1f53e1789 | ||
| e97db2d108 |
@@ -36,6 +36,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||
|
||||
79
docs/v2/implementation/exit-gate-phase-7.md
Normal file
79
docs/v2/implementation/exit-gate-phase-7.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Phase 7 Exit Gate — Scripting, Virtual Tags, Scripted Alarms, Historian Sink
|
||||
|
||||
> **Status**: Open. Closed when every compliance check passes + every deferred item either ships or is filed as a post-v2-release follow-up.
|
||||
>
|
||||
> **Compliance script**: `scripts/compliance/phase-7-compliance.ps1`
|
||||
> **Plan doc**: `docs/v2/implementation/phase-7-scripting-and-alarming.md`
|
||||
|
||||
## What shipped
|
||||
|
||||
| Stream | PR | Summary |
|
||||
|--------|-----|---------|
|
||||
| A | #177–#179 | `Core.Scripting` — Roslyn sandbox + `DependencyExtractor` + `ForbiddenTypeAnalyzer` + per-script Serilog sink + 63 tests |
|
||||
| B | #180 | `Core.VirtualTags` — dep graph (iterative Tarjan) + engine + timer scheduler + `VirtualTagSource` + 36 tests |
|
||||
| C | #181 | `Core.ScriptedAlarms` — Part 9 state machine + predicate engine + message template + `ScriptedAlarmSource` + 47 tests |
|
||||
| D | #182 | `Core.AlarmHistorian` — SQLite store-and-forward + backoff ladder + dead-letter retention + Galaxy.Host IPC contracts + 14 tests |
|
||||
| E | #183 | Config DB schema — `Script` / `VirtualTag` / `ScriptedAlarm` / `ScriptedAlarmState` entities + migration + 12 tests |
|
||||
| F | #185 | Admin UI — `ScriptService` / `VirtualTagService` / `ScriptedAlarmService` / `ScriptTestHarnessService` / `HistorianDiagnosticsService` + Monaco editor + `/alarms/historian` page + 13 tests |
|
||||
| G | #184 | Walker emits Virtual + ScriptedAlarm variables with `NodeSourceKind` discriminator + 5 tests |
|
||||
| G follow-up | #186 | `DriverNodeManager` dispatch routes by `NodeSourceKind` + writes rejected for non-Driver sources + 7 tests |
|
||||
|
||||
**Phase 7 totals**: ~197 new tests across 7 projects. Plan decisions #1–#22 all realised in code.
|
||||
|
||||
## Compliance Checks (run at exit gate)
|
||||
|
||||
Covered by `scripts/compliance/phase-7-compliance.ps1`:
|
||||
|
||||
- [x] Roslyn sandbox anchored on `ScriptContext` assembly with `ForbiddenTypeAnalyzer` defense-in-depth (plan #6)
|
||||
- [x] `DependencyExtractor` rejects non-literal tag paths with source spans (plan #7)
|
||||
- [x] Per-script rolling Serilog sink + companion-forwarding Error+ to main log (plan #12)
|
||||
- [x] VirtualTag dep graph uses iterative SCC — no stack overflow on 10 000-deep chains
|
||||
- [x] `VirtualTagSource` implements `IReadable` + `ISubscribable` per ADR-002
|
||||
- [x] Part 9 state machine covers every transition (Apply/Ack/Confirm/Shelve/Unshelve/Enable/Disable/Comment/ShelvingCheck)
|
||||
- [x] `AlarmPredicateContext` rejects `SetVirtualTag` at runtime (predicates must be pure)
|
||||
- [x] `MessageTemplate` substitutes `{TagPath}` tokens at event emission (plan #13); missing/bad → `{?}`
|
||||
- [x] SQLite sink backoff ladder 1s → 2s → 5s → 15s → 60s cap (plan #16)
|
||||
- [x] Default 1M-row capacity + 30-day dead-letter retention (plan #21)
|
||||
- [x] Per-event outcomes Ack/RetryPlease/PermanentFail on the wire
|
||||
- [x] Galaxy.Host IPC contracts (`HistorianAlarmEventRequest` / `Response` / `ConnectivityStatusNotification`)
|
||||
- [x] Config DB check constraints: trigger-required, timer-min, severity-range, alarm-type-enum, JSON comments
|
||||
- [x] `ScriptedAlarmState` keyed on `ScriptedAlarmId` (not generation-scoped) per plan #14
|
||||
- [x] Admin services: SourceHash preserves compile-cache hit on rename; Update recomputes on source change
|
||||
- [x] `ScriptTestHarnessService` enforces declared-inputs-only contract (plan #22)
|
||||
- [x] Monaco editor via CDN + textarea fallback (plan #18)
|
||||
- [x] `/alarms/historian` page with Retry-dead-lettered operator action
|
||||
- [x] Walker emits `NodeSourceKind.Virtual` + `NodeSourceKind.ScriptedAlarm` variables
|
||||
- [x] `DriverNodeManager` dispatch routes Reads by source; Writes to non-Driver rejected with `BadUserAccessDenied` (plan #6)
|
||||
|
||||
## Deferred to Post-Gate Follow-ups
|
||||
|
||||
Kept out of the capstone so the gate can close cleanly while the less-critical wiring lands in targeted PRs:
|
||||
|
||||
- [ ] **SealedBootstrap composition root** (task #239) — instantiate `VirtualTagEngine` + `ScriptedAlarmEngine` + `SqliteStoreAndForwardSink` in `Program.cs`; pass `VirtualTagSource` + `ScriptedAlarmSource` as the new `IReadable` parameters on `DriverNodeManager`. Without this, the engines are dormant in production even though every piece is tested.
|
||||
- [ ] **Live OPC UA end-to-end smoke** (task #240) — Client.CLI browse + read a virtual tag computed by Roslyn; Client.CLI acknowledge a scripted alarm via the Part 9 method node; historian-disabled deployment returns `BadNotFound` for virtual nodes rather than silent failure.
|
||||
- [ ] **sp_ComputeGenerationDiff extension** (task #241) — emit Script / VirtualTag / ScriptedAlarm sections alongside the existing Namespace/DriverInstance/Equipment/Tag/NodeAcl rows so the Admin DiffViewer shows Phase 7 changes between generations.
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
- [x] Stream A shipped + merged
|
||||
- [x] Stream B shipped + merged
|
||||
- [x] Stream C shipped + merged
|
||||
- [x] Stream D shipped + merged
|
||||
- [x] Stream E shipped + merged
|
||||
- [x] Stream F shipped + merged
|
||||
- [x] Stream G shipped + merged
|
||||
- [x] Stream G follow-up (dispatch) shipped + merged
|
||||
- [x] `phase-7-compliance.ps1` present and passes
|
||||
- [x] Full solution `dotnet test` passes (no new failures beyond pre-existing tolerated CLI flake)
|
||||
- [x] Exit-gate doc checked in
|
||||
- [ ] `SealedBootstrap` composition follow-up filed + tracked
|
||||
- [ ] Live end-to-end smoke follow-up filed + tracked
|
||||
- [ ] `sp_ComputeGenerationDiff` extension follow-up filed + tracked
|
||||
|
||||
## How to run
|
||||
|
||||
```powershell
|
||||
pwsh ./scripts/compliance/phase-7-compliance.ps1
|
||||
```
|
||||
|
||||
Exit code 0 = all pass; non-zero = failures listed in the preceding `[FAIL]` lines.
|
||||
151
scripts/compliance/phase-7-compliance.ps1
Normal file
151
scripts/compliance/phase-7-compliance.ps1
Normal file
@@ -0,0 +1,151 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Phase 7 exit-gate compliance check. Each check either passes or records a failure;
|
||||
non-zero exit = fail.
|
||||
|
||||
.DESCRIPTION
|
||||
Validates Phase 7 (scripting runtime + virtual tags + scripted alarms + historian
|
||||
alarm sink + Admin UI + address-space integration) per
|
||||
`docs/v2/implementation/phase-7-scripting-and-alarming.md`.
|
||||
|
||||
.NOTES
|
||||
Usage: pwsh ./scripts/compliance/phase-7-compliance.ps1
|
||||
Exit: 0 = all checks passed; non-zero = one or more FAILs
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$script:failures = 0
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Assert-Pass { param([string]$C) Write-Host " [PASS] $C" -ForegroundColor Green }
|
||||
function Assert-Fail { param([string]$C, [string]$R) Write-Host " [FAIL] $C - $R" -ForegroundColor Red; $script:failures++ }
|
||||
function Assert-Deferred { param([string]$C, [string]$P) Write-Host " [DEFERRED] $C (follow-up: $P)" -ForegroundColor Yellow }
|
||||
|
||||
function Assert-FileExists {
|
||||
param([string]$C, [string]$P)
|
||||
if (Test-Path (Join-Path $repoRoot $P)) { Assert-Pass "$C ($P)" }
|
||||
else { Assert-Fail $C "missing file: $P" }
|
||||
}
|
||||
|
||||
function Assert-TextFound {
|
||||
param([string]$C, [string]$Pat, [string[]]$Paths)
|
||||
foreach ($p in $Paths) {
|
||||
$full = Join-Path $repoRoot $p
|
||||
if (-not (Test-Path $full)) { continue }
|
||||
if (Select-String -Path $full -Pattern $Pat -Quiet) {
|
||||
Assert-Pass "$C (matched in $p)"
|
||||
return
|
||||
}
|
||||
}
|
||||
Assert-Fail $C "pattern '$Pat' not found in any of: $($Paths -join ', ')"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Phase 7 compliance - scripting + virtual tags + scripted alarms + historian ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "Stream A - Core.Scripting (Roslyn + sandbox + AST inference + logger)"
|
||||
Assert-FileExists "Core.Scripting project" "src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"
|
||||
Assert-TextFound "ScriptSandbox allow-list anchored on ScriptContext assembly" "contextType\.Assembly" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs")
|
||||
Assert-TextFound "ForbiddenTypeAnalyzer defense-in-depth (plan decision #6)" "class ForbiddenTypeAnalyzer" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs")
|
||||
Assert-TextFound "DependencyExtractor rejects non-literal paths (plan decision #7)" "class DependencyExtractor" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs")
|
||||
Assert-TextFound "Per-script log companion sink forwards Error+ to main log (plan decision #12)" "class ScriptLogCompanionSink" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogCompanionSink.cs")
|
||||
Assert-TextFound "ScriptContext static Deadband helper" "static bool Deadband" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream B - Core.VirtualTags (dependency graph + change/timer + source)"
|
||||
Assert-FileExists "Core.VirtualTags project" "src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"
|
||||
Assert-TextFound "DependencyGraph iterative Tarjan SCC (no stack overflow on 10k chains)" "class DependencyGraph" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs")
|
||||
Assert-TextFound "VirtualTagEngine SemaphoreSlim async-safe cascade" "SemaphoreSlim" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs")
|
||||
Assert-TextFound "VirtualTagSource IReadable + ISubscribable per ADR-002" "class VirtualTagSource" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs")
|
||||
Assert-TextFound "TimerTriggerScheduler groups by interval" "class TimerTriggerScheduler" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream C - Core.ScriptedAlarms (Part 9 state machine + predicate engine + IAlarmSource)"
|
||||
Assert-FileExists "Core.ScriptedAlarms project" "src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"
|
||||
Assert-TextFound "Part9StateMachine pure functions" "class Part9StateMachine" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs")
|
||||
Assert-TextFound "Alarm condition state with GxP audit Comments list" "Comments" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs")
|
||||
Assert-TextFound "MessageTemplate {TagPath} substitution (plan decision #13)" "class MessageTemplate" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs")
|
||||
Assert-TextFound "AlarmPredicateContext rejects SetVirtualTag (predicates must be pure)" "class AlarmPredicateContext" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs")
|
||||
Assert-TextFound "ScriptedAlarmSource implements IAlarmSource" "class ScriptedAlarmSource" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs")
|
||||
Assert-TextFound "IAlarmStateStore abstraction + in-memory default" "class InMemoryAlarmStateStore" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream D - Core.AlarmHistorian (SQLite store-and-forward + Galaxy.Host IPC contracts)"
|
||||
Assert-FileExists "Core.AlarmHistorian project" "src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"
|
||||
Assert-TextFound "SqliteStoreAndForwardSink backoff ladder (1s..60s cap)" "BackoffLadder" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
|
||||
Assert-TextFound "Default 1M row capacity + 30-day dead-letter retention (plan decision #21)" "DefaultDeadLetterRetention" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
|
||||
Assert-TextFound "Per-event outcomes (Ack/RetryPlease/PermanentFail)" "HistorianWriteOutcome" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs")
|
||||
Assert-TextFound "Galaxy.Host IPC contract HistorianAlarmEventRequest" "class HistorianAlarmEventRequest" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
|
||||
Assert-TextFound "Historian connectivity status notification" "HistorianConnectivityStatusNotification" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream E - Config DB schema"
|
||||
Assert-FileExists "Script entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs"
|
||||
Assert-FileExists "VirtualTag entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs"
|
||||
Assert-FileExists "ScriptedAlarm entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarm.cs"
|
||||
Assert-FileExists "ScriptedAlarmState entity (logical-id keyed per plan decision #14)" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarmState.cs"
|
||||
Assert-TextFound "VirtualTag trigger check constraint (change OR timer)" "CK_VirtualTag_Trigger_AtLeastOne" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
|
||||
Assert-TextFound "ScriptedAlarm severity range check" "CK_ScriptedAlarm_Severity_Range" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
|
||||
Assert-TextFound "ScriptedAlarm type enum check" "CK_ScriptedAlarm_AlarmType" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
|
||||
Assert-TextFound "ScriptedAlarmState.CommentsJson is ISJSON (GxP audit)" "CK_ScriptedAlarmState_CommentsJson_IsJson" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
|
||||
Assert-FileExists "Phase 7 migration present" "src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.cs"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream F - Admin UI (services + Monaco editor + test harness + historian diagnostics)"
|
||||
Assert-FileExists "ScriptService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs"
|
||||
Assert-FileExists "VirtualTagService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs"
|
||||
Assert-FileExists "ScriptedAlarmService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs"
|
||||
Assert-FileExists "ScriptTestHarnessService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs"
|
||||
Assert-FileExists "HistorianDiagnosticsService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/HistorianDiagnosticsService.cs"
|
||||
Assert-FileExists "ScriptEditor Razor component" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptEditor.razor"
|
||||
Assert-FileExists "ScriptsTab Razor component" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor"
|
||||
Assert-FileExists "AlarmsHistorian diagnostics page" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor"
|
||||
Assert-FileExists "Monaco loader (CDN progressive enhancement)" "src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js"
|
||||
Assert-TextFound "Scripts tab wired into DraftEditor" "ScriptsTab " @("src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor")
|
||||
Assert-TextFound "Harness enforces declared-inputs-only contract (plan decision #22)" "UnknownInputs" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream G - Address-space integration"
|
||||
Assert-TextFound "NodeSourceKind discriminator in DriverAttributeInfo" "enum NodeSourceKind" @("src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs")
|
||||
Assert-TextFound "Walker emits VirtualTag variables with Source=Virtual" "AddVirtualTagVariable" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
|
||||
Assert-TextFound "Walker emits ScriptedAlarm variables with Source=ScriptedAlarm + IsAlarm" "AddScriptedAlarmVariable" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
|
||||
Assert-TextFound "EquipmentNamespaceContent carries VirtualTags + ScriptedAlarms" "VirtualTags" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
|
||||
Assert-TextFound "DriverNodeManager selects IReadable by source kind" "SelectReadable" @("src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs")
|
||||
Assert-TextFound "Virtual/ScriptedAlarm writes rejected (plan decision #6)" "IsWriteAllowedBySource" @("src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Deferred surfaces"
|
||||
Assert-Deferred "SealedBootstrap composition root wiring (VirtualTagEngine + ScriptedAlarmEngine + SqliteStoreAndForwardSink)" "task #239"
|
||||
Assert-Deferred "Live OPC UA end-to-end test (virtual-tag Read + scripted-alarm Ack via method node)" "task #240"
|
||||
Assert-Deferred "sp_ComputeGenerationDiff extension for Script/VirtualTag/ScriptedAlarm sections" "task #241"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Cross-cutting"
|
||||
Write-Host " Running full solution test suite..." -ForegroundColor DarkGray
|
||||
$prevPref = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1
|
||||
$ErrorActionPreference = $prevPref
|
||||
$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches
|
||||
$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches
|
||||
$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value }
|
||||
$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value }
|
||||
|
||||
# Phase 6.4 exit-gate baseline was 1137; Phase 7 adds ~197 across 7 streams.
|
||||
$baseline = 1300
|
||||
if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline pre-Phase-7-exit baseline)" }
|
||||
else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" }
|
||||
|
||||
if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" }
|
||||
else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" }
|
||||
|
||||
Write-Host ""
|
||||
if ($script:failures -eq 0) {
|
||||
Write-Host "Phase 7 compliance: PASS" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Phase 7 compliance: $script:failures FAIL(s)" -ForegroundColor Red
|
||||
exit 1
|
||||
@@ -0,0 +1,79 @@
|
||||
@page "/alarms/historian"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
|
||||
@inject HistorianDiagnosticsService Diag
|
||||
|
||||
<h1>Alarm historian</h1>
|
||||
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Drain state</small>
|
||||
<h4><span class="badge @BadgeFor(_status.DrainState)">@_status.DrainState</span></h4>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Queue depth</small>
|
||||
<h4>@_status.QueueDepth.ToString("N0")</h4>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Dead-letter depth</small>
|
||||
<h4 class="@(_status.DeadLetterDepth > 0 ? "text-warning" : "")">@_status.DeadLetterDepth.ToString("N0")</h4>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Last success</small>
|
||||
<h4>@(_status.LastSuccessUtc?.ToString("u") ?? "—")</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_status.LastError))
|
||||
{
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
<strong>Last error:</strong> @_status.LastError
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
|
||||
<button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
|
||||
Retry dead-lettered (@_status.DeadLetterDepth)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_retryResult is not null)
|
||||
{
|
||||
<div class="alert alert-success mt-3">Requeued @_retryResult row(s) for retry.</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled);
|
||||
private int? _retryResult;
|
||||
|
||||
protected override void OnInitialized() => _status = Diag.GetStatus();
|
||||
|
||||
private Task RefreshAsync()
|
||||
{
|
||||
_status = Diag.GetStatus();
|
||||
_retryResult = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task RetryDeadLetteredAsync()
|
||||
{
|
||||
_retryResult = Diag.TryRetryDeadLettered();
|
||||
_status = Diag.GetStatus();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string BadgeFor(HistorianDrainState s) => s switch
|
||||
{
|
||||
HistorianDrainState.Idle => "bg-success",
|
||||
HistorianDrainState.Draining => "bg-info",
|
||||
HistorianDrainState.BackingOff => "bg-warning text-dark",
|
||||
HistorianDrainState.Disabled => "bg-secondary",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
@@ -32,6 +33,7 @@
|
||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card sticky-top">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
Monaco-backed C# code editor (Phase 7 Stream F). Progressive enhancement:
|
||||
textarea renders immediately, Monaco mounts via JS interop after first render.
|
||||
Monaco script tags are loaded once from the parent layout (wwwroot/js/monaco-loader.js
|
||||
pulls the CDN bundle).
|
||||
|
||||
Stream F keeps the interop surface small — bind `Source` two-way, and the parent
|
||||
tab re-renders on change for the dependency preview. The test-harness button
|
||||
lives in the parent so one editor can drive multiple script types.
|
||||
*@
|
||||
|
||||
<div class="script-editor">
|
||||
<textarea class="form-control font-monospace" rows="14" spellcheck="false"
|
||||
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Source { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> SourceChanged { get; set; }
|
||||
|
||||
private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}";
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId);
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Monaco bundle not yet loaded on this page — textarea fallback is
|
||||
// still functional.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Scripting
|
||||
@inject ScriptService ScriptSvc
|
||||
@inject ScriptTestHarnessService Harness
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">Scripts</h4>
|
||||
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
|
||||
</div>
|
||||
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
|
||||
</div>
|
||||
|
||||
<script src="/js/monaco-loader.js"></script>
|
||||
|
||||
@if (_loading) { <p class="text-muted">Loading…</p> }
|
||||
else if (_scripts.Count == 0 && _editing is null)
|
||||
{
|
||||
<div class="alert alert-info">No scripts yet in this draft.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="list-group">
|
||||
@foreach (var s in _scripts)
|
||||
{
|
||||
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
|
||||
@onclick="() => Open(s)">
|
||||
<strong>@s.Name</strong>
|
||||
<div class="small text-muted font-monospace">@s.ScriptId</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
@if (_editing is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
|
||||
<div>
|
||||
@if (!_isNew)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger me-2" @onclick="DeleteAsync">Delete</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" @bind="_editing.Name"/>
|
||||
</div>
|
||||
<label class="form-label">Source</label>
|
||||
<ScriptEditor @bind-Source="_editing.SourceCode"/>
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="PreviewDependencies">Analyze dependencies</button>
|
||||
<button class="btn btn-sm btn-outline-info ms-2" @onclick="RunHarnessAsync" disabled="@_harnessBusy">Run test harness</button>
|
||||
</div>
|
||||
|
||||
@if (_dependencies is not null)
|
||||
{
|
||||
<div class="mt-3">
|
||||
<strong>Inferred reads</strong>
|
||||
@if (_dependencies.Reads.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||
else
|
||||
{
|
||||
<ul class="mb-1">
|
||||
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> }
|
||||
</ul>
|
||||
}
|
||||
<strong>Inferred writes</strong>
|
||||
@if (_dependencies.Writes.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||
else
|
||||
{
|
||||
<ul class="mb-1">
|
||||
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> }
|
||||
</ul>
|
||||
}
|
||||
@if (_dependencies.Rejections.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger mt-2">
|
||||
<strong>Non-literal paths rejected:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_testResult is not null)
|
||||
{
|
||||
<div class="mt-3 border-top pt-3">
|
||||
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span>
|
||||
@if (_testResult.Outcome == ScriptTestOutcome.Success)
|
||||
{
|
||||
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div>
|
||||
@if (_testResult.Writes.Count > 0)
|
||||
{
|
||||
<div class="mt-1"><strong>Writes:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (_testResult.Errors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning mt-2 mb-0">
|
||||
@foreach (var e in _testResult.Errors) { <div>@e</div> }
|
||||
</div>
|
||||
}
|
||||
@if (_testResult.LogEvents.Count > 0)
|
||||
{
|
||||
<div class="mt-2"><strong>Script log output:</strong>
|
||||
<ul class="small mb-0">
|
||||
@foreach (var e in _testResult.LogEvents) { <li>[@e.Level] @e.RenderMessage()</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
private bool _harnessBusy;
|
||||
private bool _isNew;
|
||||
private List<Script> _scripts = [];
|
||||
private Script? _editing;
|
||||
private DependencyExtractionResult? _dependencies;
|
||||
private ScriptTestResult? _testResult;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void Open(Script s)
|
||||
{
|
||||
_editing = new Script
|
||||
{
|
||||
ScriptRowId = s.ScriptRowId, GenerationId = s.GenerationId,
|
||||
ScriptId = s.ScriptId, Name = s.Name, SourceCode = s.SourceCode,
|
||||
SourceHash = s.SourceHash, Language = s.Language,
|
||||
};
|
||||
_isNew = false;
|
||||
_dependencies = null;
|
||||
_testResult = null;
|
||||
}
|
||||
|
||||
private void StartNew()
|
||||
{
|
||||
_editing = new Script
|
||||
{
|
||||
GenerationId = GenerationId, ScriptId = "",
|
||||
Name = "new-script", SourceCode = "return 0;", SourceHash = "",
|
||||
};
|
||||
_isNew = true;
|
||||
_dependencies = null;
|
||||
_testResult = null;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
if (_isNew)
|
||||
await ScriptSvc.AddAsync(GenerationId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||
else
|
||||
await ScriptSvc.UpdateAsync(GenerationId, _editing.ScriptId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_isNew = false;
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (_editing is null || _isNew) return;
|
||||
await ScriptSvc.DeleteAsync(GenerationId, _editing.ScriptId, CancellationToken.None);
|
||||
_editing = null;
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void PreviewDependencies()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_dependencies = DependencyExtractor.Extract(_editing.SourceCode);
|
||||
}
|
||||
|
||||
private async Task RunHarnessAsync()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_harnessBusy = true;
|
||||
try
|
||||
{
|
||||
_dependencies ??= DependencyExtractor.Extract(_editing.SourceCode);
|
||||
var inputs = new Dictionary<string, DataValueSnapshot>();
|
||||
foreach (var read in _dependencies.Reads)
|
||||
inputs[read] = new DataValueSnapshot(0.0, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||
_testResult = await Harness.RunVirtualTagAsync(_editing.SourceCode, inputs, CancellationToken.None);
|
||||
}
|
||||
finally { _harnessBusy = false; }
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,18 @@ builder.Services.AddScoped<EquipmentImportBatchService>();
|
||||
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
||||
|
||||
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
|
||||
// harness, and historian diagnostics. The historian sink is the Null variant here —
|
||||
// the real SqliteStoreAndForwardSink lives in the server process. Admin reads status
|
||||
// from whichever sink is provided at composition time.
|
||||
builder.Services.AddScoped<ScriptService>();
|
||||
builder.Services.AddScoped<VirtualTagService>();
|
||||
builder.Services.AddScoped<ScriptedAlarmService>();
|
||||
builder.Services.AddScoped<ScriptTestHarnessService>();
|
||||
builder.Services.AddScoped<HistorianDiagnosticsService>();
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.IAlarmHistorianSink>(
|
||||
ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.NullAlarmHistorianSink.Instance);
|
||||
|
||||
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||
// filesystem operations.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Surfaces the local-node historian queue health on the Admin UI's
|
||||
/// <c>/alarms/historian</c> diagnostics page (Phase 7 plan decisions #16/#21).
|
||||
/// Exposes queue depth / drain state / last-error, and lets the operator retry
|
||||
/// dead-lettered rows without restarting the node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sink injected here is the server-process <see cref="IAlarmHistorianSink"/>.
|
||||
/// When <see cref="NullAlarmHistorianSink"/> is bound (historian disabled for this
|
||||
/// deployment), <see cref="TryRetryDeadLettered"/> silently returns 0 and
|
||||
/// <see cref="GetStatus"/> reports <see cref="HistorianDrainState.Disabled"/>.
|
||||
/// </remarks>
|
||||
public sealed class HistorianDiagnosticsService(IAlarmHistorianSink sink)
|
||||
{
|
||||
public HistorianSinkStatus GetStatus() => sink.GetStatus();
|
||||
|
||||
/// <summary>
|
||||
/// Operator action from the UI's "Retry dead-lettered" button. Returns the number
|
||||
/// of rows revived so the UI can flash a confirmation. When the live sink doesn't
|
||||
/// implement retry (test doubles, Null sink), returns 0.
|
||||
/// </summary>
|
||||
public int TryRetryDeadLettered()
|
||||
{
|
||||
if (sink is SqliteStoreAndForwardSink concrete)
|
||||
return concrete.RetryDeadLettered();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
66
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
66
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Draft-generation CRUD for <see cref="Script"/> rows — the C# source code referenced
|
||||
/// by Phase 7 virtual tags and scripted alarms. <see cref="Script.SourceHash"/> is
|
||||
/// recomputed on every save so Core.Scripting's compile cache sees a fresh key when
|
||||
/// source changes and reuses the compile when it doesn't.
|
||||
/// </summary>
|
||||
public sealed class ScriptService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<Script>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.Scripts.AsNoTracking()
|
||||
.Where(s => s.GenerationId == generationId)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<Script?> GetAsync(long generationId, string scriptId, CancellationToken ct) =>
|
||||
db.Scripts.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.GenerationId == generationId && s.ScriptId == scriptId, ct);
|
||||
|
||||
public async Task<Script> AddAsync(long generationId, string name, string sourceCode, CancellationToken ct)
|
||||
{
|
||||
var s = new Script
|
||||
{
|
||||
GenerationId = generationId,
|
||||
ScriptId = $"scr-{Guid.NewGuid():N}"[..20],
|
||||
Name = name,
|
||||
SourceCode = sourceCode,
|
||||
SourceHash = ComputeHash(sourceCode),
|
||||
};
|
||||
db.Scripts.Add(s);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return s;
|
||||
}
|
||||
|
||||
public async Task<Script> UpdateAsync(long generationId, string scriptId, string name, string sourceCode, CancellationToken ct)
|
||||
{
|
||||
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct)
|
||||
?? throw new InvalidOperationException($"Script '{scriptId}' not found in generation {generationId}");
|
||||
s.Name = name;
|
||||
s.SourceCode = sourceCode;
|
||||
s.SourceHash = ComputeHash(sourceCode);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return s;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string scriptId, CancellationToken ct)
|
||||
{
|
||||
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct);
|
||||
if (s is null) return;
|
||||
db.Scripts.Remove(s);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
internal static string ComputeHash(string source)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(source ?? string.Empty));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
}
|
||||
121
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs
Normal file
121
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Serilog; // resolves Serilog.ILogger explicitly in signatures
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dry-run harness for the Phase 7 scripting UI. Takes a script + a synthetic input
|
||||
/// map + evaluates once, returns the output (or rejection / exception) plus any
|
||||
/// logger emissions the script produced. Per Phase 7 plan decision #22: only inputs
|
||||
/// the <see cref="DependencyExtractor"/> identified can be supplied, so a dependency
|
||||
/// the harness can't prove statically surfaces as a harness error, not a runtime
|
||||
/// surprise later.
|
||||
/// </summary>
|
||||
public sealed class ScriptTestHarnessService
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="source"/> as a virtual-tag script (return value is the
|
||||
/// tag's new value). <paramref name="inputs"/> supplies synthetic
|
||||
/// <see cref="DataValueSnapshot"/>s for every path the extractor found.
|
||||
/// </summary>
|
||||
public async Task<ScriptTestResult> RunVirtualTagAsync(
|
||||
string source, IDictionary<string, DataValueSnapshot> inputs, CancellationToken ct)
|
||||
{
|
||||
var deps = DependencyExtractor.Extract(source);
|
||||
if (!deps.IsValid)
|
||||
return ScriptTestResult.DependencyRejections(deps.Rejections);
|
||||
|
||||
var missing = deps.Reads.Where(r => !inputs.ContainsKey(r)).ToArray();
|
||||
if (missing.Length > 0)
|
||||
return ScriptTestResult.MissingInputs(missing);
|
||||
|
||||
var extra = inputs.Keys.Where(k => !deps.Reads.Contains(k)).ToArray();
|
||||
if (extra.Length > 0)
|
||||
return ScriptTestResult.UnknownInputs(extra);
|
||||
|
||||
ScriptEvaluator<HarnessVirtualTagContext, object?> evaluator;
|
||||
try
|
||||
{
|
||||
evaluator = ScriptEvaluator<HarnessVirtualTagContext, object?>.Compile(source);
|
||||
}
|
||||
catch (Exception compileEx)
|
||||
{
|
||||
return ScriptTestResult.Threw(compileEx.Message, []);
|
||||
}
|
||||
var capturing = new CapturingSink();
|
||||
var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(capturing).CreateLogger();
|
||||
var ctx = new HarnessVirtualTagContext(inputs, logger);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await evaluator.RunAsync(ctx, ct);
|
||||
return ScriptTestResult.Ok(result, ctx.Writes, capturing.Events);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ScriptTestResult.Threw(ex.Message, capturing.Events);
|
||||
}
|
||||
}
|
||||
|
||||
// Public so Roslyn's script compilation can reference the context type through the
|
||||
// ScriptGlobals<T> surface. The harness instantiates this directly; operators never see it.
|
||||
public sealed class HarnessVirtualTagContext(
|
||||
IDictionary<string, DataValueSnapshot> inputs, Serilog.ILogger logger) : ScriptContext
|
||||
{
|
||||
public Dictionary<string, object?> Writes { get; } = [];
|
||||
public override DataValueSnapshot GetTag(string path) =>
|
||||
inputs.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, Ua.StatusCodes.BadNotFound, null, DateTime.UtcNow);
|
||||
public override void SetVirtualTag(string path, object? value) => Writes[path] = value;
|
||||
public override DateTime Now => DateTime.UtcNow;
|
||||
public override Serilog.ILogger Logger => logger;
|
||||
}
|
||||
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent e) => Events.Add(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Harness outcome: outputs, write-set, logger events, or a rejection/throw reason.</summary>
|
||||
public sealed record ScriptTestResult(
|
||||
ScriptTestOutcome Outcome,
|
||||
object? Output,
|
||||
IReadOnlyDictionary<string, object?> Writes,
|
||||
IReadOnlyList<LogEvent> LogEvents,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static ScriptTestResult Ok(object? output, IReadOnlyDictionary<string, object?> writes, IReadOnlyList<LogEvent> logs) =>
|
||||
new(ScriptTestOutcome.Success, output, writes, logs, []);
|
||||
public static ScriptTestResult Threw(string reason, IReadOnlyList<LogEvent> logs) =>
|
||||
new(ScriptTestOutcome.Threw, null, new Dictionary<string, object?>(), logs, [reason]);
|
||||
public static ScriptTestResult DependencyRejections(IReadOnlyList<DependencyRejection> rejs) =>
|
||||
new(ScriptTestOutcome.DependencyRejected, null, new Dictionary<string, object?>(), [],
|
||||
rejs.Select(r => r.Message).ToArray());
|
||||
public static ScriptTestResult MissingInputs(string[] paths) =>
|
||||
new(ScriptTestOutcome.MissingInputs, null, new Dictionary<string, object?>(), [],
|
||||
paths.Select(p => $"Missing synthetic input: {p}").ToArray());
|
||||
public static ScriptTestResult UnknownInputs(string[] paths) =>
|
||||
new(ScriptTestOutcome.UnknownInputs, null, new Dictionary<string, object?>(), [],
|
||||
paths.Select(p => $"Input '{p}' is not referenced by the script — remove it").ToArray());
|
||||
}
|
||||
|
||||
public enum ScriptTestOutcome
|
||||
{
|
||||
Success,
|
||||
Threw,
|
||||
DependencyRejected,
|
||||
MissingInputs,
|
||||
UnknownInputs,
|
||||
}
|
||||
|
||||
file static class Ua
|
||||
{
|
||||
// Mirrors OPC UA StatusCodes.BadNotFound without pulling the OPC stack into Admin.
|
||||
public static class StatusCodes { public const uint BadNotFound = 0x803E0000; }
|
||||
}
|
||||
55
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs
Normal file
55
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>Draft-generation CRUD for <see cref="ScriptedAlarm"/> rows.</summary>
|
||||
public sealed class ScriptedAlarmService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ScriptedAlarm>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.ScriptedAlarms.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.EquipmentId).ThenBy(a => a.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<ScriptedAlarm> AddAsync(
|
||||
long generationId, string equipmentId, string name, string alarmType,
|
||||
int severity, string messageTemplate, string predicateScriptId,
|
||||
bool historizeToAveva, bool retain, CancellationToken ct)
|
||||
{
|
||||
var a = new ScriptedAlarm
|
||||
{
|
||||
GenerationId = generationId,
|
||||
ScriptedAlarmId = $"sal-{Guid.NewGuid():N}"[..20],
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
AlarmType = alarmType,
|
||||
Severity = severity,
|
||||
MessageTemplate = messageTemplate,
|
||||
PredicateScriptId = predicateScriptId,
|
||||
HistorizeToAveva = historizeToAveva,
|
||||
Retain = retain,
|
||||
};
|
||||
db.ScriptedAlarms.Add(a);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return a;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string scriptedAlarmId, CancellationToken ct)
|
||||
{
|
||||
var a = await db.ScriptedAlarms.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||
if (a is null) return;
|
||||
db.ScriptedAlarms.Remove(a);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the persistent state row (ack/confirm/shelve) for this alarm identity —
|
||||
/// alarm state is NOT generation-scoped per Phase 7 plan decision #14, so the
|
||||
/// lookup is by <see cref="ScriptedAlarm.ScriptedAlarmId"/> only.
|
||||
/// </summary>
|
||||
public Task<ScriptedAlarmState?> GetStateAsync(string scriptedAlarmId, CancellationToken ct) =>
|
||||
db.ScriptedAlarmStates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||
}
|
||||
53
src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs
Normal file
53
src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>Draft-generation CRUD for <see cref="VirtualTag"/> rows.</summary>
|
||||
public sealed class VirtualTagService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<VirtualTag>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.VirtualTags.AsNoTracking()
|
||||
.Where(v => v.GenerationId == generationId)
|
||||
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<VirtualTag> AddAsync(
|
||||
long generationId, string equipmentId, string name, string dataType, string scriptId,
|
||||
bool changeTriggered, int? timerIntervalMs, bool historize, CancellationToken ct)
|
||||
{
|
||||
var v = new VirtualTag
|
||||
{
|
||||
GenerationId = generationId,
|
||||
VirtualTagId = $"vt-{Guid.NewGuid():N}"[..20],
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = dataType,
|
||||
ScriptId = scriptId,
|
||||
ChangeTriggered = changeTriggered,
|
||||
TimerIntervalMs = timerIntervalMs,
|
||||
Historize = historize,
|
||||
};
|
||||
db.VirtualTags.Add(v);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return v;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string virtualTagId, CancellationToken ct)
|
||||
{
|
||||
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct);
|
||||
if (v is null) return;
|
||||
db.VirtualTags.Remove(v);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<VirtualTag> UpdateEnabledAsync(long generationId, string virtualTagId, bool enabled, CancellationToken ct)
|
||||
{
|
||||
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct)
|
||||
?? throw new InvalidOperationException($"VirtualTag '{virtualTagId}' not found in generation {generationId}");
|
||||
v.Enabled = enabled;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
59
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js
Normal file
59
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// Phase 7 Stream F — Monaco editor loader for ScriptEditor.razor.
|
||||
// Progressive enhancement: the textarea is authoritative until Monaco attaches;
|
||||
// after attach, Monaco syncs back into the textarea on every change so Blazor's
|
||||
// @bind still sees the latest value.
|
||||
|
||||
(function () {
|
||||
if (window.otOpcUaScriptEditor) return;
|
||||
|
||||
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
|
||||
let loaderPromise = null;
|
||||
|
||||
function ensureLoader() {
|
||||
if (loaderPromise) return loaderPromise;
|
||||
loaderPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `${MONACO_CDN}/loader.js`;
|
||||
script.onload = () => {
|
||||
window.require.config({ paths: { vs: MONACO_CDN } });
|
||||
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
|
||||
};
|
||||
script.onerror = () => reject(new Error('Monaco CDN unreachable'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return loaderPromise;
|
||||
}
|
||||
|
||||
window.otOpcUaScriptEditor = {
|
||||
attach: async function (textareaId) {
|
||||
const ta = document.getElementById(textareaId);
|
||||
if (!ta) return;
|
||||
const monaco = await ensureLoader();
|
||||
|
||||
// Mount Monaco over the textarea. The textarea stays in the DOM as the
|
||||
// source of truth for Blazor's @bind — Monaco mirrors into it on every
|
||||
// keystroke so server-side state stays in sync.
|
||||
const host = document.createElement('div');
|
||||
host.style.height = '340px';
|
||||
host.style.border = '1px solid #ced4da';
|
||||
host.style.borderRadius = '0.25rem';
|
||||
ta.style.display = 'none';
|
||||
ta.parentNode.insertBefore(host, ta);
|
||||
|
||||
const editor = monaco.editor.create(host, {
|
||||
value: ta.value,
|
||||
language: 'csharp',
|
||||
theme: 'vs',
|
||||
automaticLayout: true,
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
ta.value = editor.getValue();
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
},
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 7 follow-up (task #241) — extends <c>dbo.sp_ComputeGenerationDiff</c> to emit
|
||||
/// Script / VirtualTag / ScriptedAlarm rows alongside the existing Namespace /
|
||||
/// DriverInstance / Equipment / Tag / NodeAcl output. Admin DiffViewer now shows
|
||||
/// Phase 7 changes between generations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Logical ids: ScriptId, VirtualTagId, ScriptedAlarmId — stable across generations
|
||||
/// so a Script whose source changes surfaces as Modified (CHECKSUM picks up the
|
||||
/// SourceHash delta) while a renamed script surfaces as Modified on Name alone.
|
||||
/// ScriptedAlarmState is deliberately excluded — it's not generation-scoped, so
|
||||
/// diffing it between generations is meaningless.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public partial class ExtendComputeGenerationDiffWithPhase7 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiffV3);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
|
||||
}
|
||||
|
||||
private static class Procs
|
||||
{
|
||||
/// <summary>V3 — adds Script / VirtualTag / ScriptedAlarm sections.</summary>
|
||||
public const string ComputeGenerationDiffV3 = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||
t AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- Phase 7 — Script section. CHECKSUM picks up source changes via SourceHash + rename
|
||||
-- via Name; Language future-proofs for non-C# engines. Same Name + same Source =
|
||||
-- Unchanged (identical hash).
|
||||
WITH f AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Script', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- Phase 7 — VirtualTag section.
|
||||
WITH f AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'VirtualTag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- Phase 7 — ScriptedAlarm section. ScriptedAlarmState (operator ack trail) is
|
||||
-- logical-id keyed outside the generation scope + intentionally excluded here —
|
||||
-- diffing ack state between generations is semantically meaningless.
|
||||
WITH f AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'ScriptedAlarm', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
|
||||
/// <summary>V2 — restores the pre-Phase-7 proc on Down().</summary>
|
||||
public const string ComputeGenerationDiffV2 = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||
t AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,18 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// (holding registers with level-set values, set-point writes to analog tags) — the
|
||||
/// capability invoker respects this flag when deciding whether to apply Polly retry.
|
||||
/// </param>
|
||||
/// <param name="Source">
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
|
||||
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
|
||||
/// </param>
|
||||
/// <param name="VirtualTagId">
|
||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
|
||||
/// logical id the VirtualTagEngine addresses by. Null otherwise.
|
||||
/// </param>
|
||||
/// <param name="ScriptedAlarmId">
|
||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
||||
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
||||
/// </param>
|
||||
public sealed record DriverAttributeInfo(
|
||||
string FullName,
|
||||
DriverDataType DriverDataType,
|
||||
@@ -41,4 +53,21 @@ public sealed record DriverAttributeInfo(
|
||||
SecurityClassification SecurityClass,
|
||||
bool IsHistorized,
|
||||
bool IsAlarm = false,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
NodeSourceKind Source = NodeSourceKind.Driver,
|
||||
string? VirtualTagId = null,
|
||||
string? ScriptedAlarmId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
||||
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
|
||||
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
|
||||
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
|
||||
/// materialized by the ScriptedAlarmEngine.
|
||||
/// </summary>
|
||||
public enum NodeSourceKind
|
||||
{
|
||||
Driver = 0,
|
||||
Virtual = 1,
|
||||
ScriptedAlarm = 2,
|
||||
}
|
||||
|
||||
@@ -87,6 +87,16 @@ public static class EquipmentNodeWalker
|
||||
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var virtualTagsByEquipment = (content.VirtualTags ?? [])
|
||||
.Where(v => v.Enabled)
|
||||
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
|
||||
.Where(a => a.Enabled)
|
||||
.GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
|
||||
@@ -103,9 +113,17 @@ public static class EquipmentNodeWalker
|
||||
AddIdentifierProperties(equipmentBuilder, equipment);
|
||||
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
|
||||
|
||||
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
|
||||
foreach (var tag in equipmentTags)
|
||||
AddTagVariable(equipmentBuilder, tag);
|
||||
if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
|
||||
foreach (var tag in equipmentTags)
|
||||
AddTagVariable(equipmentBuilder, tag);
|
||||
|
||||
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
|
||||
foreach (var vtag in vTags)
|
||||
AddVirtualTagVariable(equipmentBuilder, vtag);
|
||||
|
||||
if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
|
||||
foreach (var alarm in alarms)
|
||||
AddScriptedAlarmVariable(equipmentBuilder, alarm);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +175,55 @@ public static class EquipmentNodeWalker
|
||||
/// </summary>
|
||||
private static DriverDataType ParseDriverDataType(string raw) =>
|
||||
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
||||
|
||||
/// <summary>
|
||||
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
|
||||
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
|
||||
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
|
||||
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
|
||||
/// driver.
|
||||
/// </summary>
|
||||
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
|
||||
{
|
||||
var attr = new DriverAttributeInfo(
|
||||
FullName: vtag.VirtualTagId,
|
||||
DriverDataType: ParseDriverDataType(vtag.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.FreeAccess,
|
||||
IsHistorized: vtag.Historize,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false,
|
||||
Source: NodeSourceKind.Virtual,
|
||||
VirtualTagId: vtag.VirtualTagId,
|
||||
ScriptedAlarmId: null);
|
||||
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
|
||||
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
|
||||
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
|
||||
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
|
||||
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
|
||||
/// materialization path.
|
||||
/// </summary>
|
||||
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
|
||||
{
|
||||
var attr = new DriverAttributeInfo(
|
||||
FullName: alarm.ScriptedAlarmId,
|
||||
DriverDataType: DriverDataType.Boolean,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.FreeAccess,
|
||||
IsHistorized: false,
|
||||
IsAlarm: true,
|
||||
WriteIdempotent: false,
|
||||
Source: NodeSourceKind.ScriptedAlarm,
|
||||
VirtualTagId: null,
|
||||
ScriptedAlarmId: alarm.ScriptedAlarmId);
|
||||
equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -170,4 +237,6 @@ public sealed record EquipmentNamespaceContent(
|
||||
IReadOnlyList<UnsArea> Areas,
|
||||
IReadOnlyList<UnsLine> Lines,
|
||||
IReadOnlyList<Equipment> Equipment,
|
||||
IReadOnlyList<Tag> Tags);
|
||||
IReadOnlyList<Tag> Tags,
|
||||
IReadOnlyList<VirtualTag>? VirtualTags = null,
|
||||
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);
|
||||
|
||||
@@ -68,9 +68,18 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
private readonly AuthorizationGate? _authzGate;
|
||||
private readonly NodeScopeResolver? _scopeResolver;
|
||||
|
||||
// Phase 7 Stream G follow-up — per-variable NodeSourceKind so OnReadValue can dispatch
|
||||
// to the VirtualTagEngine / ScriptedAlarmEngine instead of the driver's IReadable per
|
||||
// ADR-002. Absent entries default to Driver so drivers registered before Phase 7
|
||||
// keep working unchanged.
|
||||
private readonly Dictionary<string, NodeSourceKind> _sourceByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly IReadable? _virtualReadable;
|
||||
private readonly IReadable? _scriptedAlarmReadable;
|
||||
|
||||
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
|
||||
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
|
||||
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null)
|
||||
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
|
||||
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null)
|
||||
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
|
||||
{
|
||||
_driver = driver;
|
||||
@@ -80,6 +89,8 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
_invoker = invoker;
|
||||
_authzGate = authzGate;
|
||||
_scopeResolver = scopeResolver;
|
||||
_virtualReadable = virtualReadable;
|
||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -185,6 +196,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
_variablesByFullRef[attributeInfo.FullName] = v;
|
||||
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
|
||||
_writeIdempotentByFullRef[attributeInfo.FullName] = attributeInfo.WriteIdempotent;
|
||||
_sourceByFullRef[attributeInfo.FullName] = attributeInfo.Source;
|
||||
|
||||
v.OnReadValue = OnReadValue;
|
||||
v.OnWriteValue = OnWriteValue;
|
||||
@@ -216,16 +228,18 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
private ServiceResult OnReadValue(ISystemContext context, NodeState node, NumericRange indexRange,
|
||||
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
|
||||
{
|
||||
if (_readable is null)
|
||||
var fullRef = node.NodeId.Identifier as string ?? "";
|
||||
var source = _sourceByFullRef.TryGetValue(fullRef, out var s) ? s : NodeSourceKind.Driver;
|
||||
var readable = SelectReadable(source, _readable, _virtualReadable, _scriptedAlarmReadable);
|
||||
|
||||
if (readable is null)
|
||||
{
|
||||
statusCode = StatusCodes.BadNotReadable;
|
||||
statusCode = source == NodeSourceKind.Driver ? StatusCodes.BadNotReadable : StatusCodes.BadNotFound;
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullRef = node.NodeId.Identifier as string ?? "";
|
||||
|
||||
// Phase 6.2 Stream C — authorization gate. Runs ahead of the invoker so a denied
|
||||
// read never hits the driver. Returns true in lax mode when identity lacks LDAP
|
||||
// groups; strict mode denies those cases. See AuthorizationGate remarks.
|
||||
@@ -242,7 +256,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
var result = _invoker.ExecuteAsync(
|
||||
DriverCapability.Read,
|
||||
ResolveHostFor(fullRef),
|
||||
async ct => (IReadOnlyList<DataValueSnapshot>)await _readable.ReadAsync([fullRef], ct).ConfigureAwait(false),
|
||||
async ct => (IReadOnlyList<DataValueSnapshot>)await readable.ReadAsync([fullRef], ct).ConfigureAwait(false),
|
||||
CancellationToken.None).AsTask().GetAwaiter().GetResult();
|
||||
if (result.Count == 0)
|
||||
{
|
||||
@@ -262,6 +276,32 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the <see cref="IReadable"/> the dispatch layer routes through based on the
|
||||
/// node's Phase 7 source kind (ADR-002). Extracted as a pure function for unit test
|
||||
/// coverage — the full dispatch requires the OPC UA server stack, but this kernel is
|
||||
/// deterministic and small.
|
||||
/// </summary>
|
||||
internal static IReadable? SelectReadable(
|
||||
NodeSourceKind source,
|
||||
IReadable? driverReadable,
|
||||
IReadable? virtualReadable,
|
||||
IReadable? scriptedAlarmReadable) => source switch
|
||||
{
|
||||
NodeSourceKind.Virtual => virtualReadable,
|
||||
NodeSourceKind.ScriptedAlarm => scriptedAlarmReadable,
|
||||
_ => driverReadable,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Plan decision #6 gate — returns true only when the write is allowed. Virtual tags
|
||||
/// and scripted alarms reject OPC UA writes because the write path for virtual tags
|
||||
/// is <c>ctx.SetVirtualTag</c> from within a script, and the write path for alarm
|
||||
/// state is the Part 9 method nodes (Acknowledge / Confirm / Shelve).
|
||||
/// </summary>
|
||||
internal static bool IsWriteAllowedBySource(NodeSourceKind source) =>
|
||||
source == NodeSourceKind.Driver;
|
||||
|
||||
private static NodeId MapDataType(DriverDataType t) => t switch
|
||||
{
|
||||
DriverDataType.Boolean => DataTypeIds.Boolean,
|
||||
@@ -414,10 +454,19 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
private ServiceResult OnWriteValue(ISystemContext context, NodeState node, NumericRange indexRange,
|
||||
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
|
||||
{
|
||||
if (_writable is null) return StatusCodes.BadNotWritable;
|
||||
var fullRef = node.NodeId.Identifier as string;
|
||||
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
|
||||
|
||||
// Per Phase 7 plan decision #6 — virtual tags + scripted alarms reject direct
|
||||
// OPC UA writes with BadUserAccessDenied. Scripts can write to virtual tags
|
||||
// via ctx.SetVirtualTag; operators cannot. Operator alarm actions go through
|
||||
// the Part 9 method nodes (Acknowledge / Confirm / Shelve), not through the
|
||||
// variable-value write path.
|
||||
if (_sourceByFullRef.TryGetValue(fullRef!, out var source) && !IsWriteAllowedBySource(source))
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
if (_writable is null) return StatusCodes.BadNotWritable;
|
||||
|
||||
// PR 26: server-layer write authorization. Look up the attribute's classification
|
||||
// (populated during Variable() in Discover) and check the session's roles against the
|
||||
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync
|
||||
|
||||
@@ -30,6 +30,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
|
||||
private readonly Func<string, string?>? _resilienceConfigLookup;
|
||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
|
||||
|
||||
// Phase 7 Stream G follow-up (task #239). When composed with the VirtualTagEngine +
|
||||
// ScriptedAlarmEngine sources these route node reads to the engines instead of the
|
||||
// driver. Null = Phase 7 engines not enabled for this deployment (identical to pre-
|
||||
// Phase-7 behaviour).
|
||||
private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
|
||||
private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
@@ -45,7 +53,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
StaleConfigFlag? staleConfigFlag = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
|
||||
Func<string, string?>? resilienceConfigLookup = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null)
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable = null,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable = null)
|
||||
{
|
||||
_options = options;
|
||||
_driverHost = driverHost;
|
||||
@@ -57,6 +67,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
_tierLookup = tierLookup;
|
||||
_resilienceConfigLookup = resilienceConfigLookup;
|
||||
_equipmentContentLookup = equipmentContentLookup;
|
||||
_virtualReadable = virtualReadable;
|
||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -85,7 +97,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
|
||||
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
|
||||
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
||||
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup);
|
||||
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
|
||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
|
||||
await _application.Start(_server).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||
|
||||
@@ -25,6 +25,15 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
private readonly NodeScopeResolver? _scopeResolver;
|
||||
private readonly Func<string, DriverTier>? _tierLookup;
|
||||
private readonly Func<string, string?>? _resilienceConfigLookup;
|
||||
|
||||
// Phase 7 Stream G follow-up wiring (task #239). Shared across every DriverNodeManager
|
||||
// instantiated by this server so virtual-tag reads and scripted-alarm reads from any
|
||||
// driver's address-space subtree route to the same engine. When null (no Phase 7
|
||||
// engines composed for this deployment) DriverNodeManager falls back to driver-only
|
||||
// dispatch — identical to pre-Phase-7 behaviour.
|
||||
private readonly IReadable? _virtualReadable;
|
||||
private readonly IReadable? _scriptedAlarmReadable;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
||||
|
||||
@@ -36,7 +45,9 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
AuthorizationGate? authzGate = null,
|
||||
NodeScopeResolver? scopeResolver = null,
|
||||
Func<string, DriverTier>? tierLookup = null,
|
||||
Func<string, string?>? resilienceConfigLookup = null)
|
||||
Func<string, string?>? resilienceConfigLookup = null,
|
||||
IReadable? virtualReadable = null,
|
||||
IReadable? scriptedAlarmReadable = null)
|
||||
{
|
||||
_driverHost = driverHost;
|
||||
_authenticator = authenticator;
|
||||
@@ -45,6 +56,8 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
_scopeResolver = scopeResolver;
|
||||
_tierLookup = tierLookup;
|
||||
_resilienceConfigLookup = resilienceConfigLookup;
|
||||
_virtualReadable = virtualReadable;
|
||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
@@ -77,7 +90,8 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
|
||||
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
|
||||
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
||||
authzGate: _authzGate, scopeResolver: _scopeResolver);
|
||||
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
|
||||
_driverNodeManagers.Add(manager);
|
||||
}
|
||||
|
||||
|
||||
130
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs
Normal file
130
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// Stands up the Admin Blazor Server host on a free TCP port with the live SQL Server
|
||||
/// context swapped for an EF Core InMemory DbContext + the LDAP cookie auth swapped for
|
||||
/// <see cref="TestAuthHandler"/>. Playwright connects to <see cref="BaseUrl"/>.
|
||||
/// InMemory is sufficient because UnsService's drag-drop path exercises EF operations,
|
||||
/// not raw SQL.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We deliberately build a <see cref="WebApplication"/> directly rather than going through
|
||||
/// <c>WebApplicationFactory<Program></c> — the factory's TestServer transport doesn't
|
||||
/// coexist cleanly with Kestrel-on-a-real-port, and Playwright needs a real loopback HTTP
|
||||
/// endpoint to hit. This mirrors the Program.cs entry-points for everything else.
|
||||
/// </remarks>
|
||||
public sealed class AdminWebAppFactory : IAsyncDisposable
|
||||
{
|
||||
private WebApplication? _app;
|
||||
|
||||
public string BaseUrl { get; private set; } = "";
|
||||
public long SeededGenerationId { get; private set; }
|
||||
public string SeededClusterId { get; } = "e2e-cluster";
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var port = GetFreeTcpPort();
|
||||
BaseUrl = $"http://127.0.0.1:{port}";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(Array.Empty<string>());
|
||||
builder.WebHost.UseUrls(BaseUrl);
|
||||
|
||||
// --- Mirror the Admin composition in Program.cs, but with the InMemory DB + test
|
||||
// auth swaps instead of SQL Server + LDAP cookie auth.
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddAntiforgery();
|
||||
|
||||
builder.Services.AddAuthentication(TestAuthHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("CanEdit", p => p.RequireRole(Admin.Services.AdminRoles.ConfigEditor, Admin.Services.AdminRoles.FleetAdmin))
|
||||
.AddPolicy("CanPublish", p => p.RequireRole(Admin.Services.AdminRoles.FleetAdmin));
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseInMemoryDatabase($"e2e-{Guid.NewGuid():N}"));
|
||||
|
||||
builder.Services.AddScoped<Admin.Services.ClusterService>();
|
||||
builder.Services.AddScoped<Admin.Services.GenerationService>();
|
||||
builder.Services.AddScoped<Admin.Services.UnsService>();
|
||||
builder.Services.AddScoped<Admin.Services.EquipmentService>();
|
||||
builder.Services.AddScoped<Admin.Services.NamespaceService>();
|
||||
builder.Services.AddScoped<Admin.Services.DriverInstanceService>();
|
||||
builder.Services.AddScoped<Admin.Services.DraftValidationService>();
|
||||
|
||||
_app = builder.Build();
|
||||
_app.UseStaticFiles();
|
||||
_app.UseRouting();
|
||||
_app.UseAuthentication();
|
||||
_app.UseAuthorization();
|
||||
_app.UseAntiforgery();
|
||||
_app.MapRazorComponents<Admin.Components.App>().AddInteractiveServerRenderMode();
|
||||
|
||||
// Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav.
|
||||
using (var scope = _app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
SeededGenerationId = Seed(db, SeededClusterId);
|
||||
}
|
||||
|
||||
await _app.StartAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static long Seed(OtOpcUaConfigDbContext db, string clusterId)
|
||||
{
|
||||
var cluster = new ServerCluster
|
||||
{
|
||||
ClusterId = clusterId, Name = "e2e", Enterprise = "zb", Site = "lab",
|
||||
RedundancyMode = RedundancyMode.None, NodeCount = 1, CreatedBy = "e2e",
|
||||
};
|
||||
var gen = new ConfigGeneration
|
||||
{
|
||||
ClusterId = clusterId, Status = GenerationStatus.Draft, CreatedBy = "e2e",
|
||||
};
|
||||
|
||||
db.ServerClusters.Add(cluster);
|
||||
db.ConfigGenerations.Add(gen);
|
||||
db.SaveChanges();
|
||||
|
||||
db.UnsAreas.AddRange(
|
||||
new UnsArea { UnsAreaId = "area-a", ClusterId = clusterId, Name = "warsaw", GenerationId = gen.GenerationId },
|
||||
new UnsArea { UnsAreaId = "area-b", ClusterId = clusterId, Name = "berlin", GenerationId = gen.GenerationId });
|
||||
db.UnsLines.Add(new UnsLine
|
||||
{
|
||||
UnsLineId = "line-a1", UnsAreaId = "area-a", Name = "oven-line", GenerationId = gen.GenerationId,
|
||||
});
|
||||
db.SaveChanges();
|
||||
return gen.GenerationId;
|
||||
}
|
||||
|
||||
private static int GetFreeTcpPort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
44
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs
Normal file
44
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// One Playwright runtime + Chromium browser for the whole E2E suite. Tests
|
||||
/// open a fresh <see cref="IBrowserContext"/> per fixture so cookies + localStorage
|
||||
/// stay isolated. Browser install is a one-time step:
|
||||
/// <c>pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium</c>.
|
||||
/// When the browser binary isn't present the suite reports a <see cref="PlaywrightBrowserMissingException"/>
|
||||
/// so CI can distinguish missing-browser from real test failure.
|
||||
/// </summary>
|
||||
public sealed class PlaywrightFixture : IAsyncLifetime
|
||||
{
|
||||
public IPlaywright Playwright { get; private set; } = null!;
|
||||
public IBrowser Browser { get; private set; } = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
|
||||
try
|
||||
{
|
||||
Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
|
||||
}
|
||||
catch (PlaywrightException ex) when (ex.Message.Contains("Executable doesn't exist"))
|
||||
{
|
||||
throw new PlaywrightBrowserMissingException(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Browser is not null) await Browser.CloseAsync();
|
||||
Playwright?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="PlaywrightFixture"/> when Chromium isn't installed. Tests
|
||||
/// catching this mark themselves as "skipped" rather than "failed", so CI without
|
||||
/// the install step stays green.
|
||||
/// </summary>
|
||||
public sealed class PlaywrightBrowserMissingException(string message) : Exception(message);
|
||||
34
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs
Normal file
34
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// Stamps every request with a FleetAdmin principal so E2E tests can hit
|
||||
/// authenticated Razor pages without the LDAP login flow. Registered as the
|
||||
/// default authentication scheme by <see cref="AdminWebAppFactory"/>.
|
||||
/// </summary>
|
||||
public sealed class TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "Test";
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, "e2e-test-user"),
|
||||
new Claim(ClaimTypes.Role, AdminRoles.FleetAdmin),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.Playwright;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4 UnsTab drag-drop E2E smoke (task #199). This PR lands the Playwright +
|
||||
/// WebApplicationFactory-equivalent scaffolding so future E2E coverage builds on it
|
||||
/// rather than setting it up from scratch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Prerequisite.</b> Chromium must be installed locally:
|
||||
/// <c>pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium</c>.
|
||||
/// When the binary is missing the tests <see cref="Assert.Skip"/> rather than fail hard,
|
||||
/// so CI pipelines that don't run the install step still report green.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Current scope.</b> The host-reachability smoke below proves the infra works:
|
||||
/// Kestrel-on-a-free-port, InMemory DbContext swap, <see cref="TestAuthHandler"/>
|
||||
/// bypass, and Playwright-to-real-browser are all exercised. The actual drag-drop
|
||||
/// interactive assertion is filed as a follow-up (task #242) because
|
||||
/// Blazor Server interactive render through a test-owned pipeline needs a dedicated
|
||||
/// diagnosis pass — the scaffolding lands here first so that follow-up can focus on
|
||||
/// the Blazor-specific wiring instead of rebuilding the harness.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class UnsTabDragDropE2ETests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Admin_host_serves_HTTP_via_Playwright_scaffolding()
|
||||
{
|
||||
await using var app = new AdminWebAppFactory();
|
||||
await app.StartAsync();
|
||||
|
||||
PlaywrightFixture fixture;
|
||||
try
|
||||
{
|
||||
fixture = new PlaywrightFixture();
|
||||
await fixture.InitializeAsync();
|
||||
}
|
||||
catch (PlaywrightBrowserMissingException)
|
||||
{
|
||||
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ctx = await fixture.Browser.NewContextAsync();
|
||||
var page = await ctx.NewPageAsync();
|
||||
|
||||
// Navigate to the root. We only assert the host is live + returns HTML — not
|
||||
// that the Blazor Server interactive render has booted. Booting the interactive
|
||||
// circuit in a test-owned pipeline is task #242.
|
||||
var response = await page.GotoAsync(app.BaseUrl);
|
||||
|
||||
response.ShouldNotBeNull();
|
||||
response!.Status.ShouldBeLessThan(500,
|
||||
$"Admin host returned HTTP {response.Status} at root — scaffolding broken");
|
||||
|
||||
// Static HTML shell should at least include the <body> and some content. This
|
||||
// rules out 404s + verifies the MapRazorComponents route pipeline is wired.
|
||||
var body = await page.Locator("body").InnerHTMLAsync();
|
||||
body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin.E2ETests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.51.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Admin\ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
196
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/Phase7ServicesTests.cs
Normal file
196
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/Phase7ServicesTests.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-side services shipped in Phase 7 Stream F — draft CRUD for scripts + virtual
|
||||
/// tags + scripted alarms, the pre-publish test harness, and the historian
|
||||
/// diagnostics façade.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Phase7ServicesTests
|
||||
{
|
||||
private static OtOpcUaConfigDbContext NewDb([System.Runtime.CompilerServices.CallerMemberName] string test = "")
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"phase7-{test}-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_AddAsync_generates_logical_id_and_hash()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
|
||||
var s = await svc.AddAsync(5, "line-rate", "return ctx.GetTag(\"a\").Value;", default);
|
||||
|
||||
s.ScriptId.ShouldStartWith("scr-");
|
||||
s.GenerationId.ShouldBe(5);
|
||||
s.SourceHash.Length.ShouldBe(64);
|
||||
(await svc.ListAsync(5, default)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_UpdateAsync_recomputes_hash_on_source_change()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
var s = await svc.AddAsync(5, "s", "return 1;", default);
|
||||
var hashBefore = s.SourceHash;
|
||||
|
||||
var updated = await svc.UpdateAsync(5, s.ScriptId, "s", "return 2;", default);
|
||||
updated.SourceHash.ShouldNotBe(hashBefore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_UpdateAsync_same_source_same_hash()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
var s = await svc.AddAsync(5, "s", "return 1;", default);
|
||||
var updated = await svc.UpdateAsync(5, s.ScriptId, "renamed", "return 1;", default);
|
||||
|
||||
updated.SourceHash.ShouldBe(s.SourceHash, "source unchanged → hash unchanged → compile cache hit preserved");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_DeleteAsync_is_idempotent()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
|
||||
await Should.NotThrowAsync(() => svc.DeleteAsync(5, "nonexistent", default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VirtualTagService_round_trips_trigger_flags()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new VirtualTagService(db);
|
||||
|
||||
var v = await svc.AddAsync(7, "eq-1", "LineRate", "Float32", "scr-1",
|
||||
changeTriggered: true, timerIntervalMs: 1000, historize: true, default);
|
||||
|
||||
v.ChangeTriggered.ShouldBeTrue();
|
||||
v.TimerIntervalMs.ShouldBe(1000);
|
||||
v.Historize.ShouldBeTrue();
|
||||
v.Enabled.ShouldBeTrue();
|
||||
(await svc.ListAsync(7, default)).Single().VirtualTagId.ShouldBe(v.VirtualTagId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VirtualTagService_update_enabled_toggles_flag()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new VirtualTagService(db);
|
||||
var v = await svc.AddAsync(7, "eq-1", "N", "Int32", "scr-1", true, null, false, default);
|
||||
|
||||
var disabled = await svc.UpdateEnabledAsync(7, v.VirtualTagId, false, default);
|
||||
disabled.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptedAlarmService_defaults_HistorizeToAveva_true_per_plan_decision_15()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptedAlarmService(db);
|
||||
|
||||
var a = await svc.AddAsync(9, "eq-1", "HighTemp", "LimitAlarm", severity: 800,
|
||||
messageTemplate: "{Temp} too high", predicateScriptId: "scr-9",
|
||||
historizeToAveva: true, retain: true, default);
|
||||
|
||||
a.HistorizeToAveva.ShouldBeTrue();
|
||||
a.Severity.ShouldBe(800);
|
||||
a.ScriptedAlarmId.ShouldStartWith("sal-");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_runs_successful_script_and_captures_writes()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """
|
||||
ctx.SetVirtualTag("Out", 42);
|
||||
return ctx.GetTag("In").Value;
|
||||
""";
|
||||
var inputs = new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["In"] = new(123, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, inputs, default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.Success);
|
||||
result.Output.ShouldBe(123);
|
||||
result.Writes["Out"].ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_rejects_missing_synthetic_input()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """return ctx.GetTag("A").Value;""";
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.MissingInputs);
|
||||
result.Errors[0].ShouldContain("A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_rejects_extra_synthetic_input_not_referenced_by_script()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """return 1;"""; // no GetTag calls
|
||||
var inputs = new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["Unexpected"] = new(0, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, inputs, default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.UnknownInputs);
|
||||
result.Errors[0].ShouldContain("Unexpected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_rejects_non_literal_path()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """
|
||||
var p = "A";
|
||||
return ctx.GetTag(p).Value;
|
||||
""";
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.DependencyRejected);
|
||||
result.Errors.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_surfaces_compile_error_as_Threw()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = "this is not valid C#;";
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.Threw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistorianDiagnosticsService_reports_Disabled_for_null_sink()
|
||||
{
|
||||
var diag = new HistorianDiagnosticsService(NullAlarmHistorianSink.Instance);
|
||||
diag.GetStatus().DrainState.ShouldBe(HistorianDrainState.Disabled);
|
||||
diag.TryRetryDeadLettered().ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,117 @@ public sealed class EquipmentNodeWalkerTests
|
||||
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var vtag = new VirtualTag
|
||||
{
|
||||
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "LineRate",
|
||||
DataType = "Float32", ScriptId = "scr-1", Historize = true,
|
||||
};
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [], VirtualTags: [vtag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var v = equipmentNode.Variables.Single(x => x.BrowseName == "LineRate");
|
||||
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Virtual);
|
||||
v.AttributeInfo.VirtualTagId.ShouldBe("vt-1");
|
||||
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
||||
v.AttributeInfo.IsHistorized.ShouldBeTrue();
|
||||
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "HighTemp",
|
||||
AlarmType = "LimitAlarm", MessageTemplate = "{Temp} exceeded",
|
||||
PredicateScriptId = "scr-9", Severity = 800,
|
||||
};
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [], ScriptedAlarms: [alarm]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var v = rec.Children[0].Children[0].Children[0].Variables.Single(x => x.BrowseName == "HighTemp");
|
||||
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.ScriptedAlarm);
|
||||
v.AttributeInfo.ScriptedAlarmId.ShouldBe("al-1");
|
||||
v.AttributeInfo.VirtualTagId.ShouldBeNull();
|
||||
v.AttributeInfo.IsAlarm.ShouldBeTrue();
|
||||
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Skips_Disabled_VirtualTags_And_Alarms()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var vtag = new VirtualTag
|
||||
{
|
||||
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled",
|
||||
DataType = "Float32", ScriptId = "scr-1", Enabled = false,
|
||||
};
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "DisabledAlarm",
|
||||
AlarmType = "LimitAlarm", MessageTemplate = "x",
|
||||
PredicateScriptId = "scr-9", Enabled = false,
|
||||
};
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [], VirtualTags: [vtag], ScriptedAlarms: [alarm]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe()
|
||||
{
|
||||
// Backwards-compat — callers that don't populate the new collections still work.
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content); // must not throw
|
||||
|
||||
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_tag_default_NodeSourceKind_is_Driver()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [tag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var v = rec.Children[0].Children[0].Children[0].Variables.Single();
|
||||
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Driver);
|
||||
v.AttributeInfo.VirtualTagId.ShouldBeNull();
|
||||
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ----- builders for test seed rows -----
|
||||
|
||||
private static UnsArea Area(string id, string name) => new()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 Stream G follow-up — verifies the NodeSourceKind dispatch kernel that
|
||||
/// DriverNodeManager's OnReadValue + OnWriteValue use to route per-node calls to
|
||||
/// the right backend per ADR-002. Pure functions; no OPC UA stack required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverNodeManagerSourceDispatchTests
|
||||
{
|
||||
private sealed class FakeReadable : IReadable
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<DataValueSnapshot>>([]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_source_routes_to_driver_readable()
|
||||
{
|
||||
var drv = new FakeReadable { Name = "drv" };
|
||||
var vt = new FakeReadable { Name = "vt" };
|
||||
var al = new FakeReadable { Name = "al" };
|
||||
|
||||
DriverNodeManager.SelectReadable(NodeSourceKind.Driver, drv, vt, al).ShouldBeSameAs(drv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Virtual_source_routes_to_virtual_readable()
|
||||
{
|
||||
var drv = new FakeReadable();
|
||||
var vt = new FakeReadable();
|
||||
var al = new FakeReadable();
|
||||
|
||||
DriverNodeManager.SelectReadable(NodeSourceKind.Virtual, drv, vt, al).ShouldBeSameAs(vt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_source_routes_to_alarm_readable()
|
||||
{
|
||||
var drv = new FakeReadable();
|
||||
var vt = new FakeReadable();
|
||||
var al = new FakeReadable();
|
||||
|
||||
DriverNodeManager.SelectReadable(NodeSourceKind.ScriptedAlarm, drv, vt, al).ShouldBeSameAs(al);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Virtual_source_without_virtual_readable_returns_null()
|
||||
{
|
||||
// Engine not wired → dispatch layer surfaces BadNotFound (the null propagates
|
||||
// through to the OnReadValue null-check).
|
||||
DriverNodeManager.SelectReadable(
|
||||
NodeSourceKind.Virtual, driverReadable: new FakeReadable(),
|
||||
virtualReadable: null, scriptedAlarmReadable: null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_source_without_alarm_readable_returns_null()
|
||||
{
|
||||
DriverNodeManager.SelectReadable(
|
||||
NodeSourceKind.ScriptedAlarm, driverReadable: new FakeReadable(),
|
||||
virtualReadable: new FakeReadable(), scriptedAlarmReadable: null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_source_without_driver_readable_returns_null()
|
||||
{
|
||||
// Pre-existing BadNotReadable behavior — unchanged by Phase 7 wiring.
|
||||
DriverNodeManager.SelectReadable(
|
||||
NodeSourceKind.Driver, driverReadable: null,
|
||||
virtualReadable: new FakeReadable(), scriptedAlarmReadable: new FakeReadable()).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWriteAllowedBySource_only_Driver_returns_true()
|
||||
{
|
||||
// Plan decision #6 — OPC UA writes to virtual tags / scripted alarms rejected.
|
||||
DriverNodeManager.IsWriteAllowedBySource(NodeSourceKind.Driver).ShouldBeTrue();
|
||||
DriverNodeManager.IsWriteAllowedBySource(NodeSourceKind.Virtual).ShouldBeFalse();
|
||||
DriverNodeManager.IsWriteAllowedBySource(NodeSourceKind.ScriptedAlarm).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user