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).