Services now return entities; endpoints (and the agent toolsets) map to *Response records instead of services building wire DTOs themselves. Also brings in the outline-import agent, MCP tool, ledger and web dialog that were already in progress on disk.
260 lines
10 KiB
C#
260 lines
10 KiB
C#
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<InvalidOperationException>().With.Message.Contains("same project"));
|
|
}
|
|
|
|
[Test]
|
|
public void A_blank_question_is_refused() =>
|
|
Assert.That(
|
|
async () => await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(" ")),
|
|
Throws.TypeOf<ArgumentException>());
|
|
|
|
[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<ArgumentException>());
|
|
}
|
|
}
|