diff --git a/Docs/architecture/error-reporting.md b/Docs/architecture/error-reporting.md index a5d1512d..27b32000 100644 --- a/Docs/architecture/error-reporting.md +++ b/Docs/architecture/error-reporting.md @@ -78,16 +78,17 @@ routes it. This uses the same resx pipeline as the rest of the UI chrome — see ### Positional decorators (compose, not discriminate) -`AtStepError`/`AtColumnError` (`SemiStep.Core/Recipes/Errors/`) are typed *positional* decorators: each -adds a position (`StepNumber` / `ColumnKey`) and delegates the sentence to an inner reason held in -`Inner`. `ImportedRecipeValidator` — the shared recipe-value gate — wraps each step's failures in -`AtStepError` and each column's in `AtColumnError`, **preserving the typed inner** rather than -stringifying it. +`AtStepError`/`AtColumnError`/`AtRowError` (`SemiStep.Core/Recipes/Errors/`) are typed *positional* +decorators: each adds a position (`StepNumber` / `ColumnKey` / `RowNumber`) and delegates the sentence to +an inner reason held in `Inner`. `ImportedRecipeValidator` — the shared recipe-value gate — wraps each +step's failures in `AtStepError` and each column's in `AtColumnError`, **preserving the typed inner** +rather than stringifying it. `AtRowError` is the CSV/clipboard sibling of `AtStepError` (a source-file row +is not a recipe step): the CSV row wrap in `CsvFileSerializer` composes `AtRowError(n, AtColumnError(k, ))`. These localize by **composition**, which is distinct from the fallback recursion above. A decorator's `ReasonLocalizer` case formats its *own* localized template (`AtStepFormat` = `"Step {0}: {1}"`, ru -`"Шаг {0}: {1}"`; `AtColumnFormat` = `"Column '{0}': {1}"`, ru `"Столбец «{0}»: {1}"`) with -`Localize(Inner)` as the trailing argument, so nesting composes: +`"Шаг {0}: {1}"`; `AtColumnFormat` = `"Column '{0}': {1}"`, ru `"Столбец «{0}»: {1}"`; `AtRowFormat` = `"Row {0}: {1}"`, ru +`"Строка {0}: {1}"`) with `Localize(Inner)` as the trailing argument, so nesting composes: `Localize(AtStep(3, AtColumn("gas", inner)))` → `"Шаг 3: Столбец «gas»: "`. The two modes are mirror images: @@ -128,10 +129,23 @@ instead of `Result.Fail(string)`. `ImportedRecipeValidator` no longer raises its string — it forwards the registry's typed `ActionByIdNotFoundError`. With these typed, `Localize(Inner)` renders them localized rather than falling through to `.Message`. -Still free-text (deferred to a later wave): clipboard/CSV producer errors (the CSV row-count warning is -already typed — see above), PLC, the style-editor, and the -five formula-internal free-text inners (null expression, evaluation exception, non-finite, Int32/float -overflow), which stay English under `ru` because their inner is wrapped in a plain `new Error(text)`. +The CSV import producers now localize by type too. `CsvRowConverter`/`CsvFileSerializer`/`CsvService` +raise typed leaves (`ActionColumnNotFoundError`, `ActionColumnEmptyError`, `ActionValueNotIntegerError`, +`CsvBodyEmptyError`, `CsvHeaderMismatchError`, `RecipeFileNotFoundError` in `SemiStep.Core/Recipes/Import/Errors/`) +plus the `AtRowError`/`AtColumnError` decorators and the reused `ActionByIdNotFoundError` +(all three in `SemiStep.Core/Recipes/Errors/`), so a bad cell +surfaces the full localized `AtRow -> AtColumn -> typed value error` chain under `ru`. The `CsvService` +load/save IO failures use a **Rule-B exception-envelope**: `RecipeLoadFailedError`/`RecipeSaveFailedError` +carry the `filePath` and localize a headline only (`"Failed to load recipe from '{0}'"`), raised with +`.CausedBy(ex)`. The envelope drops `: {ex.Message}` from the user-facing sentence; the raw exception detail +survives in the log through `CsvService`'s existing `_logger.LogWarning(..., ex.Message)` calls (the +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): clipboard producer errors (the CSV producers are now typed — +see above), PLC, the style-editor, and the five formula-internal free-text inners (null expression, +evaluation exception, non-finite, Int32/float overflow), which stay English under `ru` because their inner +is wrapped in a plain `new Error(text)`. ### The published rule (public error surface) diff --git a/SemiStep/SemiStep.Core/Recipes/Errors/AtRowError.cs b/SemiStep/SemiStep.Core/Recipes/Errors/AtRowError.cs new file mode 100644 index 00000000..1d0c6df6 --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Errors/AtRowError.cs @@ -0,0 +1,11 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Errors; + +public sealed class AtRowError(int rowNumber, IError inner) + : Error($"Row {rowNumber}: {inner.Message}") +{ + public int RowNumber { get; } = rowNumber; + + public IError Inner { get; } = inner; +} diff --git a/SemiStep/SemiStep.Core/Recipes/Import/CsvFileSerializer.cs b/SemiStep/SemiStep.Core/Recipes/Import/CsvFileSerializer.cs index 7de2ff63..d936eef5 100644 --- a/SemiStep/SemiStep.Core/Recipes/Import/CsvFileSerializer.cs +++ b/SemiStep/SemiStep.Core/Recipes/Import/CsvFileSerializer.cs @@ -6,6 +6,9 @@ using FluentResults; +using SemiStep.Core.Recipes.Errors; +using SemiStep.Core.Recipes.Import.Errors; + namespace SemiStep.Core.Recipes.Import; public sealed class CsvFileSerializer( @@ -38,7 +41,7 @@ public Result Deserialize(string csvBody) if (lines.Length == 0) { - return Result.Fail("CSV body is empty"); + return Result.Fail(new CsvBodyEmptyError()); } var headerResult = ValidateHeader(lines[0]); @@ -58,7 +61,7 @@ public Result Deserialize(string csvBody) var rowNumber = i + 1; foreach (var error in stepResult.Errors) { - allErrors.Add(new Error($"Row {rowNumber}").CausedBy(error)); + allErrors.Add(new AtRowError(rowNumber, error)); } } else @@ -82,9 +85,9 @@ private Result ValidateHeader(string headerLine) if (!expected.SequenceEqual(actual)) { - return Result.Fail( - $"CSV header mismatch. Expected: [{string.Join("; ", expected)}], " + - $"Actual: [{string.Join("; ", actual)}]"); + return Result.Fail(new CsvHeaderMismatchError( + string.Join("; ", expected), + string.Join("; ", actual))); } return Result.Ok(); diff --git a/SemiStep/SemiStep.Core/Recipes/Import/CsvRowConverter.cs b/SemiStep/SemiStep.Core/Recipes/Import/CsvRowConverter.cs index 5b4c7574..35a2c6af 100644 --- a/SemiStep/SemiStep.Core/Recipes/Import/CsvRowConverter.cs +++ b/SemiStep/SemiStep.Core/Recipes/Import/CsvRowConverter.cs @@ -4,6 +4,8 @@ using FluentResults; using SemiStep.Core.Configuration; +using SemiStep.Core.Recipes.Errors; +using SemiStep.Core.Recipes.Import.Errors; namespace SemiStep.Core.Recipes.Import; @@ -26,23 +28,23 @@ public Result Convert(string csvLine) var actionIndex = _columnOrder.IndexOf(ActionColumnKey); if (actionIndex < 0 || actionIndex >= rawFields.Length) { - return Result.Fail("Action column not found"); + return Result.Fail(new ActionColumnNotFoundError()); } var rawAction = rawFields[actionIndex].Trim(); if (string.IsNullOrWhiteSpace(rawAction)) { - return Result.Fail("Action column is empty"); + return Result.Fail(new ActionColumnEmptyError()); } if (!int.TryParse(rawAction, NumberStyles.Integer, CultureInfo.InvariantCulture, out var actionKey)) { - return Result.Fail($"Cannot parse action value '{rawAction}' as integer"); + return Result.Fail(new ActionValueNotIntegerError(rawAction)); } if (!config.Actions.TryGetValue(actionKey, out var actionDef)) { - return Result.Fail($"Unknown action ID '{actionKey}'"); + return Result.Fail(new ActionByIdNotFoundError(actionKey)); } var actionPropertyKeys = actionDef.Properties @@ -86,7 +88,7 @@ public Result Convert(string csvLine) { foreach (var e in parseResult.Errors) { - errors.Add(new Error($"Column '{columnKey}': {e.Message}")); + errors.Add(new AtColumnError(columnKey, e)); } continue; @@ -97,7 +99,7 @@ public Result Convert(string csvLine) { foreach (var e in validationResult.Errors) { - errors.Add(new Error($"Column '{columnKey}': {e.Message}")); + errors.Add(new AtColumnError(columnKey, e)); } continue; diff --git a/SemiStep/SemiStep.Core/Recipes/Import/CsvService.cs b/SemiStep/SemiStep.Core/Recipes/Import/CsvService.cs index c6f8d05d..eec6d17d 100644 --- a/SemiStep/SemiStep.Core/Recipes/Import/CsvService.cs +++ b/SemiStep/SemiStep.Core/Recipes/Import/CsvService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; +using SemiStep.Core.Recipes.Import.Errors; using SemiStep.Core.Recipes.Import.Warnings; using SemiStep.Core.Shared; @@ -22,7 +23,7 @@ public virtual async Task> LoadAsync(string filePath) { if (!File.Exists(filePath)) { - return Result.Fail($"Recipe file not found: {filePath}"); + return Result.Fail(new RecipeFileNotFoundError(filePath)); } string bodyText; @@ -34,12 +35,12 @@ public virtual async Task> LoadAsync(string filePath) catch (IOException ex) { _logger.LogWarning("IO error while loading recipe from {FilePath}: {Message}", filePath, ex.Message); - return Result.Fail($"Failed to load recipe from '{filePath}': {ex.Message}"); + return Result.Fail(new RecipeLoadFailedError(filePath).CausedBy(ex)); } catch (UnauthorizedAccessException ex) { _logger.LogWarning("Access denied while loading recipe from {FilePath}: {Message}", filePath, ex.Message); - return Result.Fail($"Failed to load recipe from '{filePath}': {ex.Message}"); + return Result.Fail(new RecipeLoadFailedError(filePath).CausedBy(ex)); } var result = _csvFileSerializer.Deserialize(bodyText); @@ -74,12 +75,12 @@ public virtual async Task SaveAsync(Recipe recipe, string filePath) catch (IOException ex) { _logger.LogWarning("IO error while saving recipe to {FilePath}: {Message}", filePath, ex.Message); - return Result.Fail($"Failed to save recipe to '{filePath}': {ex.Message}"); + return Result.Fail(new RecipeSaveFailedError(filePath).CausedBy(ex)); } catch (UnauthorizedAccessException ex) { _logger.LogWarning("Access denied while saving recipe to {FilePath}: {Message}", filePath, ex.Message); - return Result.Fail($"Failed to save recipe to '{filePath}': {ex.Message}"); + return Result.Fail(new RecipeSaveFailedError(filePath).CausedBy(ex)); } _logger.LogInformation("Saved recipe to {FilePath}: {StepCount} steps", filePath, recipe.StepCount); diff --git a/SemiStep/SemiStep.Core/Recipes/Import/Errors/ActionColumnEmptyError.cs b/SemiStep/SemiStep.Core/Recipes/Import/Errors/ActionColumnEmptyError.cs new file mode 100644 index 00000000..ec697be1 --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Import/Errors/ActionColumnEmptyError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Import.Errors; + +public sealed class ActionColumnEmptyError() + : Error("Action column is empty") +{ +} diff --git a/SemiStep/SemiStep.Core/Recipes/Import/Errors/ActionColumnNotFoundError.cs b/SemiStep/SemiStep.Core/Recipes/Import/Errors/ActionColumnNotFoundError.cs new file mode 100644 index 00000000..ead8766c --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Import/Errors/ActionColumnNotFoundError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Import.Errors; + +public sealed class ActionColumnNotFoundError() + : Error("Action column not found") +{ +} diff --git a/SemiStep/SemiStep.Core/Recipes/Import/Errors/ActionValueNotIntegerError.cs b/SemiStep/SemiStep.Core/Recipes/Import/Errors/ActionValueNotIntegerError.cs new file mode 100644 index 00000000..9994a10d --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Import/Errors/ActionValueNotIntegerError.cs @@ -0,0 +1,9 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Import.Errors; + +public sealed class ActionValueNotIntegerError(string rawAction) + : Error($"Cannot parse action value '{rawAction}' as integer") +{ + public string RawAction { get; } = rawAction; +} diff --git a/SemiStep/SemiStep.Core/Recipes/Import/Errors/CsvBodyEmptyError.cs b/SemiStep/SemiStep.Core/Recipes/Import/Errors/CsvBodyEmptyError.cs new file mode 100644 index 00000000..da32237e --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Import/Errors/CsvBodyEmptyError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Import.Errors; + +public sealed class CsvBodyEmptyError() + : Error("CSV body is empty") +{ +} diff --git a/SemiStep/SemiStep.Core/Recipes/Import/Errors/CsvHeaderMismatchError.cs b/SemiStep/SemiStep.Core/Recipes/Import/Errors/CsvHeaderMismatchError.cs new file mode 100644 index 00000000..17ebac69 --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Import/Errors/CsvHeaderMismatchError.cs @@ -0,0 +1,11 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Import.Errors; + +public sealed class CsvHeaderMismatchError(string expected, string actual) + : Error($"CSV header mismatch. Expected: [{expected}], Actual: [{actual}]") +{ + public string Expected { get; } = expected; + + public string Actual { get; } = actual; +} diff --git a/SemiStep/SemiStep.Core/Recipes/Import/Errors/RecipeFileNotFoundError.cs b/SemiStep/SemiStep.Core/Recipes/Import/Errors/RecipeFileNotFoundError.cs new file mode 100644 index 00000000..427064b6 --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Import/Errors/RecipeFileNotFoundError.cs @@ -0,0 +1,9 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Import.Errors; + +public sealed class RecipeFileNotFoundError(string filePath) + : Error($"Recipe file not found: {filePath}") +{ + public string FilePath { get; } = filePath; +} diff --git a/SemiStep/SemiStep.Core/Recipes/Import/Errors/RecipeLoadFailedError.cs b/SemiStep/SemiStep.Core/Recipes/Import/Errors/RecipeLoadFailedError.cs new file mode 100644 index 00000000..bfe286fe --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Import/Errors/RecipeLoadFailedError.cs @@ -0,0 +1,9 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Import.Errors; + +public sealed class RecipeLoadFailedError(string filePath) + : Error($"Failed to load recipe from '{filePath}'") +{ + public string FilePath { get; } = filePath; +} diff --git a/SemiStep/SemiStep.Core/Recipes/Import/Errors/RecipeSaveFailedError.cs b/SemiStep/SemiStep.Core/Recipes/Import/Errors/RecipeSaveFailedError.cs new file mode 100644 index 00000000..8ac17609 --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Import/Errors/RecipeSaveFailedError.cs @@ -0,0 +1,9 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Import.Errors; + +public sealed class RecipeSaveFailedError(string filePath) + : Error($"Failed to save recipe to '{filePath}'") +{ + public string FilePath { get; } = filePath; +} diff --git a/SemiStep/SemiStep.Tests/Csv/Integration/CsvImportLocalizationTests.cs b/SemiStep/SemiStep.Tests/Csv/Integration/CsvImportLocalizationTests.cs new file mode 100644 index 00000000..5b4ff525 --- /dev/null +++ b/SemiStep/SemiStep.Tests/Csv/Integration/CsvImportLocalizationTests.cs @@ -0,0 +1,55 @@ +using System.Linq; + +using Avalonia.Headless.XUnit; + +using FluentAssertions; + +using SemiStep.Tests.Csv.Helpers; +using SemiStep.Tests.UI.Localization; +using SemiStep.UI.Localization; +using SemiStep.UI.MessageService; + +using Xunit; + +namespace SemiStep.Tests.Csv.Integration; + +[Trait("Category", "Integration")] +[Trait("Component", "Csv")] +[Trait("Area", "Localization")] +public sealed class CsvImportLocalizationTests(CsvFixture fixture) : IClassFixture +{ + private const string OutOfRangeCsv = "action;step_duration;task;comment\n10;100000;0;ok\n"; + + private const string RussianChain = + "Строка 2: Столбец «step_duration»: Значение 100000 больше максимума 86400 для «time»"; + + [Fact] + public void Deserialize_OutOfRangeCell_LocalizesFullRussianChain() + { + var result = fixture.FileSerializer.Deserialize(OutOfRangeCsv); + + result.IsFailed.Should().BeTrue(); + + using (ResourcesCultureScope.Use("ru")) + { + result.Errors.Select(ReasonLocalizer.Localize) + .Should().Contain(RussianChain, + "the AtRow -> AtColumn -> typed value error composition must render Russian end to end"); + } + } + + [AvaloniaFact] + public void Deserialize_OutOfRangeCell_SurfacedViaPanel_UnderRussianCulture_ShowsRussianChain() + { + using var panel = new MessagePanelViewModel(); + var result = fixture.FileSerializer.Deserialize(OutOfRangeCsv); + + using (ResourcesCultureScope.Use("ru")) + { + panel.ReportFailure(result); + } + + var entry = panel.Entries.Should().ContainSingle(item => item.IsError).Subject; + entry.Message.Should().Contain(RussianChain); + } +} diff --git a/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs b/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs index 5d57db57..67db4d1b 100644 --- a/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs +++ b/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs @@ -11,6 +11,7 @@ using SemiStep.Core.Recipes.Analysis.Warnings; using SemiStep.Core.Recipes.Errors; using SemiStep.Core.Recipes.Formulas.Errors; +using SemiStep.Core.Recipes.Import.Errors; using SemiStep.Core.Recipes.Import.Warnings; using SemiStep.Core.Shared; @@ -35,6 +36,24 @@ public sealed class CoreErrorLocalizationCoverageTests new AtStepError(1, new Error("x")), [typeof(AtColumnError)] = new AtColumnError("k", new Error("x")), + [typeof(AtRowError)] = + new AtRowError(1, new Error("x")), + [typeof(ActionColumnNotFoundError)] = + new ActionColumnNotFoundError(), + [typeof(ActionColumnEmptyError)] = + new ActionColumnEmptyError(), + [typeof(ActionValueNotIntegerError)] = + new ActionValueNotIntegerError("abc"), + [typeof(CsvBodyEmptyError)] = + new CsvBodyEmptyError(), + [typeof(CsvHeaderMismatchError)] = + new CsvHeaderMismatchError("a; b", "a; c"), + [typeof(RecipeFileNotFoundError)] = + new RecipeFileNotFoundError("recipe.csv"), + [typeof(RecipeLoadFailedError)] = + new RecipeLoadFailedError("recipe.csv"), + [typeof(RecipeSaveFailedError)] = + new RecipeSaveFailedError("recipe.csv"), [typeof(PropertyValueTypeMismatchError)] = new PropertyValueTypeMismatchError("int", "Int32", "temperature"), [typeof(UnsupportedPropertySystemTypeError)] = diff --git a/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs b/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs index cc6afdd3..040a0bc7 100644 --- a/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs +++ b/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs @@ -11,6 +11,7 @@ using SemiStep.Core.Recipes.Analysis.Warnings; using SemiStep.Core.Recipes.Errors; using SemiStep.Core.Recipes.Formulas.Errors; +using SemiStep.Core.Recipes.Import.Errors; using SemiStep.Core.Recipes.Import.Warnings; using SemiStep.Core.Shared; using SemiStep.UI.Localization; @@ -326,6 +327,123 @@ public void Localize_TypedErrorNestedUnderUntypedWrapper_UnderRussianCulture_Sti } } + [Fact] + public void Localize_CsvRowColumnValueChain_UnderRussianCulture_ComposesRussianDetail() + { + var error = new AtRowError(2, new AtColumnError("gas", new ValueAboveMaximumError(5, 4, "amount"))); + + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(error) + .Should().Be("Строка 2: Столбец «gas»: Значение 5 больше максимума 4 для «amount»"); + } + } + + [Fact] + public void Localize_CsvHeaderMismatch_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new CsvHeaderMismatchError("action; step", "action; temp")) + .Should().Be("Несоответствие заголовка CSV. Ожидалось: [action; step], фактически: [action; temp]"); + } + } + + [Fact] + public void Localize_ActionValueNotInteger_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new ActionValueNotIntegerError("abc")) + .Should().Be("Не удалось разобрать значение действия «abc» как целое число"); + } + } + + [Fact] + public void Localize_ActionColumnNotFound_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new ActionColumnNotFoundError()) + .Should().Be("Столбец действия не найден"); + } + } + + [Fact] + public void Localize_ActionColumnEmpty_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new ActionColumnEmptyError()) + .Should().Be("Столбец действия пуст"); + } + } + + [Fact] + public void Localize_CsvBodyEmpty_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new CsvBodyEmptyError()) + .Should().Be("Тело CSV пусто"); + } + } + + [Fact] + public void Localize_RecipeFileNotFound_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new RecipeFileNotFoundError("recipe.csv")) + .Should().Be("Файл рецепта не найден: recipe.csv"); + } + } + + [Fact] + public void Localize_RecipeLoadFailed_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new RecipeLoadFailedError("recipe.csv")) + .Should().Be("Не удалось загрузить рецепт из «recipe.csv»"); + } + } + + [Fact] + public void Localize_RecipeSaveFailed_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new RecipeSaveFailedError("recipe.csv")) + .Should().Be("Не удалось сохранить рецепт в «recipe.csv»"); + } + } + + [Fact] + public void Localize_CsvImportErrors_UnderEnglishCulture_MatchOriginalMessage() + { + IError[] samples = + [ + new AtRowError(2, new AtColumnError("gas", new ValueAboveMaximumError(5, 4, "amount"))), + new CsvHeaderMismatchError("action; step", "action; temp"), + new ActionValueNotIntegerError("abc"), + new ActionColumnNotFoundError(), + new ActionColumnEmptyError(), + new CsvBodyEmptyError(), + new RecipeFileNotFoundError("recipe.csv"), + new RecipeLoadFailedError("recipe.csv"), + new RecipeSaveFailedError("recipe.csv") + ]; + + 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.UI/Localization/ReasonLocalizer.cs b/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs index 6315e2c4..477654d2 100644 --- a/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs +++ b/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs @@ -7,6 +7,7 @@ using SemiStep.Core.Recipes.Analysis.Warnings; using SemiStep.Core.Recipes.Errors; using SemiStep.Core.Recipes.Formulas.Errors; +using SemiStep.Core.Recipes.Import.Errors; using SemiStep.Core.Recipes.Import.Warnings; namespace SemiStep.UI.Localization; @@ -29,6 +30,15 @@ public static string Localize(IReason reason) FormulaComputationFailedError error => Format(Resources.ErrorFormulaComputationFailed, error.Target, Localize(error.Inner)), AtStepError error => Format(Resources.AtStepFormat, error.StepNumber, Localize(error.Inner)), AtColumnError error => Format(Resources.AtColumnFormat, error.ColumnKey, Localize(error.Inner)), + AtRowError error => Format(Resources.AtRowFormat, error.RowNumber, Localize(error.Inner)), + ActionColumnNotFoundError => Resources.ErrorActionColumnNotFound, + ActionColumnEmptyError => Resources.ErrorActionColumnEmpty, + ActionValueNotIntegerError error => Format(Resources.ErrorActionValueNotInteger, error.RawAction), + CsvBodyEmptyError => Resources.ErrorCsvBodyEmpty, + CsvHeaderMismatchError error => Format(Resources.ErrorCsvHeaderMismatch, error.Expected, error.Actual), + RecipeFileNotFoundError error => Format(Resources.ErrorRecipeFileNotFound, error.FilePath), + RecipeLoadFailedError error => Format(Resources.ErrorRecipeLoadFailed, error.FilePath), + RecipeSaveFailedError error => Format(Resources.ErrorRecipeSaveFailed, error.FilePath), PropertyValueTypeMismatchError error => Format( Resources.ErrorPropertyValueTypeMismatch, error.ExpectedType, error.ActualType, error.Id), UnsupportedPropertySystemTypeError error => Format( diff --git a/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs b/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs index 75099cd4..c8e5a76e 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs +++ b/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs @@ -302,6 +302,24 @@ public static CultureInfo? Culture public static string AtColumnFormat => ResourceManager.GetString("AtColumnFormat", resourceCulture) ?? string.Empty; + public static string AtRowFormat => ResourceManager.GetString("AtRowFormat", resourceCulture) ?? string.Empty; + + public static string ErrorActionColumnNotFound => ResourceManager.GetString("ErrorActionColumnNotFound", resourceCulture) ?? string.Empty; + + public static string ErrorActionColumnEmpty => ResourceManager.GetString("ErrorActionColumnEmpty", resourceCulture) ?? string.Empty; + + public static string ErrorActionValueNotInteger => ResourceManager.GetString("ErrorActionValueNotInteger", resourceCulture) ?? string.Empty; + + public static string ErrorCsvBodyEmpty => ResourceManager.GetString("ErrorCsvBodyEmpty", resourceCulture) ?? string.Empty; + + public static string ErrorCsvHeaderMismatch => ResourceManager.GetString("ErrorCsvHeaderMismatch", resourceCulture) ?? string.Empty; + + public static string ErrorRecipeFileNotFound => ResourceManager.GetString("ErrorRecipeFileNotFound", resourceCulture) ?? string.Empty; + + public static string ErrorRecipeLoadFailed => ResourceManager.GetString("ErrorRecipeLoadFailed", resourceCulture) ?? string.Empty; + + public static string ErrorRecipeSaveFailed => ResourceManager.GetString("ErrorRecipeSaveFailed", 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 e1eef596..f783c94d 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.resx +++ b/SemiStep/SemiStep.UI/Localization/Resources.resx @@ -460,6 +460,33 @@ Column '{0}': {1} + + Row {0}: {1} + + + Action column not found + + + Action column is empty + + + Cannot parse action value '{0}' as integer + + + CSV body is empty + + + CSV header mismatch. Expected: [{0}], Actual: [{1}] + + + Recipe file not found: {0} + + + Failed to load recipe from '{0}' + + + Failed to save recipe to '{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 6c4d045f..f5b4ae3b 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.ru.resx +++ b/SemiStep/SemiStep.UI/Localization/Resources.ru.resx @@ -460,6 +460,33 @@ Столбец «{0}»: {1} + + Строка {0}: {1} + + + Столбец действия не найден + + + Столбец действия пуст + + + Не удалось разобрать значение действия «{0}» как целое число + + + Тело CSV пусто + + + Несоответствие заголовка CSV. Ожидалось: [{0}], фактически: [{1}] + + + Файл рецепта не найден: {0} + + + Не удалось загрузить рецепт из «{0}» + + + Не удалось сохранить рецепт в «{0}» + Ожидалось значение типа {0}, получено {1} для «{2}» diff --git a/docs/plans/completed/20260730-csv-import-errors-typed.md b/docs/plans/completed/20260730-csv-import-errors-typed.md new file mode 100644 index 00000000..0d3ab7b2 --- /dev/null +++ b/docs/plans/completed/20260730-csv-import-errors-typed.md @@ -0,0 +1,136 @@ +# Slice 5b — Type the CSV import errors + +## Overview + +The CSV file-load path (`CsvService` → `CsvFileSerializer` → `CsvRowConverter`) still raises free-text +`Result.Fail(string)` / `new Error(string)` for every deserialize failure, so a CSV import error renders raw English +regardless of culture. These reach the panel via `RecipeCoordinator.LoadRecipeAsync` → `ReportFailure` (which +localizes by type). Slice 5a typed the CSV row-count *warning*; this slice types the CSV *errors*. + +The payoff is the value-error chain. Today a bad cell produces +`new Error("Row 2").CausedBy(new Error("Column 'gas': "))` — a stringified wrapper around a +now-typed inner (`PropertyParser`/`PropertyValidator` errors, typed in 4b/4c). Turning the wrappers into **composing +decorators** recovers full localization: `AtRowError(2, AtColumnError("gas", ValueAboveMaximumError(...)))` renders +under `ru` as `Строка 2: Столбец «gas»: Значение 5 больше максимума 4 для «amount»` — Russian end to end. + +**9 new typed classes** — `AtRowError` joins the composing-decorator family in `SemiStep.Core.Recipes.Errors` (next +to `AtStepError`/`AtColumnError`, which it always composes with); the 8 CSV-specific leaves/envelopes go in +`SemiStep.Core.Recipes.Import.Errors`. Plus **two reuses** of existing typed errors, and the two stringifying wrappers +become composing. The shared row/column decorators (`AtRowError`, `AtColumnError`) and the action-column errors are +introduced here and **reused by 5c** (clipboard). + +**Scope decisions:** +- `CsvRowConverter`'s `Column '{k}': {inner.Message}` wrap (`:89/:100`) **reuses the slice-3 `AtColumnError`** — its + message is byte-identical and it already composes `Localize(inner)`. No new "ColumnParseError" class. +- **`AtRowError` is new** — a positional row decorator mirroring `AtStepError` (a CSV/clipboard row is not a recipe + step), homed with its siblings in `SemiStep.Core.Recipes.Errors`. Its message becomes `Row {n}: {inner.Message}` + (was bare `Row {n}` with the inner stringified on `CausedBy`) — a display fix: **today the column/value DETAIL is + dropped**, not the prefix. `ReasonLocalizer.Localize(new Error("Row 2").CausedBy(inner))` finds no arm for the plain + `Row 2` wrapper, the fallback recursion into the (currently untyped, stringified) inner returns null, so `Localize` + falls back to `reason.Message` = bare `"Row 2"` — the panel shows just "Row 2". `AtRowError` composing the inner into + its `.Message` + arm fixes it (row prefix AND localized inner both show). +- `CsvRowConverter:45`'s `Unknown action ID '{actionKey}'` (**`actionKey` is `int`**, parsed at `:38`) **reuses + `ActionByIdNotFoundError` (4b)** — same semantics; changes the English to `Action with id {n} not found` (a + deliberate unification, like the 4c `ImportedRecipeValidator:38` dedup). +- `CsvService` IO/access failures (load `:37/:42`, save `:77/:82`) become **Rule-B exception-envelopes**: a typed error + carrying `filePath`, raised with `.CausedBy(ex)` (structural — carries the exception for future consumers; note the + coordinator log path `FormatErrors` reads only top-level `.Message` and does not walk `CausedBy`). The raw `ex.Message` + detail already survives in the log via `CsvService`'s existing `_logger.LogWarning(..., ex.Message)` calls, which stay. + The localized headline drops `: {ex.Message}` from the user-facing message. This changes the English message; flagged below. +- `PropertyParser`/`PropertyValidator` are already typed (4b/4c) — this slice only re-wraps their output, no change to them. +- Out of scope: clipboard (5c), PLC (6), style-editor (7). + +**Behavior-preserving for English — mostly.** Leaf errors keep byte-identical English (resx en == baked string). +`AtColumnError` reuse is byte-identical. Two deliberate English changes: `AtRowError` gains `: {inner}` (improvement), +and the `ActionByIdNotFoundError` reuse + the exception-envelopes reword. Each is flagged with its test impact. + +## The typed classes + +resx key `Error` (or `Format` for the composing decorators, matching `AtStepFormat`/`AtColumnFormat`); +en == current baked string unless flagged; ru guillemets «» where a value is quoted. + +### Task 1 batch — converter/row errors (4 new + 2 reuse) + +| Class | Fields | en | ru | note | +|---|---|---|---|---| +| `AtRowError` | rowNumber:int, inner:IError | `Row {0}: {1}` (`AtRowFormat`) | `Строка {0}: {1}` | NEW composing decorator; message was bare `Row {n}` | +| `AtColumnError` (reuse, slice 3) | — | `Column '{0}': {1}` | `Столбец «{0}»: {1}` | byte-identical; CsvRowConverter column wrap | +| `ActionColumnNotFoundError` | — | `Action column not found` | `Столбец действия не найден` | leaf | +| `ActionColumnEmptyError` | — | `Action column is empty` | `Столбец действия пуст` | leaf (shared w/ 5c) | +| `ActionValueNotIntegerError` | rawAction:string | `Cannot parse action value '{0}' as integer` | `Не удалось разобрать значение действия «{0}» как целое число` | leaf (shared w/ 5c) | +| `ActionByIdNotFoundError` (reuse, 4b) | — | `Action with id {0} not found` | (existing ru) | replaces `Unknown action ID '{n}'`; English change | + +### Task 2 batch — serializer/service errors (5 new) + +| Class | Fields | en | ru | note | +|---|---|---|---|---| +| `CsvBodyEmptyError` | — | `CSV body is empty` | `Тело CSV пусто` | leaf | +| `CsvHeaderMismatchError` | expected:string, actual:string | `CSV header mismatch. Expected: [{0}], Actual: [{1}]` | `Несоответствие заголовка CSV. Ожидалось: [{0}], фактически: [{1}]` | leaf | +| `RecipeFileNotFoundError` | filePath:string | `Recipe file not found: {0}` | `Файл рецепта не найден: {0}` | leaf | +| `RecipeLoadFailedError` | filePath:string | `Failed to load recipe from '{0}'` | `Не удалось загрузить рецепт из «{0}»` | Rule-B envelope, `.CausedBy(ex)`; English drops `: {ex.Message}` | +| `RecipeSaveFailedError` | filePath:string | `Failed to save recipe to '{0}'` | `Не удалось сохранить рецепт в «{0}»` | Rule-B envelope, `.CausedBy(ex)`; English drops `: {ex.Message}` | + +The ru column is a first pass — review in Rider before exec. + +## Task 1: Converter/row errors + composing wrappers + +**Files:** +- Create: `AtRowError.cs` in `SemiStep/SemiStep.Core/Recipes/Errors/` (with its `AtStepError`/`AtColumnError` siblings); `ActionColumnNotFoundError.cs`, `ActionColumnEmptyError.cs`, `ActionValueNotIntegerError.cs` in `SemiStep/SemiStep.Core/Recipes/Import/Errors/`. All public sealed, one per file, BOM; `AtStepError.cs`/`AtColumnError.cs` are the precedents — `AtRowError` composes `: {inner.Message}` and exposes `RowNumber`/`Inner`. +- Modify: `SemiStep/SemiStep.Core/Recipes/Import/CsvRowConverter.cs` (29, 35, 40, 45, 89, 100) and `CsvFileSerializer.cs` (61 the row wrap). +- Modify: `ReasonLocalizer.cs` (arms + usings), `Resources.resx`, `Resources.ru.resx`, `Resources.Designer.cs`, `CoreErrorLocalizationCoverageTests.cs`. + +- [x] Create the 4 new classes. `AtRowError(int rowNumber, IError inner) : Error($"Row {rowNumber}: {inner.Message}")` exposing `RowNumber`/`Inner` — mirror `AtStepError.cs` exactly (no `CausedBy`; the inner rides `Inner`). Leaves match the leaf-error precedent. +- [x] `CsvRowConverter.cs`: `:29` → `ActionColumnNotFoundError`; `:35` → `ActionColumnEmptyError`; `:40` → `ActionValueNotIntegerError(rawAction)`; `:45` → `new ActionByIdNotFoundError(actionKey)` (int; reuse 4b — add its using); `:89` (parse) and `:100` (validate) → `new AtColumnError(columnKey, e)` (reuse slice-3 decorator; `e` is the typed inner — do NOT stringify). Add usings. +- [x] `CsvFileSerializer.cs:61`: `new Error($"Row {rowNumber}").CausedBy(error)` → `new AtRowError(rowNumber, error)`. +- [x] `ReasonLocalizer`: arms — `AtRowError e => Format(Resources.AtRowFormat, e.RowNumber, Localize(e.Inner))`; leaves `Format`/bare `Resources.Error`; `ActionByIdNotFoundError` already has its arm (4b). Add usings. +- [x] resx: `AtRowFormat` + `ErrorActionColumnNotFound` + `ErrorActionColumnEmpty` + `ErrorActionValueNotInteger` (en == baked string; ru per table) in both resx + Designer accessors. Coverage: samples for the 4 new types (`AtRowError` sample needs an inner, e.g. `new AtRowError(1, new Error("x"))` — mirror the `AtStepError` coverage sample). New error files BOM; Designer/resx BOM as-is. +- [x] **Test blast radius (Task 1):** grep the Tests tree for the old fragments (`Row `, `Column '`, `Action column`, `Cannot parse action value`, `Unknown action ID`). `CsvPropertyValidationTests` uses `FlattenMessage` (reads `.Message`, walks `CausedBy`) — after the change `AtRowError.Message` = `Row 2: Column 'gas': ` (fragments still present, no `CausedBy`), so the `.Contains` fragment asserts survive; verify. Update any test pinning `Unknown action ID '{n}'` to `Action with id {n} not found`. Any localized-panel assertion under ambient ru culture → `ResourcesCultureScope.Use("en")`. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green. + +## Task 2: Serializer/service errors (Rule-B envelopes) + +**Files:** +- Create: `CsvBodyEmptyError.cs`, `CsvHeaderMismatchError.cs`, `RecipeFileNotFoundError.cs`, `RecipeLoadFailedError.cs`, `RecipeSaveFailedError.cs` in `SemiStep/SemiStep.Core/Recipes/Import/Errors/`. +- Modify: `CsvFileSerializer.cs` (41, 85), `CsvService.cs` (25, 37, 42, and the save block ~77/82 — read it to confirm), `ReasonLocalizer.cs`, resx trio, coverage test. + +- [x] Create the 5 classes per the table. `RecipeLoadFailedError(string filePath) : Error($"Failed to load recipe from '{filePath}'")` (and save equivalent) — the message is the localizable headline only; the exception rides `CausedBy` at the raise site. `CsvHeaderMismatchError(string expected, string actual)` carries the two joined column lists. +- [x] `CsvFileSerializer.cs`: `:41` → `CsvBodyEmptyError`; `:85` → `CsvHeaderMismatchError(string.Join("; ", expected), string.Join("; ", actual))`. +- [x] `CsvService.cs`: `:25` → `RecipeFileNotFoundError(filePath)`; `:37`/`:42` → `Result.Fail(new RecipeLoadFailedError(filePath).CausedBy(ex))` (drop the `: {ex.Message}` from the message — it survives in the log via `CausedBy` and the existing `_logger.LogWarning`); the save block `:77`/`:82` → `RecipeSaveFailedError(filePath).CausedBy(ex)`. Keep the existing `_logger.LogWarning(..., ex.Message)` calls. +- [x] `ReasonLocalizer` arms (5) + resx `Error` keys (en == baked, EXCEPT the two Rule-B envelopes whose en drops `: {ex.Message}` — flagged) + ru + Designer accessors + coverage samples. +- [x] **Resx watch:** an existing key `SaveRecipeFailed` (`Failed to save recipe`) sits one word from the new `ErrorRecipeSaveFailed` (`Failed to save recipe to '{0}'`) — no collision, but glance deliberately so you edit the right entry (and its ru). +- [x] **Test blast radius (Task 2):** grep for `Recipe file not found`, `Failed to load recipe`, `Failed to save recipe`, `CSV body is empty`, `CSV header mismatch`. The two Rule-B envelopes drop `ex.Message` from the message — update any test asserting the exception text in the load/save error message. Note: `CsvDeserializationTests`'s header/empty/invalid-action cases assert `IsFailed` ONLY (they never read `.Message`), so they stay green with no edit — do not spend effort "preserving English" for asserts that don't check it. Any localized-panel assertion under ambient ru culture → `ResourcesCultureScope.Use("en")`. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green. + +## Task 3: Cross-path tests + doc + verify + +**Files:** `ReasonLocalizerTests.cs`, the CSV integration/panel test home, `Docs/architecture/error-reporting.md`. + +- [x] ru/en render cases for a representative subset (`AtRowError` wrapping an `AtColumnError` wrapping a typed value error — the full compose; `CsvHeaderMismatchError`; `ActionValueNotIntegerError`) + the en `Localize==Message` pins. +- [x] **Value-error end-to-end (the payoff):** a CSV body with an out-of-range cell, deserialized and surfaced via `ReportFailure` under `ru`, renders `Строка N: Столбец «k»: ` — proving the `AtRow`→`AtColumn`→typed-inner composition. Home it in `SemiStep.Tests/Csv/Integration/` (alongside `CsvPropertyValidationTests`); name the file at exec time. +- [x] fragment sweep across the Tests tree for all Task 1/2 fragments; confirm the raw-`.Message` asserts stay green and only the flagged sites (unknown-action wording, the two exception-envelopes) changed; report what moved. +- [x] `Docs/architecture/error-reporting.md`: note the CSV import producers now localize by type; add `AtRowError` to the positional-decorator/composition list (`AtStep`/`AtColumn`/`FormulaComputationFailed`); note the Rule-B exception-envelope pattern (`CausedBy(ex)`, headline localizes, detail to the log). +- [x] full `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format`. + +## Post-Completion + +**Next (slice 5c):** clipboard producer errors (`ClipboardSerializer`) — `ClipboardParseFailedError` (Rule-B +exception-envelope for the top-level catch), `ColumnCountMismatchError` (rowNumber/expected/actual), `NoValidStepsError`, +and its `Action column with key '…' not found in configuration` variant — reusing 5b's shared `AtRowError`, +`AtColumnError`, `ActionColumnEmptyError`, `ActionValueNotIntegerError`, and `ActionByIdNotFoundError`. After 5c the +clipboard+CSV ingress localizes; the config-load-culture boundary is the last English-by-design surface. Then slice 6 +(#120 PLC), slice 7 (style-editor). + +--- + +**Executed by exec:** +- branch: csv-import-errors-typed +- commits: f69bd74 (converter/row errors + composing AtRowError/AtColumnError reuse) · c3d9bb8 (serializer/service errors + Rule-B envelopes) · 7cdb343 (cross-path tests + doc) · 1d484f3 (review-1: 6 ru render pins) · 1d5a1c8 (smells: doc namespace paths) +- review chain: comprehensive (5 agents, all OUTCOME ACHIEVED) → fixer 1d484f3 (LOW: pin ru render for the 6 remaining new types) → smells → fixer 1d5a1c8 (MINOR: doc path accuracy) → comment audit (Ship) → critical (satisfied by comprehensive; test/doc-only delta since). codex skipped (not installed). + +## Verify it yourself +1. `dotnet build SemiStep.slnx` — 0 warnings. +2. `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj` — 1629 passed, 0 failed. +3. The composition payoff: `--filter "FullyQualifiedName~CsvImportLocalization"` — a bad-value CSV deserialized through the real `CsvFileSerializer` renders, under ru, the full chain `Строка 2: Столбец «step_duration»: Значение 100000 больше максимума 86400 для «time»` (both the `Localize` and the panel `ReportFailure` paths). Pre-slice this showed just "Row 2". +4. All 9 typed: `--filter "FullyQualifiedName~ReasonLocalizer|FullyQualifiedName~CoreErrorLocalizationCoverage"` — coverage forces a case for every public Error subclass; the render cases pin each new type's ru string. +5. English preserved: `CsvPropertyValidationTests` FlattenMessage asserts (`Row 2`, column key, bounds) stay green unchanged — the composed `.Message` carries the full English chain. The two Rule-B envelopes drop `: {ex.Message}` from the message; the detail stays in the log via the retained `CsvService._logger.LogWarning`. +6. Manual (optional): under a Russian UI, import a CSV with an out-of-range cell or a bad header — the error shows in Russian, row/column context included.