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
153 lines
5.9 KiB
C#
153 lines
5.9 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 ProjectService _projects;
|
|
private readonly CharacterService _characters;
|
|
private readonly ChapterService _chapters;
|
|
private readonly SceneService _scenes;
|
|
|
|
public ProjectDataTests()
|
|
{
|
|
_projects = new ProjectService(_db.Context);
|
|
_characters = new CharacterService(_db.Context);
|
|
_chapters = new ChapterService(_db.Context);
|
|
_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();
|
|
}
|