Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions Docs/architecture/error-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,21 +129,28 @@ 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
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,
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)`.

Expand Down
30 changes: 19 additions & 11 deletions SemiStep/SemiStep.Core/Recipes/Clipboard/ClipboardSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClipboardSerializer> logger)
{
private const char Separator = '\t';

Expand Down Expand Up @@ -54,7 +62,8 @@ public Result<Recipe> 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));
}
}

Expand Down Expand Up @@ -83,16 +92,15 @@ private Result<Recipe> 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);
if (stepResult.IsFailed)
{
foreach (var error in stepResult.Errors)
{
errors.Add(new Error($"Row {rowNumber}").CausedBy(error));
errors.Add(new AtRowError(rowNumber, error));
}

continue;
Expand All @@ -108,7 +116,7 @@ private Result<Recipe> 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());
Expand All @@ -123,17 +131,17 @@ private Result<Step> 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;
Expand Down Expand Up @@ -188,7 +196,7 @@ private Result<Step> ParseProperties(
{
foreach (var error in parseResult.Errors)
{
errors.Add(new Error($"Column '{column.Key}': {error.Message}"));
errors.Add(new AtColumnError(column.Key, error));
}
}
else
Expand All @@ -215,7 +223,7 @@ private static Result<int> FindActionColumnIndexOrFail(List<GridColumnDefinition
}
}

return Result.Fail($"Action column with key '{StepValueParser.ActionColumnKey}' not found in configuration");
return Result.Fail(new ActionColumnNotFoundError());
}

private static Dictionary<string, int> BuildColumnIndexMap(List<GridColumnDefinition> columns)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentResults;

namespace SemiStep.Core.Recipes.Clipboard.Errors;

public sealed class ClipboardParseFailedError()
: Error("Failed to parse clipboard data")
{
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentResults;

namespace SemiStep.Core.Recipes.Clipboard.Errors;

public sealed class NoValidStepsError()
: Error("No valid steps found in clipboard data")
{
}
32 changes: 32 additions & 0 deletions SemiStep/SemiStep.Tests/Csv/Integration/CsvDeserializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -94,6 +97,7 @@ public void ClipboardDeserialize_EmptyString_ReturnsNull()
var result = fixture.ClipboardSerializer.DeserializeSteps("");

result.IsFailed.Should().BeTrue();
result.Errors[0].Should().BeOfType<NoValidStepsError>();
}

[Fact]
Expand All @@ -111,6 +115,17 @@ public void ClipboardDeserialize_MalformedQuotedData_ReturnsNull()
var result = fixture.ClipboardSerializer.DeserializeSteps(malformed);

result.IsFailed.Should().BeTrue();
result.Errors[0].Should().BeOfType<ColumnCountMismatchError>();
}

[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<ClipboardParseFailedError>();
}

[Fact]
Expand All @@ -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");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClipboardSerializer>.Instance);
var importedRecipeValidator = new ImportedRecipeValidator(_fixture.RecipeMetadataRegistry);

_clipboard = new ClipboardViewModel(
Expand Down Expand Up @@ -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<ClipboardSerializer>.Instance),
new ImportedRecipeValidator(_fixture.RecipeMetadataRegistry),
_fixture.MessagePanel,
NullLogger<ClipboardViewModel>.Instance);
Expand Down
4 changes: 3 additions & 1 deletion SemiStep/SemiStep.Tests/UI/Helpers/UIFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ public MainWindowViewModel CreateMainWindowViewModel(
MessagePanel,
NullLogger<RecipeCommandsViewModel>.Instance);

var clipboardSerializer = new ClipboardSerializer(RecipeMetadataRegistry);
var clipboardSerializer = new ClipboardSerializer(
RecipeMetadataRegistry,
NullLogger<ClipboardSerializer>.Instance);
var importedRecipeValidator = new ImportedRecipeValidator(RecipeMetadataRegistry);
var clipboard = new ClipboardViewModel(
Coordinator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)] =
Expand Down
52 changes: 52 additions & 0 deletions SemiStep/SemiStep.Tests/UI/Localization/ReasonLocalizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down
8 changes: 6 additions & 2 deletions SemiStep/SemiStep.Tests/UI/MessagePanelReportingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClipboardSerializer>.Instance);
var importedRecipeValidator = new ImportedRecipeValidator(_fixture.RecipeMetadataRegistry);
var clipboardViewModel = new ClipboardViewModel(
_fixture.Coordinator,
Expand Down Expand Up @@ -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<ClipboardSerializer>.Instance);
var importedRecipeValidator = new ImportedRecipeValidator(_fixture.RecipeMetadataRegistry);
var clipboardViewModel = new ClipboardViewModel(
_fixture.Coordinator,
Expand Down
5 changes: 5 additions & 0 deletions SemiStep/SemiStep.UI/Localization/ReasonLocalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand Down
6 changes: 6 additions & 0 deletions SemiStep/SemiStep.UI/Localization/Resources.Designer.cs

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

Loading