Add main/supporting characters, character arcs and open questions
Three things the outline could not express before:
Main vs supporting. A new CharacterImportance sits alongside CharacterRole
rather than inside it — role is the part a character plays (protagonist,
mentor, foil), importance is how much of the book they carry, and a mentor can
be either. Characters start Supporting and get promoted. Listings put main
characters first.
Character arcs. A main character's arc is a flat ordered list of stages, the
same shape as a chapter's beats and for the same reason: an arc is a sequence
of changes, not a tree. A stage can be pinned to the chapter where it lands.
Nothing refuses an arc on a supporting character — demoting someone should not
delete their work.
Open questions. What the writer has not decided yet, hanging off a chapter
outline, a character, both, or neither. They can be resolved, reopened or
deleted, and resolving can append the decision to the notes of whatever the
question was attached to, so it lands where the writer will re-read it.
Resolved questions drop off the list unless asked for.
Also adds GET /api/characters/{id}/beats — every beat a character appears in,
in manuscript order, carrying each beat's chapter so the character page can
link straight into that chapter's outline.
Deletes are deliberately asymmetric: deleting a chapter unpins arc stages and
detaches questions rather than taking them, because a plan outlives a decision
about where the chapter break falls. Deleting a character or project does take
their arcs and questions.
All three capabilities are surfaced in the REST API, the agent toolset and the
MCP server, per the one-source-of-truth rule.
Two things worth flagging in the migration: EF's generated default for the new
Importance column was an empty string, which does not parse back to a
CharacterImportance and would have faulted every read of an existing dossier —
it now defaults to Supporting, verified by migrating a database seeded on the
old schema and reading the row back through the API. And the earlier migrations
were renamed to the namespace EF derives from the output folder, so future
`migrations add` runs stop drifting.
72 tests pass (28 new). The endpoints were also exercised over curl end to end:
arc stages resolving their chapter, a character's beats across chapters, and a
question attached to both a chapter and a character resolving into both sets of
notes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
96021c5fee
commit
0358667679
@@ -0,0 +1,202 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class CharacterArcTests : ServiceTestFixture
|
||||
{
|
||||
private Guid _projectId;
|
||||
private Guid _characterId;
|
||||
|
||||
protected override void OnSetUp()
|
||||
{
|
||||
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
|
||||
_characterId = Characters.CreateAsync(
|
||||
_projectId,
|
||||
new CreateCharacterRequest("Ines", Importance: CharacterImportance.Main)).Result.Id;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_character_is_supporting_until_promoted()
|
||||
{
|
||||
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
|
||||
|
||||
Assert.That(mara.Importance, Is.EqualTo(CharacterImportance.Supporting));
|
||||
|
||||
var promoted = await Characters.UpdateAsync(
|
||||
mara.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main));
|
||||
|
||||
Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Importance_is_separate_from_the_part_a_character_plays()
|
||||
{
|
||||
var mentor = await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
|
||||
"Anders", CharacterRole.Mentor, CharacterImportance.Main));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(mentor.Role, Is.EqualTo(CharacterRole.Mentor));
|
||||
Assert.That(mentor.Importance, Is.EqualTo(CharacterImportance.Main));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Main_characters_are_listed_before_supporting_ones()
|
||||
{
|
||||
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Zeno"));
|
||||
await Characters.CreateAsync(
|
||||
_projectId, new CreateCharacterRequest("Mara", Importance: CharacterImportance.Main));
|
||||
|
||||
var listed = await Characters.ListAsync(_projectId);
|
||||
|
||||
Assert.That(
|
||||
listed.Select(c => c.Name),
|
||||
Is.EqualTo(new[] { "Ines", "Mara", "Zeno" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Arc_stages_are_appended_in_order_and_read_back_that_way()
|
||||
{
|
||||
await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("She trusts the map"));
|
||||
await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("The map is wrong"));
|
||||
await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("She navigates by memory"));
|
||||
|
||||
var arc = await Arcs.ListAsync(_characterId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(
|
||||
arc.Select(s => s.Title),
|
||||
Is.EqualTo(new[] { "She trusts the map", "The map is wrong", "She navigates by memory" }));
|
||||
Assert.That(arc.Select(s => s.SortOrder), Is.EqualTo(new[] { 1, 2, 3 }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_character_dossier_carries_its_arc()
|
||||
{
|
||||
await Arcs.CreateAsync(_characterId, new CreateArcStageRequest(
|
||||
"She trusts the map", Description: "Because her mother drew it."));
|
||||
|
||||
var character = await Characters.GetAsync(_characterId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(character.ArcStages, Has.Count.EqualTo(1));
|
||||
Assert.That(character.ArcStages[0].Description, Does.Contain("her mother drew it"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Stages_left_out_of_a_reorder_keep_their_relative_position_at_the_end()
|
||||
{
|
||||
var first = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("First"));
|
||||
await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Second"));
|
||||
var third = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Third"));
|
||||
|
||||
var reordered = await Arcs.ReorderAsync(
|
||||
_characterId, new ReorderArcStagesRequest([third.Id, first.Id]));
|
||||
|
||||
Assert.That(
|
||||
reordered.Select(s => s.Title),
|
||||
Is.EqualTo(new[] { "Third", "First", "Second" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_stage_pinned_to_a_chapter_resolves_that_chapter()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
|
||||
|
||||
var stage = await Arcs.CreateAsync(
|
||||
_characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(stage.ChapterTitle, Is.EqualTo("Landfall"));
|
||||
Assert.That(stage.ChapterNumber, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_stage_cannot_be_pinned_to_a_chapter_from_another_project()
|
||||
{
|
||||
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
|
||||
var elsewhere = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere"));
|
||||
|
||||
Assert.That(
|
||||
async () => await Arcs.CreateAsync(
|
||||
_characterId, new CreateArcStageRequest("A stage", ChapterId: elsewhere.Id)),
|
||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Deleting_a_chapter_unpins_an_arc_stage_rather_than_deleting_it()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
|
||||
var stage = await Arcs.CreateAsync(
|
||||
_characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id));
|
||||
|
||||
await Chapters.DeleteAsync(chapter.Id);
|
||||
|
||||
// How a character changes outlives a decision about where the chapter break falls.
|
||||
var survivor = await Arcs.GetAsync(stage.Id);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(survivor.ChapterId, Is.Null);
|
||||
Assert.That(survivor.Title, Is.EqualTo("The map is wrong"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Deleting_a_character_takes_their_arc()
|
||||
{
|
||||
await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("She trusts the map"));
|
||||
|
||||
await Characters.DeleteAsync(_characterId);
|
||||
|
||||
Assert.That(await Arcs.ListAsync(_characterId), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Reordering_with_an_unknown_stage_is_refused() =>
|
||||
Assert.That(
|
||||
async () => await Arcs.ReorderAsync(
|
||||
_characterId, new ReorderArcStagesRequest([Guid.NewGuid()])),
|
||||
Throws.TypeOf<NotFoundException>());
|
||||
|
||||
[Test]
|
||||
public async Task The_character_page_sees_every_beat_they_appear_in_across_the_book()
|
||||
{
|
||||
var second = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second", Number: 2));
|
||||
var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1));
|
||||
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
|
||||
|
||||
await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterId: _characterId));
|
||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterId: _characterId));
|
||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterId: mara.Id));
|
||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat"));
|
||||
|
||||
var beats = await Beats.ListForCharacterAsync(_characterId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(beats.Select(b => b.Title), Is.EqualTo(new[] { "She finds the map", "She boards anyway" }));
|
||||
Assert.That(beats[0].ChapterNumber, Is.EqualTo(1));
|
||||
Assert.That(beats[0].ChapterTitle, Is.EqualTo("First"));
|
||||
Assert.That(beats[1].ChapterId, Is.EqualTo(second.Id));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Asking_for_the_beats_of_a_character_who_does_not_exist_reports_not_found() =>
|
||||
Assert.That(
|
||||
async () => await Beats.ListForCharacterAsync(Guid.NewGuid()),
|
||||
Throws.TypeOf<NotFoundException>());
|
||||
}
|
||||
Reference in New Issue
Block a user