The self-nesting outline tree was more structure than chapter outlining needs.
A chapter outline is now a paragraph plus a flat, ordered table of beats, and
tags do the cross-referencing that nesting was doing badly.
A beat is one row: a three-to-five word title, an optional character, what
happened, and what's next. Ordering is a SortOrder column within the chapter —
no parent pointers, no cycle guards, no recursive tree building. Reordering is
one call taking beat ids in the order wanted; ids left out keep their relative
position at the end rather than jumping to the front.
Beats plan, scenes carry prose. The two layers stay separate and a beat's
SceneId is the optional link between them, nullable in both directions —
deleting a scene ungroups its beats rather than deleting the plan, since that
is a decision about prose and not about the outline.
Tags are project-scoped, unique by name case-insensitively, and attach to
characters, chapters and beats through three join tables so cascade deletes are
the database's job rather than ours. Applying an unknown tag by name creates it,
which keeps tagging a single action; GET /api/tags/{id}/references returns
everything carrying a tag across all three kinds at once.
Removed: OutlineNode, OutlineService, its endpoints, agent and MCP tools, and
the Outline tab. Added: Beat and Tag with their services, endpoints, 5 agent
tools and 10 MCP tools, a beat table on the chapter page, a tag editor used in
three places, and a Tags tab for cross-referencing.
Migration drops OutlineNodes — the scaffolder's data-loss warning is the
intended removal, not an accident.
44 tests, up from 31.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
134 lines
5.7 KiB
C#
134 lines
5.7 KiB
C#
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NovelSoftware.Application.Agent;
|
|
using NovelSoftware.Application.Dtos;
|
|
using NovelSoftware.Application.Services;
|
|
|
|
namespace NovelSoftware.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>
|
|
public class ListingTests : IDisposable
|
|
{
|
|
private readonly TestDatabase _db = new();
|
|
private readonly TagService _tags;
|
|
private readonly ProjectService _projects;
|
|
private readonly ChapterService _chapters;
|
|
private readonly SceneService _scenes;
|
|
private readonly CharacterService _characters;
|
|
|
|
public ListingTests()
|
|
{
|
|
_tags = new TagService(_db.Context);
|
|
_projects = new ProjectService(_db.Context);
|
|
_chapters = new ChapterService(_db.Context, _tags);
|
|
_scenes = new SceneService(_db.Context);
|
|
_characters = new CharacterService(_db.Context, _tags);
|
|
}
|
|
|
|
[Fact]
|
|
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();
|
|
|
|
listed.Select(p => p.Title).Should().Equal("Older Book", "Newer Book");
|
|
listed.Select(p => p.Id).Should().Contain(newer.Id);
|
|
}
|
|
|
|
[Fact]
|
|
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();
|
|
|
|
summary.CharacterCount.Should().Be(2);
|
|
summary.ChapterCount.Should().Be(2);
|
|
summary.WordCount.Should().Be(5);
|
|
}
|
|
|
|
[Fact]
|
|
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();
|
|
|
|
summary.WordCount.Should().Be(0);
|
|
summary.ChapterCount.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
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);
|
|
|
|
listed.Select(c => c.Title).Should().Equal("First", "Second");
|
|
listed.Single(c => c.Id == second.Id).SceneCount.Should().Be(2);
|
|
listed.Single(c => c.Id == second.Id).WordCount.Should().Be(3);
|
|
listed.Single(c => c.Id == first.Id).WordCount.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
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, _chapters, new BeatService(_db.Context, _tags), _scenes, _tags),
|
|
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);
|
|
|
|
listed.Should().HaveCount(2);
|
|
listed[0].Title.Should().Be("Second question.");
|
|
listed[0].MessageCount.Should().Be(2);
|
|
}
|
|
|
|
[Fact]
|
|
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", NovelSoftware.Domain.CharacterRole.Supporting));
|
|
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
|
|
"Ines", NovelSoftware.Domain.CharacterRole.Protagonist));
|
|
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
|
|
"Anders", NovelSoftware.Domain.CharacterRole.Supporting));
|
|
|
|
var listed = await _characters.ListAsync(project.Id);
|
|
|
|
listed.Select(c => c.Name).Should().Equal("Ines", "Anders", "Zeno");
|
|
}
|
|
|
|
public void Dispose() => _db.Dispose();
|
|
}
|