Files
novelly/tests/Novelly.Api.Tests/CharacterArcTests.cs
T
James Wampler 189ebf3237 Add outline import feature; drop Dto naming, map entities at the API boundary
Services now return entities; endpoints (and the agent toolsets) map to
*Response records instead of services building wire DTOs themselves.
Also brings in the outline-import agent, MCP tool, ledger and web dialog
that were already in progress on disk.
2026-08-06 18:36:40 -07:00

219 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.Chapter!.Title, Is.EqualTo("Landfall"));
Assert.That(stage.Chapter!.Number, 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].Chapter!.Number, Is.EqualTo(1));
Assert.That(beats[0].Chapter!.Title, Is.EqualTo("First"));
Assert.That(beats[1].ChapterId, Is.EqualTo(second.Id));
});
}
[Test]
public async Task Asking_for_the_beats_of_a_character_who_does_not_exist_returns_null_rather_than_throwing() =>
Assert.That(await Beats.ListForCharacterAsync(Guid.NewGuid()), Is.Null);
}