Replace the outline tree with chapter beat tables and tags

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
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 0d7b7a6f30
commit 7678cc7275
51 changed files with 3189 additions and 948 deletions
@@ -0,0 +1,159 @@
using FluentAssertions;
using NovelSoftware.Application;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
namespace NovelSoftware.Tests;
public class BeatServiceTests : IDisposable
{
private readonly TestDatabase _db = new();
private readonly BeatService _beats;
private readonly CharacterService _characters;
private readonly SceneService _scenes;
private readonly Guid _projectId;
private readonly Guid _chapterId;
public BeatServiceTests()
{
var tags = new TagService(_db.Context);
var projects = new ProjectService(_db.Context);
var chapters = new ChapterService(_db.Context, tags);
_characters = new CharacterService(_db.Context, tags);
_scenes = new SceneService(_db.Context);
_beats = new BeatService(_db.Context, tags);
_projectId = projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
_chapterId = chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id;
}
[Fact]
public async Task Beats_are_appended_in_order_and_listed_that_way()
{
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("She finds the map"));
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("The harbour burns"));
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("She boards anyway"));
var listed = await _beats.ListAsync(_chapterId);
listed.Select(b => b.Title)
.Should().Equal("She finds the map", "The harbour burns", "She boards anyway");
listed.Select(b => b.SortOrder).Should().Equal(1, 2, 3);
}
[Fact]
public async Task Reordering_renumbers_to_match_the_order_given()
{
var first = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var second = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var third = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Third"));
var reordered = await _beats.ReorderAsync(
_chapterId, new ReorderBeatsRequest([third.Id, first.Id, second.Id]));
reordered.Select(b => b.Title).Should().Equal("Third", "First", "Second");
}
[Fact]
public async Task Beats_left_out_of_a_reorder_keep_their_relative_position_at_the_end()
{
var first = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var third = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Third"));
var reordered = await _beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([third.Id, first.Id]));
reordered.Select(b => b.Title).Should().Equal("Third", "First", "Second");
}
[Fact]
public async Task Reordering_with_an_unknown_beat_is_refused()
{
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var reorder = async () => await _beats.ReorderAsync(
_chapterId, new ReorderBeatsRequest([Guid.NewGuid()]));
await reorder.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task A_beat_resolves_its_character_and_scene_names()
{
var ines = await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var scene = await _scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await _beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas",
CharacterId: ines.Id,
WhatHappened: "The pages go up faster than she expected.",
WhatsNext: "Nothing to navigate by but memory.",
SceneId: scene.Id));
beat.CharacterName.Should().Be("Ines");
beat.SceneTitle.Should().Be("The dock at dawn");
beat.WhatHappened.Should().Contain("faster than she expected");
}
[Fact]
public async Task A_beat_cannot_borrow_a_character_from_another_project()
{
var projects = new ProjectService(_db.Context);
var other = await projects.CreateAsync(new CreateProjectRequest("Other Book"));
var stranger = await _characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
var create = async () => await _beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id));
await create.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*same project*");
}
[Fact]
public async Task A_beat_cannot_be_grouped_under_a_scene_from_another_chapter()
{
var chapters = new ChapterService(_db.Context, new TagService(_db.Context));
var elsewhere = await chapters.CreateAsync(_projectId, new CreateChapterRequest("Elsewhere"));
var scene = await _scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene"));
var create = async () => await _beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id));
await create.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*same chapter*");
}
[Fact]
public async Task Deleting_a_scene_leaves_its_beats_alone()
{
var scene = await _scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await _beats.CreateAsync(
_chapterId, new CreateBeatRequest("She burns the atlas", SceneId: scene.Id));
await _scenes.DeleteAsync(scene.Id);
// The plan outlives a decision about prose — the beat is simply ungrouped.
var survivor = await _beats.GetAsync(beat.Id);
survivor.SceneId.Should().BeNull();
survivor.Title.Should().Be("She burns the atlas");
}
[Fact]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
var beat = await _beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She finds the map", WhatHappened: "Behind the lining of the case.", WhatsNext: "She books passage."));
var renamed = await _beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She finds it"));
renamed.WhatHappened.Should().Be("Behind the lining of the case.");
renamed.WhatsNext.Should().Be("She books passage.");
var cleared = await _beats.UpdateAsync(beat.Id, new UpdateBeatRequest(WhatsNext: ""));
cleared.WhatsNext.Should().BeNull();
cleared.WhatHappened.Should().Be("Behind the lining of the case.");
}
public void Dispose() => _db.Dispose();
}
+5 -3
View File
@@ -15,6 +15,7 @@ namespace NovelSoftware.Tests;
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;
@@ -22,10 +23,11 @@ public class ListingTests : IDisposable
public ListingTests()
{
_tags = new TagService(_db.Context);
_projects = new ProjectService(_db.Context);
_chapters = new ChapterService(_db.Context);
_chapters = new ChapterService(_db.Context, _tags);
_scenes = new SceneService(_db.Context);
_characters = new CharacterService(_db.Context);
_characters = new CharacterService(_db.Context, _tags);
}
[Fact]
@@ -97,7 +99,7 @@ public class ListingTests : IDisposable
var agent = new NovelAgentService(
_db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(_projects, _characters, new OutlineService(_db.Context), _chapters, _scenes),
new NovelAgentToolset(_projects, _characters, _chapters, new BeatService(_db.Context, _tags), _scenes, _tags),
Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance);
@@ -11,20 +11,23 @@ namespace NovelSoftware.Tests;
public class NovelAgentServiceTests : IDisposable
{
private readonly TestDatabase _db = new();
private readonly TagService _tags;
private readonly ProjectService _projects;
private readonly CharacterService _characters;
private readonly NovelAgentToolset _toolset;
public NovelAgentServiceTests()
{
_tags = new TagService(_db.Context);
_projects = new ProjectService(_db.Context);
_characters = new CharacterService(_db.Context);
_characters = new CharacterService(_db.Context, _tags);
_toolset = new NovelAgentToolset(
_projects,
_characters,
new OutlineService(_db.Context),
new ChapterService(_db.Context),
new SceneService(_db.Context));
new ChapterService(_db.Context, _tags),
new BeatService(_db.Context, _tags),
new SceneService(_db.Context),
_tags);
}
private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
@@ -1,121 +0,0 @@
using FluentAssertions;
using NovelSoftware.Application;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
using NovelSoftware.Domain;
namespace NovelSoftware.Tests;
public class OutlineServiceTests : IDisposable
{
private readonly TestDatabase _db = new();
private readonly OutlineService _outlines;
private readonly Guid _projectId;
public OutlineServiceTests()
{
_outlines = new OutlineService(_db.Context);
_projectId = new ProjectService(_db.Context)
.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
}
[Fact]
public async Task Nested_nodes_come_back_as_a_tree()
{
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"Act One", OutlineNodeType.Act));
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"She finds the map", OutlineNodeType.Beat, ParentId: act.Id));
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"The harbour burns", OutlineNodeType.Beat, ParentId: act.Id));
var tree = await _outlines.GetTreeAsync(_projectId);
tree.Should().ContainSingle();
tree[0].Title.Should().Be("Act One");
tree[0].Children.Select(c => c.Title)
.Should().Equal("She finds the map", "The harbour burns");
}
[Fact]
public async Task Sibling_order_follows_sort_order_not_insertion_order()
{
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Third", SortOrder: 30));
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("First", SortOrder: 10));
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Second", SortOrder: 20));
var tree = await _outlines.GetTreeAsync(_projectId);
tree.Select(n => n.Title).Should().Equal("First", "Second", "Third");
}
[Fact]
public async Task Moving_a_node_under_its_own_descendant_is_rejected()
{
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
var sequence = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"Sequence", ParentId: act.Id));
var beat = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"Beat", ParentId: sequence.Id));
var move = async () => await _outlines.MoveAsync(act.Id, new MoveOutlineNodeRequest(beat.Id, 1));
await move.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*beneath its own descendant*");
}
[Fact]
public async Task A_node_cannot_be_its_own_parent()
{
var node = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
var move = async () => await _outlines.MoveAsync(node.Id, new MoveOutlineNodeRequest(node.Id, 1));
await move.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*its own parent*");
}
[Fact]
public async Task Moving_to_the_root_detaches_from_the_old_parent()
{
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
var beat = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"Beat", ParentId: act.Id));
await _outlines.MoveAsync(beat.Id, new MoveOutlineNodeRequest(null, 2));
var tree = await _outlines.GetTreeAsync(_projectId);
tree.Should().HaveCount(2);
tree.Single(n => n.Title == "Act One").Children.Should().BeEmpty();
}
[Fact]
public async Task Deleting_a_node_takes_its_whole_subtree()
{
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
var sequence = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"Sequence", ParentId: act.Id));
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Beat", ParentId: sequence.Id));
var survivor = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act Two"));
await _outlines.DeleteAsync(act.Id);
var tree = await _outlines.GetTreeAsync(_projectId);
tree.Should().ContainSingle().Which.Id.Should().Be(survivor.Id);
using var verification = _db.CreateContext();
verification.OutlineNodes.Should().ContainSingle();
}
[Fact]
public async Task Creating_under_a_missing_parent_reports_not_found()
{
var create = async () => await _outlines.CreateAsync(_projectId,
new CreateOutlineNodeRequest("Orphan", ParentId: Guid.NewGuid()));
await create.Should().ThrowAsync<NotFoundException>();
}
public void Dispose() => _db.Dispose();
}
@@ -10,6 +10,7 @@ namespace NovelSoftware.Tests;
public class ProjectDataTests : IDisposable
{
private readonly TestDatabase _db = new();
private readonly TagService _tags;
private readonly ProjectService _projects;
private readonly CharacterService _characters;
private readonly ChapterService _chapters;
@@ -17,9 +18,10 @@ public class ProjectDataTests : IDisposable
public ProjectDataTests()
{
_tags = new TagService(_db.Context);
_projects = new ProjectService(_db.Context);
_characters = new CharacterService(_db.Context);
_chapters = new ChapterService(_db.Context);
_characters = new CharacterService(_db.Context, _tags);
_chapters = new ChapterService(_db.Context, _tags);
_scenes = new SceneService(_db.Context);
}
@@ -0,0 +1,174 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
namespace NovelSoftware.Tests;
public class TagServiceTests : IDisposable
{
private readonly TestDatabase _db = new();
private readonly TagService _tags;
private readonly CharacterService _characters;
private readonly ChapterService _chapters;
private readonly BeatService _beats;
private readonly Guid _projectId;
public TagServiceTests()
{
_tags = new TagService(_db.Context);
_characters = new CharacterService(_db.Context, _tags);
_chapters = new ChapterService(_db.Context, _tags);
_beats = new BeatService(_db.Context, _tags);
_projectId = new ProjectService(_db.Context)
.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
}
[Fact]
public async Task Applying_an_unknown_tag_by_name_creates_it()
{
var character = await _characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
character.Tags.Select(t => t.Name).Should().BeEquivalentTo(["betrayal", "the sea"]);
(await _tags.ListAsync(_projectId)).Should().HaveCount(2);
}
[Fact]
public async Task The_same_name_resolves_to_one_tag_regardless_of_casing()
{
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"]));
var chapter = await _chapters.CreateAsync(
_projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"]));
var listed = await _tags.ListAsync(_projectId);
listed.Should().ContainSingle().Which.Name.Should().Be("Betrayal");
chapter.Tags.Should().ContainSingle().Which.Id.Should().Be(listed[0].Id);
}
[Fact]
public async Task Supplying_a_tag_list_replaces_the_existing_tags()
{
var character = await _characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
var updated = await _characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"]));
updated.Tags.Select(t => t.Name).Should().BeEquivalentTo(["the sea", "maps"]);
}
[Fact]
public async Task Omitting_the_tag_list_leaves_tags_alone()
{
var character = await _characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var updated = await _characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer"));
updated.Tags.Should().ContainSingle().Which.Name.Should().Be("betrayal");
updated.Occupation.Should().Be("Cartographer");
}
[Fact]
public async Task Cross_reference_gathers_everything_carrying_a_tag()
{
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var chapter = await _chapters.CreateAsync(
_projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"]));
await _beats.CreateAsync(chapter.Id, new CreateBeatRequest(
"She burns the atlas", WhatHappened: "In the galley stove.", Tags: ["betrayal"]));
await _beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat"));
var tagId = (await _tags.ListAsync(_projectId)).Single().Id;
var references = await _tags.GetReferencesAsync(tagId);
references.Characters.Should().ContainSingle().Which.Name.Should().Be("Ines");
references.Chapters.Should().ContainSingle().Which.Title.Should().Be("Landfall");
references.Beats.Should().ContainSingle();
references.Beats[0].Title.Should().Be("She burns the atlas");
references.Beats[0].ChapterTitle.Should().Be("Landfall");
references.Beats[0].ChapterNumber.Should().Be(1);
}
[Fact]
public async Task Usage_counts_are_reported_per_kind()
{
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"]));
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara", Tags: ["sea"]));
var chapter = await _chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
await _beats.CreateAsync(chapter.Id, new CreateBeatRequest("A beat", Tags: ["sea"]));
var summary = (await _tags.ListAsync(_projectId)).Single();
summary.CharacterCount.Should().Be(2);
summary.ChapterCount.Should().Be(0);
summary.BeatCount.Should().Be(1);
summary.TotalCount.Should().Be(3);
}
[Fact]
public async Task Duplicate_tag_names_are_refused_on_create_and_rename()
{
await _tags.CreateAsync(_projectId, new CreateTagRequest("betrayal"));
var duplicate = async () => await _tags.CreateAsync(_projectId, new CreateTagRequest("Betrayal"));
await duplicate.Should().ThrowAsync<InvalidOperationException>().WithMessage("*already has a tag*");
var other = await _tags.CreateAsync(_projectId, new CreateTagRequest("the sea"));
var rename = async () => await _tags.UpdateAsync(other.Id, new UpdateTagRequest(Name: "betrayal"));
await rename.Should().ThrowAsync<InvalidOperationException>().WithMessage("*already has a tag*");
}
[Fact]
public async Task Tags_are_scoped_to_their_project()
{
var otherProject = await new ProjectService(_db.Context)
.CreateAsync(new CreateProjectRequest("Other Book"));
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"]));
await _characters.CreateAsync(otherProject.Id, new CreateCharacterRequest("Someone", Tags: ["sea"]));
(await _tags.ListAsync(_projectId)).Should().ContainSingle();
(await _tags.ListAsync(otherProject.Id)).Should().ContainSingle();
(await _db.CreateContext().Tags.CountAsync()).Should().Be(2);
}
[Fact]
public async Task Deleting_a_tag_leaves_what_carried_it_intact()
{
var character = await _characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var tagId = (await _tags.ListAsync(_projectId)).Single().Id;
await _tags.DeleteAsync(tagId);
var survivor = await _characters.GetAsync(character.Id);
survivor.Name.Should().Be("Ines");
survivor.Tags.Should().BeEmpty();
}
[Fact]
public async Task Deleting_a_project_takes_its_tags()
{
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
await new ProjectService(_db.Context).DeleteAsync(_projectId);
(await _db.CreateContext().Tags.CountAsync()).Should().Be(0);
}
[Fact]
public async Task A_blank_tag_name_is_refused()
{
var create = async () => await _tags.CreateAsync(_projectId, new CreateTagRequest(" "));
await create.Should().ThrowAsync<ArgumentException>();
}
public void Dispose() => _db.Dispose();
}