Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,10 @@ dotnet run --project SemiPlot/SemiPlot.Tools.ArchiveSeeder/SemiPlot.Tools.Archiv
--config-dir SemiPlot\Artifacts\dev-config
```

The demo writer is the same seeder run with `--follow <seconds>` instead of `--end`: it appends to
the archive `converge` created and thins it into the coarse layers on every tick
(`docs/architecture/bench.md#the-demo-writer`).
The demo writer is the same seeder run with `--follow <seconds> --opc-port <port>` instead of `--end`:
it appends to the archive `converge` created and thins it into the coarse layers on every tick
(`docs/architecture/bench.md#the-demo-writer`), and serves every value over OPC UA on the port
`--opc-port` names (`docs/architecture/bench.md#the-opc-ua-demo-server`).

`SemiPlot.AppHost` runs the whole demo stand — the bench container, `converge`, the demo writer and
the viewer — in dependency order and stops the processes it launched together; a window the viewer starts
Expand All @@ -114,7 +115,7 @@ 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, and the socket tests that need no server |
| `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 |

- Each project carries its own `TestAppBuilder.cs` with `[assembly: AvaloniaTestApplication]`. Pure
logic uses plain `[Fact]`; tests touching ReactiveUI/ScottPlot/Avalonia use
Expand Down Expand Up @@ -190,11 +191,13 @@ Frame cost during a drag is measured, not asserted: `docs/architecture/testing-s
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.
`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
carries fixed dummy passwords of the fixture's own, and the base image is the constant
`postgres:17-alpine`. A missing runtime fails every test of the collection with
`postgres:17-alpine`. A missing runtime fails every test of `ArchiveDatabaseCollection` with
`TestPipelineException`; nothing skips.

A run leaves nothing behind but the images it pulled: the resource reaper removes the built image,
Expand Down
11 changes: 7 additions & 4 deletions SemiPlot/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@
<PackageVersion Include="AwesomeAssertions" Version="9.4.0"/>
<PackageVersion Include="FluentResults" Version="4.0.0"/>
<PackageVersion Include="Microsoft.CodeAnalysis.ResxSourceGenerator" Version="3.12.0-beta1.25218.8"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.12"/>
<PackageVersion Include="Microsoft.Extensions.Diagnostics.Testing" Version="10.6.0"/>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.12"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0"/>
<PackageVersion Include="Microsoft.Reactive.Testing" Version="6.1.0"/>
<PackageVersion Include="Npgsql" Version="10.0.3"/>
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="2.0.1"/>
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="2.0.1"/>
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.0.3"/>
<PackageVersion Include="ScottPlot.Avalonia" Version="5.1.59"/>
<PackageVersion Include="Semi.Avalonia" Version="12.0.3"/>
Expand Down
8 changes: 7 additions & 1 deletion SemiPlot/SemiPlot.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
const ushort ContainerPort = 5432;
// One density for the seeded day and the live tail, so the chart shows no seam between them.
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";

// AppHostDirectory is SemiPlot/SemiPlot.AppHost, so the tracked set sits two levels above it.
var trackedConfiguration = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "ConfigFiles"));
Expand Down Expand Up @@ -48,7 +50,11 @@
.WaitFor(bench);

builder.AddProject<Projects.SemiPlot_Tools_ArchiveSeeder>("writer")
.WithArgs("--connection", writerConnection, "--follow", "1", "--change-seconds", ChangeSeconds)
.WithArgs(
"--connection", writerConnection,
"--follow", "1",
"--change-seconds", ChangeSeconds,
"--opc-port", OpcPort)
.WaitForCompletion(converge);

builder.AddProject<Projects.SemiPlot_UI>("viewer")
Expand Down
4 changes: 3 additions & 1 deletion SemiPlot/SemiPlot.Tests.Integration/CoarseFlushTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ private FollowOptions Options()
TimeSpan.FromSeconds(1),
PenCount,
SeederOptions.DefaultSeed,
SeederOptions.DefaultChangeSeconds);
SeederOptions.DefaultChangeSeconds,
TimeSpan.Zero,
55875);
}

// What LayerThinner selects for one period, in the order the read returns.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Xunit;

namespace SemiPlot.Tests.Integration.DemoScada;

// Every demo-server test joins this collection so no two servers start side by side.
[CollectionDefinition(Name)]
public sealed class DemoScadaCollection
{
public const string Name = "demo-scada";
}
114 changes: 114 additions & 0 deletions SemiPlot/SemiPlot.Tests.Integration/DemoScada/DemoScadaHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Net;
using System.Net.Sockets;

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;

using Opc.Ua;

using SemiPlot.Tools.ArchiveSeeder;

namespace SemiPlot.Tests.Integration.DemoScada;

/// <summary>Starts demo servers over one temporary certificate directory and one log collector.</summary>
public sealed class DemoScadaHost : IDisposable
{
private const int StartAttempts = 3;

// 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];

private readonly ILoggerFactory _loggerFactory;

public DemoScadaHost()
{
CertificateDirectory = Path.Combine(
Path.GetTempPath(),
"SemiPlot.Tests",
$"DemoScada-{Guid.NewGuid():N}");
_loggerFactory = LoggerFactory.Create(builder => builder
.AddProvider(new FakeLoggerProvider(Logs))
.SetMinimumLevel(LogLevel.Warning));
}

/// <summary>The zone every test server archives in: no DST, and nine hours off a UTC test machine.</summary>
public static TimeZoneInfo ArchiveZone { get; } = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");

/// <summary><see cref="StartAsync"/> picks the port.</summary>
public static FollowOptions Options { get; } = new(
"Host=127.0.0.1;Database=unused",
TimeSpan.FromSeconds(1),
PenCount: 8,
Seed: 1,
ChangeSeconds: 0.5,
ArchiveLag: TimeSpan.Zero,
OpcPort: 0);

public string CertificateDirectory { get; }

public FakeLogCollector Logs { get; } = new();

/// <summary>Starts a server on a free loopback port, retrying when another process takes the port first.</summary>
public async Task<DemoScadaServer> StartAsync(FollowOptions options)
{
for (var attempt = 1; ; attempt++)
{
try
{
return await StartOnPortAsync(options with { OpcPort = FreePort() });
}
catch (SeederException exception) when (attempt < StartAttempts && IsBusyPort(exception))
{
}
}
}

private static bool IsBusyPort(SeederException exception)
{
return exception.InnerException is ServiceResultException listener
&& listener.StatusCode == StatusCodes.BadNoCommunication;
}

public Task<DemoScadaServer> StartOnPortAsync(FollowOptions options)
{
return DemoScadaServer.StartAsync(options, ArchiveZone, CertificateDirectory, _loggerFactory);
}

public static int FreePort()
{
while (true)
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();

if (!_reservedPorts.Contains(port))
{
return port;
}
}
}

/// <summary>The server's records at Warning and above so far, for an assertion's reason.</summary>
public string ServerLog()
{
var records = Logs.GetSnapshot();

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}"));
}

public void Dispose()
{
_loggerFactory.Dispose();

if (Directory.Exists(CertificateDirectory))
{
Directory.Delete(CertificateDirectory, recursive: true);
}
}
}
Loading
Loading