Drop the Scene entity/grouping in favor of chapters carrying prose directly and beats belonging to many characters. Add markdown editor + character multi-select components to the web client. Remove all XML doc and inline comments across the touched C#/TS/CSS files in favor of self-documenting names, and record that convention in CLAUDE.md. Add .mcp.json (local MCP server config, no secrets) and ignore .idea/.
215 lines
8.2 KiB
C#
215 lines
8.2 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()
|
|
{
|
|
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);
|
|
|
|
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 async Task Reordering_with_an_unknown_stage_returns_null_rather_than_throwing() =>
|
|
Assert.That(
|
|
await Arcs.ReorderAsync(_characterId, new ReorderArcStagesRequest([Guid.NewGuid()])),
|
|
Is.Null);
|
|
|
|
[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", CharacterIds: [_characterId]));
|
|
await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterIds: [_characterId]));
|
|
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterIds: [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);
|
|
}
|