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
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ public async Task SaveThenLoad_RoundTrips()
reloaded.Should().Be(original);
}

[Fact]
public async Task SaveThenLoad_DistinctFixture_PreservesEveryMappedField()
{
using var tempDir = new TempDirectory();
var facade = new GridStyleEditorFacade();

facade.Save(tempDir.Path, GridStyleOptionsTestData.Distinct()).IsSuccess.Should().BeTrue();

var reloaded = (await facade.Load(tempDir.Path)).Value;

reloaded.Should().Be(GridStyleOptionsTestData.Distinct());
}

[Fact]
public async Task Validate_ShippedRecord_ReturnsOk()
{
Expand Down
100 changes: 100 additions & 0 deletions SemiStep/SemiStep.Tests/Helpers/GridStyleOptionsTestData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using SemiStep.Core.Configuration;

namespace SemiStep.Tests.Helpers;

/// <summary>
/// Shared test fixture for the grid-style anti-regression guards. <see cref="Distinct"/> returns a
/// <see cref="GridStyleOptions"/> in which every surfaced field holds a distinct, valid, and
/// exactly-representable value, so that a dropped or cross-wired Seed/BuildRecord/mapper line makes
/// exactly one field mismatch. Colors are 53 mutually-distinct UPPERCASE opaque #RRGGBB literals
/// (equality is string equality; <c>ToHex</c> emits X2). Numerics are integers or clean halves so the
/// int↔decimal and double↔decimal conversions are exact. All five italics are <c>true</c> — the one
/// kind whose type default (<c>false</c>) can equal a fixture value — so a dropped italic Seed line
/// surfaces as false ≠ true. <see cref="GridStyleOptions.Orientation"/> is the non-default value, which
/// proves BuildRecord preserves the unsurfaced field through <c>with</c>.
/// </summary>
public static class GridStyleOptionsTestData
{
public static GridStyleOptions Distinct()
{
return new GridStyleOptions(
FontFamily: "Distinct Test Font",
HeaderFontSize: 13,
HeaderFontWeight: 300,
HeaderItalic: true,
CellFontSize: 11,
CellFontWeight: 800,
CellItalic: true,
CellPaddingLeft: 6.5,
CellPaddingTop: 4.5,
CellPaddingRight: 7.5,
CellPaddingBottom: 3.5,
RowHeight: 28.5,
SelectionBackgroundColor: "#010101",
SelectionForegroundColor: "#020202",
CellChangedColor: "#030303",
CellChangedSelectedColor: "#040404",
DisabledCellDepth0Color: "#050505",
DisabledCellDepth1Color: "#060606",
DisabledCellDepth2Color: "#070707",
DisabledCellDepth3Color: "#080808",
DisabledCellDepth0PastColor: "#090909",
DisabledCellDepth1PastColor: "#0A0A0A",
DisabledCellDepth2PastColor: "#0B0B0B",
DisabledCellDepth3PastColor: "#0C0C0C",
DisabledCellSelectedColor: "#0D0D0D",
DisabledCellForegroundColor: "#0E0E0E",
ReadOnlyCellDepth0Color: "#0F0F0F",
ReadOnlyCellDepth1Color: "#101010",
ReadOnlyCellDepth2Color: "#111111",
ReadOnlyCellDepth3Color: "#121212",
ReadOnlyCellDepth0PastColor: "#131313",
ReadOnlyCellDepth1PastColor: "#141414",
ReadOnlyCellDepth2PastColor: "#151515",
ReadOnlyCellDepth3PastColor: "#161616",
ReadOnlyCellSelectedColor: "#171717",
ReadOnlyCellForegroundColor: "#181818",
GridLineColor: "#191919",
StatusBarBackgroundColor: "#1A1A1A",
StatusBarForegroundColor: "#1B1B1B",
StatusBarPadding: 5.5,
StatusBarItemSpacing: 10.5,
StatusBarFontSize: 17,
StatusBarFontWeight: 500,
StatusBarItalic: true,
StatusBarTimerLabelFontSize: 19,
StatusBarTimerLabelFontWeight: 600,
StatusBarTimerLabelItalic: true,
StatusBarTimerValueFontSize: 23,
StatusBarTimerValueFontWeight: 700,
StatusBarTimerValueItalic: true,
ValidationPanelBackgroundColor: "#1C1C1C",
ValidationPanelForegroundColor: "#1D1D1D",
ValidationPanelErrorColor: "#1E1E1E",
ValidationPanelWarningColor: "#1F1F1F",
ValidationPanelMaxHeight: 150.5,
ExecutionDepth0Color: "#202020",
ExecutionDepth1Color: "#212121",
ExecutionDepth2Color: "#222222",
ExecutionDepth3Color: "#232323",
ExecutionDepth0PastColor: "#242424",
ExecutionDepth1PastColor: "#252525",
ExecutionDepth2PastColor: "#262626",
ExecutionDepth3PastColor: "#272727",
ExecutionCurrentStepMarkerColor: "#282828",
InfoColor: "#292929",
ConnectedColor: "#2A2A2A",
DisconnectedColor: "#2B2B2B",
LocalModeColor: "#2C2C2C",
ConnectingColor: "#2D2D2D",
PanelBackgroundColor: "#2E2E2E",
PanelHeaderBackgroundColor: "#2F2F2F",
SubtleBorderColor: "#303030",
SeparatorColor: "#313131",
SecondaryForegroundColor: "#323232",
GridBorderColor: "#333333",
GridBackgroundColor: "#343434",
HeaderForegroundColor: "#353535",
Orientation: GridOrientation.ColumnsAsSteps);
}
}
136 changes: 118 additions & 18 deletions SemiStep/SemiStep.Tests/UI/StyleEditor/GridStyleEditorViewModelTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Reactive;
using System.Reactive.Linq;
using System.Reflection;
using System.Text.RegularExpressions;

using Avalonia.Headless.XUnit;
Expand Down Expand Up @@ -31,36 +33,77 @@ namespace SemiStep.Tests.UI.StyleEditor;
public sealed class GridStyleEditorViewModelTests
{
private const string ConfigDir = @"C:\does-not-exist";
private const int SurfacedEditablePropertyCount = 77;

[AvaloniaFact]
public void Seed_PopulatesColorAndNumericProps_FromRecord()
public void Seed_PopulatesEverySurfacedProperty_FromDistinctFixture()
{
var source = GridStyleOptions.Default with
var fixture = GridStyleOptionsTestData.Distinct();
var viewModel = CreateViewModel(fixture);

var properties = EditableProperties();
properties.Should().HaveCount(
SurfacedEditablePropertyCount,
"the fixture must exercise every surfaced editable property");

foreach (var property in properties)
{
StatusBarFontSize = 15,
StatusBarTimerValueFontSize = 30
};
var viewModel = CreateViewModel(source);
var recordValue = RecordField(property).GetValue(fixture);
var actual = property.GetValue(viewModel);
var because = $"property {property.Name} must be seeded from the fixture";

if (property.PropertyType == typeof(Color))
{
actual.Should().Be(HexColor.Parse((string)recordValue!), because);
}
else if (property.PropertyType == typeof(decimal?))
{
// The record's int font sizes and double paddings box under reflection; a direct
// (decimal) cast on a boxed int throws, so normalize both sides via Convert.ToDecimal.
Convert.ToDecimal(actual).Should().Be(Convert.ToDecimal(recordValue), because);
}
else
{
actual.Should().Be(recordValue, because);
}
}
}

viewModel.CellFontSize.Should().Be(source.CellFontSize);
viewModel.RowHeight.Should().Be((decimal)source.RowHeight);
viewModel.ValidationPanelMaxHeight.Should().Be((decimal)source.ValidationPanelMaxHeight);
viewModel.StatusBarFontSize.Should().Be(15);
viewModel.StatusBarTimerValueFontSize.Should().Be(30);
[AvaloniaFact]
public void Seed_ThenBuildRecord_PreservesEveryFieldDistinctly()
{
var viewModel = CreateViewModel(GridStyleOptionsTestData.Distinct());

viewModel.SelectionBackground.Should().Be(Color.Parse(source.SelectionBackgroundColor));
viewModel.HeaderForeground.Should().Be(Color.Parse(source.HeaderForegroundColor));
viewModel.BuildRecord().Should().Be(GridStyleOptionsTestData.Distinct());
}

[AvaloniaFact]
public void Seed_RoundTripsShippedHexValues_Losslessly()
public void BuildRecord_PerturbingEachProperty_ChangesOnlyThatMappedField()
{
var source = GridStyleOptions.Default;
var viewModel = CreateViewModel(source);
var viewModel = CreateViewModel(GridStyleOptionsTestData.Distinct());

var record = viewModel.BuildRecord();
var properties = EditableProperties();
properties.Should().HaveCount(
SurfacedEditablePropertyCount,
"the perturbation guard must cover every surfaced editable property");

record.Should().Be(source);
foreach (var property in properties)
{
var seededValue = property.GetValue(viewModel);
var baseline = viewModel.BuildRecord();

property.SetValue(viewModel, Perturb(property, seededValue));
var built = viewModel.BuildRecord();

var mappedField = RecordField(property).Name;
var changedFields = ChangedRecordFields(baseline, built);
changedFields.Should().BeEquivalentTo(
new[] { mappedField },
$"editing {property.Name} must change exactly the {mappedField} record field");

// Restore the seeded value rather than reconstruct the VM: each ctor enumerates system fonts.
property.SetValue(viewModel, seededValue);
}
}

[AvaloniaFact]
Expand Down Expand Up @@ -385,6 +428,63 @@ private static GridStyleEditorViewModel CreateViewModel(GridStyleOptions source)
NullLogger<GridStyleEditorViewModel>.Instance);
}

private static IReadOnlyList<PropertyInfo> EditableProperties()
{
return typeof(GridStyleEditorViewModel).GetProperties()
.Where(property => property.GetMethod?.IsPublic == true && property.SetMethod?.IsPublic == true)
.ToList();
}

private static PropertyInfo RecordField(PropertyInfo viewModelProperty)
{
var fieldName = viewModelProperty.PropertyType == typeof(Color)
? viewModelProperty.Name + "Color"
: viewModelProperty.Name;

return typeof(GridStyleOptions).GetProperty(fieldName)
?? throw new InvalidOperationException($"No GridStyleOptions field maps to {viewModelProperty.Name}");
}

// Color perturbation stays opaque so ToHex changes; weights are unvalidated ints so any value round-trips.
private static object? Perturb(PropertyInfo property, object? current)
{
if (property.PropertyType == typeof(Color))
{
var color = (Color)current!;
return Color.FromArgb(255, (byte)(color.R ^ 1), color.G, color.B);
}

if (property.PropertyType == typeof(decimal?))
{
return (decimal?)current + 1;
}

if (property.PropertyType == typeof(int))
{
return (int)current! + 100;
}

if (property.PropertyType == typeof(bool))
{
return !(bool)current!;
}

if (property.PropertyType == typeof(string))
{
return (string)current! + " Perturbed";
}

throw new InvalidOperationException($"No perturbation defined for {property.Name} ({property.PropertyType})");
}

private static IReadOnlyList<string> ChangedRecordFields(GridStyleOptions baseline, GridStyleOptions candidate)
{
return typeof(GridStyleOptions).GetProperties()
.Where(property => !Equals(property.GetValue(baseline), property.GetValue(candidate)))
.Select(property => property.Name)
.ToList();
}

private static async Task ExecuteSwallowing(ReactiveCommand<Unit, bool> command)
{
try
Expand Down
Loading