Files
James Wampler aca26588f9
CI / build-and-push (push) Failing after 31s
CI / deploy (push) Has been skipped
Add soft delete + trash, keyboard-first web overhaul, move chapter tags to bottom
Adds SoftDelete/Trash across characters, chapters, locations, beats with a
purge schedule and Trash page. Reworks the web client for keyboard-driven
navigation (focus helpers, help overlay, keyboard.md doc). Moves the
ChapterPage tag editor to the bottom of the page to match CharacterDetailPage.
2026-08-20 16:39:09 -07:00

285 lines
12 KiB
C#

using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Novels;
namespace Novelly.Api.Tests;
[TestFixture]
public class CharacterArcTests : ServiceTestFixture
{
private Guid _novelId;
private Guid _characterId;
protected override void OnSetUp()
{
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
_characterId = Characters.CreateAsync(
_novelId,
new CreateCharacterRequest("Ines", CharacterRole.Protagonist, CharacterImportance.Main))
.Result.Id;
}
[Test]
public async Task A_character_is_supporting_until_promoted()
{
var mara = await Characters.CreateAsync(_novelId, 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(_novelId, 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(_novelId, new CreateCharacterRequest("Zeno"));
await Characters.CreateAsync(
_novelId, new CreateCharacterRequest("Mara", Importance: CharacterImportance.Main));
var listed = await Characters.ListAsync(_novelId);
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(_novelId, new CreateCharacterRequest(
"Mara", CharacterRole.Deuteragonist, CharacterImportance.Main));
await Characters.CreateAsync(_novelId, new CreateCharacterRequest(
"Anders", CharacterRole.Antagonist, CharacterImportance.Main));
var listed = await Characters.ListAsync(_novelId);
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", Result: "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].Result, 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(_novelId, 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_novel()
{
var other = await Novels.CreateAsync(new CreateNovelRequest("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 novel"));
}
[Test]
public async Task Trashing_a_chapter_hides_it_from_an_arc_stage_without_unpinning_it()
{
var chapter = await Chapters.CreateAsync(_novelId, 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))!;
var response = survivor.ToResponse();
Assert.Multiple(() =>
{
Assert.That(survivor.ChapterId, Is.EqualTo(chapter.Id), "the pin survives so restoring the chapter restores the link");
Assert.That(response.ChapterNumber, Is.Null);
Assert.That(response.ChapterTitle, Is.Null);
Assert.That(response.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(_novelId, new CreateChapterRequest("Second", Number: 2));
var first = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("First", Number: 1));
var mara = await Characters.CreateAsync(_novelId, 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);
[Test]
public async Task An_arc_stage_groups_the_beats_assigned_to_it()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var spoiled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId]));
var humbled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Learns to swab a deck", CharacterIds: [_characterId]));
var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
var updated = await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([spoiled!.Id]));
Assert.That(updated!.Beats.Select(b => b.Id), Is.EqualTo(new[] { spoiled.Id }));
Assert.That(humbled, Is.Not.Null);
}
[Test]
public async Task Assigning_a_beat_to_a_stage_moves_it_out_of_the_characters_other_stage()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Gets hurt", CharacterIds: [_characterId]));
var early = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
var later = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Humbled"));
await Arcs.SetBeatsAsync(early.Id, new SetArcStageBeatsRequest([beat!.Id]));
await Arcs.SetBeatsAsync(later.Id, new SetArcStageBeatsRequest([beat.Id]));
var earlyAfter = (await Arcs.GetAsync(early.Id))!;
var laterAfter = (await Arcs.GetAsync(later.Id))!;
Assert.Multiple(() =>
{
Assert.That(earlyAfter.Beats, Is.Empty);
Assert.That(laterAfter.Beats.Select(b => b.Id), Is.EqualTo(new[] { beat.Id }));
});
}
[Test]
public async Task A_beat_can_only_be_grouped_into_a_stage_for_a_character_who_appears_in_it()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Mara alone", CharacterIds: [mara.Id]));
var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
Assert.That(
async () => await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([beat!.Id])),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("appears in it"));
}
[Test]
public async Task Setting_beats_on_an_unknown_stage_returns_null_rather_than_throwing() =>
Assert.That(
await Arcs.SetBeatsAsync(Guid.NewGuid(), new SetArcStageBeatsRequest([])),
Is.Null);
[Test]
public async Task Clearing_a_stages_beats_with_an_empty_list_ungroups_them()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId]));
var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([beat!.Id]));
var cleared = await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([]));
Assert.That(cleared!.Beats, Is.Empty);
}
}