From c9201ef86dda10bcdbbe24ce0d3d0719f94b9fb5 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:36:13 +0300 Subject: [PATCH] feat(opc): read the live edge from the SCADA's OPC UA The archive poll could show a change only once the SCADA flushed it, 1 to 3 minutes late for a fast pen and up to a day for a constant one, so a change could not reach the chart within 1 s. The live edge now reads the SCADA's OPC UA server through OpcUaLiveSource in a new SemiPlot.DataSource.OpcUa project, the second implementation of ILiveSource. One ManagedSession per process dials the configured opc.tcp://: from the new opc/ section (tracked as 127.0.0.1 and 49875), keeps that address, finds the project ID by browsing for the root folder and subscribes each pen set at 100 ms with a monitored-item queue of 1. SourceTimestamp, true UTC, becomes the sample time. A lost session writes one break per pen 1 ms past its newest stamp, a Bad notification writes a break, and the chart writes one ahead of a pen's first live sample past its last column, so a pen-set change draws no gap. Pens the source cannot serve are one Warning entry per subscription. OPC UA has its own status-bar indicator and a Warning through ArchiveFailureMapper, after a 30 s grace at start. A bad opc/ file is a startup failure; an unreachable server is not. Deleted: PostgresLiveSource, RealtimePoll, its two statements and poll_interval_ms with its settings field and resx keys. converge gains a required --opc-port and writes the stand's opc/opc.yaml; the AppHost passes 55875 and runs the demo writer with --archive-lag 60. --- AGENTS.md | 93 +- ConfigFiles/connection/connection.yaml | 1 - ConfigFiles/opc/opc.yaml | 2 + SemiPlot.slnx | 1 + SemiPlot/SemiPlot.AppHost/AppHost.cs | 8 +- .../ConfigurationSectionError.cs | 3 +- .../Configuration/ConnectionFile.cs | 96 ++ .../Configuration/NetworkAddress.cs | 36 + .../Data/Errors/ConnectionFileError.cs | 22 +- .../Data/Errors/LiveSourceError.cs | 69 + SemiPlot/SemiPlot.Core/Data/ILiveSource.cs | 9 +- .../SemiPlot.Core/Data/LiveSourceState.cs | 37 + SemiPlot/SemiPlot.Core/Trends/Sample.cs | 6 +- .../ClientApplicationConfiguration.cs | 68 + .../Configuration/OpcUaConnectionLoader.cs | 60 + .../Configuration/OpcUaConnectionSettings.cs | 7 + .../ConnectBackoff.cs | 45 + .../DeliveredStamps.cs | 70 + .../EndpointChoice.cs | 43 + .../LiveSessionStates.cs | 102 ++ .../SemiPlot.DataSource.OpcUa/LiveValue.cs | 87 ++ .../OpcUaDataServiceCollectionExtensions.cs | 26 + .../OpcUaLiveSource.cs | 124 ++ .../SemiPlot.DataSource.OpcUa/OpcUaSession.cs | 298 ++++ .../PenSubscription.cs | 195 +++ .../PenSubscriptionContext.cs | 15 + .../PenSubscriptionSet.cs | 224 +++ .../ProjectFolder.cs | 80 + .../ReportedUnservedSets.cs | 17 + .../SemiPlot.DataSource.OpcUa.csproj | 21 + .../SemiPlot.DataSource.OpcUa/SerialLoop.cs | 91 ++ .../SemiPlot.DataSource.OpcUa/ServerCalls.cs | 203 +++ .../StackTelemetry.cs | 8 + .../SubscriptionPlan.cs | 57 + .../SemiPlot.DataSource.OpcUa/UnservedPens.cs | 107 ++ .../ArchiveStatements.cs | 24 - .../Configuration/PostgresConnectionLoader.cs | 176 +-- .../PostgresConnectionSettings.cs | 6 +- ...PostgresDataServiceCollectionExtensions.cs | 7 - .../PostgresLiveSource.cs | 89 -- .../RealtimePoll.cs | 210 --- .../SemiPlot.DataSource.Postgres.csproj | 1 - .../ArchiveProviderFactory.cs | 3 - .../SemiPlot.Tests.Integration/ArmedGate.cs | 143 -- .../BatchCollector.cs | 56 - .../CatalogueReadUnreachableTests.cs | 2 +- .../ConvergeReadinessTests.cs | 3 +- .../ConvergeTests.cs | 13 +- .../DemoScada/DemoScadaHost.cs | 64 +- .../DemoScada/LogRecordLines.cs | 14 + .../ExplainPlanTests.cs | 57 - .../BreakRenderArchiveJourneyTests.cs | 19 +- .../Journeys/LiveEdgeArchiveJourneyTests.cs | 318 ---- .../Journeys/OpcUaLiveEdgeJourneyTests.cs | 248 +++ .../OpcUa/LiveSourceHost.cs | 198 +++ .../OpcUa/LoopbackRelay.cs | 176 +++ .../OpcUa/OpcUaLiveSourceRestartTests.cs | 167 ++ .../OpcUa/OpcUaLiveSourceTests.cs | 146 ++ .../OpcUa/OpcUaSessionTests.cs | 343 +++++ .../OpcUa/SampleRecorder.cs | 101 ++ .../RealtimeArchive.cs | 38 +- .../RealtimeEmptyArchiveTests.cs | 82 - .../RealtimePollReadTests.cs | 309 ---- .../RealtimePollTests.cs | 152 -- .../RealtimeSubscriptionDisposalTests.cs | 50 - .../RealtimeSubscriptionTests.cs | 172 --- .../SemiPlot.Tests.Integration.csproj | 1 + .../StartupProbeOpcSectionTests.cs | 58 + .../UnreachableArchive.cs | 26 +- .../ConnectionFileWriterTests.cs | 5 +- SemiPlot/SemiPlot.Tests.Unit/ConvergeTests.cs | 15 +- .../ConfigurationSectionWriterTests.cs | 1 - .../Core/Configuration/NetworkAddressTests.cs | 43 + .../DeliveredConfigurationTests.cs | 27 +- .../SemiPlot.Tests.Unit/OpcFileWriterTests.cs | 45 + .../ClientApplicationConfigurationTests.cs | 78 + .../OpcUa/ConnectBackoffTests.cs | 76 + .../OpcUa/DeliveredStampsTests.cs | 145 ++ .../OpcUa/EndpointChoiceTests.cs | 136 ++ .../OpcUa/LiveSessionStatesTests.cs | 179 +++ .../OpcUa/LiveValueTests.cs | 157 ++ .../OpcUa/OpcUaCompositionTests.cs | 75 + .../OpcUa/OpcUaConnectionLoaderTests.cs | 185 +++ .../OpcUa/PenSubscriptionSetTests.cs | 95 ++ .../OpcUa/PenSubscriptionTests.cs | 136 ++ .../OpcUa/ProjectFolderTests.cs | 166 ++ .../OpcUa/SubscriptionPlanTests.cs | 54 + .../OpcUa/TestPenSubscriptionContext.cs | 26 + .../OpcUa/UnservedPensTests.cs | 157 ++ .../Postgres/ArchiveStatementTextTests.cs | 52 - .../Postgres/ConnectionSettingsFactory.cs | 3 - .../Postgres/PostgresCompositionTests.cs | 11 - .../Postgres/PostgresConnectionLoaderTests.cs | 53 +- .../SemiPlot.Tests.Unit/SeederCommandTests.cs | 48 +- .../SemiPlot.Tests.Unit.csproj | 1 + .../UI/Bridge/FakeDataProvider.cs | 32 +- .../UI/Bridge/TrendCoordinatorTests.cs | 37 + .../UI/Chart/ChartRealtimeApplierTests.cs | 28 +- .../UI/Chart/EnvelopeLineTests.cs | 51 +- .../UI/Chart/TrendChartViewModelTests.cs | 98 +- .../UI/Chart/TrendPenStateTests.cs | 177 ++- .../UI/Di/CompositionRootTests.cs | 8 +- .../MainWindow/AppStatusBarViewModelTests.cs | 147 +- .../UI/MainWindow/AppStatusBarViewTests.cs | 75 +- .../UI/MainWindow/MainWindowTestBuilder.cs | 2 + .../UI/MainWindow/TrendWindowTests.cs | 16 + .../UI/Messages/ArchiveFailureMapperTests.cs | 171 ++- .../UI/Messages/FailureSeverityTests.cs | 66 +- .../UI/Settings/SettingsSaveTests.cs | 1 + .../UI/Settings/SettingsViewModelTests.cs | 44 +- .../UI/Settings/SettingsViewTests.cs | 2 - .../UI/Startup/StartupProbeTests.cs | 53 + .../ConnectionFileWriter.cs | 2 - .../SemiPlot.Tools.ArchiveSeeder/Converge.cs | 6 +- .../ConvergeOptions.cs | 7 +- .../DemoScadaServer.cs | 6 +- .../OpcFileWriter.cs | 31 + .../SemiPlot.Tools.ArchiveSeeder/Program.cs | 1 + .../SeederCommand.cs | 31 +- .../SemiPlot.UI/Bridge/TrendCoordinator.cs | 9 + SemiPlot/SemiPlot.UI/Chart/EnvelopeLine.cs | 33 +- .../SemiPlot.UI/Chart/TrendChartViewModel.cs | 42 + SemiPlot/SemiPlot.UI/Chart/TrendPenState.cs | 47 +- .../SemiPlot.UI/Localization/Resources.resx | 85 +- .../Localization/Resources.ru.resx | 85 +- .../SemiPlot.UI/MainWindow/AppStatusBar.axaml | 17 +- .../MainWindow/AppStatusBarViewModel.cs | 126 +- .../SemiPlot.UI/MainWindow/TrendWindow.cs | 1 + .../Messages/ArchiveFailureMapper.cs | 75 +- .../ConfigurationSectionFailureMapper.cs | 34 +- SemiPlot/SemiPlot.UI/SemiPlot.UI.csproj | 1 + .../SemiPlot.UI/Settings/SettingsDialog.axaml | 19 +- SemiPlot/SemiPlot.UI/Settings/SettingsSave.cs | 3 +- .../SemiPlot.UI/Settings/SettingsViewModel.cs | 64 +- SemiPlot/SemiPlot.UI/Startup/StartupProbe.cs | 25 +- docs/architecture/README.md | 12 +- docs/architecture/bench.md | 89 +- docs/architecture/charting.md | 115 +- docs/architecture/data-integration.md | 747 +++++++-- .../grafana-vs-build-evaluation.md | 2 +- docs/architecture/overview.md | 132 +- docs/architecture/postgres-topology.md | 23 +- docs/architecture/scada-archive-observed.md | 88 +- docs/architecture/sources.md | 2 + docs/architecture/testing-strategy.md | 112 +- docs/architecture/trend-feature-spec.md | 12 +- docs/architecture/trend-interaction.md | 33 +- docs/architecture/ui-text.md | 51 +- docs/architecture/ui-theme.md | 6 +- docs/plans/backlog.md | 9 +- .../completed/20261020-opc-live-source.md | 1368 +++++++++++++++++ readme.md | 14 +- 152 files changed, 9612 insertions(+), 2733 deletions(-) create mode 100644 ConfigFiles/opc/opc.yaml create mode 100644 SemiPlot/SemiPlot.Core/Configuration/ConnectionFile.cs create mode 100644 SemiPlot/SemiPlot.Core/Configuration/NetworkAddress.cs create mode 100644 SemiPlot/SemiPlot.Core/Data/Errors/LiveSourceError.cs create mode 100644 SemiPlot/SemiPlot.Core/Data/LiveSourceState.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/ClientApplicationConfiguration.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/Configuration/OpcUaConnectionLoader.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/Configuration/OpcUaConnectionSettings.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/ConnectBackoff.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/DeliveredStamps.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/EndpointChoice.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/LiveSessionStates.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/LiveValue.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaDataServiceCollectionExtensions.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaLiveSource.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaSession.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscription.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscriptionContext.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscriptionSet.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/ProjectFolder.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/ReportedUnservedSets.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/SemiPlot.DataSource.OpcUa.csproj create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/SerialLoop.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/ServerCalls.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/StackTelemetry.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/SubscriptionPlan.cs create mode 100644 SemiPlot/SemiPlot.DataSource.OpcUa/UnservedPens.cs delete mode 100644 SemiPlot/SemiPlot.DataSource.Postgres/PostgresLiveSource.cs delete mode 100644 SemiPlot/SemiPlot.DataSource.Postgres/RealtimePoll.cs delete mode 100644 SemiPlot/SemiPlot.Tests.Integration/ArmedGate.cs delete mode 100644 SemiPlot/SemiPlot.Tests.Integration/BatchCollector.cs create mode 100644 SemiPlot/SemiPlot.Tests.Integration/DemoScada/LogRecordLines.cs delete mode 100644 SemiPlot/SemiPlot.Tests.Integration/Journeys/LiveEdgeArchiveJourneyTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Integration/Journeys/OpcUaLiveEdgeJourneyTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Integration/OpcUa/LiveSourceHost.cs create mode 100644 SemiPlot/SemiPlot.Tests.Integration/OpcUa/LoopbackRelay.cs create mode 100644 SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaLiveSourceRestartTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaLiveSourceTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaSessionTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Integration/OpcUa/SampleRecorder.cs delete mode 100644 SemiPlot/SemiPlot.Tests.Integration/RealtimeEmptyArchiveTests.cs delete mode 100644 SemiPlot/SemiPlot.Tests.Integration/RealtimePollReadTests.cs delete mode 100644 SemiPlot/SemiPlot.Tests.Integration/RealtimePollTests.cs delete mode 100644 SemiPlot/SemiPlot.Tests.Integration/RealtimeSubscriptionDisposalTests.cs delete mode 100644 SemiPlot/SemiPlot.Tests.Integration/RealtimeSubscriptionTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Integration/StartupProbeOpcSectionTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/NetworkAddressTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcFileWriterTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/ClientApplicationConfigurationTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/ConnectBackoffTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/DeliveredStampsTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/EndpointChoiceTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/LiveSessionStatesTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/LiveValueTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/OpcUaCompositionTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/OpcUaConnectionLoaderTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/PenSubscriptionSetTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/PenSubscriptionTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/ProjectFolderTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/SubscriptionPlanTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/TestPenSubscriptionContext.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/OpcUa/UnservedPensTests.cs create mode 100644 SemiPlot/SemiPlot.Tools.ArchiveSeeder/OpcFileWriter.cs create mode 100644 docs/plans/completed/20261020-opc-live-source.md diff --git a/AGENTS.md b/AGENTS.md index 5e50876..f7abe9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,8 @@ dotnet format SemiPlot.slnx # pre-commit hook enforces this `converge` example below produce `SemiPlot/Artifacts/dev-config`, which is also where the `.zed/` and `.run/` launchers point `--config-dir`, and the password is then filled in the viewer's `Edit` -> `Settings`. -Configuration is a tree of section folders under `--config-dir`: `app/` and `connection/`. Every +Configuration is a tree of section folders under `--config-dir`: `app/`, `connection/` (the archive) +and `opc/` (the SCADA's OPC UA server, `host` and `port`). Every section folder is read whole — each `*.yaml` in it parsed on its own and merged at the key level — and a key carried by two files of one folder is a startup failure naming both. The set that ships is tracked at `ConfigFiles/` and gated by the production loaders in @@ -31,7 +32,8 @@ the tracked file (`docs/architecture/overview.md`). The settings window (`SemiPlot.UI/Settings`) is the viewer's only writer of the section folders. It edits only keys that already exist, each in the file that owns it, and only the keys the operator -changed; it creates no file, and the startup-failure window offers it only when both sections read and carry every key it edits. The +changed; it creates no file, and it edits no key of `opc/` yet. The startup-failure window offers it only +when both sections it edits, `app/` and `connection/`, read and carry every key it edits. The production loaders validate a staged copy before any file is replaced, and every change takes effect at the next start except the theme, which every running process applies live (`docs/architecture/overview.md#the-live-theme`). @@ -74,9 +76,11 @@ privilege on. Run it with `--help` for the option list. `converge` is a separate, bench-only subcommand: unlike the seeding run above, it does issue `DROP DATABASE ... WITH (FORCE)`. It waits for `--admin-connection` up to 60 s, recreates the database `--connection` names from `semiplot_provisioned`, seeds it up to `--end` or this machine's clock, -fills the catalogue and writes `connection/connection.yaml` into `--config-dir` with the bench -`semiplot` role's fixed password. That one file is all it writes, so run it against a directory that -already holds a copy of the tracked set (`docs/architecture/bench.md#the-converge-verb`): +fills the catalogue and writes two files into `--config-dir`: `connection/connection.yaml` with the +bench `semiplot` role's fixed password, and `opc/opc.yaml` with `host: 127.0.0.1` and the port its +required `--opc-port` names, the port the demo writer serves OPC UA on. These two files are all it +writes, so run it against a directory that already holds a copy of the tracked set +(`docs/architecture/bench.md#the-converge-verb`): ```powershell New-Item -ItemType Directory -Force SemiPlot\Artifacts\dev-config | Out-Null @@ -85,7 +89,7 @@ Copy-Item ConfigFiles\* SemiPlot\Artifacts\dev-config -Recurse -Force dotnet run --project SemiPlot/SemiPlot.Tools.ArchiveSeeder/SemiPlot.Tools.ArchiveSeeder.csproj -- converge ` --connection "Host=127.0.0.1;Port=55432;Database=semiplot_app;Username=scada_writer;Password=" ` --admin-connection "Host=127.0.0.1;Port=55432;Database=postgres;Username=postgres;Password=" ` - --config-dir SemiPlot\Artifacts\dev-config + --config-dir SemiPlot\Artifacts\dev-config --opc-port 55875 ``` The demo writer is the same seeder run with `--follow --opc-port ` instead of `--end`: @@ -114,8 +118,8 @@ Two projects, split on one axis: crosses a machine boundary (a socket or a conta | Project | Framework | References | Holds | | --- | --- | --- | --- | -| `SemiPlot.Tests.Unit` | xunit v3 + `Avalonia.Headless.XUnit` | `SemiPlot.UI`, `SemiPlot.Core`, `SemiPlot.DataSource.Postgres`, `SemiPlot.Tools.ArchiveSeeder` | Every test that opens no socket and starts no container: the UI, the Core models, the seeder's generators and the provider's pure classes | -| `SemiPlot.Tests.Integration` | xunit v3 + `Avalonia.Headless.XUnit` | the same four | The container harness, the container tests and the journeys in one xunit collection, the OPC UA demo server's tests in `DemoScadaCollection`, and the socket tests that need no server | +| `SemiPlot.Tests.Unit` | xunit v3 + `Avalonia.Headless.XUnit` | `SemiPlot.UI`, `SemiPlot.Core`, `SemiPlot.DataSource.Postgres`, `SemiPlot.DataSource.OpcUa`, `SemiPlot.Tools.ArchiveSeeder` | Every test that opens no socket and starts no container: the UI, the Core models, the seeder's generators and the providers' pure classes | +| `SemiPlot.Tests.Integration` | xunit v3 + `Avalonia.Headless.XUnit` | the same five | The container harness, the container tests and the archive journeys in one xunit collection, the OPC UA demo server's tests and the OPC UA live source's tests in `DemoScadaCollection`, and the socket tests that need no server | - Each project carries its own `TestAppBuilder.cs` with `[assembly: AvaloniaTestApplication]`. Pure logic uses plain `[Fact]`; tests touching ReactiveUI/ScottPlot/Avalonia use @@ -128,8 +132,8 @@ Two projects, split on one axis: crosses a machine boundary (a socket or a conta `git grep -nE "static void (Click|Clear|Type|Press)\(" -- SemiPlot/SemiPlot.Tests.Unit` prints only `HeadlessInput.cs` lines. A right click takes `Click`'s `MouseButton` overload. A hover, a drag or a held mouse button is outside the rule. -- Neither project references the other. Core, `SemiPlot.DataSource.Postgres` and `SemiPlot.UI` each - name both in `InternalsVisibleTo`. +- Neither project references the other. Core, `SemiPlot.DataSource.Postgres`, `SemiPlot.DataSource.OpcUa` + and `SemiPlot.UI` each name both in `InternalsVisibleTo`. - `SemiPlot.Tests.Unit` sets `failSkips` in `xunit.runner.json`, so no gated test may live there. `SemiPlot.Tests.Integration` carries no `xunit.runner.json`. - An xunit v3 test project is an executable: a hung test leaves `SemiPlot.Tests.Unit.exe` locked and @@ -151,6 +155,10 @@ Two projects, split on one axis: crosses a machine boundary (a socket or a conta and the view's `RequestAnimationFrame` throws there; it passes a `TestScheduler` or `AvaloniaScheduler.Instance` (`docs/architecture/testing-strategy.md#the-ui-scheduler-in-a-realised-view`). +- A test that disposes a container holding `OpcUaLiveSource` awaits `DisposeAsync()`. The synchronous + `OpcUaLiveSource.Dispose` runs the close through `Task.Run(...).Wait` with a 3 s bound, which a starved + pool under a full run outlasts; `OpcUaSessionTests.DisposeOnAConnectedSource_UnderAContextThatDropsEveryPost_ReturnsWithinItsBound` + alone covers that path. - The chart view redraws on an animation frame. A test drives one frame as `Dispatcher.UIThread.RunJobs(); AvaloniaHeadlessPlatform.ForceRenderTimerTick(); Dispatcher.UIThread.RunJobs();` (`ChartViewTestBuilder.DriveOneFrame`) and counts frames through `TrendChartView.ServedFrameCount`, @@ -166,7 +174,8 @@ Two projects, split on one axis: crosses a machine boundary (a socket or a conta ```powershell dotnet test SemiPlot.slnx # both projects dotnet test SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj # no container needed -dotnet test SemiPlot/SemiPlot.Tests.Integration/SemiPlot.Tests.Integration.csproj # container required +dotnet test SemiPlot/SemiPlot.Tests.Integration/SemiPlot.Tests.Integration.csproj # archive tests need a container +dotnet test SemiPlot/SemiPlot.Tests.Integration/SemiPlot.Tests.Integration.csproj --filter "FullyQualifiedName~OpcUa" # no container needed dotnet test SemiPlot.slnx --filter "Area=Data" dotnet test SemiPlot.slnx --filter "FullyQualifiedName~TestMethodName" ``` @@ -189,10 +198,13 @@ Frame cost during a drag is measured, not asserted: `docs/architecture/testing-s ### Container tests Every test of the `ArchiveDatabaseCollection` in `SemiPlot.Tests.Integration` needs a container runtime and -nothing else. The socket classes (`RealtimePollTests`, `CatalogueReadUnreachableTests`, -`RealtimeSubscriptionDisposalTests`, `ConvergeReadinessTests`) carry no collection, take no fixture and run without a runtime. -The OPC UA demo server's classes under `DemoScada/` run in `DemoScadaCollection`, which carries no -fixture, and need no runtime either. +nothing else. The socket classes (`CatalogueReadUnreachableTests`, `ConvergeReadinessTests`, +`StartupProbeOpcSectionTests`) carry no collection, take no fixture and run without a runtime. +The OPC UA demo server's classes under `DemoScada/`, the live source's classes under `OpcUa/` +(`OpcUaSessionTests`, `OpcUaLiveSourceTests`, `OpcUaLiveSourceRestartTests`) and +`Journeys/OpcUaLiveEdgeJourneyTests` run in `DemoScadaCollection`, which carries no fixture, and need +no runtime either. Each starts the demo server through `DemoScadaHost` on a free port, never 4840 or +49875: `git grep -n "49875" -- SemiPlot/SemiPlot.Tests.Integration/OpcUa` prints nothing. `SemiPlot/bench/Dockerfile` copies `/semibase` out of `ghcr.io/semiteq/semibase:latest`, which the fixture pulls ahead of the build, and runs `semibase bench` from `/docker-entrypoint-initdb.d/` before the published port opens. The container @@ -265,7 +277,12 @@ No abbreviations in names. - Constructor injection only (primary constructors preferred). No property injection, no service locator. - Register services in extension methods, each named for what it registers: `AddPostgresData()` in - `SemiPlot.DataSource.Postgres`, `AddUi()` in `SemiPlot.UI`. A data-source project names its own + `SemiPlot.DataSource.Postgres`, `AddOpcUaLiveSource()` in `SemiPlot.DataSource.OpcUa`, `AddUi()` in + `SemiPlot.UI`. `StartupProbe.BuildArchiveServiceProvider` calls them in that order. + `AddOpcUaLiveSource()` registers `OpcUaLiveSource` as the singleton `ILiveSource`, and a logging filter + that raises `Opc.Ua.Bindings.UaSCUaBinaryClientChannel` to `Critical`: that category writes one `Error` + per failed connect attempt while the server is down, and the source's own state line already says it. + A data-source project names its own source rather than a bare `AddData()`, so a composition root referencing several `SemiPlot.DataSource.*` projects names the one it registers. Core registers nothing. - Avoid mutable static state. @@ -273,7 +290,10 @@ No abbreviations in names. `MainWindow/TrendWindow.Build` and disposed by `TrendWindow.Dispose`: never registered, never replaced. `MainWindowViewModel` disposes only its own commands and subjects (`docs/architecture/overview.md#one-window-per-process`). -- `AddPostgresData()` registers the bare data `IScheduler` (`DefaultScheduler.Instance`). The UI +- `AddPostgresData()` registers the bare data `IScheduler` (`DefaultScheduler.Instance`). + `AddOpcUaLiveSource()` does not read that registration and passes the source no scheduler: the source + reads `DefaultScheduler.Instance` itself for its grace, settle and create-retry timers, and the connect + loop waits with `Task.Delay`. The UI scheduler is not a second container registration: `App` reads the static `AvaloniaScheduler.Instance` and passes it explicitly to `TrendWindow.Build`, which hands it to the coordinator, `MarkerSync`, the chart, the minimap, `PenCatalogueSync` and the `CsvExportViewModel` @@ -381,13 +401,19 @@ No abbreviations in names. readout's Δy (`FormatDifference`) all go through it (`docs/architecture/ui-text.md`). - Every failure the operator should see goes to `Messages/MessagePanelViewModel` through `Messages/ArchiveFailureMapper.Map`, which assigns the severity in its own per-kind switch — never - at the call site. The one message with no error behind it is - `AppStatusBarViewModel.ConnectionRestored`, which builds its own `Info` view because the mapper maps - errors. A `catch`, or an Rx `onError`, that only logs is a defect + at the call site. The two messages with no error behind them are + `AppStatusBarViewModel.ConnectionRestored`, the archive indicator's recovery entry, and + `AppStatusBarViewModel.LiveConnectionRestored`, the OPC UA indicator's recovery entry; each builds its + own `Info` view because the mapper maps errors. A `catch`, or an Rx `onError`, that only logs is a defect (`docs/architecture/data-integration.md#no-failure-stops-at-the-log`). A failure for lack of a - connection (`ArchiveFault.Unreachable`) in the catalogue loop, the marker loop or the live-edge poll is + connection (`ArchiveFault.Unreachable`) in the catalogue loop or the marker loop is logged by that reader and reaches the panel through the status bar's one `ConnectionLost` entry, which - the catalogue reads raise; the three cases that reach only the log are listed in that same section. + the catalogue reads raise; the cases that reach only the log are listed in that same section. The + OPC UA live source has an indicator of its own: `AppStatusBarViewModel` follows + `TrendCoordinator.LiveSourceStates`, and each `LiveSourceState` fault becomes one panel entry through + `ArchiveFailureMapper`. A pen fault (`LiveSourceFault.PensUnserved`) reaches the panel from + `TrendCoordinator.LivePenFaults` through `ResultReporting.TryReportFailure` + (`docs/architecture/data-integration.md#the-session-state-the-live-source-reports`). Code-behind reaches the panel through its view model (`TrendChartViewModel.ReportFailure`, `MainWindowViewModel.ReportFailure`, `PenEditorViewModel.ReportFailure`, `StartupFailureViewModel.ReportFailure`, @@ -397,10 +423,20 @@ No abbreviations in names. ### Data-source projects - `IDataProvider` + its DTOs stay in `SemiPlot.Core`; every concrete provider lives in its own - `SemiPlot.DataSource.*` project (`SemiPlot.DataSource.Postgres` is the only one). Core must not + `SemiPlot.DataSource.*` project (`SemiPlot.DataSource.Postgres` for the archive). Core must not reference a data-source project; a further provider slots in as a sibling without touching Core. - `ILiveSource` lives in `SemiPlot.Core/Data` beside `IDataProvider`, and `AddPostgresData()` registers - `PostgresLiveSource` over the same `NpgsqlDataSource` (`docs/architecture/data-integration.md#realtime`). +- The live edge reads the SCADA's OPC UA server, never the archive `[DEC:opc-live-edge]`. `ILiveSource` + lives in `SemiPlot.Core/Data` beside `IDataProvider` with three members: `Subscribe(penIds)`, `States` + (an `IObservable`, `SemiPlot.Core/Data`) and `PenFaults` (an + `IObservable`, `SemiPlot.Core/Data/Errors`, so `FailureSeverityTests` covers its + kinds). `OpcUaLiveSource` in `SemiPlot.DataSource.OpcUa` is the one implementation, one session per + process started by the first `Subscribe`, configured from the `opc/` section + (`docs/architecture/data-integration.md#realtime`). The source writes a break for a lost session and + for a `Bad` notification; the break ahead of a pen's first live sample is the chart's, in + `Chart/TrendPenState`, and never the source's + (`docs/architecture/data-integration.md#the-break-ahead-of-a-first-live-sample`). Only the + coordinator and the window hold the port, and this prints only `Bridge/TrendCoordinator.cs` and + `MainWindow/TrendWindow.cs` lines: `git grep -n "ILiveSource" -- SemiPlot/SemiPlot.UI`. - Each write path is an interface of its own and `IDataProvider` stays read-only. `IPenCatalogueEditor`, `PenCatalogue` and `PenSettingChange` live in `SemiPlot.Core/Data`; `PostgresPenCatalogueEditor` lives in `SemiPlot.DataSource.Postgres`, which `AddPostgresData()` registers beside the provider over the @@ -417,6 +453,9 @@ No abbreviations in names. archive's wall-clock time and, with `IDataProvider.QueryArchiveDaysAsync`, is one of the two reads that skip `ArchiveTimeConverter` (`docs/architecture/data-integration.md#time-boundary`). Only the export window reads it, and this prints nothing: `git grep -l "IArchiveRowReader" -- SemiPlot/SemiPlot.UI/Chart SemiPlot/SemiPlot.UI/Legend`. + The live source skips the converter as well: a sample's stamp is the notification's `SourceTimestamp`, + true UTC on the SCADA, and the OPC project touches no zone API, so this prints nothing: + `git grep -n -E "ArchiveTimeConverter|TimeZoneInfo|ToLocalTime|ToUniversalTime" -- SemiPlot/SemiPlot.DataSource.OpcUa`. - No startup code calls `IPenCatalogueEditor`. The container constructs it when `TrendWindow.Build` resolves it, and its constructor issues no statement. `RegisterNewPensAsync` has one caller, `PenEditorViewModel`: @@ -443,10 +482,10 @@ No abbreviations in names. the configuration tables, the two roles and their grants are SemiBase's; the seeder fills the archive table and the catalogue, and creates only the day partitions its rows land in (`docs/architecture/bench.md`). -- The provider and the live source run no cold-path reader: a failed read is mapped by +- The provider runs no cold-path reader: a failed read is mapped by `ArchiveExceptionMapper`, which stays synchronous, pure and unit-testable, and nothing opens a second connection to enrich the error. Each read supplies every relation its statement touches, which the - detail line names: one relation for the history and realtime reads, `semiplot_tags, trends` for the + detail line names: `trends` for the history read, `semiplot_tags, trends` for the archive extent and the archive days, all three of `semiplot_tags`, `semiplot_groups` and `semiplot_pen_groups` for the pen catalogue, and `semiplot_markers` for every marker read and write. diff --git a/ConfigFiles/connection/connection.yaml b/ConfigFiles/connection/connection.yaml index b0cfae5..eb24935 100644 --- a/ConfigFiles/connection/connection.yaml +++ b/ConfigFiles/connection/connection.yaml @@ -3,4 +3,3 @@ port: 5432 database: semiplot user: semiplot password: "" -poll_interval_ms: 1000 diff --git a/ConfigFiles/opc/opc.yaml b/ConfigFiles/opc/opc.yaml new file mode 100644 index 0000000..5ed32ee --- /dev/null +++ b/ConfigFiles/opc/opc.yaml @@ -0,0 +1,2 @@ +host: 127.0.0.1 +port: 49875 diff --git a/SemiPlot.slnx b/SemiPlot.slnx index af23ca0..0b9ef3d 100644 --- a/SemiPlot.slnx +++ b/SemiPlot.slnx @@ -1,6 +1,7 @@ + diff --git a/SemiPlot/SemiPlot.AppHost/AppHost.cs b/SemiPlot/SemiPlot.AppHost/AppHost.cs index c76f740..277a4e2 100644 --- a/SemiPlot/SemiPlot.AppHost/AppHost.cs +++ b/SemiPlot/SemiPlot.AppHost/AppHost.cs @@ -21,6 +21,8 @@ const string ChangeSeconds = "0.5"; // A running Simple-Scada holds 49875, so the stand serves OPC UA on a port of its own. const string OpcPort = "55875"; +// The archive trails the OPC UA stream as the SCADA's buffered archive does, so the stand shows that span. +const string ArchiveLagSeconds = "60"; // AppHostDirectory is SemiPlot/SemiPlot.AppHost, so the tracked set sits two levels above it. var trackedConfiguration = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "ConfigFiles")); @@ -46,7 +48,8 @@ "--connection", writerConnection, "--admin-connection", adminConnection, "--config-dir", configDirectory, - "--change-seconds", ChangeSeconds) + "--change-seconds", ChangeSeconds, + "--opc-port", OpcPort) .WaitFor(bench); builder.AddProject("writer") @@ -54,7 +57,8 @@ "--connection", writerConnection, "--follow", "1", "--change-seconds", ChangeSeconds, - "--opc-port", OpcPort) + "--opc-port", OpcPort, + "--archive-lag", ArchiveLagSeconds) .WaitForCompletion(converge); builder.AddProject("viewer") diff --git a/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionError.cs b/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionError.cs index 7e50089..15ce0c4 100644 --- a/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionError.cs +++ b/SemiPlot/SemiPlot.Core/Configuration/ConfigurationSectionError.cs @@ -6,7 +6,8 @@ namespace SemiPlot.Core.Configuration; public enum ConfigurationSectionName { App, - Connection + Connection, + Opc } public enum SectionProblem diff --git a/SemiPlot/SemiPlot.Core/Configuration/ConnectionFile.cs b/SemiPlot/SemiPlot.Core/Configuration/ConnectionFile.cs new file mode 100644 index 0000000..a37ac9d --- /dev/null +++ b/SemiPlot/SemiPlot.Core/Configuration/ConnectionFile.cs @@ -0,0 +1,96 @@ +using FluentResults; + +using SemiPlot.Core.Data.Errors; + +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace SemiPlot.Core.Configuration; + +/// The parse and the field checks every connection section loader shares. +public static class ConnectionFile +{ + public const string HostKey = "host"; + + public const string PortKey = "port"; + + private static readonly IDeserializer _deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + // The error carries no text of the exception: a parser message embeds the offending scalar, and a password + // is a scalar. The raw detail rides on CausedBy, where only the log reads it. + public static Result Deserialize( + ConfigurationSectionName section, + string sectionDirectory, + string content) + where TDto : class, new() + { + try + { + return Result.Ok(_deserializer.Deserialize(content) ?? new TDto()); + } + catch (Exception exception) + { + var error = new ConnectionFileError(section, sectionDirectory, ConnectionFileProblem.Unparseable, []); + + return Result.Fail(error.CausedBy(new ExceptionalError(exception))); + } + } + + /// + /// Every blank text and an absent port as one missing-field error, the host first and the port last; then the + /// port's range; then the host's IPv4 form. + /// + public static Result Validate( + ConfigurationSectionName section, + string sectionDirectory, + string? host, + int? port, + IReadOnlyList<(string Key, string? Value)> otherTexts) + { + var missing = new List(); + + if (string.IsNullOrWhiteSpace(host)) + { + missing.Add(HostKey); + } + + foreach (var (key, value) in otherTexts) + { + if (string.IsNullOrWhiteSpace(value)) + { + missing.Add(key); + } + } + + if (port is null) + { + missing.Add(PortKey); + } + + if (missing.Count > 0) + { + return Invalid(section, sectionDirectory, ConnectionFileProblem.MissingField, missing); + } + + if (port is < NetworkAddress.LowestPort or > NetworkAddress.HighestPort) + { + return Invalid(section, sectionDirectory, ConnectionFileProblem.OutOfRange, [PortKey]); + } + + return NetworkAddress.IsIPv4(host) + ? Result.Ok() + : Invalid(section, sectionDirectory, ConnectionFileProblem.HostNotIPv4, [HostKey]); + } + + private static Result Invalid( + ConfigurationSectionName section, + string sectionDirectory, + ConnectionFileProblem kind, + IReadOnlyList fieldNames) + { + return Result.Fail(new ConnectionFileError(section, sectionDirectory, kind, fieldNames)); + } +} diff --git a/SemiPlot/SemiPlot.Core/Configuration/NetworkAddress.cs b/SemiPlot/SemiPlot.Core/Configuration/NetworkAddress.cs new file mode 100644 index 0000000..9ccb32d --- /dev/null +++ b/SemiPlot/SemiPlot.Core/Configuration/NetworkAddress.cs @@ -0,0 +1,36 @@ +using System.Globalization; + +namespace SemiPlot.Core.Configuration; + +/// The host and port rules every connection section shares. +public static class NetworkAddress +{ + public const int LowestPort = 1; + + public const int HighestPort = 65535; + + private const int HighestOctet = 255; + + /// + /// Four dot-separated decimal octets from 0 to 255 with no leading zero, so no shortened or octal form. + /// + public static bool IsIPv4(string? text) + { + if (text is null) + { + return false; + } + + var octets = text.Split('.'); + + return octets.Length == 4 && octets.All(IsOctet); + } + + private static bool IsOctet(string octet) + { + return octet.Length is >= 1 and <= 3 + && octet.All(char.IsAsciiDigit) + && (octet.Length == 1 || octet[0] != '0') + && int.Parse(octet, CultureInfo.InvariantCulture) <= HighestOctet; + } +} diff --git a/SemiPlot/SemiPlot.Core/Data/Errors/ConnectionFileError.cs b/SemiPlot/SemiPlot.Core/Data/Errors/ConnectionFileError.cs index 0bbfc34..3142d1f 100644 --- a/SemiPlot/SemiPlot.Core/Data/Errors/ConnectionFileError.cs +++ b/SemiPlot/SemiPlot.Core/Data/Errors/ConnectionFileError.cs @@ -1,5 +1,7 @@ using FluentResults; +using SemiPlot.Core.Configuration; + namespace SemiPlot.Core.Data.Errors; public enum ConnectionFileProblem @@ -11,21 +13,31 @@ public enum ConnectionFileProblem } /// -/// The connection section could not be turned into settings. is what the operator's +/// A connection section could not be turned into settings. is what the operator's /// remedy routes on; names the offending keys, never the section's own values. /// -public sealed class ConnectionFileError(string path, ConnectionFileProblem kind, IReadOnlyList fields) - : Error(Describe(path, kind, fields)) +public sealed class ConnectionFileError( + ConfigurationSectionName section, + string path, + ConnectionFileProblem kind, + IReadOnlyList fields) + : Error(Describe(section, path, kind, fields)) { + public ConfigurationSectionName Section { get; } = section; + public string Path { get; } = path; public ConnectionFileProblem Kind { get; } = kind; public IReadOnlyList Fields { get; } = fields; - private static string Describe(string path, ConnectionFileProblem kind, IReadOnlyList fields) + private static string Describe( + ConfigurationSectionName section, + string path, + ConnectionFileProblem kind, + IReadOnlyList fields) { - var message = $"The connection folder '{path}' is invalid ({kind})"; + var message = $"The {section} configuration folder '{path}' is invalid ({kind})"; return fields.Count > 0 ? $"{message}; fields: {string.Join(", ", fields)}" : message; } } diff --git a/SemiPlot/SemiPlot.Core/Data/Errors/LiveSourceError.cs b/SemiPlot/SemiPlot.Core/Data/Errors/LiveSourceError.cs new file mode 100644 index 0000000..0b5fc7c --- /dev/null +++ b/SemiPlot/SemiPlot.Core/Data/Errors/LiveSourceError.cs @@ -0,0 +1,69 @@ +using FluentResults; + +namespace SemiPlot.Core.Data.Errors; + +/// What went wrong with the live source, as the operator needs it routed: one remedy per member. +public enum LiveSourceFault +{ + /// No session: the server does not answer, or the session it held was lost. + Unreachable, + + /// The server answers but offers no endpoint without security and with anonymous access. + EndpointRefused, + + /// The server answers but holds no project folder to read the pens from. + ProjectMissing, + + /// The server does not know some pens, or serves a value that is not a number for them. + PensUnserved +} + +/// +/// Every failure the live source can report, keyed by ; consumers route on and +/// never on . +/// +public sealed class LiveSourceError( + LiveSourceFault kind, + string endpoint, + IReadOnlyList? unknownPenIds = null, + IReadOnlyList? notNumericPenIds = null) + : Error(Describe(kind, endpoint, unknownPenIds ?? [], notNumericPenIds ?? [])) +{ + public LiveSourceFault Kind { get; } = kind; + + public string Endpoint { get; } = endpoint; + + public IReadOnlyList UnknownPenIds { get; } = unknownPenIds ?? []; + + public IReadOnlyList NotNumericPenIds { get; } = notNumericPenIds ?? []; + + private static string Describe( + LiveSourceFault kind, + string endpoint, + IReadOnlyList unknownPenIds, + IReadOnlyList notNumericPenIds) + { + return kind switch + { + LiveSourceFault.Unreachable => $"No OPC UA session to {endpoint}.", + LiveSourceFault.EndpointRefused => + $"The OPC UA server at {endpoint} offers no endpoint with security None and anonymous access.", + LiveSourceFault.ProjectMissing => $"The OPC UA server at {endpoint} holds no project folder.", + LiveSourceFault.PensUnserved => $"The OPC UA server at {endpoint} does not serve every pen." + + PenGroups(unknownPenIds, notNumericPenIds), + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; + } + + private static string PenGroups(IReadOnlyList unknownPenIds, IReadOnlyList notNumericPenIds) + { + var unknown = unknownPenIds.Count > 0 + ? FormattableString.Invariant($" Unknown pen ids: {string.Join(", ", unknownPenIds)}.") + : ""; + var notNumeric = notNumericPenIds.Count > 0 + ? FormattableString.Invariant($" Non-numeric pen ids: {string.Join(", ", notNumericPenIds)}.") + : ""; + + return unknown + notNumeric; + } +} diff --git a/SemiPlot/SemiPlot.Core/Data/ILiveSource.cs b/SemiPlot/SemiPlot.Core/Data/ILiveSource.cs index f901149..10f1b98 100644 --- a/SemiPlot/SemiPlot.Core/Data/ILiveSource.cs +++ b/SemiPlot/SemiPlot.Core/Data/ILiveSource.cs @@ -1,9 +1,16 @@ +using SemiPlot.Core.Data.Errors; using SemiPlot.Core.Trends; namespace SemiPlot.Core.Data; -/// The live edge of a pen set (docs/architecture/data-integration.md#realtime). +/// The live edge of a pen set and its session (docs/architecture/data-integration.md#realtime). public interface ILiveSource { IObservable> Subscribe(IReadOnlyList penIds); + + /// Hot, replays the current state to each subscriber, never faults or completes before disposal. + IObservable States { get; } + + /// Hot, one error per pen fault, never faults or completes before disposal. + IObservable PenFaults { get; } } diff --git a/SemiPlot/SemiPlot.Core/Data/LiveSourceState.cs b/SemiPlot/SemiPlot.Core/Data/LiveSourceState.cs new file mode 100644 index 0000000..f66a110 --- /dev/null +++ b/SemiPlot/SemiPlot.Core/Data/LiveSourceState.cs @@ -0,0 +1,37 @@ +using SemiPlot.Core.Data.Errors; + +namespace SemiPlot.Core.Data; + +/// Whether the live source's session is up, and the fault that keeps it down when one is known. +public sealed record LiveSourceState +{ + public static readonly LiveSourceState Connecting = new(isConnected: false, fault: null); + + public static readonly LiveSourceState Connected = new(isConnected: true, fault: null); + + private LiveSourceState(bool isConnected, LiveSourceError? fault) + { + IsConnected = isConnected; + Fault = fault; + } + + public bool IsConnected { get; } + + public LiveSourceError? Fault { get; } + + public static LiveSourceState FaultedBy(LiveSourceError fault) + { + return new LiveSourceState(isConnected: false, fault); + } + + /// The state's name for a log line: Connected, Connecting or the fault's kind. + public override string ToString() + { + if (IsConnected) + { + return nameof(Connected); + } + + return Fault?.Kind.ToString() ?? nameof(Connecting); + } +} diff --git a/SemiPlot/SemiPlot.Core/Trends/Sample.cs b/SemiPlot/SemiPlot.Core/Trends/Sample.cs index 044a3dd..bacfacd 100644 --- a/SemiPlot/SemiPlot.Core/Trends/Sample.cs +++ b/SemiPlot/SemiPlot.Core/Trends/Sample.cs @@ -1,4 +1,8 @@ namespace SemiPlot.Core.Trends; /// A null is a break for the pen from this moment until its next sample. -public sealed record Sample(int PenId, DateTime TimestampUtc, double? Value); +public sealed record Sample(int PenId, DateTime TimestampUtc, double? Value) +{ + /// The time axis' smallest step: a break placed beside a known stamp sits this far from it. + public static readonly TimeSpan BreakStep = TimeSpan.FromMilliseconds(1); +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/ClientApplicationConfiguration.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/ClientApplicationConfiguration.cs new file mode 100644 index 0000000..87e2afc --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/ClientApplicationConfiguration.cs @@ -0,0 +1,68 @@ +using Opc.Ua; + +using SemiPlot.DataSource.OpcUa.Configuration; + +namespace SemiPlot.DataSource.OpcUa; + +/// +/// The client's validated application configuration. Its certificate stores sit under the settings' store root +/// and stay empty: no application certificate is loaded or created +/// (docs/architecture/data-integration.md#the-opc-ua-live-source). +/// +internal static class ClientApplicationConfiguration +{ + public const string ApplicationName = "SemiPlot"; + + public const string ApplicationUri = "urn:localhost:SemiPlot"; + + private const int OperationTimeoutMilliseconds = 15000; + + public const int SessionTimeoutMilliseconds = 60000; + + public static async Task CreateValidatedAsync( + OpcUaConnectionSettings settings, + ITelemetryContext telemetry, + CancellationToken token) + { + var configuration = Create(settings.CertificateStoreRoot, telemetry); + await configuration.ValidateAsync(ApplicationType.Client, token).ConfigureAwait(false); + + return configuration; + } + + private static ApplicationConfiguration Create(string storeRoot, ITelemetryContext telemetry) + { + return new ApplicationConfiguration(telemetry) + { + ApplicationName = ApplicationName, + ApplicationUri = ApplicationUri, + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(storeRoot, "own"), + SubjectName = $"CN={ApplicationName}", + }, + TrustedIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(storeRoot, "issuer"), + }, + TrustedPeerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(storeRoot, "trusted"), + }, + RejectedCertificateStore = new CertificateStoreIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(storeRoot, "rejected"), + }, + }, + TransportQuotas = new TransportQuotas { OperationTimeout = OperationTimeoutMilliseconds }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = SessionTimeoutMilliseconds }, + }; + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/Configuration/OpcUaConnectionLoader.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/Configuration/OpcUaConnectionLoader.cs new file mode 100644 index 0000000..b90387c --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/Configuration/OpcUaConnectionLoader.cs @@ -0,0 +1,60 @@ +using FluentResults; + +using SemiPlot.Core.Configuration; +using SemiPlot.Core.Data.Errors; + +namespace SemiPlot.DataSource.OpcUa.Configuration; + +/// Every member nullable so an absent field reaches the loader as a state to report. +internal sealed class OpcUaConnectionDto +{ + public string? Host { get; set; } + + public int? Port { get; set; } +} + +/// +/// Reads the opc/ section folder into . Every failure is a +/// or a of the Opc section in +/// the result; nothing escapes as an exception, and keys the format does not name are ignored. +/// +public static class OpcUaConnectionLoader +{ + public const string HostKey = ConnectionFile.HostKey; + + public const string PortKey = ConnectionFile.PortKey; + + public const string CertificateStoreDirectoryName = "pki"; + + private const ConfigurationSectionName Section = ConfigurationSectionName.Opc; + + public static Result Load(string sectionDirectory) + { + var section = ConfigurationSection.Read(sectionDirectory, Section); + + if (section.IsFailed) + { + return Result.Fail(section.Errors); + } + + var read = ConnectionFile.Deserialize(Section, sectionDirectory, section.Value); + + if (read.IsFailed) + { + return Result.Fail(read.Errors); + } + + var dto = read.Value; + var checks = ConnectionFile.Validate(Section, sectionDirectory, dto.Host, dto.Port, []); + + if (checks.IsFailed) + { + return Result.Fail(checks.Errors); + } + + return Result.Ok(new OpcUaConnectionSettings( + dto.Host!, + dto.Port!.Value, + Path.Combine(sectionDirectory, CertificateStoreDirectoryName))); + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/Configuration/OpcUaConnectionSettings.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/Configuration/OpcUaConnectionSettings.cs new file mode 100644 index 0000000..1874537 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/Configuration/OpcUaConnectionSettings.cs @@ -0,0 +1,7 @@ +namespace SemiPlot.DataSource.OpcUa.Configuration; + +/// The SCADA's OPC UA server and the folder the client's certificate stores sit under. +public sealed record OpcUaConnectionSettings(string Host, int Port, string CertificateStoreRoot) +{ + public string EndpointUrl => $"opc.tcp://{Host}:{Port}"; +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/ConnectBackoff.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/ConnectBackoff.cs new file mode 100644 index 0000000..dcdf39c --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/ConnectBackoff.cs @@ -0,0 +1,45 @@ +namespace SemiPlot.DataSource.OpcUa; + +/// How one pass of the connect loop ended. +internal enum ConnectPassEnd +{ + /// No session ran: no server answered, the browse failed or the pass threw. + Unanswered, + + /// The server answered without a usable endpoint or project folder. + SetupFault, + + /// A session ran and ended without the source closing it. + SessionEnded +} + +/// +/// The wait before the next pass; a setup fault waits for the operator's fix in the SCADA +/// (docs/architecture/data-integration.md#the-opc-ua-live-source). +/// +internal sealed class ConnectBackoff +{ + private static readonly TimeSpan _firstRetry = TimeSpan.FromSeconds(1); + + private static readonly TimeSpan _preSessionCap = TimeSpan.FromSeconds(5); + + private static readonly TimeSpan _setupFaultRetry = TimeSpan.FromSeconds(30); + + private TimeSpan _nextUnanswered = _firstRetry; + + public TimeSpan Next(ConnectPassEnd end) + { + if (end != ConnectPassEnd.Unanswered) + { + _nextUnanswered = _firstRetry; + + return end == ConnectPassEnd.SetupFault ? _setupFaultRetry : _firstRetry; + } + + var wait = _nextUnanswered; + var doubled = _nextUnanswered * 2; + _nextUnanswered = doubled < _preSessionCap ? doubled : _preSessionCap; + + return wait; + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/DeliveredStamps.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/DeliveredStamps.cs new file mode 100644 index 0000000..58d65a6 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/DeliveredStamps.cs @@ -0,0 +1,70 @@ +using SemiPlot.Core.Trends; + +namespace SemiPlot.DataSource.OpcUa; + +/// +/// The newest delivered value stamp per pen for the process, across subscriptions, and the breaks it places: +/// past that stamp, never two in a row for one pen; only the source's loop calls it +/// (docs/architecture/data-integration.md#the-breaks-the-source-writes). +/// +internal sealed class DeliveredStamps +{ + private readonly Dictionary _pens = []; + + public void RecordValue(int penId, DateTime stampUtc) + { + var newest = _pens.GetValueOrDefault(penId).NewestUtc is { } known && known > stampUtc ? known : stampUtc; + _pens[penId] = new PenStamp(newest, EndsOnBreak: false); + } + + /// The break a Bad notification writes, or null when there is nothing to place it after. + public Sample? BadBreak(int penId, DateTime? stampUtc) + { + var pen = _pens.GetValueOrDefault(penId); + + if (pen.EndsOnBreak) + { + return null; + } + + var floor = pen.NewestUtc + Sample.BreakStep; + var breakUtc = (stampUtc, floor) switch + { + ({ } stamp, { } least) => stamp > least ? stamp : least, + ({ } stamp, null) => stamp, + (null, { } least) => least, + _ => (DateTime?)null + }; + + if (breakUtc is null) + { + return null; + } + + _pens[penId] = pen with { EndsOnBreak = true }; + + return new Sample(penId, breakUtc.Value, null); + } + + /// One break per pen with a delivered value whose last emission is not already a break. + public IReadOnlyList LossBreaks(IEnumerable penIds) + { + var breaks = new List(); + + foreach (var penId in penIds) + { + if (!_pens.TryGetValue(penId, out var pen) || pen.EndsOnBreak || pen.NewestUtc is not { } newest) + { + continue; + } + + _pens[penId] = pen with { EndsOnBreak = true }; + breaks.Add(new Sample(penId, newest + Sample.BreakStep, null)); + } + + return breaks; + } + + /// A pen that only ever read Bad has no newest value stamp. + private readonly record struct PenStamp(DateTime? NewestUtc, bool EndsOnBreak); +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/EndpointChoice.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/EndpointChoice.cs new file mode 100644 index 0000000..c84ab0c --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/EndpointChoice.cs @@ -0,0 +1,43 @@ +using Opc.Ua; + +namespace SemiPlot.DataSource.OpcUa; + +/// +/// Picks the server's endpoint without security that admits an anonymous user, at the configured address. +/// +internal static class EndpointChoice +{ + /// + /// A copy of the advertised endpoint with the server's token policies and the configured URL, so no connect + /// dials the host name the server advertises. + /// + public static EndpointDescription? Pick(IEnumerable endpoints, string configuredUrl) + { + var unsecured = endpoints.FirstOrDefault(endpoint => endpoint.SecurityMode == MessageSecurityMode.None + && endpoint.SecurityPolicyUri == SecurityPolicies.None + && AdmitsAnonymous(endpoint)); + + if (unsecured is null) + { + return null; + } + + var picked = (EndpointDescription)unsecured.Clone(); + picked.EndpointUrl = configuredUrl; + + return picked; + } + + private static bool AdmitsAnonymous(EndpointDescription endpoint) + { + foreach (var token in endpoint.UserIdentityTokens) + { + if (token.TokenType == UserTokenType.Anonymous) + { + return true; + } + } + + return false; + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/LiveSessionStates.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/LiveSessionStates.cs new file mode 100644 index 0000000..fff8479 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/LiveSessionStates.cs @@ -0,0 +1,102 @@ +using Opc.Ua; +using Opc.Ua.Client; + +using SemiPlot.Core.Data; +using SemiPlot.Core.Data.Errors; + +namespace SemiPlot.DataSource.OpcUa; + +/// +/// What one input changes: the state to publish, null when it stays, and whether the session was just lost, which +/// makes every open subscription emit its loss breaks. +/// +internal readonly record struct LiveSessionChange(LiveSourceState? Published, bool IsLoss) +{ + public static readonly LiveSessionChange None = new(null, false); +} + +/// +/// The source's session state as a pure function of the stack's transitions, the connect loop's outcomes and the +/// grace timer. Only the source's loop calls it, so it holds no lock. +/// +internal sealed class LiveSessionStates(string endpoint) +{ + private NodeId _sessionId = NodeId.Null; + private LiveSourceState _current = LiveSourceState.Connecting; + + /// True for a first connect or a recreated session, whose project folder is browsed again. + public bool IsNewSession(NodeId sessionId) + { + var isNew = sessionId != _sessionId; + _sessionId = sessionId; + + return isNew; + } + + public LiveSessionChange OnStateChanged(ConnectionState next) + { + if (next == ConnectionState.Connected) + { + return new LiveSessionChange(Publish(LiveSourceState.Connected), IsLoss: false); + } + + if (!_current.IsConnected) + { + return LiveSessionChange.None; + } + + return new LiveSessionChange(Publish(Unreachable()), IsLoss: true); + } + + /// The grace after the loop's start: the fault, once, only when nothing was published before it. + public LiveSessionChange OnGraceElapsed() + { + if (_current != LiveSourceState.Connecting) + { + return LiveSessionChange.None; + } + + return new LiveSessionChange(Publish(Unreachable()), IsLoss: false); + } + + /// A server without a usable endpoint or project folder; the same fault again publishes nothing. + public LiveSessionChange OnSetupFault(LiveSourceFault kind) + { + return new LiveSessionChange(Publish(FaultedBy(kind)), IsLoss: false); + } + + /// A pass that ended with no session: a standing setup fault becomes the fault of a server gone. + public LiveSessionChange OnUnanswered() + { + if (_current == LiveSourceState.Connecting) + { + return LiveSessionChange.None; + } + + var wasConnected = _current.IsConnected; + + return new LiveSessionChange(Publish(Unreachable()), IsLoss: wasConnected); + } + + private LiveSourceState FaultedBy(LiveSourceFault kind) + { + return LiveSourceState.FaultedBy(new LiveSourceError(kind, endpoint)); + } + + private LiveSourceState Unreachable() + { + return FaultedBy(LiveSourceFault.Unreachable); + } + + private LiveSourceState? Publish(LiveSourceState next) + { + if (next.IsConnected == _current.IsConnected && next.Fault?.Kind == _current.Fault?.Kind) + { + return null; + } + + _current = next; + + return next; + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/LiveValue.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/LiveValue.cs new file mode 100644 index 0000000..60046dd --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/LiveValue.cs @@ -0,0 +1,87 @@ +using Opc.Ua; + +namespace SemiPlot.DataSource.OpcUa; + +/// What one notification of a pen's item means for its line. +internal enum LiveReadingKind +{ + /// A number at . + Value, + + /// + /// A Bad status or a NaN or infinity: a break at , if any. + /// + Break, + + /// A value that is not a number: the pen cannot be drawn. + NotNumeric, + + /// A value with neither a source nor a server stamp: nothing to place. + Unstamped +} + +/// +/// One notification mapped; is meaningful only for . +/// +internal readonly record struct LiveReading(LiveReadingKind Kind, DateTime? StampUtc, double Value); + +/// Maps the stack's data values onto the chart's numbers and true-UTC stamps; no zone conversion. +internal static class LiveValue +{ + /// Good and Uncertain draw the value; Bad, NaN and an infinity are a break. + public static LiveReading Read(DataValue dataValue) + { + var stamp = StampOf(dataValue); + + if (dataValue.IsBad) + { + return new LiveReading(LiveReadingKind.Break, stamp, double.NaN); + } + + if (ToDouble(dataValue.WrappedValue) is not { } value) + { + return new LiveReading(LiveReadingKind.NotNumeric, stamp, double.NaN); + } + + if (!double.IsFinite(value)) + { + return new LiveReading(LiveReadingKind.Break, stamp, double.NaN); + } + + return stamp is null + ? new LiveReading(LiveReadingKind.Unstamped, null, double.NaN) + : new LiveReading(LiveReadingKind.Value, stamp, value); + } + + private static double? ToDouble(Variant variant) + { + if (variant.IsNull || !variant.TypeInfo.IsScalar) + { + return null; + } + + return variant.TypeInfo.BuiltInType switch + { + BuiltInType.SByte or BuiltInType.Byte or BuiltInType.Int16 or BuiltInType.UInt16 + or BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 + or BuiltInType.Float or BuiltInType.Double + => variant.ConvertToDouble().GetDouble(double.NaN), + _ => null + }; + } + + private static DateTime? StampOf(DataValue dataValue) + { + if (dataValue.SourceTimestamp != DateTimeUtc.MinValue) + { + return dataValue.SourceTimestamp.ToDateTime(); + } + + if (dataValue.ServerTimestamp != DateTimeUtc.MinValue) + { + return dataValue.ServerTimestamp.ToDateTime(); + } + + return null; + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaDataServiceCollectionExtensions.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaDataServiceCollectionExtensions.cs new file mode 100644 index 0000000..5f0469a --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaDataServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using SemiPlot.Core.Data; +using SemiPlot.DataSource.OpcUa.Configuration; + +namespace SemiPlot.DataSource.OpcUa; + +public static class OpcUaDataServiceCollectionExtensions +{ + /// Registers as the live source; it opens nothing on its own. + public static IServiceCollection AddOpcUaLiveSource( + this IServiceCollection services, + OpcUaConnectionSettings settings) + { + services.AddLogging(builder => builder.AddFilter(OpcUaSession.ChannelLogCategory, LogLevel.Critical)); + + // A factory, not type activation: the constructor is internal. + services.AddSingleton(provider => new OpcUaLiveSource( + settings, + provider.GetRequiredService(), + OpcUaLiveSource.Grace)); + + return services; + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaLiveSource.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaLiveSource.cs new file mode 100644 index 0000000..0669751 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaLiveSource.cs @@ -0,0 +1,124 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using Microsoft.Extensions.Logging; + +using SemiPlot.Core.Data; +using SemiPlot.Core.Data.Errors; +using SemiPlot.Core.Trends; +using SemiPlot.DataSource.OpcUa.Configuration; + +namespace SemiPlot.DataSource.OpcUa; + +/// +/// Serves the live edge from the SCADA's OPC UA server over one session per process, started by the first +/// (docs/architecture/data-integration.md#the-opc-ua-live-source). +/// +public sealed class OpcUaLiveSource : ILiveSource, IAsyncDisposable, IDisposable +{ + /// A server that starts beside the viewer answers within it, so its start writes no fault. + internal static readonly TimeSpan Grace = TimeSpan.FromSeconds(30); + + // Covers the session's 2 s close bound; a close that outlives it finishes on the pool. + private static readonly TimeSpan _disposeBound = TimeSpan.FromSeconds(3); + + private readonly ISubject _states = + Subject.Synchronize(new BehaviorSubject(LiveSourceState.Connecting)); + + private readonly ISubject _penFaults = Subject.Synchronize(new Subject()); + + private readonly OpcUaSession _session; + private readonly ILogger _logger; + private readonly Lock _gate = new(); + private bool _isStarted; + private bool _isDisposed; + + internal OpcUaLiveSource(OpcUaConnectionSettings settings, ILoggerFactory loggerFactory, TimeSpan grace) + { + _session = new OpcUaSession(settings, loggerFactory, grace, _states, _penFaults); + _logger = loggerFactory.CreateLogger(); + States = _states.AsObservable(); + PenFaults = _penFaults.AsObservable(); + } + + public IObservable States { get; } + + public IObservable PenFaults { get; } + + public IObservable> Subscribe(IReadOnlyList penIds) + { + return Observable.Create>(observer => + { + StartOnce(); + + return _session.Open(penIds, observer); + }); + } + + public ValueTask DisposeAsync() + { + return new ValueTask(DisposeCoreAsync()); + } + + /// + /// The container disposes synchronously when Main returns; the close runs on the pool, so no continuation + /// needs a the caller still holds. + /// + public void Dispose() + { + var closing = Task.Run(DisposeCoreAsync); + + if (!closing.Wait(_disposeBound)) + { + LogLateFault(closing); + } + } + + private void StartOnce() + { + lock (_gate) + { + if (_isStarted || _isDisposed) + { + return; + } + + _isStarted = true; + _session.Start(); + } + } + + /// A close that outlived the bound still reaches the log if it fails. + private void LogLateFault(Task closing) + { + // OnlyOnFaulted, so failed.Exception is never null. + _ = closing.ContinueWith( + failed => _logger.LogError(failed.Exception!.GetBaseException(), "Closing the OPC UA live source failed"), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + private async Task DisposeCoreAsync() + { + lock (_gate) + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + } + + try + { + await _session.DisposeAsync().ConfigureAwait(false); + } + finally + { + _states.OnCompleted(); + _penFaults.OnCompleted(); + } + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaSession.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaSession.cs new file mode 100644 index 0000000..325cdc6 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/OpcUaSession.cs @@ -0,0 +1,298 @@ +using System.Reactive.Concurrency; +using System.Reactive.Disposables; + +using Microsoft.Extensions.Logging; + +using Opc.Ua; +using Opc.Ua.Client; + +using SemiPlot.Core.Data; +using SemiPlot.Core.Data.Errors; +using SemiPlot.Core.Trends; +using SemiPlot.DataSource.OpcUa.Configuration; + +namespace SemiPlot.DataSource.OpcUa; + +/// +/// The process's one OPC UA session: the connect loop, the browse on every new session id, the pen subscriptions on +/// it and the state the source publishes (docs/architecture/data-integration.md#the-opc-ua-live-source). +/// +internal sealed class OpcUaSession : IAsyncDisposable +{ + /// Logs an error per failed connect attempt while the server is down; the state line says it. + public const string ChannelLogCategory = "Opc.Ua.Bindings.UaSCUaBinaryClientChannel"; + + private readonly OpcUaConnectionSettings _settings; + private readonly StackTelemetry _telemetry; + private readonly ILogger _logger; + private readonly TimeSpan _grace; + private readonly IObserver _states; + private readonly LiveSessionStates _sessionStates; + private readonly CancellationTokenSource _stopping = new(); + private readonly CancellationToken _stoppingToken; + private ApplicationConfiguration? _configuration; + + // Every state input and notification runs on this loop, so the set, LiveSessionStates and the fields below hold + // no lock. + private readonly SerialLoop _loop; + private readonly PenSubscriptionSet _subscriptions; + private ManagedSession? _session; + private TaskCompletionSource? _sessionEnded; + private StatusCode? _failedKeepAlive; + + private IDisposable _graceTimer = Disposable.Empty; + private Task _running = Task.CompletedTask; + + public OpcUaSession( + OpcUaConnectionSettings settings, + ILoggerFactory loggerFactory, + TimeSpan grace, + IObserver states, + IObserver penFaults) + { + _settings = settings; + _telemetry = new StackTelemetry(loggerFactory); + _logger = loggerFactory.CreateLogger(); + _grace = grace; + _states = states; + _sessionStates = new LiveSessionStates(settings.EndpointUrl); + _stoppingToken = _stopping.Token; + _loop = new SerialLoop(loggerFactory.CreateLogger()); + _subscriptions = new PenSubscriptionSet( + new PenSubscriptionContext( + _telemetry, + _loop, + new DeliveredStamps(), + new ReportedUnservedSets(), + penFaults, + loggerFactory.CreateLogger(), + settings.EndpointUrl), + _stoppingToken); + } + + public void Start() + { + _graceTimer = DefaultScheduler.Instance.Schedule( + _grace, + () => _loop.Enqueue(() => Apply(_sessionStates.OnGraceElapsed()))); + _running = Task.WhenAll( + Task.Run(() => _loop.PumpAsync(_stoppingToken)), + Task.Run(() => ConnectLoopAsync(_stoppingToken))); + } + + public IDisposable Open(IReadOnlyList penIds, IObserver> observer) + { + return _subscriptions.Open(penIds, observer); + } + + /// Stops both loops; the pass that holds a session closes it under its 2 s bound on the way out. + public async ValueTask DisposeAsync() + { + _graceTimer.Dispose(); + await _stopping.CancelAsync().ConfigureAwait(false); + _loop.Complete(); + await _running.ConfigureAwait(false); + _stopping.Dispose(); + } + + private async Task ConnectLoopAsync(CancellationToken token) + { + var backoff = new ConnectBackoff(); + + try + { + while (true) + { + var end = await RunGuardedPassAsync(token).ConfigureAwait(false); + await Task.Delay(backoff.Next(end), token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + } + + /// A pass that throws counts as unanswered, so no failure ends the connect loop. + private async Task RunGuardedPassAsync(CancellationToken token) + { + var end = ConnectPassEnd.Unanswered; + + try + { + _configuration ??= await ClientApplicationConfiguration.CreateValidatedAsync(_settings, _telemetry, token) + .ConfigureAwait(false); + end = await RunPassAsync(_configuration, token).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException || !token.IsCancellationRequested) + { + _logger.LogError(exception, "A connect pass to {Endpoint} failed", _settings.EndpointUrl); + } + + if (end == ConnectPassEnd.Unanswered) + { + _loop.Enqueue(() => Apply(_sessionStates.OnUnanswered())); + } + + return end; + } + + private async Task RunPassAsync(ApplicationConfiguration configuration, CancellationToken token) + { + var (session, isRefused) = await ServerCalls + .ReachAsync(_settings.EndpointUrl, configuration, _telemetry, _logger, token) + .ConfigureAwait(false); + + if (isRefused) + { + await _loop.InvokeAsync(() => Apply(_sessionStates.OnSetupFault(LiveSourceFault.EndpointRefused))) + .WaitAsync(token) + .ConfigureAwait(false); + + return ConnectPassEnd.SetupFault; + } + + if (session is null) + { + return ConnectPassEnd.Unanswered; + } + + var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + try + { + await _loop.InvokeAsync(() => OpenAsync(session, ended, token)).WaitAsync(token).ConfigureAwait(false); + + return await ended.Task.WaitAsync(token).ConfigureAwait(false); + } + finally + { + session.ConnectionStateChanged -= OnConnectionStateChanged; + session.KeepAlive -= OnKeepAlive; + + // A pass that failed still holds the session: it ends as lost. Otherwise the session was let go already. + _loop.Enqueue(() => ApplyStateChangeAsync(session, ConnectionState.Closed, null)); + await ServerCalls.CloseAsync(session, _logger).ConfigureAwait(false); + } + } + + private async Task OpenAsync( + ManagedSession session, TaskCompletionSource ended, CancellationToken token) + { + _session = session; + _sessionEnded = ended; + session.ConnectionStateChanged += OnConnectionStateChanged; + session.KeepAlive += OnKeepAlive; + + await OnConnectedAsync(session, token).ConfigureAwait(false); + } + + /// The stack raises no event for the first connect, so calls this too. + private async Task OnConnectedAsync(ManagedSession session, CancellationToken token) + { + if (_sessionStates.IsNewSession(session.SessionId) && !await BrowseAsync(session, token).ConfigureAwait(false)) + { + return; + } + + await _subscriptions.AttachAllAsync(session, token).ConfigureAwait(false); + _failedKeepAlive = null; + Apply(_sessionStates.OnStateChanged(ConnectionState.Connected)); + } + + /// Reads the project ID of a new session; false when the session ended over it. + private async Task BrowseAsync(ManagedSession session, CancellationToken token) + { + string? browsed; + + try + { + browsed = await ServerCalls.BrowseProjectIdAsync(session, token).ConfigureAwait(false); + } + catch (Exception exception) when (!token.IsCancellationRequested) + { + _logger.LogInformation(exception, "Browsing {Endpoint} failed", _settings.EndpointUrl); + EndSession(ConnectPassEnd.Unanswered); + + return false; + } + + if (await _subscriptions.ApplyProjectAsync(session, browsed).ConfigureAwait(false)) + { + return true; + } + + Apply(_sessionStates.OnSetupFault(LiveSourceFault.ProjectMissing)); + EndSession(ConnectPassEnd.SetupFault); + + return false; + } + + private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs change) + { + _loop.Enqueue(() => ApplyStateChangeAsync(sender, change.NewState, change.Error?.StatusCode)); + } + + private void OnKeepAlive(ISession session, KeepAliveEventArgs keepAlive) + { + if (keepAlive.Status is { } status && ServiceResult.IsBad(status)) + { + _loop.Enqueue(() => _failedKeepAlive = status.StatusCode); + } + } + + private async Task ApplyStateChangeAsync(object? sender, ConnectionState next, StatusCode? status) + { + if (sender is not ManagedSession session || !ReferenceEquals(session, _session)) + { + return; + } + + if (next == ConnectionState.Connected) + { + await OnConnectedAsync(session, _stoppingToken).ConfigureAwait(false); + + return; + } + + Apply(_sessionStates.OnStateChanged(next), status ?? _failedKeepAlive); + + if (next == ConnectionState.Closed) + { + EndSession(ConnectPassEnd.SessionEnded); + } + } + + /// + /// Hands the session back to the connect loop to close; its Closing and Closed publish nothing, because + /// drops the events of a session the source no longer holds. + /// + private void EndSession(ConnectPassEnd end) + { + _subscriptions.DetachAll(); + _session = null; + _sessionEnded?.TrySetResult(end); + } + + private void Apply(LiveSessionChange change, StatusCode? status = null) + { + if (_stoppingToken.IsCancellationRequested) + { + return; + } + + if (change.IsLoss) + { + _subscriptions.OnLost(); + } + + if (change.Published is not { } state) + { + return; + } + + _logger.LogInformation( + "OPC UA session to {Endpoint}: {State}, status {Status}", + _settings.EndpointUrl, state, status?.ToString() ?? "none"); + _states.OnNext(state); + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscription.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscription.cs new file mode 100644 index 0000000..1e68518 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscription.cs @@ -0,0 +1,195 @@ +using System.Reactive.Concurrency; +using System.Reactive.Disposables; + +using Microsoft.Extensions.Logging; + +using Opc.Ua; +using Opc.Ua.Client; + +using SemiPlot.Core.Data.Errors; +using SemiPlot.Core.Trends; + +namespace SemiPlot.DataSource.OpcUa; + +/// +/// One Subscribe call: its OPC UA subscription on the current session, the samples and breaks it emits and +/// the pens it cannot serve. Every member but runs on the source's loop. +/// +internal sealed class PenSubscription( + IReadOnlyList penIds, + IObserver> observer, + PenSubscriptionContext context) +{ + // A created item that has not answered by then is reported as it stands. + private static readonly TimeSpan _settleTime = TimeSpan.FromSeconds(5); + + private readonly UnservedPens _unserved = new(penIds, context.Endpoint, context.ReportedSets); + private IDisposable _settleTimer = Disposable.Empty; + + // Set from the subscriber's thread; read on the loop and on the stack's notification threads. + private volatile bool _isClosed; + + /// + /// The OPC UA subscription on the current session; null until created and after the session ends. + /// + public Subscription? Subscription { get; private set; } + + public bool IsClosed => _isClosed; + + public Subscription Build(string projectId, ushort namespaceIndex) + { + var subscription = SubscriptionPlan.CreateSubscription(context.Telemetry); + + foreach (var penId in penIds) + { + var item = SubscriptionPlan.CreateItem(context.Telemetry, projectId, namespaceIndex, penId); + item.Notification += (_, arguments) => OnNotification(subscription, penId, arguments); + subscription.AddItem(item); + } + + return subscription; + } + + /// Takes a subscription the server has created and starts the settle time of its unserved pens. + public void Attach(Subscription subscription) + { + Subscription = subscription; + ReadItemStatuses(); + _settleTimer.Dispose(); + _settleTimer = DefaultScheduler.Instance.Schedule( + _settleTime, + () => context.Loop.Enqueue(() => Report(_unserved.OnSettleElapsed()))); + } + + /// Lets go of the subscription; its notifications emit nothing from here on. + public Subscription? Detach() + { + var subscription = Subscription; + Subscription = null; + _settleTimer.Dispose(); + + return subscription; + } + + /// + /// Unknown items join the unserved pens: after a creation and after the stack recreated the session. + /// + public void ReadItemStatuses() + { + if (Subscription is null) + { + return; + } + + var statuses = new List(); + + foreach (var item in Subscription.MonitoredItems) + { + if (item.Handle is not int penId) + { + continue; + } + + var status = item.Status.Error?.StatusCode ?? StatusCodes.Good; + statuses.Add(new ItemStatus(penId, item.Created, status)); + + if (StatusCode.IsBad(status) && status != StatusCodes.BadNodeIdUnknown) + { + context.Logger.LogWarning( + "The OPC UA item of pen {PenId} at {Endpoint} was not created: {Status}", + penId, + context.Endpoint, + status); + } + } + + Report(_unserved.OnItemStatuses(statuses)); + } + + /// The session left Connected: one break per pen past its newest delivered stamp. + public void EmitLossBreaks() + { + var breaks = context.Stamps.LossBreaks(penIds); + + if (breaks.Count == 0) + { + return; + } + + context.Logger.LogDebug("The OPC UA session was lost: {Count} breaks for pens {PenIds}", breaks.Count, penIds); + Emit(breaks); + } + + /// The subscriber let go; nothing is emitted from here on. + public void Close() + { + _isClosed = true; + } + + private void OnNotification(Subscription subscription, int penId, MonitoredItemNotificationEventArgs arguments) + { + if (!_isClosed && arguments.NotificationValue is MonitoredItemNotification notification) + { + context.Loop.Enqueue(() => Deliver(subscription, penId, notification.Value)); + } + } + + private void Deliver(Subscription subscription, int penId, DataValue dataValue) + { + if (!ReferenceEquals(subscription, Subscription)) + { + return; + } + + var reading = LiveValue.Read(dataValue); + + switch (reading.Kind) + { + case LiveReadingKind.Value when reading.StampUtc is { } stamp: + context.Stamps.RecordValue(penId, stamp); + Emit([new Sample(penId, stamp, reading.Value)]); + Report(_unserved.OnValue(penId)); + break; + case LiveReadingKind.Break: + if (context.Stamps.BadBreak(penId, reading.StampUtc) is { } badBreak) + { + context.Logger.LogDebug( + "Pen {PenId} reads Bad: a break at {Stamp:O}", penId, badBreak.TimestampUtc); + Emit([badBreak]); + } + + Report(_unserved.OnAnswered(penId)); + break; + case LiveReadingKind.NotNumeric: + Report(_unserved.OnNotNumeric(penId)); + break; + default: + context.Logger.LogDebug("Pen {PenId} sent a value with neither a source nor a server stamp", penId); + Report(_unserved.OnAnswered(penId)); + break; + } + } + + private void Emit(IReadOnlyList samples) + { + if (!_isClosed) + { + observer.OnNext(samples); + } + } + + private void Report(LiveSourceError? unserved) + { + if (unserved is null || _isClosed) + { + return; + } + + context.Logger.LogWarning( + "Pens unserved by {Endpoint}: unknown {UnknownPenIds}, not numeric {NotNumericPenIds}", + unserved.Endpoint, + unserved.UnknownPenIds, + unserved.NotNumericPenIds); + context.PenFaults.OnNext(unserved); + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscriptionContext.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscriptionContext.cs new file mode 100644 index 0000000..92962e2 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscriptionContext.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; + +using SemiPlot.Core.Data.Errors; + +namespace SemiPlot.DataSource.OpcUa; + +/// What every pen subscription of one source shares. +internal sealed record PenSubscriptionContext( + StackTelemetry Telemetry, + SerialLoop Loop, + DeliveredStamps Stamps, + ReportedUnservedSets ReportedSets, + IObserver PenFaults, + ILogger Logger, + string Endpoint); diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscriptionSet.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscriptionSet.cs new file mode 100644 index 0000000..503c5b7 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/PenSubscriptionSet.cs @@ -0,0 +1,224 @@ +using System.Reactive.Concurrency; +using System.Reactive.Disposables; + +using Opc.Ua.Client; + +using SemiPlot.Core.Trends; + +namespace SemiPlot.DataSource.OpcUa; + +/// +/// The source's open pen subscriptions, the project they are built under and the session they live on. Every member +/// but runs on the source's loop, so the set holds no lock. +/// +internal sealed class PenSubscriptionSet(PenSubscriptionContext context, CancellationToken stoppingToken) +{ + // A creation that failed on a connected session is tried again after this; no Connected may come to retry it. + private static readonly TimeSpan _createRetry = TimeSpan.FromSeconds(10); + + private readonly List _open = []; + + // Let go of while the session was down, or not deleted when it was up: deleted at the next Connected. + private readonly List _stale = []; + + private string? _projectId; + private ushort _namespaceIndex; + + private ManagedSession? _connectedSession; + private IDisposable? _createRetryTimer; + + /// + /// Registers a pen set from any thread; created at once while connected, else at the next Connected. + /// + public IDisposable Open(IReadOnlyList penIds, IObserver> observer) + { + var pen = new PenSubscription(penIds, observer, context); + context.Loop.Enqueue(() => AddAsync(pen)); + + return Disposable.Create(() => + { + pen.Close(); + context.Loop.Enqueue(() => Remove(pen)); + }); + } + + /// + /// Takes the project ID a new session browsed: false when there is none. A changed ID deletes every subscription, + /// because each names the old nodes; the same ID reads the item statuses of the subscriptions the stack recreated. + /// + public async Task ApplyProjectAsync(ManagedSession session, string? browsed) + { + var change = ProjectFolder.Next(_projectId, browsed); + + if (change == ProjectFolderChange.Missing) + { + return false; + } + + if (change == ProjectFolderChange.Recreate) + { + await DeleteAllAsync(session).ConfigureAwait(false); + } + else + { + ReadItemStatuses(); + } + + _projectId = browsed; + _namespaceIndex = (ushort)session.NamespaceUris.GetIndex(ProjectFolder.NamespaceUri); + + return true; + } + + public async Task AttachAllAsync(ManagedSession session, CancellationToken token) + { + _connectedSession = session; + + foreach (var subscription in _stale) + { + await ServerCalls.RemoveSubscriptionAsync(session, subscription, context.Logger, token) + .ConfigureAwait(false); + } + + _stale.Clear(); + await CreateMissingAsync(session, token).ConfigureAwait(false); + } + + public void OnLost() + { + _connectedSession = null; + + foreach (var pen in _open) + { + if (!pen.IsClosed) + { + pen.EmitLossBreaks(); + } + } + } + + /// The session ends with every subscription on it; the next session creates them anew. + public void DetachAll() + { + _connectedSession = null; + + foreach (var pen in _open) + { + pen.Detach(); + } + + _stale.Clear(); + } + + /// A set added while the session is down breaks its pens as a set open over the loss would have. + private async Task AddAsync(PenSubscription pen) + { + if (pen.IsClosed) + { + return; + } + + _open.Add(pen); + + if (_connectedSession is { } session) + { + await CreateAsync(pen, session, stoppingToken).ConfigureAwait(false); + } + else + { + pen.EmitLossBreaks(); + } + } + + private void Remove(PenSubscription pen) + { + if (_open.Remove(pen) && pen.Detach() is { } subscription) + { + Discard(subscription); + } + } + + private async Task CreateMissingAsync(ManagedSession session, CancellationToken token) + { + foreach (var pen in _open) + { + await CreateAsync(pen, session, token).ConfigureAwait(false); + } + } + + private async Task CreateAsync(PenSubscription pen, ManagedSession session, CancellationToken token) + { + if (pen.IsClosed || pen.Subscription is not null || _projectId is not { } projectId) + { + return; + } + + var subscription = pen.Build(projectId, _namespaceIndex); + + if (await ServerCalls.CreateSubscriptionAsync(session, subscription, context.Logger, token) + .ConfigureAwait(false)) + { + pen.Attach(subscription); + + return; + } + + Discard(subscription); + _createRetryTimer ??= DefaultScheduler.Instance.Schedule( + _createRetry, + () => context.Loop.Enqueue(RetryCreateAsync)); + } + + private async Task RetryCreateAsync() + { + _createRetryTimer = null; + + if (_connectedSession is { } session) + { + await CreateMissingAsync(session, stoppingToken).ConfigureAwait(false); + } + } + + private void Discard(Subscription subscription) + { + if (_connectedSession is { } session) + { + // Never faults: RemoveSubscriptionAsync never throws, and Enqueue only queues. + _ = DeleteOrKeepAsync(session, subscription); + } + else + { + _stale.Add(subscription); + } + } + + private async Task DeleteOrKeepAsync(ManagedSession session, Subscription subscription) + { + if (!await ServerCalls.RemoveSubscriptionAsync(session, subscription, context.Logger, stoppingToken) + .ConfigureAwait(false)) + { + context.Loop.Enqueue(() => _stale.Add(subscription)); + } + } + + private async Task DeleteAllAsync(ManagedSession session) + { + foreach (var pen in _open) + { + if (pen.Detach() is { } subscription + && !await ServerCalls.RemoveSubscriptionAsync(session, subscription, context.Logger, stoppingToken) + .ConfigureAwait(false)) + { + _stale.Add(subscription); + } + } + } + + private void ReadItemStatuses() + { + foreach (var pen in _open) + { + pen.ReadItemStatuses(); + } + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/ProjectFolder.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/ProjectFolder.cs new file mode 100644 index 0000000..2271761 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/ProjectFolder.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using Opc.Ua; + +namespace SemiPlot.DataSource.OpcUa; + +/// +/// What a fresh browse of Objects means for the subscriptions built under the previous project ID. +/// +internal enum ProjectFolderChange +{ + Keep, + Recreate, + Missing +} + +/// +/// The Simple-Scada project folder under Objects: its browse name is root in the SCADA's namespace +/// and its string identifier is <projectId>root_<n>; a pen's node is +/// <projectId><penId>. +/// +internal static partial class ProjectFolder +{ + public const string NamespaceUri = "http://simple-scada.com/UA"; + + public const string RootFolderName = "root"; + + /// The project ID of the one matching child, or null when none or more than one matches. + public static string? ProjectIdOf(IEnumerable objectsChildren, NamespaceTable namespaces) + { + var namespaceIndex = namespaces.GetIndex(NamespaceUri); + string? found = null; + + foreach (var child in objectsChildren) + { + if (namespaceIndex < 0 + || child.BrowseName.NamespaceIndex != namespaceIndex + || child.BrowseName.Name != RootFolderName + || !child.NodeId.TryGetValue(out string identifier)) + { + continue; + } + + var match = RootFolderIdentifier().Match(identifier); + + if (!match.Success) + { + continue; + } + + if (found is not null) + { + return null; + } + + found = match.Groups["projectId"].Value; + } + + return found; + } + + public static NodeId NodeOf(string projectId, ushort namespaceIndex, int penId) + { + return new NodeId(projectId + penId.ToString(CultureInfo.InvariantCulture), namespaceIndex); + } + + public static ProjectFolderChange Next(string? current, string? browsed) + { + if (browsed is null) + { + return ProjectFolderChange.Missing; + } + + return browsed == current ? ProjectFolderChange.Keep : ProjectFolderChange.Recreate; + } + + [GeneratedRegex(@"^(?.+)root_[0-9]+$", RegexOptions.CultureInvariant)] + private static partial Regex RootFolderIdentifier(); +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/ReportedUnservedSets.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/ReportedUnservedSets.cs new file mode 100644 index 0000000..126f6b1 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/ReportedUnservedSets.cs @@ -0,0 +1,17 @@ +namespace SemiPlot.DataSource.OpcUa; + +/// +/// The unserved pen sets the process has already reported, shared by every subscription, so a pen-set change or a +/// recreated session that meets the same set writes no second entry. Only the source's loop calls it. +/// +internal sealed class ReportedUnservedSets +{ + private readonly HashSet _reported = []; + + public bool TryAdd(IReadOnlyList unknownPenIds, IReadOnlyList notNumericPenIds) + { + var key = string.Join(',', unknownPenIds) + "|" + string.Join(',', notNumericPenIds); + + return _reported.Add(key); + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/SemiPlot.DataSource.OpcUa.csproj b/SemiPlot/SemiPlot.DataSource.OpcUa/SemiPlot.DataSource.OpcUa.csproj new file mode 100644 index 0000000..c59f78c --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/SemiPlot.DataSource.OpcUa.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/SerialLoop.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/SerialLoop.cs new file mode 100644 index 0000000..baf1b7f --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/SerialLoop.cs @@ -0,0 +1,91 @@ +using System.Threading.Channels; + +using Microsoft.Extensions.Logging; + +namespace SemiPlot.DataSource.OpcUa; + +/// Runs queued work one item at a time on one reader, so the state the work touches needs no lock. +internal sealed class SerialLoop(ILogger logger) +{ + private readonly Channel> _queue = + Channel.CreateUnbounded>(new UnboundedChannelOptions { SingleReader = true }); + + /// A work item that throws is logged and the next one runs. + public async Task PumpAsync(CancellationToken token) + { + try + { + await foreach (var work in _queue.Reader.ReadAllAsync(token).ConfigureAwait(false)) + { + await RunAsync(work, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + } + + /// Work queued after this is dropped. + public void Complete() + { + _queue.Writer.TryComplete(); + } + + public void Enqueue(Action work) + { + Enqueue(() => + { + work(); + + return Task.CompletedTask; + }); + } + + public void Enqueue(Func work) + { + _queue.Writer.TryWrite(work); + } + + public Task InvokeAsync(Action work) + { + return InvokeAsync(() => + { + work(); + + return Task.CompletedTask; + }); + } + + /// Completes when the work has run on the loop, with its exception when it threw. + public Task InvokeAsync(Func work) + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Enqueue(async () => + { + try + { + await work().ConfigureAwait(false); + done.TrySetResult(); + } + catch (Exception exception) + { + done.TrySetException(exception); + } + }); + + return done.Task; + } + + private async Task RunAsync(Func work, CancellationToken token) + { + try + { + await work().ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException || !token.IsCancellationRequested) + { + logger.LogError(exception, "A work item of the OPC UA source failed; the source goes on"); + } + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/ServerCalls.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/ServerCalls.cs new file mode 100644 index 0000000..8e4c946 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/ServerCalls.cs @@ -0,0 +1,203 @@ +using Microsoft.Extensions.Logging; + +using Opc.Ua; +using Opc.Ua.Client; + +namespace SemiPlot.DataSource.OpcUa; + +/// The stack calls the source makes: discovery, session, browse, subscriptions and close. +internal static class ServerCalls +{ + private const string SessionName = "SemiPlot"; + + // A listener that accepts and never answers holds discovery for 90 s. + private static readonly TimeSpan _discoveryBound = TimeSpan.FromSeconds(10); + + // A CloseSession to a peer that never answers would otherwise wait the 15 s operation timeout. + private static readonly TimeSpan _closeBound = TimeSpan.FromSeconds(2); + + // A browse and a creation run on the source's loop; a session that drops in the middle must not hold it. + private static readonly TimeSpan _loopCallBound = TimeSpan.FromSeconds(10); + + /// A session on the configured URL, or none: refused when the server offers no usable endpoint. + public static async Task<(ManagedSession? Session, bool IsRefused)> ReachAsync( + string endpointUrl, + ApplicationConfiguration configuration, + ITelemetryContext telemetry, + ILogger logger, + CancellationToken token) + { + var endpoints = await DiscoverAsync(endpointUrl, configuration, logger, token).ConfigureAwait(false); + + if (endpoints is null) + { + return (null, false); + } + + if (EndpointChoice.Pick(endpoints, endpointUrl) is not { } endpoint) + { + return (null, true); + } + + return (await ConnectAsync(endpoint, configuration, telemetry, logger, token).ConfigureAwait(false), false); + } + + /// The server's endpoints, or null when it does not answer within the bound. + private static async Task?> DiscoverAsync( + string endpointUrl, + ApplicationConfiguration configuration, + ILogger logger, + CancellationToken token) + { + using var bound = CancellationTokenSource.CreateLinkedTokenSource(token); + bound.CancelAfter(_discoveryBound); + + try + { + using var discovery = await DiscoveryClient.CreateAsync( + configuration, + new Uri(endpointUrl), + DiagnosticsMasks.None, + bound.Token).ConfigureAwait(false); + + return [.. await discovery.GetEndpointsAsync(default, bound.Token).ConfigureAwait(false)]; + } + catch (Exception exception) when (!token.IsCancellationRequested) + { + logger.LogDebug(exception, "No endpoints from the OPC UA server at {Endpoint}", endpointUrl); + + return null; + } + } + + /// + /// The stack retries inside the call until a server answers, so only a session the server rejects returns null. + /// The session keeps the configured URL on every reconnect + /// (docs/architecture/data-integration.md#the-opc-ua-live-source). + /// + private static async Task ConnectAsync( + EndpointDescription endpoint, + ApplicationConfiguration configuration, + ITelemetryContext telemetry, + ILogger logger, + CancellationToken token) + { + var configured = new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(configuration)) + { + UpdateBeforeConnect = false, + }; + + try + { + return await new ManagedSessionBuilder(configuration, telemetry) + .UseEndpoint(configured) + .WithSessionName(SessionName) + .WithSessionTimeout( + TimeSpan.FromMilliseconds(ClientApplicationConfiguration.SessionTimeoutMilliseconds)) + .WithUserIdentity(new UserIdentity()) + .WithReconnectPolicy(policy => policy with { MaxTotalReconnectTime = Timeout.InfiniteTimeSpan }) + .ConnectAsync(token) + .ConfigureAwait(false); + } + catch (Exception exception) when (!token.IsCancellationRequested) + { + logger.LogDebug(exception, "No OPC UA session to {Endpoint}", endpoint.EndpointUrl); + + return null; + } + } + + /// + /// Throws when the server does not answer within the bound. + /// + public static async Task BrowseProjectIdAsync(ManagedSession session, CancellationToken token) + { + using var bound = CancellationTokenSource.CreateLinkedTokenSource(token); + bound.CancelAfter(_loopCallBound); + var browser = new Browser(session) + { + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = (uint)NodeClass.Object, + }; + var children = await browser.BrowseAsync(ObjectIds.ObjectsFolder, bound.Token).ConfigureAwait(false); + + return ProjectFolder.ProjectIdOf([.. children], session.NamespaceUris); + } + + /// + /// False when the server did not create it within the bound; the subscription stays on the session. + /// + public static async Task CreateSubscriptionAsync( + ManagedSession session, + Subscription subscription, + ILogger logger, + CancellationToken token) + { + using var bound = CancellationTokenSource.CreateLinkedTokenSource(token); + bound.CancelAfter(_loopCallBound); + + try + { + session.AddSubscription(subscription); + await subscription.CreateAsync(bound.Token).ConfigureAwait(false); + + return true; + } + catch (Exception exception) when (!token.IsCancellationRequested) + { + logger.LogWarning(exception, "Creating an OPC UA subscription failed; the source tries again"); + + return false; + } + } + + /// Never throws: false when the delete failed, which is logged. + public static async Task RemoveSubscriptionAsync( + ManagedSession session, + Subscription subscription, + ILogger logger, + CancellationToken token) + { + using var bound = CancellationTokenSource.CreateLinkedTokenSource(token); + bound.CancelAfter(_closeBound); + + try + { + await session.RemoveSubscriptionAsync(subscription, bound.Token).ConfigureAwait(false); + + return true; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Deleting an OPC UA subscription failed"); + + return false; + } + } + + /// Never throws: the connect loop goes on after any close. + public static async Task CloseAsync(ManagedSession session, ILogger logger) + { + using var giveUp = new CancellationTokenSource(_closeBound); + + try + { + await session.CloseAsync(giveUp.Token).ConfigureAwait(false); + } + catch (Exception exception) + { + logger.LogDebug(exception, "Closing the OPC UA session gave up"); + } + + try + { + await session.DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Disposing the OPC UA session failed"); + } + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/StackTelemetry.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/StackTelemetry.cs new file mode 100644 index 0000000..3b99fd0 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/StackTelemetry.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.Logging; + +using Opc.Ua; + +namespace SemiPlot.DataSource.OpcUa; + +/// Routes the OPC UA stack's logging into the container's logger factory. +internal sealed class StackTelemetry(ILoggerFactory loggerFactory) : TelemetryContextBase(loggerFactory); diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/SubscriptionPlan.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/SubscriptionPlan.cs new file mode 100644 index 0000000..18b2ee0 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/SubscriptionPlan.cs @@ -0,0 +1,57 @@ +using Opc.Ua; +using Opc.Ua.Client; + +namespace SemiPlot.DataSource.OpcUa; + +/// The shape of every OPC UA subscription the source creates and of each pen's monitored item. +internal static class SubscriptionPlan +{ + public const int PublishingIntervalMilliseconds = 100; + + public const uint KeepAliveCount = 10; + + /// The stack's default of 100 logs a lifetime shorter than the 60 s session timeout. + public const uint LifetimeCount = 600; + + public const int SamplingIntervalMilliseconds = 100; + + /// + /// Simple-Scada answers a larger queue with an empty first value, so a static variable never shows. + /// + public const uint QueueSize = 1; + + public static Subscription CreateSubscription(ITelemetryContext telemetry) + { + return new Subscription(telemetry, new SubscriptionOptions + { + DisplayName = ClientApplicationConfiguration.ApplicationName, + PublishingInterval = PublishingIntervalMilliseconds, + KeepAliveCount = KeepAliveCount, + LifetimeCount = LifetimeCount, + PublishingEnabled = true, + TimestampsToReturn = TimestampsToReturn.Both, + }); + } + + /// The pen id rides on the item as its . + public static MonitoredItem CreateItem( + ITelemetryContext telemetry, + string projectId, + ushort namespaceIndex, + int penId) + { + return new MonitoredItem(telemetry, new MonitoredItemOptions + { + StartNodeId = ProjectFolder.NodeOf(projectId, namespaceIndex, penId), + AttributeId = Attributes.Value, + NodeClass = NodeClass.Variable, + MonitoringMode = MonitoringMode.Reporting, + SamplingInterval = SamplingIntervalMilliseconds, + QueueSize = QueueSize, + DiscardOldest = true, + }) + { + Handle = penId, + }; + } +} diff --git a/SemiPlot/SemiPlot.DataSource.OpcUa/UnservedPens.cs b/SemiPlot/SemiPlot.DataSource.OpcUa/UnservedPens.cs new file mode 100644 index 0000000..9604744 --- /dev/null +++ b/SemiPlot/SemiPlot.DataSource.OpcUa/UnservedPens.cs @@ -0,0 +1,107 @@ +using Opc.Ua; + +using SemiPlot.Core.Data.Errors; + +namespace SemiPlot.DataSource.OpcUa; + +/// One monitored item's creation outcome, or its status read after the session was recreated. +internal readonly record struct ItemStatus(int PenId, bool Created, StatusCode Status); + +/// +/// One subscription's pens the source cannot serve, unknown or not numeric: one +/// once every item has answered or the settle time has passed, again +/// whenever the set grows; a pen served again leaves it. Only the source's loop calls it. +/// +internal sealed class UnservedPens(IReadOnlyList penIds, string endpoint, ReportedUnservedSets reported) +{ + private readonly HashSet _waiting = [.. penIds]; + private readonly SortedSet _unknown = []; + private readonly SortedSet _notNumeric = []; + private bool _isSettled; + + /// + /// Only BadNodeIdUnknown makes a pen unknown; an item that failed for another reason never answers and is + /// logged by its subscription, and a created item leaves the unknown set. + /// + public LiveSourceError? OnItemStatuses(IEnumerable statuses) + { + var grew = false; + + foreach (var status in statuses) + { + if (status.Status == StatusCodes.BadNodeIdUnknown) + { + _waiting.Remove(status.PenId); + grew |= _unknown.Add(status.PenId); + } + else if (StatusCode.IsBad(status.Status)) + { + _waiting.Remove(status.PenId); + } + else if (status.Created) + { + _unknown.Remove(status.PenId); + } + } + + return Report(grew); + } + + /// A pen's number: it answered and is numeric again. + public LiveSourceError? OnValue(int penId) + { + _notNumeric.Remove(penId); + + return OnAnswered(penId); + } + + /// A pen's notification the line can place without a number: a break, or a value with no stamp. + public LiveSourceError? OnAnswered(int penId) + { + _waiting.Remove(penId); + + return Report(grew: false); + } + + public LiveSourceError? OnNotNumeric(int penId) + { + _waiting.Remove(penId); + var grew = _notNumeric.Add(penId); + + return Report(grew); + } + + public LiveSourceError? OnSettleElapsed() + { + _waiting.Clear(); + + return Report(grew: false); + } + + private LiveSourceError? Report(bool grew) + { + if (_isSettled && !grew) + { + return null; + } + + if (!_isSettled && _waiting.Count > 0) + { + return null; + } + + _isSettled = true; + + if (_unknown.Count == 0 && _notNumeric.Count == 0) + { + return null; + } + + int[] unknown = [.. _unknown]; + int[] notNumeric = [.. _notNumeric]; + + return reported.TryAdd(unknown, notNumeric) + ? new LiveSourceError(LiveSourceFault.PensUnserved, endpoint, unknown, notNumeric) + : null; + } +} diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs b/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs index 0653d2d..5f49e8f 100644 --- a/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs +++ b/SemiPlot/SemiPlot.DataSource.Postgres/ArchiveStatements.cs @@ -204,30 +204,6 @@ LIMIT 1 ORDER BY 1; """; - /// - /// Every raw sample newer than the last one a subscription saw, issued once per poll tick with a strict - /// > so the row that set @lastSeen never returns twice (docs/architecture/scada-archive.md). - /// - public const string RealtimePoll = """ - SELECT id, t, v, q - FROM trends - WHERE id = ANY(@ids) AND l = 0 AND t > @lastSeen - ORDER BY t; - """; - - /// - /// The newest raw timestamp across the subscribed variables, read once to establish where a poll starts; - /// a NULL answer is a content state, not a failure. Lateral on purpose: a bare max(t) - /// under id = ANY(...) loses the index-edge transform. - /// - public const string RealtimeBaseline = """ - SELECT max(hi) AS last - FROM (SELECT DISTINCT unnest(@ids) AS id) requested - CROSS JOIN LATERAL ( - SELECT (SELECT max(t) FROM trends WHERE id = requested.id AND l = 0) AS hi - ) bounds; - """; - /// /// A window of one layer, one row per bucket, seeded before the window. /// diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/Configuration/PostgresConnectionLoader.cs b/SemiPlot/SemiPlot.DataSource.Postgres/Configuration/PostgresConnectionLoader.cs index 434390a..031a745 100644 --- a/SemiPlot/SemiPlot.DataSource.Postgres/Configuration/PostgresConnectionLoader.cs +++ b/SemiPlot/SemiPlot.DataSource.Postgres/Configuration/PostgresConnectionLoader.cs @@ -1,18 +1,12 @@ -using System.Globalization; - using FluentResults; using SemiPlot.Core.Configuration; using SemiPlot.Core.Data.Errors; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - namespace SemiPlot.DataSource.Postgres.Configuration; /// -/// Every member nullable so an absent field reaches the loader as a state to report; YAML keys follow -/// the underscored convention (PollIntervalMs reads poll_interval_ms). +/// Every member nullable so an absent field reaches the loader as a state to report. /// internal sealed class PostgresConnectionDto { @@ -26,8 +20,6 @@ internal sealed class PostgresConnectionDto public string? Password { get; set; } - public int? PollIntervalMs { get; set; } - public string? Schema { get; set; } } @@ -38,9 +30,9 @@ internal sealed class PostgresConnectionDto /// public static class PostgresConnectionLoader { - public const string HostKey = "host"; + public const string HostKey = ConnectionFile.HostKey; - public const string PortKey = "port"; + public const string PortKey = ConnectionFile.PortKey; public const string DatabaseKey = "database"; @@ -48,33 +40,20 @@ public static class PostgresConnectionLoader public const string PasswordKey = "password"; - public const string PollIntervalKey = "poll_interval_ms"; - - public const int LowestPort = 1; - - public const int HighestPort = 65535; - - public const int LowestPollIntervalMs = 1; - - private const int HighestOctet = 255; - private const string DefaultSchema = "public"; - private static readonly IDeserializer _deserializer = new DeserializerBuilder() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); + private const ConfigurationSectionName Section = ConfigurationSectionName.Connection; public static Result Load(string sectionDirectory) { - var section = ConfigurationSection.Read(sectionDirectory, ConfigurationSectionName.Connection); + var section = ConfigurationSection.Read(sectionDirectory, Section); if (section.IsFailed) { return Result.Fail(section.Errors); } - var read = Deserialize(sectionDirectory, section.Value); + var read = ConnectionFile.Deserialize(Section, sectionDirectory, section.Value); if (read.IsFailed) { @@ -82,54 +61,21 @@ public static Result Load(string sectionDirectory) } var dto = read.Value; - - var fields = ValidateFields(sectionDirectory, dto); - - if (fields.IsFailed) - { - return Result.Fail(fields.Errors); - } - - var ranges = ValidateRanges(sectionDirectory, dto); - - if (ranges.IsFailed) + var checks = ConnectionFile.Validate( + Section, + sectionDirectory, + dto.Host, + dto.Port, + [(DatabaseKey, dto.Database), (UserKey, dto.User), (PasswordKey, dto.Password)]); + + if (checks.IsFailed) { - return Result.Fail(ranges.Errors); - } - - var host = ValidateHost(sectionDirectory, dto.Host); - - if (host.IsFailed) - { - return Result.Fail(host.Errors); + return Result.Fail(checks.Errors); } return Result.Ok(Map(dto)); } - /// - /// Four dot-separated decimal octets from 0 to 255 with no leading zero, so no shortened or octal form. - /// - public static bool IsIPv4Address(string? text) - { - if (text is null) - { - return false; - } - - var octets = text.Split('.'); - - return octets.Length == 4 && octets.All(IsOctet); - } - - private static bool IsOctet(string octet) - { - return octet.Length is >= 1 and <= 3 - && octet.All(char.IsAsciiDigit) - && (octet.Length == 1 || octet[0] != '0') - && int.Parse(octet, CultureInfo.InvariantCulture) <= HighestOctet; - } - private static PostgresConnectionSettings Map(PostgresConnectionDto dto) { return new PostgresConnectionSettings( @@ -139,98 +85,6 @@ private static PostgresConnectionSettings Map(PostgresConnectionDto dto) dto.User!, dto.Password!, TimeZoneInfo.Local, - TimeSpan.FromMilliseconds(dto.PollIntervalMs!.Value), dto.Schema ?? DefaultSchema); } - - // The error carries no text of the exception: a parser message embeds the offending scalar, and the - // password is a scalar. The raw detail rides on CausedBy, where only the log reads it. - private static Result Deserialize(string sectionDirectory, string content) - { - try - { - return Result.Ok(_deserializer.Deserialize(content) ?? new PostgresConnectionDto()); - } - catch (Exception exception) - { - var error = new ConnectionFileError( - sectionDirectory, - ConnectionFileProblem.Unparseable, - []); - - return Result.Fail(error.CausedBy(new ExceptionalError(exception))); - } - } - - private static Result ValidateFields(string sectionDirectory, PostgresConnectionDto dto) - { - (string Name, string? Value)[] texts = - [ - (HostKey, dto.Host), - (DatabaseKey, dto.Database), - (UserKey, dto.User), - (PasswordKey, dto.Password) - ]; - - var missing = new List(); - - foreach (var (name, value) in texts) - { - if (string.IsNullOrWhiteSpace(value)) - { - missing.Add(name); - } - } - - if (dto.Port is null) - { - missing.Add(PortKey); - } - - if (dto.PollIntervalMs is null) - { - missing.Add(PollIntervalKey); - } - - return missing.Count == 0 - ? Result.Ok() - : Invalid(sectionDirectory, ConnectionFileProblem.MissingField, missing); - } - - private static Result ValidateRanges(string sectionDirectory, PostgresConnectionDto dto) - { - var outOfRange = new List(); - - if (dto.Port!.Value is < LowestPort or > HighestPort) - { - outOfRange.Add(PortKey); - } - - if (dto.PollIntervalMs!.Value < LowestPollIntervalMs) - { - outOfRange.Add(PollIntervalKey); - } - - return outOfRange.Count == 0 - ? Result.Ok() - : Invalid(sectionDirectory, ConnectionFileProblem.OutOfRange, outOfRange); - } - - private static Result ValidateHost(string sectionDirectory, string? host) - { - return IsIPv4Address(host) - ? Result.Ok() - : Result.Fail(new ConnectionFileError( - sectionDirectory, - ConnectionFileProblem.HostNotIPv4, - [HostKey])); - } - - private static Result Invalid( - string sectionDirectory, - ConnectionFileProblem kind, - IReadOnlyList fieldNames) - { - return Result.Fail(new ConnectionFileError(sectionDirectory, kind, fieldNames)); - } } diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/Configuration/PostgresConnectionSettings.cs b/SemiPlot/SemiPlot.DataSource.Postgres/Configuration/PostgresConnectionSettings.cs index 039095b..1f77816 100644 --- a/SemiPlot/SemiPlot.DataSource.Postgres/Configuration/PostgresConnectionSettings.cs +++ b/SemiPlot/SemiPlot.DataSource.Postgres/Configuration/PostgresConnectionSettings.cs @@ -14,7 +14,6 @@ public sealed record PostgresConnectionSettings( string Username, string Password, TimeZoneInfo SourceTimeZone, - TimeSpan PollInterval, string Schema) { /// @@ -23,7 +22,7 @@ public sealed record PostgresConnectionSettings( public const int ConnectTimeoutSeconds = 15; /// - /// Client backstop for a server that stops answering; the poll's tick and the catalogue read override it. + /// Client backstop for a server that stops answering; the catalogue read overrides it. /// public const int CommandTimeoutSeconds = 300; @@ -59,7 +58,6 @@ public override string ToString() return $"{nameof(PostgresConnectionSettings)} {{ {nameof(Host)} = {Host}, " + $"{nameof(Port)} = {Port.ToString(invariant)}, " + $"{nameof(Database)} = {Database}, {nameof(Username)} = {Username}, {nameof(Password)} = ***, " - + $"{nameof(SourceTimeZone)} = {SourceTimeZone.Id}, {nameof(PollInterval)} = {PollInterval}, " - + $"{nameof(Schema)} = {Schema} }}"; + + $"{nameof(SourceTimeZone)} = {SourceTimeZone.Id}, {nameof(Schema)} = {Schema} }}"; } } diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataServiceCollectionExtensions.cs b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataServiceCollectionExtensions.cs index 43d592f..608d31f 100644 --- a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataServiceCollectionExtensions.cs +++ b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataServiceCollectionExtensions.cs @@ -41,13 +41,6 @@ internal static IServiceCollection AddPostgresData( provider.GetRequiredService(), provider.GetRequiredService>(), historyReadConcurrency)); - services.AddSingleton(provider => new PostgresLiveSource( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService>(), - provider.GetRequiredService().PollInterval)); services.AddSingleton(provider => new PostgresPenCatalogueEditor( provider.GetRequiredService(), provider.GetRequiredService(), diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresLiveSource.cs b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresLiveSource.cs deleted file mode 100644 index 671ca62..0000000 --- a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresLiveSource.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Reactive.Concurrency; -using System.Reactive.Linq; - -using Microsoft.Extensions.Logging; - -using Npgsql; - -using SemiPlot.Core.Data; -using SemiPlot.Core.Trends; - -namespace SemiPlot.DataSource.Postgres; - -/// Serves the live edge by polling the archive over the pooled . -public sealed class PostgresLiveSource : ILiveSource -{ - private readonly NpgsqlDataSource _dataSource; - private readonly ArchiveTimeConverter _timeConverter; - private readonly ArchiveExceptionMapper _exceptionMapper; - private readonly IScheduler _scheduler; - private readonly ILogger _logger; - private readonly TimeSpan _pollInterval; - - internal PostgresLiveSource( - NpgsqlDataSource dataSource, - ArchiveTimeConverter timeConverter, - ArchiveExceptionMapper exceptionMapper, - IScheduler scheduler, - ILogger logger, - TimeSpan pollInterval) - { - _dataSource = dataSource; - _timeConverter = timeConverter; - _exceptionMapper = exceptionMapper; - _scheduler = scheduler; - _logger = logger; - _pollInterval = pollInterval; - } - - /// - /// The live edge of the requested variables. Cold: each subscription starts a poll loop of its own, on the - /// injected scheduler and at the poll interval, holding its own baseline; disposing it cancels the loop's - /// query and its wait, so no further statement is issued. - /// - public IObservable> Subscribe(IReadOnlyList penIds) - { - var subscribedIds = penIds.ToArray(); - - return Observable.Create>(observer => - _scheduler.ScheduleAsync((_, cancellationToken) => PollAsync(subscribedIds, observer, cancellationToken))); - } - - private async Task PollAsync( - int[] penIds, - IObserver> observer, - CancellationToken cancellationToken) - { - var poll = new RealtimePoll( - _dataSource, - _timeConverter, - _exceptionMapper, - penIds, - _logger); - - try - { - while (!cancellationToken.IsCancellationRequested) - { - var samples = await poll.ReadOnceAsync(cancellationToken).ConfigureAwait(false); - - // A disposal landing while the query is in flight delivers nothing further. - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (samples.Count > 0) - { - observer.OnNext(samples); - } - - await _scheduler.Sleep(_pollInterval, cancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) - { - // Disposal ends the loop. - } - } -} diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/RealtimePoll.cs b/SemiPlot/SemiPlot.DataSource.Postgres/RealtimePoll.cs deleted file mode 100644 index b77b7ba..0000000 --- a/SemiPlot/SemiPlot.DataSource.Postgres/RealtimePoll.cs +++ /dev/null @@ -1,210 +0,0 @@ -using Microsoft.Extensions.Logging; - -using Npgsql; - -using NpgsqlTypes; - -using SemiPlot.Core.Trends; - -namespace SemiPlot.DataSource.Postgres; - -/// -/// The live edge of one subscription, as an awaitable read rather than as an observable; one instance per -/// subscription, and not thread-safe, the caller drives it from one loop at a time. -/// -internal sealed class RealtimePoll( - NpgsqlDataSource dataSource, - ArchiveTimeConverter timeConverter, - ArchiveExceptionMapper exceptionMapper, - int[] penIds, - ILogger logger) -{ - // docs/architecture/scada-archive.md#quality-and-gaps - private const int LastBeforeBreakQuality = 32; - - // A stalled tick holds the live edge for at most this long, not for the connection string's backstop. - private const int TickCommandTimeoutSeconds = 10; - - // docs/architecture/data-integration.md#realtime - private static readonly TimeSpan _breakAnchorStep = TimeSpan.FromMilliseconds(1); - - private static readonly IReadOnlyList _noSamples = []; - - private int _consecutiveFailures; - - // The archive's own naive wall clock, null until the baseline is read; never taken from the local clock: - // a clock difference between the two hosts would drop or repeat the first seconds. - private DateTime? _lastSeen; - - /// - /// One tick. The first one establishes the baseline and emits no sample; every later one emits the - /// rows written since the previous tick, converted to UTC. A failed tick emits no sample. - /// - public async Task> ReadOnceAsync(CancellationToken cancellationToken) - { - try - { - await using var connection = await dataSource - .OpenConnectionAsync(cancellationToken) - .ConfigureAwait(false); - - var samples = _lastSeen is { } lastSeen - ? await ReadSamplesAsync(connection, lastSeen, cancellationToken).ConfigureAwait(false) - : await ReadBaselineAsync(connection, cancellationToken).ConfigureAwait(false); - - _consecutiveFailures = 0; - - return samples; - } - catch (OperationCanceledException) - { - // Ahead of the general catch: a self-cancelled read is no failed tick. - throw; - } - catch (Exception exception) - { - LogFailure(exception); - - return _noSamples; - } - } - - internal static NpgsqlCommand CreateTickCommand(string statementText, NpgsqlConnection connection) - { - return new NpgsqlCommand(statementText, connection) { CommandTimeout = TickCommandTimeoutSeconds }; - } - - internal static void BindPoll(NpgsqlCommand command, int[] penIds, DateTime lastSeenArchiveLocal) - { - BindIdentifiers(command, penIds); - - command.Parameters.Add(new NpgsqlParameter("lastSeen", NpgsqlDbType.Timestamp) - { - Value = lastSeenArchiveLocal - }); - } - - internal static void BindBaseline(NpgsqlCommand command, int[] penIds) - { - BindIdentifiers(command, penIds); - } - - private static void BindIdentifiers(NpgsqlCommand command, int[] penIds) - { - command.Parameters.Add(new NpgsqlParameter("ids", NpgsqlDbType.Array | NpgsqlDbType.Integer) - { - Value = penIds - }); - } - - // A null @lastSeen returns no row for good and an unbounded read dumps the archive, so the first tick - // reads the edge and emits nothing. - private async Task> ReadBaselineAsync( - NpgsqlConnection connection, - CancellationToken cancellationToken) - { - await using var command = CreateTickCommand(ArchiveStatements.RealtimeBaseline, connection); - - BindBaseline(command, penIds); - - var answer = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - - if (answer is DateTime baseline) - { - Advance(baseline); - - logger.LogDebug("The realtime baseline for {SubscribedPenCount} variables is {Baseline:O}.", - penIds.Length, baseline); - } - else - { - logger.LogDebug("The {SubscribedPenCount} subscribed variables carry no archived row yet.", - penIds.Length); - } - - return _noSamples; - } - - private async Task> ReadSamplesAsync( - NpgsqlConnection connection, - DateTime lastSeen, - CancellationToken cancellationToken) - { - await using var command = CreateTickCommand(ArchiveStatements.RealtimePoll, connection); - - BindPoll(command, penIds, lastSeen); - - await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - - var samples = new List(); - var newest = lastSeen; - var rowCount = 0; - var droppedCount = 0; - var breakCount = 0; - - while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - var penId = reader.GetInt32(0); - var archiveLocal = reader.GetDateTime(1); - var timestampUtc = timeConverter.ToUtc(archiveLocal); - var isBreakMark = reader.GetInt32(3) == LastBeforeBreakQuality; - - rowCount++; - - if (archiveLocal > newest) - { - newest = archiveLocal; - } - - // docs/architecture/data-integration.md#realtime - if (reader.IsDBNull(2)) - { - droppedCount++; - } - else - { - samples.Add(new Sample(penId, timestampUtc, reader.GetDouble(2))); - } - - if (isBreakMark) - { - samples.Add(new Sample(penId, timestampUtc + _breakAnchorStep, null)); - breakCount++; - } - } - - Advance(newest); - - logger.LogDebug( - "The realtime poll read {RowCount} rows past {LastSeen:O}: {ValueCount} value samples, {DroppedCount} " - + "rows dropped for a null value, {BreakCount} breaks.", - rowCount, - lastSeen, - rowCount - droppedCount, - droppedCount, - breakCount); - - return samples; - } - - private void Advance(DateTime archiveLocal) - { - if (_lastSeen is null || archiveLocal > _lastSeen) - { - _lastSeen = archiveLocal; - } - } - - private void LogFailure(Exception exception) - { - var error = exceptionMapper.Map(exception, ArchiveStatements.TrendsRelation); - - _consecutiveFailures++; - - logger.LogWarning( - exception, - "The realtime poll tick failed, {ConsecutiveFailures} in a row: {Reason}", - _consecutiveFailures, - error.Message); - } -} diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/SemiPlot.DataSource.Postgres.csproj b/SemiPlot/SemiPlot.DataSource.Postgres/SemiPlot.DataSource.Postgres.csproj index ade7063..ee879e7 100644 --- a/SemiPlot/SemiPlot.DataSource.Postgres/SemiPlot.DataSource.Postgres.csproj +++ b/SemiPlot/SemiPlot.DataSource.Postgres/SemiPlot.DataSource.Postgres.csproj @@ -6,7 +6,6 @@ - diff --git a/SemiPlot/SemiPlot.Tests.Integration/ArchiveProviderFactory.cs b/SemiPlot/SemiPlot.Tests.Integration/ArchiveProviderFactory.cs index e75d459..9e03010 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/ArchiveProviderFactory.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/ArchiveProviderFactory.cs @@ -19,8 +19,6 @@ public static class ArchiveProviderFactory public static readonly ArchiveTimeConverter TimeConverter = new(SourceTimeZone); - public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1); - // The pool wait a test that caps the pool sets: one connect past the cap fails within it. private const int CappedPoolWaitSeconds = 2; @@ -83,7 +81,6 @@ private static PostgresConnectionSettings SettingsFor(string connectionString) Username: builder.Username ?? string.Empty, Password: builder.Password ?? string.Empty, SourceTimeZone: SourceTimeZone, - PollInterval: PollInterval, Schema: Schema); } } diff --git a/SemiPlot/SemiPlot.Tests.Integration/ArmedGate.cs b/SemiPlot/SemiPlot.Tests.Integration/ArmedGate.cs deleted file mode 100644 index eeffec2..0000000 --- a/SemiPlot/SemiPlot.Tests.Integration/ArmedGate.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace SemiPlot.Tests.Integration; - -/// -/// Counts, across every logger of the container, the poll's baseline lines (the nth is the nth armed point) and -/// the highest run of failed ticks it logs; one caller awaits each count at a time. -/// -internal sealed class ArmedGate(ILoggerProvider? inner = null) : ILoggerProvider -{ - public const string BaselineKey = "SubscribedPenCount"; - - public const string FailureRunKey = "ConsecutiveFailures"; - - private readonly Lock _guard = new(); - - private int _baselineLineCount; - - private int _highestConsecutiveFailures; - - private (int Count, TaskCompletionSource Reached)? _armedWaiter; - - private (int Count, TaskCompletionSource Reached)? _failureWaiter; - - public int HighestConsecutiveFailures - { - get - { - lock (_guard) - { - return _highestConsecutiveFailures; - } - } - } - - public Task Armed(int baselineLineCount) - { - lock (_guard) - { - if (_baselineLineCount >= baselineLineCount) - { - return Task.CompletedTask; - } - - var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - _armedWaiter = (baselineLineCount, waiter); - - return waiter.Task; - } - } - - public Task FailuresReached(int consecutiveFailures) - { - lock (_guard) - { - if (_highestConsecutiveFailures >= consecutiveFailures) - { - return Task.CompletedTask; - } - - var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - _failureWaiter = (consecutiveFailures, waiter); - - return waiter.Task; - } - } - - public ILogger CreateLogger(string categoryName) - { - return new ForwardingLogger(this, inner?.CreateLogger(categoryName)); - } - - // The inner provider belongs to the caller that passed it in. - public void Dispose() - { - } - - private void Record(TState state) - { - if (state is not IReadOnlyList> values) - { - return; - } - - lock (_guard) - { - foreach (var (key, value) in values) - { - if (key == BaselineKey) - { - _baselineLineCount++; - _armedWaiter = Release(_armedWaiter, _baselineLineCount); - } - else if (key == FailureRunKey && value is int consecutiveFailures) - { - _highestConsecutiveFailures = Math.Max(_highestConsecutiveFailures, consecutiveFailures); - _failureWaiter = Release(_failureWaiter, _highestConsecutiveFailures); - } - } - } - } - - private static (int Count, TaskCompletionSource Reached)? Release( - (int Count, TaskCompletionSource Reached)? waiter, - int reached) - { - if (waiter is not { } pending || pending.Count > reached) - { - return waiter; - } - - pending.Reached.TrySetResult(); - - return null; - } - - private sealed class ForwardingLogger(ArmedGate gate, ILogger? inner) : ILogger - { - public IDisposable? BeginScope(TState state) - where TState : notnull - { - return inner?.BeginScope(state); - } - - public bool IsEnabled(LogLevel logLevel) - { - return true; - } - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - gate.Record(state); - inner?.Log(logLevel, eventId, state, exception, formatter); - } - } -} diff --git a/SemiPlot/SemiPlot.Tests.Integration/BatchCollector.cs b/SemiPlot/SemiPlot.Tests.Integration/BatchCollector.cs deleted file mode 100644 index e4ad4d6..0000000 --- a/SemiPlot/SemiPlot.Tests.Integration/BatchCollector.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace SemiPlot.Tests.Integration; - -/// The gate is taken before the triggering row is written and awaited after it. -internal sealed class BatchCollector : IDisposable -{ - private readonly List _batches = []; - - private readonly Lock _guard = new(); - - private readonly IDisposable _subscription; - - private TaskCompletionSource? _nextBatch; - - public BatchCollector(IObservable batches) - { - _subscription = batches.Subscribe(Collect); - } - - public IReadOnlyList Batches - { - get - { - lock (_guard) - { - return [.. _batches]; - } - } - } - - public Task NextBatch() - { - lock (_guard) - { - var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - _nextBatch = gate; - - return gate.Task; - } - } - - public void Dispose() - { - _subscription.Dispose(); - } - - private void Collect(T batch) - { - lock (_guard) - { - _batches.Add(batch); - - _nextBatch?.TrySetResult(); - } - } -} diff --git a/SemiPlot/SemiPlot.Tests.Integration/CatalogueReadUnreachableTests.cs b/SemiPlot/SemiPlot.Tests.Integration/CatalogueReadUnreachableTests.cs index 7acedb0..b86eadd 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/CatalogueReadUnreachableTests.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/CatalogueReadUnreachableTests.cs @@ -15,7 +15,7 @@ namespace SemiPlot.Tests.Integration; [Trait("Category", "Integration")] public sealed class CatalogueReadUnreachableTests { - private static readonly PostgresConnectionSettings _settings = UnreachableArchive.Settings(TimeSpan.FromSeconds(1)); + private static readonly PostgresConnectionSettings _settings = UnreachableArchive.Settings(); [Fact] public async Task ACatalogueReadOverARefusedSocket_AnswersUnreachableNamingTheArchive() diff --git a/SemiPlot/SemiPlot.Tests.Integration/ConvergeReadinessTests.cs b/SemiPlot/SemiPlot.Tests.Integration/ConvergeReadinessTests.cs index 0121da6..b986a4d 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/ConvergeReadinessTests.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/ConvergeReadinessTests.cs @@ -29,7 +29,8 @@ public async Task AServerThatAcceptsAndClosesTheSocketIsWaitedFor() $"Host=127.0.0.1;Port={port};Database=postgres;Username=postgres;Password=x", "C:\\config", null, - SeederOptions.DefaultChangeSeconds); + SeederOptions.DefaultChangeSeconds, + 55875); using var giveUp = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); giveUp.CancelAfter(TimeSpan.FromSeconds(2)); diff --git a/SemiPlot/SemiPlot.Tests.Integration/ConvergeTests.cs b/SemiPlot/SemiPlot.Tests.Integration/ConvergeTests.cs index 2fba227..66c6d28 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/ConvergeTests.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/ConvergeTests.cs @@ -2,6 +2,7 @@ using Npgsql; +using SemiPlot.DataSource.OpcUa.Configuration; using SemiPlot.DataSource.Postgres.Configuration; using SemiPlot.Tools.ArchiveSeeder; @@ -18,6 +19,9 @@ namespace SemiPlot.Tests.Integration; [Trait("Category", "Integration")] public sealed class ConvergeTests(PostgresContainerFixture postgresContainerFixture) { + // Only written into opc/opc.yaml; nothing dials it. + private const int OpcPort = 55875; + [Fact] public async Task ConvergeCreatesSeedsWritesTheFileAndRecreatesOnASecondRun() { @@ -29,7 +33,8 @@ public async Task ConvergeCreatesSeedsWritesTheFileAndRecreatesOnASecondRun() server.AdminConnectionString, configDirectory, ArchiveTemplate.Slice.End, - ArchiveTemplate.Slice.ChangeSeconds); + ArchiveTemplate.Slice.ChangeSeconds, + OpcPort); try { @@ -51,6 +56,12 @@ public async Task ConvergeCreatesSeedsWritesTheFileAndRecreatesOnASecondRun() loaded.Value.Database.Should().Be(database); loaded.Value.Username.Should().Be(BenchRoles.PlotRole); + var opc = OpcUaConnectionLoader.Load(Path.Combine(configDirectory, OpcFileWriter.DirectoryName)); + + opc.IsSuccess.Should().BeTrue(); + opc.Value.Host.Should().Be(DemoScadaServer.Host); + opc.Value.Port.Should().Be(OpcPort); + var oidBefore = await DatabaseOidAsync(server, database); await using var held = new NpgsqlConnection(server.PlotConnectionStringFor(database)); diff --git a/SemiPlot/SemiPlot.Tests.Integration/DemoScada/DemoScadaHost.cs b/SemiPlot/SemiPlot.Tests.Integration/DemoScada/DemoScadaHost.cs index 75e42d1..cdcf81b 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/DemoScada/DemoScadaHost.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/DemoScada/DemoScadaHost.cs @@ -18,6 +18,9 @@ public sealed class DemoScadaHost : IDisposable // 4840 is where the stack binds when asked for port 0, and 49875 is the real Simple-Scada's port. private static readonly int[] _reservedPorts = [4840, 49875]; + // A port picked free can be taken in the meantime by an outgoing socket, which lets go of it within seconds. + private static readonly TimeSpan _busyPortWait = TimeSpan.FromSeconds(1); + private readonly ILoggerFactory _loggerFactory; public DemoScadaHost() @@ -74,18 +77,67 @@ public Task StartOnPortAsync(FollowOptions options) return DemoScadaServer.StartAsync(options, ArchiveZone, CertificateDirectory, _loggerFactory); } + /// + /// Starts a server on a port a source already dials, waiting while another socket still holds it. + /// + public async Task StartOnPortOnceFreeAsync(FollowOptions options) + { + for (var attempt = 1; ; attempt++) + { + try + { + return await StartOnPortAsync(options); + } + catch (SeederException exception) when (attempt < StartAttempts && IsBusyPort(exception)) + { + await Task.Delay(_busyPortWait); + } + } + } + + /// A port nothing listens on now; another socket may take it before the caller binds it. public static int FreePort() + { + using var listener = ListenOnFreePort(); + + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + /// A started loopback listener on a free port that is not reserved. + public static TcpListener ListenOnFreePort() { while (true) { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - if (!_reservedPorts.Contains(port)) + if (!_reservedPorts.Contains(((IPEndPoint)listener.LocalEndpoint).Port)) + { + return listener; + } + + listener.Dispose(); + } + } + + /// A started loopback listener on a port a source dials, waiting while another socket holds it. + public static async Task ListenOnPortOnceFreeAsync(int port) + { + for (var attempt = 1; ; attempt++) + { + var listener = new TcpListener(IPAddress.Loopback, port); + + try + { + listener.Start(); + + return listener; + } + catch (SocketException exception) when (attempt < StartAttempts + && exception.SocketErrorCode == SocketError.AddressAlreadyInUse) { - return port; + listener.Dispose(); + await Task.Delay(_busyPortWait); } } } @@ -97,9 +149,7 @@ public string ServerLog() return records.Count == 0 ? "the server logged nothing at Warning or above" - : "the server logged " + string.Join( - Environment.NewLine, - records.Select(record => $"{record.Level} {record.Category}: {record.Message} {record.Exception}")); + : "the server logged " + LogRecordLines.Of(records); } public void Dispose() diff --git a/SemiPlot/SemiPlot.Tests.Integration/DemoScada/LogRecordLines.cs b/SemiPlot/SemiPlot.Tests.Integration/DemoScada/LogRecordLines.cs new file mode 100644 index 0000000..12f3c97 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Integration/DemoScada/LogRecordLines.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Logging.Testing; + +namespace SemiPlot.Tests.Integration.DemoScada; + +/// Collected log records as text for an assertion's reason, one record per line. +internal static class LogRecordLines +{ + public static string Of(IEnumerable records) + { + return string.Join( + Environment.NewLine, + records.Select(record => $"{record.Level} {record.Category}: {record.Message} {record.Exception}")); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Integration/ExplainPlanTests.cs b/SemiPlot/SemiPlot.Tests.Integration/ExplainPlanTests.cs index e5dc21e..a627fb7 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/ExplainPlanTests.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/ExplainPlanTests.cs @@ -76,10 +76,6 @@ public sealed class ExplainPlanTests( // almost whole. private static readonly TimeSpan _explainedWindow = TimeSpan.FromMinutes(2); - // How far back from the archive's end the explained poll's bound sits. A tick reads what was written - // since the previous one, so its span is the poll interval and never a slice of the archive. - private static readonly TimeSpan _polledTail = TimeSpan.FromMinutes(1); - // Far enough into the archive that every explained pen has a row before the window opens, so the seed // branch has something to find. private static readonly TimeSpan _seedProbeOffset = TimeSpan.FromMinutes(5); @@ -171,52 +167,6 @@ public async Task TheBucketedRawPlanFeedsItsWindowAggregateThroughAnIndex() AssertSeedWalkStaysWithinItsBound(plan, "history window"); } - // The poll's own index plan. - [Fact] - public async Task ThePollPlanReachesItsRowsThroughAnIndex() - { - var database = analysedArchive.Database; - - var plan = await ExplainAsync( - database.PlotConnectionString, - ArchiveStatements.RealtimePoll, - command => RealtimePoll.BindPoll(command, ExplainedPenIds(), PolledFrom()), - TestContext.Current.CancellationToken); - - _sequentialScanOverRows.IsMatch(plan).Should().BeFalse( - "The realtime poll no longer narrows on the primary key: the plan reads a row-holding trends " - + "partition sequentially, so every tick walks the current day instead of stepping into an " - + $"index.{Environment.NewLine}{plan}"); - - _indexReachedRows.IsMatch(plan).Should().BeTrue( - "The realtime poll plan reaches no row-holding trends partition through an index, neither " - + "directly nor through a bitmap, so a tick finds its rows by reading rows it does not " - + $"want.{Environment.NewLine}{plan}"); - } - - // The baseline is held to the strict index-edge form, the same one the extent statement is held to. - [Fact] - public async Task TheBaselinePlanReachesEachBoundByAnIndexEdge() - { - var database = analysedArchive.Database; - - var plan = await ExplainAsync( - database.PlotConnectionString, - ArchiveStatements.RealtimeBaseline, - command => RealtimePoll.BindBaseline(command, ExplainedPenIds()), - TestContext.Current.CancellationToken); - - _sequentialScanOverRows.IsMatch(plan).Should().BeFalse( - "The baseline statement's per-variable bounded subquery has been lost: the plan reads a " - + "row-holding trends partition sequentially, so establishing a subscription's starting " - + $"point walks the archive.{Environment.NewLine}{plan}"); - - _indexEdgeReachedRows.IsMatch(plan).Should().BeTrue( - "The baseline plan reaches no row-holding trends partition through an index scan. The lateral " - + "subquery must step to one index edge per variable; a bitmap or a plan without it reads " - + $"rows the bound does not need.{Environment.NewLine}{plan}"); - } - [Fact] public async Task TheExportSeedPlanWalksBackToTheSeedWithinItsBound() { @@ -332,13 +282,6 @@ private static void AssertSeedWalkStaysWithinItsBound( + $"than a look-back of one partition width can reach.{Environment.NewLine}{plan}"); } - // Near the archive's end, which is where a live subscription's bound always sits: the poll reads the - // tail written since the previous tick, never a span of the archive. - private static DateTime PolledFrom() - { - return ArchiveTemplate.Slice.End - _polledTail; - } - // The seeder's timestamps are the archive's own wall clock already. private static void BindBucketedParametersAt(NpgsqlCommand command, DateTime windowStart) { diff --git a/SemiPlot/SemiPlot.Tests.Integration/Journeys/BreakRenderArchiveJourneyTests.cs b/SemiPlot/SemiPlot.Tests.Integration/Journeys/BreakRenderArchiveJourneyTests.cs index 85d2883..1044a39 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/Journeys/BreakRenderArchiveJourneyTests.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/Journeys/BreakRenderArchiveJourneyTests.cs @@ -1,4 +1,5 @@ using System.Reactive.Concurrency; +using System.Reactive.Linq; using Avalonia.Headless.XUnit; @@ -10,6 +11,8 @@ using ScottPlot; using SemiPlot.Core.Data; +using SemiPlot.Core.Data.Errors; +using SemiPlot.Core.Trends; using SemiPlot.Tools.ArchiveSeeder; using SemiPlot.UI.Bridge; using SemiPlot.UI.Chart; @@ -57,7 +60,7 @@ public async Task TheFirstSeededBreakLeavesTheRenderedCurvesBroken() { await using var services = ArchiveProviderFactory.Build(seededArchive.Database.PlotConnectionString); var dataProvider = services.GetRequiredService(); - var liveSource = services.GetRequiredService(); + var liveSource = new SilentLiveSource(); var dataScheduler = services.GetRequiredService(); var catalogue = await dataProvider.QueryPensAsync(); var extent = await dataProvider.QueryArchiveExtentAsync(); @@ -198,4 +201,18 @@ private static int Chroma(byte red, byte green, byte blue) } private readonly record struct ArchiveBreak(DateTime StartUtc, DateTime EndUtc); + + /// A connected live source that delivers nothing, so every drawn column comes from the archive. + private sealed class SilentLiveSource : ILiveSource + { + public IObservable States { get; } = + Observable.Never().StartWith(LiveSourceState.Connected); + + public IObservable PenFaults { get; } = Observable.Never(); + + public IObservable> Subscribe(IReadOnlyList penIds) + { + return Observable.Never>(); + } + } } diff --git a/SemiPlot/SemiPlot.Tests.Integration/Journeys/LiveEdgeArchiveJourneyTests.cs b/SemiPlot/SemiPlot.Tests.Integration/Journeys/LiveEdgeArchiveJourneyTests.cs deleted file mode 100644 index 440da1e..0000000 --- a/SemiPlot/SemiPlot.Tests.Integration/Journeys/LiveEdgeArchiveJourneyTests.cs +++ /dev/null @@ -1,318 +0,0 @@ -using System.Reactive.Concurrency; -using System.Reactive.Linq; - -using Avalonia.Headless.XUnit; - -using AwesomeAssertions; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -using SemiPlot.Core.Data; -using SemiPlot.Core.Trends; -using SemiPlot.Tools.ArchiveSeeder; -using SemiPlot.UI.Bridge; -using SemiPlot.UI.Chart; -using SemiPlot.UI.Messages; - -using Xunit; - -namespace SemiPlot.Tests.Integration.Journeys; - -// Proves a row written while the app runs reaches the chart's live edge exactly once, composing -// TrendCoordinator, TrendChartViewModel and navigation over RealtimeSubscriptionTests' live source rule. -// Clones the template because this class appends; every wait is an awaited emission bounded by GateBound. -[Collection(ArchiveDatabaseCollection.Name)] -[Trait("Component", "UI")] -[Trait("Area", "Chart")] -[Trait("Category", "Integration")] -public sealed class LiveEdgeArchiveJourneyTests(PostgresContainerFixture postgresContainerFixture) - : ClonedArchiveTest(postgresContainerFixture, CloneSource.Template) -{ - [AvaloniaFact] - public async Task ARowWrittenAfterStartupReachesTheChartOnceAndMovesItsLiveEdge() - { - await using var stand = await OpenStandAsync(); - var catalogue = stand.Catalogue; - var chart = stand.Chart; - var batches = stand.Batches; - - stand.Extent.IsEmpty.Should().BeFalse("the live edge is asserted against the archive's own last row"); - - var seededLastUtc = stand.Extent.LastUtc; - var seededLastLocal = ArchiveProviderFactory.TimeConverter.ToArchiveLocal(seededLastUtc); - var firstAppendLocal = seededLastLocal.AddSeconds(1); - var secondAppendLocal = seededLastLocal.AddSeconds(2); - var firstAppendUtc = ArchiveProviderFactory.TimeConverter.ToUtc(firstAppendLocal); - var secondAppendUtc = ArchiveProviderFactory.TimeConverter.ToUtc(secondAppendLocal); - - chart.Navigation.IsSticky.Should().BeTrue("a window that does not follow the edge cannot be moved by it"); - chart.Navigation.To.Should().Be(seededLastUtc, "the window opens on the archive's own last sample"); - - // The gate is taken before the write and awaited after it, so the delivery cannot slip between them. - var firstDelivery = batches.NextBatch(); - - await AppendAsync(firstAppendLocal, catalogue, tick: 1); - await firstDelivery.WaitAsync(GateBound, TestContext.Current.CancellationToken); - - var afterTheFirstWrite = batches.Batches; - - afterTheFirstWrite.Should().HaveCount( - 1, - "one write of one row per variable is one batch: a second batch here is the same row delivered " - + "twice"); - AssertCarries(afterTheFirstWrite[0], firstAppendUtc, catalogue, tick: 1); - - var secondDelivery = batches.NextBatch(); - - await AppendAsync(secondAppendLocal, catalogue, tick: 2); - await secondDelivery.WaitAsync(GateBound, TestContext.Current.CancellationToken); - - var afterTheSecondWrite = batches.Batches; - - afterTheSecondWrite.Should().HaveCount(2); - AssertCarries(afterTheSecondWrite[1], secondAppendUtc, catalogue, tick: 2); - - // The monotonic lastSeen rule, read from the consumer's side: two writes, two timestamps, in order - // and each of them once. A poll binding its lower bound inclusively would repeat the first here. - afterTheSecondWrite - .SelectMany(batch => batch.Timestamps) - .Should().Equal([firstAppendUtc, secondAppendUtc]); - - chart.Navigation.To.Should().Be( - secondAppendUtc, - "ChartRealtimeApplier hands the last timestamp of a batch to ChartNavigationController.OnLiveEdge, " - + "so the whole path down to the axis is covered rather than the pen states alone"); - chart.Pens.Should().AllSatisfy(pen => pen.CurrentValue.Should().Be(ValueFor(pen.Pen.PenId, tick: 2))); - } - - [AvaloniaFact] - public async Task ARowMarkingTheLastSampleBeforeABreakReachesTheChartAsItsValueAndThenABreak() - { - await using var stand = await OpenStandAsync(); - var pen = stand.Catalogue[0]; - var seededLastLocal = ArchiveProviderFactory.TimeConverter.ToArchiveLocal(stand.Extent.LastUtc); - var appendLocal = seededLastLocal.AddSeconds(1); - var appendUtc = ArchiveProviderFactory.TimeConverter.ToUtc(appendLocal); - var value = ValueFor(pen.PenId, tick: 1); - var delivery = stand.Batches.NextBatch(); - - await AppendAsync(appendLocal, [pen], tick: 1, ArchiveRow.LastBeforeBreakQuality); - await delivery.WaitAsync(GateBound, TestContext.Current.CancellationToken); - - // The batch is what the poll delivered, which no history read can race. - var batch = stand.Batches.Batches.Should().ContainSingle().Subject; - var breakUtc = appendUtc.AddMilliseconds(1); - - batch.Timestamps.Should().Equal([appendUtc, breakUtc]); - var penValues = batch.Pens.Should().ContainSingle().Subject; - penValues.PenId.Should().Be(pen.PenId); - penValues.Values.Should().Equal([value, null]); - stand.Chart.Navigation.To.Should().Be(breakUtc, "the break is the batch's last timestamp"); - - var state = stand.Chart.Pens.Should().ContainSingle(candidate => candidate.Pen.PenId == pen.PenId).Subject; - var columns = state.Line.SnapshotColumns(); - - // A landed history read may add a NaN ahead of the break: - // docs/architecture/charting.md#per-pen-plottable-envelopeline - var lastRealColumn = columns.Last(column => !double.IsNaN(column.Center)); - - lastRealColumn.X.Should().Be(LocalTimeAxis.ToAxis(appendUtc)); - lastRealColumn.Center.Should().Be(value); - columns[^1].X.Should().Be(LocalTimeAxis.ToAxis(breakUtc)); - double.IsNaN(columns[^1].Center).Should().BeTrue("the row marks the last sample before a break"); - state.CurrentValue.Should().Be(value); - } - - // The archive is per-variable and change-based with a deadband, so one poll tick spans as many distinct - // timestamps as there are variables; a pen with no sample at a timestamp is left alone rather than - // appended as null, which would draw a break the archive never recorded. - [AvaloniaFact] - public async Task RowsOnAVariableOfTheirOwnReachTheChartWithoutBreakingAnyPen() - { - await using var stand = await OpenStandAsync(); - var catalogue = stand.Catalogue; - var chart = stand.Chart; - - catalogue.Count.Should().BeGreaterThan( - 1, "a timestamp only one pen sampled needs a second pen to be absent from it"); - - var seededLastLocal = ArchiveProviderFactory.TimeConverter.ToArchiveLocal(stand.Extent.LastUtc); - var delivery = stand.Batches.NextBatch(); - - await AppendStaggeredAsync(seededLastLocal, catalogue, tick: 1); - await delivery.WaitAsync(GateBound, TestContext.Current.CancellationToken); - - // One COPY is one transaction, so the whole set becomes visible to a single poll tick and reaches - // the coordinator as one buffer window. - var batch = stand.Batches.Batches.Should().ContainSingle().Subject; - - batch.Timestamps.Should().HaveCount(catalogue.Count); - batch.Pens.Should().AllSatisfy(values => values.TimestampsUtc.Should().ContainSingle()); - batch.Pens.SelectMany(values => values.TimestampsUtc).Should().OnlyHaveUniqueItems(); - - chart.Pens.Should().AllSatisfy(pen => - { - pen.Line.SnapshotColumns().Should().NotContain(column => double.IsNaN(column.Center)); - pen.CurrentValue.Should().Be(ValueFor(pen.Pen.PenId, tick: 1)); - }); - } - - // Only the raw layer appends a realtime value as a point of its own; a coarse layer folds a value - // into the last column instead, and the assertions read the appended point. - private async Task OpenStandAsync() - { - var armed = new ArmedGate(); - var services = ArchiveProviderFactory.Build(Database.PlotConnectionString, armed); - LiveEdgeStand? stand = null; - - try - { - var dataProvider = services.GetRequiredService(); - var liveSource = services.GetRequiredService(); - var dataScheduler = services.GetRequiredService(); - var catalogue = await dataProvider.QueryPensAsync(); - var extent = await dataProvider.QueryArchiveExtentAsync(); - - catalogue.IsSuccess.Should().BeTrue(ArchiveReadSupport.Describe(catalogue)); - extent.IsSuccess.Should().BeTrue(ArchiveReadSupport.Describe(extent)); - - var coordinator = new TrendCoordinator( - dataProvider, liveSource, catalogue.Value, dataScheduler, ImmediateScheduler.Instance); - - var messagePanel = new MessagePanelViewModel(); - var chart = new TrendChartViewModel( - coordinator, - new NoMarkers(), - dataScheduler, - ImmediateScheduler.Instance, - TimeZoneInfo.Utc, - messagePanel, - NullLogger.Instance); - var batches = new BatchCollector(coordinator.RealtimeBatches); - stand = new LiveEdgeStand( - services, coordinator, armed, chart, messagePanel, batches, catalogue.Value, extent.Value); - - chart.ApplyCatalogue(catalogue.Value); - - chart.Navigation.SeedFromArchiveExtent(extent.Value); - - var scalesBefore = chart.ScalesRevision; - chart.RequestInitialHistory(); - await JourneyWait.Until(() => chart.ScalesRevision > scalesBefore); - - // A failed history read also bumps ScalesRevision, so the wait alone does not prove the read landed. - messagePanel.HasEntries.Should().BeFalse("the initial history read has to succeed"); - - chart.Navigation.ActiveLayer.Should().Be(AggregationLayer.Raw); - - await armed.Armed(1).WaitAsync(GateBound, TestContext.Current.CancellationToken); - - return stand; - } - catch - { - if (stand is null) - { - await services.DisposeAsync(); - armed.Dispose(); - } - else - { - await stand.DisposeAsync(); - } - - throw; - } - } - - // One row per variable, written through the appending path ArchiveWriter takes for a follow run: the - // archive already carries the template's rows, so the seeded refusal has to be stood down. The day - // partition the rows fall into already exists, and CreateStatement's IF NOT EXISTS passes through it. - private async Task AppendAsync( - DateTime archiveLocal, - IReadOnlyList pens, - int tick, - int quality = ArchiveRow.OrdinaryQuality) - { - var rows = pens - .Select(pen => new ArchiveRow( - pen.PenId, - ArchiveRow.RawLayer, - archiveLocal, - ValueFor(pen.PenId, tick), - quality)) - .ToArray(); - - var written = await Writer() - .WriteAsync(rows, archiveLocal, archiveLocal.AddSeconds(1), allowExistingRows: true); - - written.Should().Be(rows.Length); - } - - // One row per variable, each on a second of its own, so no two variables share a timestamp. Written in - // one COPY, which is one transaction: the whole set becomes visible to the poll at the same instant. - private async Task AppendStaggeredAsync(DateTime baseLocal, IReadOnlyList pens, int tick) - { - var rows = pens - .Select((pen, index) => new ArchiveRow( - pen.PenId, - ArchiveRow.RawLayer, - baseLocal.AddSeconds(index + 1), - ValueFor(pen.PenId, tick), - ArchiveRow.OrdinaryQuality)) - .ToArray(); - - var written = await Writer().WriteAsync( - rows, - baseLocal.AddSeconds(1), - baseLocal.AddSeconds(pens.Count + 1), - allowExistingRows: true); - - written.Should().Be(rows.Length); - } - - private static void AssertCarries(RealtimeBatch batch, DateTime timestampUtc, IReadOnlyList pens, int tick) - { - batch.Timestamps.Should().Equal([timestampUtc]); - batch.Pens.Select(values => values.PenId).Should().BeEquivalentTo(pens.Select(pen => pen.PenId)); - batch.Pens.Should().AllSatisfy(values => values.Values.Should().Equal([ValueFor(values.PenId, tick)])); - } - - // Distinct per variable and per write, so a batch carrying the right timestamp with somebody else's - // value still fails. - private static double ValueFor(int penId, int tick) - { - return penId + (tick * 0.25); - } - - private sealed class LiveEdgeStand( - ServiceProvider services, - TrendCoordinator coordinator, - ArmedGate armed, - TrendChartViewModel chart, - MessagePanelViewModel messagePanel, - BatchCollector batches, - IReadOnlyList catalogue, - ArchiveExtent extent) : IAsyncDisposable - { - public TrendChartViewModel Chart => chart; - - public BatchCollector Batches => batches; - - public IReadOnlyList Catalogue => catalogue; - - public ArchiveExtent Extent => extent; - - public async ValueTask DisposeAsync() - { - batches.Dispose(); - chart.Dispose(); - messagePanel.Dispose(); - coordinator.Dispose(); - - await services.DisposeAsync(); - armed.Dispose(); - } - } -} diff --git a/SemiPlot/SemiPlot.Tests.Integration/Journeys/OpcUaLiveEdgeJourneyTests.cs b/SemiPlot/SemiPlot.Tests.Integration/Journeys/OpcUaLiveEdgeJourneyTests.cs new file mode 100644 index 0000000..51ed8a7 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Integration/Journeys/OpcUaLiveEdgeJourneyTests.cs @@ -0,0 +1,248 @@ +using System.Reactive.Concurrency; + +using Avalonia.Headless.XUnit; + +using AwesomeAssertions; + +using FluentResults; + +using Microsoft.Extensions.Logging.Abstractions; + +using ReactiveUI.Avalonia; + +using SemiPlot.Core.Data; +using SemiPlot.Core.Trends; +using SemiPlot.DataSource.Postgres; +using SemiPlot.Tests.Integration.DemoScada; +using SemiPlot.Tests.Integration.OpcUa; +using SemiPlot.Tools.ArchiveSeeder; +using SemiPlot.UI.Bridge; +using SemiPlot.UI.Chart; +using SemiPlot.UI.Messages; + +using Xunit; + +namespace SemiPlot.Tests.Integration.Journeys; + +// A demo server, an OpcUaLiveSource over it and a history fake ending a minute before the server's start, opened +// as TrendWindow opens the chart. The UI scheduler is the dispatcher, so history and live batches land in order. +[Collection(DemoScadaCollection.Name)] +[Trait("Component", "UI")] +[Trait("Area", "Chart")] +[Trait("Category", "Integration")] +public sealed class OpcUaLiveEdgeJourneyTests(OpcUaLiveEdgeJourneyTests.ServerFixture fixture) + : IClassFixture +{ + private const int TestTimeoutMilliseconds = 180000; + private const int HistoryColumnCount = 5; + private const int LiveValueFloor = 3; + + private static readonly TimeSpan _historyLead = TimeSpan.FromMinutes(1); + private static readonly TimeSpan _historyStep = TimeSpan.FromMinutes(1); + private static readonly TimeSpan _aheadBreakStep = TimeSpan.FromMilliseconds(1); + + [AvaloniaFact(Timeout = TestTimeoutMilliseconds)] + public async Task AServedValue_ReachesTheChartBehindABreak_AndMovesTheStickyWindowToTheNewestStamp() + { + var historyEndUtc = fixture.StartFloorUtc - _historyLead; + var catalogue = Catalogue(); + var history = new FixedHistory(historyEndUtc); + await using var live = new LiveSourceHost(LiveSourceHost.PortOf(fixture.Server)); + using var coordinator = new TrendCoordinator( + history, live.Source, catalogue, DefaultScheduler.Instance, AvaloniaScheduler.Instance); + using var chart = new TrendChartViewModel( + coordinator, + new NoMarkers(), + DefaultScheduler.Instance, + AvaloniaScheduler.Instance, + TimeZoneInfo.Utc, + new MessagePanelViewModel(), + NullLogger.Instance); + DateTime? newestDeliveredUtc = null; + using var newestTap = coordinator.RealtimeBatches.Subscribe(batch => + newestDeliveredUtc = Later(newestDeliveredUtc, batch.Timestamps[^1])); + + chart.Navigation.SeedFromArchiveExtent(history.Extent); + chart.ApplyCatalogue(catalogue); + chart.RequestInitialHistory(); + chart.Navigation.ActiveLayer.Should().Be( + AggregationLayer.Raw, "a raw window appends each value as its own column"); + + await WaitUntil(() => chart.Pens.All(HoldsTheHistoryAndABreak), "the history and a break", live); + await WaitUntil(() => chart.Pens.All(HoldsLiveValues), "live values behind the break", live); + + foreach (var pen in chart.Pens) + { + AssertTheLine(pen, history.Columns, historyEndUtc, Because(live)); + } + + chart.Navigation.IsSticky.Should().BeTrue(Because(live)); + chart.Navigation.To.Should().Be( + newestDeliveredUtc, + "the sticky window follows the newest stamp the live edge delivered; {0}", + Because(live)); + chart.Navigation.To.Should().BeAfter(historyEndUtc, Because(live)); + } + + private static IReadOnlyList Catalogue() + { + // Every pen visible, the hidden-on-start one included, so each one is read for its history. + return [.. RawLayerGenerator.SelectPens(ServerFixture.Options.PenCount) + .Select(pen => new Pen( + pen.PenId, + pen.Name, + pen.Groups, + pen.Color, + pen.Unit, + pen.Format, + EnabledOnStart: true, + LineStyle: pen.LineStyle))]; + } + + private static DateTime Later(DateTime? newest, DateTime candidate) + { + return newest is { } known && known >= candidate ? known : candidate; + } + + private static bool HoldsTheHistoryAndABreak(TrendPenState pen) + { + var columns = pen.Line.SnapshotColumns(); + + return columns.Count > HistoryColumnCount && double.IsNaN(columns[HistoryColumnCount].Center); + } + + private static bool HoldsLiveValues(TrendPenState pen) + { + return pen.Line.SnapshotColumns().Count >= HistoryColumnCount + 1 + LiveValueFloor; + } + + private static void AssertTheLine( + TrendPenState pen, + IReadOnlyList historyColumns, + DateTime historyEndUtc, + string because) + { + var penId = pen.Pen.PenId; + var columns = pen.Line.SnapshotColumns(); + var aheadBreak = columns[HistoryColumnCount]; + var liveColumns = columns.Skip(HistoryColumnCount + 1).ToArray(); + var converter = new ArchiveTimeConverter(DemoScadaHost.ArchiveZone); + + columns.Take(HistoryColumnCount).Should().Equal(historyColumns, "pen {0}; {1}", penId, because); + double.IsNaN(aheadBreak.Center).Should().BeTrue("pen {0}; {1}", penId, because); + (LocalTimeAxis.FromAxis(liveColumns[0].X) - LocalTimeAxis.FromAxis(aheadBreak.X)).Should().Be( + _aheadBreakStep, + "the chart writes its break 1 ms ahead of the pen's first live column; pen {0}; {1}", + penId, + because); + + foreach (var column in liveColumns) + { + var stampUtc = LocalTimeAxis.FromAxis(column.X); + var expected = LiveTailGenerator + .ChangesAt(ServerFixture.Options, converter.ToArchiveLocal(stampUtc)) + .Single(row => row.Id == penId); + + stampUtc.Should().BeAfter(historyEndUtc, "pen {0}; {1}", penId, because); + column.Center.Should().Be(expected.Value, "pen {0} at {1:O}; {2}", penId, stampUtc, because); + column.Min.Should().Be(column.Center, "pen {0}; {1}", penId, because); + column.Max.Should().Be(column.Center, "pen {0}; {1}", penId, because); + } + } + + private async Task WaitUntil(Func condition, string awaited, LiveSourceHost live) + { + await FluentActions.Awaiting(() => JourneyWait.Until(condition)) + .Should().NotThrowAsync("the chart should hold {0}; {1}", awaited, Because(live)); + } + + private string Because(LiveSourceHost live) + { + return $"{live.Describe()}; {fixture.Host.ServerLog()}"; + } + + /// One demo server for the class, and a moment taken before it started. + public sealed class ServerFixture : IAsyncLifetime + { + public static readonly FollowOptions Options = DemoScadaHost.Options with { ChangeSeconds = 0.25 }; + + private DemoScadaServer? _server; + + public DemoScadaHost Host { get; } = new(); + + public DemoScadaServer Server => + _server ?? throw new InvalidOperationException("The demo server was used before it was started."); + + /// No later than the server's start instant, which the server does not expose. + public DateTime StartFloorUtc { get; private set; } + + public async ValueTask InitializeAsync() + { + StartFloorUtc = DateTime.UtcNow; + _server = await Host.StartAsync(Options); + } + + public async ValueTask DisposeAsync() + { + if (_server is not null) + { + await _server.DisposeAsync(); + } + + Host.Dispose(); + } + } + + /// The same history rows for every pen, ending at a fixed moment, clipped to each read's range. + private sealed class FixedHistory(DateTime historyEndUtc) : IDataProvider + { + private readonly DateTime[] _timestamps = + [.. Enumerable.Range(0, HistoryColumnCount) + .Select(index => historyEndUtc - ((HistoryColumnCount - 1 - index) * _historyStep))]; + + public IReadOnlyList Columns => + [ + .. _timestamps.Select( + (stamp, index) => new EnvelopeColumn(LocalTimeAxis.ToAxis(stamp), index, index, index)) + ]; + + public Task>> QueryPensAsync() + { + return Task.FromResult(Result.Ok(Catalogue())); + } + + public Task>> QueryHistoryAsync( + IReadOnlyList penIds, + DateTime fromUtc, + DateTime toUtc, + AggregationLayer layer, + int targetColumnCount, + CancellationToken cancellationToken = default) + { + var indexes = Enumerable.Range(0, HistoryColumnCount) + .Where(index => _timestamps[index] >= fromUtc && _timestamps[index] <= toUtc) + .ToArray(); + DateTime[] timestamps = [.. indexes.Select(index => _timestamps[index])]; + double[] values = [.. indexes.Select(index => (double)index)]; + IReadOnlyList envelopes = + [.. penIds.Select(penId => new PenHistoryEnvelope(penId, timestamps, values, values, values))]; + + return Task.FromResult(Result.Ok(envelopes)); + } + + public ArchiveExtent Extent => new(_timestamps[0], _timestamps[^1]); + + public Task> QueryArchiveExtentAsync() + { + return Task.FromResult(Result.Ok(Extent)); + } + + public Task>> QueryArchiveDaysAsync( + DateOnly firstDay, + DateOnly lastDay, + CancellationToken cancellationToken = default) + { + return Task.FromResult(Result.Ok>([])); + } + } +} diff --git a/SemiPlot/SemiPlot.Tests.Integration/OpcUa/LiveSourceHost.cs b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/LiveSourceHost.cs new file mode 100644 index 0000000..c9ea85c --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/LiveSourceHost.cs @@ -0,0 +1,198 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; + +using SemiPlot.Core.Data; +using SemiPlot.Core.Data.Errors; +using SemiPlot.DataSource.OpcUa; +using SemiPlot.DataSource.OpcUa.Configuration; +using SemiPlot.Tests.Integration.DemoScada; +using SemiPlot.Tools.ArchiveSeeder; + +namespace SemiPlot.Tests.Integration.OpcUa; + +/// One state of the source and the moment the test received it. +internal sealed record ReceivedState(LiveSourceState State, DateTime ReceivedUtc) +{ + public string Name => State.ToString(); +} + +/// +/// A live source on one loopback port with a log collector of its own, recording its states, its pen faults and the +/// batches of every subscription it opened as they arrive. +/// +internal sealed class LiveSourceHost : IAsyncDisposable +{ + /// For a test about the grace; a first connect on a loaded machine can outlast it. + public static readonly TimeSpan ShortGrace = TimeSpan.FromSeconds(1); + + private static readonly TimeSpan _pollInterval = TimeSpan.FromMilliseconds(100); + + private readonly string _storeRoot; + private readonly ILoggerFactory _loggerFactory; + private readonly Lock _gate = new(); + private readonly List _states = []; + private readonly List _penFaults = []; + private readonly List _recorders = []; + private readonly IDisposable _recording; + private readonly IDisposable _faultRecording; + private volatile bool _isCompleted; + + /// A source with the production grace, so a server that is already up writes no fault first. + public LiveSourceHost(int port) + : this(port, OpcUaLiveSource.Grace) + { + } + + public LiveSourceHost(int port, TimeSpan grace) + { + _storeRoot = Path.Combine(Path.GetTempPath(), "SemiPlot.Tests", $"LiveSource-{Guid.NewGuid():N}"); + _loggerFactory = LoggerFactory.Create(builder => builder + .AddProvider(new FakeLoggerProvider(Logs)) + .SetMinimumLevel(LogLevel.Information) + .AddFilter(OpcUaSession.ChannelLogCategory, LogLevel.Critical)); + Source = new OpcUaLiveSource( + new OpcUaConnectionSettings("127.0.0.1", port, _storeRoot), + _loggerFactory, + grace); + _recording = Source.States.Subscribe(Record, () => _isCompleted = true); + _faultRecording = Source.PenFaults.Subscribe(Record); + } + + public static IReadOnlyList DemoPenIds { get; } = + [.. RawLayerGenerator.SelectPens(DemoScadaHost.Options.PenCount).Select(pen => pen.PenId)]; + + public OpcUaLiveSource Source { get; } + + public FakeLogCollector Logs { get; } = new(); + + public DateTime StartedUtc { get; private set; } + + public bool IsCompleted => _isCompleted; + + public IReadOnlyList States + { + get + { + lock (_gate) + { + return [.. _states]; + } + } + } + + public IReadOnlyList PenFaults + { + get + { + lock (_gate) + { + return [.. _penFaults]; + } + } + } + + /// The subscription opened. + public SampleRecorder Primary + { + get => field ?? throw new InvalidOperationException("The host opened no subscription yet."); + private set; + } + + public static int PortOf(DemoScadaServer server) + { + return new Uri(server.EndpointUrl).Port; + } + + /// Starts the session the way the coordinator does, by subscribing the demo's pens. + public void Start() + { + Start(DemoPenIds); + } + + public void Start(IReadOnlyList penIds) + { + StartedUtc = DateTime.UtcNow; + Primary = Subscribe(penIds); + } + + /// Opens one more subscription; the host disposes it with itself. + public SampleRecorder Subscribe(IReadOnlyList penIds) + { + var recorder = new SampleRecorder(Source.Subscribe(penIds)); + + lock (_gate) + { + _recorders.Add(recorder); + } + + return recorder; + } + + public Task WaitForStatesAsync(int count, TimeSpan bound) + { + return WaitUntilAsync(() => States.Count >= count, bound); + } + + public static async Task WaitUntilAsync(Func condition, TimeSpan bound) + { + using var giveUp = new CancellationTokenSource(bound); + + while (!condition()) + { + try + { + await Task.Delay(_pollInterval, giveUp.Token); + } + catch (OperationCanceledException) + { + return condition(); + } + } + + return true; + } + + /// The states so far and the source's records at Warning and above, for an assertion's reason. + public string Describe() + { + var states = string.Join(", ", States.Select(state => $"{state.Name}@{state.ReceivedUtc:HH:mm:ss.fff}")); + var warnings = LogRecordLines.Of(Logs.GetSnapshot().Where(record => record.Level >= LogLevel.Warning)); + var faults = string.Join(", ", PenFaults.Select(fault => fault.Message)); + + return $"states [{states}]; pen faults [{faults}]; the source logged [{warnings}]"; + } + + public async ValueTask DisposeAsync() + { + foreach (var recorder in _recorders) + { + recorder.Dispose(); + } + + await Source.DisposeAsync(); + _recording.Dispose(); + _faultRecording.Dispose(); + _loggerFactory.Dispose(); + + if (Directory.Exists(_storeRoot)) + { + Directory.Delete(_storeRoot, recursive: true); + } + } + + private void Record(LiveSourceError fault) + { + lock (_gate) + { + _penFaults.Add(fault); + } + } + + private void Record(LiveSourceState state) + { + lock (_gate) + { + _states.Add(new ReceivedState(state, DateTime.UtcNow)); + } + } +} diff --git a/SemiPlot/SemiPlot.Tests.Integration/OpcUa/LoopbackRelay.cs b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/LoopbackRelay.cs new file mode 100644 index 0000000..9c3012a --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/LoopbackRelay.cs @@ -0,0 +1,176 @@ +using System.Net; +using System.Net.Sockets; + +using SemiPlot.Tests.Integration.DemoScada; + +namespace SemiPlot.Tests.Integration.OpcUa; + +/// +/// A loopback TCP relay to one server port that a test can cut: a cut closes every relayed connection and closes +/// each new one at once until the relay resumes, while the server keeps running. +/// +internal sealed class LoopbackRelay : IAsyncDisposable +{ + private readonly TcpListener _listener; + private readonly int _serverPort; + private readonly CancellationTokenSource _stopping = new(); + private readonly Lock _gate = new(); + private readonly List _open = []; + private readonly List _relays = []; + private readonly Task _accepting; + private bool _isCut; + + private LoopbackRelay(TcpListener listener, int serverPort) + { + _listener = listener; + _serverPort = serverPort; + _accepting = AcceptAsync(_stopping.Token); + } + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + public static LoopbackRelay Start(int serverPort) + { + return new LoopbackRelay(DemoScadaHost.ListenOnFreePort(), serverPort); + } + + public void Cut() + { + lock (_gate) + { + _isCut = true; + + foreach (var client in _open) + { + client.Dispose(); + } + + _open.Clear(); + } + } + + public void Resume() + { + lock (_gate) + { + _isCut = false; + } + } + + public async ValueTask DisposeAsync() + { + await _stopping.CancelAsync(); + _listener.Stop(); + Cut(); + await _accepting; + + Task[] relays; + + lock (_gate) + { + relays = [.. _relays]; + } + + await Task.WhenAll(relays); + _stopping.Dispose(); + } + + private async Task AcceptAsync(CancellationToken cancellationToken) + { + try + { + while (true) + { + var client = await _listener.AcceptTcpClientAsync(cancellationToken); + var relay = RelayAsync(client, cancellationToken); + + lock (_gate) + { + _relays.Add(relay); + } + } + } + catch (Exception exception) when (exception is OperationCanceledException or ObjectDisposedException + or SocketException) + { + } + } + + private async Task RelayAsync(TcpClient client, CancellationToken cancellationToken) + { + using var server = new TcpClient(); + + using (client) + { + if (!TryOpen(client, server)) + { + return; + } + + Task[] copies = []; + + try + { + await server.ConnectAsync(IPAddress.Loopback, _serverPort, cancellationToken); + var clientStream = client.GetStream(); + var serverStream = server.GetStream(); + copies = + [ + clientStream.CopyToAsync(serverStream, cancellationToken), + serverStream.CopyToAsync(clientStream, cancellationToken), + ]; + await Task.WhenAny(copies); + } + catch (Exception exception) when (IsClosedSocket(exception)) + { + } + finally + { + Close(client, server); + } + + try + { + await Task.WhenAll(copies); + } + catch (Exception exception) when (IsClosedSocket(exception)) + { + } + } + } + + private static bool IsClosedSocket(Exception exception) + { + return exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException + or InvalidOperationException; + } + + /// False while the relay is cut: the connection is closed at once. + private bool TryOpen(TcpClient client, TcpClient server) + { + lock (_gate) + { + if (_isCut) + { + return false; + } + + _open.Add(client); + _open.Add(server); + + return true; + } + } + + private void Close(TcpClient client, TcpClient server) + { + lock (_gate) + { + _open.Remove(client); + _open.Remove(server); + } + + client.Dispose(); + server.Dispose(); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaLiveSourceRestartTests.cs b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaLiveSourceRestartTests.cs new file mode 100644 index 0000000..7c8a012 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaLiveSourceRestartTests.cs @@ -0,0 +1,167 @@ +using AwesomeAssertions; + +using SemiPlot.Tests.Integration.DemoScada; +using SemiPlot.Tools.ArchiveSeeder; + +using Xunit; + +namespace SemiPlot.Tests.Integration.OpcUa; + +// Every endpoint is a free loopback port from DemoScadaHost. +[Collection(DemoScadaCollection.Name)] +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Integration")] +public sealed class OpcUaLiveSourceRestartTests : IDisposable +{ + private const int TestTimeoutMilliseconds = 180000; + + private static readonly TimeSpan _connectBound = TimeSpan.FromSeconds(60); + private static readonly TimeSpan _lossBound = TimeSpan.FromSeconds(30); + private static readonly TimeSpan _sampleBound = TimeSpan.FromSeconds(30); + private static readonly TimeSpan _breakStep = TimeSpan.FromMilliseconds(1); + + // Longer than the 4-5 s the stack takes to notice the loss, shorter than the 60 s session timeout. + private static readonly TimeSpan _cutLength = TimeSpan.FromSeconds(8); + + private readonly DemoScadaHost _host = new(); + + public void Dispose() + { + _host.Dispose(); + } + + private static FollowOptions Options => DemoScadaHost.Options with { ChangeSeconds = 0.25 }; + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task AServerRestartedOnItsPort_BreaksEachPenOnceAtItsNewestStamp_BeforeTheNewSessionsSamples() + { + var penIds = LiveSourceHost.DemoPenIds; + var first = await _host.StartAsync(Options); + var port = LiveSourceHost.PortOf(first); + await using var live = new LiveSourceHost(port); + + try + { + live.Start(); + (await LiveSourceHost.WaitUntilAsync(() => live.Primary.HasValuesForEach(penIds), _connectBound)).Should() + .BeTrue(Because(live)); + } + finally + { + await first.DisposeAsync(); + } + + (await live.WaitForStatesAsync(3, _lossBound)).Should().BeTrue(Because(live)); + var restartUtc = DateTime.UtcNow; + await using var restarted = await _host.StartOnPortOnceFreeAsync(Options with { OpcPort = port }); + (await live.WaitForStatesAsync(4, _connectBound)).Should().BeTrue(Because(live)); + (await LiveSourceHost.WaitUntilAsync(() => live.Primary.HasValueAfterABreakForEach(penIds), _sampleBound)) + .Should().BeTrue(Because(live)); + + Names(live).Should().Equal(["Connecting", "Connected", "Unreachable", "Connected"], Because(live)); + var resumed = ShouldBreakEachPenOnceAtItsNewestStamp(live.Primary, penIds, Because(live)); + resumed.Should().AllSatisfy( + sample => sample.Sample.TimestampUtc.Should().BeOnOrAfter(restartUtc, Because(live)), + "the new server stamps every first notification with its start; {0}", + Because(live)); + live.Primary.Terminations.Should().BeEmpty(Because(live)); + } + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task ACutTransport_BreaksEachPenOnce_ResumesPastTheBreaks_AndASubscriptionDisposedInTheCutStaysQuiet() + { + IReadOnlyList keptPenIds = [.. LiveSourceHost.DemoPenIds.SkipLast(2)]; + IReadOnlyList droppedPenIds = [.. LiveSourceHost.DemoPenIds.TakeLast(2)]; + await using var server = await _host.StartAsync(Options); + await using var relay = LoopbackRelay.Start(LiveSourceHost.PortOf(server)); + await using var live = new LiveSourceHost(relay.Port); + + live.Start(keptPenIds); + var dropped = live.Subscribe(droppedPenIds); + (await LiveSourceHost.WaitUntilAsync( + () => live.Primary.HasValuesForEach(keptPenIds) && dropped.HasValuesForEach(droppedPenIds), + _connectBound)).Should().BeTrue(Because(live)); + + var cutUtc = DateTime.UtcNow; + relay.Cut(); + (await live.WaitForStatesAsync(3, _lossBound)).Should().BeTrue( + "a session that kept the configured URL dials the relay and notices the cut; {0}", + Because(live)); + dropped.Dispose(); + var droppedBatches = dropped.BatchCount; + var restOfTheCut = _cutLength - (DateTime.UtcNow - cutUtc); + + if (restOfTheCut > TimeSpan.Zero) + { + await Task.Delay(restOfTheCut, TestContext.Current.CancellationToken); + } + + relay.Resume(); + (await live.WaitForStatesAsync(4, _connectBound)).Should().BeTrue(Because(live)); + (await LiveSourceHost.WaitUntilAsync(() => live.Primary.HasValueAfterABreakForEach(keptPenIds), _sampleBound)) + .Should().BeTrue(Because(live)); + + Names(live).Should().Equal(["Connecting", "Connected", "Unreachable", "Connected"], Because(live)); + ShouldBreakEachPenOnceAtItsNewestStamp(live.Primary, keptPenIds, Because(live)); + + // The recorder lets go of the stream on dispose, so this pins only that the dispose raised no fault and no + // completion; PenSubscriptionTests holds what a closed subscription emits. + dropped.BatchCount.Should().Be(droppedBatches, Because(live)); + dropped.Terminations.Should().BeEmpty(Because(live)); + live.Primary.Terminations.Should().BeEmpty(Because(live)); + } + + /// + /// Each pen breaks exactly once, 1 ms past its newest value before the break, and every break arrives before the + /// first value of any pen after its break; returns those first values, which also lie past their pen's break. + /// + private static IReadOnlyList ShouldBreakEachPenOnceAtItsNewestStamp( + SampleRecorder recorder, + IReadOnlyList penIds, + string because) + { + var samples = recorder.Samples; + var lastBreakIndex = -1; + var firstResumedIndex = int.MaxValue; + var resumed = new List(); + + foreach (var penId in penIds) + { + var indexed = samples + .Select((sample, index) => (Sample: sample, Index: index)) + .Where(entry => entry.Sample.Sample.PenId == penId) + .ToArray(); + var penBreak = indexed.Where(entry => entry.Sample.IsBreak).Should() + .ContainSingle("pen {0}; {1}", penId, because).Subject; + var newest = indexed + .Where(entry => entry.Index < penBreak.Index) + .Max(entry => entry.Sample.Sample.TimestampUtc); + var next = indexed.First(entry => entry.Index > penBreak.Index); + + penBreak.Sample.Sample.TimestampUtc.Should().Be(newest + _breakStep, "pen {0}; {1}", penId, because); + next.Sample.Sample.TimestampUtc.Should().BeAfter( + penBreak.Sample.Sample.TimestampUtc, + "pen {0}; {1}", + penId, + because); + lastBreakIndex = Math.Max(lastBreakIndex, penBreak.Index); + firstResumedIndex = Math.Min(firstResumedIndex, next.Index); + resumed.Add(next.Sample); + } + + lastBreakIndex.Should().BeLessThan(firstResumedIndex, "every break precedes the new session; {0}", because); + + return resumed; + } + + private static IReadOnlyList Names(LiveSourceHost live) + { + return [.. live.States.Select(state => state.Name)]; + } + + private string Because(LiveSourceHost live) + { + return $"{live.Describe()}; {_host.ServerLog()}"; + } +} diff --git a/SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaLiveSourceTests.cs b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaLiveSourceTests.cs new file mode 100644 index 0000000..fa54f09 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaLiveSourceTests.cs @@ -0,0 +1,146 @@ +using AwesomeAssertions; + +using SemiPlot.Core.Data.Errors; +using SemiPlot.DataSource.Postgres; +using SemiPlot.Tests.Integration.DemoScada; +using SemiPlot.Tools.ArchiveSeeder; + +using Xunit; + +namespace SemiPlot.Tests.Integration.OpcUa; + +// One demo server on a free loopback port serves the class, archiving in Asia/Tokyo, so a stamp sent as local time +// misses true UTC by nine hours even on a UTC machine. +[Collection(DemoScadaCollection.Name)] +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Integration")] +public sealed class OpcUaLiveSourceTests : IClassFixture +{ + private const int TestTimeoutMilliseconds = 180000; + private const int FirstUnknownPenId = 99998; + private const int SecondUnknownPenId = 99999; + + private static readonly TimeSpan _connectBound = TimeSpan.FromSeconds(60); + private static readonly TimeSpan _sampleBound = TimeSpan.FromSeconds(30); + private static readonly TimeSpan _clockTolerance = TimeSpan.FromSeconds(60); + + // Longer than the 5 s a subscription waits for its items before it reports its unserved pens. + private static readonly TimeSpan _settleWait = TimeSpan.FromSeconds(7); + + private readonly DemoScadaServerFixture _fixture; + + public OpcUaLiveSourceTests(DemoScadaServerFixture fixture) + { + _fixture = fixture; + _fixture.Host.Logs.Clear(); + } + + private static IReadOnlyList PenIds => LiveSourceHost.DemoPenIds; + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task EveryPen_DeliversTheLatticeValueAtItsTrueUtcStamp_InIncreasingOrder() + { + var options = DemoScadaServerFixture.Options; + var converter = new ArchiveTimeConverter(DemoScadaHost.ArchiveZone); + await using var live = new LiveSourceHost(LiveSourceHost.PortOf(_fixture.Server)); + + live.Start(); + (await live.WaitForStatesAsync(2, _connectBound)).Should().BeTrue(Because(live)); + (await LiveSourceHost.WaitUntilAsync(() => live.Primary.HasValuesForEach(PenIds, 3), _sampleBound)).Should() + .BeTrue(Because(live)); + + foreach (var penId in PenIds) + { + var received = live.Primary.SamplesOf(penId); + var stamps = received.Select(sample => sample.Sample.TimestampUtc).ToArray(); + + received.Should().HaveCountGreaterThanOrEqualTo(3, "pen {0}; {1}", penId, Because(live)); + received.Should().NotContain(sample => sample.IsBreak, "pen {0}; {1}", penId, Because(live)); + stamps.Should().BeInAscendingOrder("pen {0}; {1}", penId, Because(live)) + .And.OnlyHaveUniqueItems("pen {0}; {1}", penId, Because(live)); + + foreach (var sample in received) + { + var stamp = sample.Sample.TimestampUtc; + var expected = LiveTailGenerator.ChangesAt(options, converter.ToArchiveLocal(stamp)) + .Single(row => row.Id == penId); + + stamp.Kind.Should().Be(DateTimeKind.Utc, "pen {0}; {1}", penId, Because(live)); + stamp.Should().BeCloseTo( + sample.ReceivedUtc, + _clockTolerance, + "a local stamp sent as UTC misses by the zone's offset; pen {0}; {1}", + penId, + Because(live)); + sample.Sample.Value.Should().Be(expected.Value, "pen {0} at {1:O}; {2}", penId, stamp, Because(live)); + } + } + + live.Primary.Terminations.Should().BeEmpty(Because(live)); + } + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task TwoUnknownPens_RaiseOneFaultNamingBoth_AndASecondSubscriptionOfTheSameSetRaisesNone() + { + IReadOnlyList penIds = [.. PenIds, FirstUnknownPenId, SecondUnknownPenId]; + await using var live = new LiveSourceHost(LiveSourceHost.PortOf(_fixture.Server)); + + live.Start(penIds); + (await LiveSourceHost.WaitUntilAsync(() => live.PenFaults.Count > 0, _connectBound)).Should() + .BeTrue(Because(live)); + (await LiveSourceHost.WaitUntilAsync(() => live.Primary.HasValuesForEach(PenIds), _sampleBound)).Should() + .BeTrue("the served pens keep delivering; {0}", Because(live)); + + live.Primary.Dispose(); + var second = live.Subscribe(penIds); + (await LiveSourceHost.WaitUntilAsync(() => second.HasValuesForEach(PenIds), _sampleBound)).Should() + .BeTrue(Because(live)); + await Task.Delay(_settleWait, TestContext.Current.CancellationToken); + + var fault = live.PenFaults.Should().ContainSingle(Because(live)).Subject; + fault.Kind.Should().Be(LiveSourceFault.PensUnserved, Because(live)); + fault.UnknownPenIds.Should().Equal([FirstUnknownPenId, SecondUnknownPenId], Because(live)); + fault.NotNumericPenIds.Should().BeEmpty(Because(live)); + live.Primary.Terminations.Should().BeEmpty(Because(live)); + second.Terminations.Should().BeEmpty(Because(live)); + } + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task ASecondSubscriptionOfTheSameSet_OnTheLiveSession_WritesNoBreak_AndStartsAtEachPensNewestStamp() + { + await using var live = new LiveSourceHost(LiveSourceHost.PortOf(_fixture.Server)); + + live.Start(); + (await LiveSourceHost.WaitUntilAsync(() => live.Primary.HasValuesForEach(PenIds, 2), _connectBound)).Should() + .BeTrue(Because(live)); + live.Primary.Samples.Should().NotContain(sample => sample.IsBreak, Because(live)); + + live.Primary.Dispose(); + var newestBeforeTheSwitch = PenIds.ToDictionary( + penId => penId, + penId => live.Primary.SamplesOf(penId).Max(sample => sample.Sample.TimestampUtc)); + var second = live.Subscribe(PenIds); + (await LiveSourceHost.WaitUntilAsync(() => second.HasValuesForEach(PenIds, 2), _sampleBound)).Should() + .BeTrue(Because(live)); + + second.Samples.Should().NotContain(sample => sample.IsBreak, Because(live)); + + foreach (var penId in PenIds) + { + second.SamplesOf(penId)[0].Sample.TimestampUtc.Should().BeOnOrAfter( + newestBeforeTheSwitch[penId], + "pen {0}; {1}", + penId, + Because(live)); + } + + live.Primary.Terminations.Should().BeEmpty(Because(live)); + second.Terminations.Should().BeEmpty(Because(live)); + } + + private string Because(LiveSourceHost live) + { + return $"{live.Describe()}; {_fixture.Host.ServerLog()}"; + } +} diff --git a/SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaSessionTests.cs b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaSessionTests.cs new file mode 100644 index 0000000..a7ef5e4 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/OpcUaSessionTests.cs @@ -0,0 +1,343 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +using AwesomeAssertions; + +using SemiPlot.Tests.Integration.DemoScada; + +using Xunit; + +namespace SemiPlot.Tests.Integration.OpcUa; + +// Every endpoint is a free loopback port from DemoScadaHost; the waits are bounded by the timings in +// docs/architecture/data-integration.md#the-session-state-the-live-source-reports. +[Collection(DemoScadaCollection.Name)] +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Integration")] +public sealed class OpcUaSessionTests : IDisposable +{ + private const int TestTimeoutMilliseconds = 180000; + + private static readonly TimeSpan _connectBound = TimeSpan.FromSeconds(60); + private static readonly TimeSpan _lossBound = TimeSpan.FromSeconds(30); + private static readonly TimeSpan _clockTolerance = TimeSpan.FromMilliseconds(50); + + // The source's own bounds. + private static readonly TimeSpan _closeBound = TimeSpan.FromSeconds(2); + private static readonly TimeSpan _syncDisposeBound = TimeSpan.FromSeconds(3); + + // The connect loop's longest wait while no server answers. + private static readonly TimeSpan _preSessionCap = TimeSpan.FromSeconds(5); + + // Room for the pool to schedule the close's continuations under a full test run. + private static readonly TimeSpan _poolSlack = TimeSpan.FromSeconds(3); + + // Discovery gives a peer that never answers 10 s; the next pass dials 1 s later. + private static readonly TimeSpan _discoveryBound = TimeSpan.FromSeconds(10); + private static readonly TimeSpan _secondDialBound = TimeSpan.FromSeconds(20); + private static readonly TimeSpan _timerTolerance = TimeSpan.FromMilliseconds(500); + + private readonly DemoScadaHost _host = new(); + + public void Dispose() + { + _host.Dispose(); + } + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task ARunningServer_TakesConnectingThenConnected() + { + await using var server = await _host.StartAsync(DemoScadaHost.Options); + await using var live = new LiveSourceHost(LiveSourceHost.PortOf(server)); + + live.Start(); + + (await live.WaitForStatesAsync(2, _connectBound)).Should().BeTrue(Because(live)); + Names(live).Should().Equal(["Connecting", "Connected"], Because(live)); + } + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task NoServer_TakesUnreachableAfterTheGrace_ThenConnectedOnceAServerStartsOnThePort() + { + var port = DemoScadaHost.FreePort(); + await using var live = new LiveSourceHost(port, LiveSourceHost.ShortGrace); + + live.Start(); + + (await live.WaitForStatesAsync(2, _lossBound)).Should().BeTrue(Because(live)); + Names(live).Should().Equal(["Connecting", "Unreachable"], Because(live)); + (live.States[1].ReceivedUtc - live.StartedUtc).Should().BeGreaterThanOrEqualTo( + LiveSourceHost.ShortGrace - _clockTolerance, + Because(live)); + + await using var server = await _host.StartOnPortOnceFreeAsync(DemoScadaHost.Options with { OpcPort = port }); + + (await live.WaitForStatesAsync(3, _connectBound)).Should().BeTrue(Because(live)); + Names(live).Should().Equal(["Connecting", "Unreachable", "Connected"], Because(live)); + } + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task AServerRestartedOnItsPort_TakesConnectedUnreachableConnected() + { + var first = await _host.StartAsync(DemoScadaHost.Options); + var port = LiveSourceHost.PortOf(first); + await using var live = new LiveSourceHost(port); + + try + { + live.Start(); + (await live.WaitForStatesAsync(2, _connectBound)).Should().BeTrue(Because(live)); + } + finally + { + await first.DisposeAsync(); + } + + (await live.WaitForStatesAsync(3, _lossBound)).Should().BeTrue(Because(live)); + + await using var restarted = await _host.StartOnPortOnceFreeAsync(DemoScadaHost.Options with { OpcPort = port }); + + (await live.WaitForStatesAsync(4, _connectBound)).Should().BeTrue(Because(live)); + Names(live).Should().Equal(["Connecting", "Connected", "Unreachable", "Connected"], Because(live)); + } + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task AListenerThatAcceptsAndCloses_KeepsTheSourceInDiscoveryWithNoSetupFault() + { + using var listener = DemoScadaHost.ListenOnFreePort(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var accepts = new AcceptCounter(); + using var stopAccepting = new CancellationTokenSource(); + var accepting = AcceptAndCloseAsync(listener, accepts, stopAccepting.Token); + await using var live = new LiveSourceHost(port, LiveSourceHost.ShortGrace); + + live.Start(); + + (await LiveSourceHost.WaitUntilAsync(() => accepts.Count >= 3, _lossBound)).Should().BeTrue( + "discovery keeps dialling the listener; {0}", + Because(live)); + Names(live).Should().Equal(["Connecting", "Unreachable"], Because(live)); + + await stopAccepting.CancelAsync(); + listener.Stop(); + await accepting; + } + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task AListenerThatAcceptsAndNeverAnswers_IsDialledAgainAfterTheDiscoveryBound() + { + using var listener = DemoScadaHost.ListenOnFreePort(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + using var held = new HeldConnections(); + using var stopAccepting = new CancellationTokenSource(); + var accepting = AcceptAndHoldAsync(listener, held, stopAccepting.Token); + await using var live = new LiveSourceHost(port, LiveSourceHost.ShortGrace); + + live.Start(); + + (await LiveSourceHost.WaitUntilAsync(() => held.AcceptedUtc.Count >= 2, _secondDialBound)).Should().BeTrue( + "discovery gives up on a silent peer and the next pass dials again; {0}", + Because(live)); + var accepted = held.AcceptedUtc; + (accepted[1] - accepted[0]).Should().BeGreaterThanOrEqualTo( + _discoveryBound - _timerTolerance, + "the first pass holds the silent peer for its whole bound; {0}", + Because(live)); + Names(live).Should().Equal(["Connecting", "Unreachable"], Because(live)); + + await stopAccepting.CancelAsync(); + listener.Stop(); + await accepting; + } + + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task DisposingTheSourceWhileItReconnects_ReturnsWithinItsBound_PublishesNothing_AndDialsNoMore() + { + var server = await _host.StartAsync(DemoScadaHost.Options); + var port = LiveSourceHost.PortOf(server); + await using var live = new LiveSourceHost(port); + + try + { + live.Start(); + (await live.WaitForStatesAsync(2, _connectBound)).Should().BeTrue(Because(live)); + var hasValues = await LiveSourceHost.WaitUntilAsync( + () => live.Primary.HasValuesForEach(LiveSourceHost.DemoPenIds), _lossBound); + hasValues.Should().BeTrue(Because(live)); + } + finally + { + await server.DisposeAsync(); + } + + (await live.WaitForStatesAsync(3, _lossBound)).Should().BeTrue(Because(live)); + var lossBreaks = live.Primary.Samples.Count(received => received.IsBreak); + var batchesBeforeDispose = live.Primary.BatchCount; + var watch = Stopwatch.StartNew(); + await live.Source.DisposeAsync(); + var elapsed = watch.Elapsed; + + elapsed.Should().BeLessThan(_closeBound + _poolSlack, Because(live)); + live.IsCompleted.Should().BeTrue(Because(live)); + Names(live).Should().Equal(["Connecting", "Connected", "Unreachable"], Because(live)); + lossBreaks.Should().Be(LiveSourceHost.DemoPenIds.Count, "the loss breaks every pen once; {0}", Because(live)); + + using var listener = await DemoScadaHost.ListenOnPortOnceFreeAsync(port); + var accepts = new AcceptCounter(); + using var stopAccepting = new CancellationTokenSource(); + var accepting = AcceptAndCloseAsync(listener, accepts, stopAccepting.Token); + + await Task.Delay(_preSessionCap + TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + + accepts.Count.Should().Be(0, "a disposed source dials nothing; {0}", Because(live)); + live.Primary.BatchCount.Should().Be(batchesBeforeDispose, "the dispose writes no break; {0}", Because(live)); + await stopAccepting.CancelAsync(); + listener.Stop(); + await accepting; + } + + // Dispose returns at its 3 s bound whatever the close does, so completion is what tells a finished close from one + // stuck on a continuation the context dropped. + [Fact(Timeout = TestTimeoutMilliseconds)] + public async Task DisposeOnAConnectedSource_UnderAContextThatDropsEveryPost_ReturnsWithinItsBound() + { + await using var server = await _host.StartAsync(DemoScadaHost.Options); + await using var live = new LiveSourceHost(LiveSourceHost.PortOf(server)); + live.Start(); + (await live.WaitForStatesAsync(2, _connectBound)).Should().BeTrue(Because(live)); + + var elapsed = DisposeUnder(new DroppingSynchronizationContext(), live.Source); + + elapsed.Should().BeLessThan(_syncDisposeBound + _poolSlack, Because(live)); + (await LiveSourceHost.WaitUntilAsync(() => live.IsCompleted, _closeBound + _poolSlack)).Should().BeTrue( + "the close finishes on the pool; {0}", + Because(live)); + } + + private static TimeSpan DisposeUnder(SynchronizationContext context, IDisposable source) + { + var previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + + try + { + var watch = Stopwatch.StartNew(); + source.Dispose(); + + return watch.Elapsed; + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + } + + private static async Task AcceptAndCloseAsync( + TcpListener listener, + AcceptCounter accepts, + CancellationToken cancellationToken) + { + try + { + while (true) + { + using var client = await listener.AcceptTcpClientAsync(cancellationToken); + accepts.Increment(); + } + } + catch (Exception exception) when (exception is OperationCanceledException or ObjectDisposedException + or SocketException) + { + } + } + + private static async Task AcceptAndHoldAsync( + TcpListener listener, + HeldConnections held, + CancellationToken cancellationToken) + { + try + { + while (true) + { + held.Add(await listener.AcceptTcpClientAsync(cancellationToken)); + } + } + catch (Exception exception) when (exception is OperationCanceledException or ObjectDisposedException + or SocketException) + { + } + } + + private static IReadOnlyList Names(LiveSourceHost live) + { + return [.. live.States.Select(state => state.Name)]; + } + + private string Because(LiveSourceHost live) + { + return $"{live.Describe()}; {_host.ServerLog()}"; + } + + private sealed class AcceptCounter + { + private int _count; + + public int Count => Volatile.Read(ref _count); + + public void Increment() + { + Interlocked.Increment(ref _count); + } + } + + /// + /// Accepted connections the test keeps open and never answers, with the moment each was accepted. + /// + private sealed class HeldConnections : IDisposable + { + private readonly Lock _gate = new(); + private readonly List _clients = []; + private readonly List _acceptedUtc = []; + + public IReadOnlyList AcceptedUtc + { + get + { + lock (_gate) + { + return [.. _acceptedUtc]; + } + } + } + + public void Add(TcpClient client) + { + lock (_gate) + { + _clients.Add(client); + _acceptedUtc.Add(DateTime.UtcNow); + } + } + + public void Dispose() + { + lock (_gate) + { + foreach (var client in _clients) + { + client.Dispose(); + } + } + } + } + + private sealed class DroppingSynchronizationContext : SynchronizationContext + { + public override void Post(SendOrPostCallback d, object? state) + { + } + } +} diff --git a/SemiPlot/SemiPlot.Tests.Integration/OpcUa/SampleRecorder.cs b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/SampleRecorder.cs new file mode 100644 index 0000000..4e81691 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Integration/OpcUa/SampleRecorder.cs @@ -0,0 +1,101 @@ +using System.Reactive; +using System.Reactive.Linq; + +using SemiPlot.Core.Trends; + +namespace SemiPlot.Tests.Integration.OpcUa; + +/// One sample and the moment the test received the batch that carried it. +internal sealed record ReceivedSample(Sample Sample, DateTime ReceivedUtc) +{ + public bool IsBreak => Sample.Value is null; +} + +/// Records one subscription's batches in arrival order, and every completion or fault of its stream. +internal sealed class SampleRecorder : IDisposable +{ + private readonly Lock _gate = new(); + private readonly List _samples = []; + private readonly List _terminations = []; + private readonly IDisposable _subscription; + private int _batchCount; + + public SampleRecorder(IObservable> stream) + { + _subscription = stream.Materialize().Subscribe(Record); + } + + public int BatchCount + { + get + { + lock (_gate) + { + return _batchCount; + } + } + } + + public IReadOnlyList Samples + { + get + { + lock (_gate) + { + return [.. _samples]; + } + } + } + + /// Each completion or fault the stream raised; a live stream raises neither. + public IReadOnlyList Terminations + { + get + { + lock (_gate) + { + return [.. _terminations]; + } + } + } + + public IReadOnlyList SamplesOf(int penId) + { + return [.. Samples.Where(received => received.Sample.PenId == penId)]; + } + + public bool HasValuesForEach(IEnumerable penIds, int count = 1) + { + return penIds.All(penId => SamplesOf(penId).Count(received => !received.IsBreak) >= count); + } + + public bool HasValueAfterABreakForEach(IEnumerable penIds) + { + return penIds.All(penId => SamplesOf(penId) + .SkipWhile(received => !received.IsBreak) + .Any(received => !received.IsBreak)); + } + + public void Dispose() + { + _subscription.Dispose(); + } + + private void Record(Notification> notification) + { + var receivedUtc = DateTime.UtcNow; + + lock (_gate) + { + if (notification.Kind != NotificationKind.OnNext) + { + _terminations.Add($"{notification.Kind} {notification.Exception}"); + + return; + } + + _batchCount++; + _samples.AddRange(notification.Value.Select(sample => new ReceivedSample(sample, receivedUtc))); + } + } +} diff --git a/SemiPlot/SemiPlot.Tests.Integration/RealtimeArchive.cs b/SemiPlot/SemiPlot.Tests.Integration/RealtimeArchive.cs index 3922bb9..ccf7123 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/RealtimeArchive.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/RealtimeArchive.cs @@ -1,60 +1,24 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - using Npgsql; using NpgsqlTypes; -using SemiPlot.DataSource.Postgres; using SemiPlot.Tools.ArchiveSeeder; namespace SemiPlot.Tests.Integration; -/// The archive rows, the subscription and the append path the realtime test classes share. +/// The archive day and the append path a test class writes its own rows with. internal static class RealtimeArchive { private const string AppendCommand = """ INSERT INTO public.trends (id, l, t, v, q) VALUES (@id, 0, @t, @v, @q); """; - // A strict subset of the seeded identifiers, so a tick that ignored @ids would fail. - public static readonly int[] SubscribedPenIds = [1, 2]; - // One calendar day, so the write creates the single partition tp2026m01d01 and every appended row falls // inside it. Winter under Europe/Berlin, so the conversion out is an unambiguous +1 h. public static readonly DateTime Day = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); public static readonly DateTime NextDay = Day.AddDays(1); - // The newest seeded row, which is what each subscription's baseline answers. Everything appended sits - // after it and hours short of the partition's upper bound. - public static readonly DateTime SeededLast = Day.AddMinutes(1); - - public static RealtimePoll NewPoll(IServiceProvider services, ILogger? logger = null) - { - return new RealtimePoll( - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService(), - SubscribedPenIds, - logger ?? NullLogger.Instance); - } - - // Two rows per subscribed variable, the second of them the baseline every subscription starts from. - public static IReadOnlyList SeededRows() - { - var rows = new List(); - - foreach (var penId in SubscribedPenIds) - { - rows.Add(new ArchiveRow(penId, ArchiveRow.RawLayer, Day, penId, ArchiveRow.OrdinaryQuality)); - rows.Add(new ArchiveRow(penId, ArchiveRow.RawLayer, SeededLast, penId, ArchiveRow.OrdinaryQuality)); - } - - return rows; - } - // Written as scada_writer, the role the SCADA itself writes with, and one row at a time: a COPY would go // through ArchiveWriter, which refuses an archive already carrying rows. The day partition is created // first, because an INSERT into a partitioned table with no matching partition lands in the default one. diff --git a/SemiPlot/SemiPlot.Tests.Integration/RealtimeEmptyArchiveTests.cs b/SemiPlot/SemiPlot.Tests.Integration/RealtimeEmptyArchiveTests.cs deleted file mode 100644 index 8de79cc..0000000 --- a/SemiPlot/SemiPlot.Tests.Integration/RealtimeEmptyArchiveTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using AwesomeAssertions; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Testing; - -using SemiPlot.Core.Data; - -using Xunit; - -using static SemiPlot.Tests.Integration.RealtimeArchive; - -namespace SemiPlot.Tests.Integration; - -// The branch every other gated class seeds past: an archive with public.trends but no row for the -// subscribed variables, whose baseline scalar answers NULL. -[Collection(ArchiveDatabaseCollection.Name)] -[Trait("Component", "Core")] -[Trait("Area", "Data")] -[Trait("Category", "Integration")] -public sealed class RealtimeEmptyArchiveTests(PostgresContainerFixture postgresContainerFixture) - : ClonedArchiveTest(postgresContainerFixture, CloneSource.Provisioned) -{ - // A NULL answer leaves lastSeen unset, so the next tick repeats the baseline read rather than binding - // @lastSeen to nothing and going blind for good. - [Fact] - public async Task TheBaselineBranchRepeatsUntilTheArchiveCarriesARow() - { - var cancellationToken = TestContext.Current.CancellationToken; - - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString); - using var logs = new FakeLoggerProvider(); - - var poll = NewPoll(services, logs.CreateLogger("RealtimePoll")); - - (await poll.ReadOnceAsync(cancellationToken)).Should().BeEmpty(); - (await poll.ReadOnceAsync(cancellationToken)).Should().BeEmpty(); - - logs.Collector.GetSnapshot() - .Count(entry => entry.GetStructuredStateValue(ArmedGate.BaselineKey) is not null) - .Should().Be(2); - } - - // A tick that emitted the first row ever written would have to emit every row since the archive began. - [Fact] - public async Task TheFirstEverRowBecomesTheBaselineAndTheNextOneIsDelivered() - { - var cancellationToken = TestContext.Current.CancellationToken; - var first = Day.AddMinutes(1); - var second = Day.AddMinutes(2); - - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString); - - var poll = NewPoll(services); - - await poll.ReadOnceAsync(cancellationToken); - await AppendAsync(Database, SubscribedPenIds[0], first, 1.5, cancellationToken: cancellationToken); - - var baseline = await poll.ReadOnceAsync(cancellationToken); - - baseline.Should().BeEmpty(); - - await AppendAsync(Database, SubscribedPenIds[1], second, 2.5, cancellationToken: cancellationToken); - - var sample = (await poll.ReadOnceAsync(cancellationToken)).Should().ContainSingle().Which; - - sample.PenId.Should().Be(SubscribedPenIds[1]); - sample.TimestampUtc.Should().Be(ArchiveProviderFactory.TimeConverter.ToUtc(second)); - } - - [Fact] - public async Task ASubscriptionOverAnArchiveWithNoRowLogsItsBaseline() - { - using var armed = new ArmedGate(); - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString, armed); - - var liveSource = services.GetRequiredService(); - - using var subscription = liveSource.Subscribe(SubscribedPenIds).Subscribe(_ => { }); - - await armed.Armed(1).WaitAsync(GateBound, TestContext.Current.CancellationToken); - } -} diff --git a/SemiPlot/SemiPlot.Tests.Integration/RealtimePollReadTests.cs b/SemiPlot/SemiPlot.Tests.Integration/RealtimePollReadTests.cs deleted file mode 100644 index 1777280..0000000 --- a/SemiPlot/SemiPlot.Tests.Integration/RealtimePollReadTests.cs +++ /dev/null @@ -1,309 +0,0 @@ -using AwesomeAssertions; - -using Microsoft.Extensions.Logging.Testing; - -using SemiPlot.Core.Trends; -using SemiPlot.Tools.ArchiveSeeder; - -using Xunit; - -using static SemiPlot.Tests.Integration.RealtimeArchive; - -namespace SemiPlot.Tests.Integration; - -// The archive is written by this class rather than cloned from the bench template: a poll is asserted -// against a handful of rows at timestamps the test chose, and the template's own last timestamp is -// whatever the generator happened to produce. -[Collection(ArchiveDatabaseCollection.Name)] -[Trait("Component", "Core")] -[Trait("Area", "Data")] -[Trait("Category", "Integration")] -public sealed class RealtimePollReadTests(PostgresContainerFixture postgresContainerFixture) - : ClonedArchiveTest(postgresContainerFixture, CloneSource.Provisioned) -{ - private const int UnsubscribedPenId = 3; - - // Later than every subscribed variable's own newest row, so a baseline that ignored @ids would answer - // this instant instead. - private static readonly DateTime _unsubscribedLast = Day.AddMinutes(2); - - protected override async ValueTask SeedAsync() - { - await Writer().WriteAsync(SeededRowsWithAnUnsubscribedVariable(), Day, NextDay); - } - - // It emits nothing: a first tick that emitted rows would have to emit every row since the archive began. - [Fact] - public async Task TheFirstTickEmitsNoSample() - { - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString); - - var samples = await NewPoll(services).ReadOnceAsync(TestContext.Current.CancellationToken); - - samples.Should().BeEmpty(); - } - - [Fact] - public async Task ATickAfterAnAppendedRowEmitsExactlyThatRow() - { - var cancellationToken = TestContext.Current.CancellationToken; - var appended = SeededLast.AddSeconds(1); - - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString); - - var poll = NewPoll(services); - - await poll.ReadOnceAsync(cancellationToken); - await AppendAsync( - Database, - SubscribedPenIds[0], - appended, - 42.5, - ArchiveRow.OrdinaryQuality, - cancellationToken: cancellationToken); - - var samples = await poll.ReadOnceAsync(cancellationToken); - - var sample = samples.Should().ContainSingle().Which; - - sample.PenId.Should().Be(SubscribedPenIds[0]); - sample.Value.Should().Be(42.5); - sample.TimestampUtc.Should().Be(ArchiveProviderFactory.TimeConverter.ToUtc(appended)); - } - - [Fact] - public async Task ATickWithNothingNewEmitsNothing() - { - var cancellationToken = TestContext.Current.CancellationToken; - - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString); - - var poll = NewPoll(services); - - await poll.ReadOnceAsync(cancellationToken); - - var samples = await poll.ReadOnceAsync(cancellationToken); - - samples.Should().BeEmpty(); - } - - [Fact] - public async Task ATickIgnoresARowOfAnUnsubscribedVariable() - { - var cancellationToken = TestContext.Current.CancellationToken; - - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString); - - var poll = NewPoll(services); - - await poll.ReadOnceAsync(cancellationToken); - await AppendAsync( - Database, - UnsubscribedPenId, - SeededLast.AddSeconds(1), - 7, - ArchiveRow.OrdinaryQuality, - cancellationToken: cancellationToken); - - var samples = await poll.ReadOnceAsync(cancellationToken); - - samples.Should().BeEmpty(); - } - - // The poll advances past the null row, or it would re-read the same row on every later tick. A delivered - // row cannot show that, so the tick after the null row reads its own debug entry: it must count no row. - [Fact] - public async Task ARowCarryingANullValueEmitsNoSampleAndIsNotReadAgain() - { - var cancellationToken = TestContext.Current.CancellationToken; - - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString); - using var logs = new FakeLoggerProvider(); - - var poll = NewPoll(services, logs.CreateLogger("RealtimePoll")); - - await poll.ReadOnceAsync(cancellationToken); - await AppendAsync( - Database, - SubscribedPenIds[0], - SeededLast.AddSeconds(1), - null, - ArchiveRow.OrdinaryQuality, - cancellationToken: cancellationToken); - - var samples = await poll.ReadOnceAsync(cancellationToken); - - samples.Should().BeEmpty(); - - await poll.ReadOnceAsync(cancellationToken); - - PollCounts(logs).Should().Equal(("1", "0", "1", "0"), ("0", "0", "0", "0")); - } - - [Fact] - public async Task ARowMarkingTheLastSampleBeforeABreakEmitsItsValueAndThenABreak() - { - var cancellationToken = TestContext.Current.CancellationToken; - var appended = SeededLast.AddSeconds(1); - var appendedUtc = ArchiveProviderFactory.TimeConverter.ToUtc(appended); - - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString); - using var logs = new FakeLoggerProvider(); - - var poll = NewPoll(services, logs.CreateLogger("RealtimePoll")); - - await poll.ReadOnceAsync(cancellationToken); - await AppendAsync( - Database, - SubscribedPenIds[0], - appended, - 11.25, - ArchiveRow.LastBeforeBreakQuality, - cancellationToken: cancellationToken); - - var samples = await poll.ReadOnceAsync(cancellationToken); - - samples.Should().Equal( - new Sample(SubscribedPenIds[0], appendedUtc, 11.25), - new Sample(SubscribedPenIds[0], appendedUtc.AddMilliseconds(1), null)); - PollCounts(logs).Should().Equal(("1", "1", "0", "1")); - } - - // docs/architecture/data-integration.md#realtime - [Fact] - public async Task ARowMarkingTheLastSampleBeforeABreakWithANullValueEmitsOnlyTheBreak() - { - var cancellationToken = TestContext.Current.CancellationToken; - var appended = SeededLast.AddSeconds(1); - - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString); - using var logs = new FakeLoggerProvider(); - - var poll = NewPoll(services, logs.CreateLogger("RealtimePoll")); - - await poll.ReadOnceAsync(cancellationToken); - await AppendAsync( - Database, - SubscribedPenIds[0], - appended, - null, - ArchiveRow.LastBeforeBreakQuality, - cancellationToken: cancellationToken); - - var samples = await poll.ReadOnceAsync(cancellationToken); - - samples.Should().Equal(new Sample( - SubscribedPenIds[0], - ArchiveProviderFactory.TimeConverter.ToUtc(appended).AddMilliseconds(1), - null)); - - await poll.ReadOnceAsync(cancellationToken); - - PollCounts(logs).Should().Equal(("1", "0", "1", "1"), ("0", "0", "0", "0")); - } - - // Renamed rather than dropped: the container is shared. - [Fact] - public async Task TheFirstTickAfterTheTableReturnsTakesTheBaseline() - { - const int FailedTickCount = 3; - var cancellationToken = TestContext.Current.CancellationToken; - - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString); - - var poll = NewPoll(services); - - await HideTrendsAsync(cancellationToken); - - try - { - for (var tick = 0; tick < FailedTickCount; tick++) - { - (await poll.ReadOnceAsync(cancellationToken)).Should().BeEmpty(); - } - } - finally - { - await RestoreTrendsAsync(cancellationToken); - } - - // The recovering tick is an ordinary baseline read, taken at the newest row then present: row A, so - // it delivers nothing, and only row B written after it is delivered. - var appendedFirst = SeededLast.AddSeconds(1); - var appendedSecond = SeededLast.AddSeconds(2); - - await AppendAsync( - Database, - SubscribedPenIds[0], - appendedFirst, - 1, - ArchiveRow.OrdinaryQuality, - cancellationToken: cancellationToken); - - var recovery = await poll.ReadOnceAsync(cancellationToken); - - recovery.Should().BeEmpty(); - - await AppendAsync( - Database, - SubscribedPenIds[1], - appendedSecond, - 2, - ArchiveRow.OrdinaryQuality, - cancellationToken: cancellationToken); - - var delivery = await poll.ReadOnceAsync(cancellationToken); - - var delivered = delivery.Should().ContainSingle().Which; - - delivered.PenId.Should().Be(SubscribedPenIds[1]); - delivered.TimestampUtc.Should().Be(ArchiveProviderFactory.TimeConverter.ToUtc(appendedSecond)); - } - - // Renamed rather than dropped: the rows and the day partitions come back with it, so the recovering - // tick reads the archive this class seeded rather than an empty one. - private Task HideTrendsAsync(CancellationToken cancellationToken) - { - return ArchiveDatabase.ExecuteAsync( - Database.AdminConnectionString, - "ALTER TABLE public.trends RENAME TO trends_hidden_by_test;", - cancellationToken); - } - - private Task RestoreTrendsAsync(CancellationToken cancellationToken) - { - return ArchiveDatabase.ExecuteAsync( - Database.AdminConnectionString, - "ALTER TABLE public.trends_hidden_by_test RENAME TO trends;", - cancellationToken); - } - - // The baseline entry carries no RowCount. - private static IEnumerable<(string? Rows, string? Values, string? Dropped, string? Breaks)> PollCounts( - FakeLoggerProvider logs) - { - return logs.Collector.GetSnapshot() - .Where(entry => entry.GetStructuredStateValue("RowCount") is not null) - .Select(entry => ( - entry.GetStructuredStateValue("RowCount"), - entry.GetStructuredStateValue("ValueCount"), - entry.GetStructuredStateValue("DroppedCount"), - entry.GetStructuredStateValue("BreakCount"))); - } - - // The unsubscribed variable's newest row sits a minute after the subscribed ones, so a baseline read - // over the whole archive rather than over the requested identifiers answers a different instant and fails. - private static IReadOnlyList SeededRowsWithAnUnsubscribedVariable() - { - return - [ - .. SeededRows(), - new ArchiveRow( - UnsubscribedPenId, - ArchiveRow.RawLayer, - _unsubscribedLast, - UnsubscribedPenId, - ArchiveRow.OrdinaryQuality) - ]; - } -} diff --git a/SemiPlot/SemiPlot.Tests.Integration/RealtimePollTests.cs b/SemiPlot/SemiPlot.Tests.Integration/RealtimePollTests.cs deleted file mode 100644 index 367cbcf..0000000 --- a/SemiPlot/SemiPlot.Tests.Integration/RealtimePollTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -using System.Reactive; -using System.Reactive.Concurrency; -using System.Reactive.Linq; - -using AwesomeAssertions; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Testing; - -using SemiPlot.Core.Data; -using SemiPlot.Core.Trends; -using SemiPlot.DataSource.Postgres; -using SemiPlot.DataSource.Postgres.Configuration; - -using Xunit; - -using static SemiPlot.Tests.Integration.RealtimeArchive; - -namespace SemiPlot.Tests.Integration; - -// A failed tick and its logged run of failures. No server: every tick connects to an address nothing answers. -// What a successful tick reads is covered by RealtimePollReadTests. -[Trait("Component", "Core")] -[Trait("Area", "Data")] -[Trait("Category", "Integration")] -public sealed class RealtimePollTests -{ - private const int TickCount = 5; - - private static readonly PostgresConnectionSettings _settings = UnreachableArchive.Settings(TimeSpan.FromSeconds(1)); - - private static readonly PostgresConnectionSettings _fastSettings = - UnreachableArchive.Settings(TimeSpan.FromMilliseconds(10)); - - [Fact] - public async Task AFailedTickEmitsNoSample() - { - await using var services = UnreachableArchive.Services(_settings); - - var samples = await NewPoll(services).ReadOnceAsync(TestContext.Current.CancellationToken); - - samples.Should().BeEmpty(); - } - - [Fact] - public async Task EachFailedTickLogsTheRunOfFailuresItExtends() - { - var cancellationToken = TestContext.Current.CancellationToken; - - await using var services = UnreachableArchive.Services(_settings); - using var logs = new FakeLoggerProvider(); - - var poll = NewPoll(services, logs.CreateLogger("RealtimePoll")); - - for (var tick = 0; tick < TickCount; tick++) - { - await poll.ReadOnceAsync(cancellationToken); - } - - logs.Collector.GetSnapshot() - .Select(entry => entry.GetStructuredStateValue(ArmedGate.FailureRunKey)) - .Should().Equal("1", "2", "3", "4", "5"); - } - - [Fact] - public async Task ASubscriptionThatOnlyFailsNeitherCompletesNorFaults() - { - // The last failure runs only if nothing terminal followed the one before it. - const int FailedTicksBeforeAssert = 6; - - using var gate = new ArmedGate(); - await using var services = UnreachableArchive.Services(_fastSettings, gate); - - var liveSource = services.GetRequiredService(); - - using var notifications = new BatchCollector>>( - liveSource.Subscribe([1]).Materialize()); - - await gate.FailuresReached(FailedTicksBeforeAssert) - .WaitAsync(ClonedArchiveTest.GateBound, TestContext.Current.CancellationToken); - - notifications.Batches.Should().BeEmpty(); - } - - [Fact] - public async Task ThePollLogsUnderTheLiveSourcesCategory() - { - using var logs = new FakeLoggerProvider(); - using var gate = new ArmedGate(logs); - await using var services = UnreachableArchive.Services(_fastSettings, gate); - - using var subscription = services.GetRequiredService().Subscribe([1]).Subscribe(_ => { }); - - // The gate counts a line before its inner logger writes it, so the second failure proves the first written. - await gate.FailuresReached(2).WaitAsync(ClonedArchiveTest.GateBound, TestContext.Current.CancellationToken); - - logs.Collector.GetSnapshot() - .Where(entry => entry.GetStructuredStateValue(ArmedGate.FailureRunKey) is not null) - .Should().NotBeEmpty() - .And.OnlyContain(entry => entry.Category == typeof(PostgresLiveSource).FullName); - } - - [Fact] - public async Task ThePollWaitsTheConfiguredIntervalAfterATick() - { - var pollInterval = TimeSpan.FromMilliseconds(1234); - var scheduler = new DueTimeRecorder(); - await using var services = UnreachableArchive.Services( - UnreachableArchive.Settings(pollInterval), - scheduler: scheduler); - - using var subscription = services.GetRequiredService().Subscribe([1]).Subscribe(_ => { }); - - var dueTime = await scheduler.FirstDueTime - .WaitAsync(ClonedArchiveTest.GateBound, TestContext.Current.CancellationToken); - - dueTime.Should().Be(pollInterval); - } - - private sealed class DueTimeRecorder : IScheduler - { - private readonly TaskCompletionSource _firstDueTime = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - public Task FirstDueTime => _firstDueTime.Task; - - public DateTimeOffset Now => DefaultScheduler.Instance.Now; - - public IDisposable Schedule(TState state, Func action) - { - return DefaultScheduler.Instance.Schedule(state, action); - } - - public IDisposable Schedule( - TState state, - TimeSpan dueTime, - Func action) - { - _firstDueTime.TrySetResult(dueTime); - - return DefaultScheduler.Instance.Schedule(state, dueTime, action); - } - - public IDisposable Schedule( - TState state, - DateTimeOffset dueTime, - Func action) - { - return DefaultScheduler.Instance.Schedule(state, dueTime, action); - } - } -} diff --git a/SemiPlot/SemiPlot.Tests.Integration/RealtimeSubscriptionDisposalTests.cs b/SemiPlot/SemiPlot.Tests.Integration/RealtimeSubscriptionDisposalTests.cs deleted file mode 100644 index 2b531ce..0000000 --- a/SemiPlot/SemiPlot.Tests.Integration/RealtimeSubscriptionDisposalTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using AwesomeAssertions; - -using Microsoft.Extensions.DependencyInjection; - -using SemiPlot.Core.Data; -using SemiPlot.Core.Trends; - -using Xunit; - -namespace SemiPlot.Tests.Integration; - -[Trait("Component", "Core")] -[Trait("Area", "Data")] -[Trait("Category", "Integration")] -public sealed class RealtimeSubscriptionDisposalTests -{ - private const int CalibrationTicks = 3; - - // Silence alone proves nothing (a leaked loop against this unreachable address is also silent), so a - // second, left-running live source's own failed ticks calibrate the wait. - [Fact] - public async Task SubscribingAndDisposingAtOnceDeliversNothingAndStopsThePoll() - { - var settings = UnreachableArchive.Settings(TimeSpan.FromMilliseconds(10)); - - using var droppedGate = new ArmedGate(); - using var runningGate = new ArmedGate(); - using var droppedServices = UnreachableArchive.Services(settings, droppedGate); - using var runningServices = UnreachableArchive.Services(settings, runningGate); - - var dropped = droppedServices.GetRequiredService(); - var running = runningServices.GetRequiredService(); - - using var droppedBatches = new BatchCollector>(dropped.Subscribe([1])); - - droppedBatches.Dispose(); - - using var control = running.Subscribe([1]).Subscribe(_ => { }); - - await runningGate.FailuresReached(CalibrationTicks) - .WaitAsync(ClonedArchiveTest.GateBound, TestContext.Current.CancellationToken); - - // The control needed three ticks to get here, so a loop that survived its own disposal has had the - // same three plus this margin. Anything it logged has been recorded by now. - await Task.Delay(settings.PollInterval * 10, TestContext.Current.CancellationToken); - - droppedBatches.Batches.Should().BeEmpty(); - droppedGate.HighestConsecutiveFailures.Should().BeLessThan(CalibrationTicks); - } -} diff --git a/SemiPlot/SemiPlot.Tests.Integration/RealtimeSubscriptionTests.cs b/SemiPlot/SemiPlot.Tests.Integration/RealtimeSubscriptionTests.cs deleted file mode 100644 index 84cfb73..0000000 --- a/SemiPlot/SemiPlot.Tests.Integration/RealtimeSubscriptionTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System.Reactive; -using System.Reactive.Linq; - -using AwesomeAssertions; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Testing; - -using SemiPlot.Core.Data; -using SemiPlot.Core.Trends; - -using Xunit; - -using static SemiPlot.Tests.Integration.RealtimeArchive; - -namespace SemiPlot.Tests.Integration; - -// Every synchronisation point is an awaited emission (the poll's baseline log line, or a delivered batch) -// bounded by GateBound, except the disposal check, which waits out poll intervals because a stopped poll emits -// nothing. Appending before the baseline has run races the subscription and fails the await at the bound. -[Collection(ArchiveDatabaseCollection.Name)] -[Trait("Component", "Core")] -[Trait("Area", "Data")] -[Trait("Category", "Integration")] -public sealed class RealtimeSubscriptionTests(PostgresContainerFixture postgresContainerFixture) - : ClonedArchiveTest(postgresContainerFixture, CloneSource.Provisioned) -{ - private const int FailureRunBound = 3; - - protected override async ValueTask SeedAsync() - { - await Writer().WriteAsync(SeededRows(), Day, NextDay); - } - - // The logger belongs to this live source and this one subscription, so every per-tick entry that carries a - // row count (written on each successful tick after the baseline) is the subscription's own. A poll the - // disposal left running would write one a second. - [Fact] - public async Task DisposingASubscriptionStopsItsPoll() - { - var cancellationToken = TestContext.Current.CancellationToken; - - using var logs = new FakeLoggerProvider(); - using var armed = new ArmedGate(logs); - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString, armed); - - var liveSource = services.GetRequiredService(); - - using var subscription = new BatchCollector>(liveSource.Subscribe(SubscribedPenIds)); - - await armed.Armed(1).WaitAsync(GateBound, cancellationToken); - - var delivery = subscription.NextBatch(); - - await AppendAsync( - Database, SubscribedPenIds[0], SeededLast.AddSeconds(1), 1, cancellationToken: cancellationToken); - await delivery.WaitAsync(GateBound, cancellationToken); - - PollEntries(logs).Should().NotBeEmpty("the running subscription writes an entry for the tick that delivered"); - - subscription.Dispose(); - - // A tick already past its query when the subscription is dropped may still write its entry. - await Task.Delay(ArchiveProviderFactory.PollInterval * 2, cancellationToken); - - var entriesAfterDisposal = PollEntries(logs).Count; - - await AppendAsync( - Database, SubscribedPenIds[0], SeededLast.AddSeconds(2), 2, cancellationToken: cancellationToken); - await Task.Delay(ArchiveProviderFactory.PollInterval * 3, cancellationToken); - - PollEntries(logs).Count.Should().Be(entriesAfterDisposal); - } - - // The sequence is cold, so each subscription runs a RealtimePoll of its own and takes a baseline of its - // own. The second one's baseline is already past the row the first was delivered, so that row must never - // reach it, while the row appended after both are armed reaches both. - [Fact] - public async Task TwoSubscriptionsEachKeepTheirOwnLastSeen() - { - var cancellationToken = TestContext.Current.CancellationToken; - var beforeTheSecondSubscription = SeededLast.AddSeconds(1); - var afterBoth = SeededLast.AddSeconds(2); - - using var armed = new ArmedGate(); - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString, armed); - - var liveSource = services.GetRequiredService(); - - using var first = new BatchCollector>(liveSource.Subscribe(SubscribedPenIds)); - - await armed.Armed(1).WaitAsync(GateBound, cancellationToken); - - var firstSeesTheEarlyRow = first.NextBatch(); - - await AppendAsync( - Database, SubscribedPenIds[0], beforeTheSecondSubscription, 1, cancellationToken: cancellationToken); - await firstSeesTheEarlyRow.WaitAsync(GateBound, cancellationToken); - - using var second = new BatchCollector>(liveSource.Subscribe(SubscribedPenIds)); - - await armed.Armed(2).WaitAsync(GateBound, cancellationToken); - - var firstSeesTheLateRow = first.NextBatch(); - var secondSeesTheLateRow = second.NextBatch(); - - await AppendAsync( - Database, SubscribedPenIds[0], afterBoth, 2, cancellationToken: cancellationToken); - await firstSeesTheLateRow.WaitAsync(GateBound, cancellationToken); - await secondSeesTheLateRow.WaitAsync(GateBound, cancellationToken); - - armed.HighestConsecutiveFailures.Should().BeLessThan( - FailureRunBound, - "the test relies on an archive that answers the poll, and a run of failed ticks this long says it did not"); - Delivered(first).Should().Equal( - [ - ArchiveProviderFactory.TimeConverter.ToUtc(beforeTheSecondSubscription), - ArchiveProviderFactory.TimeConverter.ToUtc(afterBoth) - ]); - Delivered(second).Should().Equal([ArchiveProviderFactory.TimeConverter.ToUtc(afterBoth)]); - } - - // The archive holds no row for the second identifier, which is what an identifier the source does not - // serve looks like to the poll. - [Fact] - public async Task AnIdentifierTheSourceDoesNotServeLeavesTheStreamRunning() - { - const int UnservedPenId = 9999; - var cancellationToken = TestContext.Current.CancellationToken; - - using var armed = new ArmedGate(); - using var services = ArchiveProviderFactory.Build(Database.PlotConnectionString, armed); - - var liveSource = services.GetRequiredService(); - - using var notifications = new BatchCollector>>( - liveSource.Subscribe([SubscribedPenIds[0], UnservedPenId]).Materialize()); - - await armed.Armed(1).WaitAsync(GateBound, cancellationToken); - - var firstDelivery = notifications.NextBatch(); - - await AppendAsync( - Database, SubscribedPenIds[0], SeededLast.AddSeconds(1), 1, cancellationToken: cancellationToken); - await firstDelivery.WaitAsync(GateBound, cancellationToken); - - var secondDelivery = notifications.NextBatch(); - - await AppendAsync( - Database, SubscribedPenIds[0], SeededLast.AddSeconds(2), 2, cancellationToken: cancellationToken); - await secondDelivery.WaitAsync(GateBound, cancellationToken); - - notifications.Batches.Should().HaveCount(2) - .And.OnlyContain(notification => notification.Kind == NotificationKind.OnNext); - notifications.Batches - .SelectMany(notification => notification.Value) - .Select(sample => sample.PenId) - .Should().Equal(SubscribedPenIds[0], SubscribedPenIds[0]); - } - - private static IReadOnlyList PollEntries(FakeLoggerProvider logs) - { - return [.. logs.Collector.GetSnapshot().Where(entry => entry.GetStructuredStateValue("RowCount") is not null)]; - } - - private static DateTime[] Delivered(BatchCollector> collector) - { - return [.. collector.Batches - .SelectMany(batch => batch) - .Select(sample => sample.TimestampUtc)]; - } -} diff --git a/SemiPlot/SemiPlot.Tests.Integration/SemiPlot.Tests.Integration.csproj b/SemiPlot/SemiPlot.Tests.Integration/SemiPlot.Tests.Integration.csproj index 6bbb1bc..452e1e1 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/SemiPlot.Tests.Integration.csproj +++ b/SemiPlot/SemiPlot.Tests.Integration/SemiPlot.Tests.Integration.csproj @@ -29,6 +29,7 @@ + diff --git a/SemiPlot/SemiPlot.Tests.Integration/StartupProbeOpcSectionTests.cs b/SemiPlot/SemiPlot.Tests.Integration/StartupProbeOpcSectionTests.cs new file mode 100644 index 0000000..1d6644e --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Integration/StartupProbeOpcSectionTests.cs @@ -0,0 +1,58 @@ +using System.Net; + +using AwesomeAssertions; + +using SemiPlot.Core.Data.Errors; +using SemiPlot.Tests.Integration.DemoScada; +using SemiPlot.UI; +using SemiPlot.UI.Startup; + +using Serilog.Events; + +using Xunit; + +namespace SemiPlot.Tests.Integration; + +// Opens a loopback socket toward a port nothing answers and no container, so it runs without a container +// runtime. +[Trait("Component", "UI")] +[Trait("Area", "Di")] +[Trait("Category", "Integration")] +public sealed class StartupProbeOpcSectionTests : IDisposable +{ + private readonly string _configDirectory = + Path.Combine(Path.GetTempPath(), $"semiplot-probe-opc-{Guid.NewGuid():N}"); + + public void Dispose() + { + Directory.Delete(_configDirectory, recursive: true); + } + + [Fact] + public void AValidOpcSection_PassesTheStartToTheArchiveReadAndOpensNoOpcSocket() + { + using var opcPort = DemoScadaHost.ListenOnFreePort(); + var port = ((IPEndPoint)opcPort.LocalEndpoint).Port; + WriteSection( + StartupProbe.ConnectionDirectoryName, + "connection.yaml", + "host: 127.0.0.1\nport: 1\ndatabase: semiplot\nuser: semiplot\npassword: unused\n"); + WriteSection(StartupProbe.OpcDirectoryName, "opc.yaml", $"host: 127.0.0.1\nport: {port}\n"); + + var result = StartupProbe.Run( + new StartupOptions(_configDirectory, Path.Combine(_configDirectory, "semiplot.log"), LogEventLevel.Fatal)); + + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle().Which.Should().BeOfType() + .Which.Kind.Should().Be(ArchiveFault.Unreachable); + opcPort.Pending().Should().BeFalse("the probe connects to nothing but the archive"); + } + + private void WriteSection(string section, string fileName, string content) + { + var directory = Path.Combine(_configDirectory, section); + + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, fileName), content); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Integration/UnreachableArchive.cs b/SemiPlot/SemiPlot.Tests.Integration/UnreachableArchive.cs index 76c8649..64d5c2d 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/UnreachableArchive.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/UnreachableArchive.cs @@ -1,7 +1,4 @@ -using System.Reactive.Concurrency; - using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using SemiPlot.DataSource.Postgres; using SemiPlot.DataSource.Postgres.Configuration; @@ -12,7 +9,7 @@ namespace SemiPlot.Tests.Integration; // container. internal static class UnreachableArchive { - public static PostgresConnectionSettings Settings(TimeSpan pollInterval) + public static PostgresConnectionSettings Settings() { return new PostgresConnectionSettings( Host: "127.0.0.1", @@ -21,25 +18,14 @@ public static PostgresConnectionSettings Settings(TimeSpan pollInterval) Username: "semiplot_reader", Password: "unused", SourceTimeZone: TimeZoneInfo.Utc, - PollInterval: pollInterval, Schema: "public"); } - public static ServiceProvider Services( - PostgresConnectionSettings settings, - ILoggerProvider? loggerProvider = null, - IScheduler? scheduler = null) + public static ServiceProvider Services(PostgresConnectionSettings settings) { - var services = new ServiceCollection() - .AddTestLogging(loggerProvider) - .AddPostgresData(settings); - - if (scheduler is not null) - { - // Registered after AddPostgresData's own scheduler, so every resolution takes this one. - services.AddSingleton(scheduler); - } - - return services.BuildServiceProvider(); + return new ServiceCollection() + .AddLogging() + .AddPostgresData(settings) + .BuildServiceProvider(); } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/ConnectionFileWriterTests.cs b/SemiPlot/SemiPlot.Tests.Unit/ConnectionFileWriterTests.cs index 5b4a8f3..f00f5d0 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/ConnectionFileWriterTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/ConnectionFileWriterTests.cs @@ -30,7 +30,6 @@ await ConnectionFileWriter.WriteAsync( "semiplot_dev", "semiplot_reader", "s3cret", - TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); var result = PostgresConnectionLoader.Load( @@ -42,7 +41,6 @@ await ConnectionFileWriter.WriteAsync( result.Value.Database.Should().Be("semiplot_dev"); result.Value.Username.Should().Be("semiplot_reader"); result.Value.Password.Should().Be("s3cret"); - result.Value.PollInterval.Should().Be(TimeSpan.FromSeconds(1)); } [Fact] @@ -51,8 +49,7 @@ public async Task TheSectionDirectoryIsCreatedWhenItDoesNotExist() var nested = Path.Combine(_directory, "nested"); await ConnectionFileWriter.WriteAsync( - nested, "127.0.0.1", 5432, "db", "user", "pw", TimeSpan.FromSeconds(1), - TestContext.Current.CancellationToken); + nested, "127.0.0.1", 5432, "db", "user", "pw", TestContext.Current.CancellationToken); File.Exists(Path.Combine(nested, ConnectionFileWriter.DirectoryName, ConnectionFileWriter.FileName)) .Should().BeTrue(); diff --git a/SemiPlot/SemiPlot.Tests.Unit/ConvergeTests.cs b/SemiPlot/SemiPlot.Tests.Unit/ConvergeTests.cs index 6af3270..d82aceb 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/ConvergeTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/ConvergeTests.cs @@ -15,7 +15,13 @@ public sealed class ConvergeTests [Fact] public async Task AConnectionStringWithNoDatabaseIsRejected() { - var options = new ConvergeOptions("Host=127.0.0.1;Username=postgres", "Host=127.0.0.1", "C:\\config", null, SeederOptions.DefaultChangeSeconds); + var options = new ConvergeOptions( + "Host=127.0.0.1;Username=postgres", + "Host=127.0.0.1", + "C:\\config", + null, + SeederOptions.DefaultChangeSeconds, + 55875); Func act = () => Converge.RunAsync(options, TestContext.Current.CancellationToken); @@ -28,7 +34,12 @@ public async Task AConnectionStringWithNoDatabaseIsRejected() public async Task AConnectionNamingANonBenchDatabaseIsRejected(string database) { var options = new ConvergeOptions( - $"Host=127.0.0.1;Database={database};Username=scada_writer", "Host=127.0.0.1", "C:\\config", null, SeederOptions.DefaultChangeSeconds); + $"Host=127.0.0.1;Database={database};Username=scada_writer", + "Host=127.0.0.1", + "C:\\config", + null, + SeederOptions.DefaultChangeSeconds, + 55875); Func act = () => Converge.RunAsync(options, TestContext.Current.CancellationToken); diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs index 5aae03f..4b62b39 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/ConfigurationSectionWriterTests.cs @@ -132,7 +132,6 @@ public void TheUntouchedKeysOfARewrittenFileStillLoad() loaded.Value.Port.Should().Be(5432); loaded.Value.Database.Should().Be("semiplot"); loaded.Value.Username.Should().Be("semiplot"); - loaded.Value.PollInterval.Should().Be(TimeSpan.FromSeconds(1)); loaded.Value.Schema.Should().Be("archive"); } diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/NetworkAddressTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/NetworkAddressTests.cs new file mode 100644 index 0000000..87f1c71 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Configuration/NetworkAddressTests.cs @@ -0,0 +1,43 @@ +using AwesomeAssertions; + +using SemiPlot.Core.Configuration; + +using Xunit; + +namespace SemiPlot.Tests.Unit.Core.Configuration; + +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class NetworkAddressTests +{ + [Theory] + [InlineData("0.0.0.0")] + [InlineData("127.0.0.1")] + [InlineData("192.168.0.10")] + [InlineData("255.255.255.255")] + public void AnIPv4AddressOfFourDecimalOctetsIsAHost(string host) + { + NetworkAddress.IsIPv4(host).Should().BeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("localhost")] + [InlineData("::1")] + [InlineData("::ffff:127.0.0.1")] + [InlineData("127.1")] + [InlineData("1.2.3.4.5")] + [InlineData("1..2.3")] + [InlineData("256.0.0.1")] + [InlineData("1.2.3.1000")] + [InlineData("010.0.0.1")] + [InlineData("+1.2.3.4")] + [InlineData(" 127.0.0.1")] + [InlineData("\uFF11.2.3.4")] + public void AnythingButFourDecimalOctetsIsNotAHost(string? host) + { + NetworkAddress.IsIPv4(host).Should().BeFalse(); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/DeliveredConfigurationTests.cs b/SemiPlot/SemiPlot.Tests.Unit/DeliveredConfigurationTests.cs index 59c67d4..e310688 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/DeliveredConfigurationTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/DeliveredConfigurationTests.cs @@ -1,6 +1,7 @@ using AwesomeAssertions; using SemiPlot.Core.Data.Errors; +using SemiPlot.DataSource.OpcUa.Configuration; using SemiPlot.DataSource.Postgres.Configuration; using SemiPlot.Tests.Unit.UI.Settings; using SemiPlot.Tools.ArchiveSeeder; @@ -69,10 +70,22 @@ public void TheShippedConnectionSectionIsValidOnceItCarriesACredential() result.Value.Database.Should().Be("semiplot"); result.Value.Username.Should().Be("semiplot"); result.Value.Schema.Should().Be("public"); - result.Value.PollInterval.Should().Be(TimeSpan.FromMilliseconds(1000)); } - // converge overwrites this one file inside the demo's copy of the set. A writer aiming anywhere else + [Fact] + public void TheShippedOpcSectionLoads() + { + var directory = ShippedConfiguration.SectionDirectory(StartupProbe.OpcDirectoryName); + + var result = OpcUaConnectionLoader.Load(directory); + + result.IsSuccess.Should().BeTrue(Describe(result)); + result.Value.Host.Should().Be("127.0.0.1"); + result.Value.Port.Should().Be(49875); + result.Value.CertificateStoreRoot.Should().Be(Path.Combine(directory, "pki")); + } + + // converge overwrites these files inside the demo's copy of the set. A writer aiming anywhere else // would add a second file carrying the same keys, which is the conflict the section rule refuses. [Fact] public void TheSeederWritesOverTheShippedConnectionFile() @@ -83,4 +96,14 @@ public void TheSeederWritesOverTheShippedConnectionFile() File.Exists(target).Should().BeTrue(target); } + + [Fact] + public void TheSeederWritesOverTheShippedOpcFile() + { + OpcFileWriter.DirectoryName.Should().Be(StartupProbe.OpcDirectoryName); + + var target = OpcFileWriter.PathFor(ShippedConfiguration.Root); + + File.Exists(target).Should().BeTrue(target); + } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcFileWriterTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcFileWriterTests.cs new file mode 100644 index 0000000..5712516 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcFileWriterTests.cs @@ -0,0 +1,45 @@ +using AwesomeAssertions; + +using SemiPlot.DataSource.OpcUa.Configuration; +using SemiPlot.Tools.ArchiveSeeder; + +using Xunit; + +using static SemiPlot.Tests.Unit.ResultText; + +namespace SemiPlot.Tests.Unit; + +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class OpcFileWriterTests : IDisposable +{ + private readonly TemporaryDirectory _directory = new("semiplot-opc-file"); + + public void Dispose() + { + _directory.Dispose(); + } + + [Fact] + public async Task TheWrittenFileLoadsThroughTheOpcLoader() + { + await OpcFileWriter.WriteAsync(_directory, "10.20.30.40", 55875, TestContext.Current.CancellationToken); + + var result = OpcUaConnectionLoader.Load(Path.Combine(_directory, OpcFileWriter.DirectoryName)); + + result.IsSuccess.Should().BeTrue(Describe(result)); + result.Value.Host.Should().Be("10.20.30.40"); + result.Value.Port.Should().Be(55875); + } + + [Fact] + public async Task TheSectionDirectoryIsCreatedWhenItDoesNotExist() + { + var nested = Path.Combine(_directory, "nested"); + + await OpcFileWriter.WriteAsync(nested, "127.0.0.1", 55875, TestContext.Current.CancellationToken); + + File.Exists(OpcFileWriter.PathFor(nested)).Should().BeTrue(); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/ClientApplicationConfigurationTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/ClientApplicationConfigurationTests.cs new file mode 100644 index 0000000..0affe98 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/ClientApplicationConfigurationTests.cs @@ -0,0 +1,78 @@ +using AwesomeAssertions; + +using Microsoft.Extensions.Logging.Abstractions; + +using Opc.Ua; + +using SemiPlot.DataSource.OpcUa; +using SemiPlot.DataSource.OpcUa.Configuration; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +// The store root sits under a temp directory, never under the tracked configuration set. +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class ClientApplicationConfigurationTests : IDisposable +{ + private readonly TemporaryDirectory _directory = new("semiplot-opc-client"); + + public void Dispose() + { + _directory.Dispose(); + } + + private string StoreRoot => Path.Combine(_directory, OpcUaConnectionLoader.CertificateStoreDirectoryName); + + [Fact] + public async Task TheConfigurationValidatesWithTheClientsIdentityAndTimeouts() + { + var configuration = await CreateAsync(); + + configuration.ApplicationType.Should().Be(ApplicationType.Client); + configuration.ApplicationName.Should().Be("SemiPlot"); + configuration.ApplicationUri.Should().Be("urn:localhost:SemiPlot"); + configuration.TransportQuotas.Should().NotBeNull(); + configuration.TransportQuotas.OperationTimeout.Should().Be(15000); + configuration.ClientConfiguration.Should().NotBeNull(); + configuration.ClientConfiguration.DefaultSessionTimeout.Should().Be(60000); + } + + [Fact] + public async Task TheFourStoresSitUnderTheSettingsStoreRoot() + { + var security = (await CreateAsync()).SecurityConfiguration; + + security.Should().NotBeNull(); + security.ApplicationCertificate.Should().NotBeNull(); + security.ApplicationCertificate.StoreType.Should().Be(CertificateStoreType.Directory); + security.ApplicationCertificate.StorePath.Should().Be(Path.Combine(StoreRoot, "own")); + security.ApplicationCertificate.SubjectName.Should().Be("CN=SemiPlot"); + security.TrustedIssuerCertificates.Should().NotBeNull(); + security.TrustedIssuerCertificates.StorePath.Should().Be(Path.Combine(StoreRoot, "issuer")); + security.TrustedPeerCertificates.Should().NotBeNull(); + security.TrustedPeerCertificates.StorePath.Should().Be(Path.Combine(StoreRoot, "trusted")); + security.RejectedCertificateStore.Should().NotBeNull(); + security.RejectedCertificateStore.StorePath.Should().Be(Path.Combine(StoreRoot, "rejected")); + } + + [Fact] + public async Task ValidationCreatesNoFileOrFolder() + { + await CreateAsync(); + + Directory.EnumerateFileSystemEntries(_directory).Should().BeEmpty(); + } + + private Task CreateAsync() + { + var settings = new OpcUaConnectionSettings("127.0.0.1", 4841, StoreRoot); + + return ClientApplicationConfiguration.CreateValidatedAsync( + settings, + new StackTelemetry(NullLoggerFactory.Instance), + TestContext.Current.CancellationToken); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/ConnectBackoffTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/ConnectBackoffTests.cs new file mode 100644 index 0000000..5a60e05 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/ConnectBackoffTests.cs @@ -0,0 +1,76 @@ +using AwesomeAssertions; + +using SemiPlot.Core.Data.Errors; +using SemiPlot.DataSource.OpcUa; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class ConnectBackoffTests +{ + private const string Endpoint = "opc.tcp://127.0.0.1:4841"; + + private readonly ConnectBackoff _backoff = new(); + + [Fact] + public void WhileNoServerAnswers_TheWaitDoublesFromOneSecondToTheFiveSecondCap() + { + var waits = Enumerable.Range(0, 5).Select(_ => _backoff.Next(ConnectPassEnd.Unanswered)); + + waits.Select(wait => wait.TotalSeconds).Should().Equal(1, 2, 4, 5, 5); + } + + // The two pieces the session's setup-fault path calls in turn, side by side. + [Theory] + [InlineData(LiveSourceFault.EndpointRefused)] + [InlineData(LiveSourceFault.ProjectMissing)] + public void ASetupFault_IsReportedOnce_AndRetriedAfterThirtySeconds(LiveSourceFault kind) + { + var states = new LiveSessionStates(Endpoint); + + var first = states.OnSetupFault(kind); + var firstWait = _backoff.Next(ConnectPassEnd.SetupFault); + var second = states.OnSetupFault(kind); + var secondWait = _backoff.Next(ConnectPassEnd.SetupFault); + + (first.Published?.Fault?.Kind).Should().Be(kind); + first.IsLoss.Should().BeFalse(); + second.Should().Be(LiveSessionChange.None); + firstWait.Should().Be(TimeSpan.FromSeconds(30)); + secondWait.Should().Be(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void ASetupFault_StartsTheNextRunOfFailuresAtOneSecond() + { + FailThreeTimesThen(ConnectPassEnd.SetupFault); + + _backoff.Next(ConnectPassEnd.Unanswered).Should().Be(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void AnEndedSession_StartsTheNextRunOfFailuresAtOneSecond() + { + FailThreeTimesThen(ConnectPassEnd.SessionEnded); + + _backoff.Next(ConnectPassEnd.Unanswered).Should().Be(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ASessionThatEndedOnItsOwn_IsRetriedAfterOneSecond() + { + _backoff.Next(ConnectPassEnd.SessionEnded).Should().Be(TimeSpan.FromSeconds(1)); + } + + private void FailThreeTimesThen(ConnectPassEnd end) + { + _backoff.Next(ConnectPassEnd.Unanswered); + _backoff.Next(ConnectPassEnd.Unanswered); + _backoff.Next(ConnectPassEnd.Unanswered); + _backoff.Next(end); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/DeliveredStampsTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/DeliveredStampsTests.cs new file mode 100644 index 0000000..a445e4d --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/DeliveredStampsTests.cs @@ -0,0 +1,145 @@ +using AwesomeAssertions; + +using SemiPlot.Core.Trends; +using SemiPlot.DataSource.OpcUa; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class DeliveredStampsTests +{ + private static readonly DateTime _firstUtc = new(2026, 10, 20, 1, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _secondUtc = _firstUtc.AddSeconds(30); + private static readonly TimeSpan _oneMillisecond = TimeSpan.FromMilliseconds(1); + + private readonly DeliveredStamps _stamps = new(); + + [Fact] + public void ALoss_BreaksEveryPenWithADeliveredValueOneMillisecondPastItsNewest() + { + _stamps.RecordValue(1, _firstUtc); + _stamps.RecordValue(2, _secondUtc); + + var breaks = _stamps.LossBreaks([1, 2, 3]); + + breaks.Should().Equal( + new Sample(1, _firstUtc + _oneMillisecond, null), + new Sample(2, _secondUtc + _oneMillisecond, null)); + } + + [Fact] + public void ASecondLossInARow_WritesNoSecondBreak() + { + _stamps.RecordValue(1, _firstUtc); + _stamps.LossBreaks([1]); + + _stamps.LossBreaks([1]).Should().BeEmpty(); + } + + [Fact] + public void AValueAfterABreak_ArmsTheNextLossBreak() + { + _stamps.RecordValue(1, _firstUtc); + _stamps.LossBreaks([1]); + _stamps.RecordValue(1, _secondUtc); + + _stamps.LossBreaks([1]).Should().Equal(new Sample(1, _secondUtc + _oneMillisecond, null)); + } + + [Fact] + public void AnOlderValueDeliveredAgain_KeepsTheNewestStamp() + { + _stamps.RecordValue(1, _secondUtc); + _stamps.RecordValue(1, _firstUtc); + + _stamps.LossBreaks([1]).Should().Equal(new Sample(1, _secondUtc + _oneMillisecond, null)); + } + + [Fact] + public void TheNewestStamp_SurvivesANewSubscriptionOfTheSamePen() + { + _stamps.RecordValue(5, _secondUtc); + + var breaksOfTheNextSubscription = _stamps.LossBreaks([5, 6]); + + breaksOfTheNextSubscription.Should().Equal(new Sample(5, _secondUtc + _oneMillisecond, null)); + } + + [Fact] + public void ABadBreak_IsClampedOneMillisecondPastTheNewest() + { + _stamps.RecordValue(1, _secondUtc); + + _stamps.BadBreak(1, _firstUtc).Should().Be(new Sample(1, _secondUtc + _oneMillisecond, null)); + } + + [Fact] + public void ABadBreakPastTheNewest_KeepsItsOwnStamp() + { + _stamps.RecordValue(1, _firstUtc); + + _stamps.BadBreak(1, _secondUtc).Should().Be(new Sample(1, _secondUtc, null)); + } + + [Fact] + public void ABadWithNoStamp_BreaksOneMillisecondPastTheNewest() + { + _stamps.RecordValue(1, _firstUtc); + + _stamps.BadBreak(1, null).Should().Be(new Sample(1, _firstUtc + _oneMillisecond, null)); + } + + [Fact] + public void ABadWithNeitherStampNorDeliveredValue_WritesNothing() + { + _stamps.BadBreak(1, null).Should().BeNull(); + } + + [Fact] + public void ABadOnAPenWithoutADeliveredValue_BreaksAtItsStamp() + { + _stamps.BadBreak(1, _firstUtc).Should().Be(new Sample(1, _firstUtc, null)); + } + + [Fact] + public void ASecondBadOnAPenWithoutADeliveredValue_WritesNoSecondBreak() + { + _stamps.BadBreak(1, _firstUtc); + + _stamps.BadBreak(1, _secondUtc).Should().BeNull(); + _stamps.LossBreaks([1]).Should().BeEmpty(); + } + + [Fact] + public void AValueAfterABadBreak_ArmsTheNextBadBreak() + { + _stamps.RecordValue(1, _firstUtc); + _stamps.BadBreak(1, null); + _stamps.RecordValue(1, _secondUtc); + + _stamps.BadBreak(1, null).Should().Be(new Sample(1, _secondUtc + _oneMillisecond, null)); + } + + [Fact] + public void AValueAfterABadOnAPenWithoutADeliveredValue_ArmsTheNextLossBreak() + { + _stamps.BadBreak(1, _firstUtc); + _stamps.RecordValue(1, _secondUtc); + + _stamps.LossBreaks([1]).Should().Equal(new Sample(1, _secondUtc + _oneMillisecond, null)); + } + + [Fact] + public void ALossAfterABadBreak_WritesNoSecondBreak() + { + _stamps.RecordValue(1, _firstUtc); + _stamps.BadBreak(1, _secondUtc); + + _stamps.LossBreaks([1]).Should().BeEmpty(); + _stamps.BadBreak(1, _secondUtc.AddSeconds(1)).Should().BeNull(); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/EndpointChoiceTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/EndpointChoiceTests.cs new file mode 100644 index 0000000..b77713c --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/EndpointChoiceTests.cs @@ -0,0 +1,136 @@ +using AwesomeAssertions; + +using Opc.Ua; + +using SemiPlot.DataSource.OpcUa; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +// The advertisements are the measured ones of the real Simple-Scada and of the stock 2.0.1 server; nothing is dialled. +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class EndpointChoiceTests +{ + private const string ConfiguredUrl = "opc.tcp://127.0.0.1:4841"; + private const string SimpleScadaUrl = "opc.tcp://WORKING-PC:49875"; + private const string DemoUrl = "opc.tcp://127.0.0.1:4841"; + + [Fact] + public void TheRealScadasSevenEndpoints_YieldTheNoneOneAtTheConfiguredUrlWithItsAdvertisedToken() + { + EndpointDescription[] endpoints = + [ + SimpleScada(MessageSecurityMode.Sign, SecurityPolicies.Basic128Rsa15), + SimpleScada(MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic128Rsa15), + SimpleScada(MessageSecurityMode.None, SecurityPolicies.None), + SimpleScada(MessageSecurityMode.Sign, SecurityPolicies.Basic256), + SimpleScada(MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256), + SimpleScada(MessageSecurityMode.Sign, SecurityPolicies.Basic256Sha256), + SimpleScada(MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256), + ]; + + var chosen = EndpointChoice.Pick(endpoints, ConfiguredUrl); + + chosen.Should().NotBeNull(); + chosen!.SecurityMode.Should().Be(MessageSecurityMode.None); + chosen.SecurityPolicyUri.Should().Be(SecurityPolicies.None); + chosen.EndpointUrl.Should().Be(ConfiguredUrl); + TokenOf(chosen).PolicyId.Should().Be("Anonymous"); + TokenOf(chosen).SecurityPolicyUri.Should().Be(SecurityPolicies.None); + } + + [Fact] + public void TheStockServersThreeEndpoints_YieldTheNoneOneWithItsOwnPolicyId() + { + EndpointDescription[] endpoints = + [ + Endpoint(DemoUrl, MessageSecurityMode.None, SecurityPolicies.None, Anonymous("1", SecurityPolicies.None)), + Endpoint(DemoUrl, MessageSecurityMode.Sign, SecurityPolicies.Basic256Sha256, Anonymous("2", "")), + Endpoint(DemoUrl, MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256, Anonymous("2", "")), + ]; + + var chosen = EndpointChoice.Pick(endpoints, ConfiguredUrl); + + chosen.Should().NotBeNull(); + chosen!.SecurityMode.Should().Be(MessageSecurityMode.None); + TokenOf(chosen).PolicyId.Should().Be("1"); + } + + [Fact] + public void ThePick_LeavesTheAdvertisedEndpointAsTheServerIssuedIt() + { + var advertised = SimpleScada(MessageSecurityMode.None, SecurityPolicies.None); + + var chosen = EndpointChoice.Pick([advertised], ConfiguredUrl); + + chosen.Should().NotBeSameAs(advertised); + advertised.EndpointUrl.Should().Be(SimpleScadaUrl); + } + + [Fact] + public void NoNoneEndpoint_YieldsNothing() + { + EndpointDescription[] endpoints = + [ + SimpleScada(MessageSecurityMode.Sign, SecurityPolicies.Basic256Sha256), + SimpleScada(MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256), + ]; + + EndpointChoice.Pick(endpoints, ConfiguredUrl).Should().BeNull(); + } + + [Fact] + public void ANoneEndpointWithoutAnAnonymousToken_YieldsNothing() + { + var userName = new UserTokenPolicy(UserTokenType.UserName) + { + PolicyId = "UserName", + SecurityPolicyUri = SecurityPolicies.None, + }; + EndpointDescription[] endpoints = + [ + Endpoint(SimpleScadaUrl, MessageSecurityMode.None, SecurityPolicies.None, userName), + ]; + + EndpointChoice.Pick(endpoints, ConfiguredUrl).Should().BeNull(); + } + + private static EndpointDescription SimpleScada(MessageSecurityMode mode, string policy) + { + return Endpoint(SimpleScadaUrl, mode, policy, Anonymous("Anonymous", SecurityPolicies.None)); + } + + private static UserTokenPolicy Anonymous(string policyId, string securityPolicyUri) + { + return new UserTokenPolicy(UserTokenType.Anonymous) + { + PolicyId = policyId, + SecurityPolicyUri = securityPolicyUri, + }; + } + + private static EndpointDescription Endpoint( + string url, + MessageSecurityMode mode, + string policy, + UserTokenPolicy token) + { + return new EndpointDescription + { + EndpointUrl = url, + SecurityMode = mode, + SecurityPolicyUri = policy, + UserIdentityTokens = [token], + }; + } + + private static UserTokenPolicy TokenOf(EndpointDescription endpoint) + { + endpoint.UserIdentityTokens.Count.Should().Be(1); + + return endpoint.UserIdentityTokens[0]; + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/LiveSessionStatesTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/LiveSessionStatesTests.cs new file mode 100644 index 0000000..5e82d3d --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/LiveSessionStatesTests.cs @@ -0,0 +1,179 @@ +using AwesomeAssertions; + +using Opc.Ua; +using Opc.Ua.Client; + +using SemiPlot.Core.Data; +using SemiPlot.Core.Data.Errors; +using SemiPlot.DataSource.OpcUa; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class LiveSessionStatesTests +{ + private const string Endpoint = "opc.tcp://127.0.0.1:4841"; + + private readonly LiveSessionStates _states = new(Endpoint); + + [Fact] + public void TheFirstConnect_PublishesConnected() + { + var change = _states.OnStateChanged(ConnectionState.Connected); + + change.Published.Should().Be(LiveSourceState.Connected); + change.IsLoss.Should().BeFalse(); + } + + [Theory] + [InlineData(ConnectionState.Reconnecting)] + [InlineData(ConnectionState.Disconnected)] + [InlineData(ConnectionState.Closing)] + [InlineData(ConnectionState.Closed)] + public void LeavingConnected_PublishesTheUnreachableFaultAndOneLoss(ConnectionState next) + { + _states.OnStateChanged(ConnectionState.Connected); + + var change = _states.OnStateChanged(next); + var repeated = _states.OnStateChanged(next); + + change.IsLoss.Should().BeTrue(); + FaultOf(change).Kind.Should().Be(LiveSourceFault.Unreachable); + FaultOf(change).Endpoint.Should().Be(Endpoint); + repeated.Should().Be(LiveSessionChange.None); + } + + [Fact] + public void LeavingAStateBeforeAnyConnect_PublishesNothingAndLosesNothing() + { + _states.OnStateChanged(ConnectionState.Disconnected).Should().Be(LiveSessionChange.None); + } + + [Fact] + public void ConnectedAfterAFault_PublishesConnected() + { + _states.OnStateChanged(ConnectionState.Connected); + _states.OnStateChanged(ConnectionState.Reconnecting); + + var change = _states.OnStateChanged(ConnectionState.Connected); + + change.Published.Should().Be(LiveSourceState.Connected); + change.IsLoss.Should().BeFalse(); + } + + [Fact] + public void ConnectedTwice_PublishesOnce() + { + _states.OnStateChanged(ConnectionState.Connected); + + _states.OnStateChanged(ConnectionState.Connected).Should().Be(LiveSessionChange.None); + } + + [Fact] + public void TheGraceWithNoSession_PublishesTheUnreachableFaultOnce() + { + var change = _states.OnGraceElapsed(); + + FaultOf(change).Kind.Should().Be(LiveSourceFault.Unreachable); + change.IsLoss.Should().BeFalse(); + _states.OnGraceElapsed().Should().Be(LiveSessionChange.None); + } + + [Fact] + public void TheGraceAfterASession_PublishesNothing() + { + _states.OnStateChanged(ConnectionState.Connected); + _states.OnStateChanged(ConnectionState.Reconnecting); + + _states.OnGraceElapsed().Should().Be(LiveSessionChange.None); + } + + [Theory] + [InlineData(LiveSourceFault.EndpointRefused)] + [InlineData(LiveSourceFault.ProjectMissing)] + public void TheGraceAfterASetupFault_KeepsTheSetupFault(LiveSourceFault kind) + { + _states.OnSetupFault(kind); + + _states.OnGraceElapsed().Should().Be(LiveSessionChange.None); + _states.OnSetupFault(kind).Should().Be(LiveSessionChange.None, "the fault the state holds is still {0}", kind); + } + + [Fact] + public void ASetupFault_PublishesOnceForTheSameKind() + { + var first = _states.OnSetupFault(LiveSourceFault.ProjectMissing); + var again = _states.OnSetupFault(LiveSourceFault.ProjectMissing); + + FaultOf(first).Kind.Should().Be(LiveSourceFault.ProjectMissing); + FaultOf(first).Endpoint.Should().Be(Endpoint); + again.Should().Be(LiveSessionChange.None); + } + + [Fact] + public void ASetupFaultOfAnotherKind_PublishesTheNewKind() + { + _states.OnSetupFault(LiveSourceFault.EndpointRefused); + + var change = _states.OnSetupFault(LiveSourceFault.ProjectMissing); + + FaultOf(change).Kind.Should().Be(LiveSourceFault.ProjectMissing); + change.IsLoss.Should().BeFalse(); + } + + [Theory] + [InlineData(LiveSourceFault.EndpointRefused)] + [InlineData(LiveSourceFault.ProjectMissing)] + public void AnUnansweredPassAfterASetupFault_PublishesTheUnreachableFault(LiveSourceFault kind) + { + _states.OnSetupFault(kind); + + var change = _states.OnUnanswered(); + + FaultOf(change).Kind.Should().Be(LiveSourceFault.Unreachable); + change.IsLoss.Should().BeFalse(); + _states.OnUnanswered().Should().Be(LiveSessionChange.None); + } + + [Fact] + public void AnUnansweredPassWithinTheGrace_PublishesNothing() + { + _states.OnUnanswered().Should().Be(LiveSessionChange.None); + FaultOf(_states.OnGraceElapsed()).Kind.Should().Be(LiveSourceFault.Unreachable); + } + + [Fact] + public void AnUnansweredPassThatStillHeldTheConnectedState_IsALoss() + { + _states.OnStateChanged(ConnectionState.Connected); + + var change = _states.OnUnanswered(); + + FaultOf(change).Kind.Should().Be(LiveSourceFault.Unreachable); + change.IsLoss.Should().BeTrue(); + } + + [Fact] + public void ANewSessionId_AsksForABrowseAndTheSameIdDoesNot() + { + var first = new NodeId(17u, 1); + var recreated = new NodeId(18u, 1); + + _states.IsNewSession(first).Should().BeTrue(); + _states.IsNewSession(first).Should().BeFalse(); + _states.IsNewSession(recreated).Should().BeTrue(); + } + + private static LiveSourceError FaultOf(LiveSessionChange change) + { + change.Published.Should().NotBeNull(); + change.Published.IsConnected.Should().BeFalse(); + change.Published.Fault.Should().NotBeNull(); + + return change.Published.Fault; + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/LiveValueTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/LiveValueTests.cs new file mode 100644 index 0000000..58a4257 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/LiveValueTests.cs @@ -0,0 +1,157 @@ +using AwesomeAssertions; + +using Opc.Ua; + +using SemiPlot.DataSource.OpcUa; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class LiveValueTests +{ + private static readonly DateTime _sourceUtc = + new DateTime(2026, 10, 20, 1, 2, 3, DateTimeKind.Utc).AddTicks(4567); + + private static readonly DateTime _serverUtc = _sourceUtc.AddMilliseconds(250); + + public static TheoryData NumericValues => new() + { + { Variant.From((sbyte)-5), -5 }, + { Variant.From((byte)1), 1 }, + { Variant.From((short)-300), -300 }, + { Variant.From((ushort)300), 300 }, + { Variant.From(-70000), -70000 }, + { Variant.From(70000u), 70000 }, + { Variant.From(-5000000000L), -5000000000 }, + { Variant.From(5000000000UL), 5000000000 }, + { Variant.From(1.5f), 1.5 }, + { Variant.From(2.25), 2.25 }, + }; + + public static TheoryData NonNumericValues => new() + { + { "Boolean", Variant.From(true) }, + { "String", Variant.From("12.5") }, + { "DateTime", Variant.From(new DateTimeUtc(_sourceUtc)) }, + { "array", Variant.From([1.0, 2.0]) }, + { "null", Variant.Null }, + }; + + [Theory] + [MemberData(nameof(NumericValues))] + public void ANumericBuiltInType_ReadsAsItsDouble(Variant variant, double expected) + { + var reading = LiveValue.Read(new DataValue(variant, StatusCodes.Good, _sourceUtc, _serverUtc)); + + reading.Kind.Should().Be(LiveReadingKind.Value); + reading.Value.Should().Be(expected); + } + + [Theory] + [MemberData(nameof(NonNumericValues))] + public void ANonNumericValue_ReadsAsNotNumeric(string kind, Variant variant) + { + var reading = LiveValue.Read(new DataValue(variant, StatusCodes.Good, _sourceUtc, _serverUtc)); + + reading.Kind.Should().Be(LiveReadingKind.NotNumeric, kind); + } + + [Fact] + public void Good_ReadsAsAValueAtTheSourceStamp() + { + var reading = LiveValue.Read(new DataValue(Variant.From(2.25), StatusCodes.Good, _sourceUtc, _serverUtc)); + + reading.Kind.Should().Be(LiveReadingKind.Value); + reading.Value.Should().Be(2.25); + reading.StampUtc.Should().NotBeNull(); + reading.StampUtc!.Value.Kind.Should().Be(DateTimeKind.Utc); + reading.StampUtc.Value.Ticks.Should().Be(_sourceUtc.Ticks); + } + + [Fact] + public void GoodWithInfoBits_ReadsAsAValue() + { + var withInfoBits = new StatusCode(0x00000010u); + + var reading = LiveValue.Read(new DataValue(Variant.From(1.5f), withInfoBits, _sourceUtc, _serverUtc)); + + reading.Kind.Should().Be(LiveReadingKind.Value); + reading.Value.Should().Be(1.5); + } + + [Fact] + public void Uncertain_ReadsAsAValue() + { + var status = StatusCodes.UncertainLastUsableValue; + + var reading = LiveValue.Read(new DataValue(Variant.From(7), status, _sourceUtc, _serverUtc)); + + reading.Kind.Should().Be(LiveReadingKind.Value); + reading.Value.Should().Be(7); + } + + [Fact] + public void Bad_ReadsAsABreakAtTheSourceStamp() + { + var status = StatusCodes.BadCommunicationError; + + var reading = LiveValue.Read(new DataValue(Variant.From(7), status, _sourceUtc, _serverUtc)); + + reading.Kind.Should().Be(LiveReadingKind.Break); + reading.StampUtc.Should().Be(_sourceUtc); + } + + [Fact] + public void BadWithNoStamp_ReadsAsABreakWithNoStamp() + { + var reading = LiveValue.Read(DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown)); + + reading.Kind.Should().Be(LiveReadingKind.Break); + reading.StampUtc.Should().BeNull(); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void ANonFiniteDouble_ReadsAsABreakAtTheSourceStamp(double value) + { + var reading = LiveValue.Read(new DataValue(Variant.From(value), StatusCodes.Good, _sourceUtc, _serverUtc)); + + reading.Kind.Should().Be(LiveReadingKind.Break); + reading.StampUtc.Should().Be(_sourceUtc); + } + + [Fact] + public void ANonFiniteFloat_ReadsAsABreak() + { + var reading = LiveValue.Read(new DataValue(Variant.From(float.NaN), StatusCodes.Good, _sourceUtc, _serverUtc)); + + reading.Kind.Should().Be(LiveReadingKind.Break); + } + + [Fact] + public void NoSourceStamp_TakesTheServerStamp() + { + var value = new DataValue(Variant.From(2.25), StatusCodes.Good, DateTimeUtc.MinValue, _serverUtc); + + var reading = LiveValue.Read(value); + + reading.Kind.Should().Be(LiveReadingKind.Value); + reading.StampUtc!.Value.Ticks.Should().Be(_serverUtc.Ticks); + reading.StampUtc.Value.Kind.Should().Be(DateTimeKind.Utc); + } + + [Fact] + public void NeitherStamp_YieldsNoSample() + { + var reading = LiveValue.Read(new DataValue(Variant.From(2.25))); + + reading.Kind.Should().Be(LiveReadingKind.Unstamped); + reading.StampUtc.Should().BeNull(); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/OpcUaCompositionTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/OpcUaCompositionTests.cs new file mode 100644 index 0000000..1aa5a52 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/OpcUaCompositionTests.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; + +using AwesomeAssertions; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; + +using SemiPlot.Core.Data; +using SemiPlot.DataSource.OpcUa; +using SemiPlot.DataSource.OpcUa.Configuration; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +// Port 1 is closed, and no test here subscribes: resolving and disposing the source opens no socket. +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class OpcUaCompositionTests +{ + private static readonly OpcUaConnectionSettings _settings = + new("127.0.0.1", 1, Path.Combine(Path.GetTempPath(), "semiplot-unused-pki")); + + [Fact] + public void TheLiveSourceIsTheOpcUaSource_RegisteredOnceAsASingleton() + { + using var provider = Build(new ServiceCollection().AddLogging()); + + provider.GetServices().Should().ContainSingle().Which.Should().BeOfType(); + provider.GetRequiredService().Should().BeSameAs(provider.GetRequiredService()); + } + + // Disposed asynchronously: the synchronous Dispose hops to the pool, which a parallel suite can starve for seconds. + [Fact] + public async Task ResolvingAndDisposingWithNoSubscription_ReturnsAtOnce_AndTheSourceNeverLeftConnecting() + { + var provider = Build(new ServiceCollection().AddLogging()); + var source = provider.GetRequiredService(); + var states = new List(); + var isCompleted = false; + using var recording = source.States.Subscribe(states.Add, () => isCompleted = true); + + var watch = Stopwatch.StartNew(); + await provider.DisposeAsync(); + + watch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1)); + states.Should().Equal(LiveSourceState.Connecting); + isCompleted.Should().BeTrue(); + } + + [Fact] + public void TheStackChannelLogsOnlyCriticalLines_AndEveryOtherCategoryKeepsItsLevel() + { + var collector = new FakeLogCollector(); + using var provider = Build(new ServiceCollection().AddLogging(builder => builder + .AddProvider(new FakeLoggerProvider(collector)) + .SetMinimumLevel(LogLevel.Debug))); + var loggerFactory = provider.GetRequiredService(); + + var channel = loggerFactory.CreateLogger(OpcUaSession.ChannelLogCategory); + var session = loggerFactory.CreateLogger("Opc.Ua.Client.Session"); + + channel.IsEnabled(LogLevel.Error).Should().BeFalse(); + channel.IsEnabled(LogLevel.Critical).Should().BeTrue(); + session.IsEnabled(LogLevel.Error).Should().BeTrue(); + session.IsEnabled(LogLevel.Debug).Should().BeTrue(); + } + + private static ServiceProvider Build(IServiceCollection services) + { + return services.AddOpcUaLiveSource(_settings).BuildServiceProvider(); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/OpcUaConnectionLoaderTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/OpcUaConnectionLoaderTests.cs new file mode 100644 index 0000000..45e94a1 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/OpcUaConnectionLoaderTests.cs @@ -0,0 +1,185 @@ +using AwesomeAssertions; + +using FluentResults; + +using SemiPlot.Core.Configuration; +using SemiPlot.Core.Data.Errors; +using SemiPlot.DataSource.OpcUa.Configuration; + +using Xunit; + +using static SemiPlot.Tests.Unit.ResultText; + +namespace SemiPlot.Tests.Unit.OpcUa; + +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class OpcUaConnectionLoaderTests : IDisposable +{ + private static readonly (string Field, string Value)[] _validFields = + [ + ("host", "127.0.0.1"), + ("port", "4841") + ]; + + private readonly TemporaryDirectory _directory = new("semiplot-opc"); + + public void Dispose() + { + _directory.Dispose(); + } + + [Fact] + public void TheTrackedShapeLoads() + { + WriteFile(Compose(_validFields)); + + var result = OpcUaConnectionLoader.Load(_directory); + + result.IsSuccess.Should().BeTrue(Describe(result)); + result.Value.Host.Should().Be("127.0.0.1"); + result.Value.Port.Should().Be(4841); + result.Value.EndpointUrl.Should().Be("opc.tcp://127.0.0.1:4841"); + result.Value.CertificateStoreRoot.Should().Be(Path.Combine(_directory, "pki")); + } + + [Fact] + public void AnAbsentSectionYieldsTheOpcSectionsError() + { + var absent = Path.Combine(_directory, "opc"); + + var result = OpcUaConnectionLoader.Load(absent); + + var error = ErrorOf(result); + error.Problem.Should().Be(SectionProblem.DirectoryMissing); + error.Section.Should().Be(ConfigurationSectionName.Opc); + error.Directory.Should().Be(absent); + } + + [Theory] + [InlineData("host")] + [InlineData("port")] + public void AnAbsentFieldYieldsTheMissingFieldDiscriminator(string field) + { + WriteFile(Compose(_validFields.Where(pair => pair.Field != field))); + + var result = OpcUaConnectionLoader.Load(_directory); + + AssertFileError(result, ConnectionFileProblem.MissingField).Fields.Should().Equal(field); + } + + // A bare key carries a null, which the nullable DTO reports as absent. + [Theory] + [InlineData("host", "\" \"")] + [InlineData("host", "")] + [InlineData("port", "")] + public void ABlankFieldYieldsTheMissingFieldDiscriminator(string field, string value) + { + WriteFile(Compose(Replace(field, value))); + + var result = OpcUaConnectionLoader.Load(_directory); + + AssertFileError(result, ConnectionFileProblem.MissingField).Fields.Should().Equal(field); + } + + [Fact] + public void BothAbsentFieldsAreNamedInOneError() + { + WriteFile("other: 1\n"); + + var result = OpcUaConnectionLoader.Load(_directory); + + AssertFileError(result, ConnectionFileProblem.MissingField).Fields.Should().Equal("host", "port"); + } + + [Theory] + [InlineData("0")] + [InlineData("65536")] + public void APortOutsideItsRangeYieldsTheOutOfRangeDiscriminator(string value) + { + WriteFile(Compose(Replace("port", value))); + + var result = OpcUaConnectionLoader.Load(_directory); + + AssertFileError(result, ConnectionFileProblem.OutOfRange).Fields.Should().Equal("port"); + } + + [Theory] + [InlineData("localhost")] + [InlineData("WORKING-PC")] + [InlineData("127.0.0.01")] + public void AHostThatIsNotAnIPv4AddressYieldsTheHostNotIPv4Discriminator(string host) + { + WriteFile(Compose(Replace("host", host))); + + var result = OpcUaConnectionLoader.Load(_directory); + + AssertFileError(result, ConnectionFileProblem.HostNotIPv4).Fields.Should().Equal("host"); + } + + [Theory] + [InlineData("opc-port")] + [InlineData("\"\"")] + public void APortThatIsNotANumberYieldsTheUnparseableDiscriminator(string value) + { + WriteFile(Compose(Replace("port", value))); + + var result = OpcUaConnectionLoader.Load(_directory); + + var error = AssertFileError(result, ConnectionFileProblem.Unparseable); + error.Fields.Should().BeEmpty(); + error.Reasons.OfType().Should().ContainSingle(); + } + + [Fact] + public void AnUnknownKeyIsIgnored() + { + WriteFile(Compose(_validFields.Append(("unknown_key", "1000")))); + + var result = OpcUaConnectionLoader.Load(_directory); + + result.IsSuccess.Should().BeTrue(Describe(result)); + } + + // The client's certificate stores sit under the section, and a section folder is read top-level only. + [Fact] + public void AYamlFileUnderThePkiFolderIsNotRead() + { + WriteFile(Compose(_validFields)); + + var pki = Path.Combine(_directory, "pki"); + Directory.CreateDirectory(pki); + File.WriteAllText(Path.Combine(pki, "stores.yaml"), "port: 0\nhost: localhost\n"); + + var result = OpcUaConnectionLoader.Load(_directory); + + result.IsSuccess.Should().BeTrue(Describe(result)); + result.Value.Port.Should().Be(4841); + } + + private ConnectionFileError AssertFileError(IResultBase result, ConnectionFileProblem kind) + { + var error = ErrorOf(result); + error.Section.Should().Be(ConfigurationSectionName.Opc); + error.Path.Should().Be(_directory); + error.Kind.Should().Be(kind); + + return error; + } + + private static IEnumerable<(string Field, string Value)> Replace(string field, string value) + { + return _validFields.Select(pair => pair.Field == field ? (field, value) : pair); + } + + private static string Compose(IEnumerable<(string Field, string Value)> fields) + { + return string.Join("\n", fields.Select(pair => $"{pair.Field}: {pair.Value}")) + "\n"; + } + + private void WriteFile(string content) + { + File.WriteAllText(Path.Combine(_directory, "opc.yaml"), content); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/PenSubscriptionSetTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/PenSubscriptionSetTests.cs new file mode 100644 index 0000000..4997326 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/PenSubscriptionSetTests.cs @@ -0,0 +1,95 @@ +using System.Reactive; +using System.Reactive.Subjects; + +using AwesomeAssertions; + +using Microsoft.Extensions.Logging.Abstractions; + +using SemiPlot.Core.Data.Errors; +using SemiPlot.Core.Trends; +using SemiPlot.DataSource.OpcUa; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +// No session ever connects: the test records the stamps a delivered value would, and the queued work runs on a loop +// the test pumps. +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class PenSubscriptionSetTests : IDisposable +{ + private const string Endpoint = "opc.tcp://127.0.0.1:4841"; + + private static readonly DateTime _firstUtc = new(2026, 10, 20, 1, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _secondUtc = _firstUtc.AddSeconds(30); + private static readonly TimeSpan _oneMillisecond = TimeSpan.FromMilliseconds(1); + + private readonly SerialLoop _loop = new(NullLogger.Instance); + private readonly DeliveredStamps _stamps = new(); + private readonly Subject _penFaults = new(); + private readonly PenSubscriptionSet _set; + + public PenSubscriptionSetTests() + { + _set = new PenSubscriptionSet( + TestPenSubscriptionContext.Create(_loop, _stamps, _penFaults, Endpoint), + CancellationToken.None); + } + + public void Dispose() + { + _penFaults.Dispose(); + } + + // The coordinator's Switch closes the old set at once while its removal waits behind a loss already queued. + [Fact] + public async Task ALossQueuedAheadOfAPenSetChange_BreaksTheKeptPensOnTheNewSet() + { + var closedSamples = new List(); + var keptSamples = new List(); + var held = new TaskCompletionSource(); + var pump = _loop.PumpAsync(TestContext.Current.CancellationToken); + var first = _set.Open([1, 2], Observer.Create>(closedSamples.AddRange)); + await _loop.InvokeAsync(() => + { + _stamps.RecordValue(1, _firstUtc); + _stamps.RecordValue(2, _secondUtc); + }); + + _loop.Enqueue(() => held.Task); + _loop.Enqueue(_set.OnLost); + first.Dispose(); + _set.Open([1, 2], Observer.Create>(keptSamples.AddRange)); + held.SetResult(); + _loop.Complete(); + await pump; + + closedSamples.Should().BeEmpty(); + keptSamples.Should().Equal( + new Sample(1, _firstUtc + _oneMillisecond, null), + new Sample(2, _secondUtc + _oneMillisecond, null)); + } + + [Fact] + public async Task ASetAddedWhileTheSessionIsDown_BreaksOnlyPensWithADeliveredValueOnce() + { + var samples = new List(); + var other = new List(); + _stamps.RecordValue(1, _firstUtc); + + _set.Open([1, 3], Observer.Create>(samples.AddRange)); + _set.Open([1], Observer.Create>(other.AddRange)); + await DrainAsync(); + + samples.Should().Equal(new Sample(1, _firstUtc + _oneMillisecond, null)); + other.Should().BeEmpty("the pen's last emission is already a break"); + } + + private async Task DrainAsync() + { + _loop.Complete(); + await _loop.PumpAsync(TestContext.Current.CancellationToken); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/PenSubscriptionTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/PenSubscriptionTests.cs new file mode 100644 index 0000000..8024403 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/PenSubscriptionTests.cs @@ -0,0 +1,136 @@ +using System.Reactive; +using System.Reactive.Subjects; + +using AwesomeAssertions; + +using Microsoft.Extensions.Logging.Abstractions; + +using Opc.Ua; +using Opc.Ua.Client; + +using SemiPlot.Core.Data.Errors; +using SemiPlot.Core.Trends; +using SemiPlot.DataSource.OpcUa; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +// No session: the stack raises an item's Notification from SaveValueInCache, and the queued work runs when the test +// drains the loop. +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class PenSubscriptionTests : IDisposable +{ + private const string Endpoint = "opc.tcp://127.0.0.1:4841"; + private const string ProjectId = "FD34514A35"; + private const ushort NamespaceIndex = 3; + + private static readonly DateTime _firstUtc = new(2026, 10, 20, 1, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _secondUtc = _firstUtc.AddSeconds(30); + + private readonly SerialLoop _loop = new(NullLogger.Instance); + private readonly DeliveredStamps _stamps = new(); + private readonly Subject _penFaults = new(); + private readonly List _faults = []; + private readonly List _samples = []; + private readonly IDisposable _faultRecording; + + public PenSubscriptionTests() + { + _faultRecording = _penFaults.Subscribe(_faults.Add); + } + + public void Dispose() + { + _faultRecording.Dispose(); + _penFaults.Dispose(); + } + + [Fact] + public async Task ABadNotification_EmitsABreakAtItsStamp() + { + var (_, subscription) = Attached(1); + var bad = new DataValue(Variant.From(2.5), StatusCodes.BadCommunicationError, _secondUtc, _secondUtc); + + Notify(subscription, 1, new DataValue(Variant.From(2.5), StatusCodes.Good, _firstUtc, _firstUtc)); + Notify(subscription, 1, bad); + await DrainAsync(); + + _samples.Should().Equal(new Sample(1, _firstUtc, 2.5), new Sample(1, _secondUtc, null)); + _faults.Should().BeEmpty(); + } + + [Fact] + public async Task ANonNumericNotification_ReportsThePenAsNotNumeric_AndAnUnstampedOneCountsAsAnswered() + { + var (_, subscription) = Attached(1, 2); + + Notify(subscription, 1, new DataValue(Variant.From(2.5))); + Notify(subscription, 2, new DataValue(Variant.From("12.5"), StatusCodes.Good, _firstUtc, _firstUtc)); + await DrainAsync(); + + _samples.Should().BeEmpty("neither notification carries a number to place"); + var fault = _faults.Should().ContainSingle("pen 1 answered, so every pen has").Subject; + fault.Kind.Should().Be(LiveSourceFault.PensUnserved); + fault.UnknownPenIds.Should().BeEmpty(); + fault.NotNumericPenIds.Should().Equal(2); + } + + [Fact] + public void AClosedSubscription_EmitsNoLossBreak() + { + var pen = new PenSubscription([1], Recorder(), Context()); + _stamps.RecordValue(1, _firstUtc); + + pen.Close(); + pen.EmitLossBreaks(); + + _samples.Should().BeEmpty(); + } + + [Fact] + public async Task ANotificationQueuedBeforeTheClose_EmitsNothing() + { + var (pen, subscription) = Attached(1); + + Notify(subscription, 1, new DataValue(Variant.From(2.5), StatusCodes.Good, _firstUtc, _firstUtc)); + pen.Close(); + await DrainAsync(); + + _samples.Should().BeEmpty(); + } + + private static void Notify(Subscription subscription, int penId, DataValue value) + { + subscription.MonitoredItems + .Single(item => item.Handle is int handle && handle == penId) + .SaveValueInCache(new MonitoredItemNotification { Value = value }); + } + + private (PenSubscription Pen, Subscription Subscription) Attached(params int[] penIds) + { + var pen = new PenSubscription(penIds, Recorder(), Context()); + var subscription = pen.Build(ProjectId, NamespaceIndex); + pen.Attach(subscription); + + return (pen, subscription); + } + + private IObserver> Recorder() + { + return Observer.Create>(_samples.AddRange); + } + + private PenSubscriptionContext Context() + { + return TestPenSubscriptionContext.Create(_loop, _stamps, _penFaults, Endpoint); + } + + private async Task DrainAsync() + { + _loop.Complete(); + await _loop.PumpAsync(TestContext.Current.CancellationToken); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/ProjectFolderTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/ProjectFolderTests.cs new file mode 100644 index 0000000..5f49b38 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/ProjectFolderTests.cs @@ -0,0 +1,166 @@ +using AwesomeAssertions; + +using Opc.Ua; + +using SemiPlot.DataSource.OpcUa; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class ProjectFolderTests +{ + private const ushort ScadaIndex = 3; + + private static readonly NamespaceTable _realNamespaces = new( + [ + Namespaces.OpcUa, + "urn:WORKING-PC:SimpleScada:UAServer", + "http://www.simple-scada.com/UA/Namespace", + ProjectFolder.NamespaceUri, + ]); + + private static readonly NamespaceTable _demoNamespaces = new( + [ + Namespaces.OpcUa, + "urn:localhost:SemiPlotDemoScada", + "http://opcfoundation.org/UA/Diagnostics", + ProjectFolder.NamespaceUri, + ]); + + [Fact] + public void TheRealObjectsOrder_YieldsTheProjectId() + { + ReferenceDescription[] children = + [ + Standard("Server", ObjectIds.Server), + Folder("system", "FD34514A35system_1"), + Folder("root", "FD34514A35root_2"), + Folder("group1", "FD34514A35group1_3"), + ]; + + ProjectFolder.ProjectIdOf(children, _realNamespaces).Should().Be("FD34514A35"); + } + + [Fact] + public void TheDemoObjectsOrder_YieldsTheProjectId() + { + ReferenceDescription[] children = + [ + Standard("Server", ObjectIds.Server), + Standard("Aliases", new NodeId(23470u)), + Standard("Locations", new NodeId(31915u)), + Folder("root", "5E4D0C1A0Broot_2"), + Folder("Chamber", "5E4D0C1A0BChamber_3"), + Folder("Gas", "5E4D0C1A0BGas_4"), + ]; + + ProjectFolder.ProjectIdOf(children, _demoNamespaces).Should().Be("5E4D0C1A0B"); + } + + [Fact] + public void ARootInAnotherNamespace_YieldsNothing() + { + ReferenceDescription[] children = + [ + Standard("Server", ObjectIds.Server), + Folder("root", "FD34514A35root_2", namespaceIndex: 2), + ]; + + ProjectFolder.ProjectIdOf(children, _realNamespaces).Should().BeNull(); + } + + [Theory] + [InlineData("FD34514A35root")] + [InlineData("FD34514A35root_")] + [InlineData("FD34514A35root_2x")] + [InlineData("root_2")] + public void ARootWhoseIdentifierDoesNotEndRootNumber_YieldsNothing(string identifier) + { + ReferenceDescription[] children = [Folder("root", identifier)]; + + ProjectFolder.ProjectIdOf(children, _realNamespaces).Should().BeNull(); + } + + [Fact] + public void TwoRootFolders_YieldNothing() + { + ReferenceDescription[] children = + [ + Folder("root", "FD34514A35root_2"), + Folder("root", "AB12345678root_5"), + ]; + + ProjectFolder.ProjectIdOf(children, _realNamespaces).Should().BeNull(); + } + + [Fact] + public void NoRootFolder_YieldsNothing() + { + ReferenceDescription[] children = + [ + Standard("Server", ObjectIds.Server), + Folder("group1", "FD34514A35group1_3"), + ]; + + ProjectFolder.ProjectIdOf(children, _realNamespaces).Should().BeNull(); + } + + [Fact] + public void AServerWithoutTheScadasNamespace_YieldsNothing() + { + var namespaces = new NamespaceTable([Namespaces.OpcUa, "urn:localhost:Other"]); + ReferenceDescription[] children = [Folder("root", "FD34514A35root_2", namespaceIndex: 1)]; + + ProjectFolder.ProjectIdOf(children, namespaces).Should().BeNull(); + } + + [Fact] + public void ThePensNode_IsTheProjectIdFollowedByThePenId() + { + ProjectFolder.NodeOf("FD34514A35", ScadaIndex, 1005).ToString().Should().Be("ns=3;s=FD34514A351005"); + } + + [Fact] + public void Next_KeepsAnEqualId() + { + ProjectFolder.Next("FD34514A35", "FD34514A35").Should().Be(ProjectFolderChange.Keep); + } + + [Theory] + [InlineData("FD34514A35", "AB12345678")] + [InlineData(null, "FD34514A35")] + public void Next_RecreatesOnADifferentId(string? current, string browsed) + { + ProjectFolder.Next(current, browsed).Should().Be(ProjectFolderChange.Recreate); + } + + [Theory] + [InlineData("FD34514A35")] + [InlineData(null)] + public void Next_ReportsMissingOnNone(string? current) + { + ProjectFolder.Next(current, null).Should().Be(ProjectFolderChange.Missing); + } + + private static ReferenceDescription Standard(string name, NodeId node) + { + return new ReferenceDescription + { + BrowseName = new QualifiedName(name, 0), + NodeId = new ExpandedNodeId(node), + }; + } + + private static ReferenceDescription Folder(string name, string identifier, ushort namespaceIndex = ScadaIndex) + { + return new ReferenceDescription + { + BrowseName = new QualifiedName(name, namespaceIndex), + NodeId = new ExpandedNodeId(new NodeId(identifier, namespaceIndex)), + }; + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/SubscriptionPlanTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/SubscriptionPlanTests.cs new file mode 100644 index 0000000..22ac1cb --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/SubscriptionPlanTests.cs @@ -0,0 +1,54 @@ +using AwesomeAssertions; + +using Microsoft.Extensions.Logging.Abstractions; + +using Opc.Ua; + +using SemiPlot.DataSource.OpcUa; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class SubscriptionPlanTests +{ + private const string ProjectId = "FD34514A35"; + private const ushort NamespaceIndex = 3; + + private readonly StackTelemetry _telemetry = new(NullLoggerFactory.Instance); + + [Fact] + public void AMonitoredItemsQueueIsOne_BecauseSimpleScadaSendsAnEmptyFirstValueAboveIt() + { + var item = SubscriptionPlan.CreateItem(_telemetry, ProjectId, NamespaceIndex, 1005); + + item.QueueSize.Should().Be(1); + } + + [Fact] + public void AMonitoredItem_SamplesEvery100MillisecondsTheValueOfThePensNode() + { + var item = SubscriptionPlan.CreateItem(_telemetry, ProjectId, NamespaceIndex, 1005); + + item.SamplingInterval.Should().Be(100); + item.AttributeId.Should().Be(Attributes.Value); + item.MonitoringMode.Should().Be(MonitoringMode.Reporting); + item.StartNodeId.Should().Be(new NodeId("FD34514A351005", NamespaceIndex)); + item.Handle.Should().Be(1005); + } + + [Fact] + public void ASubscription_Publishes100MillisecondsWithBothStamps() + { + var subscription = SubscriptionPlan.CreateSubscription(_telemetry); + + subscription.PublishingInterval.Should().Be(100); + subscription.TimestampsToReturn.Should().Be(TimestampsToReturn.Both); + subscription.KeepAliveCount.Should().Be(10); + subscription.LifetimeCount.Should().Be(600); + subscription.PublishingEnabled.Should().BeTrue(); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/TestPenSubscriptionContext.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/TestPenSubscriptionContext.cs new file mode 100644 index 0000000..70f8758 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/TestPenSubscriptionContext.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging.Abstractions; + +using SemiPlot.Core.Data.Errors; +using SemiPlot.DataSource.OpcUa; + +namespace SemiPlot.Tests.Unit.OpcUa; + +/// A pen subscription context over silent telemetry and logging, with the test's own loop and sinks. +internal static class TestPenSubscriptionContext +{ + public static PenSubscriptionContext Create( + SerialLoop loop, + DeliveredStamps stamps, + IObserver penFaults, + string endpoint) + { + return new PenSubscriptionContext( + new StackTelemetry(NullLoggerFactory.Instance), + loop, + stamps, + new ReportedUnservedSets(), + penFaults, + NullLogger.Instance, + endpoint); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/OpcUa/UnservedPensTests.cs b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/UnservedPensTests.cs new file mode 100644 index 0000000..8161b4e --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/OpcUa/UnservedPensTests.cs @@ -0,0 +1,157 @@ +using AwesomeAssertions; + +using Opc.Ua; + +using SemiPlot.Core.Data.Errors; +using SemiPlot.DataSource.OpcUa; + +using Xunit; + +namespace SemiPlot.Tests.Unit.OpcUa; + +[Trait("Component", "Core")] +[Trait("Area", "Data")] +[Trait("Category", "Unit")] +public sealed class UnservedPensTests +{ + private const string Endpoint = "opc.tcp://127.0.0.1:4841"; + + private readonly ReportedUnservedSets _reported = new(); + + [Fact] + public void UnknownAndNonNumericPens_ReportAsOneErrorOnceEveryItemHasAnswered() + { + var pens = Over(1, 2, 3, 4); + + pens.OnItemStatuses([Served(1), Unknown(2), Served(3), Served(4)]).Should().BeNull(); + pens.OnNotNumeric(3).Should().BeNull(); + pens.OnAnswered(1).Should().BeNull(); + var error = pens.OnAnswered(4); + + AssertUnserved(error, unknown: [2], notNumeric: [3]); + } + + [Fact] + public void AnItemNotCreatedAsUnknown_IsUnknown() + { + var pens = Over(1, 2); + + pens.OnItemStatuses([Served(1), new ItemStatus(2, Created: false, StatusCodes.BadNodeIdUnknown)]) + .Should().BeNull(); + var error = pens.OnAnswered(1); + + AssertUnserved(error, unknown: [2], notNumeric: []); + } + + [Fact] + public void AnItemNotCreatedForAnotherReason_IsNotReportedAsUnknown_AndWaitsForNothing() + { + var pens = Over(1, 2, 3); + + pens.OnItemStatuses([Served(1), new ItemStatus(2, Created: false, StatusCodes.BadNodeIdInvalid), Served(3)]) + .Should().BeNull(); + pens.OnAnswered(1).Should().BeNull(); + var error = pens.OnNotNumeric(3); + + AssertUnserved(error, unknown: [], notNumeric: [3]); + } + + [Fact] + public void APenServedAgainAfterARecreation_LeavesTheNextReport() + { + var pens = Over(1, 2, 3); + pens.OnItemStatuses([Unknown(1), Served(2), Served(3)]); + pens.OnNotNumeric(2); + pens.OnSettleElapsed(); + + pens.OnItemStatuses([Served(1), Served(2), Served(3)]).Should().BeNull("a shrunk set is not reported"); + pens.OnValue(2).Should().BeNull("a shrunk set is not reported"); + var error = pens.OnNotNumeric(3); + + AssertUnserved(error, unknown: [], notNumeric: [3]); + } + + [Fact] + public void TheSettle_ReportsWhileItemsStillWait() + { + var pens = Over(1, 2); + pens.OnItemStatuses([Unknown(1), Served(2)]); + + var error = pens.OnSettleElapsed(); + + AssertUnserved(error, unknown: [1], notNumeric: []); + } + + [Fact] + public void ALaterNonNumericPen_ReportsTheGrownSet() + { + var pens = Over(2, 3); + pens.OnItemStatuses([Unknown(2), Served(3)]); + pens.OnSettleElapsed(); + + var error = pens.OnNotNumeric(3); + + AssertUnserved(error, unknown: [2], notNumeric: [3]); + pens.OnNotNumeric(3).Should().BeNull(); + } + + [Fact] + public void StatusesReadAfterARecreation_AddTheirUnknownPensAndReportTheGrownSet() + { + var pens = Over(1, 2); + pens.OnItemStatuses([Served(1), Served(2)]); + pens.OnAnswered(1); + pens.OnAnswered(2).Should().BeNull(); + + var error = pens.OnItemStatuses([Unknown(1), Served(2)]); + + AssertUnserved(error, unknown: [1], notNumeric: []); + } + + [Fact] + public void ASetTheProcessAlreadyReported_IsNotReportedAgain() + { + var first = Over(1, 2); + first.OnItemStatuses([Unknown(1), Served(2)]); + first.OnSettleElapsed().Should().NotBeNull(); + var second = Over(1, 2); + second.OnItemStatuses([Unknown(1), Served(2)]); + + second.OnSettleElapsed().Should().BeNull(); + } + + [Fact] + public void AnEmptySet_ReportsNothing() + { + var pens = Over(1, 2); + pens.OnItemStatuses([Served(1), Served(2)]); + + pens.OnAnswered(1).Should().BeNull(); + pens.OnAnswered(2).Should().BeNull(); + pens.OnSettleElapsed().Should().BeNull(); + } + + private static ItemStatus Served(int penId) + { + return new ItemStatus(penId, Created: true, StatusCodes.Good); + } + + private static ItemStatus Unknown(int penId) + { + return new ItemStatus(penId, Created: true, StatusCodes.BadNodeIdUnknown); + } + + private static void AssertUnserved(LiveSourceError? error, int[] unknown, int[] notNumeric) + { + error.Should().NotBeNull(); + error.Kind.Should().Be(LiveSourceFault.PensUnserved); + error.Endpoint.Should().Be(Endpoint); + error.UnknownPenIds.Should().Equal(unknown); + error.NotNumericPenIds.Should().Equal(notNumeric); + } + + private UnservedPens Over(params int[] penIds) + { + return new UnservedPens(penIds, Endpoint, _reported); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/Postgres/ArchiveStatementTextTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Postgres/ArchiveStatementTextTests.cs index e2b8280..e823d6f 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Postgres/ArchiveStatementTextTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Postgres/ArchiveStatementTextTests.cs @@ -25,10 +25,6 @@ public sealed class ArchiveStatementTextTests // arrive from a GROUP BY whose output order is the planner's business. [InlineData(ArchiveStatements.BucketedHistoryWindow, "ORDER BY id, t;")] [InlineData(ArchiveStatements.ExportSeedRow, "ORDER BY t DESC\nLIMIT 1;")] - // A poll returns every subscribed pen in one result. The index scan behind it happens to yield - // ascending time per identifier, so the loss of this ordering shows up only once the planner picks - // another path. - [InlineData(ArchiveStatements.RealtimePoll, "ORDER BY t;")] // The export writes the slice rows in the order they arrive, so the ascending order is the statement's, // with the checklist order breaking a tie. [InlineData(ArchiveStatements.ExportSliceRows, "ORDER BY t, array_position(@ids, id);")] @@ -104,18 +100,6 @@ public void NoExportStatementSelectsTheQualityColumn(string statement) // The backwards seek is bounded by the wider of the requested window and one partition width. Drop // the floor and a pen quiet for days vanishes from the window instead of drawing at its last value. [InlineData(ArchiveStatements.BucketedHistoryWindow, new[] { "greatest(@to - @from, interval '1 day')" })] - // A poll feeds the live edge, which draws raw samples. Without the layer filter the same tick also - // returns the coarse rows LayerThinner writes for the same instants, and the fold reads them as - // further samples of the same pen. - [InlineData(ArchiveStatements.RealtimePoll, new[] { "AND l = 0" })] - // The baseline is the instant the first poll starts from, so it has to be the newest *raw* row. Read - // across every layer it would answer with a coarse timestamp, and the poll's strict `t > @lastSeen` - // would then skip the raw samples already written under it. - [InlineData(ArchiveStatements.RealtimeBaseline, new[] { "AND l = 0" })] - // A caller may hand the same identifier twice, since nothing upstream deduplicates a pen list. - // Without DISTINCT the lateral join runs once per copy and the baseline carries one row per copy, - // which the fold reads as a second pen. - [InlineData(ArchiveStatements.RealtimeBaseline, new[] { "DISTINCT unnest(@ids)" })] [InlineData(ArchiveStatements.ExportSeedRow, new[] { "t < @from" })] [InlineData(ArchiveStatements.ExportRowCount, new[] { "prior.t < @from" })] [InlineData(ArchiveStatements.ExportSeedRow, new[] { "t >= @from - interval '1 day'" })] @@ -196,42 +180,6 @@ public void TheBucketIsTheWindowDividedByTheColumnTarget() PostgresDataProvider.BucketFor(from, from.AddHours(1), 60).Should().Be(TimeSpan.FromMinutes(1)); } - [Fact] - public void ThePollBinderNamesExactlyTheStatementsOwnParameters() - { - using var command = new NpgsqlCommand(ArchiveStatements.RealtimePoll); - - RealtimePoll.BindPoll(command, [1, 2], new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Unspecified)); - - AssertBinderNamesTheStatementsOwnParameters(command, ArchiveStatements.RealtimePoll); - } - - [Fact] - public void TheBaselineBinderNamesExactlyTheStatementsOwnParameters() - { - using var command = new NpgsqlCommand(ArchiveStatements.RealtimeBaseline); - - RealtimePoll.BindBaseline(command, [1, 2]); - - AssertBinderNamesTheStatementsOwnParameters(command, ArchiveStatements.RealtimeBaseline); - } - - // A tick must not inherit the connection string's five-minute backstop, or a server that accepts and - // then answers nothing would hold the live edge for minutes. - [Theory] - [InlineData(ArchiveStatements.RealtimeBaseline)] - [InlineData(ArchiveStatements.RealtimePoll)] - public void ATicksStatementCarriesItsOwnBoundAndNotTheDataSourceBackstop(string statementText) - { - const int TickBoundSeconds = 10; - - using var connection = new NpgsqlConnection(); - - using var command = RealtimePoll.CreateTickCommand(statementText, connection); - - command.CommandTimeout.Should().Be(TickBoundSeconds); - } - // Three failed catalogue reads raise the archive fault, so each must fail at its own bound rather than at // the connection string's five-minute backstop. [Fact] diff --git a/SemiPlot/SemiPlot.Tests.Unit/Postgres/ConnectionSettingsFactory.cs b/SemiPlot/SemiPlot.Tests.Unit/Postgres/ConnectionSettingsFactory.cs index f2084d6..12654fe 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Postgres/ConnectionSettingsFactory.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Postgres/ConnectionSettingsFactory.cs @@ -10,8 +10,6 @@ internal static class ConnectionSettingsFactory public const string Username = "semiplot_reader"; - private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(1); - public static PostgresConnectionSettings Create( TimeZoneInfo? sourceTimeZone = null, string host = "127.0.0.1", @@ -24,7 +22,6 @@ public static PostgresConnectionSettings Create( Username: Username, Password: "unused", SourceTimeZone: sourceTimeZone ?? TimeZoneInfo.Utc, - PollInterval: _pollInterval, Schema: "public"); } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/Postgres/PostgresCompositionTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Postgres/PostgresCompositionTests.cs index 798ace7..2fa5dd4 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Postgres/PostgresCompositionTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Postgres/PostgresCompositionTests.cs @@ -51,16 +51,6 @@ public void AddPostgresDataResolvesThePostgresProvider() provider.Should().BeOfType(); } - [Fact] - public void AddPostgresDataResolvesThePostgresLiveSource() - { - using var services = BuildProvider(); - - var liveSource = services.GetRequiredService(); - - liveSource.Should().BeOfType(); - } - // The data source is a DI singleton, and ServiceProvider.Dispose() throws for an instantiated // singleton implementing only IAsyncDisposable. Resolving one and then disposing synchronously is the // case that catches an async-only wrapper. @@ -92,7 +82,6 @@ public void TheResolvedConverterCarriesTheConfiguredSourceZone() // cannot tell the two apart. [Theory] [InlineData(typeof(IDataProvider))] - [InlineData(typeof(ILiveSource))] [InlineData(typeof(IPenCatalogueEditor))] [InlineData(typeof(IMarkerStore))] [InlineData(typeof(IScheduler))] diff --git a/SemiPlot/SemiPlot.Tests.Unit/Postgres/PostgresConnectionLoaderTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Postgres/PostgresConnectionLoaderTests.cs index 41d14e1..7f32fc0 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Postgres/PostgresConnectionLoaderTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Postgres/PostgresConnectionLoaderTests.cs @@ -29,7 +29,6 @@ private static readonly (string Field, string Value)[] _validFields = ("database", "\"semiplot_dev\""), ("user", "\"semiplot_reader\""), ("password", "\"s3cret\""), - ("poll_interval_ms", "1000"), ("schema", "\"public\"") ]; @@ -53,10 +52,21 @@ public void AValidSectionPopulatesEveryField() result.Value.Database.Should().Be("semiplot_dev"); result.Value.Username.Should().Be("semiplot_reader"); result.Value.Password.Should().Be("s3cret"); - result.Value.PollInterval.Should().Be(TimeSpan.FromSeconds(1)); result.Value.Schema.Should().Be("public"); } + // An upgraded site keeps its installed connection file, which still carries the retired poll period. + [Fact] + public void ALeftoverPollIntervalKeyIsIgnored() + { + WriteFile(Compose(_validFields.Append(("poll_interval_ms", "1000")))); + + var result = PostgresConnectionLoader.Load(_directory); + + result.IsSuccess.Should().BeTrue(Describe(result)); + result.Value.Port.Should().Be(5433); + } + [Fact] public void AValidSectionCarriesTheMachinesTimeZone() { @@ -112,6 +122,7 @@ public void AFieldWhoseValueTheFormatRejectsYieldsTheUnparseableDiscriminator() var result = PostgresConnectionLoader.Load(_directory); var error = ErrorOf(result); + error.Section.Should().Be(ConfigurationSectionName.Connection); error.Path.Should().Be(_directory); error.Kind.Should().Be(ConnectionFileProblem.Unparseable); } @@ -132,7 +143,6 @@ public void AnEmptyFileYieldsTheMissingFieldDiscriminator() [InlineData("database")] [InlineData("user")] [InlineData("password")] - [InlineData("poll_interval_ms")] public void AnAbsentRequiredFieldYieldsTheMissingFieldDiscriminator(string field) { WriteFile(Compose(_validFields.Where(pair => pair.Field != field))); @@ -140,6 +150,7 @@ public void AnAbsentRequiredFieldYieldsTheMissingFieldDiscriminator(string field var result = PostgresConnectionLoader.Load(_directory); var error = ErrorOf(result); + error.Section.Should().Be(ConfigurationSectionName.Connection); error.Path.Should().Be(_directory); error.Kind.Should().Be(ConnectionFileProblem.MissingField); error.Fields.Should().Equal(field); @@ -157,36 +168,6 @@ public void ABlankRequiredFieldYieldsTheMissingFieldDiscriminator() error.Fields.Should().Equal("host"); } - [Theory] - [InlineData("0.0.0.0")] - [InlineData("127.0.0.1")] - [InlineData("192.168.0.10")] - [InlineData("255.255.255.255")] - public void AnIPv4AddressOfFourDecimalOctetsIsAHost(string host) - { - PostgresConnectionLoader.IsIPv4Address(host).Should().BeTrue(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("localhost")] - [InlineData("::1")] - [InlineData("::ffff:127.0.0.1")] - [InlineData("127.1")] - [InlineData("1.2.3.4.5")] - [InlineData("1..2.3")] - [InlineData("256.0.0.1")] - [InlineData("1.2.3.1000")] - [InlineData("010.0.0.1")] - [InlineData("+1.2.3.4")] - [InlineData(" 127.0.0.1")] - [InlineData("\uFF11.2.3.4")] - public void AnythingButFourDecimalOctetsIsNotAHost(string? host) - { - PostgresConnectionLoader.IsIPv4Address(host).Should().BeFalse(); - } - [Fact] public void AHostThatIsNotAnIPv4AddressYieldsTheHostNotIPv4Discriminator() { @@ -195,6 +176,7 @@ public void AHostThatIsNotAnIPv4AddressYieldsTheHostNotIPv4Discriminator() var result = PostgresConnectionLoader.Load(_directory); var error = ErrorOf(result); + error.Section.Should().Be(ConfigurationSectionName.Connection); error.Path.Should().Be(_directory); error.Kind.Should().Be(ConnectionFileProblem.HostNotIPv4); error.Fields.Should().Equal("host"); @@ -203,7 +185,7 @@ public void AHostThatIsNotAnIPv4AddressYieldsTheHostNotIPv4Discriminator() [Fact] public void EveryAbsentFieldIsReportedInOneError() { - var absent = new[] { "host", "poll_interval_ms" }; + var absent = new[] { "host", "port" }; WriteFile(Compose(_validFields.Where(pair => !absent.Contains(pair.Field)))); var result = PostgresConnectionLoader.Load(_directory); @@ -217,8 +199,6 @@ public void EveryAbsentFieldIsReportedInOneError() [InlineData("port", "0")] [InlineData("port", "-1")] [InlineData("port", "65536")] - [InlineData("poll_interval_ms", "0")] - [InlineData("poll_interval_ms", "-1")] public void AValueOutsideItsRangeYieldsTheOutOfRangeDiscriminator(string field, string value) { WriteFile(Compose(Replace(field, value))); @@ -226,6 +206,7 @@ public void AValueOutsideItsRangeYieldsTheOutOfRangeDiscriminator(string field, var result = PostgresConnectionLoader.Load(_directory); var error = ErrorOf(result); + error.Section.Should().Be(ConfigurationSectionName.Connection); error.Path.Should().Be(_directory); error.Kind.Should().Be(ConnectionFileProblem.OutOfRange); error.Fields.Should().Equal(field); diff --git a/SemiPlot/SemiPlot.Tests.Unit/SeederCommandTests.cs b/SemiPlot/SemiPlot.Tests.Unit/SeederCommandTests.cs index 12b1869..7ca0312 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/SeederCommandTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/SeederCommandTests.cs @@ -16,6 +16,12 @@ public sealed class SeederCommandTests private const string Connection = BenchOptions.ConnectionString; private const string EndText = BenchOptions.EndText; + private static readonly string[] _convergeRequired = + [ + "converge", "--connection", Connection, "--admin-connection", "Host=127.0.0.1;Database=postgres", + "--config-dir", "C:\\config" + ]; + [Fact] public async Task ASeedingRunAppliesDefaultsWhenOnlyRequiredOptionsAreGiven() { @@ -167,15 +173,32 @@ public async Task ASeedingRunRejectsAFollowOption(string option, string value) } [Fact] - public async Task AConvergeRunRejectsAnOpcPort() + public async Task AConvergeRunCarriesItsOpcPort() { - var run = await RunAsync( - [ - "converge", "--connection", Connection, "--admin-connection", "Host=127.0.0.1;Database=postgres", - "--config-dir", "C:\\config", "--opc-port", "1" - ]); + var run = await RunAsync([.. _convergeRequired, "--opc-port", "55875"]); + + run.ExitCode.Should().Be(0); + run.Converge.Should().BeOfType().Which.OpcPort.Should().Be(55875); + } + + [Fact] + public async Task AConvergeRunWithoutAnOpcPortIsRejected() + { + var run = await RunAsync(_convergeRequired); AssertRejectedWith(run, "--opc-port"); + AssertRejectedWith(run, "49875"); + } + + [Theory] + [InlineData("0")] + [InlineData("65536")] + public async Task AConvergeOpcPortOutsideItsRangeIsRejectedNamingTheValue(string value) + { + var run = await RunAsync([.. _convergeRequired, "--opc-port", value]); + + AssertRejectedWith(run, "Option '--opc-port'"); + AssertRejectedWith(run, value); } [Theory] @@ -293,8 +316,7 @@ public async Task AParseFailurePointsAtHelpAndExitsWithOne() [Fact] public async Task AConvergeRunAppliesDefaultsWhenOnlyRequiredOptionsAreGiven() { - var run = await RunAsync( - ["converge", "--connection", Connection, "--admin-connection", "Host=127.0.0.1;Database=postgres", "--config-dir", "C:\\config"]); + var run = await RunAsync([.. _convergeRequired, "--opc-port", "55875"]); run.ExitCode.Should().Be(0); run.Seed.Should().BeNull(); @@ -313,10 +335,7 @@ public async Task AConvergeRunAppliesDefaultsWhenOnlyRequiredOptionsAreGiven() public async Task AConvergeRunAcceptsAChangeIntervalAndAnEnd() { var run = await RunAsync( - [ - "converge", "--connection", Connection, "--admin-connection", "Host=127.0.0.1;Database=postgres", - "--config-dir", "C:\\config", "--change-seconds", "0.5", "--end", EndText - ]); + [.. _convergeRequired, "--opc-port", "55875", "--change-seconds", "0.5", "--end", EndText]); run.ExitCode.Should().Be(0); @@ -333,13 +352,16 @@ public async Task AConvergeRunAcceptsAChangeIntervalAndAnEnd() [InlineData("--connection", true)] [InlineData("--admin-connection", true)] [InlineData("--config-dir", true)] + [InlineData("--opc-port", false)] + [InlineData("--opc-port", true)] public async Task AConvergeRunRejectsAMissingOrBlankRequiredOption(string option, bool blank) { (string Flag, string Value)[] every = [ ("--connection", Connection), ("--admin-connection", "Host=127.0.0.1;Database=postgres"), - ("--config-dir", "C:\\config") + ("--config-dir", "C:\\config"), + ("--opc-port", "55875") ]; var arguments = new List { "converge" }; diff --git a/SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj b/SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj index 9d9f6cf..014d5f6 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj +++ b/SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj @@ -37,6 +37,7 @@ + diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs index ab3b181..6d4811a 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Reactive.Concurrency; using System.Reactive.Linq; +using System.Reactive.Subjects; using AwesomeAssertions; @@ -34,6 +35,10 @@ internal sealed class FakeDataProvider( private int? _penToBreakOnNextTick; + private readonly BehaviorSubject _liveStates = new(LiveSourceState.Connected); + private readonly Subject _penFaults = new(); + private readonly Subject> _pushedSamples = new(); + public DateTime ArchiveFirstUtc { get; set; } = new(2025, 12, 25, 0, 0, 0, DateTimeKind.Utc); public DateTime ArchiveLastUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -145,6 +150,10 @@ private IReadOnlyList HistoryQueries // Runs on the thread that opens or closes a live edge subscription, as it opens or closes it. public Action? LiveSubscriptionChanging { get; set; } + public IObservable States => _liveStates.AsObservable(); + + public IObservable PenFaults => _penFaults.AsObservable(); + public IReadOnlyList Pens { get; set; } = pens ?? [FirstPen, SecondPen]; // Off by default: every pen shares one timestamp per tick. Set, each pen's sample offsets by its own @@ -157,17 +166,33 @@ private IReadOnlyList HistoryQueries public bool PoisonRealtimeWindow { get; set; } // A live source that ends the live edge with OnError, the terminal message TrendCoordinator.RealtimeBatches - // has to survive. The real live source catches everything inside its poll, so only this fake reaches it. + // has to survive. OpcUaLiveSource never calls OnError, so only this fake reaches it. public Exception? RealtimeStreamFailure { get; set; } public bool RealtimeStreamCompletes { get; set; } - /// The next tick follows the pen's value with a break 1 ms later, as the poll's q = 32 row. + /// The next tick follows the pen's value with a break 1 ms later, as a lost session breaks it. public void BreakRealtimePenOnce(int penId) { _penToBreakOnNextTick = penId; } + public void PushLiveState(LiveSourceState state) + { + _liveStates.OnNext(state); + } + + public void RaisePenFault(LiveSourceError fault) + { + _penFaults.OnNext(fault); + } + + /// One list on every open live edge, beside the ticks, stamped as the test chooses. + public void PushLiveSamples(IReadOnlyList samples) + { + _pushedSamples.OnNext(samples); + } + /// The pen's one break across the batches, and the value its tick carried 1 ms ahead of it. public static (double Value, DateTime ValueUtc, DateTime BreakUtc) TheBreakIn( IEnumerable batches, @@ -227,7 +252,8 @@ private IObservable> BuildLiveEdge(IReadOnlyList penI .Interval(_realtimeInterval, _scheduler) .Select(tick => PoisonRealtimeWindow ? new PoisonedSampleList() - : TickSamples(subscribed, tick)); + : TickSamples(subscribed, tick)) + .Merge(_pushedSamples); } private IReadOnlyList TickSamples(IReadOnlyList subscribed, long tick) diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/TrendCoordinatorTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/TrendCoordinatorTests.cs index 86b2095..7482306 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/TrendCoordinatorTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/TrendCoordinatorTests.cs @@ -4,6 +4,8 @@ using Microsoft.Reactive.Testing; +using SemiPlot.Core.Data; +using SemiPlot.Core.Data.Errors; using SemiPlot.Core.Trends; using SemiPlot.Tests.Unit.UI.Chart; using SemiPlot.UI.Bridge; @@ -17,6 +19,8 @@ namespace SemiPlot.Tests.Unit.UI.Bridge; [Trait("Category", "Unit")] public sealed class TrendCoordinatorTests { + private const string OpcEndpoint = "opc.tcp://127.0.0.1:4840"; + [Fact] public void RealtimeBatches_EmitOnePerBufferWindow() { @@ -277,6 +281,39 @@ public void ALiveSourceThatCompletes_LeavesTheLiveEdgeSilentUntilTheNextSetPens( failures.Should().BeEmpty(); } + // The source raises both streams on its own loop thread, and the bar and the panel are bound state. + [Fact] + public void TheLiveSourcesStatesAndPenFaults_ArriveOnTheUiScheduler() + { + var dataScheduler = new TestScheduler(); + var uiScheduler = new TestScheduler(); + var provider = new FakeDataProvider(dataScheduler, ChartTestBuilder.RealtimeInterval); + using var coordinator = new TrendCoordinator(provider, provider, provider.Pens, dataScheduler, uiScheduler); + var states = new List(); + var faults = new List(); + using var stateSubscription = coordinator.LiveSourceStates.Subscribe(states.Add); + using var faultSubscription = coordinator.LivePenFaults.Subscribe(faults.Add); + var lost = LiveSourceState.FaultedBy(new LiveSourceError(LiveSourceFault.Unreachable, OpcEndpoint)); + var unserved = new LiveSourceError(LiveSourceFault.PensUnserved, OpcEndpoint, unknownPenIds: [7]); + + states.Should().BeEmpty("the replayed state waits for the UI scheduler"); + + uiScheduler.AdvanceBy(1); + + states.Should().Equal(LiveSourceState.Connected); + + provider.PushLiveState(lost); + provider.RaisePenFault(unserved); + + states.Should().HaveCount(1); + faults.Should().BeEmpty(); + + uiScheduler.AdvanceBy(1); + + states.Should().Equal(LiveSourceState.Connected, lost); + faults.Should().ContainSingle().Which.Should().BeSameAs(unserved); + } + private static (TrendCoordinator Coordinator, TestScheduler Scheduler, FakeDataProvider Provider) CreateCoordinator(TimeSpan? realtimeInterval = null) { diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartRealtimeApplierTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartRealtimeApplierTests.cs index dd996fa..38b5417 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartRealtimeApplierTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartRealtimeApplierTests.cs @@ -24,32 +24,37 @@ public void AtRaw_AValueAndThenABreak_AppendARealColumnAndThenANaNColumn() Apply(state, foldIntoColumn: false, (_start, SampledValue), (_start.AddMilliseconds(1), null)); var columns = state.Line.SnapshotColumns(); - columns.Should().HaveCount(2); - columns[0].Center.Should().Be(SampledValue); - double.IsNaN(columns[1].Center).Should().BeTrue(); - columns[1].X.Should().Be(LocalTimeAxis.ToAxis(_start.AddMilliseconds(1))); + columns.Should().HaveCount(3, "the first value opens behind its own break"); + double.IsNaN(columns[0].Center).Should().BeTrue(); + columns[1].Center.Should().Be(SampledValue); + double.IsNaN(columns[2].Center).Should().BeTrue(); + columns[2].X.Should().Be(LocalTimeAxis.ToAxis(_start.AddMilliseconds(1))); state.CurrentValue.Should().Be(SampledValue); } [Fact] public void AtACoarseLayer_TheValueFoldsAndTheBreakAppendsPastTheFoldedColumn() { + const double FirstValue = 3.0; var state = NewState(); state.LoadHistory(new PenHistoryEnvelope(1, [_start], [1.0], [5.0], [2.0]), _start); Apply( state, foldIntoColumn: true, - (_start.AddSeconds(1), SampledValue), - (_start.AddSeconds(1).AddMilliseconds(1), null)); + (_start.AddSeconds(1), FirstValue), + (_start.AddSeconds(2), SampledValue), + (_start.AddSeconds(2).AddMilliseconds(1), null)); var columns = state.Line.SnapshotColumns(); - columns.Should().HaveCount(2); - columns[0].Min.Should().Be(1.0); - columns[0].Max.Should().Be(SampledValue); - columns[0].Center.Should().Be(SampledValue); + columns.Should().HaveCount(4, "the first value opens a column behind its own break"); double.IsNaN(columns[1].Center).Should().BeTrue(); - columns[1].X.Should().Be(LocalTimeAxis.ToAxis(_start.AddSeconds(1).AddMilliseconds(1))); + columns[2].X.Should().Be(LocalTimeAxis.ToAxis(_start.AddSeconds(1))); + columns[2].Min.Should().Be(FirstValue); + columns[2].Max.Should().Be(SampledValue); + columns[2].Center.Should().Be(SampledValue); + double.IsNaN(columns[3].Center).Should().BeTrue(); + columns[3].X.Should().Be(LocalTimeAxis.ToAxis(_start.AddSeconds(2).AddMilliseconds(1))); state.CurrentValue.Should().Be(SampledValue); } @@ -63,7 +68,6 @@ public void AtACoarseLayer_OnAPenWithNoColumn_TheBreakAppendsAndTheNextValueOpen Apply( state, foldIntoColumn: true, - (_start, SampledValue), (_start.AddMilliseconds(1), null), (_start.AddSeconds(1), NextValue)); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/EnvelopeLineTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/EnvelopeLineTests.cs index 051d18c..adc60da 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/EnvelopeLineTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/EnvelopeLineTests.cs @@ -124,7 +124,8 @@ public void AReplaceKeepsOnlyTheAppendedColumnsPastItsEnd() line.AppendColumn(Column(11.0)).Should().BeTrue(); line.AppendColumn(Column(12.0)).Should().BeTrue(); - line.ReplaceColumns(ColumnsFrom(0.0, 4), UnboundedRequestedEndX); + line.ReplaceColumns(ColumnsFrom(0.0, 4), UnboundedRequestedEndX) + .Should().BeFalse("no column lies past the end"); line.SnapshotColumns().Select(column => column.X).Should().Equal( [0.0, 1.0, 2.0, 3.0, 11.0, 12.0], @@ -155,11 +156,52 @@ public void AReplaceDropsTheAppendedColumnsPastTheRequestedEnd() line.AppendColumn(Column(6.0)).Should().BeTrue(); line.AppendColumn(Column(7.0)).Should().BeTrue(); - line.ReplaceColumns(ColumnsFrom(0.0, 3), requestedEndX: 6.0); + line.ReplaceColumns(ColumnsFrom(0.0, 3), requestedEndX: 6.0).Should().BeTrue(); line.SnapshotColumns().Select(column => column.X).Should().Equal(0.0, 1.0, 2.0, 5.0, 6.0); } + [Fact] + public void AnAppendAfterBreakAtOrBeforeTheLastColumn_AppendsNothing() + { + var line = new EnvelopeLine(); + line.ReplaceColumns(ColumnsFrom(0.0, 2), UnboundedRequestedEndX); + + line.AppendAfterBreak(Gap(0.5), Column(1.0)).Should().BeFalse(); + line.AppendAfterBreak(Gap(-1.0), Column(0.0)).Should().BeFalse(); + + line.SnapshotColumns().Select(column => column.X).Should().Equal(0.0, 1.0); + } + + [Fact] + public void AnAppendAfterBreakOnAnEmptyLine_AppendsTheGapAndThenTheSample() + { + var line = new EnvelopeLine(); + + line.AppendAfterBreak(Gap(4.0), Column(5.0)).Should().BeTrue(); + + line.SnapshotColumns().Should().Equal(Gap(4.0), Column(5.0)); + } + + [Theory] + [InlineData(false, 2.5, new[] { 0.0, 1.0, 2.5, 3.0 }, 1)] + [InlineData(false, 1.0, new[] { 0.0, 1.0, 3.0 }, 0)] + [InlineData(true, 2.5, new[] { 0.0, 1.0, 3.0 }, 1)] + public void AnAppendAfterBreak_AppendsTheGapOnlyPastTheLastColumnOfALineNotEndingOnOne( + bool endsOnGap, + double gapX, + double[] expectedXs, + int expectedGaps) + { + var line = new EnvelopeLine(); + line.ReplaceColumns(endsOnGap ? [Column(0.0), Gap(1.0)] : ColumnsFrom(0.0, 2), UnboundedRequestedEndX); + + line.AppendAfterBreak(Gap(gapX), Column(3.0)).Should().BeTrue(); + + line.SnapshotColumns().Select(column => column.X).Should().Equal(expectedXs); + line.SnapshotColumns().Count(column => double.IsNaN(column.Center)).Should().Be(expectedGaps); + } + [Fact] public void ASecondReplaceKeepsTheTailTheFirstOneKept() { @@ -491,6 +533,11 @@ private static EnvelopeColumn Column(double x) return new EnvelopeColumn(x, 0.0, 1.0, 0.5); } + private static EnvelopeColumn Gap(double x) + { + return new EnvelopeColumn(x, double.NaN, double.NaN, double.NaN); + } + private static EnvelopeColumn[] ColumnsFrom(double firstX, int count) { return [.. Enumerable.Range(0, count).Select(index => Column(firstX + index))]; diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs index 5629c3a..c26d67d 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Globalization; using System.Reactive.Linq; using Avalonia.Headless.XUnit; @@ -7,6 +8,7 @@ using FluentResults; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using Microsoft.Reactive.Testing; @@ -14,6 +16,7 @@ using ScottPlot; +using SemiPlot.Core.Data.Errors; using SemiPlot.Core.Trends; using SemiPlot.Tests.Unit.UI.Bridge; using SemiPlot.Tests.Unit.UI.Messages; @@ -967,6 +970,8 @@ public void Coordinator_CoarseLayerRealtime_FoldsInsteadOfGrowingColumns() // A coarse (non-Raw) layer folds realtime into the current column instead of appending. viewModel.Navigation.ZoomAt(48.0, viewModel.Navigation.To); viewModel.Navigation.ActiveLayer.Should().NotBe(AggregationLayer.Raw); + scheduler.AdvanceBy(TrendCoordinator.BatchWindow.Ticks); + state.Line.SnapshotColumns().Should().HaveCount(2, "the first value opens a column behind its break"); var columnsBefore = state.Line.SnapshotColumns().Count; scheduler.AdvanceBy(TrendCoordinator.BatchWindow.Ticks); @@ -1034,7 +1039,9 @@ await ReleaseAndAwait( LandTheMarginReadFromTheReleasedGate(scheduler); var columnsBefore = state.Line.SnapshotColumns(); var foldedColumn = columnsBefore[^1]; - foldedColumn.X.Should().Be(LocalTimeAxis.ToAxis(FakeDataProvider.RealtimeEpoch)); + foldedColumn.X.Should().BeGreaterThan( + LocalTimeAxis.ToAxis(FakeDataProvider.RealtimeEpoch), "the first live value opened a column of its own"); + double.IsNaN(columnsBefore[^2].Center).Should().BeTrue("that column opens behind a break"); var batches = new List(); using var subscription = coordinator.RealtimeBatches.Subscribe(batches.Add); @@ -1062,7 +1069,8 @@ public async Task ABreakAtRawAppendsANaNColumnOneMillisecondPastItsValue() var (viewModel, scheduler, coordinator, provider) = CreateViewModel(RealtimeInterval); var state = viewModel.AddPen(FakeDataProvider.FirstPen); await LoadRawEndingAtTheRealtimeEpoch(viewModel, scheduler, provider); - state.Line.SnapshotColumns().Should().NotContain(column => double.IsNaN(column.Center)); + state.Line.SnapshotColumns().Should().ContainSingle( + column => double.IsNaN(column.Center), "only the first live value opened behind a break"); var batches = new List(); using var subscription = coordinator.RealtimeBatches.Subscribe(batches.Add); @@ -1071,8 +1079,8 @@ public async Task ABreakAtRawAppendsANaNColumnOneMillisecondPastItsValue() var (value, valueUtc, breakUtc) = FakeDataProvider.TheBreakIn(batches, FakeDataProvider.FirstPen.PenId); var columns = state.Line.SnapshotColumns(); - var breakIndex = columns.ToList().FindIndex(column => double.IsNaN(column.Center)); - columns.Count(column => double.IsNaN(column.Center)).Should().Be(1); + var breakIndex = columns.ToList().FindLastIndex(column => double.IsNaN(column.Center)); + columns.Count(column => double.IsNaN(column.Center)).Should().Be(2); breakIndex.Should().BePositive(); columns[breakIndex].X.Should().Be(LocalTimeAxis.ToAxis(breakUtc)); columns[breakIndex - 1].X.Should().Be(LocalTimeAxis.ToAxis(valueUtc)); @@ -1250,10 +1258,38 @@ public void Coordinator_RealtimeTimestampsOnlyOnePenSampled_BreakNeitherPen() scheduler.AdvanceBy(TrendCoordinator.BatchWindow.Ticks); - first.Line.SnapshotColumns().Should().NotBeEmpty(); - second.Line.SnapshotColumns().Should().NotBeEmpty(); - first.Line.SnapshotColumns().Should().NotContain(column => double.IsNaN(column.Center)); - second.Line.SnapshotColumns().Should().NotContain(column => double.IsNaN(column.Center)); + foreach (var state in new[] { first, second }) + { + var columns = state.Line.SnapshotColumns(); + columns.Should().HaveCountGreaterThan(2); + double.IsNaN(columns[0].Center).Should().BeTrue("the first live value opens behind a break"); + columns.Skip(1).Should().NotContain(column => double.IsNaN(column.Center)); + } + } + + [AvaloniaFact] + public void ACatalogueThatAddsAPen_BreaksOnlyTheAddedPensFirstLiveValue() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(RealtimeInterval); + var kept = viewModel.AddPen(FakeDataProvider.FirstPen); + scheduler.AdvanceBy(TrendCoordinator.BatchWindow.Ticks * 2); + var keptColumnsBefore = kept.Line.SnapshotColumns().Count; + var subscriptionsBefore = provider.LiveSubscriptionsOpened; + + var added = viewModel.AddPen(FakeDataProvider.SecondPen); + scheduler.AdvanceBy(TrendCoordinator.BatchWindow.Ticks * 4); + + provider.LiveSubscriptionsOpened.Should().Be(subscriptionsBefore + 1, "the new pen set is subscribed afresh"); + var keptColumns = kept.Line.SnapshotColumns(); + keptColumns.Should().HaveCountGreaterThan(keptColumnsBefore); + keptColumns.Skip(keptColumnsBefore).Should().NotContain( + column => double.IsNaN(column.Center), "the kept pen stays joined to the live stream"); + var addedColumns = added.Line.SnapshotColumns(); + double.IsNaN(addedColumns[0].Center).Should().BeTrue("the added pen's first live value opens behind a break"); + var firstAddedUtc = FakeDataProvider.RealtimeEpoch + RealtimeInterval; + addedColumns[1].X.Should().Be(LocalTimeAxis.ToAxis(firstAddedUtc)); + addedColumns[0].X.Should().Be(LocalTimeAxis.ToAxis(firstAddedUtc.AddMilliseconds(-1.0))); + addedColumns.Skip(1).Should().NotContain(column => double.IsNaN(column.Center)); } [AvaloniaFact] @@ -2654,6 +2690,52 @@ public void AThrowOutOfTheMessagePanel_IsLoggedAndTheRealtimeApplyContinues() valueWhenTheReportThrew, "the batches after the failed report still reach the pen"); } + [AvaloniaFact] + public void APensUnservedFault_LandsInThePanelOnceAsAWarning() + { + using var panel = new MessagePanelViewModel(); + var (viewModel, _, _, provider) = CreateViewModel(messagePanel: panel); + using var chart = viewModel; + + provider.RaisePenFault( + new LiveSourceError(LiveSourceFault.PensUnserved, "opc.tcp://127.0.0.1:4840", unknownPenIds: [7, 9])); + + panel.Entries.Should().ContainSingle(); + panel.Entries[0].RepeatCount.Should().Be(1); + panel.Entries[0].View.Severity.Should().Be(MessageSeverity.Warning); + panel.Entries[0].View.Title.Should().Be(Resources.FailureLiveSourcePensUnservedTitle); + } + + // The break sits minutes past the value, so a lag taken from it would land minutes outside the bracket. + [AvaloniaFact] + public void ALiveBatch_LogsOneLagLineFromItsNewestValueNotFromABreak() + { + var logger = new FakeLogger(); + var (viewModel, scheduler, _, provider) = CreateViewModel(logger: logger); + using var chart = viewModel; + chart.AddPen(FakeDataProvider.FirstPen); + chart.AddPen(FakeDataProvider.SecondPen); + var newestValueUtc = DateTime.UtcNow.AddMinutes(-10); + var before = DateTime.UtcNow; + + provider.PushLiveSamples( + [ + new Sample(FakeDataProvider.SecondPen.PenId, newestValueUtc.AddSeconds(-1), 3.0), + new Sample(FakeDataProvider.FirstPen.PenId, newestValueUtc, 4.0), + new Sample(FakeDataProvider.FirstPen.PenId, newestValueUtc.AddMinutes(20), null) + ]); + scheduler.AdvanceBy(TrendCoordinator.BatchWindow.Ticks); + var after = DateTime.UtcNow; + + var lagLine = logger.Collector.GetSnapshot() + .Should().ContainSingle(record => record.Message.StartsWith("Live batch applied", StringComparison.Ordinal)) + .Subject; + lagLine.Level.Should().Be(LogLevel.Debug); + lagLine.GetStructuredStateValue("ValueCount").Should().Be("2"); + var lag = double.Parse(lagLine.GetStructuredStateValue("LagMilliseconds")!, CultureInfo.InvariantCulture); + lag.Should().BeInRange((before - newestValueUtc).TotalMilliseconds, (after - newestValueUtc).TotalMilliseconds); + } + private static (TrendChartViewModel ViewModel, PixelRect DataRect) RenderTwoFlatPens( double firstValue, double secondValue, diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendPenStateTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendPenStateTests.cs index 6b1b10d..1f544fc 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendPenStateTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendPenStateTests.cs @@ -72,14 +72,14 @@ public void Realtime_BreakOneTickAfterTheValue_IsRejectedAndOneMillisecondAfterI state.AppendRealtime(_start.AddTicks(1), value: null); - state.Line.SnapshotColumns().Should().ContainSingle("the axis keeps milliseconds only"); + state.Line.SnapshotColumns().Should().HaveCount(2, "the axis keeps milliseconds only"); state.AppendRealtime(_start.AddMilliseconds(1.0), value: null); var columns = state.Line.SnapshotColumns(); - columns.Should().HaveCount(2); - columns[0].Center.Should().Be(5.0); - double.IsNaN(columns[1].Center).Should().BeTrue(); + columns.Should().HaveCount(3); + columns[1].Center.Should().Be(5.0); + double.IsNaN(columns[2].Center).Should().BeTrue(); state.CurrentValue.Should().Be(5.0); } @@ -105,16 +105,17 @@ public void Realtime_LiveEdgeColumnDegeneratesToMinEqualsMaxEqualsValue() state.AppendRealtime(_start, 42.0); - state.Line.SnapshotColumns().Should().ContainSingle(); - state.Line.SnapshotColumns()[0].Min.Should().Be(42.0); - state.Line.SnapshotColumns()[0].Max.Should().Be(42.0); - state.Line.SnapshotColumns()[0].Center.Should().Be(42.0); + var columns = state.Line.SnapshotColumns(); + columns.Should().HaveCount(2, "the first value opens behind its break"); + columns[^1].Min.Should().Be(42.0); + columns[^1].Max.Should().Be(42.0); + columns[^1].Center.Should().Be(42.0); } [Theory] [InlineData(1.0, 2, 6.0)] [InlineData(0.0, 2, 6.0)] - [InlineData(2.0, 3, 42.0)] + [InlineData(2.0, 4, 42.0)] public void Realtime_SampleAfterTheLastPoint_IsAppendedAndOneAtOrBeforeItIsIgnored( double minutesFromStart, int expectedColumns, @@ -139,7 +140,7 @@ public void Realtime_AfterAHistoryLoadEndingBeforeALiveColumn_KeepsItAndRejectsA state.LoadHistory(TwoColumnEnvelope(), _start.AddHours(1.0)); AppendAtOrBeforeTheLiveColumn(state); - state.Line.SnapshotColumns().Should().HaveCount(3); + state.Line.SnapshotColumns().Should().HaveCount(4); state.Line.SnapshotColumns()[^1].Center.Should().Be(7.0); state.CurrentValue.Should().Be(7.0); } @@ -153,25 +154,166 @@ public void Realtime_AfterAClearedHistoryEndingBeforeALiveColumn_KeepsItAndRejec state.ClearHistory(_start.AddHours(1.0)); AppendAtOrBeforeTheLiveColumn(state); - state.Line.SnapshotColumns().Should().ContainSingle(); + state.Line.SnapshotColumns().Should().HaveCount(2); state.Line.SnapshotColumns()[^1].Center.Should().Be(7.0); state.CurrentValue.Should().Be(7.0); } [Fact] - public void FoldRealtime_WidensTheMinMaxOfTheCurrentColumn() + public void Realtime_AFirstValuePastTheLastColumn_OpensOneMillisecondBehindANaNColumn() + { + var state = NewState(); + state.LoadHistory(TwoColumnEnvelope(), _start.AddHours(1.0)); + + state.AppendRealtime(_start.AddMinutes(2.0), 42.0); + state.AppendRealtime(_start.AddMinutes(3.0), 43.0); + + var columns = state.Line.SnapshotColumns(); + columns.Select(column => column.X).Should().Equal( + XOf(_start), + XOf(_start.AddMinutes(1.0)), + XOf(_start.AddMinutes(2.0).AddMilliseconds(-1.0)), + XOf(_start.AddMinutes(2.0)), + XOf(_start.AddMinutes(3.0))); + double.IsNaN(columns[2].Center).Should().BeTrue(); + columns.Count(column => double.IsNaN(column.Center)).Should().Be(1, "only the first value opens a break"); + state.CurrentValue.Should().Be(43.0); + } + + [Fact] + public void Realtime_AFirstValueAtTheLastColumn_IsRejectedAndTheNextValuePastItAppendsWithNoBreak() + { + var state = NewState(); + state.LoadHistory(TwoColumnEnvelope(), _start.AddHours(1.0)); + + state.AppendRealtime(_start.AddMinutes(1.0), 42.0); + state.AppendRealtime(_start.AddMinutes(2.0), 43.0); + + var columns = state.Line.SnapshotColumns(); + columns.Should().HaveCount(3); + columns.Should().NotContain(column => double.IsNaN(column.Center)); + state.CurrentValue.Should().Be(43.0); + } + + [Fact] + public void Realtime_OnAnEmptyLine_TheFirstValueOpensBehindANaNColumnAndALaterHistoryLoadKeepsBoth() + { + var state = NewState(); + + state.AppendRealtime(_start.AddMinutes(10.0), 7.0); + state.LoadHistory(TwoColumnEnvelope(), _start.AddHours(1.0)); + + var columns = state.Line.SnapshotColumns(); + columns.Select(column => column.X).Should().Equal( + XOf(_start), + XOf(_start.AddMinutes(1.0)), + XOf(_start.AddMinutes(10.0).AddMilliseconds(-1.0)), + XOf(_start.AddMinutes(10.0))); + double.IsNaN(columns[2].Center).Should().BeTrue(); + columns[3].Center.Should().Be(7.0); + } + + [Fact] + public void Realtime_AFirstValueOnALineEndingOnAGap_TakesNoSecondNaNColumn() + { + var state = NewState(); + state.LoadHistory( + new PenHistoryEnvelope(1, [_start, _start.AddMinutes(1.0)], [1.0, double.NaN], [1.0, double.NaN], + [1.0, double.NaN]), + _start.AddHours(1.0)); + + state.AppendRealtime(_start.AddMinutes(2.0), 42.0); + + var columns = state.Line.SnapshotColumns(); + columns.Should().HaveCount(3); + columns.Count(column => double.IsNaN(column.Center)).Should().Be(1); + columns[^1].Center.Should().Be(42.0); + } + + [Fact] + public void Realtime_AFirstValueOneMillisecondPastTheLastColumn_TakesNoBreak() + { + var state = NewState(); + state.LoadHistory(TwoColumnEnvelope(), _start.AddHours(1.0)); + + state.AppendRealtime(_start.AddMinutes(1.0).AddMilliseconds(1.0), 42.0); + + var columns = state.Line.SnapshotColumns(); + columns.Should().HaveCount(3); + columns.Should().NotContain(column => double.IsNaN(column.Center)); + } + + [Fact] + public void Realtime_ARejectedBreak_StillOpensTheNextValuePastTheLastColumnBehindABreak() + { + var state = NewState(); + state.LoadHistory(TwoColumnEnvelope(), _start.AddHours(1.0)); + state.AppendRealtime(_start.AddMinutes(1.0), 6.0); + + state.AppendRealtime(_start.AddSeconds(30.0), value: null); + state.Line.SnapshotColumns().Should().HaveCount(2, "the break lies inside the history"); + + state.AppendRealtime(_start.AddMinutes(2.0), 42.0); + + var columns = state.Line.SnapshotColumns(); + columns.Should().HaveCount(4); + double.IsNaN(columns[2].Center).Should().BeTrue(); + columns[2].X.Should().Be(XOf(_start.AddMinutes(2.0).AddMilliseconds(-1.0))); + } + + [Theory] + [InlineData(2.5, 2)] + [InlineData(60.0, 1)] + public void Realtime_AfterAHistoryApply_TheNextValueOpensBehindABreakOnlyWhenTheApplyDroppedALiveColumn( + double requestedEndMinutes, + int expectedGaps) + { + var state = NewState(); + state.LoadHistory(TwoColumnEnvelope(), _start.AddHours(1.0)); + state.AppendRealtime(_start.AddMinutes(2.0), 42.0); + state.AppendRealtime(_start.AddMinutes(3.0), 43.0); + + state.LoadHistory(TwoColumnEnvelope(), _start.AddMinutes(requestedEndMinutes)); + state.AppendRealtime(_start.AddMinutes(4.0), 44.0); + + var columns = state.Line.SnapshotColumns(); + columns.Count(column => double.IsNaN(column.Center)).Should().Be(expectedGaps); + columns[^1].Center.Should().Be(44.0); + state.CurrentValue.Should().Be(44.0); + } + + [Fact] + public void FoldRealtime_AFirstValueOpensAColumnBehindTheBreakAndTheNextOneWidensIt() { var state = NewState(); state.LoadHistory(new PenHistoryEnvelope(1, [_start], [1.0], [5.0], [2.0]), _start); state.FoldRealtime(_start.AddMinutes(1.0), 9.0); + state.FoldRealtime(_start.AddMinutes(2.0), 4.0); + + var columns = state.Line.SnapshotColumns(); + columns.Should().HaveCount(3); + double.IsNaN(columns[1].Center).Should().BeTrue(); + columns[1].X.Should().Be(XOf(_start.AddMinutes(1.0).AddMilliseconds(-1.0))); + columns[2].Should().Be(new EnvelopeColumn(XOf(_start.AddMinutes(1.0)), 4.0, 9.0, 4.0)); + state.CurrentValue.Should().Be(4.0); + } + + [Fact] + public void FoldRealtime_AFirstValueAtTheLastColumn_WidensItAndTheNextOneFoldsWithNoBreak() + { + var state = NewState(); + state.LoadHistory(new PenHistoryEnvelope(1, [_start], [1.0], [5.0], [2.0]), _start); + + state.FoldRealtime(_start, 9.0); + state.FoldRealtime(_start.AddMinutes(1.0), 3.0); var columns = state.Line.SnapshotColumns(); columns.Should().ContainSingle(); - columns[0].Max.Should().Be(9.0); columns[0].Min.Should().Be(1.0); - columns[0].Center.Should().Be(9.0); - state.CurrentValue.Should().Be(9.0); + columns[0].Max.Should().Be(9.0); + columns[0].Center.Should().Be(3.0); + state.CurrentValue.Should().Be(3.0); } private static void AppendAtOrBeforeTheLiveColumn(TrendPenState state) @@ -181,6 +323,11 @@ private static void AppendAtOrBeforeTheLiveColumn(TrendPenState state) state.AppendRealtime(_start.AddMinutes(10.0), 43.0); } + private static double XOf(DateTime utc) + { + return LocalTimeAxis.ToAxis(utc); + } + private static TrendPenState NewState() { return new TrendPenState(FakeDataProvider.FirstPen, new EnvelopeLine()); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Di/CompositionRootTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Di/CompositionRootTests.cs index 590993e..b7ff76b 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Di/CompositionRootTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Di/CompositionRootTests.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.Logging; using SemiPlot.Core.Data; +using SemiPlot.DataSource.OpcUa; +using SemiPlot.DataSource.OpcUa.Configuration; using SemiPlot.DataSource.Postgres; using SemiPlot.Tests.Unit.Postgres; using SemiPlot.UI.MainWindow; @@ -37,7 +39,7 @@ public void Container_ResolvesTheLiveSource() { using var provider = BuildContainer(); - provider.GetRequiredService().Should().BeOfType(); + provider.GetServices().Should().ContainSingle().Which.Should().BeOfType(); } [Fact] @@ -87,6 +89,8 @@ public void Container_ResolvesTheMessagePanelAsOneInstance() private static ServiceProvider BuildContainer() { - return StartupProbe.BuildArchiveServiceProvider(ConnectionSettingsFactory.Create()); + return StartupProbe.BuildArchiveServiceProvider( + ConnectionSettingsFactory.Create(), + new OpcUaConnectionSettings("127.0.0.1", 1, Path.Combine(Path.GetTempPath(), "semiplot-unused-pki"))); } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/AppStatusBarViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/AppStatusBarViewModelTests.cs index d96a64e..544d8e4 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/AppStatusBarViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/AppStatusBarViewModelTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Reactive.Linq; using System.Reactive.Subjects; using System.Resources; @@ -24,8 +25,8 @@ namespace SemiPlot.Tests.Unit.UI.MainWindow; /// -/// The status bar's own state: the connection it follows, the layer named from resx, and the two entries -/// the connection writes into the message panel. +/// The status bar's own state: the archive and live connections it follows, the layer named from resx, and +/// the two entries each connection writes into the message panel. /// [Collection(ProcessGlobalStateCollection.Name)] [Trait("Component", "UI")] @@ -36,6 +37,9 @@ public sealed class AppStatusBarViewModelTests private static readonly ArchiveConnectionState _lost = new(FakePenCatalogueEditor.Refusal(ArchiveFault.ConnectionLost, "3")); + private static readonly LiveSourceState _liveLost = + LiveSourceState.FaultedBy(new LiveSourceError(LiveSourceFault.Unreachable, "opc.tcp://127.0.0.1:4840")); + [Fact] public void Connected_AsTheFirstTick_WritesNoEntry() { @@ -107,7 +111,7 @@ public void AThrowInTheHandler_IsReportedAndTheStreamSurvives() var logger = new ThrowingLogger(throwsOnce: true); using var states = new Subject(); using var statusBar = new AppStatusBarViewModel( - panel, states, NavigationAtRawLayer(), logger); + panel, states, Observable.Never(), NavigationAtRawLayer(), logger); states.OnNext(_lost); @@ -131,7 +135,7 @@ public void AThrowOutOfThePanel_IsLoggedAndTheStreamSurvives() var logger = new FakeLogger(); using var states = new Subject(); using var statusBar = new AppStatusBarViewModel( - panel, states, NavigationAtRawLayer(), logger); + panel, states, Observable.Never(), NavigationAtRawLayer(), logger); var poison = ReportingTestDoubles.PoisonEntries(panel); states.OnNext(_lost); @@ -161,6 +165,141 @@ public void AfterDisposal_TheBarStopsFollowingTheStream() panel.Entries.Should().BeEmpty(); } + [Fact] + public void LiveConnecting_WritesNoEntryAndReadsNeitherConnectedNorFaulted() + { + using var panel = new MessagePanelViewModel(); + using var liveStates = new Subject(); + using var statusBar = NewStatusBar(panel, liveSourceStates: liveStates); + + liveStates.OnNext(LiveSourceState.Connecting); + + panel.Entries.Should().BeEmpty(); + statusBar.IsLiveConnected.Should().BeFalse(); + statusBar.IsLiveFaulted.Should().BeFalse("the grace is not a fault"); + statusBar.LiveConnectionText.Should().Be(Resources.StatusLiveConnecting); + } + + [Fact] + public void LiveConnected_AsTheFirstTick_WritesNoEntry() + { + using var panel = new MessagePanelViewModel(); + using var liveStates = new Subject(); + using var statusBar = NewStatusBar(panel, liveSourceStates: liveStates); + + liveStates.OnNext(LiveSourceState.Connecting); + liveStates.OnNext(LiveSourceState.Connected); + + panel.Entries.Should().BeEmpty(); + statusBar.IsLiveConnected.Should().BeTrue(); + statusBar.IsLiveFaulted.Should().BeFalse(); + statusBar.LiveConnectionText.Should().Be(Resources.StatusLiveConnectionOk); + } + + [Fact] + public void ALiveFault_WritesOneWarningAndSetsTheFaultText() + { + using var panel = new MessagePanelViewModel(); + using var liveStates = new Subject(); + using var statusBar = NewStatusBar(panel, liveSourceStates: liveStates); + + liveStates.OnNext(_liveLost); + + statusBar.IsLiveConnected.Should().BeFalse(); + statusBar.IsLiveFaulted.Should().BeTrue(); + statusBar.LiveConnectionText.Should().Be(Resources.StatusLiveConnectionFault); + statusBar.IsConnected.Should().BeTrue("the archive indicator follows its own stream"); + panel.Entries.Should().ContainSingle(); + panel.Entries[0].View.Should().Be(ArchiveFailureMapper.Map(_liveLost.Fault!)); + panel.Entries[0].View.Severity.Should().Be(MessageSeverity.Warning); + } + + [Fact] + public void LiveConnected_AfterAFault_WritesExactlyOneInfoEntry() + { + using var panel = new MessagePanelViewModel(); + using var liveStates = new Subject(); + using var statusBar = NewStatusBar(panel, liveSourceStates: liveStates); + + liveStates.OnNext(LiveSourceState.Connected); + liveStates.OnNext(_liveLost); + liveStates.OnNext(LiveSourceState.Connected); + + panel.Entries.Should().HaveCount(2); + panel.Entries[0].View.Severity.Should().Be(MessageSeverity.Info); + panel.Entries[0].View.Title.Should().Be(Resources.StatusLiveConnectionRestoredTitle); + panel.Entries[0].View.Detail.Should().Be(Resources.StatusLiveConnectionRestoredDetail); + panel.Entries[1].View.Should().Be(ArchiveFailureMapper.Map(_liveLost.Fault!)); + statusBar.IsLiveConnected.Should().BeTrue(); + statusBar.IsLiveFaulted.Should().BeFalse(); + } + + [Fact] + public void LiveConnected_TwiceAfterOneFault_WritesOneRecoveryEntry() + { + using var panel = new MessagePanelViewModel(); + using var liveStates = new Subject(); + using var statusBar = NewStatusBar(panel, liveSourceStates: liveStates); + + liveStates.OnNext(_liveLost); + liveStates.OnNext(LiveSourceState.Connected); + liveStates.OnNext(LiveSourceState.Connected); + + panel.Entries.Where(entry => entry.View.Severity == MessageSeverity.Info).Should().ContainSingle(); + panel.Entries[0].RepeatCount.Should().Be(1, "a second Connected owes no second entry"); + } + + [Fact] + public void LiveConnected_AfterAnArchiveFault_WritesNoRecoveryEntry() + { + using var panel = new MessagePanelViewModel(); + using var states = new Subject(); + using var liveStates = new Subject(); + using var statusBar = NewStatusBar(panel, states, liveStates); + + states.OnNext(_lost); + liveStates.OnNext(LiveSourceState.Connected); + + panel.Entries.Should().ContainSingle().Which.View.Severity.Should().Be(MessageSeverity.Warning); + } + + [Fact] + public void AThrowInTheLiveHandler_IsReportedAndTheStreamSurvives() + { + using var panel = new MessagePanelViewModel(); + var logger = new ThrowingLogger(throwsOnce: true); + using var liveStates = new Subject(); + using var statusBar = new AppStatusBarViewModel( + panel, Observable.Never(), liveStates, NavigationAtRawLayer(), logger); + + liveStates.OnNext(_liveLost); + + panel.Entries.Should().HaveCount(2); + panel.Entries[0].View.Should().Be(ArchiveFailureMapper.Map(new ExceptionalError(logger.Thrown))); + panel.Entries[1].View.Severity.Should().Be(MessageSeverity.Warning); + + liveStates.OnNext(LiveSourceState.Connected); + + statusBar.IsLiveConnected.Should().BeTrue("the next state still arrives"); + panel.Entries.Should().HaveCount(3); + panel.Entries[0].View.Severity.Should().Be(MessageSeverity.Info); + } + + [Fact] + public void AfterDisposal_TheBarStopsFollowingTheLiveStream() + { + using var panel = new MessagePanelViewModel(); + using var liveStates = new Subject(); + var statusBar = NewStatusBar(panel, liveSourceStates: liveStates); + statusBar.Dispose(); + + liveStates.OnNext(_liveLost); + + statusBar.IsLiveFaulted.Should().BeFalse(); + statusBar.LiveConnectionText.Should().Be(Resources.StatusLiveConnecting); + panel.Entries.Should().BeEmpty(); + } + [Theory] [InlineData(AggregationLayer.Raw, "StatusLayerRaw")] [InlineData(AggregationLayer.Minute, "StatusLayerMinute")] diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/AppStatusBarViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/AppStatusBarViewTests.cs index 3c4842c..3d6dd21 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/AppStatusBarViewTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/AppStatusBarViewTests.cs @@ -23,7 +23,7 @@ namespace SemiPlot.Tests.Unit.UI.MainWindow; -/// The realised bar: the indicator's class, its palette brush, its command, and the layer caption. +/// The realised bar: each indicator's class, its palette brush, its command, and the layer caption. [Collection(ProcessGlobalStateCollection.Name)] [Trait("Component", "UI")] [Trait("Area", "Chart")] @@ -33,6 +33,9 @@ public sealed class AppStatusBarViewTests private static readonly ArchiveConnectionState _lost = new(FakePenCatalogueEditor.Refusal(ArchiveFault.ConnectionLost, "3")); + private static readonly LiveSourceState _liveLost = + LiveSourceState.FaultedBy(new LiveSourceError(LiveSourceFault.Unreachable, "opc.tcp://127.0.0.1:4840")); + [AvaloniaFact] public void ConnectionIndicator_CarriesTheStateClassAndOpensThePanel() { @@ -98,6 +101,76 @@ public void ConnectionIndicator_PaintsBothStatesFromThePalette(AppThemeVariant t } } + [AvaloniaFact] + public void LiveConnectionIndicator_CarriesTheStateClassAndOpensThePanel() + { + using var panel = new MessagePanelViewModel(); + using var liveStates = new Subject(); + using var statusBar = NewStatusBar(panel, liveSourceStates: liveStates); + var view = new AppStatusBar { DataContext = statusBar }; + var window = new Window { Content = view }; + window.Show(); + var indicator = view.FindControl public sealed class DemoScadaServer : IAsyncDisposable { + public const string Host = "127.0.0.1"; + private const string ApplicationName = "SemiPlotDemoScada"; private readonly StandardServer _server; @@ -48,7 +50,7 @@ public static async Task StartAsync( ILoggerFactory loggerFactory) { var telemetry = new Telemetry(loggerFactory); - var endpointUrl = $"opc.tcp://127.0.0.1:{options.OpcPort}"; + var endpointUrl = $"opc.tcp://{Host}:{options.OpcPort}"; var configuration = CreateConfiguration(endpointUrl, certificateDirectory, telemetry); await configuration.ValidateAsync(ApplicationType.Server, CancellationToken.None); @@ -81,7 +83,7 @@ public static async Task StartAsync( await server.DisposeAsync(); throw new SeederException( - $"OPC UA port {options.OpcPort} is in use on 127.0.0.1; a running Simple-Scada holds " + + $"OPC UA port {options.OpcPort} is in use on {Host}; a running Simple-Scada holds " + $"{FollowOptions.SimpleScadaPort} by default. Pass a free --opc-port.", exception); } diff --git a/SemiPlot/SemiPlot.Tools.ArchiveSeeder/OpcFileWriter.cs b/SemiPlot/SemiPlot.Tools.ArchiveSeeder/OpcFileWriter.cs new file mode 100644 index 0000000..1d65589 --- /dev/null +++ b/SemiPlot/SemiPlot.Tools.ArchiveSeeder/OpcFileWriter.cs @@ -0,0 +1,31 @@ +namespace SemiPlot.Tools.ArchiveSeeder; + +public static class OpcFileWriter +{ + public const string DirectoryName = "opc"; + + public const string FileName = "opc.yaml"; + + /// The file this writer targets under a configuration root. + public static string PathFor(string configDirectory) + { + return Path.Combine(configDirectory, DirectoryName, FileName); + } + + public static async Task WriteAsync( + string configDirectory, + string host, + int port, + CancellationToken cancellationToken = default) + { + Directory.CreateDirectory(Path.Combine(configDirectory, DirectoryName)); + + var content = + $""" + host: {host} + port: {port} + """ + Environment.NewLine; + + await File.WriteAllTextAsync(PathFor(configDirectory), content, cancellationToken); + } +} diff --git a/SemiPlot/SemiPlot.Tools.ArchiveSeeder/Program.cs b/SemiPlot/SemiPlot.Tools.ArchiveSeeder/Program.cs index d0d35dc..dc8706b 100644 --- a/SemiPlot/SemiPlot.Tools.ArchiveSeeder/Program.cs +++ b/SemiPlot/SemiPlot.Tools.ArchiveSeeder/Program.cs @@ -225,6 +225,7 @@ private static async Task ConvergeAsync(ConvergeOptions options) Console.WriteLine($"rows written {result.RowsWritten}"); Console.WriteLine($"tags written {result.TagsWritten}"); Console.WriteLine($"config {ConnectionFileWriter.PathFor(options.ConfigDirectory)}"); + Console.WriteLine($"opc config {OpcFileWriter.PathFor(options.ConfigDirectory)}"); return 0; } diff --git a/SemiPlot/SemiPlot.Tools.ArchiveSeeder/SeederCommand.cs b/SemiPlot/SemiPlot.Tools.ArchiveSeeder/SeederCommand.cs index 2b4c0e5..de01ee8 100644 --- a/SemiPlot/SemiPlot.Tools.ArchiveSeeder/SeederCommand.cs +++ b/SemiPlot/SemiPlot.Tools.ArchiveSeeder/SeederCommand.cs @@ -79,8 +79,9 @@ public static class SeederCommand private static readonly Option _opcPort = new("--opc-port") { - Description = "Port the OPC UA demo server listens on, on 127.0.0.1. Required for a follow run; a running " - + $"Simple-Scada holds {FollowOptions.SimpleScadaPort} by default.", + Description = $"Port the OPC UA demo server listens on, on {DemoScadaServer.Host}. Required for a follow run " + + "and for converge, which writes it into opc/opc.yaml; a running Simple-Scada holds " + + $"{FollowOptions.SimpleScadaPort} by default.", CustomParser = result => ParseWholeNumber(result, "--opc-port", 1, IPEndPoint.MaxPort) }; @@ -105,7 +106,8 @@ private static readonly (Option Option, string Name)[] _followOnly = private static readonly Option _configDir = new("--config-dir") { - Description = "Configuration root. The archive connection is written into its connection/ folder.", + Description = "Configuration root. The archive connection is written into its connection/ folder and the " + + "OPC UA endpoint into its opc/ folder.", Required = true }; @@ -165,16 +167,20 @@ private static RootCommand Build() return root; } - // Shares --connection and --end with the root command: converge accepts the same connection string - // shape and the same optional end bound, and System.CommandLine allows one Option under several commands. + // Shares --connection, --end and --opc-port with the root command: converge accepts the same connection + // string shape, the same optional end bound and the same port range, and System.CommandLine allows one + // Option under several commands. private static Command BuildConverge() { var converge = new Command( "converge", "Recreates the stand database from semiplot_provisioned, seeds it, fills the tag catalogue and " - + "writes the connection file. Bench only."); + + "writes the connection and OPC UA files. Bench only."); - foreach (var option in new Option[] { _connection, _convergeAdminConnection, _configDir, _end, _changeSeconds }) + foreach (var option in new Option[] + { + _connection, _convergeAdminConnection, _configDir, _end, _changeSeconds, _opcPort + }) { converge.Options.Add(option); } @@ -307,12 +313,21 @@ private static Task InterpretConverge(ParseResult parsed, Func>([.. pens.Select(pen => pen.PenId)]); RealtimeBatches = BuildRealtimeBatches(); RealtimeFailures = _realtimeFailures.ObserveOn(_uiScheduler); + LiveSourceStates = _liveSource.States.ObserveOn(_uiScheduler); + LivePenFaults = _liveSource.PenFaults.ObserveOn(_uiScheduler); } /// @@ -53,6 +56,12 @@ public TrendCoordinator( /// public IObservable RealtimeFailures { get; } + /// The live source's session state, the current one first, on the UI scheduler. + public IObservable LiveSourceStates { get; } + + /// The live source's pen faults, on the UI scheduler. + public IObservable LivePenFaults { get; } + /// The pen set the live edge follows: the constructor's, or the last one handed to SetPens. public IReadOnlyList PenIds => _penIds.Value; diff --git a/SemiPlot/SemiPlot.UI/Chart/EnvelopeLine.cs b/SemiPlot/SemiPlot.UI/Chart/EnvelopeLine.cs index 22bd10b..5cf7d12 100644 --- a/SemiPlot/SemiPlot.UI/Chart/EnvelopeLine.cs +++ b/SemiPlot/SemiPlot.UI/Chart/EnvelopeLine.cs @@ -192,13 +192,15 @@ internal void SetLineWidth(float width) } // docs/architecture/data-integration.md#what-one-history-query-covers - internal void ReplaceColumns(IReadOnlyList columns, double requestedEndX) + /// True when a live column past was dropped. + internal bool ReplaceColumns(IReadOnlyList columns, double requestedEndX) { lock (_renderStateLock) { var newHistoryEndX = columns.Count > 0 ? columns[^1].X : double.NegativeInfinity; var keptTailStart = EnvelopePath.FirstAfter(_columns, Math.Max(_historyEndX, newHistoryEndX)); var keptTailEnd = Math.Max(keptTailStart, EnvelopePath.FirstAfter(_columns, requestedEndX)); + var droppedPastRequestedEnd = keptTailEnd < _columns.Count; _columns.RemoveRange(keptTailEnd, _columns.Count - keptTailEnd); _columns.RemoveRange(0, keptTailStart); @@ -210,6 +212,27 @@ internal void ReplaceColumns(IReadOnlyList columns, double reque { _columns.RemoveRange(0, overflow); } + + return droppedPastRequestedEnd; + } + } + + /// Appends the sample behind the gap; the gap only where the line does not already end on one. + internal bool AppendAfterBreak(EnvelopeColumn gap, EnvelopeColumn sample) + { + lock (_renderStateLock) + { + if (_columns.Count > 0 && sample.X <= _columns[^1].X) + { + return false; + } + + if (_columns.Count == 0 || (!EndsOnGap(_columns[^1]) && gap.X > _columns[^1].X)) + { + AppendAfterLastColumn(gap); + } + + return AppendAfterLastColumn(sample); } } @@ -234,8 +257,7 @@ internal bool FoldIntoLastColumn(EnvelopeColumn sample) var index = _columns.Count - 1; var column = _columns[index]; - var endsOnGap = double.IsNaN(column.Min) || double.IsNaN(column.Max); - if (endsOnGap) + if (EndsOnGap(column)) { return AppendAfterLastColumn(sample); } @@ -268,6 +290,11 @@ internal bool FoldIntoLastColumn(EnvelopeColumn sample) } } + private static bool EndsOnGap(EnvelopeColumn column) + { + return double.IsNaN(column.Min) || double.IsNaN(column.Max); + } + private bool AppendAfterLastColumn(EnvelopeColumn column) { if (_columns.Count > 0 && column.X <= _columns[^1].X) diff --git a/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs b/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs index 53a6ac0..c9b3b55 100644 --- a/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs +++ b/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs @@ -82,6 +82,8 @@ public TrendChartViewModel( .Subscribe(ApplyRealtimeBatch)); _disposables.Add(_coordinator.RealtimeFailures .Subscribe(ReportFailure)); + _disposables.Add(_coordinator.LivePenFaults + .Subscribe(fault => _messagePanel.TryReportFailure(fault, _logger))); Plot.HideLegend(); } @@ -804,6 +806,7 @@ private void ApplyRealtimeBatch(RealtimeBatch batch) _realtimeApplier.Apply(batch, foldIntoColumn); RequestRedraw(); + LogLiveLag(batch); } catch (Exception applyFailure) { @@ -811,6 +814,45 @@ private void ApplyRealtimeBatch(RealtimeBatch batch) } } + private void LogLiveLag(RealtimeBatch batch) + { + if (!_logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + var valueCount = 0; + var newestValueUtc = DateTime.MinValue; + + foreach (var pen in batch.Pens) + { + for (var index = 0; index < pen.Values.Count; index++) + { + if (pen.Values[index] is null) + { + continue; + } + + valueCount++; + + if (pen.TimestampsUtc[index] > newestValueUtc) + { + newestValueUtc = pen.TimestampsUtc[index]; + } + } + } + + if (valueCount == 0) + { + return; + } + + _logger.LogDebug( + "Live batch applied: {ValueCount} values, {LagMilliseconds} ms after the newest", + valueCount, + (DateTime.UtcNow - newestValueUtc).TotalMilliseconds); + } + /// The chart's own Rx pipelines, the view's included, route a terminal failure here. public void ReportFailure(Exception failure) { diff --git a/SemiPlot/SemiPlot.UI/Chart/TrendPenState.cs b/SemiPlot/SemiPlot.UI/Chart/TrendPenState.cs index ec4e93f..8a440bd 100644 --- a/SemiPlot/SemiPlot.UI/Chart/TrendPenState.cs +++ b/SemiPlot/SemiPlot.UI/Chart/TrendPenState.cs @@ -8,6 +8,8 @@ namespace SemiPlot.UI.Chart; public sealed class TrendPenState : ReactiveObject { + private bool _isJoinedToLive; + public TrendPenState(Pen pen, EnvelopeLine line) { Pen = pen; @@ -52,7 +54,9 @@ internal void SetVisibility(bool isVisible) IsVisible = isVisible; } - /// is the read's own right edge: no live column past it is kept. + /// + /// is the read's own right edge: no live column past it is kept. + /// public void LoadHistory(PenHistoryEnvelope envelope, DateTime requestedToUtc) { var columns = new List(envelope.Timestamps.Count); @@ -74,23 +78,52 @@ public void ClearHistory(DateTime requestedToUtc) ReplaceHistory([], requestedToUtc); } + // docs/architecture/data-integration.md#the-break-ahead-of-a-first-live-sample public void AppendRealtime(DateTime timestampUtc, double? value) { - if (Line.AppendColumn(LiveColumn(timestampUtc, value ?? double.NaN)) && value.HasValue) + if (value is not { } sampled) { - CurrentValue = value; + Line.AppendColumn(LiveColumn(timestampUtc, double.NaN)); + _isJoinedToLive = false; + return; } + + var sample = LiveColumn(timestampUtc, sampled); + TakeLiveValue(sampled, _isJoinedToLive + ? Line.AppendColumn(sample) + : Line.AppendAfterBreak(AheadBreak(timestampUtc), sample)); } // docs/architecture/charting.md#per-pen-plottable-envelopeline public void FoldRealtime(DateTime timestampUtc, double value) { - if (Line.FoldIntoLastColumn(LiveColumn(timestampUtc, value))) + var sample = LiveColumn(timestampUtc, value); + var isAppendedAfterBreak = !_isJoinedToLive && Line.AppendAfterBreak(AheadBreak(timestampUtc), sample); + + if (isAppendedAfterBreak) + { + TakeLiveValue(value, isTaken: true); + + return; + } + + TakeLiveValue(value, Line.FoldIntoLastColumn(sample)); + } + + private void TakeLiveValue(double value, bool isTaken) + { + _isJoinedToLive = true; + if (isTaken) { CurrentValue = value; } } + private static EnvelopeColumn AheadBreak(DateTime timestampUtc) + { + return LiveColumn(timestampUtc - Sample.BreakStep, double.NaN); + } + private static EnvelopeColumn LiveColumn(DateTime timestampUtc, double value) { return new EnvelopeColumn(LocalTimeAxis.ToAxis(timestampUtc), value, value, value); @@ -104,7 +137,11 @@ private void RestyleLine() // docs/architecture/charting.md#per-pen-plottable-envelopeline private void ReplaceHistory(IReadOnlyList columns, DateTime requestedToUtc) { - Line.ReplaceColumns(columns, LocalTimeAxis.ToAxis(requestedToUtc)); + if (Line.ReplaceColumns(columns, LocalTimeAxis.ToAxis(requestedToUtc))) + { + _isJoinedToLive = false; + } + CurrentValue = Line.LastNonGapCenter(); } } diff --git a/SemiPlot/SemiPlot.UI/Localization/Resources.resx b/SemiPlot/SemiPlot.UI/Localization/Resources.resx index 1e3b1e7..2ed3a4c 100644 --- a/SemiPlot/SemiPlot.UI/Localization/Resources.resx +++ b/SemiPlot/SemiPlot.UI/Localization/Resources.resx @@ -54,6 +54,21 @@ SemiPlot is reading the archive again. + + OPC UA connecting + + + OPC UA connected + + + OPC UA not answering + + + The connection to the OPC UA server is restored + + + SemiPlot is receiving live values again. + Layer: {0} @@ -261,17 +276,20 @@ SemiPlot cannot read the archive connection settings + + SemiPlot cannot read the OPC UA connection settings + - SemiPlot cannot parse a file of the connection folder '{0}': a key carries a value this format does not accept. + SemiPlot cannot parse a file of the folder '{0}': a key carries a value this format does not accept. - SemiPlot rejected the connection folder '{0}': required fields are absent or blank: {1}. + SemiPlot rejected the folder '{0}': required fields are absent or blank: {1}. - SemiPlot rejected the connection folder '{0}': values outside the range this build accepts: {1}. + SemiPlot rejected the folder '{0}': values outside the range this build accepts: {1}. - SemiPlot rejected the connection folder '{0}': the field '{1}' is not an IPv4 address of four decimal numbers from 0 to 255. + SemiPlot rejected the folder '{0}': the field '{1}' is not an IPv4 address of four decimal numbers from 0 to 255. Correct the value whose type the field does not accept, such as a port that is not a number. @@ -280,7 +298,7 @@ Every field is required. Add the fields the detail lists to a file in that folder. - Set port to a number from {0} to {1} and poll_interval_ms to {2} or more. + Set port to a number from {0} to {1}. Set the host to an IPv4 address of four numbers from 0 to 255, such as 127.0.0.1. A host name is not accepted. @@ -330,6 +348,48 @@ SemiPlot keeps reading and clears this message once the archive answers. Check that the PostgreSQL server is running. Check that it is reachable from this machine. + + SemiPlot cannot reach the OPC UA server + + + SemiPlot has no OPC UA session to {0}. The chart keeps the history it shows and gets no live values until the session is back. + + + SemiPlot keeps connecting in the background and writes a new message once the session is back. Check that the SCADA project is running. Check that the host and port in the OPC UA connection settings are reachable from this machine. Check the route and the firewall. + + + The OPC UA server refuses SemiPlot + + + The OPC UA server at {0} offers no endpoint with security mode None and anonymous access. + + + Allow an endpoint with security mode None and anonymous access in the settings of the SCADA's OPC UA server. SemiPlot tries again every 30 seconds. + + + The OPC UA server holds no SCADA project + + + The OPC UA server at {0} answers but holds no project folder with the pens. + + + Start the SCADA project. Check that the host and port in the OPC UA connection settings name the server of this project. SemiPlot tries again every 30 seconds. + + + The OPC UA server does not serve some pens + + + The OPC UA server at {0} sends no live values for some pens. {1} + + + Check that each listed pen still exists in the SCADA project and archives a number. These pens show the archive's values only. + + + Unknown pen ids: {0}. + + + Pen ids whose value is not a number: {0}. + The archive has an unexpected structure @@ -441,6 +501,9 @@ SemiPlot cannot read the archive connection folder + + SemiPlot cannot read the OPC UA connection folder + SemiPlot did not find the configuration folder '{0}'. @@ -465,12 +528,18 @@ Create that folder with a '*.yaml' file carrying the archive connection fields. Or start SemiPlot with the --config-dir argument naming the folder that holds it. + + Create that folder with a '*.yaml' file carrying a 'host' key and a 'port' key of the SCADA's OPC UA server. Or start SemiPlot with the --config-dir argument naming the folder that holds it. + Add a '*.yaml' file carrying a 'locale' key and a 'theme' key to that folder. Add a '*.yaml' file carrying the archive connection fields to that folder. + + Add a '*.yaml' file carrying a 'host' key and a 'port' key of the SCADA's OPC UA server to that folder. + Give the account running SemiPlot read access to that folder. Correct the syntax of the files in it. @@ -579,9 +648,6 @@ Password - - Poll interval, ms - Saved. Takes effect after a restart. @@ -603,9 +669,6 @@ Port: a whole number 1–65535 - - Poll interval: a whole number above 0 - {0}: required diff --git a/SemiPlot/SemiPlot.UI/Localization/Resources.ru.resx b/SemiPlot/SemiPlot.UI/Localization/Resources.ru.resx index fdeb2a0..107e5dc 100644 --- a/SemiPlot/SemiPlot.UI/Localization/Resources.ru.resx +++ b/SemiPlot/SemiPlot.UI/Localization/Resources.ru.resx @@ -54,6 +54,21 @@ SemiPlot снова опрашивает базу данных. + + Подключение к серверу OPC UA + + + Связь с сервером OPC UA установлена + + + Сервер OPC UA не отвечает + + + Связь с сервером OPC UA восстановлена + + + SemiPlot снова получает новые значения с сервера OPC UA. + Детализация: {0} @@ -261,17 +276,20 @@ Не удаётся прочитать настройки подключения к базе данных + + Не удаётся прочитать настройки подключения к серверу OPC UA + - SemiPlot не может разобрать файл в каталоге подключения «{0}»: ключ содержит значение недопустимого формата. + SemiPlot не может разобрать файл в каталоге «{0}»: ключ содержит значение недопустимого формата. - SemiPlot отклонил каталог подключения «{0}»: обязательные поля отсутствуют или пусты: {1}. + SemiPlot отклонил каталог «{0}»: обязательные поля отсутствуют или пусты: {1}. - SemiPlot отклонил каталог подключения «{0}»: значения полей вне допустимого диапазона: {1}. + SemiPlot отклонил каталог «{0}»: значения полей вне допустимого диапазона: {1}. - SemiPlot отклонил каталог подключения «{0}»: в поле «{1}» не IPv4-адрес: нужны четыре десятичных числа от 0 до 255. + SemiPlot отклонил каталог «{0}»: в поле «{1}» не IPv4-адрес: нужны четыре десятичных числа от 0 до 255. Исправьте значение неверного типа, например порт, который не является числом. @@ -280,7 +298,7 @@ Все поля обязательны. Добавьте поля из сообщения в файл этого каталога. - Задайте port от {0} до {1} и poll_interval_ms не меньше {2}. + Задайте port от {0} до {1}. Укажите в поле host IPv4-адрес из четырёх чисел от 0 до 255, например 127.0.0.1. Имя сервера не принимается. @@ -330,6 +348,48 @@ SemiPlot продолжает опрашивать базу данных и снимет это сообщение, когда она ответит. Проверьте, что сервер PostgreSQL запущен. Проверьте, что он доступен с этой машины. + + Нет связи с сервером OPC UA + + + У SemiPlot нет сеанса OPC UA с {0}. График сохраняет показанную историю и не получает новых значений, пока сеанс не восстановится. + + + SemiPlot продолжает подключаться в фоне и напишет новое сообщение, когда сеанс восстановится. Проверьте, что проект SCADA запущен. Проверьте, что хост и порт из настроек подключения к серверу OPC UA доступны с этой машины. Проверьте маршрут и межсетевой экран. + + + Сервер OPC UA не принимает SemiPlot + + + Сервер OPC UA по адресу {0} не предлагает точку подключения без защиты (режим None) с анонимным доступом. + + + Разрешите в настройках сервера OPC UA системы SCADA точку подключения без защиты (режим None) с анонимным доступом. SemiPlot повторяет попытку каждые 30 секунд. + + + На сервере OPC UA нет проекта SCADA + + + Сервер OPC UA по адресу {0} отвечает, но не содержит каталога проекта с перьями. + + + Запустите проект SCADA. Проверьте, что хост и порт из настроек подключения к серверу OPC UA указывают на сервер этого проекта. SemiPlot повторяет попытку каждые 30 секунд. + + + Сервер OPC UA не передаёт значения некоторых перьев + + + Сервер OPC UA по адресу {0} не передаёт новые значения некоторых перьев. {1} + + + Проверьте, что каждое из перечисленных перьев есть в проекте SCADA и архивирует число. Эти перья показывают только значения из архива. + + + Неизвестные номера перьев: {0}. + + + Номера перьев, значение которых не число: {0}. + Структура базы данных не соответствует ожидаемой @@ -441,6 +501,9 @@ Не удаётся прочитать каталог подключения к базе данных + + Не удаётся прочитать каталог подключения к серверу OPC UA + SemiPlot не нашёл каталог настроек «{0}». @@ -465,12 +528,18 @@ Создайте этот каталог и поместите в него файл «*.yaml» с настройками подключения к базе данных. Или запустите SemiPlot с аргументом --config-dir, указав каталог, в котором этот файл уже есть. + + Создайте этот каталог и поместите в него файл «*.yaml» с настройками «host» и «port» сервера OPC UA системы SCADA. Или запустите SemiPlot с аргументом --config-dir, указав каталог, в котором этот файл уже есть. + Добавьте в этот каталог файл «*.yaml» с настройками «locale» и «theme». Добавьте в этот каталог файл «*.yaml» с настройками подключения к базе данных. + + Добавьте в этот каталог файл «*.yaml» с настройками «host» и «port» сервера OPC UA системы SCADA. + Предоставьте учётной записи, от имени которой работает SemiPlot, право на чтение этого каталога. Исправьте синтаксис в файлах каталога. @@ -579,9 +648,6 @@ Пароль - - Период опроса, мс - Сохранено. Вступит в силу после перезапуска. @@ -603,9 +669,6 @@ Порт: целое число 1–65535 - - Период опроса: целое число больше 0 - {0}: обязательное поле diff --git a/SemiPlot/SemiPlot.UI/MainWindow/AppStatusBar.axaml b/SemiPlot/SemiPlot.UI/MainWindow/AppStatusBar.axaml index 533bb3a..972341f 100644 --- a/SemiPlot/SemiPlot.UI/MainWindow/AppStatusBar.axaml +++ b/SemiPlot/SemiPlot.UI/MainWindow/AppStatusBar.axaml @@ -18,7 +18,7 @@ -