Add batch character assignment for chapter outline beats

Lets a writer select several beats and add one character to all of them
at once, without disturbing each beat's existing characters. Surfaced
through the REST API, the embedded agent, and the MCP server per the
project's rule that all three share the same service methods.
This commit is contained in:
James Wampler
2026-08-15 20:36:25 -07:00
parent 6774a38684
commit df10b1f99b
9 changed files with 277 additions and 33 deletions
@@ -103,6 +103,65 @@ public class BeatServiceTests : ServiceTestFixture
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
}
[Test]
public async Task Assigning_a_character_to_several_beats_leaves_their_other_characters_alone()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id]));
var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var assigned = await Beats.AssignCharacterAsync(
_chapterId, new AssignCharacterToBeatsRequest(mara.Id, [first.Id, second.Id]));
Assert.Multiple(() =>
{
Assert.That(
assigned!.Single(b => b.Id == first.Id).Characters.Select(c => c.Name),
Is.EquivalentTo(new[] { "Ines", "Mara" }));
Assert.That(
assigned.Single(b => b.Id == second.Id).Characters.Select(c => c.Name),
Is.EqualTo(new[] { "Mara" }));
});
}
[Test]
public async Task Assigning_a_character_already_on_a_beat_does_not_duplicate_it()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id]));
var assigned = await Beats.AssignCharacterAsync(
_chapterId, new AssignCharacterToBeatsRequest(ines.Id, [beat.Id]));
Assert.That(assigned!.Single(b => b.Id == beat.Id).Characters.Select(c => c.Name), Is.EqualTo(new[] { "Ines" }));
}
[Test]
public async Task Assigning_a_character_from_another_project_returns_null()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
Assert.That(
await Beats.AssignCharacterAsync(_chapterId, new AssignCharacterToBeatsRequest(stranger.Id, [beat.Id])),
Is.Null);
}
[Test]
public async Task Assigning_to_an_unknown_beat_returns_null_rather_than_partially_applying()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var result = await Beats.AssignCharacterAsync(
_chapterId, new AssignCharacterToBeatsRequest(ines.Id, [beat.Id, Guid.NewGuid()]));
Assert.That(result, Is.Null);
Assert.That((await Beats.GetAsync(beat.Id))!.Characters, Is.Empty);
}
[Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
@@ -51,7 +51,8 @@ public abstract class ServiceTestFixture
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Beats = new BeatService(
Db.Context, Tags, BeatLogs,
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator());
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
new AssignCharacterToBeatsRequestValidator());
Arcs = new CharacterArcService(
Db.Context, ArcLogs,
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());