using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common; using Novelly.Api.Projects; using Novelly.Api.Questions; namespace Novelly.Api.Tests; [TestFixture] public class OpenQuestionTests : ServiceTestFixture { private Guid _projectId; private Guid _chapterId; private Guid _characterId; protected override void OnSetUp() { _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; _chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id; _characterId = Characters.CreateAsync(_projectId, 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(_projectId, 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( _projectId, 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(_projectId, new CreateOpenQuestionRequest( "Where does the chapter break?", ChapterId: _chapterId)); await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( "What does she actually want?", CharacterId: _characterId)); await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); var forChapter = await Questions.ListAsync(_projectId, chapterId: _chapterId); var forCharacter = await Questions.ListAsync(_projectId, characterId: _characterId); var forProject = await Questions.ListAsync(_projectId); 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(forProject, Has.Count.EqualTo(3)); }); } [Test] public async Task Resolved_questions_drop_off_the_list_unless_asked_for() { var settled = await Questions.CreateAsync( _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); await Questions.ResolveAsync(settled.Id, new ResolveOpenQuestionRequest("After the harbour.")); var open = await Questions.ListAsync(_projectId); var everything = await Questions.ListAsync(_projectId, 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)); // Still-open questions come first, so the list stays about what is undecided. 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( _projectId, 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(_projectId, 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(() => { // The existing note is kept and the decision lands underneath it. 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(_projectId, 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(_projectId, 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(_projectId, 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); // Only the chapter was cleared — a null id means "leave alone", not "detach". Assert.That(detached.CharacterId, Is.EqualTo(_characterId)); }); } [Test] public async Task Deleting_a_chapter_leaves_its_questions_open_rather_than_taking_them() { var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( "Does she know about the letter?", ChapterId: _chapterId)); await Chapters.DeleteAsync(_chapterId); var survivor = (await Questions.GetAsync(question.Id))!; Assert.Multiple(() => { Assert.That(survivor.ChapterId, 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( _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); await Questions.DeleteAsync(question.Id); Assert.Multiple(async () => { Assert.That(await Questions.ListAsync(_projectId, includeResolved: true), Is.Empty); Assert.That(await Questions.GetAsync(question.Id), Is.Null); }); } [Test] public async Task Deleting_a_project_takes_its_questions() { await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); await Projects.DeleteAsync(_projectId); using var verification = Db.CreateContext(); Assert.That(verification.OpenQuestions.Count(), Is.EqualTo(0)); } [Test] public void A_question_cannot_be_attached_to_another_project_s_chapter() { var elsewhere = Chapters.CreateAsync( Projects.CreateAsync(new CreateProjectRequest("Other Book")).Result.Id, new CreateChapterRequest("Elsewhere")).Result; Assert.That( async () => await Questions.CreateAsync( _projectId, new CreateOpenQuestionRequest("A question", ChapterId: elsewhere.Id)), Throws.TypeOf().With.Message.Contains("same project")); } [Test] public void A_blank_question_is_refused() => Assert.That( async () => await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(" ")), Throws.TypeOf()); [Test] public async Task Resolving_with_nothing_decided_is_refused() { var question = await Questions.CreateAsync( _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); Assert.That( async () => await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest(" ")), Throws.TypeOf()); } }