fix(welcome): notify wizard host on AccountStep validity change so Next enables (live e2e blocker) + regression test
All checks were successful
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Successful in 4m35s

AccountStep now exposes OnValidityChanged EventCallback<bool> and fires it at the end of every Validate() call (including OnInitialized). Routes.razor drops the @ref/IsValid polling pattern in favour of _accountValid updated via the callback + StateHasChanged, matching the existing OnRunningChanged pattern used by ApplyStep. Adds 5 bUnit regression tests covering: initial-invalid, all-valid, re-invalid on clear, short/non-numeric PIN, and pre-populated state on Back→Forward re-mount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysadmin
2026-06-09 10:25:38 +01:00
parent 166e4d8d0c
commit 4a5bd96ef8
3 changed files with 112 additions and 4 deletions

View File

@@ -40,7 +40,7 @@
<FlavourStep Flavours="_flavours" />
break;
case 2:
<AccountStep @ref="_accountStep" />
<AccountStep OnValidityChanged="@(v => { _accountValid = v; StateHasChanged(); })" />
break;
case 3:
<PrefsStep />
@@ -82,14 +82,14 @@
private int _currentStep = 0;
private bool _loading = true;
private bool _applyRunning = false;
private bool _accountValid = false;
private string? _error;
private IReadOnlyList<FlavourManifest> _flavours = Array.Empty<FlavourManifest>();
private AccountStep? _accountStep;
private bool CanGoNext => _currentStep switch
{
1 => State.Flavour is not null,
2 => _accountStep?.IsValid ?? false,
2 => _accountValid,
_ => true
};

View File

@@ -1,6 +1,7 @@
@inject WizardState State
<div class="step account-step">
<h1>Set Up Your Account</h1>
<p class="step-subtitle">Create your daily-use account and administrator credentials.</p>
@@ -53,7 +54,10 @@
private readonly Dictionary<string, string> _errors = new();
private readonly HashSet<string> _touched = new();
/// <summary>True when all fields are valid. Used by the wizard host to gate Next.</summary>
/// <summary>Notifies the wizard host whenever validity changes (and on initial mount).</summary>
[Parameter] public EventCallback<bool> OnValidityChanged { get; set; }
/// <summary>True when all fields are valid.</summary>
public bool IsValid { get; private set; }
protected override void OnInitialized() => Validate();
@@ -81,5 +85,6 @@
_errors["bitlockerpin"] = "BitLocker PIN must be all digits and at least 6 digits long.";
IsValid = _errors.Count == 0;
_ = OnValidityChanged.InvokeAsync(IsValid);
}
}

View File

@@ -0,0 +1,103 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using SilverOS.Welcome.App.Components;
using SilverOS.Welcome.App.Components.Steps;
using Xunit;
public class AccountStepTests : TestContext
{
// Helper: register WizardState and render AccountStep with an OnValidityChanged capture.
private (IRenderedComponent<AccountStep> cut, Func<bool?> lastValidity) RenderStep(WizardState? state = null)
{
var wizardState = state ?? new WizardState();
Services.AddSingleton(wizardState);
bool? captured = null;
var cut = RenderComponent<AccountStep>(p =>
p.Add(s => s.OnValidityChanged,
EventCallback.Factory.Create<bool>(this, v => captured = v)));
return (cut, () => captured);
}
[Fact]
public void OnValidityChanged_fires_false_on_initial_mount_with_empty_fields()
{
var (_, lastValidity) = RenderStep();
Assert.NotNull(lastValidity());
Assert.False(lastValidity(), "Step should be invalid on first mount (empty fields).");
}
[Fact]
public void OnValidityChanged_fires_true_after_all_valid_inputs_are_entered()
{
var (cut, lastValidity) = RenderStep();
// Simulate user filling in all four fields.
cut.Find("#username").Input("alice");
cut.Find("#password").Input("Secret1!");
cut.Find("#adminpassword").Input("Admin1!");
cut.Find("#bitlockerpin").Input("123456");
Assert.True(lastValidity(), "Step should be valid after all fields are correctly filled.");
}
[Fact]
public void OnValidityChanged_fires_false_when_a_field_is_cleared_after_being_valid()
{
var (cut, lastValidity) = RenderStep();
cut.Find("#username").Input("alice");
cut.Find("#password").Input("Secret1!");
cut.Find("#adminpassword").Input("Admin1!");
cut.Find("#bitlockerpin").Input("123456");
Assert.True(lastValidity()); // sanity
// Clear a required field — must revert to invalid.
cut.Find("#username").Input("");
Assert.False(lastValidity(), "Step should become invalid again when a required field is cleared.");
}
[Fact]
public void OnValidityChanged_fires_false_when_pin_is_non_numeric_or_too_short()
{
var (cut, lastValidity) = RenderStep();
cut.Find("#username").Input("alice");
cut.Find("#password").Input("Secret1!");
cut.Find("#adminpassword").Input("Admin1!");
// Too short — 5 digits.
cut.Find("#bitlockerpin").Input("12345");
Assert.False(lastValidity(), "PIN with only 5 digits must be invalid.");
// Non-numeric.
cut.Find("#bitlockerpin").Input("abc123");
Assert.False(lastValidity(), "Non-numeric PIN must be invalid.");
// Exactly 6 digits — valid.
cut.Find("#bitlockerpin").Input("123456");
Assert.True(lastValidity(), "Exactly 6 numeric digits is valid.");
}
[Fact]
public void OnValidityChanged_fires_true_on_mount_when_wizard_state_already_populated()
{
var prefilledState = new WizardState
{
Username = "alice",
Password = "Secret1!",
AdminPassword = "Admin1!",
BitLockerPin = "123456"
};
var (_, lastValidity) = RenderStep(prefilledState);
Assert.True(lastValidity(),
"Step should fire valid=true on mount when WizardState already has valid values (Back→Forward re-mount).");
}
}