From e0b7d27ba73a12ba76159d4163777165241a09f8 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 4 May 2026 19:03:47 +0200 Subject: [PATCH] Stage workspace controls after bootstrap --- .../Services/WorkspaceQueryServiceTests.cs | 245 ++-------- .../Pages/HomeControls/CampaignLogPanel.razor | 63 ++- .../HomeControls/CampaignLogPanel.razor.cs | 120 ++--- .../Pages/HomeControls/CharacterPanel.razor | 40 +- .../HomeControls/CharacterPanel.razor.cs | 104 ++--- RpgRoller/Components/Pages/Workspace.razor | 435 +++++++++--------- RpgRoller/Components/Pages/Workspace.razor.cs | 27 +- tests/e2e/smoke.spec.js | 83 ++++ 8 files changed, 542 insertions(+), 575 deletions(-) diff --git a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs index 1d1b1d6..8188bee 100644 --- a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs @@ -1,218 +1,74 @@ -using Microsoft.AspNetCore.Http; +using System.Text.Json; +using Microsoft.JSInterop; using RpgRoller.Components; namespace RpgRoller.Tests; public sealed class WorkspaceQueryServiceTests { - private sealed class StubGameService : IGameService + private sealed class StubJsRuntime(Func handler) : IJSRuntime { - public IReadOnlyList GetRulesets() + public ValueTask InvokeAsync(string identifier, object?[]? args) { - throw new NotSupportedException(); + return ValueTask.FromResult((TValue)handler(identifier, args, typeof(TValue))!); } - public ServiceResult Register(string username, string password, string displayName) + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, + object?[]? args) { - throw new NotSupportedException(); + return InvokeAsync(identifier, args); } - - public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password) - { - throw new NotSupportedException(); - } - - public void Logout(string sessionToken) - { - throw new NotSupportedException(); - } - - public UserSummary? GetUserBySession(string sessionToken) - { - throw new NotSupportedException(); - } - - public ServiceResult GetMe(string sessionToken) - { - return GetMeHandler(sessionToken); - } - - public ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId) - { - throw new NotSupportedException(); - } - - public ServiceResult> GetCampaigns(string sessionToken) - { - return GetCampaignsHandler(sessionToken); - } - - public ServiceResult> GetCharacterCampaignOptions(string sessionToken) - { - throw new NotSupportedException(); - } - - public ServiceResult GetCampaign(string sessionToken, Guid campaignId) - { - throw new NotSupportedException(); - } - - public ServiceResult DeleteCampaign(string sessionToken, Guid campaignId) - { - throw new NotSupportedException(); - } - - public ServiceResult> GetUsernames(string sessionToken) - { - throw new NotSupportedException(); - } - - public ServiceResult> GetUsers(string sessionToken) - { - throw new NotSupportedException(); - } - - public ServiceResult UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList roles) - { - throw new NotSupportedException(); - } - - public ServiceResult DeleteUser(string sessionToken, Guid userId) - { - throw new NotSupportedException(); - } - - public ServiceResult CreateCharacter(string sessionToken, string name, Guid campaignId) - { - throw new NotSupportedException(); - } - - public ServiceResult UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) - { - throw new NotSupportedException(); - } - - public ServiceResult DeleteCharacter(string sessionToken, Guid characterId) - { - throw new NotSupportedException(); - } - - public ServiceResult ActivateCharacter(string sessionToken, Guid characterId) - { - throw new NotSupportedException(); - } - - public ServiceResult> GetOwnCharacters(string sessionToken) - { - throw new NotSupportedException(); - } - - public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) - { - throw new NotSupportedException(); - } - - public ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) - { - throw new NotSupportedException(); - } - - public ServiceResult DeleteSkillGroup(string sessionToken, Guid skillGroupId) - { - throw new NotSupportedException(); - } - - public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false) - { - throw new NotSupportedException(); - } - - public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false) - { - throw new NotSupportedException(); - } - - public ServiceResult DeleteSkill(string sessionToken, Guid skillId) - { - throw new NotSupportedException(); - } - - public ServiceResult GetCharacterSheet(string sessionToken, Guid characterId) - { - throw new NotSupportedException(); - } - - public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0) - { - throw new NotSupportedException(); - } - - public ServiceResult RollCustom(string sessionToken, Guid characterId, string expression, string visibility) - { - throw new NotSupportedException(); - } - - public ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId) - { - throw new NotSupportedException(); - } - - public ServiceResult GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) - { - throw new NotSupportedException(); - } - - public ServiceResult GetRollDetail(string sessionToken, Guid rollId) - { - throw new NotSupportedException(); - } - - public ServiceResult GetCampaignStateSnapshot(string sessionToken, Guid campaignId) - { - throw new NotSupportedException(); - } - - public Func> GetMeHandler { get; init; } = _ => ServiceResult.Failure("unexpected_call", "Unexpected GetMe call."); - - public Func>> GetCampaignsHandler { get; init; } = _ => ServiceResult>.Failure("unexpected_call", "Unexpected GetCampaigns call."); } [Fact] - public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext() + public async Task GetCampaignsAsync_UsesCampaignsApiEndpoint() { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers.Cookie = "rpgroller_session=session-token"; - - var accessor = new HttpContextAccessor { HttpContext = httpContext }; - var sessionTokenAccessor = new WorkspaceSessionTokenAccessor(accessor); - - Assert.Equal("session-token", sessionTokenAccessor.GetRequiredSessionToken()); - } - - [Fact] - public async Task GetCampaignsAsync_UsesCapturedSessionToken() - { - var service = new StubGameService - { - GetCampaignsHandler = sessionToken => + var queryService = new WorkspaceQueryService(new RpgRollerApiClient( + new StubJsRuntime((identifier, args, returnType) => { - Assert.Equal("server-session", sessionToken); - return ServiceResult>.Success([new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), 1)]); - } - }; + Assert.Equal("rpgRollerApi.request", identifier); + Assert.Equal("GET", args![0]); + Assert.Equal("/api/campaigns", args[1]); + Assert.Null(args[2]); + + return CreateJsApiResponse(args: new + { + ok = true, + status = 200, + data = new[] + { + new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"), + 1) + } + }, returnType); + }))); - var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("server-session")); var campaigns = await queryService.GetCampaignsAsync(); - Assert.Single(campaigns); + var campaign = Assert.Single(campaigns); + Assert.Equal("Alpha", campaign.Name); } [Fact] - public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException() + public async Task GetMeAsync_MapsUnauthorizedApiResponseToApiRequestException() { - var service = new StubGameService { GetMeHandler = _ => ServiceResult.Failure("unauthorized", "You must be logged in.") }; + var queryService = new WorkspaceQueryService(new RpgRollerApiClient( + new StubJsRuntime((identifier, args, returnType) => + { + Assert.Equal("rpgRollerApi.request", identifier); + Assert.Equal("GET", args![0]); + Assert.Equal("/api/me", args[1]); + + return CreateJsApiResponse(args: new + { + ok = false, + status = 401, + error = "You must be logged in.", + code = "unauthorized", + data = (object?)null + }, returnType); + }))); - var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session")); var exception = await Assert.ThrowsAsync(queryService.GetMeAsync); Assert.Equal(401, exception.StatusCode); @@ -220,10 +76,11 @@ public sealed class WorkspaceQueryServiceTests Assert.Equal("unauthorized", exception.ErrorCode); } - private static WorkspaceSessionTokenAccessor CreateSessionTokenAccessor(string sessionToken) + private static object CreateJsApiResponse(object args, Type returnType) { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}"; - return new(new HttpContextAccessor { HttpContext = httpContext }); + var json = JsonSerializer.Serialize(args); + return JsonSerializer.Deserialize(json, returnType, JsonOptions)!; } + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); } \ No newline at end of file diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor index bdaca84..89c260e 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor @@ -82,30 +82,43 @@
-
-
- - @CustomRollStatusText + @if (EnableCustomRollComposer) + { + +
+ + @CustomRollStatusText +
+
+ + +
+

@CustomRollHelpText

+ @if (HasCustomRollError) + { + + } + + } + else + { +
+
+ Custom roll + @CustomRollStatusText +
+

Loading roll composer...

-
- - -
-

@CustomRollHelpText

- @if (HasCustomRollError) - { - - } - + }
- \ No newline at end of file + diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs index b7e9a6f..c90030e 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs @@ -29,7 +29,8 @@ public partial class CampaignLogPanel catch (JSDisconnectedException) { } - catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase)) + catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", + StringComparison.OrdinalIgnoreCase)) { } } @@ -58,11 +59,12 @@ public partial class CampaignLogPanel IsSubmittingCustomRoll = true; try { - var roll = await ApiClient.RequestAsync("POST", $"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new - { - expression, - visibility = NormalizedRollVisibility - }); + var roll = await ApiClient.RequestAsync("POST", + $"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new + { + expression, + visibility = NormalizedRollVisibility + }); CustomRollState.Model.Expression = string.Empty; CustomRollState.ResetValidation(); @@ -71,7 +73,8 @@ public partial class CampaignLogPanel await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef); await InvokeAsync(StateHasChanged); } - catch (ApiRequestException ex) when (string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal)) + catch (ApiRequestException ex) when + (string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal)) { SetCustomRollError(ex.Message); await InvokeAsync(StateHasChanged); @@ -93,7 +96,8 @@ public partial class CampaignLogPanel private static IReadOnlyList GetEventBadges(CampaignLogListEntry entry) { - return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null).Cast().ToArray(); + return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null) + .Cast().ToArray(); } private static bool HasSummary(CampaignLogListEntry entry) @@ -105,16 +109,16 @@ public partial class CampaignLogPanel { return code switch { - "w6" => new("Wild 6", "positive"), - "w1" => new("Wild 1", "danger"), - "n20" => new("Nat 20", "positive"), - "n1" => new("Nat 1", "danger"), - "rf" => new("Fumble", "danger"), + "w6" => new("Wild 6", "positive"), + "w1" => new("Wild 1", "danger"), + "n20" => new("Nat 20", "positive"), + "n1" => new("Nat 1", "danger"), + "rf" => new("Fumble", "danger"), "r100" => new("100", "rare"), - "r66" => new("66", "rare"), - "rs5" => new("Retry +5", "rare"), + "r66" => new("66", "rare"), + "rs5" => new("Retry +5", "rare"), "rs10" => new("Retry +10", "rare"), - _ => null + _ => null }; } @@ -130,11 +134,9 @@ public partial class CampaignLogPanel return string.Join(" ", classes); } - [Inject] - private IJSRuntime JS { get; set; } = null!; + [Inject] private IJSRuntime JS { get; set; } = null!; - [Inject] - private RpgRollerApiClient ApiClient { get; set; } = null!; + [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!; private ElementReference LogPanelRef { get; set; } private ElementReference LogFeedRef { get; set; } @@ -145,54 +147,44 @@ public partial class CampaignLogPanel private FormState CustomRollState { get; } = new(); private bool IsSubmittingCustomRoll { get; set; } - [Parameter] - public bool IsCampaignDataLoading { get; set; } + [Parameter] public bool IsCampaignDataLoading { get; set; } - [Parameter] - public IReadOnlyList CampaignLog { get; set; } = []; + [Parameter] public IReadOnlyList CampaignLog { get; set; } = []; - [Parameter] - public Guid? ExpandedRollId { get; set; } + [Parameter] public Guid? ExpandedRollId { get; set; } - [Parameter] - public Guid? FreshRollId { get; set; } + [Parameter] public Guid? FreshRollId { get; set; } - [Parameter] - public EventCallback ToggleRollDetailRequested { get; set; } + [Parameter] public EventCallback ToggleRollDetailRequested { get; set; } - [Parameter] - public Func ResolveRollDetail { get; set; } = _ => null; + [Parameter] public Func ResolveRollDetail { get; set; } = _ => null; - [Parameter] - public Func IsRollDetailLoading { get; set; } = _ => false; + [Parameter] public Func IsRollDetailLoading { get; set; } = _ => false; - [Parameter] - public Func GetRollDetailError { get; set; } = _ => null; + [Parameter] public Func GetRollDetailError { get; set; } = _ => null; - [Parameter] - public Guid? SelectedCharacterId { get; set; } + [Parameter] public Guid? SelectedCharacterId { get; set; } - [Parameter] - public string? SelectedCharacterName { get; set; } + [Parameter] public string? SelectedCharacterName { get; set; } - [Parameter] - public string SelectedCampaignRulesetId { get; set; } = string.Empty; + [Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty; - [Parameter] - public string RollVisibility { get; set; } = "public"; + [Parameter] public string RollVisibility { get; set; } = "public"; - [Parameter] - public bool IsMutating { get; set; } + [Parameter] public bool EnableCustomRollComposer { get; set; } - [Parameter] - public EventCallback CustomRollCreated { get; set; } + [Parameter] public bool IsMutating { get; set; } - [Parameter] - public EventCallback ErrorOccurred { get; set; } + [Parameter] public EventCallback CustomRollCreated { get; set; } + + [Parameter] public EventCallback ErrorOccurred { get; set; } private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression"); private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression"); - private bool IsCustomRollDisabled => IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue; + + private bool IsCustomRollDisabled => + IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue; + private string CustomRollInputCssClass => HasCustomRollError ? "custom-roll-input error" : "custom-roll-input"; private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null; private string CustomRollErrorElementId => "custom-roll-expression-error"; @@ -200,23 +192,31 @@ public partial class CampaignLogPanel private string CustomRollPlaceholder => SelectedCampaignRulesetId.ToLowerInvariant() switch { - RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4", - RulesetFormHelpers.RulesetIds.Dnd5e => "e.g. 2d12+2", + RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4", + RulesetFormHelpers.RulesetIds.Dnd5e => "e.g. 2d12+2", RulesetFormHelpers.RulesetIds.Rolemaster => "e.g. d10, 15d10, d100!+85", - _ => "Enter a roll expression" + _ => "Enter a roll expression" }; - private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName) ? $"For {SelectedCharacterName} • {RollVisibilityLabel}" : "Select a character to enable"; + private string CustomRollStatusText => + SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName) + ? $"For {SelectedCharacterName} • {RollVisibilityLabel}" + : "Select a character to enable"; private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch { - RulesetFormHelpers.RulesetIds.D6 => "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.", - RulesetFormHelpers.RulesetIds.Rolemaster => $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.", - _ => "Uses the selected campaign ruleset and current visibility." + RulesetFormHelpers.RulesetIds.D6 => + "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.", + RulesetFormHelpers.RulesetIds.Rolemaster => + $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.", + _ => "Uses the selected campaign ruleset and current visibility." }; - private string RollVisibilityLabel => string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public"; - private string NormalizedRollVisibility => string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; + private string RollVisibilityLabel => + string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public"; + + private string NormalizedRollVisibility => + string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; private string CustomRollExpression { diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor index a937bd0..309b014 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor @@ -49,20 +49,34 @@
- - + @if (EnableInteractiveControls) + { + + + } + else + { +

Loading skill controls...

+ }
- - + @if (EnableInteractiveControls) + { + + + } + else + { +

Visibility: @(RollVisibility == "private" ? "Private" : "Public")

+ }
@{ @@ -247,4 +261,4 @@ AvailableSkillGroups="SelectedCharacterSkillGroups" IsMutating="IsMutating" SkillSaved="OnSkillUpdatedAsync" - CancelRequested="CloseSkillModals"/> \ No newline at end of file + CancelRequested="CloseSkillModals"/> diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs index edaafe6..6f1a848 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs @@ -9,7 +9,9 @@ public partial class CharacterPanel { private void OpenCreateSkillModal(Guid? skillGroupId = null) { - var selectedGroup = skillGroupId.HasValue ? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value) : null; + var selectedGroup = skillGroupId.HasValue + ? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value) + : null; CreateSkillInitialModel = new() { @@ -176,7 +178,11 @@ public partial class CharacterPanel try { var selectedCharacterId = SelectedCharacterId!.Value; - var createdGroup = await ApiClient.RequestAsync("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange)); + var createdGroup = await ApiClient.RequestAsync("POST", + $"/api/characters/{selectedCharacterId}/skill-groups", + new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), + SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, + SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange)); CloseSkillGroupModals(); await SkillGroupCreated.InvokeAsync(createdGroup.Id); } @@ -230,7 +236,11 @@ public partial class CharacterPanel try { var editingSkillGroupId = EditingSkillGroupId!.Value; - var updatedGroup = await ApiClient.RequestAsync("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange)); + var updatedGroup = await ApiClient.RequestAsync("PUT", + $"/api/skill-groups/{editingSkillGroupId}", + new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), + SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, + SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange)); CloseSkillGroupModals(); await SkillGroupUpdated.InvokeAsync(updatedGroup.Id); } @@ -276,7 +286,8 @@ public partial class CharacterPanel return true; var filter = SkillFilterText.Trim(); - return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase); + return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || + skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase); } private static string InitialsFor(string value) @@ -317,9 +328,13 @@ public partial class CharacterPanel private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId); private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId); - private bool IsSkillGroupRolemasterOpenEnded => RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition); - private string SkillGroupExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the default expression for skills created in this group."; + private bool IsSkillGroupRolemasterOpenEnded => + RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition); + + private string SkillGroupExpressionHelpText => IsRolemasterRuleset + ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." + : "Enter the default expression for skills created in this group."; private bool ShowCreateSkillModal { get; set; } private bool ShowEditSkillModal { get; set; } @@ -335,78 +350,55 @@ public partial class CharacterPanel private bool IsSubmittingSkillGroup { get; set; } private string SkillFilterText { get; set; } = string.Empty; - [Inject] - private RpgRollerApiClient ApiClient { get; set; } = null!; + [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!; - [Parameter] - public bool IsCampaignDataLoading { get; set; } + [Parameter] public bool IsCampaignDataLoading { get; set; } - [Parameter] - public CampaignRoster? SelectedCampaign { get; set; } + [Parameter] public CampaignRoster? SelectedCampaign { get; set; } - [Parameter] - public Guid? SelectedCharacterId { get; set; } + [Parameter] public Guid? SelectedCharacterId { get; set; } - [Parameter] - public CharacterSummary? SelectedCharacter { get; set; } + [Parameter] public CharacterSummary? SelectedCharacter { get; set; } - [Parameter] - public bool IsMutating { get; set; } + [Parameter] public bool IsMutating { get; set; } - [Parameter] - public IReadOnlyList SelectedCharacterSkills { get; set; } = []; + [Parameter] public IReadOnlyList SelectedCharacterSkills { get; set; } = []; - [Parameter] - public IReadOnlyList SelectedCharacterSkillGroups { get; set; } = []; + [Parameter] public IReadOnlyList SelectedCharacterSkillGroups { get; set; } = []; - [Parameter] - public string SelectedCampaignRulesetId { get; set; } = string.Empty; + [Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty; - [Parameter] - public string RollVisibility { get; set; } = "public"; + [Parameter] public string RollVisibility { get; set; } = "public"; - [Parameter] - public EventCallback RollVisibilityChanged { get; set; } + [Parameter] public bool EnableInteractiveControls { get; set; } - [Parameter] - public Func OwnerLabel { get; set; } = _ => string.Empty; + [Parameter] public EventCallback RollVisibilityChanged { get; set; } - [Parameter] - public Func SkillDefinitionLabel { get; set; } = _ => string.Empty; + [Parameter] public Func OwnerLabel { get; set; } = _ => string.Empty; - [Parameter] - public Func CanEditCharacter { get; set; } = _ => false; + [Parameter] public Func SkillDefinitionLabel { get; set; } = _ => string.Empty; - [Parameter] - public Func CanEditSkill { get; set; } = _ => false; + [Parameter] public Func CanEditCharacter { get; set; } = _ => false; - [Parameter] - public EventCallback CharacterSelected { get; set; } + [Parameter] public Func CanEditSkill { get; set; } = _ => false; - [Parameter] - public EventCallback EditCharacterRequested { get; set; } + [Parameter] public EventCallback CharacterSelected { get; set; } - [Parameter] - public EventCallback SkillCreated { get; set; } + [Parameter] public EventCallback EditCharacterRequested { get; set; } - [Parameter] - public EventCallback SkillUpdated { get; set; } + [Parameter] public EventCallback SkillCreated { get; set; } - [Parameter] - public EventCallback SkillGroupCreated { get; set; } + [Parameter] public EventCallback SkillUpdated { get; set; } - [Parameter] - public EventCallback SkillGroupUpdated { get; set; } + [Parameter] public EventCallback SkillGroupCreated { get; set; } - [Parameter] - public EventCallback SkillDeleted { get; set; } + [Parameter] public EventCallback SkillGroupUpdated { get; set; } - [Parameter] - public EventCallback SkillGroupDeleted { get; set; } + [Parameter] public EventCallback SkillDeleted { get; set; } - [Parameter] - public EventCallback ErrorOccurred { get; set; } + [Parameter] public EventCallback SkillGroupDeleted { get; set; } - [Parameter] - public EventCallback RollRequested { get; set; } + [Parameter] public EventCallback ErrorOccurred { get; set; } + + [Parameter] public EventCallback RollRequested { get; set; } } \ No newline at end of file diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index ca7d67e..643966b 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -1,244 +1,233 @@ @using RpgRoller.Components.Pages.HomeControls
- @if (!IsInitialized) +

@State.LiveAnnouncement

+ + @if (State.HasHealthIssue) { -
-

RpgRoller

-

Loading workspace...

-
+ } - else - { -

@State.LiveAnnouncement

- @if (State.HasHealthIssue) +
+ + + @if (State.IsPlayScreen) { - - } - -
- - - @if (State.IsPlayScreen) - { -
- - - -
- - } - else if (State.IsManagementScreen) - { - + - } - else if (State.IsAdminScreen) - { -
- @if (State.IsCurrentUserAdmin) - { -
-
-

Database

-
-

Download the current SQLite file for backup or offline inspection.

- -
- } + SkillCreated="Play.OnSkillCreatedAsync" + SkillUpdated="Play.OnSkillUpdatedAsync" + SkillGroupCreated="Play.OnSkillGroupCreatedAsync" + SkillGroupUpdated="Play.OnSkillGroupUpdatedAsync" + SkillDeleted="Play.OnSkillDeletedAsync" + SkillGroupDeleted="Play.OnSkillGroupDeletedAsync" + ErrorOccurred="Play.OnCharacterPanelErrorAsync" + RollRequested="Play.RollSkillAsync"/> + + +
+ + } + else if (State.IsManagementScreen) + { + + } + else if (State.IsAdminScreen) + { +
+ @if (State.IsCurrentUserAdmin) + {
-

User Management

+

Database

+
+

Download the current SQLite file for backup or offline inspection.

+ - @if (State.IsAdminDataLoading) - { -

Loading users...

- } - else if (!State.IsCurrentUserAdmin) - { -

Admin role is required to manage users.

- } - else if (State.AdminUsers.Count == 0) - { -

No users found.

- } - else - { -
    - @foreach (var user in State.AdminUsers) - { -
  • -
    - @user.Username -

    @user.DisplayName

    -

    Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))

    -
    -
    - - -
    -
  • - } -
- }
-
+ } +
+
+

User Management

+
+ @if (State.IsAdminDataLoading) + { +

Loading users...

+ } + else if (!State.IsCurrentUserAdmin) + { +

Admin role is required to manage users.

+ } + else if (State.AdminUsers.Count == 0) + { +

No users found.

+ } + else + { +
    + @foreach (var user in State.AdminUsers) + { +
  • +
    + @user.Username +

    @user.DisplayName

    +

    Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))

    +
    +
    + + +
    +
  • + } +
+ } +
+ + } +
+ + @if (State.Toasts.Count > 0) + { +
+ @foreach (var toast in State.Toasts) + { +
+

@toast.Message

+
}
- - @if (State.Toasts.Count > 0) - { -
- @foreach (var toast in State.Toasts) - { -
-

@toast.Message

-
- } -
- } }
-@if (IsInitialized) -{ - + - + - -} + diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index 11c62f9..91ddbfc 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -12,11 +12,28 @@ public partial class Workspace : IAsyncDisposable protected override async Task OnAfterRenderAsync(bool firstRender) { State.HasInteractiveRenderStarted = true; - if (!firstRender) + if (firstRender) + { + await Session.InitializeAsync(); + HasSessionInitialized = true; + await InvokeAsync(StateHasChanged); + return; + } + + if (!HasSessionInitialized) return; - await Session.InitializeAsync(); - IsInitialized = true; + if (!EnableCharacterControls) + { + EnableCharacterControls = true; + await InvokeAsync(StateHasChanged); + return; + } + + if (EnableCustomRollComposer) + return; + + EnableCustomRollComposer = true; await InvokeAsync(StateHasChanged); } @@ -98,8 +115,10 @@ public partial class Workspace : IAsyncDisposable [Parameter] public EventCallback LoggedOut { get; set; } - private bool IsInitialized { get; set; } private WorkspaceState State { get; } = new(); + private bool HasSessionInitialized { get; set; } + private bool EnableCharacterControls { get; set; } + private bool EnableCustomRollComposer { get; set; } private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, diff --git a/tests/e2e/smoke.spec.js b/tests/e2e/smoke.spec.js index 06e2040..60d2bc9 100644 --- a/tests/e2e/smoke.spec.js +++ b/tests/e2e/smoke.spec.js @@ -91,6 +91,89 @@ test("successful login transitions to play workspace", async ({ page, context }) await expect(page.locator("#login-username")).toHaveCount(0); }); +test("workspace stays usable when input controls are DOM-wrapped during mount", async ({ page, context }) => { + const username = `wrapped-${Date.now()}`; + await registerAndLogin(context.request, username, "Wrapped Inputs"); + + const campaign = await postJson(context.request, "/api/campaigns", { + name: "Wrapped Inputs", + rulesetId: "d6" + }); + const character = await postJson(context.request, "/api/characters", { + name: "Wrapper Hero", + campaignId: campaign.id + }); + await postJson(context.request, `/api/characters/${character.id}/skills`, { + name: "Stealth", + diceRollDefinition: "2D+1", + wildDice: 1, + allowFumble: true + }); + + await page.addInitScript(() => { + const wrappedMarker = "rrWrappedByTest"; + + function wrapControl(element) { + if (!(element instanceof HTMLElement) || !element.isConnected || element.dataset[wrappedMarker] === "1") { + return; + } + + const parent = element.parentNode; + if (!parent) { + return; + } + + const wrapper = document.createElement("span"); + wrapper.dataset[wrappedMarker] = "1"; + element.dataset[wrappedMarker] = "1"; + parent.insertBefore(wrapper, element); + wrapper.appendChild(element); + } + + function queueWrap(node) { + if (!(node instanceof Element)) { + return; + } + + if (node.matches("input, select")) { + queueMicrotask(() => wrapControl(node)); + } + + node.querySelectorAll("input, select").forEach((element) => { + queueMicrotask(() => wrapControl(element)); + }); + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach(queueWrap); + }); + }); + + observer.observe(document.documentElement, { childList: true, subtree: true }); + document.querySelectorAll("input, select").forEach((element) => queueWrap(element)); + }); + + const blazorErrors = []; + page.on("console", (message) => { + if (message.type() !== "error") { + return; + } + + const text = message.text(); + if (/error applying batch|unhandled exception on the current circuit/i.test(text)) { + blazorErrors.push(text); + } + }); + + await page.goto("/"); + await expect(page.getByText("Campaign Log")).toBeVisible(); + await expect(page.locator("#skill-filter-input")).toBeVisible(); + await expect(page.locator("#custom-roll-expression")).toBeVisible(); + await expect(page.getByRole("button", { name: "Roll Stealth" })).toBeVisible(); + expect(blazorErrors).toEqual([]); +}); + test("Rolemaster open-ended roll detail renders specialized dice chips", async ({ page, context }) => { const username = `rm-${Date.now()}`; const displayName = "Rolemaster Smoke";