Files
novelly/tests/Novelly.Api.Tests/CharacterArcTests.cs
T
James WamplerandClaude Opus 5 4f396bb5f9 Surface arcs, character beats and open questions in the web client
The character page gains three sections under the dossier: the arc as an
editable ordered table with each stage pinnable to a chapter, every beat the
character appears in across the book (each row linking into that chapter's
outline), and the character's open questions. The sidebar groups main
characters above supporting ones, and both the sheet and the add dialog let you
set importance.

The arc section shows for main characters, and also for supporting ones that
already have stages — demoting someone should not hide work they thought they
had lost.

The outline page gains a notes section and an open-questions section at the
bottom. Beat rows are now anchored so the character page can link straight to a
row. Raising a question from either page attaches it to what that page is
about, and the section hides the association it is already scoped to rather
than repeating "Landfall" on every row.

Also fixes an ordering wart the browser run exposed: both CharacterRole and
CharacterImportance are stored as text, so ordering them in SQL ordered the
spelling — "Deuteragonist" beat "Protagonist" and the sidebar put the second
lead above the character the book is about. Listing now sorts after
materialising, which uses the enums' declaration order. The test for it was
checked both ways: it fails on the SQL ordering and passes on the fix.

73 tests pass, the web client builds and lints clean. Driven in a browser
end to end: resolving a question with "also add to notes" drops it off the open
list and appends the decision under the chapter's existing note, "show
resolved" brings it back with a Reopen button, and a beat link on the character
page lands on the right chapter outline at that beat's anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
2026-08-06 12:11:20 -07:00

221 lines
8.5 KiB
C#

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", CharacterRole.Protagonist, 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 Within_a_group_the_lead_comes_before_the_second_lead()
{
// Both enums are stored as text, so ordering them in SQL orders the spelling and
// "Deuteragonist" beats "Protagonist" — burying the character the book is about.
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
"Mara", CharacterRole.Deuteragonist, CharacterImportance.Main));
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
"Anders", CharacterRole.Antagonist, CharacterImportance.Main));
var listed = await Characters.ListAsync(_projectId);
Assert.That(
listed.Select(c => c.Name),
Is.EqualTo(new[] { "Ines", "Anders", "Mara" }));
}
[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>());
}