Files
novelly/tests/NovelSoftware.Tests/ProjectDataTests.cs
T
James WamplerandClaude Opus 5 7678cc7275 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
2026-08-06 12:11:20 -07:00

155 lines
6.0 KiB
C#

using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
using NovelSoftware.Domain;
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;
private readonly SceneService _scenes;
public ProjectDataTests()
{
_tags = new TagService(_db.Context);
_projects = new ProjectService(_db.Context);
_characters = new CharacterService(_db.Context, _tags);
_chapters = new ChapterService(_db.Context, _tags);
_scenes = new SceneService(_db.Context);
}
private async Task<Guid> NewProjectAsync() =>
(await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
[Fact]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
var id = (await _projects.CreateAsync(
new CreateProjectRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id;
var afterPartialUpdate = await _projects.UpdateAsync(id, new UpdateProjectRequest(Title: "The Salt Road"));
afterPartialUpdate.Title.Should().Be("The Salt Road");
afterPartialUpdate.Genre.Should().Be("Fantasy");
afterPartialUpdate.Logline.Should().Be("A cartographer goes to sea.");
var afterClear = await _projects.UpdateAsync(id, new UpdateProjectRequest(Genre: ""));
afterClear.Genre.Should().BeNull();
afterClear.Logline.Should().Be("A cartographer goes to sea.");
}
[Fact]
public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given()
{
var projectId = await NewProjectAsync();
var first = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var second = await _chapters.CreateAsync(projectId, new CreateChapterRequest("The Harbour"));
first.Number.Should().Be(1);
second.Number.Should().Be(2);
}
[Fact]
public async Task Word_count_is_recomputed_whenever_prose_changes()
{
var projectId = await NewProjectAsync();
var chapter = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var scene = await _scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
"The dock at dawn", Prose: "Five words go right here"));
scene.WordCount.Should().Be(5);
var rewritten = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
Prose: "Now\nthere are seven words in total"));
rewritten.WordCount.Should().Be(7);
var cleared = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: ""));
cleared.Prose.Should().BeNull();
cleared.WordCount.Should().Be(0);
}
[Fact]
public async Task Scene_updates_that_omit_prose_leave_the_draft_untouched()
{
var projectId = await NewProjectAsync();
var chapter = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var scene = await _scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
"The dock at dawn", Prose: "The tide came in slow."));
var updated = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised));
updated.Prose.Should().Be("The tide came in slow.");
updated.WordCount.Should().Be(5);
updated.Status.Should().Be(DraftStatus.Revised);
}
[Fact]
public async Task Deleting_a_project_takes_its_characters_chapters_and_scenes()
{
var projectId = await NewProjectAsync();
await _characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
var chapter = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
await _scenes.CreateAsync(chapter.Id, new CreateSceneRequest("The dock at dawn"));
await _projects.DeleteAsync(projectId);
using var verification = _db.CreateContext();
(await verification.Projects.CountAsync()).Should().Be(0);
(await verification.Characters.CountAsync()).Should().Be(0);
(await verification.Chapters.CountAsync()).Should().Be(0);
(await verification.Scenes.CountAsync()).Should().Be(0);
}
[Fact]
public async Task Relating_characters_across_projects_is_refused()
{
var firstProject = await NewProjectAsync();
var secondProject = (await _projects.CreateAsync(new CreateProjectRequest("Other Book"))).Id;
var ines = await _characters.CreateAsync(firstProject, new CreateCharacterRequest("Ines"));
var stranger = await _characters.CreateAsync(secondProject, new CreateCharacterRequest("Stranger"));
var relate = async () => await _characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(stranger.Id, "sister"));
await relate.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*same project*");
}
[Fact]
public async Task Relationships_resolve_the_other_character_by_name()
{
var projectId = await NewProjectAsync();
var ines = await _characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
var mara = await _characters.CreateAsync(projectId, new CreateCharacterRequest("Mara"));
var updated = await _characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire."));
updated.Relationships.Should().ContainSingle()
.Which.RelatedCharacterName.Should().Be("Mara");
}
[Fact]
public async Task Reading_a_missing_project_reports_not_found()
{
var get = async () => await _projects.GetAsync(Guid.NewGuid());
await get.Should().ThrowAsync<NotFoundException>();
}
public void Dispose() => _db.Dispose();
}