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
20 changes: 12 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ next start except the theme, which every running process applies live
`.editorconfig`'s style and quality analyzer rules fail `dotnet build` (`TreatWarningsAsErrors`,
`EnforceCodeStyleInBuild`) and `dotnet format SemiPlot.slnx --verify-no-changes` alike, so a
regression stops both the build and the pre-commit hook. The hook is `.githooks/pre-commit`; wire it
once per clone with `git config core.hooksPath .githooks`. Over the staged `.cs` files it runs
once per clone with `git config core.hooksPath .githooks`. It refuses a commit on `master` or `main`,
so branch first. Over the staged `.cs` files it runs
`dotnet format --verify-no-changes` and the comment linter
[terse](https://github.com/mrcsin/terse), pinned in `.config/dotnet-tools.json` and restored by the
hook itself: ASCII-only source, a `<summary>` of at most three lines, no `//` essays, banners or
Expand Down Expand Up @@ -131,7 +132,9 @@ Two projects, split on one axis: crosses a machine boundary (a socket or a conta
- `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
the next build fails with MSB3027 until it is killed. The container half is bounded at two minutes.
the next build fails with MSB3027 until it is killed. `PostgresContainerFixture` bounds the image
pull at two minutes, and the image build, the container start and the readiness wait at a second
two minutes.
- The way to hang one: build `PenCatalogueSync` over `ImmediateScheduler.Instance`. Its loop runs
through `ScheduleAsync` and waits on `Schedule(ReadInterval)` before every read, and
`ImmediateScheduler` runs each wait by sleeping on the calling thread, so `Start()` never returns. A
Expand Down Expand Up @@ -169,9 +172,9 @@ dotnet test SemiPlot.slnx --filter "FullyQualifiedName~TestMethodName"

CI has two jobs: `unit-windows` runs `SemiPlot.Tests.Unit` on `windows-latest`; `linux` builds the
solution once on `ubuntu-latest` and runs both test projects, the Docker daemon of the runner serving
the container fixture. A Windows-only API fails
the Linux leg only once a test executes the call (`CA1416` is a warning); a Windows path used as a
string does not fail it at all. The fail-only behavior is in `docs/architecture/testing-strategy.md`.
the container fixture. A call to a Windows-only API fails the build on both legs: `CA1416` is a
warning, and `TreatWarningsAsErrors` makes it an error. Behaviour that differs only at run time fails
the Linux leg once a test executes it, and a Windows path used as a string does not fail it at all.

Test traits: `[Trait("Component", "Core|UI")]`,
`[Trait("Area", "Data|Bridge|Chart|Di|Messages")]`,
Expand Down Expand Up @@ -336,8 +339,8 @@ No abbreviations in names.
a single one would pan or pick adds a marker (`ChartPressAction.AddMarker`), and one where it would
drag a marker deletes it (`ChartPressAction.DeleteMarker`,
`docs/architecture/trend-interaction.md#the-time-markers`). The bar's `IsSticky` has a single writer
(the `WindowChanged` handler refreshing from `Navigation.IsSticky`) — do not reintroduce imperative
`IsSticky =` assignments.
after the constructor seeds it (the `WindowChanged` handler refreshing from `Navigation.IsSticky`) — do
not reintroduce imperative `IsSticky =` assignments.
- A checkable menu item reads its flag `Mode=OneWay` and writes it only through the command it
invokes, so the command is the flag's single writer. A two-way `IsChecked` would make the control a
second writer and the two halves would drift. The sidebar's visibility box follows the same rule: it
Expand Down Expand Up @@ -440,4 +443,5 @@ No abbreviations in names.

This is the project overview file; do not add specifics here. See the machine-readable
architecture docs in `docs/architecture/*` (English). Plans live in `docs/plans/`
(`YYYYMMDD-<name>.md`; completed ones in `docs/plans/completed/`).
(`YYYYMMDD-<name>.md`; completed ones in `docs/plans/completed/`), multi-PR roadmaps in
`docs/plans/roadmaps/`, and deferred follow-ups in `docs/plans/backlog.md`.
3 changes: 1 addition & 2 deletions SemiPlot/SemiPlot.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
var builder = DistributedApplication.CreateBuilder(args);

// The Aspire AppHost SDK does not add a project resource's assembly as a compile reference, so the
// role names and passwords cannot come from BenchRoles.cs (SemiPlot.Tools.ArchiveSeeder) directly;
// they are repeated here, as they were in the now-deleted scripts/bench-demo.ps1.
// role names and passwords repeat BenchRoles.cs (SemiPlot.Tools.ArchiveSeeder) here.
const string StandDatabase = "semiplot_app";
const string MaintenanceDatabase = "postgres";
const string ProvisionedDatabase = "semiplot_provisioned";
Expand Down
4 changes: 2 additions & 2 deletions SemiPlot/SemiPlot.Tests.Integration/SeededArchiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ public async Task PerLayerRowCountsMatchTheGenerator()
counts.Should().Equal(_generated.Value);
}

// A missing daily partition sends rows to the catch-all instead of failing the write, so a non-empty
// tpdefault is the documented fault signal (docs/architecture/scada-archive.md#reader-hazards).
// A missing daily partition sends rows to the catch-all instead of failing the write. The seeder creates
// every partition its rows need and the bench runs no SCADA, so tpdefault holds nothing here.
[Fact]
public async Task TheDefaultPartitionIsEmpty()
{
Expand Down
5 changes: 2 additions & 3 deletions SemiPlot/SemiPlot.Tests.Unit/PartitionScriptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@

namespace SemiPlot.Tests.Unit;

// docs/architecture/scada-archive.md#database-objects for the tpYYYYmMMdDD name, #reader-hazards for
// why a missing partition matters: rows land in tpdefault, which the later slices read as a fault
// signal.
// docs/architecture/scada-archive.md#database-objects for the tpYYYYmMMdDD name; a missing partition
// sends the seeder's rows to tpdefault instead of failing the write.
[Trait("Component", "Core")]
[Trait("Area", "Data")]
[Trait("Category", "Unit")]
Expand Down
4 changes: 2 additions & 2 deletions SemiPlot/SemiPlot.Tools.ArchiveSeeder/PartitionScript.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
namespace SemiPlot.Tools.ArchiveSeeder;

// One day partition per covered day, named tpYYYYmMMdDD (docs/architecture/scada-archive.md#database-objects);
// a row with no partition lands in tpdefault, a fault signal (#reader-hazards), so the writer creates them
// all before it copies anything.
// a row with no partition lands in tpdefault instead of failing the write (#reader-hazards), so the writer
// creates them all before it copies anything.
public static class PartitionScript
{
// Invariant throughout: a machine running a non-Gregorian calendar would otherwise name the
Expand Down
8 changes: 6 additions & 2 deletions docs/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ implementation plans see `docs/plans/`.
role's contract, the configuration tables and the columns read, the provisioning states the client
must survive, retention and capacity. Installation, configuration, provisioning order and the role
definitions are owned by SemiBase (`github.com/Semiteq/SemiBase`) and cross-referenced there.
- [postgres-topology.md](./postgres-topology.md) - the map of the database: who owns and who writes
each object, the archive's layers and partitions, the classes SemiPlot reads and writes through, the
provisioning states, and the bench.
- [sources.md](./sources.md) — citation convention and the registry every factual claim resolves
against: vendor manual pages, vendor forum topics, our own measurements, our own decisions.
against: vendor manual pages, vendor forum topics, vendor support answers, our own measurements, our
own decisions.
- [bench.md](./bench.md) — the seeded PostgreSQL bench: the seeder and the demo writer, the lattice
they share, the layer thinning, the container fixture and its template-and-clone lifecycle, the
application bench recipe, and the headless render and input guards.
Expand Down Expand Up @@ -74,7 +78,7 @@ SemiBase creates `public.trends`, as `bench.md` and `postgres-instance.md` state
| Platform | .NET 10 (`net10.0`), ships on Windows, C# 14 |
| Desktop shell | Avalonia 12.0.5 (Win32 + Skia + HarfBuzz + `Semi.Avalonia`), ReactiveUI MVVM |
| Chart renderer | ScottPlot 5 (`ScottPlot.Avalonia` 5.1.59, MIT, SkiaSharp) — native control |
| Data source | One read-only Npgsql data source (a connection pool) on the Simple-Scada archive (`trends` / `messages`) — history, extent and realtime alike; a history read holds up to four pooled connections at once (`data-integration.md#operation-to-statement`). No application server, no OPC UA client, no local TCP protocol |
| Data source | One read-only Npgsql data source (a connection pool) on the Simple-Scada archive (`trends`; the role may read `messages`, and no statement does) — history, extent and realtime alike; a history read holds up to four pooled connections at once (`data-integration.md#operation-to-statement`). No application server, no OPC UA client, no local TCP protocol |
| Wide windows | The SCADA's own archive layers (`l = 1/2`); no summary tables, aggregator service, scheduler or extensions of ours (`history-read-path-evaluation.md`) |
| Retention | One depth for all archived data; coarse layers cannot outlive raw data |
| Tag names | Our own `semiplot_tags` table, one row per key SCADA writes, registered by `semiplot_register_new_pens()` — the archive stores numbers only |
Expand Down
46 changes: 27 additions & 19 deletions docs/architecture/bench.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ is remains in `scada-archive.md`; what SemiPlot reads from it remains in `data-i
| Template build, the clones, teardown | `SemiPlot.Tests.Integration` |

The archive table is the provisioner's, not this repository's: nothing here transcribes the vendor's
DDL. The seeder creates the day partitions its rows land in and nothing else, and never destroys.
An absent `public.trends` is a failure naming the provisioning that did not run; a table already
carrying rows or day partitions is refused. Only the test fixture drops databases, and only ones it
created itself.
DDL. The seeding run creates the day partitions its rows land in and nothing else, and issues no
`DROP`. An absent `public.trends` is a failure naming the provisioning that did not run; a table already
carrying rows or day partitions is refused. Two things drop databases: the test fixture, only ones it
created itself, and `converge`, which recreates the `semiplot_` database `--connection` names with
`DROP DATABASE IF EXISTS ... WITH (FORCE)` (`#the-converge-verb`).

## The standard slice

Expand Down Expand Up @@ -78,6 +79,8 @@ run.
`--follow <seconds>` runs the seeder as a demo writer: it appends to an archive somebody else seeded,
so it plants no break and fills no tag catalogue. `--end`, `--days` and `--admin-connection` are
rejected in this mode. `--pens`, `--seed` and `--change-seconds` mean what they mean in a seeding run.
`--follow` and `--change-seconds` each take a finite number of seconds above 0 and at most one day,
`FollowOptions.MaximumSeconds` (86 400), in every run.

```powershell
dotnet run --project SemiPlot/SemiPlot.Tools.ArchiveSeeder/SemiPlot.Tools.ArchiveSeeder.csproj -- `
Expand Down Expand Up @@ -165,10 +168,10 @@ reason.
The fixture builds that image in-process from the `bench/` directory copied to the output
directory, under the fixed tag `semiplot-bench:test`. Readiness is the wait strategy's `psql`
against `public.trends` over TCP — the entrypoint's temporary server listens on the unix socket only
— so a container whose provisioning did not complete never becomes ready. The pull, the build, the
start and the readiness wait share one two-minute bound,
`PostgresContainerFixture._startupBound`, so a bench that never comes up fails rather than hanging
`SemiPlot.Tests.Integration.exe`.
— so a container whose provisioning did not complete never becomes ready. The build, the start and
the readiness wait share one two-minute bound, `PostgresContainerFixture._startupBound`, so a bench that
never comes up fails rather than hanging `SemiPlot.Tests.Integration.exe`. The pull ahead of them runs
under a two-minute bound of its own, and a pull that runs out of it is reported like any failed pull.

`PostgresContainerFixture` maps the runtime's `localhost` host to `127.0.0.1` and passes any other host
through, so a remote `DOCKER_HOST` that reports a hostname is not a supported setup: the connection
Expand Down Expand Up @@ -236,14 +239,15 @@ dotnet run --project SemiPlot/SemiPlot.Tools.ArchiveSeeder -- converge `
--config-dir SemiPlot\Artifacts\dev-config
```

It waits for the admin connection up to 60 s, then unconditionally `DROP DATABASE IF EXISTS ...
WITH (FORCE)` and `CREATE DATABASE ... TEMPLATE semiplot_provisioned` against the database
`--connection` names, seeds it with `SeederOptions` at the defaults (`--change-seconds` may override
It first refuses a database `--connection` names unless the name starts with `semiplot_` and is not
`semiplot_provisioned` itself (`Converge.RequireBenchDatabase`), before it opens any connection. It then
waits for the admin connection up to 60 s, runs `DROP DATABASE IF EXISTS ... WITH (FORCE)` and
`CREATE DATABASE ... TEMPLATE semiplot_provisioned` against that database, seeds it with `SeederOptions` at the defaults (`--change-seconds` may override
the change interval; the AppHost passes the writer's 0.5 s so the seeded day and the live tail share
one density) up to `--end` or this machine's
clock, fills the catalogue through `--admin-connection` re-pointed at the stand database, and
writes `connection/connection.yaml` under `--config-dir` with the bench `semiplot` role's fixed
password.
password and a `poll_interval_ms` of 1000.

The catalogue is written once, here. `TagCatalogWriter` upserts `semiplot_tags`, inserts the group
names into `semiplot_groups` and replaces each pen's rows in `semiplot_pen_groups`, all three in one
Expand Down Expand Up @@ -286,8 +290,8 @@ construction, on a fixed host port with `isProxied: false` and one database name
| `%TEMP%\SemiPlot\ConfigFiles` | A copy of the repository's `ConfigFiles/`, `converge`'s rewritten `connection/connection.yaml` included | Swept and recreated at start, removed on `ApplicationStopping` |
| `%TEMP%\SemiPlot\Logs` | `semiplot.log`, and the `semiplot_NNN.log` files it rolls into at 5 MB | Swept and recreated at start, kept after the stand stops |

`DemoDirectories.Prepare` resolves the tracked set two levels above `builder.AppHostDirectory` and
throws `DirectoryNotFoundException` when it is absent, so a missing source fails the AppHost at once
`AppHost.cs` resolves the tracked set two levels above `builder.AppHostDirectory`, and
`DemoDirectories.Prepare` throws `DirectoryNotFoundException` when it is absent, so a missing source fails the AppHost at once
rather than producing an empty copy the viewer reports later. The AppHost passes
`%TEMP%\SemiPlot\ConfigFiles` as `--config-dir` to both `converge` and the viewer, and the log path
plus `--logging-level information` to the viewer, which needs all three keys.
Expand All @@ -300,7 +304,9 @@ removes the configuration copy when it stops (`DemoDirectories.cs`).
Serilog file sink declared `shared: true`, so deleting the directory during shutdown would race that
handle, and the log of a run that failed is the one thing worth keeping after the stand is gone. The
sweep deletes file by file and tolerates a file still held open, so a viewer that outlived its stand
cannot stop the next one from starting. `ApplicationStopping` does not fire when the AppHost is
cannot stop the next one from starting. The configuration sweep tolerates no survivor:
`DemoDirectories.RequireSwept` fails the AppHost start with an `IOException` naming every file the sweep
left, because such a file would merge with the fresh copy. `ApplicationStopping` does not fire when the AppHost is
killed rather than stopped; the sweep at the next start is the backstop for that path. A window started
from the viewer's `File` menu holds `semiplot.log` open the same way and can outlive the stand.

Expand All @@ -322,8 +328,8 @@ file for a refused login, delete the catalogue rows for an empty catalogue.
Nothing machine-dependent lives in `AppHost.cs`: the role passwords are `BenchRoles`' public
constants, the port is 55432, the configuration and log paths come from `DemoDirectories`, and the
seeder and the viewer both take the machine's own time zone. The
`Live demo` run configuration at the repository root's `.run/` is a `DotNetProject` configuration
over `SemiPlot.AppHost`, in the shape of `Debug.run.xml`.
`Live demo` run configuration at the repository root's `.run/` is an `AspireHostConfiguration` over
`SemiPlot.AppHost`'s `http` launch profile.

The launchers that start the viewer alone - `.run/Debug.run.xml` and the `.zed/` run and debug
entries - point `--config-dir` at `SemiPlot/Artifacts/dev-config` through `$PROJECT_DIR$` and
Expand All @@ -347,8 +353,10 @@ where a realised control sits in the strip, at what size and with which figures.
| `UI/Minimap/MinimapHoverTests` | headless pointer events into `MinimapView` | a hover shows a line at the pointer and its time centred under it in the label row, inside the row at either end, where the time hides only the end label it covers, also after a new extent relabels that label; the line stays inside the strip at its right edge and keeps its fraction through a resize; a newer sample relabels the time; a drag keeps the line under the pointer, and a move off the strip or an exit hides the line and the time |
| `UI/Minimap/MinimapViewTests` | a realised `MinimapView` with a published band | the strip holds the band, the marker and the hover line in that order and no baseline; the band is sized to the strip, with figures in the pen's colour that a pan leaves as they are; the end labels sit in a row under the strip, and the view keeps its height when the extent fills them |

**The render guard.** `Plot.RenderInMemory(width, height)` rasterises through SkiaSharp with no
Avalonia in the loop, so it is a plain `[Fact]`; `Plot.RenderManager.LastRender.Layout.DataRect`
**The render guard.** `Plot.GetImage(width, height)` rasterises through SkiaSharp with no Avalonia in
the loop. `LogAxisRenderTests` builds a bare `Plot` and is a plain `[Fact]`; `ChartGapRenderTests` builds
its plot through `TrendChartViewModel`, a ReactiveUI object, and runs under `[AvaloniaFact]`.
`Plot.RenderManager.LastRender.Layout.DataRect`
describes that same render and `Plot.GetPixel(Coordinates)` maps a time to its pixel column.
Detection is by colour band — a dominance test across the channels — never by exact byte, so
antialiasing and theme changes move bytes without moving the answer. A golden image would fail for
Expand Down
Loading
Loading