Fix campaigns rerender after mutations
This commit is contained in:
173
RpgRoller.Tests/WorkspaceCampaignCoordinatorTests.cs
Normal file
173
RpgRoller.Tests/WorkspaceCampaignCoordinatorTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
91
tests/e2e/campaigns-refresh.js
Normal file
91
tests/e2e/campaigns-refresh.js
Normal 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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user