diff --git a/Docs/architecture/error-reporting.md b/Docs/architecture/error-reporting.md index 987a9c2c..027a496a 100644 --- a/Docs/architecture/error-reporting.md +++ b/Docs/architecture/error-reporting.md @@ -42,6 +42,14 @@ How a failed operation reaches the user. Read this before adding a new error-sur localizing path; it delegates to the raw `MessagePanelViewModel.ReportWarning(string)` member, which stays as the internal sink that accepts an already-composed string. `RowCountMismatchWarning` (the CSV row-count mismatch on file load) is the first typed warning to flow through it. +- `void ReportFailure(this MessagePanelViewModel, IError)` — the single-error twin of + `ReportFailure(IResultBase)`/`ReportWarnings`. It localizes **one** transient `IError` + (`ReasonLocalizer.Localize(error)`) into the operation slot at `Error` severity, delegating to the raw + `ReportError(string)` sink. `IError` is not `IResultBase`, so this is an unambiguous overload, not a new + name. It is the localizing path for a fault that arrives as a bare error rather than inside a `Result`: + `RecipeCoordinator.OnPlcFault` routes each `IPlcSyncService.Faults` error through it, and + `ConnectionLostError` is the first typed PLC fault to flow through it. An untyped inner still falls + through to `.Message`, so a free-text `IError` stays English. A failed operation surfaces **all** of its error messages, not just `Errors[0]`. The message *set* is the same one the log records; under `Resources.Culture = ru` the panel text is localized while the log @@ -63,12 +71,16 @@ template, formatting the structured data into it. An unmapped type falls through recurses over `CausedBy` (`IError.Reasons`), so a typed cause nested inside an untyped wrapper still localizes. -Localization is applied at exactly **three panel seams**, each of which routes its reasons through +Localization is applied at exactly **four panel seams**, each of which routes its reasons through `ReasonLocalizer.Localize`: -- `ResultReportingExtensions.ReportFailure` — the transient operation channel, error side. +- `ResultReportingExtensions.ReportFailure(IResultBase)` — the transient operation channel, error side + (the localized join of a failed `Result`'s errors). +- `ResultReportingExtensions.ReportFailure(IError)` — the transient operation channel, single-error side. + The single-`IError` twin of the `IResultBase` overload: it localizes one bare transient error (e.g. a + PLC fault off `IPlcSyncService.Faults`) rather than a whole `Result`. - `ResultReportingExtensions.ReportWarnings` — the transient operation channel, warning side (the twin - of `ReportFailure`). Distinct from `RefreshReasons`: `ReportWarnings` carries the outcome of a single + of `ReportFailure(IResultBase)`). Distinct from `RefreshReasons`: `ReportWarnings` carries the outcome of a single operation, while `RefreshReasons` rebuilds the persistent snapshot-validity list. - `MessagePanelViewModel.RefreshReasons` — the persistent validation channel (both the error and the warning branch). @@ -186,9 +198,10 @@ always English; only the panel seams localize. - **Persistent structural state** (the current recipe's validity) goes to the validation channel via `RefreshReasons`, and from there to the status-bar counts. - **PLC connection-loss / sync failures** arrive as a typed `IError` on `IPlcSyncService.Faults`, a - discrete one-shot channel. `RecipeCoordinator` bridges each fault to the operation channel via - `ReportError(error.Message)` — the message text is owned in Core. The persistent "disconnected" - label stays in the status bar. + discrete one-shot channel. `RecipeCoordinator.OnPlcFault` bridges each fault to the operation channel + via `ReportFailure(error)` (the single-`IError` seam), so a typed fault localizes while the log keeps + the English `.Message`. `ConnectionLostError` is the first typed PLC fault; an untyped fault still + falls through to its English `.Message`. The persistent "disconnected" label stays in the status bar. - **Command exceptions** (a `ReactiveCommand` fault on `ThrownExceptions`) are not `Result`-based; they route through `ReportThrownExceptions` (see below) rather than a hand-written handler. - **One deliberate exception** to the `"; "` idiom: the clipboard *paste* failure lists each error on diff --git a/SemiStep/SemiStep.Core/Plc/Sync/ConnectionLostError.cs b/SemiStep/SemiStep.Core/Plc/Sync/ConnectionLostError.cs new file mode 100644 index 00000000..a0c23f86 --- /dev/null +++ b/SemiStep/SemiStep.Core/Plc/Sync/ConnectionLostError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +namespace SemiStep.Core.Plc.Sync; + +public sealed class ConnectionLostError() + : Error("PLC connection lost") +{ +} diff --git a/SemiStep/SemiStep.Core/Plc/Sync/PlcSyncCoordinator.cs b/SemiStep/SemiStep.Core/Plc/Sync/PlcSyncCoordinator.cs index ec51c408..87d580b7 100644 --- a/SemiStep/SemiStep.Core/Plc/Sync/PlcSyncCoordinator.cs +++ b/SemiStep/SemiStep.Core/Plc/Sync/PlcSyncCoordinator.cs @@ -163,7 +163,7 @@ public void ResetForDisable() public void HandleConnectionLost() { _executor.Reset(); - EmitFault(new Error("PLC connection lost")); + EmitFault(new ConnectionLostError()); } public async Task WaitForPendingSyncAsync(CancellationToken ct = default) diff --git a/SemiStep/SemiStep.Tests/S7/PlcSyncCoordinatorTests.cs b/SemiStep/SemiStep.Tests/S7/PlcSyncCoordinatorTests.cs index ea13673e..21680bd6 100644 --- a/SemiStep/SemiStep.Tests/S7/PlcSyncCoordinatorTests.cs +++ b/SemiStep/SemiStep.Tests/S7/PlcSyncCoordinatorTests.cs @@ -238,6 +238,7 @@ public void HandleConnectionLost_EmitsExactlyOneFault() faults.Should().ContainSingle( "a runtime connection loss must emit exactly one fault on the Faults channel"); + faults[0].Should().BeOfType(); faults[0].Message.ToLowerInvariant().Should().Contain("connection"); } diff --git a/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs b/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs index 64a4c66a..9deb26ab 100644 --- a/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs +++ b/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs @@ -6,6 +6,7 @@ using FluentResults; +using SemiStep.Core.Plc.Sync; using SemiStep.Core.Plc.Sync.Ownership; using SemiStep.Core.Recipes; using SemiStep.Core.Recipes.Analysis.Warnings; @@ -29,6 +30,8 @@ public sealed class CoreErrorLocalizationCoverageTests { private static readonly IReadOnlyDictionary _typeData = new Dictionary { + [typeof(ConnectionLostError)] = + new ConnectionLostError(), [typeof(OwnedByAnotherInstanceError)] = new OwnedByAnotherInstanceError(new OwnerInfo(1, "MACHINE", "alice", DateTimeOffset.UnixEpoch)), [typeof(FormulaComputationFailedError)] = diff --git a/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs b/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs index b66cd8a2..0a1bba79 100644 --- a/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs +++ b/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs @@ -6,6 +6,7 @@ using FluentResults; +using SemiStep.Core.Plc.Sync; using SemiStep.Core.Plc.Sync.Ownership; using SemiStep.Core.Recipes; using SemiStep.Core.Recipes.Analysis.Warnings; @@ -60,6 +61,27 @@ public void Localize_OwnedByAnotherInstance_UnderEnglishCulture_MatchesOriginalM } } + [Fact] + public void Localize_ConnectionLost_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new ConnectionLostError()) + .Should().Be("Соединение с ПЛК потеряно"); + } + } + + [Fact] + public void Localize_ConnectionLost_UnderEnglishCulture_MatchesOriginalMessage() + { + var error = new ConnectionLostError(); + + using (ResourcesCultureScope.Use("en")) + { + ReasonLocalizer.Localize(error).Should().Be(error.Message); + } + } + [Fact] public void Localize_UnmatchedEndForWarning_UnderRussianCulture_RendersRussianSentence() { diff --git a/SemiStep/SemiStep.Tests/UI/RecipeCoordinatorTests.cs b/SemiStep/SemiStep.Tests/UI/RecipeCoordinatorTests.cs index 8a8e248d..feb2d16e 100644 --- a/SemiStep/SemiStep.Tests/UI/RecipeCoordinatorTests.cs +++ b/SemiStep/SemiStep.Tests/UI/RecipeCoordinatorTests.cs @@ -8,10 +8,12 @@ using SemiStep.Core.Plc.Configuration; using SemiStep.Core.Plc.State; +using SemiStep.Core.Plc.Sync; using SemiStep.Core.Recipes; using SemiStep.Tests.Core.Helpers; using SemiStep.Tests.Helpers; using SemiStep.Tests.UI.Helpers; +using SemiStep.Tests.UI.Localization; using SemiStep.UI.Coordinator; using SemiStep.UI.Localization; @@ -374,7 +376,9 @@ public void PlcFault_RoutesToMessagePanelOperationChannel() _fixture.Session.Reset(); _fixture.MessagePanel.Entries.Should().BeEmpty("a fresh empty recipe has no structural defects"); - _fixture.PlcSyncService.PushFault(new Error("PLC connection lost")); + using var cultureScope = ResourcesCultureScope.Use("en"); + + _fixture.PlcSyncService.PushFault(new ConnectionLostError()); _fixture.MessagePanel.Entries.Should().ContainSingle( "a PLC fault must surface as a transient operation message in the panel"); diff --git a/SemiStep/SemiStep.Tests/UI/ResultReportingExtensionsTests.cs b/SemiStep/SemiStep.Tests/UI/ResultReportingExtensionsTests.cs index 57833935..e1ed0bef 100644 --- a/SemiStep/SemiStep.Tests/UI/ResultReportingExtensionsTests.cs +++ b/SemiStep/SemiStep.Tests/UI/ResultReportingExtensionsTests.cs @@ -6,6 +6,7 @@ using FluentResults; +using SemiStep.Core.Plc.Sync; using SemiStep.Core.Recipes.Formulas.Errors; using SemiStep.Tests.UI.Localization; @@ -109,4 +110,25 @@ public void ReportFailure_TypedError_UnderRussianCulture_LocalizesInPanel() var entry = _panel.Entries.Should().ContainSingle(item => item.IsError).Subject; entry.Message.Should().Be("Вычисление формулы для цели «temp» не выполнено: min > max"); } + + [AvaloniaFact] + public void ReportFailure_SingleTypedError_UnderRussianCulture_LocalizesInOperationSlot() + { + using (ResourcesCultureScope.Use("ru")) + { + _panel.ReportFailure(new ConnectionLostError()); + } + + var entry = _panel.Entries.Should().ContainSingle(item => item.IsError).Subject; + entry.Message.Should().Be("Соединение с ПЛК потеряно"); + } + + [AvaloniaFact] + public void ReportFailure_SingleUntypedError_FallsThroughToItsMessage() + { + _panel.ReportFailure((IError)new Error("x")); + + var entry = _panel.Entries.Should().ContainSingle(item => item.IsError).Subject; + entry.Message.Should().Be("x"); + } } diff --git a/SemiStep/SemiStep.UI/Coordinator/RecipeCoordinator.cs b/SemiStep/SemiStep.UI/Coordinator/RecipeCoordinator.cs index 2f3326d7..63a67f9c 100644 --- a/SemiStep/SemiStep.UI/Coordinator/RecipeCoordinator.cs +++ b/SemiStep/SemiStep.UI/Coordinator/RecipeCoordinator.cs @@ -527,7 +527,7 @@ private void OnPlcFault(IError error) } _logger.LogWarning("PLC fault: {Message}", error.Message); - _messagePanel.ReportError(error.Message); + _messagePanel.ReportFailure(error); } } diff --git a/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs b/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs index 6e2e86ee..6df213d5 100644 --- a/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs +++ b/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs @@ -3,6 +3,7 @@ using FluentResults; +using SemiStep.Core.Plc.Sync; using SemiStep.Core.Plc.Sync.Ownership; using SemiStep.Core.Recipes.Analysis.Warnings; using SemiStep.Core.Recipes.Clipboard.Errors; @@ -24,6 +25,7 @@ public static string Localize(IReason reason) { var text = reason switch { + ConnectionLostError => Resources.ErrorConnectionLost, OwnedByAnotherInstanceError error => Format( Resources.ErrorOwnedByAnotherInstance, error.Holder.UserName, diff --git a/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs b/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs index 96156b8a..f0fcaed3 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs +++ b/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs @@ -294,6 +294,8 @@ public static CultureInfo? Culture public static string EditorDefaultFont => ResourceManager.GetString("EditorDefaultFont", resourceCulture) ?? string.Empty; + public static string ErrorConnectionLost => ResourceManager.GetString("ErrorConnectionLost", resourceCulture) ?? string.Empty; + public static string ErrorOwnedByAnotherInstance => ResourceManager.GetString("ErrorOwnedByAnotherInstance", resourceCulture) ?? string.Empty; public static string ErrorFormulaComputationFailed => ResourceManager.GetString("ErrorFormulaComputationFailed", resourceCulture) ?? string.Empty; diff --git a/SemiStep/SemiStep.UI/Localization/Resources.resx b/SemiStep/SemiStep.UI/Localization/Resources.resx index 63e003e2..048176a2 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.resx +++ b/SemiStep/SemiStep.UI/Localization/Resources.resx @@ -448,6 +448,9 @@ (Default) + + PLC connection lost + PLC sync is owned by another instance (user {0}, since {1:HH:mm} UTC). diff --git a/SemiStep/SemiStep.UI/Localization/Resources.ru.resx b/SemiStep/SemiStep.UI/Localization/Resources.ru.resx index d345b69f..df0348c7 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.ru.resx +++ b/SemiStep/SemiStep.UI/Localization/Resources.ru.resx @@ -448,6 +448,9 @@ (По умолчанию) + + Соединение с ПЛК потеряно + Синхронизация ПЛК занята другим экземпляром (пользователь {0}, с {1:HH:mm} UTC). diff --git a/SemiStep/SemiStep.UI/MessageService/ResultReportingExtensions.cs b/SemiStep/SemiStep.UI/MessageService/ResultReportingExtensions.cs index 43a2fbe9..16db20ec 100644 --- a/SemiStep/SemiStep.UI/MessageService/ResultReportingExtensions.cs +++ b/SemiStep/SemiStep.UI/MessageService/ResultReportingExtensions.cs @@ -22,6 +22,11 @@ public static void ReportFailure(this MessagePanelViewModel panel, IResultBase r panel.ReportError(context is null ? message : $"{context}: {message}"); } + public static void ReportFailure(this MessagePanelViewModel panel, IError error) + { + panel.ReportError(ReasonLocalizer.Localize(error)); + } + public static void ReportWarnings(this MessagePanelViewModel panel, IResultBase result) { var message = Join(result.Successes.OfType().Select(ReasonLocalizer.Localize)); diff --git a/docs/plans/completed/20260731-plc-fault-seam.md b/docs/plans/completed/20260731-plc-fault-seam.md new file mode 100644 index 00000000..a74aab1f --- /dev/null +++ b/docs/plans/completed/20260731-plc-fault-seam.md @@ -0,0 +1,99 @@ +# Slice 6a — Transient-fault localizing seam + typed PLC connection-lost + +## Overview + +The panel has localizing seams for a Result's errors (`ReportFailure(IResultBase)`) and for a Result's warnings +(`ReportWarnings(IResultBase)`, slice 5a), plus the snapshot-validity channel (`RefreshReasons`). It has **no seam for a +single transient `IError`**. The PLC fault channel carries a fully-typed `IError` end to end +(`PlcSyncCoordinator.EmitFault` → `Faults` observable → `RecipeCoordinator.OnPlcFault`), but `OnPlcFault` flattens it to +`.Message` into the raw `ReportError(string)` sink — so the PLC fault renders raw English regardless of culture, and the +one fault it carries is an untyped `new Error("PLC connection lost")` anyway. + +This slice builds the error-side twin of slice 5a's warning seam and proves it on the connection-lost fault: + +1. **`ReportFailure(this MessagePanelViewModel, IError error)`** — a new localizing overload mirroring + `ReportFailure(IResultBase)`: `panel.ReportError(ReasonLocalizer.Localize(error))`. (`IError` is not `IResultBase`, + so this is an unambiguous overload of the existing method, not a new name.) +2. **`ConnectionLostError`** (typed) replaces `PlcSyncCoordinator`'s `new Error("PLC connection lost")`. +3. `RecipeCoordinator.OnPlcFault` routes through the new seam (`_messagePanel.ReportFailure(error)`), keeping the + English `_logger.LogWarning("PLC fault: {Message}", error.Message)` (log-English invariant). + +It is the small mechanism cut of slice 6 (PLC), before the bulk PLC error typing + un-laundering (6b) and the #120 +cancellation plumbing (6c). The transient-error seam is the roadmap's missing error-side seam — the single-`IError` +localizing path that did not exist. + +**Behavior-preserving for English.** `ConnectionLostError`'s English base message equals today's exact string, and the +resx en value equals it byte-for-byte. Under `ru` the PLC fault now reads Russian. The seam localizes any `IError`: an +untyped inner still falls through to `.Message` (English), so nothing else regresses. + +**Scope guard:** only the connection-lost fault + the seam. The other PLC faults (`PlcSyncExecutor`'s recipe-active / +laundered inners), the make-public of `NotConnectedError`/`ProtocolVersionMismatchError`, `PlcCommandFailedError`, and +the `CausedBy(ex)` diagnostics are **6b**. The `IS7Reader` cancellation threading is **6c**. `RefreshReasons` and the +existing `ReportFailure(IResultBase)`/`ReportWarnings` seams are untouched. + +## Acceptance + +1. `ReportFailure(this MessagePanelViewModel, IError error)` localizes a single error via `ReasonLocalizer.Localize` and + reports it to the transient operation slot — the single-`IError` twin of `ReportFailure(IResultBase)`. +2. `ConnectionLostError` (public, PLC) carries an English base message identical to the pre-slice string; + `PlcSyncCoordinator.HandleConnectionLost` raises it instead of `new Error("PLC connection lost")`. +3. `RecipeCoordinator.OnPlcFault` routes through `ReportFailure(error)`; the connection-lost fault renders Russian under `ru`. +4. `ReasonLocalizer` localizes `ConnectionLostError`; en unchanged, ru Russian. resx parity + coverage test green. +5. `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green. + +## Task 1: Type the fault + build the seam + route OnPlcFault + +**Files:** +- Create: `SemiStep/SemiStep.Core/Plc/Sync/ConnectionLostError.cs` (public sealed, `Error` base, BOM; `OwnedByAnotherInstanceError.cs` as the typed-PLC-error precedent — this one is fieldless). +- Modify: `SemiStep/SemiStep.Core/Plc/Sync/PlcSyncCoordinator.cs` (~166 the `EmitFault(new Error("PLC connection lost"))`). +- Modify: `SemiStep/SemiStep.UI/MessageService/ResultReportingExtensions.cs` — add the `ReportFailure(IError)` overload. +- Modify: `SemiStep/SemiStep.UI/Coordinator/RecipeCoordinator.cs` (`OnPlcFault`, ~530). +- Modify: `SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs` (arm + using), `Resources.resx`, `Resources.ru.resx`, `Resources.Designer.cs` (1 key), `SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs` (1 sample). +- Modify (test fix): `SemiStep/SemiStep.Tests/UI/RecipeCoordinatorTests.cs` (~377 `PlcFault_RoutesToMessagePanelOperationChannel`). + +- [x] `public sealed class ConnectionLostError() : Error("PLC connection lost")` (fieldless — mirror the leaf-error precedent; primary ctor). Namespace `SemiStep.Core.Plc.Sync` (alongside `PlcSyncCoordinator`). `using FluentResults;` for the `Error` base. English base message byte-identical. BOM. (The coverage-test sample also needs `using SemiStep.Core.Plc.Sync;`.) +- [x] `PlcSyncCoordinator.cs`: `EmitFault(new Error("PLC connection lost"))` → `EmitFault(new ConnectionLostError())` (same namespace, no using needed; KEEP `using FluentResults;` — the file uses `IError`/`Subject` throughout). +- [x] Pin the type at the source: strengthen `PlcSyncCoordinatorTests.HandleConnectionLost_EmitsExactlyOneFault` (~:241) to also assert `faults[0].Should().BeOfType()` (currently asserts only `.Message` contains "connection", which survives byte-identical) — so a regression away from the typed fault fails at the emit source. Add the `using SemiStep.Core.Plc.Sync;`. +- [x] `ResultReportingExtensions.cs`: add `public static void ReportFailure(this MessagePanelViewModel panel, IError error) => panel.ReportError(ReasonLocalizer.Localize(error));` (or a block body). Overload of the existing `ReportFailure(IResultBase, string?)`. `IError` is `FluentResults` — the using is already present. +- [x] `RecipeCoordinator.OnPlcFault`: `_messagePanel.ReportError(error.Message)` → `_messagePanel.ReportFailure(error)`. KEEP the `_logger.LogWarning("PLC fault: {Message}", error.Message)` line (English log). Confirm `SemiStep.UI.MessageService` using is present (ReportFailure is already used elsewhere in this file). +- [x] `ReasonLocalizer`: arm `ConnectionLostError => Resources.ErrorConnectionLost` (bare, no fields) + `using SemiStep.Core.Plc.Sync;`. +- [x] resx: `ErrorConnectionLost` en `PLC connection lost` (== baked) + ru — match the existing PLC terminology in the ru resx (check how `ErrorOwnedByAnotherInstance` renders "PLC"; use the same term — likely `ПЛК`, e.g. `Соединение с ПЛК потеряно`) + hand-written Designer accessor. Coverage: 1 sample. New error file BOM; Designer/resx BOM as-is. +- [x] **Fix the routing test.** `RecipeCoordinatorTests.cs` `PlcFault_RoutesToMessagePanelOperationChannel` (~377) pushes `new Error("PLC connection lost")` and asserts `.Message.Should().Be("PLC connection lost")`. Update `PushFault(new ConnectionLostError())` so it matches production. Its real contract is "a PLC fault routes to the operation channel as one Error-severity entry" — keep `ContainSingle` + `Severity == Error` + `ErrorCount == 0`. For the text, **prefer** wrapping in `ResourcesCultureScope.Use("en")` and keeping `.Be("PLC connection lost")` — that keeps the exact-text guard on the byte-identical English contract (dropping the assert loses a guard for free). Add the usings `SemiStep.Core.Plc.Sync;` and `SemiStep.Tests.UI.Localization;`. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green. + +## Task 2: Cover the seam + doc + verify + +**Files:** `ReasonLocalizerTests.cs`, a MessagePanel/report test home, `Docs/architecture/error-reporting.md`. + +- [x] `ReasonLocalizerTests`: ru render case `new ConnectionLostError()` → the ru string (under `ResourcesCultureScope.Use("ru")`) + an en `Localize(sample).Should().Be(sample.Message)` pin (under its OWN `ResourcesCultureScope.Use("en")` — with `Resources.Culture` null the bare arm returns the ru satellite on this machine). +- [x] **Seam unit cases** in `SemiStep/SemiStep.Tests/UI/ResultReportingExtensionsTests.cs` (the home of the existing `ReportFailure(result)` cases): `panel.ReportFailure(new ConnectionLostError())` under `ResourcesCultureScope.Use("ru")` puts the Russian text in the transient operation slot (Severity=Error); an untyped-fallthrough case `panel.ReportFailure((IError)new Error("x"))` reports `"x"` (an untyped error localizes to its `.Message`). If a panel-level end-to-end fits better, `MessagePanelReportingTests.cs` is the panel-transient home. +- [x] fragment sweep: grep the Tests tree for `PLC connection lost` — confirm only the now-updated `RecipeCoordinatorTests` fault test references it, and it is culture-proof. +- [x] `Docs/architecture/error-reporting.md`: document the transient single-error seam — `ReportFailure(IError)` is the single-error twin of `ReportFailure(IResultBase)`/`ReportWarnings`, localizing one transient error into the operation slot; note `OnPlcFault` now uses it and `ConnectionLostError` is the first typed PLC fault. +- [x] full `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format`. + +## Post-Completion + +**Next (slice 6b):** the bulk PLC error-pipe work — make `NotConnectedError`/`ProtocolVersionMismatchError` public +(promote `Expected`/`Actual` to properties for the localizer) + arms + resx; new `RecipeActiveError` (`PlcSyncExecutor`'s +recipe-active fault) and `PlcCommandFailedError` envelope; **un-launder** `PlcSyncExecutor:205/212/235` (forward the +typed inner `NotConnectedError` instead of re-wrapping `.Message`); and #120 part 2 — the `PlcTransactionExecutor` +`Result.Fail(ex.Message)` catches (105/191/232/267/301) + `PlcLifecycleManager:142` become `PlcCommandFailedError(...) +.CausedBy(ex)` so the exception type+stack reach the log. Then **6c** (#120 part 1) — thread a `CancellationToken` +through `IS7Reader` → `S7Service` → `PlcTransactionExecutor`, dropping the manual `IsCancellationRequested` polling in +`PlcLifecycleManager.PerformReconnectReconciliationAsync`. After 6b/6c the PLC surface localizes and the config-load +boundary is the last English-by-design surface; slice 7 (style-editor) closes the panel-seam gaps. + +--- + +**Executed by exec:** +- branch: plc-fault-seam +- commits: d3a86db (ConnectionLostError + ReportFailure(IError) seam + route OnPlcFault + arm/resx + test fixes) · 0b1bd01 (seam/render tests + doc) · 9d2af24 (review-1: doc overload qualifier) · c99814c (smells: leaf-error brace style) +- review chain: comprehensive (5 agents, all OUTCOME ACHIEVED) → fixer 9d2af24 (INFO doc) → smells → fixer c99814c (MINOR brace style) → comment audit (Ship) → critical (satisfied by comprehensive; doc/cosmetic delta since). codex skipped (not installed). + +## Verify it yourself +1. `dotnet build SemiStep.slnx` — 0 warnings. +2. `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj` — 1643 passed, 0 failed. +3. The fault seam localizes: `--filter "FullyQualifiedName~ResultReportingExtensions|FullyQualifiedName~ReasonLocalizer|FullyQualifiedName~CoreErrorLocalizationCoverage"` — `ReportFailure(new ConnectionLostError())` under ru puts `Соединение с ПЛК потеряно` in the transient operation slot (Severity=Error); an untyped `IError` falls through to its `.Message`; coverage forces the case. +4. Routed + pinned at the source: `--filter "FullyQualifiedName~PlcSyncCoordinator|FullyQualifiedName~RecipeCoordinatorTests"` — `HandleConnectionLost` emits a `ConnectionLostError` (BeOfType pin); `PlcFault_RoutesToMessagePanelOperationChannel` shows it as one Error entry (en-scoped: "PLC connection lost"). Pre-slice the fault flattened to `ReportError(string)` with no localization. +5. English preserved: `ConnectionLostError.Message` == resx en byte-identical; `OnPlcFault` keeps the English `_logger.LogWarning("PLC fault: {Message}", ...)`. +6. Manual (optional): under a Russian UI, drop the PLC connection — the fault shows in Russian in the operation channel.