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
25 changes: 19 additions & 6 deletions Docs/architecture/error-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions SemiStep/SemiStep.Core/Plc/Sync/ConnectionLostError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentResults;

namespace SemiStep.Core.Plc.Sync;

public sealed class ConnectionLostError()
: Error("PLC connection lost")
{
}
2 changes: 1 addition & 1 deletion SemiStep/SemiStep.Core/Plc/Sync/PlcSyncCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions SemiStep/SemiStep.Tests/S7/PlcSyncCoordinatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectionLostError>();
faults[0].Message.ToLowerInvariant().Should().Contain("connection");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +30,8 @@ public sealed class CoreErrorLocalizationCoverageTests
{
private static readonly IReadOnlyDictionary<Type, IReason> _typeData = new Dictionary<Type, IReason>
{
[typeof(ConnectionLostError)] =
new ConnectionLostError(),
[typeof(OwnedByAnotherInstanceError)] =
new OwnedByAnotherInstanceError(new OwnerInfo(1, "MACHINE", "alice", DateTimeOffset.UnixEpoch)),
[typeof(FormulaComputationFailedError)] =
Expand Down
22 changes: 22 additions & 0 deletions SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down
6 changes: 5 additions & 1 deletion SemiStep/SemiStep.Tests/UI/RecipeCoordinatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
22 changes: 22 additions & 0 deletions SemiStep/SemiStep.Tests/UI/ResultReportingExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

using FluentResults;

using SemiStep.Core.Plc.Sync;
using SemiStep.Core.Recipes.Formulas.Errors;

using SemiStep.Tests.UI.Localization;
Expand Down Expand Up @@ -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");
}
}
2 changes: 1 addition & 1 deletion SemiStep/SemiStep.UI/Coordinator/RecipeCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ private void OnPlcFault(IError error)
}

_logger.LogWarning("PLC fault: {Message}", error.Message);
_messagePanel.ReportError(error.Message);
_messagePanel.ReportFailure(error);
}
}

Expand Down
2 changes: 2 additions & 0 deletions SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions SemiStep/SemiStep.UI/Localization/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions SemiStep/SemiStep.UI/Localization/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,9 @@
<data name="EditorDefaultFont" xml:space="preserve">
<value>(Default)</value>
</data>
<data name="ErrorConnectionLost" xml:space="preserve">
<value>PLC connection lost</value>
</data>
<data name="ErrorOwnedByAnotherInstance" xml:space="preserve">
<value>PLC sync is owned by another instance (user {0}, since {1:HH:mm} UTC).</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions SemiStep/SemiStep.UI/Localization/Resources.ru.resx
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,9 @@
<data name="EditorDefaultFont" xml:space="preserve">
<value>(По умолчанию)</value>
</data>
<data name="ErrorConnectionLost" xml:space="preserve">
<value>Соединение с ПЛК потеряно</value>
</data>
<data name="ErrorOwnedByAnotherInstance" xml:space="preserve">
<value>Синхронизация ПЛК занята другим экземпляром (пользователь {0}, с {1:HH:mm} UTC).</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Warning>().Select(ReasonLocalizer.Localize));
Expand Down
Loading