Add character aliases/identity links, move-beats, keyboard help overlay

- Characters can carry aliases and be linked as the same underlying
  person (canonical SameCharacterAsId, optional reveal chapter/note),
  surfaced through the API, MCP tools, agent toolset, and web UI.
- Characters page redesigned as a filterable/sortable table (name+
  aliases, role, importance, occupation, tags) instead of a sidebar
  list, to stay usable as the cast grows.
- Beats can be moved between chapters (BeatService.MoveAsync + MCP/
  agent tool + endpoint).
- Add a keyboard-shortcuts help overlay (HelpButton/HelpOverlayContext)
  wired into the project layout.
- CLAUDE.md: require every frontend component to carry a unique id
  attribute; apply it to CharacterMultiSelect and MarkdownEditor.
This commit is contained in:
James Wampler
2026-08-17 17:26:50 -07:00
parent b4f4b35e3c
commit f124b9b4bb
32 changed files with 3002 additions and 356 deletions
@@ -124,4 +124,119 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task Reading_a_missing_character_returns_null_rather_than_throwing() =>
Assert.That(await Characters.GetAsync(Guid.NewGuid()), Is.Null);
[Test]
public async Task Aliases_round_trip_on_create_and_update()
{
var created = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man", "Kael"]));
Assert.That(created.Aliases, Is.EqualTo(new[] { "The Grey Man", "Kael" }));
var updated = (await Characters.UpdateAsync(
created.Id, new UpdateCharacterRequest(Aliases: ["The Stranger"])))!;
Assert.That(updated.Aliases, Is.EqualTo(new[] { "The Stranger" }));
}
[Test]
public async Task Clearing_aliases_with_an_empty_list_empties_them()
{
var created = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man"]));
var cleared = (await Characters.UpdateAsync(created.Id, new UpdateCharacterRequest(Aliases: [])))!;
Assert.That(cleared.Aliases, Is.Empty);
}
[Test]
public async Task Linking_a_character_to_its_true_identity_records_it_on_both_sides()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
var linked = (await Characters.LinkIdentityAsync(
stranger.Id, new LinkCharacterIdentityRequest(kael.Id, Note: "Same man, after the exile.")))!;
Assert.Multiple(() =>
{
Assert.That(linked.SameCharacterAsId, Is.EqualTo(kael.Id));
Assert.That(linked.SameCharacterAs?.Name, Is.EqualTo("Kael"));
Assert.That(linked.IdentityNote, Is.EqualTo("Same man, after the exile."));
});
var canonical = (await Characters.GetAsync(kael.Id))!;
Assert.That(canonical.OtherIdentities.Select(o => o.Id), Is.EqualTo(new[] { stranger.Id }));
}
[Test]
public async Task Linking_to_a_character_that_is_itself_an_alias_flattens_to_the_canonical()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
var exile = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Exile"));
await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id));
var linked = (await Characters.LinkIdentityAsync(exile.Id, new LinkCharacterIdentityRequest(stranger.Id)))!;
Assert.That(linked.SameCharacterAsId, Is.EqualTo(kael.Id));
}
[Test]
public async Task Linking_a_character_to_itself_is_rejected()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
Assert.That(
async () => await Characters.LinkIdentityAsync(kael.Id, new LinkCharacterIdentityRequest(kael.Id)),
Throws.TypeOf<InvalidOperationException>());
}
[Test]
public async Task Linking_identities_across_projects_is_refused()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
Assert.That(
async () => await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
}
[Test]
public async Task Deleting_the_canonical_character_leaves_its_other_identities_alive()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id));
await Characters.DeleteAsync(kael.Id);
using var verification = Db.CreateContext();
var survivor = await verification.Characters.FirstAsync(c => c.Id == stranger.Id);
Assert.That(survivor.SameCharacterAsId, Is.Null);
}
[Test]
public async Task Unlinking_an_identity_clears_the_reveal_chapter_and_note()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("The Reveal"));
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
await Characters.LinkIdentityAsync(
stranger.Id, new LinkCharacterIdentityRequest(kael.Id, chapter.Id, "Same man."));
var removed = await Characters.UnlinkIdentityAsync(stranger.Id);
var after = (await Characters.GetAsync(stranger.Id))!;
Assert.Multiple(() =>
{
Assert.That(removed, Is.True);
Assert.That(after.SameCharacterAsId, Is.Null);
Assert.That(after.RevealedInChapterId, Is.Null);
Assert.That(after.IdentityNote, Is.Null);
});
}
}