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:
@@ -162,6 +162,56 @@ public class BeatServiceTests : ServiceTestFixture
|
||||
Assert.That((await Beats.GetAsync(beat.Id))!.Characters, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Moving_beats_appends_them_to_the_end_of_the_target_chapter()
|
||||
{
|
||||
var other = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second landfall"));
|
||||
await Beats.CreateAsync(other.Id, new CreateBeatRequest("Already there"));
|
||||
var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
|
||||
var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
|
||||
|
||||
var moved = await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(other.Id, [second.Id, first.Id]));
|
||||
|
||||
var listed = await Beats.ListAsync(other.Id);
|
||||
var remaining = await Beats.ListAsync(_chapterId);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(moved!.Select(b => b.ChapterId), Is.All.EqualTo(other.Id));
|
||||
Assert.That(listed.Select(b => b.Title), Is.EqualTo(new[] { "Already there", "Second", "First" }));
|
||||
Assert.That(remaining, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Moving_to_an_unknown_chapter_returns_null()
|
||||
{
|
||||
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
|
||||
|
||||
Assert.That(await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(Guid.NewGuid(), [beat.Id])), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Moving_to_a_chapter_in_another_project_returns_null()
|
||||
{
|
||||
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
|
||||
var otherChapter = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere"));
|
||||
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
|
||||
|
||||
Assert.That(await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(otherChapter.Id, [beat.Id])), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Moving_an_unknown_beat_returns_null_rather_than_partially_applying()
|
||||
{
|
||||
var other = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second landfall"));
|
||||
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
|
||||
|
||||
var result = await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(other.Id, [beat.Id, Guid.NewGuid()]));
|
||||
|
||||
Assert.That(result, Is.Null);
|
||||
Assert.That((await Beats.GetAsync(beat.Id))!.ChapterId, Is.EqualTo(_chapterId));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,12 +63,13 @@ public abstract class ServiceTestFixture
|
||||
Db.Context, Access, UserContext, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator());
|
||||
Characters = new CharacterService(
|
||||
Db.Context, Access, Tags, CharacterLogs,
|
||||
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
|
||||
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(),
|
||||
new LinkCharacterIdentityRequestValidator());
|
||||
Chapters = new ChapterService(Db.Context, Access, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
||||
Beats = new BeatService(
|
||||
Db.Context, Access, Tags, BeatLogs,
|
||||
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
|
||||
new AssignCharacterToBeatsRequestValidator());
|
||||
new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator());
|
||||
Arcs = new CharacterArcService(
|
||||
Db.Context, Access, ArcLogs,
|
||||
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());
|
||||
|
||||
Reference in New Issue
Block a user