Fix campaigns rerender after mutations

This commit is contained in:
2026-05-05 01:10:04 +02:00
parent 777befdbf0
commit ba9536de12
4 changed files with 285 additions and 4 deletions

View File

@@ -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<string>();
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<string>();
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<string>();
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<Guid?, Task>? reloadCampaignsAsync = null,
Func<Task>? reloadCharacterCampaignOptionsAsync = null,
Func<Task>? refreshCampaignScopeAsync = null,
Func<Task>? syncStateEventsAsync = null,
Func<Task>? 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<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
{
return ValueTask.FromResult(default(TValue)!);
}
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken,
object?[]? args)
{
return InvokeAsync<TValue>(identifier, args);
}
}
}

View File

@@ -161,7 +161,7 @@ public partial class Workspace : IAsyncDisposable
private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient, private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient,
Session.LoadKnownUsernamesAsync, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, 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, private WorkspaceAdminCoordinator Admin => m_Admin ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery,
ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message)); ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message));

View File

@@ -6,7 +6,17 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, Func<Task> loadKnownUsernamesAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync) public sealed class WorkspaceCampaignCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
RpgRollerApiClient apiClient,
Func<Task> loadKnownUsernamesAsync,
Func<Guid?, Task> reloadCampaignsAsync,
Func<Task> reloadCharacterCampaignOptionsAsync,
Func<Task> refreshCampaignScopeAsync,
Func<Task> syncStateEventsAsync,
Func<Task> requestRefreshAsync)
{ {
public async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) public async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{ {
@@ -27,6 +37,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
await refreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await syncStateEventsAsync(); await syncStateEventsAsync();
feedback.SetStatus("Campaign created.", false); feedback.SetStatus("Campaign created.", false);
await requestRefreshAsync();
} }
public void OpenCreateCharacterModal() public void OpenCreateCharacterModal()
@@ -34,7 +45,8 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
state.CreateCharacterInitialModel = new() state.CreateCharacterInitialModel = new()
{ {
Name = string.Empty, 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 OwnerUsername = string.Empty
}; };
@@ -77,6 +89,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
await refreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await syncStateEventsAsync(); await syncStateEventsAsync();
feedback.SetStatus("Character created.", false); feedback.SetStatus("Character created.", false);
await requestRefreshAsync();
} }
public async Task OnCharacterUpdatedAsync(Guid? campaignId) public async Task OnCharacterUpdatedAsync(Guid? campaignId)
@@ -87,6 +100,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
await refreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await syncStateEventsAsync(); await syncStateEventsAsync();
feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false); feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
await requestRefreshAsync();
} }
public async Task DeleteSelectedCampaignAsync() public async Task DeleteSelectedCampaignAsync()
@@ -115,6 +129,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
finally finally
{ {
state.IsMutating = false; state.IsMutating = false;
await requestRefreshAsync();
} }
} }
@@ -144,12 +159,14 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
finally finally
{ {
state.IsMutating = false; state.IsMutating = false;
await requestRefreshAsync();
} }
} }
public bool CanEditCharacter(CharacterSummary character) 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) public bool CanDeleteCharacter(CharacterSummary character)

View File

@@ -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;
});