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.
301 lines
13 KiB
C#
301 lines
13 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Novelly.Api.Beats;
|
|
using Novelly.Api.Chapters;
|
|
using Novelly.Api.Characters;
|
|
using Novelly.Api.Novels;
|
|
|
|
namespace Novelly.Api.Tests;
|
|
|
|
[TestFixture]
|
|
public class CharacterServiceTests : ServiceTestFixture
|
|
{
|
|
private Guid _novelId;
|
|
|
|
protected override void OnSetUp()
|
|
{
|
|
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
|
|
}
|
|
|
|
[Test]
|
|
public async Task New_characters_default_to_supporting_role_and_importance()
|
|
{
|
|
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(character.Role, Is.EqualTo(CharacterRole.Supporting));
|
|
Assert.That(character.Importance, Is.EqualTo(CharacterImportance.Supporting));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Promoting_a_character_to_main_sticks_until_changed_again()
|
|
{
|
|
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
|
|
|
|
var promoted = (await Characters.UpdateAsync(
|
|
character.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!;
|
|
|
|
Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main));
|
|
|
|
var afterUnrelatedUpdate = (await Characters.UpdateAsync(
|
|
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")))!;
|
|
|
|
Assert.That(afterUnrelatedUpdate.Importance, Is.EqualTo(CharacterImportance.Main));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
|
|
{
|
|
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest(
|
|
"Ines", Motivation: "To find her sister.", Conflict: "Torn between vengeance and letting go."));
|
|
|
|
var renamed = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Name: "Ines Vell")))!;
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(renamed.Name, Is.EqualTo("Ines Vell"));
|
|
Assert.That(renamed.Motivation, Is.EqualTo("To find her sister."));
|
|
Assert.That(renamed.Conflict, Is.EqualTo("Torn between vengeance and letting go."));
|
|
});
|
|
|
|
var cleared = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Conflict: "")))!;
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(cleared.Conflict, Is.Null);
|
|
Assert.That(cleared.Motivation, Is.EqualTo("To find her sister."));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Relating_characters_across_novels_is_refused()
|
|
{
|
|
var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
|
|
var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
|
|
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
|
|
|
|
Assert.That(
|
|
async () => await Characters.AddRelationshipAsync(
|
|
ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")),
|
|
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Removing_a_relationship_leaves_both_characters_in_place()
|
|
{
|
|
var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
|
|
var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
|
|
|
|
var withRelationship = (await Characters.AddRelationshipAsync(
|
|
ines.Id, new CreateRelationshipRequest(mara.Id, "sister")))!;
|
|
var relationshipId = withRelationship.Relationships[0].Id;
|
|
|
|
var removed = await Characters.RemoveRelationshipAsync(relationshipId);
|
|
var afterRemoval = (await Characters.GetAsync(ines.Id))!;
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(removed, Is.True);
|
|
Assert.That(afterRemoval.Relationships, Is.Empty);
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Adding_a_relationship_records_it_on_both_characters()
|
|
{
|
|
var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
|
|
var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
|
|
|
|
await Characters.AddRelationshipAsync(
|
|
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother"));
|
|
|
|
var inesAfter = (await Characters.GetAsync(ines.Id))!;
|
|
var maraAfter = (await Characters.GetAsync(mara.Id))!;
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(inesAfter.Relationships, Has.Count.EqualTo(1));
|
|
Assert.That(inesAfter.Relationships[0].RelationshipType, Is.EqualTo("sister"));
|
|
Assert.That(inesAfter.Relationships[0].RelatedCharacterId, Is.EqualTo(mara.Id));
|
|
Assert.That(maraAfter.Relationships, Has.Count.EqualTo(1));
|
|
Assert.That(maraAfter.Relationships[0].RelationshipType, Is.EqualTo("brother"));
|
|
Assert.That(maraAfter.Relationships[0].RelatedCharacterId, Is.EqualTo(ines.Id));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task A_relationship_with_no_reciprocal_type_mirrors_the_same_type_both_ways()
|
|
{
|
|
var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
|
|
var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
|
|
|
|
await Characters.AddRelationshipAsync(ines.Id, new CreateRelationshipRequest(mara.Id, "rival"));
|
|
|
|
var maraAfter = (await Characters.GetAsync(mara.Id))!;
|
|
|
|
Assert.That(maraAfter.Relationships[0].RelationshipType, Is.EqualTo("rival"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Removing_a_relationship_removes_the_reciprocal_side_too()
|
|
{
|
|
var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
|
|
var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
|
|
|
|
var withRelationship = (await Characters.AddRelationshipAsync(
|
|
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")))!;
|
|
var relationshipId = withRelationship.Relationships[0].Id;
|
|
|
|
await Characters.RemoveRelationshipAsync(relationshipId);
|
|
var maraAfter = (await Characters.GetAsync(mara.Id))!;
|
|
|
|
Assert.That(maraAfter.Relationships, Is.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them()
|
|
{
|
|
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
|
|
var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
|
|
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map", CharacterIds: [ines.Id]));
|
|
|
|
await Characters.DeleteAsync(ines.Id);
|
|
|
|
using var verification = Db.CreateContext();
|
|
var survivingBeat = await verification.Beats.FirstAsync(b => b.Id == beat.Id);
|
|
Assert.That(survivingBeat, Is.Not.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Creating_a_character_under_a_missing_novel_returns_null_rather_than_throwing() =>
|
|
Assert.That(
|
|
await Characters.CreateAsync(Guid.NewGuid(), new CreateCharacterRequest("Ines")),
|
|
Is.Null);
|
|
|
|
[Test]
|
|
public async Task Reading_a_missing_character_returns_null_rather_than_throwing() =>
|
|
Assert.That(await Characters.GetAsync(Guid.NewGuid()), Is.Null);
|
|
|
|
[Test]
|
|
public async Task Aliases_round_trip_on_create_and_update()
|
|
{
|
|
var created = await Characters.CreateAsync(
|
|
_novelId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man", "Kael"]));
|
|
|
|
Assert.That(created.Aliases, Is.EqualTo(new[] { "The Grey Man", "Kael" }));
|
|
|
|
var updated = (await Characters.UpdateAsync(
|
|
created.Id, new UpdateCharacterRequest(Aliases: ["The Stranger"])))!;
|
|
|
|
Assert.That(updated.Aliases, Is.EqualTo(new[] { "The Stranger" }));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Clearing_aliases_with_an_empty_list_empties_them()
|
|
{
|
|
var created = await Characters.CreateAsync(
|
|
_novelId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man"]));
|
|
|
|
var cleared = (await Characters.UpdateAsync(created.Id, new UpdateCharacterRequest(Aliases: [])))!;
|
|
|
|
Assert.That(cleared.Aliases, Is.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Linking_a_character_to_its_true_identity_records_it_on_both_sides()
|
|
{
|
|
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
|
|
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
|
|
|
|
var linked = (await Characters.LinkIdentityAsync(
|
|
stranger.Id, new LinkCharacterIdentityRequest(kael.Id, Note: "Same man, after the exile.")))!;
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(linked.SameCharacterAsId, Is.EqualTo(kael.Id));
|
|
Assert.That(linked.SameCharacterAs?.Name, Is.EqualTo("Kael"));
|
|
Assert.That(linked.IdentityNote, Is.EqualTo("Same man, after the exile."));
|
|
});
|
|
|
|
var canonical = (await Characters.GetAsync(kael.Id))!;
|
|
Assert.That(canonical.OtherIdentities.Select(o => o.Id), Is.EqualTo(new[] { stranger.Id }));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Linking_to_a_character_that_is_itself_an_alias_flattens_to_the_canonical()
|
|
{
|
|
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
|
|
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
|
|
var exile = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Exile"));
|
|
|
|
await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id));
|
|
var linked = (await Characters.LinkIdentityAsync(exile.Id, new LinkCharacterIdentityRequest(stranger.Id)))!;
|
|
|
|
Assert.That(linked.SameCharacterAsId, Is.EqualTo(kael.Id));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Linking_a_character_to_itself_is_rejected()
|
|
{
|
|
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
|
|
|
|
Assert.That(
|
|
async () => await Characters.LinkIdentityAsync(kael.Id, new LinkCharacterIdentityRequest(kael.Id)),
|
|
Throws.TypeOf<InvalidOperationException>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Linking_identities_across_novels_is_refused()
|
|
{
|
|
var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
|
|
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
|
|
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
|
|
|
|
Assert.That(
|
|
async () => await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)),
|
|
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Trashing_the_canonical_character_keeps_the_link_so_restoring_brings_it_back()
|
|
{
|
|
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
|
|
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
|
|
await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id));
|
|
|
|
await Characters.DeleteAsync(kael.Id);
|
|
|
|
using var verification = Db.CreateContext();
|
|
var survivor = await verification.Characters.FirstAsync(c => c.Id == stranger.Id);
|
|
var response = (await Characters.GetAsync(stranger.Id))!.ToResponse();
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(survivor.SameCharacterAsId, Is.EqualTo(kael.Id), "the pin survives so restoring Kael restores the identity link");
|
|
Assert.That(response.SameCharacterAsName, Is.Null, "a trashed canonical identity does not show up while it's in the trash");
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Unlinking_an_identity_clears_the_reveal_chapter_and_note()
|
|
{
|
|
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("The Reveal"));
|
|
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
|
|
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
|
|
await Characters.LinkIdentityAsync(
|
|
stranger.Id, new LinkCharacterIdentityRequest(kael.Id, chapter.Id, "Same man."));
|
|
|
|
var removed = await Characters.UnlinkIdentityAsync(stranger.Id);
|
|
var after = (await Characters.GetAsync(stranger.Id))!;
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(removed, Is.True);
|
|
Assert.That(after.SameCharacterAsId, Is.Null);
|
|
Assert.That(after.RevealedInChapterId, Is.Null);
|
|
Assert.That(after.IdentityNote, Is.Null);
|
|
});
|
|
}
|
|
}
|