Files
novelly/tests/Novelly.Api.Tests/ListingTests.cs
T
James WamplerandClaude Opus 5 0358667679 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
2026-08-06 12:11:20 -07:00

130 lines
5.5 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Novelly.Api.Agent;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
namespace Novelly.Api.Tests;
/// <summary>
/// Covers the list endpoints, which sort and aggregate in SQL rather than in memory.
/// SQLite is fussier than the in-memory provider about what it will translate — ordering
/// by a DateTimeOffset, for one — so these have to run against real SQLite to be worth anything.
/// </summary>
[TestFixture]
public class ListingTests : ServiceTestFixture
{
[Test]
public async Task Projects_are_listed_most_recently_updated_first()
{
var older = await Projects.CreateAsync(new CreateProjectRequest("Older Book"));
var newer = await Projects.CreateAsync(new CreateProjectRequest("Newer Book"));
// Touching the older project should float it to the top.
await Projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised."));
var listed = await Projects.ListAsync();
Assert.Multiple(() =>
{
Assert.That(listed.Select(p => p.Title), Is.EqualTo(new[] { "Older Book", "Newer Book" }));
Assert.That(listed.Select(p => p.Id), Does.Contain(newer.Id));
});
}
[Test]
public async Task Project_summaries_aggregate_counts_and_words_across_chapters()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara"));
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour"));
await Scenes.CreateAsync(first.Id, new CreateSceneRequest("Dawn", Prose: "One two three"));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five"));
var summary = (await Projects.ListAsync()).Single();
Assert.Multiple(() =>
{
Assert.That(summary.CharacterCount, Is.EqualTo(2));
Assert.That(summary.ChapterCount, Is.EqualTo(2));
Assert.That(summary.WordCount, Is.EqualTo(5));
});
}
[Test]
public async Task A_project_with_no_chapters_reports_zero_words_rather_than_failing()
{
await Projects.CreateAsync(new CreateProjectRequest("Empty"));
var summary = (await Projects.ListAsync()).Single();
Assert.Multiple(() =>
{
Assert.That(summary.WordCount, Is.EqualTo(0));
Assert.That(summary.ChapterCount, Is.EqualTo(0));
});
}
[Test]
public async Task Chapters_are_listed_in_manuscript_order_with_scene_totals()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2));
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("A", Prose: "One two"));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("B", Prose: "Three"));
var listed = await Chapters.ListAsync(project.Id);
Assert.Multiple(() =>
{
Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" }));
Assert.That(listed.Single(c => c.Id == second.Id).SceneCount, Is.EqualTo(2));
Assert.That(listed.Single(c => c.Id == second.Id).WordCount, Is.EqualTo(3));
Assert.That(listed.Single(c => c.Id == first.Id).WordCount, Is.EqualTo(0));
});
}
[Test]
public async Task Conversations_are_listed_most_recently_updated_first()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var agent = new NovelAgentService(
Db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions),
Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance);
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("First question."));
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("Second question."));
var listed = await agent.ListConversationsAsync(project.Id);
Assert.Multiple(() =>
{
Assert.That(listed, Has.Count.EqualTo(2));
Assert.That(listed[0].Title, Is.EqualTo("Second question."));
Assert.That(listed[0].MessageCount, Is.EqualTo(2));
});
}
[Test]
public async Task Characters_are_listed_by_role_then_name()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Zeno", CharacterRole.Supporting));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines", CharacterRole.Protagonist));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Anders", CharacterRole.Supporting));
var listed = await Characters.ListAsync(project.Id);
Assert.That(listed.Select(c => c.Name), Is.EqualTo(new[] { "Ines", "Anders", "Zeno" }));
}
}