From 5cd395ca3f2180de1f88a7228ee23d77138689f5 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:27:53 +0300 Subject: [PATCH] feat(l10n): localize grid-style editor errors by type The in-app grid-style editor was the last unlocalized operator surface. Its ErrorMessage was built by joining raw .Message strings, bypassing ReasonLocalizer entirely, so a load/validate/save failure rendered English regardless of UI culture. The editor opens after startup, when the culture is established, so there is no reason to leave any of it English. Type all eight grid-style error producers and route the three ErrorMessage join sites through ReasonLocalizer.Localize: - GridStyleValidator: config-missing, section-missing (unifying the 'colors' and generic cases), orientation-invalid, key-missing, invalid-hex-color. - GridStyleLoader: config-not-found, and load-failed as a Rule-B envelope. - GridStyleWriter: save-failed as a Rule-B envelope. Each gets a ReasonLocalizer arm, en/ru resx, and a coverage sample; the join sites localize by type and fall through to .Message for anything not yet typed. English base messages stay byte-identical except the two Rule-B headlines, which drop the ": {ex.Message}" tail. The orientation error carries its two expected values as properties because GridOrientationValues is internal to Core and invisible to the UI localizer. The dropped exception detail is preserved on CausedBy and logged at both consumers of the shared loader: the editor view model (LogWarning) and the startup path in Program.cs (Log.Error) -- without the startup sink a malformed grid_style.yaml at launch would lose its parse detail entirely. ReportSaveException drops its raw tail to the already-localized Resources.SaveFailed; the exceptions reaching it are pre-write pipeline throws, not file-write failures, so naming a file would mislead. After this the operator-facing error surfaces are fully typed. The startup config load (before the culture exists) and the two PLC malformed-wire diagnostic edges remain English by design. Issue #118 (the editor's structural refactor) is independent and unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- Docs/architecture/error-reporting.md | 41 ++++- .../GridStyleConfigMissingError.cs | 8 + .../GridStyleConfigNotFoundError.cs | 9 ++ .../GridStyleHexColorInvalidError.cs | 13 ++ .../Configuration/GridStyleKeyMissingError.cs | 11 ++ .../Configuration/GridStyleLoadFailedError.cs | 9 ++ .../GridStyleOrientationInvalidError.cs | 13 ++ .../Configuration/GridStyleSaveFailedError.cs | 9 ++ .../GridStyleSectionMissingError.cs | 9 ++ .../Configuration/Loaders/GridStyleLoader.cs | 6 +- .../Configuration/Loaders/GridStyleWriter.cs | 2 +- .../Validation/GridStyleValidator.cs | 19 ++- .../GridStyleEditorFacadeTests.cs | 39 +++++ .../CoreErrorLocalizationCoverageTests.cs | 17 ++ .../UI/Localization/ReasonLocalizerTests.cs | 109 +++++++++++++ .../GridStyleEditorViewModelTests.cs | 101 +++++++++++- .../GridStyleEditorWindowOwnerRoutingTests.cs | 4 +- .../Localization/ReasonLocalizer.cs | 11 ++ .../Localization/Resources.Designer.cs | 16 ++ .../SemiStep.UI/Localization/Resources.resx | 24 +++ .../Localization/Resources.ru.resx | 24 +++ SemiStep/SemiStep.UI/Program.cs | 7 + .../StyleEditor/GridStyleEditorViewModel.cs | 16 +- .../20260803-style-editor-errors-typed.md | 153 ++++++++++++++++++ 24 files changed, 647 insertions(+), 23 deletions(-) create mode 100644 SemiStep/SemiStep.Core/Configuration/GridStyleConfigMissingError.cs create mode 100644 SemiStep/SemiStep.Core/Configuration/GridStyleConfigNotFoundError.cs create mode 100644 SemiStep/SemiStep.Core/Configuration/GridStyleHexColorInvalidError.cs create mode 100644 SemiStep/SemiStep.Core/Configuration/GridStyleKeyMissingError.cs create mode 100644 SemiStep/SemiStep.Core/Configuration/GridStyleLoadFailedError.cs create mode 100644 SemiStep/SemiStep.Core/Configuration/GridStyleOrientationInvalidError.cs create mode 100644 SemiStep/SemiStep.Core/Configuration/GridStyleSaveFailedError.cs create mode 100644 SemiStep/SemiStep.Core/Configuration/GridStyleSectionMissingError.cs create mode 100644 docs/plans/completed/20260803-style-editor-errors-typed.md diff --git a/Docs/architecture/error-reporting.md b/Docs/architecture/error-reporting.md index d4b65ce8..7f3c4bb9 100644 --- a/Docs/architecture/error-reporting.md +++ b/Docs/architecture/error-reporting.md @@ -162,9 +162,46 @@ 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): the style-editor and the five formula-internal free-text inners (null expression, +### The style-editor surface localizes by type + +The in-app grid-style editor (`GridStyleEditorViewModel`) localizes its entire `ErrorMessage` surface by +type. Its two `Result`-join sites (`LoadAsync`, `Save`) route each error through `ReasonLocalizer.Localize` +rather than joining raw `.Message` strings, so both the validation failures and the file-I/O failures render +in the current culture. Eight typed producers back it (all in `SemiStep.Core/Configuration/`): + +- **Validation** (`GridStyleValidator`): `GridStyleConfigMissingError`, `GridStyleSectionMissingError`, + `GridStyleOrientationInvalidError`, `GridStyleKeyMissingError`, `GridStyleHexColorInvalidError`. The + orientation error carries its two expected values as properties (`ExpectedRows`/`ExpectedColumns`) rather + than referencing the `internal GridOrientationValues` constants, which `ReasonLocalizer` (in `SemiStep.UI`) + cannot see. +- **File I/O** (`GridStyleLoader`/`GridStyleWriter`): `GridStyleConfigNotFoundError` (a leaf), plus the two + **Rule-B envelopes** `GridStyleLoadFailedError`/`GridStyleSaveFailedError` (headline only, `.CausedBy(ex)`, + the `: {ex.Message}` tail dropped from the sentence). + +**Two diagnostics sinks for the Rule-B envelopes.** The loader/writer are logger-less internal helpers, so +the dropped `ex.Message` is logged at each consumer, and the `ExceptionalError` is nested under `CausedBy` +(walk one level: `result.Errors.SelectMany(e => e.Reasons).OfType()`): + +- **Editor consumer** — `GridStyleEditorViewModel.LoadAsync`/`Save` log each nested `ExceptionalError` at + `LogWarning` after setting the localized `ErrorMessage`. +- **Startup consumer** — `GridStyleLoader`/`GridStyleValidator` are also on the startup config-load path + (`ConfigFacade.LoadAndValidateAsync`, consumed by `Program.cs`), which logs the same nested + `ExceptionalError` so a YAML parse detail is not lost at launch. + +`SaveCommand.ThrownExceptions` (a command-pipeline throw, before any file is written) is distinct: it commits +to `Resources.SaveFailed` alone (no `grid_style.yaml` in the wording, since no file was touched), with the raw +exception kept in the log — see the modal-dialog note under "Command-exception pipe". + +With this surface typed, the editor no longer shows mixed languages (English loader error next to Russian +validator error). The **startup** config load — which reads YAML to *establish* the UI culture, so no culture +exists yet — is now the sole English-by-design surface among the config/recipe/editor operator surfaces +(the PLC layer keeps two malformed-wire diagnostic edges English by design — the short protocol-version +read and the codec decode failures — noted in "PLC faults localize by type" below). + +Still free-text (deferred to a later wave): 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)`. (PLC is now typed — see "PLC faults localize by type" below — +is wrapped in a plain `new Error(text)`. (The style-editor surface is now typed — see "The style-editor +surface localizes by type" below. PLC is now typed — see "PLC faults localize by type" below — except the short protocol-version read and the codec decode failures, which stay English by design as noted there; the enable-catch cancellation message is also plain English, settled by slice 6c.) diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleConfigMissingError.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleConfigMissingError.cs new file mode 100644 index 00000000..2859d941 --- /dev/null +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleConfigMissingError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +namespace SemiStep.Core.Configuration; + +public sealed class GridStyleConfigMissingError() + : Error("Grid style configuration is missing (ui/grid_style.yaml).") +{ +} diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleConfigNotFoundError.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleConfigNotFoundError.cs new file mode 100644 index 00000000..1b87ade5 --- /dev/null +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleConfigNotFoundError.cs @@ -0,0 +1,9 @@ +using FluentResults; + +namespace SemiStep.Core.Configuration; + +public sealed class GridStyleConfigNotFoundError(string filePath) + : Error($"Grid style config not found: {filePath}") +{ + public string FilePath { get; } = filePath; +} diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleHexColorInvalidError.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleHexColorInvalidError.cs new file mode 100644 index 00000000..567f64bc --- /dev/null +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleHexColorInvalidError.cs @@ -0,0 +1,13 @@ +using FluentResults; + +namespace SemiStep.Core.Configuration; + +public sealed class GridStyleHexColorInvalidError(string section, string key, string value) + : Error($"Grid style '{section}.{key}' has invalid hex color: '{value}'. Expected format: '#RRGGBB' or '#AARRGGBB'.") +{ + public string Section { get; } = section; + + public string Key { get; } = key; + + public string Value { get; } = value; +} diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleKeyMissingError.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleKeyMissingError.cs new file mode 100644 index 00000000..2a693010 --- /dev/null +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleKeyMissingError.cs @@ -0,0 +1,11 @@ +using FluentResults; + +namespace SemiStep.Core.Configuration; + +public sealed class GridStyleKeyMissingError(string section, string key) + : Error($"Grid style '{section}.{key}' is missing or empty.") +{ + public string Section { get; } = section; + + public string Key { get; } = key; +} diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleLoadFailedError.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleLoadFailedError.cs new file mode 100644 index 00000000..1f4849e2 --- /dev/null +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleLoadFailedError.cs @@ -0,0 +1,9 @@ +using FluentResults; + +namespace SemiStep.Core.Configuration; + +public sealed class GridStyleLoadFailedError(string fileName) + : Error($"Failed to load {fileName}") +{ + public string FileName { get; } = fileName; +} diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleOrientationInvalidError.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleOrientationInvalidError.cs new file mode 100644 index 00000000..b38cdb29 --- /dev/null +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleOrientationInvalidError.cs @@ -0,0 +1,13 @@ +using FluentResults; + +namespace SemiStep.Core.Configuration; + +public sealed class GridStyleOrientationInvalidError(string value, string expectedRows, string expectedColumns) + : Error($"Grid style 'orientation' has unknown value: '{value}'. Expected '{expectedRows}' or '{expectedColumns}'.") +{ + public string Value { get; } = value; + + public string ExpectedRows { get; } = expectedRows; + + public string ExpectedColumns { get; } = expectedColumns; +} diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleSaveFailedError.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleSaveFailedError.cs new file mode 100644 index 00000000..8125d214 --- /dev/null +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleSaveFailedError.cs @@ -0,0 +1,9 @@ +using FluentResults; + +namespace SemiStep.Core.Configuration; + +public sealed class GridStyleSaveFailedError(string fileName) + : Error($"Failed to save {fileName}") +{ + public string FileName { get; } = fileName; +} diff --git a/SemiStep/SemiStep.Core/Configuration/GridStyleSectionMissingError.cs b/SemiStep/SemiStep.Core/Configuration/GridStyleSectionMissingError.cs new file mode 100644 index 00000000..831e20cb --- /dev/null +++ b/SemiStep/SemiStep.Core/Configuration/GridStyleSectionMissingError.cs @@ -0,0 +1,9 @@ +using FluentResults; + +namespace SemiStep.Core.Configuration; + +public sealed class GridStyleSectionMissingError(string section) + : Error($"Grid style configuration is missing '{section}' section.") +{ + public string Section { get; } = section; +} diff --git a/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleLoader.cs b/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleLoader.cs index 47251d0b..8d41f804 100644 --- a/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleLoader.cs +++ b/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleLoader.cs @@ -20,14 +20,14 @@ internal static class GridStyleLoader if (!Directory.Exists(uiDir)) { - return Result.Fail($"Grid style config not found: {uiDir}"); + return Result.Fail(new GridStyleConfigNotFoundError(uiDir)); } var filePath = Path.Combine(uiDir, "grid_style.yaml"); if (!File.Exists(filePath)) { - return Result.Fail($"Grid style config not found: {filePath}"); + return Result.Fail(new GridStyleConfigNotFoundError(filePath)); } try @@ -38,7 +38,7 @@ internal static class GridStyleLoader } catch (Exception ex) { - return Result.Fail($"Failed to load {Path.GetFileName(filePath)}: {ex.Message}"); + return Result.Fail(new GridStyleLoadFailedError(Path.GetFileName(filePath)).CausedBy(ex)); } } } diff --git a/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleWriter.cs b/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleWriter.cs index 2bd9ef62..fc0ba85a 100644 --- a/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleWriter.cs +++ b/SemiStep/SemiStep.Core/Configuration/Loaders/GridStyleWriter.cs @@ -36,7 +36,7 @@ public Result Save(string configDirectory, GridStyleOptions options) } catch (Exception ex) { - return Result.Fail($"Failed to save {Path.GetFileName(filePath)}: {ex.Message}"); + return Result.Fail(new GridStyleSaveFailedError(Path.GetFileName(filePath)).CausedBy(ex)); } } diff --git a/SemiStep/SemiStep.Core/Configuration/Validation/GridStyleValidator.cs b/SemiStep/SemiStep.Core/Configuration/Validation/GridStyleValidator.cs index 0a0dc7d0..dcbc82c0 100644 --- a/SemiStep/SemiStep.Core/Configuration/Validation/GridStyleValidator.cs +++ b/SemiStep/SemiStep.Core/Configuration/Validation/GridStyleValidator.cs @@ -16,12 +16,12 @@ public static Result Validate(GridStyleOptionsDto? dto) { if (dto is null) { - return Result.Fail("Grid style configuration is missing (ui/grid_style.yaml)."); + return Result.Fail(new GridStyleConfigMissingError()); } if (dto.Colors is null) { - return Result.Fail("Grid style configuration is missing 'colors' section."); + return Result.Fail(new GridStyleSectionMissingError("colors")); } var errors = new List(); @@ -182,9 +182,10 @@ private static void ValidateOrientation(string? orientation, List errors return; } - errors.Add(new Error( - $"Grid style 'orientation' has unknown value: '{orientation}'. " + - $"Expected '{GridOrientationValues.RowsAsSteps}' or '{GridOrientationValues.ColumnsAsSteps}'.")); + errors.Add(new GridStyleOrientationInvalidError( + orientation, + GridOrientationValues.RowsAsSteps, + GridOrientationValues.ColumnsAsSteps)); } private static void ValidateSection( @@ -194,7 +195,7 @@ private static void ValidateSection( { if (keys is null) { - errors.Add(new Error($"Grid style configuration is missing '{sectionPath}' section.")); + errors.Add(new GridStyleSectionMissingError(sectionPath)); return; } @@ -229,15 +230,13 @@ private static void ValidateKey(string sectionPath, string keyName, string? valu { if (string.IsNullOrWhiteSpace(value)) { - errors.Add(new Error($"Grid style '{sectionPath}.{keyName}' is missing or empty.")); + errors.Add(new GridStyleKeyMissingError(sectionPath, keyName)); return; } if (!_hexColorRegex.IsMatch(value)) { - errors.Add(new Error( - $"Grid style '{sectionPath}.{keyName}' has invalid hex color: '{value}'. " + - "Expected format: '#RRGGBB' or '#AARRGGBB'.")); + errors.Add(new GridStyleHexColorInvalidError(sectionPath, keyName, value)); } } } diff --git a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs index 6cfaa914..3f2a8d52 100644 --- a/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs +++ b/SemiStep/SemiStep.Tests/Core/Configuration/GridStyleEditorFacadeTests.cs @@ -1,6 +1,9 @@ using FluentAssertions; +using FluentResults; + using SemiStep.Core.Configuration; +using SemiStep.Core.Configuration.Loaders; using SemiStep.Tests.Config.Helpers; using SemiStep.Tests.Helpers; @@ -91,6 +94,42 @@ public async Task Save_InvalidRecord_FailsValidationGuardBeforeWriting() after.Should().Be(before); } + [Fact] + public async Task Load_UnparseableYaml_CarriesOriginalExceptionOnCausedBy() + { + using var tempDir = new TempDirectory(); + var uiDir = Path.Combine(tempDir.Path, "ui"); + Directory.CreateDirectory(uiDir); + await File.WriteAllTextAsync( + Path.Combine(uiDir, "grid_style.yaml"), + ": : : not valid yaml @@@ {[", + TestContext.Current.CancellationToken); + + var result = await GridStyleLoader.LoadAsync(tempDir.Path); + + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle().Which.Should().BeOfType(); + var exceptional = result.Errors.SelectMany(error => error.Reasons).OfType().ToList(); + exceptional.Should().ContainSingle(); + exceptional[0].Exception.Should().NotBeNull(); + } + + [Fact] + public void Save_WriteFailure_CarriesOriginalExceptionOnCausedBy() + { + using var tempDir = new TempDirectory(); + var configDirAsFile = Path.Combine(tempDir.Path, "not-a-directory"); + File.WriteAllText(configDirAsFile, string.Empty); + + var result = new GridStyleWriter().Save(configDirAsFile, GridStyleOptions.Default); + + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle().Which.Should().BeOfType(); + var exceptional = result.Errors.SelectMany(error => error.Reasons).OfType().ToList(); + exceptional.Should().ContainSingle(); + exceptional[0].Exception.Should().NotBeNull(); + } + private static TempDirectory CopyShippedConfig(string equipment) { var source = ShippedConfigLocator.GetConfigDirectory(equipment); diff --git a/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs b/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs index f2f80b4f..a437c197 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.Configuration; using SemiStep.Core.Plc.S7.Protocol; using SemiStep.Core.Plc.Sync; using SemiStep.Core.Plc.Sync.Ownership; @@ -37,6 +38,22 @@ public sealed class CoreErrorLocalizationCoverageTests new NotConnectedError(), [typeof(ProtocolVersionMismatchError)] = new ProtocolVersionMismatchError(2, 1), + [typeof(GridStyleConfigMissingError)] = + new GridStyleConfigMissingError(), + [typeof(GridStyleSectionMissingError)] = + new GridStyleSectionMissingError("colors"), + [typeof(GridStyleOrientationInvalidError)] = + new GridStyleOrientationInvalidError("x", "rows_as_steps", "columns_as_steps"), + [typeof(GridStyleKeyMissingError)] = + new GridStyleKeyMissingError("colors.cells", "depth_0"), + [typeof(GridStyleHexColorInvalidError)] = + new GridStyleHexColorInvalidError("colors.cells", "depth_0", "zzz"), + [typeof(GridStyleConfigNotFoundError)] = + new GridStyleConfigNotFoundError("config/ui/grid_style.yaml"), + [typeof(GridStyleLoadFailedError)] = + new GridStyleLoadFailedError("grid_style.yaml"), + [typeof(GridStyleSaveFailedError)] = + new GridStyleSaveFailedError("grid_style.yaml"), [typeof(RecipeActiveError)] = new RecipeActiveError(), [typeof(RecipeNotCommittedError)] = diff --git a/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs b/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs index 17a55e0f..b4dfb132 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.Configuration; using SemiStep.Core.Plc.S7.Protocol; using SemiStep.Core.Plc.Sync; using SemiStep.Core.Plc.Sync.Ownership; @@ -601,6 +602,114 @@ public void Localize_ClipboardImportErrors_UnderEnglishCulture_MatchOriginalMess } } + [Fact] + public void Localize_GridStyleConfigMissing_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new GridStyleConfigMissingError()) + .Should().Be("Конфигурация стиля таблицы отсутствует (ui/grid_style.yaml)."); + } + } + + [Fact] + public void Localize_GridStyleSectionMissing_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new GridStyleSectionMissingError("colors")) + .Should().Be("В конфигурации стиля таблицы отсутствует раздел «colors»."); + } + } + + [Fact] + public void Localize_GridStyleOrientationInvalid_UnderRussianCulture_ComposesValueAndExpected() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new GridStyleOrientationInvalidError("x", "rows_as_steps", "columns_as_steps")) + .Should().Be( + "Недопустимое значение 'orientation' стиля таблицы: «x». " + + "Ожидается «rows_as_steps» или «columns_as_steps»."); + } + } + + [Fact] + public void Localize_GridStyleKeyMissing_UnderRussianCulture_ComposesSectionAndKey() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new GridStyleKeyMissingError("colors.cells", "depth_0")) + .Should().Be("Параметр стиля таблицы «colors.cells.depth_0» отсутствует или пуст."); + } + } + + [Fact] + public void Localize_GridStyleHexColorInvalid_UnderRussianCulture_ComposesSectionKeyAndValue() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new GridStyleHexColorInvalidError("colors.cells", "depth_0", "zzz")) + .Should().Be( + "Недопустимый цвет в «colors.cells.depth_0»: «zzz». " + + "Ожидается формат «#RRGGBB» или «#AARRGGBB»."); + } + } + + [Fact] + public void Localize_GridStyleConfigNotFound_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new GridStyleConfigNotFoundError(@"C:\config\ui\grid_style.yaml")) + .Should().Be(@"Файл конфигурации стиля таблицы не найден: C:\config\ui\grid_style.yaml"); + } + } + + [Fact] + public void Localize_GridStyleLoadFailed_UnderRussianCulture_RendersRussianHeadline() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new GridStyleLoadFailedError("grid_style.yaml")) + .Should().Be("Не удалось загрузить grid_style.yaml"); + } + } + + [Fact] + public void Localize_GridStyleSaveFailed_UnderRussianCulture_RendersRussianHeadline() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new GridStyleSaveFailedError("grid_style.yaml")) + .Should().Be("Не удалось сохранить grid_style.yaml"); + } + } + + [Fact] + public void Localize_GridStyleErrors_UnderEnglishCulture_MatchOriginalMessage() + { + IError[] samples = + [ + new GridStyleConfigMissingError(), + new GridStyleSectionMissingError("colors"), + new GridStyleOrientationInvalidError("x", "rows_as_steps", "columns_as_steps"), + new GridStyleKeyMissingError("colors.cells", "depth_0"), + new GridStyleHexColorInvalidError("colors.cells", "depth_0", "zzz"), + new GridStyleConfigNotFoundError(@"C:\config\ui\grid_style.yaml"), + new GridStyleLoadFailedError("grid_style.yaml"), + new GridStyleSaveFailedError("grid_style.yaml") + ]; + + using (ResourcesCultureScope.Use("en")) + { + foreach (var sample in samples) + { + ReasonLocalizer.Localize(sample).Should().Be(sample.Message); + } + } + } + [AvaloniaFact] public void ReportFailure_TypedError_UnderRussianCulture_SurfacesRussianSentenceInPanel() { diff --git a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs index 617e99b7..80c1d2f3 100644 --- a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs +++ b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs @@ -1,6 +1,7 @@ using System; using System.Reactive; using System.Reactive.Linq; +using System.Text.RegularExpressions; using Avalonia.Headless.XUnit; using Avalonia.Media; @@ -15,7 +16,9 @@ using ReactiveUI; using SemiStep.Core.Configuration; +using SemiStep.Tests.Config.Helpers; using SemiStep.Tests.Helpers; +using SemiStep.Tests.UI.Localization; using SemiStep.UI.Localization; using SemiStep.UI.StyleEditor; @@ -274,7 +277,7 @@ public async Task SaveCommand_WhenSaveThrows_SurfacesErrorMessageAndLogs_Without await ExecuteSwallowing(viewModel.SaveCommand); - viewModel.ErrorMessage.Should().Be($"{Resources.SaveFailed}: disk gone"); + viewModel.ErrorMessage.Should().Be(Resources.SaveFailed); var logged = logger.Entries.Should().ContainSingle().Subject; logged.Level.Should().Be(LogLevel.Error); logged.Exception.Should().BeSameAs(failure); @@ -293,12 +296,86 @@ public void ReportSaveException_SurfacesOnEditorErrorMessage_AndLogsWithExceptio viewModel.ReportSaveException(failure); - viewModel.ErrorMessage.Should().Be($"{Resources.SaveFailed}: boom"); + viewModel.ErrorMessage.Should().Be(Resources.SaveFailed); var logged = logger.Entries.Should().ContainSingle().Subject; logged.Level.Should().Be(LogLevel.Error); logged.Exception.Should().BeSameAs(failure); } + [AvaloniaFact] + public async Task LoadAsync_WhenLoaderFailsWithCausedByException_LogsItAtWarning() + { + var logger = new RecordingLogger(); + var failure = new InvalidOperationException("yaml parse failed"); + var viewModel = new GridStyleEditorViewModel( + new CausedByFailingFacade(loadResult: Result.Fail( + new GridStyleLoadFailedError("grid_style.yaml").CausedBy(failure))), + ConfigDir, + GridStyleOptions.Default, + logger); + + await viewModel.LoadAsync(); + + var logged = logger.Entries.Should().ContainSingle().Subject; + logged.Level.Should().Be(LogLevel.Warning); + logged.Exception.Should().BeSameAs(failure); + } + + [AvaloniaFact] + public async Task SaveCommand_WhenWriterFailsWithCausedByException_LogsItAtWarning() + { + var logger = new RecordingLogger(); + var failure = new IOException("disk full"); + var viewModel = new GridStyleEditorViewModel( + new CausedByFailingFacade(saveResult: Result.Fail( + new GridStyleSaveFailedError("grid_style.yaml").CausedBy(failure))), + ConfigDir, + GridStyleOptions.Default, + logger); + + viewModel.CanSave.Should().BeTrue("the seeded default draft is valid, so SaveCommand can execute"); + + await viewModel.SaveCommand.Execute(); + + var logged = logger.Entries.Should().ContainSingle().Subject; + logged.Level.Should().Be(LogLevel.Warning); + logged.Exception.Should().BeSameAs(failure); + } + + [AvaloniaFact] + public async Task LoadAsync_MalformedHexColor_UnderRussianCulture_RendersRussianErrorMessage() + { + using var tempDir = CopyShippedConfigWithInvalidColor(); + var viewModel = new GridStyleEditorViewModel( + new GridStyleEditorFacade(), + tempDir.Path, + GridStyleOptions.Default, + NullLogger.Instance); + + using (ResourcesCultureScope.Use("ru")) + { + await viewModel.LoadAsync(); + } + + viewModel.ErrorMessage.Should().Be( + "Недопустимый цвет в «colors.cells.changed_selected»: «zzz». " + + "Ожидается формат «#RRGGBB» или «#AARRGGBB»."); + } + + private static TempDirectory CopyShippedConfigWithInvalidColor() + { + var source = ShippedConfigLocator.GetConfigDirectory("MBE"); + var tempDir = new TempDirectory(); + var uiDir = Path.Combine(tempDir.Path, "ui"); + Directory.CreateDirectory(uiDir); + + var original = File.ReadAllText(Path.Combine(source, "ui", "grid_style.yaml")); + var corrupted = Regex.Replace(original, "changed_selected:\\s*\"#[0-9A-Fa-f]+\"", "changed_selected: \"zzz\""); + File.WriteAllText(Path.Combine(uiDir, "grid_style.yaml"), corrupted); + + return tempDir; + } + private static GridStyleEditorViewModel CreateViewModel(GridStyleOptions source) { return new GridStyleEditorViewModel( @@ -337,4 +414,24 @@ public Result Save(string configDir, GridStyleOptions options) throw failure; } } + + private sealed class CausedByFailingFacade( + Result? loadResult = null, + Result? saveResult = null) : IGridStyleEditorFacade + { + public Task> Load(string configDir) + { + return Task.FromResult(loadResult ?? Result.Ok(GridStyleOptions.Default)); + } + + public Result Validate(GridStyleOptions options) + { + return Result.Ok(); + } + + public Result Save(string configDir, GridStyleOptions options) + { + return saveResult ?? Result.Ok(); + } + } } diff --git a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowOwnerRoutingTests.cs b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowOwnerRoutingTests.cs index f847f467..1ebe71ba 100644 --- a/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowOwnerRoutingTests.cs +++ b/SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorWindowOwnerRoutingTests.cs @@ -166,8 +166,8 @@ public async Task OnSaveCompleted_RestartPromptThrows_ContainsFaultAndReports() editor.OnSaveCompleted(true); Dispatcher.UIThread.RunJobs(); - viewModel.ErrorMessage.Should().StartWith( - Resources.SaveFailed + ":", + viewModel.ErrorMessage.Should().Be( + Resources.SaveFailed, "the fault must surface on the editor's own error surface instead of crashing the app"); } diff --git a/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs b/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs index 24707c89..a4b0ecde 100644 --- a/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs +++ b/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs @@ -3,6 +3,7 @@ using FluentResults; +using SemiStep.Core.Configuration; using SemiStep.Core.Plc.S7.Protocol; using SemiStep.Core.Plc.Sync; using SemiStep.Core.Plc.Sync.Ownership; @@ -30,6 +31,16 @@ public static string Localize(IReason reason) NotConnectedError => Resources.ErrorNotConnected, ProtocolVersionMismatchError error => Format( Resources.ErrorProtocolVersionMismatch, error.Actual, error.Expected), + GridStyleConfigMissingError => Resources.ErrorGridStyleConfigMissing, + GridStyleSectionMissingError error => Format(Resources.ErrorGridStyleSectionMissing, error.Section), + GridStyleOrientationInvalidError error => Format( + Resources.ErrorGridStyleOrientationInvalid, error.Value, error.ExpectedRows, error.ExpectedColumns), + GridStyleKeyMissingError error => Format(Resources.ErrorGridStyleKeyMissing, error.Section, error.Key), + GridStyleHexColorInvalidError error => Format( + Resources.ErrorGridStyleHexColorInvalid, error.Section, error.Key, error.Value), + GridStyleConfigNotFoundError error => Format(Resources.ErrorGridStyleConfigNotFound, error.FilePath), + GridStyleLoadFailedError error => Format(Resources.ErrorGridStyleLoadFailed, error.FileName), + GridStyleSaveFailedError error => Format(Resources.ErrorGridStyleSaveFailed, error.FileName), RecipeActiveError => Resources.ErrorRecipeActive, RecipeNotCommittedError => Resources.ErrorRecipeNotCommitted, WriteVerificationFailedError error => Format( diff --git a/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs b/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs index 8be77716..ed0d6e62 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs +++ b/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs @@ -340,6 +340,22 @@ public static CultureInfo? Culture public static string ErrorRecipeSaveFailed => ResourceManager.GetString("ErrorRecipeSaveFailed", resourceCulture) ?? string.Empty; + public static string ErrorGridStyleConfigMissing => ResourceManager.GetString("ErrorGridStyleConfigMissing", resourceCulture) ?? string.Empty; + + public static string ErrorGridStyleSectionMissing => ResourceManager.GetString("ErrorGridStyleSectionMissing", resourceCulture) ?? string.Empty; + + public static string ErrorGridStyleOrientationInvalid => ResourceManager.GetString("ErrorGridStyleOrientationInvalid", resourceCulture) ?? string.Empty; + + public static string ErrorGridStyleKeyMissing => ResourceManager.GetString("ErrorGridStyleKeyMissing", resourceCulture) ?? string.Empty; + + public static string ErrorGridStyleHexColorInvalid => ResourceManager.GetString("ErrorGridStyleHexColorInvalid", resourceCulture) ?? string.Empty; + + public static string ErrorGridStyleConfigNotFound => ResourceManager.GetString("ErrorGridStyleConfigNotFound", resourceCulture) ?? string.Empty; + + public static string ErrorGridStyleLoadFailed => ResourceManager.GetString("ErrorGridStyleLoadFailed", resourceCulture) ?? string.Empty; + + public static string ErrorGridStyleSaveFailed => ResourceManager.GetString("ErrorGridStyleSaveFailed", resourceCulture) ?? string.Empty; + public static string ErrorPropertyValueTypeMismatch => ResourceManager.GetString("ErrorPropertyValueTypeMismatch", resourceCulture) ?? string.Empty; public static string ErrorUnsupportedPropertySystemType => ResourceManager.GetString("ErrorUnsupportedPropertySystemType", resourceCulture) ?? string.Empty; diff --git a/SemiStep/SemiStep.UI/Localization/Resources.resx b/SemiStep/SemiStep.UI/Localization/Resources.resx index 6fef8304..217f5ff4 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.resx +++ b/SemiStep/SemiStep.UI/Localization/Resources.resx @@ -517,6 +517,30 @@ Failed to save recipe to '{0}' + + Grid style configuration is missing (ui/grid_style.yaml). + + + Grid style configuration is missing '{0}' section. + + + Grid style 'orientation' has unknown value: '{0}'. Expected '{1}' or '{2}'. + + + Grid style '{0}.{1}' is missing or empty. + + + Grid style '{0}.{1}' has invalid hex color: '{2}'. Expected format: '#RRGGBB' or '#AARRGGBB'. + + + Grid style config not found: {0} + + + Failed to load {0} + + + Failed to save {0} + Expected {0} value but got {1} for '{2}' diff --git a/SemiStep/SemiStep.UI/Localization/Resources.ru.resx b/SemiStep/SemiStep.UI/Localization/Resources.ru.resx index 1e21da53..c048c8a7 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.ru.resx +++ b/SemiStep/SemiStep.UI/Localization/Resources.ru.resx @@ -517,6 +517,30 @@ Не удалось сохранить рецепт в «{0}» + + Конфигурация стиля таблицы отсутствует (ui/grid_style.yaml). + + + В конфигурации стиля таблицы отсутствует раздел «{0}». + + + Недопустимое значение 'orientation' стиля таблицы: «{0}». Ожидается «{1}» или «{2}». + + + Параметр стиля таблицы «{0}.{1}» отсутствует или пуст. + + + Недопустимый цвет в «{0}.{1}»: «{2}». Ожидается формат «#RRGGBB» или «#AARRGGBB». + + + Файл конфигурации стиля таблицы не найден: {0} + + + Не удалось загрузить {0} + + + Не удалось сохранить {0} + Ожидалось значение типа {0}, получено {1} для «{2}» diff --git a/SemiStep/SemiStep.UI/Program.cs b/SemiStep/SemiStep.UI/Program.cs index be1a276c..146a2f93 100644 --- a/SemiStep/SemiStep.UI/Program.cs +++ b/SemiStep/SemiStep.UI/Program.cs @@ -1,5 +1,7 @@ using System.Globalization; +using FluentResults; + using Microsoft.Extensions.DependencyInjection; using SemiStep.Core.Configuration.Facade; @@ -86,6 +88,11 @@ private static async Task StartupAsync(StartupOptions options) Log.Error("Configuration error: {Error}", error); } + foreach (var exceptional in result.Errors.SelectMany(e => e.Reasons).OfType()) + { + Log.Error(exceptional.Exception, "Configuration load exception"); + } + return StartupOutcome.Failed(errors); } diff --git a/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs b/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs index e244a1d7..21fcfbae 100644 --- a/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs +++ b/SemiStep/SemiStep.UI/StyleEditor/GridStyleEditorViewModel.cs @@ -190,7 +190,8 @@ public async Task LoadAsync() var result = await _gridStyleEditorFacade.Load(_configDir); if (result.IsFailed) { - ErrorMessage = string.Join("; ", result.Errors.Select(error => error.Message)); + ErrorMessage = string.Join("; ", result.Errors.Select(ReasonLocalizer.Localize)); + LogCausedByExceptions(result, "Grid style load failed"); return; } @@ -294,7 +295,8 @@ private bool Save() var result = _gridStyleEditorFacade.Save(_configDir, BuildRecord()); if (result.IsFailed) { - ErrorMessage = string.Join("; ", result.Errors.Select(error => error.Message)); + ErrorMessage = string.Join("; ", result.Errors.Select(ReasonLocalizer.Localize)); + LogCausedByExceptions(result, "Grid style save failed"); return false; } @@ -305,7 +307,15 @@ private bool Save() internal void ReportSaveException(Exception exception) { _logger.LogError(exception, "Style editor save failed"); - ErrorMessage = $"{Resources.SaveFailed}: {exception.Message}"; + ErrorMessage = Resources.SaveFailed; + } + + private void LogCausedByExceptions(IResultBase result, string message) + { + foreach (var exceptional in result.Errors.SelectMany(error => error.Reasons).OfType()) + { + _logger.LogWarning(exceptional.Exception, "{Message}", message); + } } private void Seed(GridStyleOptions options) diff --git a/docs/plans/completed/20260803-style-editor-errors-typed.md b/docs/plans/completed/20260803-style-editor-errors-typed.md new file mode 100644 index 00000000..feeddbf3 --- /dev/null +++ b/docs/plans/completed/20260803-style-editor-errors-typed.md @@ -0,0 +1,153 @@ +# Slice 7 — Localize the style-editor error surface + +## Overview + +The in-app grid-style editor (`GridStyleEditorViewModel`) is the last unlocalized operator surface in the +error-localization roadmap. Its `ErrorMessage` (bound to the editor window) is built by **joining raw `.Message` +strings**, bypassing `ReasonLocalizer` entirely — `ReasonLocalizer` is not referenced in `StyleEditor/` at all. Three +join sites: + +```csharp +// LoadAsync (:193) — load + validate failures +ErrorMessage = string.Join("; ", result.Errors.Select(error => error.Message)); +// Save (:297) — validate + write failures +ErrorMessage = string.Join("; ", result.Errors.Select(error => error.Message)); +// ReportSaveException (:308) — the async-save command's exception +ErrorMessage = $"{Resources.SaveFailed}: {exception.Message}"; +``` + +The eight English strings these can show all come from three free-text producers: + +- **`GridStyleValidator`** (validation of `ui/grid_style.yaml`): config-missing, `colors`-section-missing, generic + section-missing, orientation-invalid, key-missing-or-empty, invalid-hex-color. +- **`GridStyleWriter.Save`**: `Failed to save {file}: {ex.Message}`. +- **`GridStyleLoader.LoadAsync`**: `Grid style config not found: {path}`, `Failed to load {file}: {ex.Message}`. + +**Why the whole surface localizes (no English-by-design leftover here).** The roadmap's "config-load boundary stays +English" rule is about the **startup** config load — the app reads YAML at launch to *establish* the UI culture, so an +error there genuinely can't be localized (no culture yet). The **style editor is different**: the operator opens it +*after* startup, so the culture is always up when any of these eight fire. Leaving the loader errors English while the +validator errors are Russian would make one panel show mixed languages depending on whether the file is missing +(English) or a color is malformed (Russian). So this slice types **all eight** producers and routes the three join sites +through `ReasonLocalizer.Localize`. After it, the editor surface has no English leftover. + +Pure localization. The structural refactor of this file (issue #118 — the ~60-field declarative map, async `Save`, +`HexColor.Parse` removal) is a **separate concern and out of scope**; this slice touches only the three join sites in the +view model and the eight Core producers. The #118 rewrite will preserve/move the three routed lines, so the two do not +conflict. + +**Split guidance.** The slice is two mechanism groups — Task 1 (validation errors, the common in-editor path) and Task 2 +(file-I/O Rule-B envelopes). Each is independently shippable: Task 1 alone leaves the loader/writer file-I/O errors +English (a smaller, coherent PR), and Task 2 finishes the surface. Exec both for the complete slice, or stop after Task 1 +and run Task 2 as a follow-up — either way the plan carries the whole surface so nothing dangles. + +## The typed classes + +Eight `public sealed` errors in `SemiStep.Core.Configuration` (leaf-error shape; the two file-I/O ones are Rule-B +exception-envelopes carrying `CausedBy(ex)`). resx key `Error`; en == the current baked string unless flagged +(Rule-B drops the `: {ex.Message}` tail from the headline). ru uses guillemets «» around quoted values. Making each +public auto-enrolls it in `CoreErrorLocalizationCoverageTests`, so every type's arm + resx + sample must land in the same +task as its make-public (no red window). The ru column is provisional — review before exec. + +| Class | Fields | en | ru | source | +|---|---|---|---|---| +| `GridStyleConfigMissingError` | — | `Grid style configuration is missing (ui/grid_style.yaml).` | `Конфигурация стиля таблицы отсутствует (ui/grid_style.yaml).` | Validator :19 | +| `GridStyleSectionMissingError` | section | `Grid style configuration is missing '{0}' section.` | `В конфигурации стиля таблицы отсутствует раздел «{0}».` | Validator :24 (`colors`) + :197 (generic) — unify | +| `GridStyleOrientationInvalidError` | value, expectedRows, expectedColumns | `Grid style 'orientation' has unknown value: '{0}'. Expected '{1}' or '{2}'.` | `Недопустимое значение 'orientation' стиля таблицы: «{0}». Ожидается «{1}» или «{2}».` | Validator :185 — **carries the two expected values as properties** (see note below) | +| `GridStyleKeyMissingError` | section, key | `Grid style '{0}.{1}' is missing or empty.` | `Параметр стиля таблицы «{0}.{1}» отсутствует или пуст.` | Validator :232 | +| `GridStyleHexColorInvalidError` | section, key, value | `Grid style '{0}.{1}' has invalid hex color: '{2}'. Expected format: '#RRGGBB' or '#AARRGGBB'.` | `Недопустимый цвет в «{0}.{1}»: «{2}». Ожидается формат «#RRGGBB» или «#AARRGGBB».` | Validator :238 | +| `GridStyleConfigNotFoundError` | path | `Grid style config not found: {0}` | `Файл конфигурации стиля таблицы не найден: {0}` | Loader :23/:30 | +| `GridStyleLoadFailedError` | fileName | `Failed to load {0}` (drops `: {ex.Message}`) | `Не удалось загрузить {0}` | Loader :41 — Rule-B, `CausedBy(ex)` | +| `GridStyleSaveFailedError` | fileName | `Failed to save {0}` (drops `: {ex.Message}`) | `Не удалось сохранить {0}` | Writer :39 — Rule-B, `CausedBy(ex)` | + +`GridStyleConfigMissingError` (validator, the DTO deserialized to null) and `GridStyleConfigNotFoundError` (loader, the +file/dir is absent) are kept distinct — different layers, different context. All eight in +`SemiStep.Core.Configuration` (BOM, empty-brace leaf shape; the field-carrying ones expose their fields as properties for +the arm, like `ProtocolVersionMismatchError`). + +**Orientation error carries its expected values — do NOT reference `GridOrientationValues` from the arm.** +`GridOrientationValues` is `internal` in `SemiStep.Core.Configuration.Dto` and Core's only `InternalsVisibleTo` is +`SemiStep.Tests` — so `ReasonLocalizer` (in `SemiStep.UI`) cannot see it (CS0122). Give +`GridStyleOrientationInvalidError(string value, string expectedRows, string expectedColumns)` three properties; the +validator (which *can* see the internal constants) constructs it as +`new GridStyleOrientationInvalidError(orientation, GridOrientationValues.RowsAsSteps, GridOrientationValues.ColumnsAsSteps)`, +and the arm formats `(e.Value, e.ExpectedRows, e.ExpectedColumns)`. Do not widen `GridOrientationValues` to public or add +an `InternalsVisibleTo` for the UI. + +## Task 1: Type the validation errors + route the view-model join sites + +Types the five `GridStyleValidator` producers and makes the editor localize validation failures. After this task, a +malformed color or missing section renders Russian in the editor; the loader/writer file-I/O errors still fall through to +English (`.Message`) until Task 2 — the routing localizes what is typed and passes the rest through unchanged (the +established `ReasonLocalizer` fall-through). + +**Files:** +- Create: `Configuration/GridStyleConfigMissingError.cs`, `GridStyleSectionMissingError.cs`, `GridStyleOrientationInvalidError.cs`, `GridStyleKeyMissingError.cs`, `GridStyleHexColorInvalidError.cs` (namespace `SemiStep.Core.Configuration`). +- Modify: `Configuration/Validation/GridStyleValidator.cs` (emit the typed errors at :19/:24/:185/:197/:232/:238 instead of `Result.Fail(string)` / `new Error(string)`). +- Modify: `StyleEditor/GridStyleEditorViewModel.cs` (:193 and :297 — route through `ReasonLocalizer`). +- Modify: `ReasonLocalizer.cs` (5 arms + `SemiStep.Core.Configuration` using), `Resources.resx`, `Resources.ru.resx`, `Resources.Designer.cs` (5 keys), `CoreErrorLocalizationCoverageTests.cs` (5 samples). + +- [x] Create the 5 validator error classes `public sealed`, BOM, empty-brace leaf shape; field-carrying ones expose their fields as `public` properties (`Section`, `Key`, `Value`, and for orientation `Value`/`ExpectedRows`/`ExpectedColumns`). Base message == the current baked string (byte-identical en). +- [x] `GridStyleValidator`: replace each `Result.Fail("…")` / `errors.Add(new Error("…"))` with the typed error — `:19` → `GridStyleConfigMissingError`, `:24` → `GridStyleSectionMissingError("colors")`, `:185` → `GridStyleOrientationInvalidError(orientation, GridOrientationValues.RowsAsSteps, GridOrientationValues.ColumnsAsSteps)`, `:197` → `GridStyleSectionMissingError(sectionPath)`, `:232` → `GridStyleKeyMissingError(sectionPath, keyName)`, `:238` → `GridStyleHexColorInvalidError(sectionPath, keyName, value)`. The `List` accumulation and `Result.Fail(errors)` shape stays (multiple typed errors join at the seam). +- [x] `GridStyleEditorViewModel` `:193` and `:297`: `string.Join("; ", result.Errors.Select(error => error.Message))` → `string.Join("; ", result.Errors.Select(ReasonLocalizer.Localize))`. Add the `SemiStep.UI.Localization` using if needed (same assembly). Keep it inline — two call sites do not warrant an extension helper (KISS). +- [x] `ReasonLocalizer`: 5 arms — the fieldless/single-field ones bare or `Format(...)`; `GridStyleOrientationInvalidError e => Format(Resources.ErrorGridStyleOrientationInvalid, e.Value, e.ExpectedRows, e.ExpectedColumns)` (values off the error, NOT `GridOrientationValues` — see the orientation note); `GridStyleHexColorInvalidError e => Format(Resources.ErrorGridStyleHexColorInvalid, e.Section, e.Key, e.Value)`; etc. Add the `SemiStep.Core.Configuration` using. +- [x] resx: 5 keys per the table (en/ru) + Designer accessors. Coverage: 5 samples (`GridStyleSectionMissingError("colors")`, `GridStyleHexColorInvalidError("colors.cells", "depth_0", "zzz")`, etc.). New files BOM; Designer/resx as-is. +- [x] **Test blast radius:** grep the Tests tree for the raw strings (`missing 'colors' section`, `invalid hex color`, `is missing or empty`, `unknown value`). The actual validator-test files are `GridStyleColorsValidationTests.cs` and `GridStyleOrientationTests.cs` (there is no `GridStyleValidatorTests`); their asserts are `.Message.Contains(fragment)` and the typed errors keep byte-identical base messages, so **they pass unchanged** — Task 1's validator-test churn is near zero. Optionally convert a few `.Message.Contains` to `HasError()` for durability (polish, not required). Localized-editor assertions under ambient ru → `ResourcesCultureScope.Use("en")`. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green. + +## Task 2: Type the file-I/O errors (Rule-B envelopes) + the save-exception path + +Finishes the surface: the loader/writer file-I/O errors localize by type, the raw `ex.Message` rides `CausedBy` + +a log line instead of the headline. + +**Files:** +- Create: `Configuration/GridStyleConfigNotFoundError.cs`, `GridStyleLoadFailedError.cs`, `GridStyleSaveFailedError.cs`. +- Modify: `Configuration/Loaders/GridStyleLoader.cs` (:23/:30 → `GridStyleConfigNotFoundError`; :41 → `GridStyleLoadFailedError(file).CausedBy(ex)`), `Configuration/Loaders/GridStyleWriter.cs` (:39 → `GridStyleSaveFailedError(file).CausedBy(ex)` — note: `GridStyleWriter` lives under `Loaders/`). +- Modify: `StyleEditor/GridStyleEditorViewModel.cs` (`LoadAsync`/`Save` exception-sink; `:305-308 ReportSaveException`), and the startup error handler (`Program.cs` / the startup facade-error loop — the second `ExceptionalError` sink). +- Modify: `ReasonLocalizer.cs` (3 arms), resx trio (3 keys), coverage test (3 samples — `GridStyleConfigNotFoundError` sample uses the **file** form, e.g. `.../ui/grid_style.yaml`). + +- [x] Create the 3 file-I/O errors `public sealed`, BOM. `GridStyleConfigNotFoundError(string path)` is a leaf (`Grid style config not found: {path}`). `GridStyleLoadFailedError(string fileName)` / `GridStyleSaveFailedError(string fileName)` are Rule-B envelopes — base message `Failed to load/save {fileName}` (NO `: {ex.Message}`; the exception rides `CausedBy`). +- [x] `GridStyleLoader`: `:23`/`:30` → `Result.Fail(new GridStyleConfigNotFoundError(uiDir/filePath))`; `:41` catch → `Result.Fail(new GridStyleLoadFailedError(Path.GetFileName(filePath)).CausedBy(ex))`. `GridStyleWriter:39` catch → `Result.Fail(new GridStyleSaveFailedError(Path.GetFileName(filePath)).CausedBy(ex))`. +- [x] **Diagnostics sink for the dropped `ex.Message` — TWO consumers, both must log the `CausedBy` exception.** `GridStyleLoader`/`GridStyleWriter` are logger-less internal helpers (`LoadAsync` is static; `GridStyleWriter` is `new()`-ed inside the facade), so threading `ILogger` through them is a structural change this slice avoids — the sink lives at each consumer. **The `ExceptionalError` is nested under `CausedBy`, not top-level**, so extraction walks one level: `result.Errors.SelectMany(e => e.Reasons).OfType()`. + - **Editor consumer** — `GridStyleEditorViewModel.LoadAsync`/`Save` (has `_logger`): after setting the localized `ErrorMessage`, log any `ExceptionalError` via `_logger.LogWarning(ex, "Grid style load/save failed")`. + - **Startup consumer** — `GridStyleLoader`/`GridStyleValidator` are ALSO on the startup path: `ConfigFacade.LoadAndValidateAsync` (`ConfigFacade.cs:~86`) calls the same loader/validator, consumed by `Program.cs`/`StartupAsync`, which today logs `error.Message` only. After the Rule-B tail drop, a YAML parse error's detail would land NOWHERE at startup (the editor sink never runs on that path). Task 2 MUST add the same `ExceptionalError` sink to the startup error loop — `Log.Error(ex, "…")` for each `error.Reasons.OfType()`. Read `Program.cs`/the startup facade-error handler to place it. (The typed validator errors are byte-identical `.Message` there, so startup display is unchanged; only the load-failed exception detail needs the new sink.) This is a real regression if omitted — do not skip it. +- [x] `ReportSaveException` (:305-308): **commit to `Resources.SaveFailed` alone** — `ErrorMessage = Resources.SaveFailed;` (drop the `: {exception.Message}` tail), keep the existing `_logger.LogError(exception, ...)`. Do NOT route through `GridStyleSaveFailedError`: exceptions reaching `SaveCommand.ThrownExceptions` are precisely the ones the writer did NOT catch (the writer catches file-I/O into `Result.Fail`), so they come from `BuildRecord`/`HexColor.ToHex`/the command pipeline — before any file is written — and naming `grid_style.yaml` would be a lie. `Resources.SaveFailed` (en "Save failed" / ru "Не удалось сохранить") is already localized, so this path just drops the raw exception tail from the UI (it stays in the log). **`SaveFailed` is a shared resx key** (also used by the recipe-save/MainWindow flow) — do NOT change its resx value, only the view-model's use of it here. +- [x] `ReasonLocalizer`: 3 arms. resx: 3 keys (en/ru) + Designer. Coverage: 3 samples (`GridStyleLoadFailedError("grid_style.yaml")`, etc.). +- [x] **Test blast radius:** grep for `config not found`, `Failed to load`, `Failed to save`, **and `SaveFailed`** (the resx-symbol form — `GridStyleEditorViewModelTests` asserts `$"{Resources.SaveFailed}: disk gone"` / `": boom"` around :277/:296, which the string-fragment grep misses; the `:308` change breaks them → update to assert `Resources.SaveFailed` without the tail). Also any `GridStyleLoaderTests`/`GridStyleWriterTests`/`LoaderFailLoudTests`/facade test asserting those `.Message` strings — the Rule-B headlines dropped the `: {ex.Message}` tail (asserts on the tail move to `CausedBy`/the exception; asserts on `"Failed to load" + filename` survive). ru editor assertions → scope en. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green. + +## Task 3: Cross-path tests + doc + verify + +**Files:** `ReasonLocalizerTests.cs`, the style-editor test home (`GridStyleEditorViewModelTests` / `GridStyleEditorFacadeTests`), `Docs/architecture/error-reporting.md` (and `grid-style-configuration.md` if it documents the error strings). + +- [x] ru/en render cases for all 8 types (the field-carrying ones with representative args — `GridStyleHexColorInvalidError("colors.cells", "depth_0", "zzz")` → the composed ru; `GridStyleOrientationInvalidError` → the two expected values) + the en `Localize == .Message` pins (Rule-B ones: `Localize == base headline`, sans exception). +- [x] **End-to-end payoff:** drive the editor's `LoadAsync`/`Save` with a malformed `grid_style.yaml` (or a facade result carrying a typed validation error) and assert the `ErrorMessage` renders Russian under `ru` — e.g. an invalid hex color surfaces `Недопустимый цвет в «…»: «zzz»…`. Read the existing `GridStyleEditorViewModelTests` fixture for the load/save harness (overlay a bad file per the invalid-config overlay pattern in CLAUDE.md). +- [x] **CausedBy preservation:** a `GridStyleLoadFailedError`/`GridStyleSaveFailedError` retains its exception on `.Reasons` (the `ExceptionalError` from `CausedBy`) — assert the load/save-failed path carries the original exception. +- [x] fragment sweep across the Tests tree for all Task 1/2 fragments; confirm only the flagged sites changed; report what moved. +- [x] `Docs/architecture/error-reporting.md`: the style-editor error surface now localizes by type (validation + file I/O); note the two Rule-B envelopes (`CausedBy(ex)` + log, headline localizes) and that the **startup** config-load boundary remains the sole English-by-design surface (the editor surface no longer is). Update the "still free-text"/producer list. +- [x] full `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format`. + +## Post-Completion + +After this slice the error-localization roadmap's operator surfaces are fully typed; the only remaining English-by-design +surface is the **startup** config load (before the UI culture is established), plus the two PLC malformed-wire +diagnostic edges (short protocol-version read, codec decode-Fail) documented in `error-reporting.md`. Issue #118 (the +`GridStyleEditorViewModel` structural refactor — declarative field map, async `Save`, `HexColor.Parse` removal) remains +open and independent; the three join sites this slice routes will be carried through that rewrite. + +**Executed by exec:** +- branch: style-editor-errors-typed + +## Verify it yourself + +The behavior — style-editor errors render Russian under a Russian UI — has no reliable pure-manual repro without +hand-corrupting `ui/grid_style.yaml`; verify by the tests and the diff: + +1. **All 8 producers localize by type + parity gates hold:** + `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj --filter "FullyQualifiedName~Localization"` — + `ReasonLocalizerTests` pins each of the 8 types' ru render (e.g. `GridStyleHexColorInvalidError("colors.cells","depth_0","zzz")` → the composed `Недопустимый цвет…`) and the en `Localize == .Message` pins; `CoreErrorLocalizationCoverageTests` fails if any of the 8 public errors lacks an arm/sample; `ResourceSyncTests` fails on any en/ru/Designer key or placeholder mismatch. +2. **End-to-end payoff (malformed file → Russian in the editor):** + `dotnet test ... --filter "FullyQualifiedName~GridStyleEditorViewModelTests"` — `LoadAsync_MalformedHexColor` overlays a corrupted `changed_selected` on the shipped config, runs `LoadAsync` under `ru`, and asserts `ErrorMessage` is the exact Russian hex-color sentence (a regression to raw `.Message` yields English → fails). +3. **Rule-B exception detail survives the tail-drop (both sinks):** the same file's two log-sink tests assert `LoadAsync`/`Save` on a `CausedBy` failure emit a `LogLevel.Warning` whose `Exception` is the original — removing the VM's `LogCausedByExceptions` call empties the capture and fails them. The startup `Program.cs` `Log.Error` sink is covered by inspection (Serilog static `Log` is not capturable) and exercises the same nested `SelectMany(Reasons).OfType()` walk. +4. **CausedBy preservation:** `dotnet test ... --filter "FullyQualifiedName~GridStyleEditorFacadeTests"` — an unparseable yaml yields `GridStyleLoadFailedError` with the exception retained on the nested `.Reasons`; a write-into-a-file-as-dir yields `GridStyleSaveFailedError` likewise. +5. **Whole suite:** `dotnet build SemiStep.slnx` (0 warnings) and `dotnet test` (1685 passed, 0 failed).