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
36 changes: 25 additions & 11 deletions Docs/architecture/error-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, <typed value error>))`.

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»: <inner>"`. The two modes are
mirror images:

Expand Down Expand Up @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions SemiStep/SemiStep.Core/Recipes/Errors/AtRowError.cs
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 8 additions & 5 deletions SemiStep/SemiStep.Core/Recipes/Import/CsvFileSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -38,7 +41,7 @@ public Result<Recipe> Deserialize(string csvBody)

if (lines.Length == 0)
{
return Result.Fail("CSV body is empty");
return Result.Fail(new CsvBodyEmptyError());
}

var headerResult = ValidateHeader(lines[0]);
Expand All @@ -58,7 +61,7 @@ public Result<Recipe> 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
Expand All @@ -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();
Expand Down
14 changes: 8 additions & 6 deletions SemiStep/SemiStep.Core/Recipes/Import/CsvRowConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -26,23 +28,23 @@ public Result<Step> 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
Expand Down Expand Up @@ -86,7 +88,7 @@ public Result<Step> Convert(string csvLine)
{
foreach (var e in parseResult.Errors)
{
errors.Add(new Error($"Column '{columnKey}': {e.Message}"));
errors.Add(new AtColumnError(columnKey, e));
}

continue;
Expand All @@ -97,7 +99,7 @@ public Result<Step> Convert(string csvLine)
{
foreach (var e in validationResult.Errors)
{
errors.Add(new Error($"Column '{columnKey}': {e.Message}"));
errors.Add(new AtColumnError(columnKey, e));
}

continue;
Expand Down
11 changes: 6 additions & 5 deletions SemiStep/SemiStep.Core/Recipes/Import/CsvService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using Microsoft.Extensions.Logging;

using SemiStep.Core.Recipes.Import.Errors;
using SemiStep.Core.Recipes.Import.Warnings;
using SemiStep.Core.Shared;

Expand All @@ -22,7 +23,7 @@ public virtual async Task<Result<Recipe>> LoadAsync(string filePath)
{
if (!File.Exists(filePath))
{
return Result.Fail($"Recipe file not found: {filePath}");
return Result.Fail(new RecipeFileNotFoundError(filePath));
}

string bodyText;
Expand All @@ -34,12 +35,12 @@ public virtual async Task<Result<Recipe>> 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);
Expand Down Expand Up @@ -74,12 +75,12 @@ public virtual async Task<Result> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentResults;

namespace SemiStep.Core.Recipes.Import.Errors;

public sealed class ActionColumnEmptyError()
: Error("Action column is empty")
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentResults;

namespace SemiStep.Core.Recipes.Import.Errors;

public sealed class ActionColumnNotFoundError()
: Error("Action column not found")
{
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentResults;

namespace SemiStep.Core.Recipes.Import.Errors;

public sealed class CsvBodyEmptyError()
: Error("CSV body is empty")
{
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<CsvFixture>
{
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)] =
Expand Down
Loading