From 00feca767f0bdd8f19881f667fc3034ddbceb0bc Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:47:18 +0300 Subject: [PATCH] feat(l10n): localize PLC I/O errors by type (#120) The PLC producer surface stayed English after slice 6a typed only the connection-lost fault. NotConnectedError and ProtocolVersionMismatchError were internal (invisible to the localizer); the recipe-active, write-verification and not-committed faults were free-text new Error(...) / Result.Fail(string); and PlcSyncExecutor laundered typed inners by re-wrapping their .Message into a fresh untyped Error before emitting the fault, discarding the type so the localizing seam could not reach it. On the diagnostics side, PlcTransactionExecutor's five catch { return Result.Fail(ex.Message); } sites kept only the message string -- the exception type and stack were gone before any log sink (issue #120, second half). Type the PLC producer surface, un-launder the fault forwarding, and land the exception in a log: - Make NotConnectedError (fieldless) and ProtocolVersionMismatchError (with Expected/Actual) public; add the leaves RecipeActiveError, WriteVerificationFailedError(attempts), RecipeNotCommittedError, and the Rule-B envelope PlcCommandFailedError -- each with a ReasonLocalizer arm, en/ru resx, and a coverage sample. Six typed faults now localize. - Un-launder PlcSyncExecutor: forward the typed inner directly (reportFault(Errors[0]), return activeResult.ToResult()) so it localizes through the 6a seam; type the recipe-active and exception-stringify sites. - Diagnostics: the five PlcTransactionExecutor catches now return PlcCommandFailedError().CausedBy(ex) and log the exception once -- writes at Error, reads at Warning. The redundant PlcExecutionMonitor poll-error line is guarded to skip PlcCommandFailedError (already logged at the catch) while still surfacing the decode-Fail path, keeping exactly-once logging with no silenced failure. English is behavior-preserving except PlcCommandFailedError, which replaces the raw ex.Message headline with a localized "PLC command failed" and keeps the exception on CausedBy plus the log. Cancellation threading (issue #120, first half) and the two remaining English producers (short protocol-version read; enable catch) are deferred to the next slice. Co-Authored-By: Claude Opus 4.8 (1M context) --- Docs/architecture/error-reporting.md | 45 ++++- .../Plc/S7/Protocol/NotConnectedError.cs | 5 +- .../Protocol/ProtocolVersionMismatchError.cs | 9 +- .../Plc/Sync/PlcCommandFailedError.cs | 8 + .../Plc/Sync/PlcExecutionMonitor.cs | 7 +- .../SemiStep.Core/Plc/Sync/PlcSyncExecutor.cs | 20 +-- .../Plc/Sync/PlcTransactionExecutor.cs | 27 +-- .../Plc/Sync/RecipeActiveError.cs | 8 + .../Plc/Sync/RecipeNotCommittedError.cs | 8 + .../Plc/Sync/WriteVerificationFailedError.cs | 9 + .../S7/PlcSyncCoordinatorTests.cs | 45 ++++- .../S7/PlcTransactionExecutorTests.cs | 16 ++ .../CoreErrorLocalizationCoverageTests.cs | 13 ++ .../UI/Localization/ReasonLocalizerTests.cs | 83 +++++++++ .../UI/ResultReportingExtensionsTests.cs | 37 ++++ .../Localization/ReasonLocalizer.cs | 9 + .../Localization/Resources.Designer.cs | 12 ++ .../SemiStep.UI/Localization/Resources.resx | 18 ++ .../Localization/Resources.ru.resx | 18 ++ .../completed/20260731-plc-errors-typed.md | 166 ++++++++++++++++++ 20 files changed, 530 insertions(+), 33 deletions(-) create mode 100644 SemiStep/SemiStep.Core/Plc/Sync/PlcCommandFailedError.cs create mode 100644 SemiStep/SemiStep.Core/Plc/Sync/RecipeActiveError.cs create mode 100644 SemiStep/SemiStep.Core/Plc/Sync/RecipeNotCommittedError.cs create mode 100644 SemiStep/SemiStep.Core/Plc/Sync/WriteVerificationFailedError.cs create mode 100644 docs/plans/completed/20260731-plc-errors-typed.md diff --git a/Docs/architecture/error-reporting.md b/Docs/architecture/error-reporting.md index 027a496a..67eb9456 100644 --- a/Docs/architecture/error-reporting.md +++ b/Docs/architecture/error-reporting.md @@ -162,9 +162,44 @@ survives in the log through `CsvService`'s existing `_logger.LogWarning(..., ex. coordinator log joiner reads only the top-level `.Message` and does not walk `CausedBy`, so the localized headline is what the operator reads while the exception rides the cause for future consumers). -Still free-text (deferred to a later wave): PLC, the style-editor, and the five formula-internal free-text inners (null expression, +Still free-text (deferred to a later wave): the style-editor and the five formula-internal free-text inners (null expression, evaluation exception, non-finite, Int32/float overflow), which stay English under `ru` because their inner -is wrapped in a plain `new Error(text)`. +is wrapped in a plain `new Error(text)`. (PLC is now typed — see "PLC faults localize by type" below — +except the two deliberately-deferred producers noted there.) + +### PLC faults localize by type + +The PLC operator-facing producer surface now raises **typed** errors, so a sync failure localizes by type +on the fault channel the same way recipe failures do on the panel. Seven typed faults cover it: +`ConnectionLostError` and `NotConnectedError` (connection), `ProtocolVersionMismatchError` (version), +`RecipeActiveError` (a sync attempted while the PLC executes the recipe), `WriteVerificationFailedError(attempts)` +(the write-back read-verify mismatch), `RecipeNotCommittedError` (the managing-area commit check), and the +Rule-B envelope `PlcCommandFailedError`. `NotConnectedError`/`ProtocolVersionMismatchError` live in +`SemiStep.Core/Plc/S7/Protocol/`; the five sync-side faults in `SemiStep.Core/Plc/Sync/`. + +**Un-laundering the fault seam.** `PlcSyncExecutor` used to re-wrap a typed inner's `.Message` in a fresh +`new Error(...)` before emitting the fault, discarding the type so `OnPlcFault`'s single-`IError` seam could +not localize it. It now forwards the typed inner directly (`reportFault(activeResult.Errors[0])`, +`reportFault(writeResult.Errors[0])`), so the type reaches `ReasonLocalizer` and the fault renders in the +current culture. The 6a fault seam is single-error by design: forwarding `Errors[0]` drops any sibling +errors, which the transient one-slot operation channel could not show anyway. + +**Rule-B envelope.** `PlcCommandFailedError` is a fieldless headline (`"PLC command failed"`): the raw +transport exception message (a socket/timeout string) is not operator-useful, so it rides `.CausedBy(ex)` +into the result chain and a `_logger.Log(ex, …)` line at the catch (writes at `LogError`, reads at +`LogWarning`) into the diagnostics log, while the localized headline is what the operator reads. Same shape +as `RecipeLoadFailedError`. `PlcTransactionExecutor`'s five read/write catches raise it; the exception type +survives on `Reasons` for future consumers. + +**Producers that stay English by design (deferred):** `PlcTransactionExecutor`'s short +protocol-version read (`"…returned {n} bytes, expected {m}"`) is a malformed-wire diagnostic edge, not an +operator-actionable fault; `PlcLifecycleManager`'s enable catch is a connect/cancellation concern that +belongs with the cancellation pass (slice 6c). A third: the codec decode failures (`ExecutionStateCodec` +and `ManagingAreaCodec` return a plain `new Error("…data length …")` on a short read) are still untyped, so +when `PlcSyncExecutor` forwards them through `reportFault(activeResult.Errors[0])` a decode-Fail reaches the +panel as raw English. This is a malformed-wire diagnostic edge like the short-version read, not an +operator-actionable fault; typing the codec layer is out of scope for this wave. All three stay free-text +until then. ### The published rule (public error surface) @@ -200,8 +235,10 @@ always English; only the panel seams localize. - **PLC connection-loss / sync failures** arrive as a typed `IError` on `IPlcSyncService.Faults`, a 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. + the English `.Message`. The whole PLC operator-facing fault surface is now typed (connection, version, + recipe-active, write-verification, not-committed, command — see "PLC faults localize by type"); 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/S7/Protocol/NotConnectedError.cs b/SemiStep/SemiStep.Core/Plc/S7/Protocol/NotConnectedError.cs index 139a7734..a25f2569 100644 --- a/SemiStep/SemiStep.Core/Plc/S7/Protocol/NotConnectedError.cs +++ b/SemiStep/SemiStep.Core/Plc/S7/Protocol/NotConnectedError.cs @@ -2,4 +2,7 @@ namespace SemiStep.Core.Plc.S7.Protocol; -internal sealed class NotConnectedError(string message) : Error(message); +public sealed class NotConnectedError() + : Error("Not connected to PLC") +{ +} diff --git a/SemiStep/SemiStep.Core/Plc/S7/Protocol/ProtocolVersionMismatchError.cs b/SemiStep/SemiStep.Core/Plc/S7/Protocol/ProtocolVersionMismatchError.cs index 9cb20f4d..95773aa3 100644 --- a/SemiStep/SemiStep.Core/Plc/S7/Protocol/ProtocolVersionMismatchError.cs +++ b/SemiStep/SemiStep.Core/Plc/S7/Protocol/ProtocolVersionMismatchError.cs @@ -2,5 +2,10 @@ namespace SemiStep.Core.Plc.S7.Protocol; -internal sealed class ProtocolVersionMismatchError(int expected, int actual) - : Error($"PLC protocol version {actual} does not match expected {expected}"); +public sealed class ProtocolVersionMismatchError(int expected, int actual) + : Error($"PLC protocol version {actual} does not match expected {expected}") +{ + public int Expected { get; } = expected; + + public int Actual { get; } = actual; +} diff --git a/SemiStep/SemiStep.Core/Plc/Sync/PlcCommandFailedError.cs b/SemiStep/SemiStep.Core/Plc/Sync/PlcCommandFailedError.cs new file mode 100644 index 00000000..d0f0b083 --- /dev/null +++ b/SemiStep/SemiStep.Core/Plc/Sync/PlcCommandFailedError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +namespace SemiStep.Core.Plc.Sync; + +public sealed class PlcCommandFailedError() + : Error("PLC command failed") +{ +} diff --git a/SemiStep/SemiStep.Core/Plc/Sync/PlcExecutionMonitor.cs b/SemiStep/SemiStep.Core/Plc/Sync/PlcExecutionMonitor.cs index 700391f9..7d16f554 100644 --- a/SemiStep/SemiStep.Core/Plc/Sync/PlcExecutionMonitor.cs +++ b/SemiStep/SemiStep.Core/Plc/Sync/PlcExecutionMonitor.cs @@ -117,7 +117,12 @@ private async Task PollLoopAsync(CancellationToken ct) return; } - _logger.LogWarning("Execution monitor poll error: {Message}", result.Errors[0].Message); + if (!result.Errors.OfType().Any()) + { + _logger.LogWarning( + "Execution monitor poll error: {Message}", result.Errors[0].Message); + } + continue; } diff --git a/SemiStep/SemiStep.Core/Plc/Sync/PlcSyncExecutor.cs b/SemiStep/SemiStep.Core/Plc/Sync/PlcSyncExecutor.cs index e2fd2c64..fe3ae339 100644 --- a/SemiStep/SemiStep.Core/Plc/Sync/PlcSyncExecutor.cs +++ b/SemiStep/SemiStep.Core/Plc/Sync/PlcSyncExecutor.cs @@ -152,7 +152,7 @@ private void StartDebounce() { _logger.LogError(ex, "Unhandled exception in sync task"); setStatus(PlcSyncStatus.Failed); - reportFault(new Error(ex.Message)); + reportFault(new PlcCommandFailedError().CausedBy(ex)); } }, ct); } @@ -191,34 +191,31 @@ private async Task CheckCanSyncAsync(CancellationToken ct) { _logger.LogDebug("Skipping sync: not connected to PLC"); - return Result.Fail("Not connected"); + return Result.Fail(new NotConnectedError()); } var activeResult = await transactionExecutor.IsRecipeActiveAsync(ct); if (activeResult.IsFailed) { var isDisconnected = activeResult.Errors.OfType().Any(); - var faultMessage = isDisconnected - ? "Not connected to PLC" - : activeResult.Errors[0].Message; setStatus(PlcSyncStatus.Failed); - reportFault(new Error(faultMessage)); + reportFault(activeResult.Errors[0]); if (isDisconnected) { _logger.LogWarning("Sync blocked: not connected to PLC"); } - return Result.Fail(activeResult.Errors[0].Message); + return activeResult.ToResult(); } if (activeResult.Value) { setStatus(PlcSyncStatus.Failed); - reportFault(new Error("Recipe is being executed on PLC")); + reportFault(new RecipeActiveError()); _logger.LogWarning("Sync blocked: recipe is being executed on PLC"); - return Result.Fail("Recipe active"); + return Result.Fail(new RecipeActiveError()); } return Result.Ok(); @@ -232,8 +229,9 @@ private async Task WriteSyncAsync(Recipe recipe, CancellationToken ct) if (writeResult.IsFailed) { setStatus(PlcSyncStatus.Failed); - reportFault(new Error(writeResult.Errors[0].Message)); - if (!writeResult.Errors.OfType().Any()) + reportFault(writeResult.Errors[0]); + if (!writeResult.Errors.OfType().Any() + && !writeResult.Errors.OfType().Any()) { _logger.LogError("Sync failed: {Message}", writeResult.Errors[0].Message); } diff --git a/SemiStep/SemiStep.Core/Plc/Sync/PlcTransactionExecutor.cs b/SemiStep/SemiStep.Core/Plc/Sync/PlcTransactionExecutor.cs index a53e6fdd..255857cf 100644 --- a/SemiStep/SemiStep.Core/Plc/Sync/PlcTransactionExecutor.cs +++ b/SemiStep/SemiStep.Core/Plc/Sync/PlcTransactionExecutor.cs @@ -60,7 +60,7 @@ public async Task> ReadRecipeDataAsync(CancellationToken c { if (!_transport.IsConnected) { - return Result.Fail(new NotConnectedError("Not connected to PLC")); + return Result.Fail(new NotConnectedError()); } try @@ -102,7 +102,8 @@ public async Task> ReadRecipeDataAsync(CancellationToken c } catch (Exception ex) when (ex is not OperationCanceledException) { - return Result.Fail(ex.Message); + _logger.LogWarning(ex, "PLC recipe data read failed"); + return Result.Fail(new PlcCommandFailedError().CausedBy(ex)); } } @@ -110,7 +111,7 @@ public async Task WriteRecipeWithRetryAsync(Recipe recipe, CancellationT { if (!_transport.IsConnected) { - return Result.Fail(new NotConnectedError("Not connected to PLC")); + return Result.Fail(new NotConnectedError()); } var dataResult = _converter.FromRecipe(recipe); @@ -152,7 +153,7 @@ public async Task WriteRecipeWithRetryAsync(Recipe recipe, CancellationT } return Result.Fail( - $"Recipe write verification failed after {_protocolSettings.MaxRetryAttempts} attempts"); + new WriteVerificationFailedError(_protocolSettings.MaxRetryAttempts)); } public Task> ReadManagingAreaAsync(CancellationToken ct = default) @@ -168,7 +169,7 @@ public async Task> ReadProtocolVersionAsync(CancellationToken ct = d { if (!_transport.IsConnected) { - return Result.Fail(new NotConnectedError("Not connected to PLC")); + return Result.Fail(new NotConnectedError()); } try @@ -188,7 +189,8 @@ public async Task> ReadProtocolVersionAsync(CancellationToken ct = d } catch (Exception ex) when (ex is not OperationCanceledException) { - return Result.Fail(ex.Message); + _logger.LogWarning(ex, "PLC protocol version read failed"); + return Result.Fail(new PlcCommandFailedError().CausedBy(ex)); } } @@ -202,7 +204,7 @@ public async Task> ReadRecipeFromPlcAsync(CancellationToken ct = if (!managingAreaResult.Value.Committed) { - return Result.Fail("Recipe not committed on PLC"); + return Result.Fail(new RecipeNotCommittedError()); } var recipeDataResult = await ReadRecipeDataAsync(ct); @@ -219,7 +221,7 @@ private async Task> ReadAndDecodeAsync( { if (!_transport.IsConnected) { - return Result.Fail(new NotConnectedError("Not connected to PLC")); + return Result.Fail(new NotConnectedError()); } try @@ -229,7 +231,8 @@ private async Task> ReadAndDecodeAsync( } catch (Exception ex) when (ex is not OperationCanceledException) { - return Result.Fail(ex.Message); + _logger.LogWarning(ex, "PLC read/decode failed for DB {DbNumber}", dbNumber); + return Result.Fail(new PlcCommandFailedError().CausedBy(ex)); } } @@ -264,7 +267,8 @@ private async Task WriteRecipeDataAsync(PlcRecipeData data, Cancellation } catch (Exception ex) when (ex is not OperationCanceledException) { - return Result.Fail(ex.Message); + _logger.LogError(ex, "PLC recipe data write failed"); + return Result.Fail(new PlcCommandFailedError().CausedBy(ex)); } } @@ -298,7 +302,8 @@ private async Task WriteManagingAreaAsync(ManagingAreaPcData data, Cance } catch (Exception ex) when (ex is not OperationCanceledException) { - return Result.Fail(ex.Message); + _logger.LogError(ex, "PLC managing-area write failed"); + return Result.Fail(new PlcCommandFailedError().CausedBy(ex)); } } } diff --git a/SemiStep/SemiStep.Core/Plc/Sync/RecipeActiveError.cs b/SemiStep/SemiStep.Core/Plc/Sync/RecipeActiveError.cs new file mode 100644 index 00000000..227a7ede --- /dev/null +++ b/SemiStep/SemiStep.Core/Plc/Sync/RecipeActiveError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +namespace SemiStep.Core.Plc.Sync; + +public sealed class RecipeActiveError() + : Error("Recipe is being executed on PLC") +{ +} diff --git a/SemiStep/SemiStep.Core/Plc/Sync/RecipeNotCommittedError.cs b/SemiStep/SemiStep.Core/Plc/Sync/RecipeNotCommittedError.cs new file mode 100644 index 00000000..187637bd --- /dev/null +++ b/SemiStep/SemiStep.Core/Plc/Sync/RecipeNotCommittedError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +namespace SemiStep.Core.Plc.Sync; + +public sealed class RecipeNotCommittedError() + : Error("Recipe not committed on PLC") +{ +} diff --git a/SemiStep/SemiStep.Core/Plc/Sync/WriteVerificationFailedError.cs b/SemiStep/SemiStep.Core/Plc/Sync/WriteVerificationFailedError.cs new file mode 100644 index 00000000..24792d1c --- /dev/null +++ b/SemiStep/SemiStep.Core/Plc/Sync/WriteVerificationFailedError.cs @@ -0,0 +1,9 @@ +using FluentResults; + +namespace SemiStep.Core.Plc.Sync; + +public sealed class WriteVerificationFailedError(int attempts) + : Error($"Recipe write verification failed after {attempts} attempts") +{ + public int Attempts { get; } = attempts; +} diff --git a/SemiStep/SemiStep.Tests/S7/PlcSyncCoordinatorTests.cs b/SemiStep/SemiStep.Tests/S7/PlcSyncCoordinatorTests.cs index 21680bd6..5797d1e2 100644 --- a/SemiStep/SemiStep.Tests/S7/PlcSyncCoordinatorTests.cs +++ b/SemiStep/SemiStep.Tests/S7/PlcSyncCoordinatorTests.cs @@ -320,7 +320,7 @@ public async Task NotifyRecipeChanged_IsValidTrue_Disconnected_DebounceAbortLeav } [Fact] - public async Task SyncWriteFailure_EmitsFaultCarryingMessage() + public async Task SyncWriteFailure_EmitsTypedFaultCarryingException() { var (coordinator, transport, _) = Build(connected: true); const string FailureMessage = "transport write blew up"; @@ -344,8 +344,47 @@ await TestHelpers.WaitUntilAsync( cancellationToken: TestContext.Current.CancellationToken); faults.Should().ContainSingle("a sync-write failure must emit exactly one fault"); - faults[0].Message.Should().Be(FailureMessage, - "the fault must carry the write-failure message so the user sees the cause"); + faults[0].Should().BeOfType( + "the fault must carry the typed envelope so the panel localizes it by type"); + faults[0].Reasons.OfType() + .Should().ContainSingle("CausedBy must retain the underlying exception in the result chain") + .Which.Exception.Message.Should().Be(FailureMessage, + "the original exception rides CausedBy for the diagnostics log"); + coordinator.Status.Should().Be(PlcSyncStatus.Failed); + } + + [Fact] + public async Task SyncWhenRecipeActiveOnPlc_EmitsTypedRecipeActiveFault() + { + var (coordinator, transport, _) = Build(connected: true); + + var layout = BuildTestConfiguration().Layout; + + // Drive IsRecipeActiveAsync true: the execution-state DB read reports the recipe as active. + transport.SetReadResponseForDb(layout.ExecutionDb.DbNumber, (_, count) => + { + var bytes = new byte[count]; + bytes[layout.ExecutionDb.RecipeActiveOffset] = 1; + return bytes; + }); + + var faults = new List(); + using var subscription = coordinator.Faults.Subscribe(faults.Add); + + coordinator.NotifyRecipeChanged(Recipe.Empty, isValid: true); + await coordinator.WaitForPendingSyncAsync(TestContext.Current.CancellationToken); + + await TestHelpers.WaitUntilAsync( + () => faults.Count > 0, + timeout: TimeSpan.FromMilliseconds(2500), + pollInterval: TimeSpan.FromMilliseconds(20), + cancellationToken: TestContext.Current.CancellationToken); + + faults.Should().ContainSingle("a sync blocked by an active recipe must emit exactly one fault"); + faults[0].Should().BeOfType( + "the fault must carry the typed error so the panel localizes it by type"); + transport.WriteLog.Should().BeEmpty( + "no write may reach the PLC while a recipe is active"); coordinator.Status.Should().Be(PlcSyncStatus.Failed); } } diff --git a/SemiStep/SemiStep.Tests/S7/PlcTransactionExecutorTests.cs b/SemiStep/SemiStep.Tests/S7/PlcTransactionExecutorTests.cs index a8918416..05b546c9 100644 --- a/SemiStep/SemiStep.Tests/S7/PlcTransactionExecutorTests.cs +++ b/SemiStep/SemiStep.Tests/S7/PlcTransactionExecutorTests.cs @@ -223,6 +223,8 @@ public async Task WriteRecipeWithRetryAsync_VerificationMismatch_RetriesUpToMaxA result.IsFailed.Should().BeTrue( "after exhausting all retry attempts the result must be failed"); + result.HasError().Should().BeTrue( + "the failure reason must be a typed WriteVerificationFailedError"); result.Errors.Should().ContainSingle(e => e.Message.Contains("verification failed", StringComparison.OrdinalIgnoreCase)); } @@ -269,6 +271,20 @@ public async Task WriteRecipeWithRetryAsync_NotConnected_ReturnsFailedResultWith "the failure reason must be a NotConnectedError"); } + [Fact] + public async Task ReadRecipeFromPlcAsync_ManagingAreaNotCommitted_ReturnsRecipeNotCommittedError() + { + var (executor, _) = BuildExecutor(); + + // The default transport returns all-zero managing-area bytes, so Committed decodes to false. + var result = await executor.ReadRecipeFromPlcAsync(TestContext.Current.CancellationToken); + + result.IsFailed.Should().BeTrue( + "reading a recipe the PLC has not committed must fail"); + result.HasError().Should().BeTrue( + "the failure reason must be a typed RecipeNotCommittedError"); + } + [Fact] public async Task ReadProtocolVersionAsync_ReturnsBigEndianValueAtVersionOffset() { diff --git a/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs b/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs index 9deb26ab..f2f80b4f 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.S7.Protocol; using SemiStep.Core.Plc.Sync; using SemiStep.Core.Plc.Sync.Ownership; using SemiStep.Core.Recipes; @@ -32,6 +33,18 @@ public sealed class CoreErrorLocalizationCoverageTests { [typeof(ConnectionLostError)] = new ConnectionLostError(), + [typeof(NotConnectedError)] = + new NotConnectedError(), + [typeof(ProtocolVersionMismatchError)] = + new ProtocolVersionMismatchError(2, 1), + [typeof(RecipeActiveError)] = + new RecipeActiveError(), + [typeof(RecipeNotCommittedError)] = + new RecipeNotCommittedError(), + [typeof(WriteVerificationFailedError)] = + new WriteVerificationFailedError(3), + [typeof(PlcCommandFailedError)] = + new PlcCommandFailedError(), [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 0a1bba79..17a55e0f 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.S7.Protocol; using SemiStep.Core.Plc.Sync; using SemiStep.Core.Plc.Sync.Ownership; using SemiStep.Core.Recipes; @@ -82,6 +83,88 @@ public void Localize_ConnectionLost_UnderEnglishCulture_MatchesOriginalMessage() } } + [Fact] + public void Localize_NotConnected_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new NotConnectedError()) + .Should().Be("Нет соединения с ПЛК"); + } + } + + [Fact] + public void Localize_ProtocolVersionMismatch_UnderRussianCulture_ComposesActualAndExpected() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new ProtocolVersionMismatchError(expected: 2, actual: 1)) + .Should().Be("Версия протокола ПЛК 1 не соответствует ожидаемой 2"); + } + } + + [Fact] + public void Localize_RecipeActive_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new RecipeActiveError()) + .Should().Be("Рецепт выполняется на ПЛК"); + } + } + + [Fact] + public void Localize_RecipeNotCommitted_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new RecipeNotCommittedError()) + .Should().Be("Рецепт не зафиксирован на ПЛК"); + } + } + + [Fact] + public void Localize_WriteVerificationFailed_UnderRussianCulture_ComposesAttempts() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new WriteVerificationFailedError(3)) + .Should().Be("Проверка записи рецепта не удалась после 3 попыток"); + } + } + + [Fact] + public void Localize_PlcCommandFailed_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new PlcCommandFailedError()) + .Should().Be("Ошибка команды ПЛК"); + } + } + + [Fact] + public void Localize_PlcProducerErrors_UnderEnglishCulture_MatchOriginalMessage() + { + IError[] samples = + [ + new NotConnectedError(), + new ProtocolVersionMismatchError(expected: 2, actual: 1), + new RecipeActiveError(), + new RecipeNotCommittedError(), + new WriteVerificationFailedError(3), + new PlcCommandFailedError() + ]; + + using (ResourcesCultureScope.Use("en")) + { + foreach (var sample in samples) + { + ReasonLocalizer.Localize(sample).Should().Be(sample.Message); + } + } + } + [Fact] public void Localize_UnmatchedEndForWarning_UnderRussianCulture_RendersRussianSentence() { diff --git a/SemiStep/SemiStep.Tests/UI/ResultReportingExtensionsTests.cs b/SemiStep/SemiStep.Tests/UI/ResultReportingExtensionsTests.cs index e1ed0bef..a0ac775c 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.S7.Protocol; using SemiStep.Core.Plc.Sync; using SemiStep.Core.Recipes.Formulas.Errors; @@ -123,6 +124,42 @@ public void ReportFailure_SingleTypedError_UnderRussianCulture_LocalizesInOperat entry.Message.Should().Be("Соединение с ПЛК потеряно"); } + [AvaloniaFact] + public void ReportFailure_NotConnectedFault_UnderRussianCulture_LocalizesInOperationSlot() + { + using (ResourcesCultureScope.Use("ru")) + { + _panel.ReportFailure(new NotConnectedError()); + } + + var entry = _panel.Entries.Should().ContainSingle(item => item.IsError).Subject; + entry.Message.Should().Be("Нет соединения с ПЛК"); + } + + [AvaloniaFact] + public void ReportFailure_RecipeActiveFault_UnderRussianCulture_LocalizesInOperationSlot() + { + using (ResourcesCultureScope.Use("ru")) + { + _panel.ReportFailure(new RecipeActiveError()); + } + + var entry = _panel.Entries.Should().ContainSingle(item => item.IsError).Subject; + entry.Message.Should().Be("Рецепт выполняется на ПЛК"); + } + + [AvaloniaFact] + public void ReportFailure_PlcCommandFailedFault_UnderRussianCulture_LocalizesInOperationSlot() + { + using (ResourcesCultureScope.Use("ru")) + { + _panel.ReportFailure(new PlcCommandFailedError()); + } + + var entry = _panel.Entries.Should().ContainSingle(item => item.IsError).Subject; + entry.Message.Should().Be("Ошибка команды ПЛК"); + } + [AvaloniaFact] public void ReportFailure_SingleUntypedError_FallsThroughToItsMessage() { diff --git a/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs b/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs index 6df213d5..24707c89 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.S7.Protocol; using SemiStep.Core.Plc.Sync; using SemiStep.Core.Plc.Sync.Ownership; using SemiStep.Core.Recipes.Analysis.Warnings; @@ -26,6 +27,14 @@ public static string Localize(IReason reason) var text = reason switch { ConnectionLostError => Resources.ErrorConnectionLost, + NotConnectedError => Resources.ErrorNotConnected, + ProtocolVersionMismatchError error => Format( + Resources.ErrorProtocolVersionMismatch, error.Actual, error.Expected), + RecipeActiveError => Resources.ErrorRecipeActive, + RecipeNotCommittedError => Resources.ErrorRecipeNotCommitted, + WriteVerificationFailedError error => Format( + Resources.ErrorWriteVerificationFailed, error.Attempts), + PlcCommandFailedError => Resources.ErrorPlcCommandFailed, 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 f0fcaed3..8be77716 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs +++ b/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs @@ -296,6 +296,18 @@ public static CultureInfo? Culture public static string ErrorConnectionLost => ResourceManager.GetString("ErrorConnectionLost", resourceCulture) ?? string.Empty; + public static string ErrorNotConnected => ResourceManager.GetString("ErrorNotConnected", resourceCulture) ?? string.Empty; + + public static string ErrorProtocolVersionMismatch => ResourceManager.GetString("ErrorProtocolVersionMismatch", resourceCulture) ?? string.Empty; + + public static string ErrorRecipeActive => ResourceManager.GetString("ErrorRecipeActive", resourceCulture) ?? string.Empty; + + public static string ErrorRecipeNotCommitted => ResourceManager.GetString("ErrorRecipeNotCommitted", resourceCulture) ?? string.Empty; + + public static string ErrorWriteVerificationFailed => ResourceManager.GetString("ErrorWriteVerificationFailed", resourceCulture) ?? string.Empty; + + public static string ErrorPlcCommandFailed => ResourceManager.GetString("ErrorPlcCommandFailed", 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 048176a2..6fef8304 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.resx +++ b/SemiStep/SemiStep.UI/Localization/Resources.resx @@ -451,6 +451,24 @@ PLC connection lost + + Not connected to PLC + + + PLC protocol version {0} does not match expected {1} + + + Recipe is being executed on PLC + + + Recipe not committed on PLC + + + Recipe write verification failed after {0} attempts + + + PLC command failed + 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 df0348c7..1e21da53 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.ru.resx +++ b/SemiStep/SemiStep.UI/Localization/Resources.ru.resx @@ -451,6 +451,24 @@ Соединение с ПЛК потеряно + + Нет соединения с ПЛК + + + Версия протокола ПЛК {0} не соответствует ожидаемой {1} + + + Рецепт выполняется на ПЛК + + + Рецепт не зафиксирован на ПЛК + + + Проверка записи рецепта не удалась после {0} попыток + + + Ошибка команды ПЛК + Синхронизация ПЛК занята другим экземпляром (пользователь {0}, с {1:HH:mm} UTC). diff --git a/docs/plans/completed/20260731-plc-errors-typed.md b/docs/plans/completed/20260731-plc-errors-typed.md new file mode 100644 index 00000000..7b6509fe --- /dev/null +++ b/docs/plans/completed/20260731-plc-errors-typed.md @@ -0,0 +1,166 @@ +# Slice 6b — Type the PLC error surface + un-launder + #120 CausedBy + +## Overview + +Slice 6a added the transient single-`IError` fault seam (`ReportFailure(IError)`) and typed the connection-lost fault, +so `RecipeCoordinator.OnPlcFault` now localizes any typed fault. But the rest of the PLC producer surface is still +English: `NotConnectedError`/`ProtocolVersionMismatchError` are `internal` (invisible to the localizer), the +recipe-active / write-verification / not-committed faults are free-text `new Error(...)` / `Result.Fail(string)`, and +`PlcSyncExecutor` **launders** typed inners — it re-wraps a typed inner's `.Message` into a fresh untyped `Error` before +emitting the fault, discarding the type so the 6a seam can't localize it. On the diagnostics side (#120 part 2), +`PlcTransactionExecutor`'s five `catch { return Result.Fail(ex.Message); }` sites keep only the message string — the +exception type and stack are gone before any log sink, and no code walks `Result.Reasons`. + +This slice types the PLC producer surface, un-launders the fault forwarding, and lands the exception in a log: + +1. Make `NotConnectedError` (fieldless) and `ProtocolVersionMismatchError` (with `Expected`/`Actual` props) **public**. + New leaves `RecipeActiveError`, `WriteVerificationFailedError(attempts)`, `RecipeNotCommittedError`, and the Rule-B + envelope `PlcCommandFailedError`. Each with arm + resx + coverage sample. +2. **Un-launder `PlcSyncExecutor`**: `reportFault(new Error(inner.Message))` / `Result.Fail(inner.Message)` at 205/212/235 + → forward the typed inner directly, so it localizes through the 6a seam. Type the recipe-active fault (218/221) and + the exception-stringify (155). +3. **#120 part 2** — the five `PlcTransactionExecutor` catches become `new PlcCommandFailedError().CausedBy(ex)` AND + **log the exception at the catch**: writes at `LogError(ex, …)`, reads at `LogWarning(ex, …)`. The `CausedBy(ex)` + preserves the type in the result chain; the `Log(ex, …)` line is the diagnostics sink (message + stack). Because the + read catch now owns a Warning-with-exception, the `PlcExecutionMonitor:120` generic poll-error Warning + (`"…: {Message}"`, which after typing would log the useless generic `"PLC command failed"` and double-log the same + failure) is **removed** — the `ReadAndDecodeAsync:232` catch it fronts already Warns with the real exception. + +**6 typed classes** (2 make-public, 4 new). After this the PLC **operator-facing** faults localize by type; 6c is the +#120 cancellation plumbing (orthogonal). + +**Scope decisions:** +- `NotConnectedError` becomes fieldless (`public sealed class NotConnectedError() : Error("Not connected to PLC")`) — + verified: its 4 call sites in `PlcTransactionExecutor` (63/113/171/222) all pass exactly `"Not connected to PLC"`, so + the `(string message)` ctor is needless; update the 4 callers to `new NotConnectedError()`. (A typed error's message + must be fixed for the resx to own it.) +- The un-laundering is byte-identical for the disconnected case: today `faultMessage = "Not connected to PLC"` when + `isDisconnected`, which is exactly `NotConnectedError.Message` — so `reportFault(activeResult.Errors[0])` (forward the + typed inner) preserves the message AND recovers the type. Keep the `isDisconnected` bool via + `activeResult.Errors.OfType().Any()` (free, multi-error-safe) for its `LogWarning` branch. +- `PlcCommandFailedError` is a **fieldless** Rule-B envelope (`"PLC command failed"`): the raw exception message (a + socket/timeout string) is not operator-friendly and rides `CausedBy(ex)` + the new `Log(ex,…)` to the log; the + localized headline is the operator-useful part. Same shape as 5b's `RecipeLoadFailedError`. +- `PlcSyncExecutor:194` `Result.Fail("Not connected")` and `:221` `Result.Fail("Recipe active")` are INTERNAL return + values (not faults reaching the panel — `PlcSyncCoordinatorTests` confirms `CheckCanSync` failure emits its fault via + `reportFault`, not the return). Type them too for consistency (`NotConnectedError` / `RecipeActiveError`); the message + changes are internal-only. +- **Deferred, stated in the doc, NOT typed here:** `PlcTransactionExecutor:183` (short protocol-version read, + `"…returned {n} bytes, expected {m}"`) is a malformed-wire diagnostic edge, not an operator-actionable fault — stays + English by design. `PlcLifecycleManager:141` (enable catch) is a connect/cancellation concern — a test routes + `TaskCanceledException` through it and the catch has no `OperationCanceledException` guard — so it belongs with 6c's + cancellation pass, not here. #120 part 1 (cancellation threading) is 6c. + +**Behavior for English:** the make-public and new-leaf messages keep byte-identical English base strings (resx en == the +current baked string). The one deliberate change is `PlcCommandFailedError`: the raw `ex.Message` is replaced by the +"PLC command failed" headline in the user/fault message; the exception rides `CausedBy` to the result chain and the new +`Log(ex,…)` line to the log. `HasError` type assertions in the PLC tests survive the make-public (types preserved). +Each flagged change lists its test impact. + +## The typed classes + +resx key `Error`; en == current baked string unless flagged; ru guillemets «» where a value is quoted (none here). + +| Class | Fields | en | ru | note | +|---|---|---|---|---| +| `NotConnectedError` | — | `Not connected to PLC` | `Нет соединения с ПЛК` | make public + fieldless (was `internal (string)`); update 4 callers | +| `ProtocolVersionMismatchError` | expected:int, actual:int | `PLC protocol version {0} does not match expected {1}` | `Версия протокола ПЛК {0} не соответствует ожидаемой {1}` | make public + expose `Expected`/`Actual`; **arm arg order (Actual, Expected)** — message reads "version {actual} … expected {expected}"; verified raise `PlcLifecycleManager:157` passes `(expected, actual)` | +| `RecipeActiveError` | — | `Recipe is being executed on PLC` | `Рецепт выполняется на ПЛК` | new leaf; replaces `PlcSyncExecutor:218` | +| `WriteVerificationFailedError` | attempts:int | `Recipe write verification failed after {0} attempts` | `Проверка записи рецепта не удалась после {0} попыток` | new leaf; replaces `PlcTransactionExecutor:154` | +| `RecipeNotCommittedError` | — | `Recipe not committed on PLC` | `Рецепт не зафиксирован на ПЛК` | new leaf; replaces `PlcTransactionExecutor:205` | +| `PlcCommandFailedError` | — | `PLC command failed` | `Ошибка команды ПЛК` | new Rule-B envelope; drops raw `ex.Message` (→ `CausedBy` + `Log(ex,…)`) | + +`NotConnectedError`/`ProtocolVersionMismatchError` stay in `SemiStep.Core.Plc.S7.Protocol` (just make public). The four +new classes go in `SemiStep.Core.Plc.Sync` (alongside 6a's `ConnectionLostError`). The ru column is provisional. + +## Task 1: Type the PLC producer surface + un-launder PlcSyncExecutor + +Types every non-exception PLC producer error and recovers the types at the fault seam. + +**Files:** +- Modify: `SemiStep/SemiStep.Core/Plc/S7/Protocol/NotConnectedError.cs` (public + fieldless), `ProtocolVersionMismatchError.cs` (public + props). +- Create: `Plc/Sync/RecipeActiveError.cs`, `Plc/Sync/WriteVerificationFailedError.cs`, `Plc/Sync/RecipeNotCommittedError.cs`. +- Modify: `Plc/Sync/PlcTransactionExecutor.cs` (4× `new NotConnectedError("…")` → `new NotConnectedError()` at 63/113/171/222; `:154` → `new WriteVerificationFailedError(_protocolSettings.MaxRetryAttempts)`; `:205` → `new RecipeNotCommittedError()`). `PlcLifecycleManager.cs` (~157 `ProtocolVersionMismatchError` ctor still compiles with props). +- Modify: `Plc/Sync/PlcSyncExecutor.cs` (194, 205, 212, 218, 221, 235 — the un-laundering). +- Modify: `ReasonLocalizer.cs` (5 arms + usings), `Resources.resx`, `Resources.ru.resx`, `Resources.Designer.cs` (5 keys), `CoreErrorLocalizationCoverageTests.cs` (5 samples). + +- [x] `NotConnectedError.cs`: `internal sealed class NotConnectedError(string message) : Error(message);` → `public sealed class NotConnectedError()` + multi-line empty-brace body `: Error("Not connected to PLC")` (leaf-error family shape). Update the 4 `PlcTransactionExecutor` callers to `new NotConnectedError()`. +- [x] `ProtocolVersionMismatchError.cs`: make `public sealed`; keep ctor `(int expected, int actual)` and message; add `public int Expected { get; } = expected;` `public int Actual { get; } = actual;`. +- [x] Create `RecipeActiveError` (`: Error("Recipe is being executed on PLC")`), `RecipeNotCommittedError` (`: Error("Recipe not committed on PLC")`), `WriteVerificationFailedError(int attempts) : Error($"Recipe write verification failed after {attempts} attempts")` with `public int Attempts { get; } = attempts;`. All **`public sealed`** (required for the cross-assembly `ReasonLocalizer` arm + coverage auto-enrollment via `type.IsVisible`), `SemiStep.Core.Plc.Sync`, BOM, empty-brace shape. +- [x] `PlcTransactionExecutor`: `:154` `Result.Fail($"Recipe write verification failed after {…} attempts")` → `Result.Fail(new WriteVerificationFailedError(_protocolSettings.MaxRetryAttempts))`; `:205` `Result.Fail("Recipe not committed on PLC")` → `Result.Fail(new RecipeNotCommittedError())`. +- [x] **Un-launder `PlcSyncExecutor.CheckCanSyncAsync`** (~194-222): `:194` `Result.Fail("Not connected")` → `Result.Fail(new NotConnectedError())`; `:205` `reportFault(new Error(faultMessage))` → `reportFault(activeResult.Errors[0])` (forward typed inner; keep `var isDisconnected = activeResult.Errors.OfType().Any();` for the LogWarning branch, drop the redundant `faultMessage`); `:212` `return Result.Fail(activeResult.Errors[0].Message)` → `return activeResult.ToResult()` (forward the failed `Result`'s errors — API proven at `PlcTransactionExecutor:119/135/200`); `:218` `reportFault(new Error("Recipe is being executed on PLC"))` → `reportFault(new RecipeActiveError())`; `:221` `Result.Fail("Recipe active")` → `Result.Fail(new RecipeActiveError())`. +- [x] **Un-launder `WriteSyncAsync`** (~235): `reportFault(new Error(writeResult.Errors[0].Message))` → `reportFault(writeResult.Errors[0])` (forward typed inner); keep the `:238` `OfType()` LogError guard. +- [x] `ReasonLocalizer`: arms `NotConnectedError => Resources.ErrorNotConnected`, `RecipeActiveError => Resources.ErrorRecipeActive`, `RecipeNotCommittedError => Resources.ErrorRecipeNotCommitted` (bare); `ProtocolVersionMismatchError e => Format(Resources.ErrorProtocolVersionMismatch, e.Actual, e.Expected)` (arg order Actual then Expected); `WriteVerificationFailedError e => Format(Resources.ErrorWriteVerificationFailed, e.Attempts)`. Add the `SemiStep.Core.Plc.S7.Protocol` using (`SemiStep.Core.Plc.Sync` is already imported — `ConnectionLostError` uses it). +- [x] resx: 5 keys per the table, en/ru + Designer accessors. Coverage: 5 samples (`ProtocolVersionMismatchError(2, 1)`, `WriteVerificationFailedError(3)`, the 3 fieldless; add usings). New files BOM; Designer/resx BOM as-is. +- [x] **Test blast radius (Task 1):** grep the Tests tree for `Not connected`, `Recipe active`, `Recipe is being executed`, `Recipe not committed`, `verification failed`, `protocol version`, and `HasError` / `HasError` / `OfType`. The `HasError`/`OfType` type asserts (`PlcTransactionExecutorTests:268/298`, `PlcLifecycleManagerReconnectTests:286/305/328`, `PlcExecutionMonitorTests:250`, `S7ServiceTests:111`) SURVIVE the make-public (types preserved). Update any test asserting the raw `.Message` string of a re-wrapped fault or the `"Recipe active"`/`"Not connected"` internal literals to the typed form / byte-identical message. Note `StubS7Service.cs:116/126` `Result.Fail("Not connected")` are stub-internal test doubles — leave them untyped (they do not flow through the production localizer). Localized-panel assertions under ambient ru → `ResourcesCultureScope.Use("en")`. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green. + +## Task 2: PlcCommandFailedError envelope + #120 exception logging + +**Files:** +- Create: `Plc/Sync/PlcCommandFailedError.cs`. +- Modify: `PlcTransactionExecutor.cs` (the 5 `catch { return Result.Fail(ex.Message); }` — 105, 191, 232, 267, 301), `PlcSyncExecutor.cs` (~155), `Plc/Sync/PlcExecutionMonitor.cs` (`:120` delete). +- Modify: `ReasonLocalizer.cs` (1 arm), resx trio, coverage test, `PlcSyncCoordinatorTests.cs` (the contract test). + +- [x] Create `PlcCommandFailedError` (`public sealed class PlcCommandFailedError() : Error("PLC command failed")`, `SemiStep.Core.Plc.Sync`, BOM, empty-brace shape). +- [x] `PlcTransactionExecutor` — each catch keeps its `when (ex is not OperationCanceledException)` guard; body becomes **log-then-typed-return**, severity by path: + - `:267` `WriteRecipeDataAsync` → `_logger.LogError(ex, "PLC recipe data write failed"); return Result.Fail(new PlcCommandFailedError().CausedBy(ex));` + - `:301` `WriteManagingAreaAsync` → `_logger.LogError(ex, "PLC managing-area write failed"); return Result.Fail(new PlcCommandFailedError().CausedBy(ex));` + - `:105` `ReadRecipeDataAsync` → `_logger.LogWarning(ex, "PLC recipe data read failed"); return Result.Fail(new PlcCommandFailedError().CausedBy(ex));` + - `:191` `ReadProtocolVersionAsync` → `_logger.LogWarning(ex, "PLC protocol version read failed"); return …` + - `:232` `ReadAndDecodeAsync` → `_logger.LogWarning(ex, "PLC read/decode failed for DB {DbNumber}", dbNumber); return …` + (Writes at Error — infrequent, operator-significant. Reads at Warning — matches the volume `PlcExecutionMonitor:120` already produced per poll failure, but now carries the real exception+stack instead of a message string.) +- [x] **`PlcExecutionMonitor.cs:120`** — delete the `_logger.LogWarning("Execution monitor poll error: {Message}", result.Errors[0].Message);` line (and its `continue;` stays). After typing, `result.Errors[0].Message` is the generic `"PLC command failed"` and the `ReadAndDecodeAsync:232` catch it fronts already Warns with the real exception — this line would only double-log a useless generic. Keep the `:108-118` `NotConnectedError` branch untouched (that is control flow: stop + `onConnectionLost`). +- [x] `PlcSyncExecutor.cs:155`: `reportFault(new Error(ex.Message))` → `reportFault(new PlcCommandFailedError().CausedBy(ex))`. The `_logger.LogError(ex, "Unhandled exception in sync task")` at `:153` already logs the exception+stack — do NOT add logging here. +- [x] `ReasonLocalizer`: arm `PlcCommandFailedError => Resources.ErrorPlcCommandFailed`. resx `ErrorPlcCommandFailed` en `PLC command failed` + ru + Designer accessor. Coverage: 1 sample. BOM. +- [x] **Rewrite the fault-contract test** `PlcSyncCoordinatorTests.SyncWriteFailure_EmitsFaultCarryingMessage` (~323-350): today asserts `faults[0].Message == "transport write blew up"` (the raw `ex.Message`). After un-laundering + typing, the write fault is `PlcCommandFailedError` carrying the exception on `CausedBy`. Rewrite to assert `faults[0].Should().BeOfType()` AND the exception (`"transport write blew up"`) is retained on `faults[0].Reasons` (the `ExceptionalError` from `CausedBy`). This doubles as the Task 3 CausedBy-preservation proof. +- [x] **Test blast radius (Task 2):** grep for any other test asserting a raw exception message out of `PlcTransactionExecutor`/`PlcSyncExecutor` (the `ex.Message` catches). The severity/type-only reporting tests (`MainWindowViewModelReportingTests`, `PlcExecutionMonitorTests:250`) survive. `PlcTransactionExecutorTests`'s `verification failed` assert is `.Message.Contains("verification failed")` — SURVIVES unchanged (message byte-identical after Task 1); converting it to `HasError()` is optional polish, not required. Also grep `PlcExecutionMonitorTests` for any assert on the deleted `:120` poll-error Warning text (adjust if one pins it). Localized-panel assertions under ru → scope en. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green. + +## Task 3: Cross-path tests + doc + verify + +**Files:** `ReasonLocalizerTests.cs`, the PLC/panel test home, `Docs/architecture/error-reporting.md`. + +- [x] ru/en render cases for the 6 types (`ProtocolVersionMismatchError(2, 1)` → composed ru with actual/expected; `WriteVerificationFailedError(3)` → the attempts value; the 4 leaves) + the en `Localize == .Message` pins. (`ReasonLocalizerTests.cs`: 6 ru `[Fact]`s + one grouped en pin.) +- [x] **Fault-chain end-to-end (the payoff):** drive `PlcSyncExecutor` (or the seam directly) so `reportFault` emits a typed PLC inner (`NotConnectedError` / `RecipeActiveError` / `PlcCommandFailedError`), surfaced through `OnPlcFault` → the 6a seam, and assert Russian in the operation slot under `ru`. (`ResultReportingExtensionsTests.cs`: 3 `[AvaloniaFact]` operation-slot tests mirroring the 6a `ConnectionLostError` case. The full-executor drive of `PlcCommandFailedError` is already the Task 2 `SyncWriteFailure_EmitsTypedFaultCarryingException` contract test.) +- [x] **CausedBy + log preservation:** covered by the Task 2 contract-test rewrite (exception retained on `Reasons`). No separate unit assertion added — the contract test already asserts `faults[0].Reasons.OfType()...Exception.Message == FailureMessage`; a bare `CausedBy` unit test would only re-cover FluentResults' own behavior. +- [x] fragment sweep across the Tests tree for all Task 1/2 fragments; confirm the `HasError` asserts stay green and only the flagged sites changed. Sweep result: `PlcLifecycleManagerReconnectTests:286/305/328` (`HasError`) and `PlcTransactionExecutorTests:268/298` (`HasError`) survive the make-public unchanged; `PlcTransactionExecutorTests:227` (`.Message.Contains("verification failed")`) survives byte-identical; `StubS7Service:116/126` (`Result.Fail("Not connected")`) stay stub-internal untyped by design; no test pins the deleted `PlcExecutionMonitor:120` poll-error text. +- [x] `Docs/architecture/error-reporting.md`: PLC operator-facing faults now localize by type (connection / version / active / write-verification / not-committed / command). Note the un-laundering (faults forward the typed inner; the 6a seam is single-error, so `Errors[0]` forwarding drops siblings by design), the Rule-B `PlcCommandFailedError` (`CausedBy(ex)` + `Log(ex,…)`, headline localizes), and the two **deliberately-deferred English** producers: `PlcTransactionExecutor:183` (short-version-read diagnostic) and `PlcLifecycleManager:141` (enable catch → 6c). (Added a "PLC faults localize by type" subsection; updated the "Still free-text" line and the PLC routing-rules bullet.) +- [x] full `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green (1659 passed, 0 failed); `dotnet format` (no changes). + +## Post-Completion + +**Next (slice 6c):** #120 part 1 — thread a `CancellationToken` through `IS7Reader` (`ReadManagingAreaAsync` / +`ReadRecipeFromPlcAsync` / `ReadProtocolVersionAsync`) → `S7Service` → `PlcTransactionExecutor`, drop the manual +`IsCancellationRequested` polling in `PlcLifecycleManager.PerformReconnectReconciliationAsync`, and give the +`PlcLifecycleManager:141` enable catch its cancellation-aware handling (the `TaskCanceledException` routed there today). +Then slice 7 (style-editor `GridStyleEditorViewModel` `.Message`-join surface, pairs with #118). After 6c/7 the +config-load-culture boundary is the last English-by-design surface. + +**Executed by exec:** +- branch: plc-errors-typed + +## Verify it yourself + +The slice has no manual repro that a test does not already demonstrate (the payoff is a localized panel string under +Russian culture, and the failure paths are transport exceptions the tests fake). Verify by the tests and the diff: + +1. **Types localize by type + parity gates hold:** + `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj --filter "FullyQualifiedName~Localization"` — + `ReasonLocalizerTests` pins the ru render of all six types (e.g. `ProtocolVersionMismatchError(2,1)` → + "Версия протокола ПЛК 1 не соответствует ожидаемой 2") and the en `Localize == .Message` pins; + `CoreErrorLocalizationCoverageTests` fails if any public Core error lacks an arm/sample; `ResourceSyncTests` + fails on any en/ru/Designer key or placeholder mismatch. +2. **Un-laundered fault reaches the panel in Russian (the payoff):** + `dotnet test ... --filter "FullyQualifiedName~ResultReportingExtensionsTests"` — the seam facts drive + `NotConnectedError` / `RecipeActiveError` / `PlcCommandFailedError` through `ReportFailure(IError)` and assert + Russian in the operation slot. +3. **Production emission is typed, not laundered:** + `dotnet test ... --filter "FullyQualifiedName~PlcSyncCoordinatorTests"` — `SyncWhenRecipeActiveOnPlc_...` asserts + the emitted fault is `RecipeActiveError`; `SyncWriteFailure_EmitsTypedFaultCarryingException` asserts + `PlcCommandFailedError` with the original exception retained on `.Reasons` (#120 CausedBy). +4. **Exactly-once logging / no silenced path:** read `git show master..HEAD -- .../PlcExecutionMonitor.cs + .../PlcSyncExecutor.cs .../PlcTransactionExecutor.cs` — each failure path logs once (read exception + decode-Fail + via the guarded monitor Warning; write exception via the catch `LogError`; verification-exhausted via the guarded + `WriteSyncAsync` line). +5. **Whole suite:** `dotnet build SemiStep.slnx` (0 warnings) and `dotnet test` (1661 passed, 0 failed).