Builds out the vertical slice for planning and writing a novel. Three front ends — the React UI, an embedded Claude agent, and an MCP stdio server — all go through one REST API, so an edit made from Claude Code and one made in the browser are the same edit. Layout: Domain entities and enums, no dependencies Application services, DTOs, the agent tool-use loop and its 15 tools Infrastructure EF Core 10 + SQLite, Anthropic SDK client Api ASP.NET Core 10 minimal APIs, OpenAPI, ProblemDetails Mcp MCP stdio server, 21 tools over the same REST API Web React 19 + Vite + TanStack Query + Tailwind v4 Data model is Project > Characters / OutlineNodes / Chapters > Scenes, plus agent conversations. The outline is a self-nesting tree so acts, sequences and beats can be arranged however the book wants; scenes carry goal/conflict/outcome because that is what the agent drafts prose from. Notes on a few choices: - Conversation history replays to the model as text only. The agent re-reads current state through its tools rather than trusting a record of edits that may since have changed in the UI. - The user's turn is persisted before the tool loop runs, so a question is recorded even when the model call fails. Turn order uses an explicit sequence column; timestamps tie when a turn completes inside one tick. - Tool failures return is_error results rather than throwing, so the model can read the message and correct itself. MCP tools do the same via CallToolResult, which keeps the API's own message instead of a generic SDK error. - The Anthropic client is constructed lazily. It is injected into the agent service, which also serves read-only endpoints, and those should keep working on an install with no key. Sending without one returns 503, not 400. - DateTimeOffset is stored as UTC ticks. SQLite refuses to ORDER BY the default text form, which every "recently updated first" listing depends on. Tests run against real in-memory SQLite rather than the EF in-memory provider so they exercise the cascade deletes and query translation that actually ship. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
122 lines
4.7 KiB
C#
122 lines
4.7 KiB
C#
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();
|
|
}
|