From 2c723648f887bfc065bd3063cca451151fe1cd8e Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:29:05 +0300 Subject: [PATCH] feat(l10n): localize clipboard import errors by type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clipboard paste path (ClipboardSerializer.DeserializeSteps) raised free-text Result.Fail/new Error for every parse failure, so a paste error rendered raw English regardless of culture. It reaches the panel via ClipboardViewModel.PasteStepsAsync -> ReportFailure, which localizes by type. Type the clipboard path, reusing the shared tabular-parse errors 5b introduced (AtRowError, AtColumnError, ActionColumnEmptyError, ActionValueNotIntegerError, ActionByIdNotFoundError, ActionColumnNotFoundError) and adding three clipboard-specific classes: - ClipboardParseFailedError -- Rule-B exception-envelope for the top-level parse catch. Inject ILogger and LogWarning the exception detail (mirrors CsvService), since the localized headline drops ex.Message; the catch is real (CsvHelper throws on malformed pastes). - ColumnCountMismatchError and NoValidStepsError -- leaves, byte-identical English. The value chain composes AtRowError -> AtColumnError -> PropertyValueParseError: pasting a non-numeric cell into a float column renders, under ru, "Строка 1: Столбец «task»: Не удалось разобрать «abc» как float" -- Russian end to end (was just "Row 1"). This completes the CSV+clipboard ingress: after it, only the config-load-culture boundary is English by design. English is behavior-preserving except three flagged changes (same as 5b): AtRowError gains ": {inner}", the ActionByIdNotFoundError reuse rewords unknown-action, and ClipboardParseFailedError drops ": {ex.Message}" (kept in the log). Co-Authored-By: Claude Opus 4.8 (1M context) --- Docs/architecture/error-reporting.md | 15 ++- .../Recipes/Clipboard/ClipboardSerializer.cs | 30 +++-- .../Errors/ClipboardParseFailedError.cs | 8 ++ .../Errors/ColumnCountMismatchError.cs | 14 +++ .../Clipboard/Errors/NoValidStepsError.cs | 8 ++ .../Integration/CsvDeserializationTests.cs | 32 ++++++ .../ClipboardViewModelCanExecuteTests.cs | 6 +- .../SemiStep.Tests/UI/Helpers/UIFixture.cs | 4 +- .../CoreErrorLocalizationCoverageTests.cs | 7 ++ .../UI/Localization/ReasonLocalizerTests.cs | 52 +++++++++ .../UI/MessagePanelReportingTests.cs | 8 +- .../Localization/ReasonLocalizer.cs | 5 + .../Localization/Resources.Designer.cs | 6 + .../SemiStep.UI/Localization/Resources.resx | 9 ++ .../Localization/Resources.ru.resx | 9 ++ .../20260730-clipboard-import-errors-typed.md | 107 ++++++++++++++++++ 16 files changed, 300 insertions(+), 20 deletions(-) create mode 100644 SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/ClipboardParseFailedError.cs create mode 100644 SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/ColumnCountMismatchError.cs create mode 100644 SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/NoValidStepsError.cs create mode 100644 docs/plans/completed/20260730-clipboard-import-errors-typed.md diff --git a/Docs/architecture/error-reporting.md b/Docs/architecture/error-reporting.md index 27b32000..987a9c2c 100644 --- a/Docs/architecture/error-reporting.md +++ b/Docs/architecture/error-reporting.md @@ -129,12 +129,20 @@ 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`. -The CSV import producers now localize by type too. `CsvRowConverter`/`CsvFileSerializer`/`CsvService` +The CSV **and clipboard** 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` +surfaces the full localized `AtRow -> AtColumn -> typed value error` chain under `ru`. `ClipboardSerializer` +(the paste path) raises the same shared tabular-parse errors — `AtRowError`/`AtColumnError`, `ActionColumnEmptyError`, +`ActionValueNotIntegerError`, `ActionByIdNotFoundError`, `ActionColumnNotFoundError` — plus three clipboard-specific +leaves (`ClipboardParseFailedError`, `ColumnCountMismatchError`, `NoValidStepsError` in +`SemiStep.Core/Recipes/Clipboard/Errors/`), so a non-parseable pasted cell surfaces the same +`Строка 1: Столбец «task»: Не удалось разобрать «abc» как float` chain under `ru`. The shared tabular-parse +errors serve both the CSV and the clipboard ingress; with both typed, the file-and-paste import surface is fully +localized. `ClipboardParseFailedError` is a Rule-B exception-envelope (headline only, `.CausedBy(ex)`, detail kept in +the log through `ClipboardSerializer`'s `logger.LogWarning(..., ex.Message)`), mirroring `CsvService`. 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 @@ -142,8 +150,7 @@ 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): 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, +Still free-text (deferred to a later wave): 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)`. diff --git a/SemiStep/SemiStep.Core/Recipes/Clipboard/ClipboardSerializer.cs b/SemiStep/SemiStep.Core/Recipes/Clipboard/ClipboardSerializer.cs index 0853443b..1b9c6373 100644 --- a/SemiStep/SemiStep.Core/Recipes/Clipboard/ClipboardSerializer.cs +++ b/SemiStep/SemiStep.Core/Recipes/Clipboard/ClipboardSerializer.cs @@ -6,9 +6,17 @@ using FluentResults; +using Microsoft.Extensions.Logging; + +using SemiStep.Core.Recipes.Clipboard.Errors; +using SemiStep.Core.Recipes.Errors; +using SemiStep.Core.Recipes.Import.Errors; + namespace SemiStep.Core.Recipes.Clipboard; -public sealed class ClipboardSerializer(RecipeMetadataRegistry recipeMetadataRegistry) +public sealed class ClipboardSerializer( + RecipeMetadataRegistry recipeMetadataRegistry, + ILogger logger) { private const char Separator = '\t'; @@ -54,7 +62,8 @@ public Result DeserializeSteps(string tsvBody) } catch (Exception ex) { - return Result.Fail($"Failed to parse clipboard data: {ex.Message}"); + logger.LogWarning("Failed to parse clipboard data: {Message}", ex.Message); + return Result.Fail(new ClipboardParseFailedError().CausedBy(ex)); } } @@ -83,8 +92,7 @@ private Result ReadAllSteps( if (csvReader.ColumnCount > csvColumns.Count) { return Result.Fail( - $"Column count mismatch on row {rowNumber}: expected {csvColumns.Count}, got {csvReader.ColumnCount}. " + - "The clipboard data does not match the current configuration."); + new ColumnCountMismatchError(rowNumber, csvColumns.Count, csvReader.ColumnCount)); } var stepResult = TryParseStep(csvReader, csvColumns, columnIndexMap, actionColumnIndex); @@ -92,7 +100,7 @@ private Result ReadAllSteps( { foreach (var error in stepResult.Errors) { - errors.Add(new Error($"Row {rowNumber}").CausedBy(error)); + errors.Add(new AtRowError(rowNumber, error)); } continue; @@ -108,7 +116,7 @@ private Result ReadAllSteps( if (steps.Count == 0) { - return Result.Fail("No valid steps found in clipboard data"); + return Result.Fail(new NoValidStepsError()); } return new Recipe(steps.ToImmutableList()); @@ -123,17 +131,17 @@ private Result TryParseStep( var rawAction = csvReader.GetField(actionColumnIndex); 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 (recipeMetadataRegistry.ActionExists(actionKey).IsFailed) { - return Result.Fail($"Unknown action ID '{actionKey}'"); + return Result.Fail(new ActionByIdNotFoundError(actionKey)); } var actionDef = recipeMetadataRegistry.GetAction(actionKey).Value; @@ -188,7 +196,7 @@ private Result ParseProperties( { foreach (var error in parseResult.Errors) { - errors.Add(new Error($"Column '{column.Key}': {error.Message}")); + errors.Add(new AtColumnError(column.Key, error)); } } else @@ -215,7 +223,7 @@ private static Result FindActionColumnIndexOrFail(List BuildColumnIndexMap(List columns) diff --git a/SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/ClipboardParseFailedError.cs b/SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/ClipboardParseFailedError.cs new file mode 100644 index 00000000..2c2362e9 --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/ClipboardParseFailedError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Clipboard.Errors; + +public sealed class ClipboardParseFailedError() + : Error("Failed to parse clipboard data") +{ +} diff --git a/SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/ColumnCountMismatchError.cs b/SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/ColumnCountMismatchError.cs new file mode 100644 index 00000000..0547abac --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/ColumnCountMismatchError.cs @@ -0,0 +1,14 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Clipboard.Errors; + +public sealed class ColumnCountMismatchError(int rowNumber, int expected, int actual) + : Error($"Column count mismatch on row {rowNumber}: expected {expected}, got {actual}. " + + "The clipboard data does not match the current configuration.") +{ + public int RowNumber { get; } = rowNumber; + + public int Expected { get; } = expected; + + public int Actual { get; } = actual; +} diff --git a/SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/NoValidStepsError.cs b/SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/NoValidStepsError.cs new file mode 100644 index 00000000..64fe23d8 --- /dev/null +++ b/SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/NoValidStepsError.cs @@ -0,0 +1,8 @@ +using FluentResults; + +namespace SemiStep.Core.Recipes.Clipboard.Errors; + +public sealed class NoValidStepsError() + : Error("No valid steps found in clipboard data") +{ +} diff --git a/SemiStep/SemiStep.Tests/Csv/Integration/CsvDeserializationTests.cs b/SemiStep/SemiStep.Tests/Csv/Integration/CsvDeserializationTests.cs index c51ddcbf..67ecb7b4 100644 --- a/SemiStep/SemiStep.Tests/Csv/Integration/CsvDeserializationTests.cs +++ b/SemiStep/SemiStep.Tests/Csv/Integration/CsvDeserializationTests.cs @@ -3,7 +3,10 @@ using FluentAssertions; using SemiStep.Core.Recipes; +using SemiStep.Core.Recipes.Clipboard.Errors; using SemiStep.Tests.Csv.Helpers; +using SemiStep.Tests.UI.Localization; +using SemiStep.UI.Localization; using Xunit; @@ -94,6 +97,7 @@ public void ClipboardDeserialize_EmptyString_ReturnsNull() var result = fixture.ClipboardSerializer.DeserializeSteps(""); result.IsFailed.Should().BeTrue(); + result.Errors[0].Should().BeOfType(); } [Fact] @@ -111,6 +115,17 @@ public void ClipboardDeserialize_MalformedQuotedData_ReturnsNull() var result = fixture.ClipboardSerializer.DeserializeSteps(malformed); result.IsFailed.Should().BeTrue(); + result.Errors[0].Should().BeOfType(); + } + + [Fact] + public void ClipboardDeserialize_UnescapedQuote_ReturnsClipboardParseFailed() + { + var unescapedQuote = "1\"0\t5.0"; + var result = fixture.ClipboardSerializer.DeserializeSteps(unescapedQuote); + + result.IsFailed.Should().BeTrue(); + result.Errors[0].Should().BeOfType(); } [Fact] @@ -134,4 +149,21 @@ public void ClipboardDeserialize_MoreColumnsThanExpected_ReturnsColumnCountMisma result.Errors.Should().ContainSingle() .Which.Message.Should().Contain("Column count mismatch"); } + + [Fact] + public void ClipboardDeserialize_NonParseableValue_UnderRussianCulture_ComposesLocalizedRowColumnValueChain() + { + // Field order: action, step_duration, task (float), comment. Action 20 (For) declares the float + // "task" property. + var body = "20\t5.0\tabc\tc"; + var result = fixture.ClipboardSerializer.DeserializeSteps(body); + + result.IsFailed.Should().BeTrue(); + + using (ResourcesCultureScope.Use("ru")) + { + var localized = ReasonLocalizer.Localize(result.Errors[0]); + localized.Should().Contain("Строка 1: Столбец «task»: Не удалось разобрать «abc» как float"); + } + } } diff --git a/SemiStep/SemiStep.Tests/UI/Clipboard/ClipboardViewModelCanExecuteTests.cs b/SemiStep/SemiStep.Tests/UI/Clipboard/ClipboardViewModelCanExecuteTests.cs index 30c0dfda..aa2c7d7f 100644 --- a/SemiStep/SemiStep.Tests/UI/Clipboard/ClipboardViewModelCanExecuteTests.cs +++ b/SemiStep/SemiStep.Tests/UI/Clipboard/ClipboardViewModelCanExecuteTests.cs @@ -33,7 +33,9 @@ public async ValueTask InitializeAsync() _surface = _fixture.CreateCanonicalSurface(); _surface.Initialize(); - var clipboardSerializer = new ClipboardSerializer(_fixture.RecipeMetadataRegistry); + var clipboardSerializer = new ClipboardSerializer( + _fixture.RecipeMetadataRegistry, + NullLogger.Instance); var importedRecipeValidator = new ImportedRecipeValidator(_fixture.RecipeMetadataRegistry); _clipboard = new ClipboardViewModel( @@ -114,7 +116,7 @@ public void Cut_CanExecuteTrue_WhenSelectionExistsBeforeConstruction() using var clipboard = new ClipboardViewModel( _fixture.Coordinator, _surface, - new ClipboardSerializer(_fixture.RecipeMetadataRegistry), + new ClipboardSerializer(_fixture.RecipeMetadataRegistry, NullLogger.Instance), new ImportedRecipeValidator(_fixture.RecipeMetadataRegistry), _fixture.MessagePanel, NullLogger.Instance); diff --git a/SemiStep/SemiStep.Tests/UI/Helpers/UIFixture.cs b/SemiStep/SemiStep.Tests/UI/Helpers/UIFixture.cs index 49432f81..556d6956 100644 --- a/SemiStep/SemiStep.Tests/UI/Helpers/UIFixture.cs +++ b/SemiStep/SemiStep.Tests/UI/Helpers/UIFixture.cs @@ -152,7 +152,9 @@ public MainWindowViewModel CreateMainWindowViewModel( MessagePanel, NullLogger.Instance); - var clipboardSerializer = new ClipboardSerializer(RecipeMetadataRegistry); + var clipboardSerializer = new ClipboardSerializer( + RecipeMetadataRegistry, + NullLogger.Instance); var importedRecipeValidator = new ImportedRecipeValidator(RecipeMetadataRegistry); var clipboard = new ClipboardViewModel( Coordinator, diff --git a/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs b/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs index 67db4d1b..64a4c66a 100644 --- a/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs +++ b/SemiStep/SemiStep.Tests/UI/Localization/CoreErrorLocalizationCoverageTests.cs @@ -9,6 +9,7 @@ using SemiStep.Core.Plc.Sync.Ownership; using SemiStep.Core.Recipes; using SemiStep.Core.Recipes.Analysis.Warnings; +using SemiStep.Core.Recipes.Clipboard.Errors; using SemiStep.Core.Recipes.Errors; using SemiStep.Core.Recipes.Formulas.Errors; using SemiStep.Core.Recipes.Import.Errors; @@ -40,6 +41,12 @@ public sealed class CoreErrorLocalizationCoverageTests new AtRowError(1, new Error("x")), [typeof(ActionColumnNotFoundError)] = new ActionColumnNotFoundError(), + [typeof(ClipboardParseFailedError)] = + new ClipboardParseFailedError(), + [typeof(ColumnCountMismatchError)] = + new ColumnCountMismatchError(1, 4, 5), + [typeof(NoValidStepsError)] = + new NoValidStepsError(), [typeof(ActionColumnEmptyError)] = new ActionColumnEmptyError(), [typeof(ActionValueNotIntegerError)] = diff --git a/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs b/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs index 040a0bc7..b66cd8a2 100644 --- a/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs +++ b/SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs @@ -9,6 +9,7 @@ using SemiStep.Core.Plc.Sync.Ownership; using SemiStep.Core.Recipes; using SemiStep.Core.Recipes.Analysis.Warnings; +using SemiStep.Core.Recipes.Clipboard.Errors; using SemiStep.Core.Recipes.Errors; using SemiStep.Core.Recipes.Formulas.Errors; using SemiStep.Core.Recipes.Import.Errors; @@ -444,6 +445,57 @@ public void Localize_CsvImportErrors_UnderEnglishCulture_MatchOriginalMessage() } } + [Fact] + public void Localize_ClipboardParseFailed_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new ClipboardParseFailedError()) + .Should().Be("Не удалось разобрать данные буфера обмена"); + } + } + + [Fact] + public void Localize_ColumnCountMismatch_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new ColumnCountMismatchError(2, 4, 5)) + .Should().Be( + "Несоответствие количества столбцов в строке 2: ожидалось 4, получено 5. " + + "Данные буфера обмена не соответствуют текущей конфигурации."); + } + } + + [Fact] + public void Localize_NoValidSteps_UnderRussianCulture_RendersRussianSentence() + { + using (ResourcesCultureScope.Use("ru")) + { + ReasonLocalizer.Localize(new NoValidStepsError()) + .Should().Be("В данных буфера обмена не найдено допустимых шагов"); + } + } + + [Fact] + public void Localize_ClipboardImportErrors_UnderEnglishCulture_MatchOriginalMessage() + { + IError[] samples = + [ + new ClipboardParseFailedError(), + new ColumnCountMismatchError(2, 4, 5), + new NoValidStepsError() + ]; + + 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/MessagePanelReportingTests.cs b/SemiStep/SemiStep.Tests/UI/MessagePanelReportingTests.cs index f35a270b..c59fa707 100644 --- a/SemiStep/SemiStep.Tests/UI/MessagePanelReportingTests.cs +++ b/SemiStep/SemiStep.Tests/UI/MessagePanelReportingTests.cs @@ -166,7 +166,9 @@ public void RecipeGrid_ChangeToUnknownAction_ReportsError() [AvaloniaFact] public async Task Clipboard_PasteInvalidContent_ReportsError() { - var clipboardSerializer = new ClipboardSerializer(_fixture.RecipeMetadataRegistry); + var clipboardSerializer = new ClipboardSerializer( + _fixture.RecipeMetadataRegistry, + NullLogger.Instance); var importedRecipeValidator = new ImportedRecipeValidator(_fixture.RecipeMetadataRegistry); var clipboardViewModel = new ClipboardViewModel( _fixture.Coordinator, @@ -367,7 +369,9 @@ public async Task Clipboard_PasteValidContent_LeavesNoOperationError() _fixture.Coordinator.NewRecipe(); _fixture.Coordinator.AppendStep(RecipeTestDriver.WaitActionId); - var clipboardSerializer = new ClipboardSerializer(_fixture.RecipeMetadataRegistry); + var clipboardSerializer = new ClipboardSerializer( + _fixture.RecipeMetadataRegistry, + NullLogger.Instance); var importedRecipeValidator = new ImportedRecipeValidator(_fixture.RecipeMetadataRegistry); var clipboardViewModel = new ClipboardViewModel( _fixture.Coordinator, diff --git a/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs b/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs index 477654d2..6e2e86ee 100644 --- a/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs +++ b/SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs @@ -5,6 +5,7 @@ using SemiStep.Core.Plc.Sync.Ownership; using SemiStep.Core.Recipes.Analysis.Warnings; +using SemiStep.Core.Recipes.Clipboard.Errors; using SemiStep.Core.Recipes.Errors; using SemiStep.Core.Recipes.Formulas.Errors; using SemiStep.Core.Recipes.Import.Errors; @@ -31,6 +32,10 @@ public static string Localize(IReason reason) 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)), + ClipboardParseFailedError => Resources.ErrorClipboardParseFailed, + ColumnCountMismatchError error => Format( + Resources.ErrorColumnCountMismatch, error.RowNumber, error.Expected, error.Actual), + NoValidStepsError => Resources.ErrorNoValidSteps, ActionColumnNotFoundError => Resources.ErrorActionColumnNotFound, ActionColumnEmptyError => Resources.ErrorActionColumnEmpty, ActionValueNotIntegerError error => Format(Resources.ErrorActionValueNotInteger, error.RawAction), diff --git a/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs b/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs index c8e5a76e..96156b8a 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs +++ b/SemiStep/SemiStep.UI/Localization/Resources.Designer.cs @@ -306,6 +306,12 @@ public static CultureInfo? Culture public static string ErrorActionColumnNotFound => ResourceManager.GetString("ErrorActionColumnNotFound", resourceCulture) ?? string.Empty; + public static string ErrorClipboardParseFailed => ResourceManager.GetString("ErrorClipboardParseFailed", resourceCulture) ?? string.Empty; + + public static string ErrorColumnCountMismatch => ResourceManager.GetString("ErrorColumnCountMismatch", resourceCulture) ?? string.Empty; + + public static string ErrorNoValidSteps => ResourceManager.GetString("ErrorNoValidSteps", resourceCulture) ?? string.Empty; + public static string ErrorActionColumnEmpty => ResourceManager.GetString("ErrorActionColumnEmpty", resourceCulture) ?? string.Empty; public static string ErrorActionValueNotInteger => ResourceManager.GetString("ErrorActionValueNotInteger", resourceCulture) ?? string.Empty; diff --git a/SemiStep/SemiStep.UI/Localization/Resources.resx b/SemiStep/SemiStep.UI/Localization/Resources.resx index f783c94d..63e003e2 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.resx +++ b/SemiStep/SemiStep.UI/Localization/Resources.resx @@ -466,6 +466,15 @@ Action column not found + + Failed to parse clipboard data + + + Column count mismatch on row {0}: expected {1}, got {2}. The clipboard data does not match the current configuration. + + + No valid steps found in clipboard data + Action column is empty diff --git a/SemiStep/SemiStep.UI/Localization/Resources.ru.resx b/SemiStep/SemiStep.UI/Localization/Resources.ru.resx index f5b4ae3b..d345b69f 100644 --- a/SemiStep/SemiStep.UI/Localization/Resources.ru.resx +++ b/SemiStep/SemiStep.UI/Localization/Resources.ru.resx @@ -466,6 +466,15 @@ Столбец действия не найден + + Не удалось разобрать данные буфера обмена + + + Несоответствие количества столбцов в строке {0}: ожидалось {1}, получено {2}. Данные буфера обмена не соответствуют текущей конфигурации. + + + В данных буфера обмена не найдено допустимых шагов + Столбец действия пуст diff --git a/docs/plans/completed/20260730-clipboard-import-errors-typed.md b/docs/plans/completed/20260730-clipboard-import-errors-typed.md new file mode 100644 index 00000000..30ac2fc0 --- /dev/null +++ b/docs/plans/completed/20260730-clipboard-import-errors-typed.md @@ -0,0 +1,107 @@ +# Slice 5c — Type the clipboard import errors + +## Overview + +The clipboard paste path (`ClipboardSerializer.DeserializeSteps`) still raises free-text `Result.Fail(string)` / +`new Error(string)` for every parse failure, so a paste error renders raw English regardless of culture. It reaches +the panel via `ClipboardViewModel.PasteStepsAsync` → `ReportFailure` (localizes by type, routed in #115). Slice 5b +typed the CSV import path and introduced the shared tabular-parse errors; this slice types the clipboard path, +**reusing** them. + +The payoff mirrors 5b, with one path difference: `ClipboardSerializer.ParseProperties` (`:186`) calls +`PropertyParser.Parse` **only** — NOT `PropertyValidator.Validate` (unlike `CsvRowConverter`, which does both). Range +checks (min/max) happen later, in `ImportedRecipeValidator` on the paste ViewModel path (`ClipboardViewModel. +DeserializeStepsFromClipboard:172`), which wraps as `AtStepError` (`Шаг N`, already localized in slice 3). So the +clipboard `AtRowError → AtColumnError → inner` chain only ever carries a **parse** error (`PropertyValueParseError`, +typed in 4c). The standard test config's property columns are `step_duration`/`task` (float) and `comment` (string) — +there is no int property column — so a non-parseable cell yields `PropertyValueParseError(rawValue, "float")`. The +payoff: pasting `abc` into the `task` (float) column composes, under `ru`, to +`Строка 1: Столбец «task»: Не удалось разобрать «abc» как float` — Russian end to end. + +**3 new typed classes** in `SemiStep.Core.Recipes.Clipboard.Errors`, plus **6 reuses** of errors shipped in 5b/4b/slice-3. +After this the clipboard+CSV ingress fully localizes. + +**Reuse map (no new class — the shared tabular-parse errors from 5b/4b/slice-3):** +- `:95` `new Error($"Row {rowNumber}").CausedBy(error)` → `new AtRowError(rowNumber, error)` (5b composing decorator; English gains `: {inner}` — the display fix, same as 5b). +- `:126` `Action column is empty` → `ActionColumnEmptyError` (5b, byte-identical). +- `:131` `Cannot parse action value '{rawAction}' as integer` → `ActionValueNotIntegerError(rawAction)` (5b, byte-identical). +- `:136` `Unknown action ID '{actionKey}'` (**`actionKey` is `int`**, parsed at `:129`) → `ActionByIdNotFoundError(actionKey)` (4b; English rewords to `Action with id {n} not found`, same unification as 5b). +- `:191` `new Error($"Column '{column.Key}': {error.Message}")` → `new AtColumnError(column.Key, error)` (slice-3, byte-identical, composes the typed inner). +- `:218` `Action column with key '{key}' not found in configuration` → `ActionColumnNotFoundError` (5b, fieldless) — same defect/trigger as CSV's `CsvRowConverter:29` (action column missing under the same `SaveToCsv` filter; the key is always the constant `"action"`), so reuse and accept the English change to `Action column not found` (the `:136` unification precedent). No new class. + +**New clipboard-specific classes:** +- `:57` catch-all `Failed to parse clipboard data: {ex.Message}` → `ClipboardParseFailedError` — **Rule-B exception-envelope**: no fields, message is the headline only (`Failed to parse clipboard data`), raised `.CausedBy(ex)`. **Mirror CSV's Rule-B exactly**: inject `ILogger` and add `_logger.LogWarning("Failed to parse clipboard data: {Message}", ex.Message)` at the catch, so the exception detail survives in the log (this catch is NOT rare — CsvHelper throws `BadDataException` on malformed quoted pastes, covered by `ClipboardDeserialize_MalformedQuotedData`). Without the log line `ex.Message` would vanish from both panel and log. +- `:85` `ColumnCountMismatchError(int rowNumber, int expected, int actual)` — message byte-identical (`Column count mismatch on row {0}: expected {1}, got {2}. The clipboard data does not match the current configuration.`). +- `:111` `No valid steps found in clipboard data` → `NoValidStepsError` (leaf, no fields). + +**Scope decisions:** +- The shared errors live in `SemiStep.Core.Recipes.Import.Errors` (5b) and `Recipes.Errors` (slice-3/4b). Reusing them + from `Recipes.Clipboard` is a mild namespace cross (clipboard referencing "import" errors), but they are the shared + tabular-parse errors 5b explicitly introduced for 5c to reuse — accept it rather than churn shipped code with a rename. +- `PropertyParser` is already typed (4c) — `AtColumnError` reuse composes its typed inner. +- Out of scope: PLC (6), style-editor (7), the config-load-culture boundary (English by design). + +**Behavior-preserving for English — mostly.** The reused byte-identical errors and the two new leaf/mismatch classes +keep English unchanged. Three deliberate changes (all flagged, same shape as 5b): `AtRowError` adds `: {inner}`, the +`ActionByIdNotFoundError` reuse rewords unknown-action, and `ClipboardParseFailedError` drops `: {ex.Message}`. + +## The typed classes + +resx key `Error`; en == current baked string unless flagged; ru guillemets «» where a value is quoted. + +| Class | Fields | en | ru | note | +|---|---|---|---|---| +| `ClipboardParseFailedError` | — | `Failed to parse clipboard data` | `Не удалось разобрать данные буфера обмена` | Rule-B; drops `: {ex.Message}`, `.CausedBy(ex)` + `LogWarning` | +| `ColumnCountMismatchError` | rowNumber:int, expected:int, actual:int | `Column count mismatch on row {0}: expected {1}, got {2}. The clipboard data does not match the current configuration.` | `Несоответствие количества столбцов в строке {0}: ожидалось {1}, получено {2}. Данные буфера обмена не соответствуют текущей конфигурации.` | leaf | +| `NoValidStepsError` | — | `No valid steps found in clipboard data` | `В данных буфера обмена не найдено допустимых шагов` | leaf | + +The ru column is a first pass — review in Rider before exec. + +## Task 1: Type the clipboard errors + rewire all sites + +**Files:** +- Create: `ClipboardParseFailedError.cs`, `ColumnCountMismatchError.cs`, `NoValidStepsError.cs` in `SemiStep/SemiStep.Core/Recipes/Clipboard/Errors/` (public sealed, one per file, BOM; leaf-error precedents). +- Modify: `SemiStep/SemiStep.Core/Recipes/Clipboard/ClipboardSerializer.cs` (ctor + sites 57, 85, 95, 111, 126, 131, 136, 191, 218). +- Verify (no edit expected): `ClipboardDi.AddClipboard()` uses `AddSingleton()` (open registration), and logging is already registered (`Program.cs` `AddLogging`, tests `.AddLogging()`), so the new `ILogger` ctor param auto-resolves with NO `ClipboardDi.cs` edit. Confirm this; do not hunt for a registration change that does not exist. +- Modify: `ReasonLocalizer.cs` (3 arms + usings), `Resources.resx`, `Resources.ru.resx`, `Resources.Designer.cs` (3 keys), `CoreErrorLocalizationCoverageTests.cs` (3 samples). + +- [x] Create the 3 new classes per the table. `ClipboardParseFailedError` is fieldless — base message `Failed to parse clipboard data` (headline only). `ColumnCountMismatchError(int rowNumber, int expected, int actual)` base message byte-identical to the concatenated `:85-87` string. Plain int interpolation. +- [x] `ClipboardSerializer.cs` ctor: add `ILogger` (primary-ctor param, matching `CsvService`'s logger injection). No `ClipboardDi.cs` edit needed (open registration auto-resolves). Update the **5** direct test construction sites that call `new ClipboardSerializer(...)`: `UIFixture.cs:155`, `MessagePanelReportingTests.cs:169` and `:370`, `ClipboardViewModelCanExecuteTests.cs:36` and `:117` — pass `NullLogger.Instance` (bounded). `CsvTestHelper.cs` resolves via DI (auto-injects), so it needs no change — confirm. +- [x] `ClipboardSerializer.cs` — rewire all 9 sites: `:57` → `_logger.LogWarning("Failed to parse clipboard data: {Message}", ex.Message);` then `return Result.Fail(new ClipboardParseFailedError().CausedBy(ex));`; `:85` → `new ColumnCountMismatchError(rowNumber, csvColumns.Count, csvReader.ColumnCount)`; `:95` → `new AtRowError(rowNumber, error)`; `:111` → `NoValidStepsError`; `:126` → `ActionColumnEmptyError`; `:131` → `ActionValueNotIntegerError(rawAction)`; `:136` → `new ActionByIdNotFoundError(actionKey)`; `:191` → `new AtColumnError(column.Key, error)` (pass the typed inner, do NOT stringify); `:218` → `ActionColumnNotFoundError` (reuse 5b; English drops the key detail → `Action column not found`). Add usings (`Microsoft.Extensions.Logging`, `SemiStep.Core.Recipes.Clipboard.Errors`, `SemiStep.Core.Recipes.Errors`, `SemiStep.Core.Recipes.Import.Errors`). +- [x] `ReasonLocalizer`: 3 arms for the new types (`Format`/bare `Resources.Error`). The reused types' arms (incl. `ActionColumnNotFoundError` from 5b) already exist. Add usings. +- [x] resx: `ErrorClipboardParseFailed`, `ErrorColumnCountMismatch`, `ErrorNoValidSteps` (en per table; ru per table) in BOTH resx + Designer accessors. Coverage: 3 samples. New error files BOM; Designer/resx BOM as-is. +- [x] **Test blast radius**: grep the Tests tree for `Failed to parse clipboard`, `Column count mismatch`, `No valid steps`, `not found in configuration`, `Unknown action ID`, `Row ` (clipboard), `Action column`. Update any clipboard test pinning `Unknown action ID '{n}'` → `Action with id {n} not found`. Verify any clipboard `FlattenMessage`/`.Message` assert survives the `AtRowError` composition (fragments now in `.Message`). If a test asserted the dropped `: {ex.Message}` of the parse-failed catch, update it to the headline / typed error. Localized-panel assertions under ambient ru → `ResourcesCultureScope.Use("en")`. +- [x] `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green. + +## Task 2: Cross-path tests + doc + verify + +**Files:** `ReasonLocalizerTests.cs`, the clipboard integration/panel test home, `Docs/architecture/error-reporting.md`. + +- [x] ru/en render cases: the 3 new types' ru render (literal Russian strings) + the en `Localize==Message` pin. (The reused types are already pinned by 5b.) +- [x] **Value-error end-to-end (the payoff):** a TSV clipboard body with a **non-parseable** cell (e.g. `abc` in an int column — NOT out-of-range, which clipboard-deserialize does not check; see the Overview), deserialized via the real `ClipboardSerializer.DeserializeSteps` and surfaced through `ReportFailure` under `ru`, renders `Строка 1: Столбец «task»: Не удалось разобрать «abc» как float` — proving the `AtRow`→`AtColumn`→`PropertyValueParseError` composition works for the clipboard path. Use a TSV body like `10\t5.0\tabc\tc` (no header; field order action, step_duration, task, comment; first data row = row 1). Compute the exact expected string from the ru resx (`AtRowFormat`/`AtColumnFormat`/`ErrorPropertyValueParse`) — confirm the `task` column is float-typed in the standard config. Home it beside the existing `ClipboardDeserialize_*` cases in `SemiStep.Tests/Csv/Integration/CsvDeserializationTests.cs` (uses `CsvFixture` / `fixture.ClipboardSerializer`). [decision: action 10 (Wait) declares no `task` property, so the task column is skipped for it — used action `20` (For), which declares the float `task`, body `20\t5.0\tabc\tc`; expected string unchanged.] +- [x] fragment sweep across the Tests tree for all Task 1 fragments; confirm raw-`.Message` asserts stay green and only the flagged sites changed; report what moved. +- [x] `Docs/architecture/error-reporting.md`: note the clipboard import producers now localize by type (the CSV+clipboard ingress is now fully typed); the shared tabular-parse errors serve both. Keep it proportionate. +- [x] full `dotnet build SemiStep.slnx` 0 warnings; full `dotnet test` green; `dotnet format`. + +## Post-Completion + +**Next:** with 5c the clipboard+CSV ingress fully localizes; the only remaining English-by-design surface is the +config-load-culture boundary (`Program.cs` sets `Resources.Culture` only after a successful config load, so a config-load +failure shows hardcoded English by necessity). Then slice 6 (#120 PLC — makes `NotConnectedError`/`ProtocolVersionMismatchError` +public + typed transport envelopes, un-launders `PlcSyncExecutor`/`PlcTransactionExecutor`, routes the `OnPlcFault` sink), +and slice 7 (style-editor `GridStyleEditorViewModel` `.Message`-join surface). + +--- + +**Executed by exec:** +- branch: clipboard-import-errors-typed +- commits: b82a87e (type + rewire 9 sites, ILogger inject, 6 reuses) · 44554f1 (cross-path tests + doc, payoff via action 20) · 27249bb (review-1: type-pin parse-failed/no-valid-steps end-to-end + new unescaped-quote catch test) · 1cb96b3 (smells: doc logger ref) · 701b397 (comments: trim payoff-test decode-key tail) +- review chain: comprehensive (5 agents, all OUTCOME ACHIEVED) → fixer 27249bb (LOW: end-to-end type pins) → smells → fixer 1cb96b3 (MINOR doc) → comment audit → fixer 701b397 (MINOR) → 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` — 1638 passed, 0 failed. +3. The clipboard composition payoff: `--filter "FullyQualifiedName~CsvDeserialization"` — a non-parseable cell pasted (`20\t5.0\tabc\tc`) through the real `ClipboardSerializer.DeserializeSteps` renders, under ru, `Строка 1: Столбец «task»: Не удалось разобрать «abc» как float` (AtRow→AtColumn→PropertyValueParseError). Pre-slice this showed just "Row 1". The typed `ClipboardParseFailedError` (unescaped-quote → BadDataException catch), `NoValidStepsError` (empty input), and `ColumnCountMismatchError` (too-many-columns) are each pinned end-to-end. +4. All 3 new types localize: `--filter "FullyQualifiedName~ReasonLocalizer|FullyQualifiedName~CoreErrorLocalizationCoverage"` — coverage forces a case per public Error subclass; the render cases pin each new ru string. +5. English preserved: the reused shared errors are byte-identical; `ColumnCountMismatchError`'s message is byte-identical (the existing `.Contains("Column count mismatch")` assert stays green). The three flagged changes (AtRowError `: {inner}`, ActionByIdNotFoundError reword, ClipboardParseFailedError drops `: {ex.Message}` → kept in the log via `logger.LogWarning`) are the only English deltas. +6. Manual (optional): under a Russian UI, paste TSV with a bad cell / too many columns / malformed quoting — the error shows in Russian with row/column context.