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
41 changes: 39 additions & 2 deletions Docs/architecture/error-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,46 @@ survives in the log through `CsvService`'s existing `_logger.LogWarning(..., ex.
coordinator log joiner reads only the top-level `.Message` and does not walk `CausedBy`, so the localized
headline is what the operator reads while the exception rides the cause for future consumers).

Still free-text (deferred to a later wave): the style-editor and the five formula-internal free-text inners (null expression,
### The style-editor surface localizes by type

The in-app grid-style editor (`GridStyleEditorViewModel`) localizes its entire `ErrorMessage` surface by
type. Its two `Result`-join sites (`LoadAsync`, `Save`) route each error through `ReasonLocalizer.Localize`
rather than joining raw `.Message` strings, so both the validation failures and the file-I/O failures render
in the current culture. Eight typed producers back it (all in `SemiStep.Core/Configuration/`):

- **Validation** (`GridStyleValidator`): `GridStyleConfigMissingError`, `GridStyleSectionMissingError`,
`GridStyleOrientationInvalidError`, `GridStyleKeyMissingError`, `GridStyleHexColorInvalidError`. The
orientation error carries its two expected values as properties (`ExpectedRows`/`ExpectedColumns`) rather
than referencing the `internal GridOrientationValues` constants, which `ReasonLocalizer` (in `SemiStep.UI`)
cannot see.
- **File I/O** (`GridStyleLoader`/`GridStyleWriter`): `GridStyleConfigNotFoundError` (a leaf), plus the two
**Rule-B envelopes** `GridStyleLoadFailedError`/`GridStyleSaveFailedError` (headline only, `.CausedBy(ex)`,
the `: {ex.Message}` tail dropped from the sentence).

**Two diagnostics sinks for the Rule-B envelopes.** The loader/writer are logger-less internal helpers, so
the dropped `ex.Message` is logged at each consumer, and the `ExceptionalError` is nested under `CausedBy`
(walk one level: `result.Errors.SelectMany(e => e.Reasons).OfType<ExceptionalError>()`):

- **Editor consumer** — `GridStyleEditorViewModel.LoadAsync`/`Save` log each nested `ExceptionalError` at
`LogWarning` after setting the localized `ErrorMessage`.
- **Startup consumer** — `GridStyleLoader`/`GridStyleValidator` are also on the startup config-load path
(`ConfigFacade.LoadAndValidateAsync`, consumed by `Program.cs`), which logs the same nested
`ExceptionalError` so a YAML parse detail is not lost at launch.

`SaveCommand.ThrownExceptions` (a command-pipeline throw, before any file is written) is distinct: it commits
to `Resources.SaveFailed` alone (no `grid_style.yaml` in the wording, since no file was touched), with the raw
exception kept in the log — see the modal-dialog note under "Command-exception pipe".

With this surface typed, the editor no longer shows mixed languages (English loader error next to Russian
validator error). The **startup** config load — which reads YAML to *establish* the UI culture, so no culture
exists yet — is now the sole English-by-design surface among the config/recipe/editor operator surfaces
(the PLC layer keeps two malformed-wire diagnostic edges English by design — the short protocol-version
read and the codec decode failures — noted in "PLC faults localize by type" below).

Still free-text (deferred to a later wave): the five formula-internal free-text inners (null expression,
evaluation exception, non-finite, Int32/float overflow), which stay English under `ru` because their inner
is wrapped in a plain `new Error(text)`. (PLC is now typed — see "PLC faults localize by type" below —
is wrapped in a plain `new Error(text)`. (The style-editor surface is now typed — see "The style-editor
surface localizes by type" below. PLC is now typed — see "PLC faults localize by type" below —
except the short protocol-version read and the codec decode failures, which stay English by design as noted
there; the enable-catch cancellation message is also plain English, settled by slice 6c.)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using FluentResults;

namespace SemiStep.Core.Configuration;

public sealed class GridStyleConfigMissingError()
: Error("Grid style configuration is missing (ui/grid_style.yaml).")
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using FluentResults;

namespace SemiStep.Core.Configuration;

public sealed class GridStyleConfigNotFoundError(string filePath)
: Error($"Grid style config not found: {filePath}")
{
public string FilePath { get; } = filePath;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentResults;

namespace SemiStep.Core.Configuration;

public sealed class GridStyleHexColorInvalidError(string section, string key, string value)
: Error($"Grid style '{section}.{key}' has invalid hex color: '{value}'. Expected format: '#RRGGBB' or '#AARRGGBB'.")
{
public string Section { get; } = section;

public string Key { get; } = key;

public string Value { get; } = value;
}
11 changes: 11 additions & 0 deletions SemiStep/SemiStep.Core/Configuration/GridStyleKeyMissingError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using FluentResults;

namespace SemiStep.Core.Configuration;

public sealed class GridStyleKeyMissingError(string section, string key)
: Error($"Grid style '{section}.{key}' is missing or empty.")
{
public string Section { get; } = section;

public string Key { get; } = key;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using FluentResults;

namespace SemiStep.Core.Configuration;

public sealed class GridStyleLoadFailedError(string fileName)
: Error($"Failed to load {fileName}")
{
public string FileName { get; } = fileName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentResults;

namespace SemiStep.Core.Configuration;

public sealed class GridStyleOrientationInvalidError(string value, string expectedRows, string expectedColumns)
: Error($"Grid style 'orientation' has unknown value: '{value}'. Expected '{expectedRows}' or '{expectedColumns}'.")
{
public string Value { get; } = value;

public string ExpectedRows { get; } = expectedRows;

public string ExpectedColumns { get; } = expectedColumns;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using FluentResults;

namespace SemiStep.Core.Configuration;

public sealed class GridStyleSaveFailedError(string fileName)
: Error($"Failed to save {fileName}")
{
public string FileName { get; } = fileName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using FluentResults;

namespace SemiStep.Core.Configuration;

public sealed class GridStyleSectionMissingError(string section)
: Error($"Grid style configuration is missing '{section}' section.")
{
public string Section { get; } = section;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ internal static class GridStyleLoader

if (!Directory.Exists(uiDir))
{
return Result.Fail<GridStyleOptionsDto?>($"Grid style config not found: {uiDir}");
return Result.Fail<GridStyleOptionsDto?>(new GridStyleConfigNotFoundError(uiDir));
}

var filePath = Path.Combine(uiDir, "grid_style.yaml");

if (!File.Exists(filePath))
{
return Result.Fail<GridStyleOptionsDto?>($"Grid style config not found: {filePath}");
return Result.Fail<GridStyleOptionsDto?>(new GridStyleConfigNotFoundError(filePath));
}

try
Expand All @@ -38,7 +38,7 @@ internal static class GridStyleLoader
}
catch (Exception ex)
{
return Result.Fail($"Failed to load {Path.GetFileName(filePath)}: {ex.Message}");
return Result.Fail(new GridStyleLoadFailedError(Path.GetFileName(filePath)).CausedBy(ex));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public Result Save(string configDirectory, GridStyleOptions options)
}
catch (Exception ex)
{
return Result.Fail($"Failed to save {Path.GetFileName(filePath)}: {ex.Message}");
return Result.Fail(new GridStyleSaveFailedError(Path.GetFileName(filePath)).CausedBy(ex));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ public static Result Validate(GridStyleOptionsDto? dto)
{
if (dto is null)
{
return Result.Fail("Grid style configuration is missing (ui/grid_style.yaml).");
return Result.Fail(new GridStyleConfigMissingError());
}

if (dto.Colors is null)
{
return Result.Fail("Grid style configuration is missing 'colors' section.");
return Result.Fail(new GridStyleSectionMissingError("colors"));
}

var errors = new List<IError>();
Expand Down Expand Up @@ -182,9 +182,10 @@ private static void ValidateOrientation(string? orientation, List<IError> errors
return;
}

errors.Add(new Error(
$"Grid style 'orientation' has unknown value: '{orientation}'. " +
$"Expected '{GridOrientationValues.RowsAsSteps}' or '{GridOrientationValues.ColumnsAsSteps}'."));
errors.Add(new GridStyleOrientationInvalidError(
orientation,
GridOrientationValues.RowsAsSteps,
GridOrientationValues.ColumnsAsSteps));
}

private static void ValidateSection(
Expand All @@ -194,7 +195,7 @@ private static void ValidateSection(
{
if (keys is null)
{
errors.Add(new Error($"Grid style configuration is missing '{sectionPath}' section."));
errors.Add(new GridStyleSectionMissingError(sectionPath));
return;
}

Expand Down Expand Up @@ -229,15 +230,13 @@ private static void ValidateKey(string sectionPath, string keyName, string? valu
{
if (string.IsNullOrWhiteSpace(value))
{
errors.Add(new Error($"Grid style '{sectionPath}.{keyName}' is missing or empty."));
errors.Add(new GridStyleKeyMissingError(sectionPath, keyName));
return;
}

if (!_hexColorRegex.IsMatch(value))
{
errors.Add(new Error(
$"Grid style '{sectionPath}.{keyName}' has invalid hex color: '{value}'. " +
"Expected format: '#RRGGBB' or '#AARRGGBB'."));
errors.Add(new GridStyleHexColorInvalidError(sectionPath, keyName, value));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using FluentAssertions;

using FluentResults;

using SemiStep.Core.Configuration;
using SemiStep.Core.Configuration.Loaders;
using SemiStep.Tests.Config.Helpers;
using SemiStep.Tests.Helpers;

Expand Down Expand Up @@ -91,6 +94,42 @@ public async Task Save_InvalidRecord_FailsValidationGuardBeforeWriting()
after.Should().Be(before);
}

[Fact]
public async Task Load_UnparseableYaml_CarriesOriginalExceptionOnCausedBy()
{
using var tempDir = new TempDirectory();
var uiDir = Path.Combine(tempDir.Path, "ui");
Directory.CreateDirectory(uiDir);
await File.WriteAllTextAsync(
Path.Combine(uiDir, "grid_style.yaml"),
": : : not valid yaml @@@ {[",
TestContext.Current.CancellationToken);

var result = await GridStyleLoader.LoadAsync(tempDir.Path);

result.IsFailed.Should().BeTrue();
result.Errors.Should().ContainSingle().Which.Should().BeOfType<GridStyleLoadFailedError>();
var exceptional = result.Errors.SelectMany(error => error.Reasons).OfType<ExceptionalError>().ToList();
exceptional.Should().ContainSingle();
exceptional[0].Exception.Should().NotBeNull();
}

[Fact]
public void Save_WriteFailure_CarriesOriginalExceptionOnCausedBy()
{
using var tempDir = new TempDirectory();
var configDirAsFile = Path.Combine(tempDir.Path, "not-a-directory");
File.WriteAllText(configDirAsFile, string.Empty);

var result = new GridStyleWriter().Save(configDirAsFile, GridStyleOptions.Default);

result.IsFailed.Should().BeTrue();
result.Errors.Should().ContainSingle().Which.Should().BeOfType<GridStyleSaveFailedError>();
var exceptional = result.Errors.SelectMany(error => error.Reasons).OfType<ExceptionalError>().ToList();
exceptional.Should().ContainSingle();
exceptional[0].Exception.Should().NotBeNull();
}

private static TempDirectory CopyShippedConfig(string equipment)
{
var source = ShippedConfigLocator.GetConfigDirectory(equipment);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

using FluentResults;

using SemiStep.Core.Configuration;
using SemiStep.Core.Plc.S7.Protocol;
using SemiStep.Core.Plc.Sync;
using SemiStep.Core.Plc.Sync.Ownership;
Expand Down Expand Up @@ -37,6 +38,22 @@ public sealed class CoreErrorLocalizationCoverageTests
new NotConnectedError(),
[typeof(ProtocolVersionMismatchError)] =
new ProtocolVersionMismatchError(2, 1),
[typeof(GridStyleConfigMissingError)] =
new GridStyleConfigMissingError(),
[typeof(GridStyleSectionMissingError)] =
new GridStyleSectionMissingError("colors"),
[typeof(GridStyleOrientationInvalidError)] =
new GridStyleOrientationInvalidError("x", "rows_as_steps", "columns_as_steps"),
[typeof(GridStyleKeyMissingError)] =
new GridStyleKeyMissingError("colors.cells", "depth_0"),
[typeof(GridStyleHexColorInvalidError)] =
new GridStyleHexColorInvalidError("colors.cells", "depth_0", "zzz"),
[typeof(GridStyleConfigNotFoundError)] =
new GridStyleConfigNotFoundError("config/ui/grid_style.yaml"),
[typeof(GridStyleLoadFailedError)] =
new GridStyleLoadFailedError("grid_style.yaml"),
[typeof(GridStyleSaveFailedError)] =
new GridStyleSaveFailedError("grid_style.yaml"),
[typeof(RecipeActiveError)] =
new RecipeActiveError(),
[typeof(RecipeNotCommittedError)] =
Expand Down
Loading