Add main/supporting characters, character arcs and open questions
Three things the outline could not express before:
Main vs supporting. A new CharacterImportance sits alongside CharacterRole
rather than inside it — role is the part a character plays (protagonist,
mentor, foil), importance is how much of the book they carry, and a mentor can
be either. Characters start Supporting and get promoted. Listings put main
characters first.
Character arcs. A main character's arc is a flat ordered list of stages, the
same shape as a chapter's beats and for the same reason: an arc is a sequence
of changes, not a tree. A stage can be pinned to the chapter where it lands.
Nothing refuses an arc on a supporting character — demoting someone should not
delete their work.
Open questions. What the writer has not decided yet, hanging off a chapter
outline, a character, both, or neither. They can be resolved, reopened or
deleted, and resolving can append the decision to the notes of whatever the
question was attached to, so it lands where the writer will re-read it.
Resolved questions drop off the list unless asked for.
Also adds GET /api/characters/{id}/beats — every beat a character appears in,
in manuscript order, carrying each beat's chapter so the character page can
link straight into that chapter's outline.
Deletes are deliberately asymmetric: deleting a chapter unpins arc stages and
detaches questions rather than taking them, because a plan outlives a decision
about where the chapter break falls. Deleting a character or project does take
their arcs and questions.
All three capabilities are surfaced in the REST API, the agent toolset and the
MCP server, per the one-source-of-truth rule.
Two things worth flagging in the migration: EF's generated default for the new
Importance column was an empty string, which does not parse back to a
CharacterImportance and would have faulted every read of an existing dossier —
it now defaults to Supporting, verified by migrating a database seeded on the
old schema and reading the row back through the API. And the earlier migrations
were renamed to the namespace EF derives from the output folder, so future
`migrations add` runs stop drifting.
72 tests pass (28 new). The endpoints were also exercised over curl end to end:
arc stages resolving their chapter, a character's beats across chapters, and a
question attached to both a chapter and a character resolving into both sets of
notes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
96021c5fee
commit
0358667679
@@ -0,0 +1,261 @@
|
||||
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.ChapterTitle, Is.EqualTo("Landfall"));
|
||||
Assert.That(question.ChapterNumber, Is.EqualTo(1));
|
||||
Assert.That(question.CharacterName, 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(
|
||||
async () => await Questions.GetAsync(question.Id),
|
||||
Throws.TypeOf<NotFoundException>());
|
||||
});
|
||||
}
|
||||
|
||||
[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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user