diff --git a/RpgRoller.Tests/WorkspaceCampaignCoordinatorTests.cs b/RpgRoller.Tests/WorkspaceCampaignCoordinatorTests.cs new file mode 100644 index 0000000..a48503b --- /dev/null +++ b/RpgRoller.Tests/WorkspaceCampaignCoordinatorTests.cs @@ -0,0 +1,173 @@ +using Microsoft.JSInterop; +using RpgRoller.Components; +using RpgRoller.Components.Pages; + +namespace RpgRoller.Tests; + +public sealed class WorkspaceCampaignCoordinatorTests +{ + [Fact] + public async Task OnCampaignCreatedAsync_RequestsRefreshAfterReloadingWorkspaceState() + { + var calls = new List(); + var coordinator = CreateCoordinator( + reloadCampaignsAsync: campaignId => + { + calls.Add($"reloadCampaigns:{campaignId:D}"); + return Task.CompletedTask; + }, + reloadCharacterCampaignOptionsAsync: () => + { + calls.Add("reloadCharacterCampaignOptions"); + return Task.CompletedTask; + }, + refreshCampaignScopeAsync: () => + { + calls.Add("refreshCampaignScope"); + return Task.CompletedTask; + }, + syncStateEventsAsync: () => + { + calls.Add("syncStateEvents"); + return Task.CompletedTask; + }, + requestRefreshAsync: () => + { + calls.Add("requestRefresh"); + return Task.CompletedTask; + }); + + var campaignId = Guid.NewGuid(); + await coordinator.OnCampaignCreatedAsync(campaignId); + + Assert.Equal([ + $"reloadCampaigns:{campaignId:D}", + "reloadCharacterCampaignOptions", + "refreshCampaignScope", + "syncStateEvents", + "requestRefresh" + ], calls); + } + + [Fact] + public async Task OnCharacterCreatedAsync_RequestsRefreshAfterReloadingWorkspaceState() + { + var calls = new List(); + var coordinator = CreateCoordinator( + reloadCampaignsAsync: campaignId => + { + calls.Add($"reloadCampaigns:{campaignId:D}"); + return Task.CompletedTask; + }, + reloadCharacterCampaignOptionsAsync: () => + { + calls.Add("reloadCharacterCampaignOptions"); + return Task.CompletedTask; + }, + refreshCampaignScopeAsync: () => + { + calls.Add("refreshCampaignScope"); + return Task.CompletedTask; + }, + syncStateEventsAsync: () => + { + calls.Add("syncStateEvents"); + return Task.CompletedTask; + }, + requestRefreshAsync: () => + { + calls.Add("requestRefresh"); + return Task.CompletedTask; + }); + + var campaignId = Guid.NewGuid(); + await coordinator.OnCharacterCreatedAsync(campaignId); + + Assert.Equal([ + $"reloadCampaigns:{campaignId:D}", + "reloadCharacterCampaignOptions", + "refreshCampaignScope", + "syncStateEvents", + "requestRefresh" + ], calls); + } + + [Fact] + public async Task OnCharacterUpdatedAsync_RequestsRefreshAfterReloadingWorkspaceState() + { + var calls = new List(); + var coordinator = CreateCoordinator( + reloadCampaignsAsync: campaignId => + { + calls.Add($"reloadCampaigns:{campaignId:D}"); + return Task.CompletedTask; + }, + reloadCharacterCampaignOptionsAsync: () => + { + calls.Add("reloadCharacterCampaignOptions"); + return Task.CompletedTask; + }, + refreshCampaignScopeAsync: () => + { + calls.Add("refreshCampaignScope"); + return Task.CompletedTask; + }, + syncStateEventsAsync: () => + { + calls.Add("syncStateEvents"); + return Task.CompletedTask; + }, + requestRefreshAsync: () => + { + calls.Add("requestRefresh"); + return Task.CompletedTask; + }); + + var campaignId = Guid.NewGuid(); + await coordinator.OnCharacterUpdatedAsync(campaignId); + + Assert.Equal([ + $"reloadCampaigns:{campaignId:D}", + "reloadCharacterCampaignOptions", + "refreshCampaignScope", + "syncStateEvents", + "requestRefresh" + ], calls); + } + + private static WorkspaceCampaignCoordinator CreateCoordinator( + Func? reloadCampaignsAsync = null, + Func? reloadCharacterCampaignOptionsAsync = null, + Func? refreshCampaignScopeAsync = null, + Func? syncStateEventsAsync = null, + Func? requestRefreshAsync = null) + { + var state = new WorkspaceState(); + var feedback = new WorkspaceFeedbackService(state, () => Task.CompletedTask); + return new WorkspaceCampaignCoordinator( + state, + feedback, + new StubJsRuntime(), + new RpgRollerApiClient(new StubJsRuntime()), + () => Task.CompletedTask, + reloadCampaignsAsync ?? (_ => Task.CompletedTask), + reloadCharacterCampaignOptionsAsync ?? (() => Task.CompletedTask), + refreshCampaignScopeAsync ?? (() => Task.CompletedTask), + syncStateEventsAsync ?? (() => Task.CompletedTask), + requestRefreshAsync ?? (() => Task.CompletedTask)); + } + + private sealed class StubJsRuntime : IJSRuntime + { + public ValueTask InvokeAsync(string identifier, object?[]? args) + { + return ValueTask.FromResult(default(TValue)!); + } + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, + object?[]? args) + { + return InvokeAsync(identifier, args); + } + } +} \ No newline at end of file diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index b8770b7..973c73f 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -161,7 +161,7 @@ public partial class Workspace : IAsyncDisposable private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient, Session.LoadKnownUsernamesAsync, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, - Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync); + Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync, RequestRefreshAsync); private WorkspaceAdminCoordinator Admin => m_Admin ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery, ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message)); diff --git a/RpgRoller/Components/Pages/WorkspaceCampaignCoordinator.cs b/RpgRoller/Components/Pages/WorkspaceCampaignCoordinator.cs index 2d0b20f..fcf3e20 100644 --- a/RpgRoller/Components/Pages/WorkspaceCampaignCoordinator.cs +++ b/RpgRoller/Components/Pages/WorkspaceCampaignCoordinator.cs @@ -6,7 +6,17 @@ using RpgRoller.Contracts; namespace RpgRoller.Components.Pages; [ExcludeFromCodeCoverage] -public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, Func loadKnownUsernamesAsync, Func reloadCampaignsAsync, Func reloadCharacterCampaignOptionsAsync, Func refreshCampaignScopeAsync, Func syncStateEventsAsync) +public sealed class WorkspaceCampaignCoordinator( + WorkspaceState state, + WorkspaceFeedbackService feedback, + IJSRuntime js, + RpgRollerApiClient apiClient, + Func loadKnownUsernamesAsync, + Func reloadCampaignsAsync, + Func reloadCharacterCampaignOptionsAsync, + Func refreshCampaignScopeAsync, + Func syncStateEventsAsync, + Func requestRefreshAsync) { public async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) { @@ -27,6 +37,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace await refreshCampaignScopeAsync(); await syncStateEventsAsync(); feedback.SetStatus("Campaign created.", false); + await requestRefreshAsync(); } public void OpenCreateCharacterModal() @@ -34,7 +45,8 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace state.CreateCharacterInitialModel = new() { Name = string.Empty, - CampaignId = state.SelectedCampaignId?.ToString() ?? state.CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty, + CampaignId = state.SelectedCampaignId?.ToString() ?? + state.CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty, OwnerUsername = string.Empty }; @@ -77,6 +89,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace await refreshCampaignScopeAsync(); await syncStateEventsAsync(); feedback.SetStatus("Character created.", false); + await requestRefreshAsync(); } public async Task OnCharacterUpdatedAsync(Guid? campaignId) @@ -87,6 +100,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace await refreshCampaignScopeAsync(); await syncStateEventsAsync(); feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false); + await requestRefreshAsync(); } public async Task DeleteSelectedCampaignAsync() @@ -115,6 +129,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace finally { state.IsMutating = false; + await requestRefreshAsync(); } } @@ -144,12 +159,14 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace finally { state.IsMutating = false; + await requestRefreshAsync(); } } public bool CanEditCharacter(CharacterSummary character) { - return state.User is not null && (character.OwnerUserId == state.User.Id || state.IsCurrentUserGm || state.IsCurrentUserAdmin); + return state.User is not null && + (character.OwnerUserId == state.User.Id || state.IsCurrentUserGm || state.IsCurrentUserAdmin); } public bool CanDeleteCharacter(CharacterSummary character) diff --git a/tests/e2e/campaigns-refresh.js b/tests/e2e/campaigns-refresh.js new file mode 100644 index 0000000..435b50d --- /dev/null +++ b/tests/e2e/campaigns-refresh.js @@ -0,0 +1,91 @@ +const assert = require("node:assert/strict"); +const { + absoluteUrl, + clickByTitle, + clickText, + fillInput, + getValue, + registerAndLoginApi, + runSmokeTests, + seedAuthenticatedBrowser, + uniqueName, + waitFor, + withDriver, + waitForSelector, + waitForText, + waitForUrl +} = require("./lib/selenium-smoke"); + +const tests = [ + { + name: "campaign management rerenders immediately after campaign and character mutations", + run: async () => withDriver({}, async (driver) => { + const username = uniqueName("campaign-refresh"); + const { sessionCookie } = await registerAndLoginApi(username, "Campaign Refresh"); + const campaignName = uniqueName("campaign"); + const characterName = uniqueName("character"); + const updatedCharacterName = uniqueName("character-updated"); + + await seedAuthenticatedBrowser(driver, sessionCookie); + await driver.get(absoluteUrl("/campaigns")); + await waitForUrl(driver, "/campaigns"); + await waitForText(driver, "Character Management"); + + await clickText(driver, "button", "Add campaign", { contains: true }); + await waitForSelector(driver, "#campaign-name"); + await fillInput(driver, "#campaign-name", campaignName); + await fillInput(driver, "#campaign-ruleset", "d6"); + await clickText(driver, "button", "Create Campaign"); + + await waitFor( + driver, + () => driver.executeScript( + (name) => (document.querySelector("#campaign-select")?.textContent || "").includes(name), + campaignName + ), + `Expected campaign ${campaignName} to appear in the campaign selector.` + ); + + const selectedCampaignId = await getValue(driver, "#campaign-select"); + assert.ok(selectedCampaignId, "Expected a selected campaign after campaign creation."); + + await clickText(driver, "button", "Add character", { contains: true }); + await waitForSelector(driver, "#character-create-name"); + await fillInput(driver, "#character-create-name", characterName); + await clickText(driver, "button", "Create Character"); + + await waitFor( + driver, + () => driver.executeScript( + (name) => [...document.querySelectorAll(".management-list strong")].some((element) => element.textContent.includes(name)), + characterName + ), + `Expected character ${characterName} to appear in the campaign roster.` + ); + + await clickByTitle(driver, "Edit character"); + await waitForSelector(driver, "#character-edit-name"); + await fillInput(driver, "#character-edit-name", updatedCharacterName); + await clickText(driver, "button", "Save Character"); + + await waitFor( + driver, + () => driver.executeScript( + (nextName, previousName) => { + const names = [...document.querySelectorAll(".management-list strong")] + .map((element) => element.textContent || ""); + return names.some((name) => name.includes(nextName)) && names.every((name) => !name.includes(previousName)); + }, + updatedCharacterName, + characterName + ), + `Expected updated character name ${updatedCharacterName} to appear immediately in the campaign roster.` + ); + }) + } +]; + +runSmokeTests(tests).catch((error) => { + console.error(error.stack || error); + process.exitCode = 1; +});