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
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ public void ANameTypedThenRefreshClicked_WritesBeforeTheRegistration()
}

[AvaloniaFact]
public void ANameTypedWhileARefreshRuns_ReachesNoFieldAndWritesNothing()
public async Task ANameTypedWhileARefreshRuns_ReachesNoFieldAndWritesNothing()
{
_editor.ReadResult = Result.Ok(_catalogue);
using var viewModel = _editor.EditorOver(_catalogue, _messagePanel);
Expand All @@ -736,11 +736,10 @@ public void ANameTypedWhileARefreshRuns_ReachesNoFieldAndWritesNothing()
name.IsEffectivelyEnabled.Should().BeFalse("the form waits for the rebuild");
HeadlessInput.Type(window, name, " typed during refresh");
registration.SetResult();
Dispatcher.UIThread.RunJobs();
await HeadlessWait.Until(() => name.IsEffectivelyEnabled);
HeadlessInput.Click(window, name);
HeadlessInput.Press(window, PhysicalKey.Tab);

name.IsEffectivelyEnabled.Should().BeTrue();
name.Text.Should().Be(Pressure.Name);
_editor.Calls.Should().Equal(new FakeEditorCall.RegisterNewPens(), new FakeEditorCall.Read());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ public void AFullCatalogue_RealisesOnlyTheMembershipBoxesOnScreen()
}

[AvaloniaFact]
public void ARenameTypedWhileARefreshRuns_ReachesNoFieldAndWritesNothing()
public async Task ARenameTypedWhileARefreshRuns_ReachesNoFieldAndWritesNothing()
{
_editor.ReadResult = Result.Ok(_catalogue);
using var viewModel = _editor.EditorOver(_catalogue, _messagePanel);
Expand All @@ -277,7 +277,7 @@ public void ARenameTypedWhileARefreshRuns_ReachesNoFieldAndWritesNothing()
Named<TextBox>(window, "NewGroupName").IsEffectivelyEnabled.Should().BeFalse();
HeadlessInput.Type(window, rename, " typed during refresh");
registration.SetResult();
Dispatcher.UIThread.RunJobs();
await HeadlessWait.Until(() => Named<ListBox>(window, "GroupList").IsEffectivelyEnabled);
HeadlessInput.Click(window, GroupAt(window, 1));

rename.Text.Should().Be(Spare.Name);
Expand Down
37 changes: 19 additions & 18 deletions SemiPlot/SemiPlot.UI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,27 +96,28 @@ private static void LogStartupFailure(IReadOnlyList<IError> errors)

private static void CreateLogger(string logFilePath, LogEventLevel logLevel)
{
const string ProcessIdProperty = "ProcessId";
const string Template =
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}";
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{" + ProcessIdProperty + "}] [{Level:u3}] "
+ "{SourceContext}: {Message:lj}{NewLine}{Exception}";
const long LogFileSizeLimitBytes = 5 * 1024 * 1024;

var invariant = CultureInfo.InvariantCulture;

var configuration =
new LoggerConfiguration()
.MinimumLevel.Is(logLevel)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: Template, formatProvider: invariant);

configuration = configuration.WriteTo.File(
path: logFilePath,
rollingInterval: RollingInterval.Infinite,
fileSizeLimitBytes: 5 * 1024 * 1024,
rollOnFileSizeLimit: true,
retainedFileCountLimit: 5,
shared: true,
outputTemplate: Template,
formatProvider: invariant);

Log.Logger = configuration.CreateLogger();
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Is(logLevel)
.Enrich.FromLogContext()
.Enrich.WithProperty(ProcessIdProperty, Environment.ProcessId)
.WriteTo.Console(outputTemplate: Template, formatProvider: invariant)
.WriteTo.File(
path: logFilePath,
rollingInterval: RollingInterval.Infinite,
fileSizeLimitBytes: LogFileSizeLimitBytes,
rollOnFileSizeLimit: true,
retainedFileCountLimit: 5,
shared: true,
outputTemplate: Template,
formatProvider: invariant)
.CreateLogger();
}
}
2 changes: 1 addition & 1 deletion docs/architecture/bench.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ construction, on a fixed host port with `isProxied: false` and one database name
| Path | Holds | Lifecycle |
| --- | --- | --- |
| `%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` | Swept and recreated at start, kept after the stand stops |
| `%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
Expand Down
11 changes: 11 additions & 0 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ connection never changes while a process runs.
| The pen catalogue: name, colour, style, unit, mask, groups, initial scale (the 5 s catalogue loop) | The group shown, the time window, the live-edge mode |
| The theme, applied live in every open window (`#the-live-theme`) | The current scale of each pen, and its visibility |
| The language, read at each window's start | The message panel and its entries |
| The log file `--log-file` names, each event carrying its writer's process id after the timestamp (`#command-line`) | |

`MainWindow/TrendWindow` is the window's composition and its owner. `TrendWindow.Build` takes the start
sequence's `StartupData`, the process's `InstanceLauncher` (which names the configuration directory) and the
Expand Down Expand Up @@ -702,6 +703,16 @@ level, and a loaded connection section by one naming the time zone the provider
(`data-integration.md#time-boundary`). At `information` or below those two lines are the only ones a
healthy run writes, and at `warning`, `error` or `fatal` the file stays empty until the first failure.

Several processes write one log file when their windows share `--log-file`, which every copy a window
starts does (`#another-instance`). The sink is `shared: true`: it writes each event whole under a
cross-process mutex. Each event's first line carries its writer's process id in brackets after the
timestamp, and the event's other lines, such as a stack trace, follow it unbroken, so the interleaved
events of two windows stay apart. The shared file's own length decides the 5 MB roll: a write that finds
the file full is refused, and its process moves to the newest file in the sequence (`semiplot_001.log`,
then `semiplot_002.log`). Every process moves at its first write after the file fills, so no two
processes write two files of the sequence at once; one process's events can still span two files over
time, and a reader follows that process by its id.

The process exits `0` when the main window opened and closed normally, and `1` when the start failed —
the startup failure and the fatal catch alike — so a launcher can tell one from the other.

Expand Down
14 changes: 13 additions & 1 deletion docs/architecture/testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ underneath the test. Touching a file is not crossing a boundary.
A unit test must not open a socket, read the wall clock, or depend on anything the machine resolves —
`PATH`, an installed service, a display. It runs everywhere, ungated.

The live theme's watcher is the one exception to the wall clock. Its pipeline runs on a `TestScheduler` in
The wall clock has two exceptions: the live theme's watcher and `HeadlessWait`.

The live theme's watcher is the first. Its pipeline runs on a `TestScheduler` in
`UI/Settings/AppSectionWatcherTests.cs`, but what only the operating system shows needs a real
`FileSystemWatcher` over a temporary folder and the real 300 ms quiet period: that a `File.Replace` raises
an event at all, and that `App` wires the watch on both starts. Those tests,
Expand All @@ -62,6 +64,16 @@ one of them pumps the dispatcher for one second to let a failed reload land befo
(`overview.md#the-live-theme`). A temporary folder is a file the test creates, not a machine resource, so
they stay ungated on both CI legs.

`UI/HeadlessWait.cs` is the second, and the approved wait for a hop from a pool thread back to the
dispatcher that an `[AvaloniaFact]` test cannot drive. A `ReactiveCommand` whose task finishes on the pool
reaches the view through an `AvaloniaScheduler` post, and `Dispatcher.UIThread.RunJobs()` can drain the
queue before that post lands. `HeadlessWait.Until` checks the awaited outcome every 10 ms, pumping the
dispatcher between checks, and throws after 30 s. The exception reaches no further: the clock bounds the
wait and no test asserts on elapsed time, and a pipeline that takes an `IScheduler` runs on a
`TestScheduler` instead. Its users besides the theme tests are `UI/MainWindow/MainWindowViewTests.cs`,
`UI/Startup/StartupFailureWindowTests.cs`, `UI/Settings/SettingsViewModelTests.cs`, `SettingsViewTests.cs`,
`UI/PenEditor/PenEditorViewTests.cs` and `PenGroupsViewTests.cs`.

Two pieces of state are process-global and the project runs its classes in parallel: the UI culture
(`CultureInfo.DefaultThreadCurrentUICulture` / `CurrentUICulture`) and the application's theme
variant. A class that writes either one, **and** a class that reads what either one selects (a
Expand Down
57 changes: 46 additions & 11 deletions docs/plans/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,52 @@ each is a scoped future task.
`NextHistorySequence()` counter as every gesture re-query, so both entry points draw a fresh monotonic
stamp from one unified counter — the frozen-sequence hazard no longer exists.

## Platform audit leftovers

Small cleanups outside `completed/20260929-window-per-process.md`. Each is independent; fold one into a
nearby edit.
Paths are under `SemiPlot/SemiPlot.UI/` unless they name another project.

- **Dead logarithmic scale.** `PenScaleSettings.IsLogarithmic` (`SemiPlot.Core/Trends/PenScaleSettings.cs:6`)
has no production writer and `PenScale.IsLogarithmic` (`SemiPlot.Core/Trends/PenScale.cs:9`) no reader;
the log branches of `SemiPlot.Core/Trends/PenScaleModel.cs` run only under `PenScaleModelTests`.
- **Disposed guards set inconsistently.** `ObjectDisposedException.ThrowIf` opens 11 members of
`Chart/TrendChartViewModel.cs` (`:219` to `:390`) and 2 of `Minimap/MinimapViewModel.cs` (`:84`, `:94`).
Some guard real work: `RequestInitialHistory` (`:219`) and `ApplyCatalogue` (`:231`) push into the
debouncer and the live edge. Others are missing on the paths the guarded `SetAxisLimits` (`:390`) shares:
`AutoscalePen` (`:375`), `RestoreInitialScale(int)` (`:382`) and `WidenToArchiveExtent` (`:259`) carry no
guard. Decide the guard set per member against `csharp.md`'s rule: guard only where a late call corrupts
state or leaks.
- **`ChartCursorReader` has one caller.** It wraps `CursorReadoutModel.ReadAt` for
`TrendChartViewModel.MoveCursor` alone (`Chart/TrendChartViewModel.cs:63`, `:329`).
- **Hand-rolled log-level switch.** `StartupOptions.ParseLogLevel` (`StartupOptions.cs:105-117`) restates
`Enum.TryParse<LogEventLevel>(ignoreCase: true)` plus the `info` alias. `Enum.TryParse` alone also
accepts a numeric string (`"42"` parses to an undefined level) and a comma list, which would widen the
closed list in `LoggingLevelValues` and `readme.md`; a replacement checks `Enum.IsDefined` and rejects
numeric input.
- **`ThrowIfNull` on repository-internal APIs.** `SemiPlot.Core/Trends/PenHistoryEnvelope.cs:12-15`,
`ArchiveTimeConverter.cs:13` and `RealtimePoll.cs:64-69` in `SemiPlot.DataSource.Postgres`, and the two
`PostgresDataProvider` entry points at `:80` and `:127`.
- **Two copies of the empty-reading format.** `TrendLegendRowViewModel.FormatReading`
(`Legend/TrendLegendRowViewModel.cs:65-68`) and `ChartHoverReadout.FormatValue` (`Chart/ChartHoverReadout.cs:43-48`)
both map no value to `Resources.NoValuePlaceholder` around `PenValueFormat.Format`.
- **A property read only by tests.** No production code reads `TrendChartViewModel.ScaleSettings`
(`Chart/TrendChartViewModel.cs:112`).
- **Hand-rolled pluralisation.** `PostgresConnectionLoader.Invalid`
(`SemiPlot.DataSource.Postgres/Configuration/PostgresConnectionLoader.cs:236-237`) picks "field"/"fields"
and "is"/"are" by count.
- **`throw exception;` loses the stack trace.** `ArchiveExceptionMapper.Map`
(`SemiPlot.DataSource.Postgres/ArchiveExceptionMapper.cs:28`) rethrows `OperationCanceledException` that way.
- **Double UI hop.** `MinimapViewModel.LoadExtentAsync` resumes its `await` on the UI thread its callers run
on, then posts `ApplyExtent` to the UI scheduler again (`Minimap/MinimapViewModel.cs:86-87`). Under a
`TestScheduler` no `SynchronizationContext` exists, and the `Observable.Start` at `:87` is the one route
that brings `ApplyExtent` back, so dropping `:87` is no fix: keep one of the two marshalling routes.
- **Allocation per pointer move.** Every `MoveCursor` (`Chart/TrendChartView.axaml.cs:305`) builds an array
in `ChartCursorReader.ReadAt` (`Chart/ChartCursorReader.cs:13-16`) and a dictionary in
`CursorReadoutModel.ReadAt` (`SemiPlot.Core/Trends/CursorReadoutModel.cs:9`).
- **An idle chart keeps a 400 ms timer.** The debouncer's `Sample` runs on the data scheduler for the chart's
lifetime, idle or not (`Chart/ChartHistoryRequestDebouncer.cs:55-57`, `Chart/TrendChartViewModel.cs:25`).

## Tooling

- **NU1903 advisory.** Transitive `Tmds.DBus.Protocol` 0.21.2 (pulled by Avalonia, unused on the Win32
Expand All @@ -87,17 +133,6 @@ each is a scoped future task.
Deferred out of the harness simplification (`completed/20260828-simplify-the-test-harness.md`). None is a
defect in the shipped tree; each is a scoped follow-up an audit named.

- **The stand and the fixture build the bench image differently.** `scripts/bench-demo.ps1` builds
`semiplot-bench:manual` with no build arguments, while `PostgresContainerFixture` builds its own tag with
`BASE_IMAGE` and a resolved `PROVISIONER_IMAGE` digest. The stand can therefore run a different provisioner
than the tests do, and nothing says so. Either give the script the same two arguments or state in
`docs/architecture/bench.md` that the stand deliberately tracks the floating tag.

- **The freshness bound lives in two copies.** `StaleArchiveGuard.MaximumAge` (five minutes) and the script's
`$LiveWithin` are held in sync by a comment in each file. Drift fails loud — the writer refuses and names
the script — so this is hygiene, not a hazard. It collapses to one owner only if the convergence moves into
C#, which was considered and rejected on cost; revisit only alongside that.

- **An unspent cut list, about 200 lines.** An over-engineering audit proposed more than the two cuts taken
(the teardown leak audit and the break-marker validator). Still on the table: `ProvisionerResolution` with
its staleness reason and version probe, whose whole yield is one stderr line; the outer `Result<T>` layer in
Expand Down
Loading
Loading