Files
novelly/tests/Novelly.Api.Tests/OpenQuestionTests.cs
T
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

259 lines
9.8 KiB
C#

using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
namespace Novelly.Api.Tests;
[TestFixture]
public class OpenQuestionTests : ServiceTestFixture
{
private Guid _novelId;
private Guid _chapterId;
private Guid _characterId;
protected override void OnSetUp()
{
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
_chapterId = Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")).Result.Id;
_characterId = Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")).Result.Id;
}
[Test]
public async Task A_question_can_hang_off_a_chapter_and_a_character_at_once()
{
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Does she know about the letter before the harbour?",
ChapterId: _chapterId,
CharacterId: _characterId));
Assert.Multiple(() =>
{
Assert.That(question.Chapter!.Title, Is.EqualTo("Landfall"));
Assert.That(question.Chapter!.Number, Is.EqualTo(1));
Assert.That(question.Character!.Name, Is.EqualTo("Ines"));
Assert.That(question.IsResolved, Is.False);
});
}
[Test]
public async Task A_question_about_the_book_as_a_whole_needs_no_association()
{
var question = await Questions.CreateAsync(
_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
Assert.Multiple(() =>
{
Assert.That(question.ChapterId, Is.Null);
Assert.That(question.CharacterId, Is.Null);
});
}
[Test]
public async Task The_outline_and_the_character_page_each_see_only_their_own_questions()
{
await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId));
await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"What does she actually want?", CharacterId: _characterId));
await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
var forChapter = await Questions.ListAsync(_novelId, chapterId: _chapterId);
var forCharacter = await Questions.ListAsync(_novelId, characterId: _characterId);
var forNovel = await Questions.ListAsync(_novelId);
Assert.Multiple(() =>
{
Assert.That(forChapter.Select(q => q.Question), Is.EqualTo(new[] { "Where does the chapter break?" }));
Assert.That(forCharacter.Select(q => q.Question), Is.EqualTo(new[] { "What does she actually want?" }));
Assert.That(forNovel, Has.Count.EqualTo(3));
});
}
[Test]
public async Task Resolved_questions_drop_off_the_list_unless_asked_for()
{
var settled = await Questions.CreateAsync(
_novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
await Questions.ResolveAsync(settled.Id, new ResolveOpenQuestionRequest("After the harbour."));
var open = await Questions.ListAsync(_novelId);
var everything = await Questions.ListAsync(_novelId, includeResolved: true);
Assert.Multiple(() =>
{
Assert.That(open.Select(q => q.Question), Is.EqualTo(new[] { "Is this one book or two?" }));
Assert.That(everything, Has.Count.EqualTo(2));
Assert.That(everything[0].IsResolved, Is.False);
Assert.That(everything[1].IsResolved, Is.True);
});
}
[Test]
public async Task Resolving_records_what_was_decided()
{
var question = await Questions.CreateAsync(
_novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
var resolved = (await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")))!;
Assert.Multiple(() =>
{
Assert.That(resolved.IsResolved, Is.True);
Assert.That(resolved.Resolution, Is.EqualTo("After the harbour burns."));
Assert.That(resolved.ResolvedAt, Is.Not.Null);
});
}
[Test]
public async Task A_resolution_can_be_added_as_a_note_on_what_it_was_about()
{
await Chapters.UpdateAsync(_chapterId, new UpdateChapterRequest(Notes: "Runs long."));
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId));
await Questions.ResolveAsync(
question.Id,
new ResolveOpenQuestionRequest("After the harbour burns.", AppendToNotes: true));
var chapter = (await Chapters.GetAsync(_chapterId))!;
var character = (await Characters.GetAsync(_characterId))!;
Assert.Multiple(() =>
{
Assert.That(chapter.Notes, Does.StartWith("Runs long."));
Assert.That(chapter.Notes, Does.Contain("Where does the chapter break? — After the harbour burns."));
Assert.That(character.Notes, Is.EqualTo("Where does the chapter break? — After the harbour burns."));
});
}
[Test]
public async Task A_resolution_stays_off_the_notes_unless_asked_for()
{
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId));
await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest("After the harbour."));
Assert.That((await Chapters.GetAsync(_chapterId))!.Notes, Is.Null);
}
[Test]
public async Task Reopening_clears_the_resolution_but_leaves_the_note_behind()
{
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId));
await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour.", AppendToNotes: true));
var reopened = (await Questions.ReopenAsync(question.Id))!;
var chapter = (await Chapters.GetAsync(_chapterId))!;
Assert.Multiple(() =>
{
Assert.That(reopened.IsResolved, Is.False);
Assert.That(reopened.Resolution, Is.Null);
Assert.That(chapter.Notes, Does.Contain("After the harbour."));
});
}
[Test]
public async Task A_question_can_be_detached_from_what_it_was_about()
{
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId));
var detached = (await Questions.UpdateAsync(
question.Id, new UpdateOpenQuestionRequest(ClearChapter: true)))!;
Assert.Multiple(() =>
{
Assert.That(detached.ChapterId, Is.Null);
Assert.That(detached.CharacterId, Is.EqualTo(_characterId));
});
}
[Test]
public async Task Trashing_a_chapter_leaves_its_questions_open_and_pinned_but_hides_the_chapter()
{
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Does she know about the letter?", ChapterId: _chapterId));
await Chapters.DeleteAsync(_chapterId);
var survivor = (await Questions.GetAsync(question.Id))!;
var response = survivor.ToResponse();
Assert.Multiple(() =>
{
Assert.That(survivor.ChapterId, Is.EqualTo(_chapterId), "the pin survives so restoring the chapter restores the link");
Assert.That(response.ChapterTitle, Is.Null);
Assert.That(survivor.Question, Is.EqualTo("Does she know about the letter?"));
});
}
[Test]
public async Task A_question_can_be_deleted_outright()
{
var question = await Questions.CreateAsync(
_novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
await Questions.DeleteAsync(question.Id);
Assert.Multiple(async () =>
{
Assert.That(await Questions.ListAsync(_novelId, includeResolved: true), Is.Empty);
Assert.That(await Questions.GetAsync(question.Id), Is.Null);
});
}
[Test]
public async Task Deleting_a_novel_takes_its_questions()
{
await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
await Novels.DeleteAsync(_novelId);
using var verification = Db.CreateContext();
Assert.That(verification.OpenQuestions.Count(), Is.EqualTo(0));
}
[Test]
public void A_question_cannot_be_attached_to_another_novel_s_chapter()
{
var elsewhere = Chapters.CreateAsync(
Novels.CreateAsync(new CreateNovelRequest("Other Book")).Result.Id,
new CreateChapterRequest("Elsewhere")).Result;
Assert.That(
async () => await Questions.CreateAsync(
_novelId, new CreateOpenQuestionRequest("A question", ChapterId: elsewhere.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
}
[Test]
public void A_blank_question_is_refused() =>
Assert.That(
async () => await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(" ")),
Throws.TypeOf<ArgumentException>());
[Test]
public async Task Resolving_with_nothing_decided_is_refused()
{
var question = await Questions.CreateAsync(
_novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
Assert.That(
async () => await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest(" ")),
Throws.TypeOf<ArgumentException>());
}
}