Remove Scenes, group beats by multiple characters; strip comments repo-wide
Drop the Scene entity/grouping in favor of chapters carrying prose directly and beats belonging to many characters. Add markdown editor + character multi-select components to the web client. Remove all XML doc and inline comments across the touched C#/TS/CSS files in favor of self-documenting names, and record that convention in CLAUDE.md. Add .mcp.json (local MCP server config, no secrets) and ignore .idea/.
This commit is contained in:
@@ -10,9 +10,6 @@ public class AnthropicClientTests
|
||||
{
|
||||
[Test]
|
||||
public void Constructing_without_a_key_does_not_throw() =>
|
||||
// The agent service takes this as a dependency and also serves read-only endpoints
|
||||
// (listing conversations, reading a transcript). Throwing at construction would
|
||||
// take those down on any install that has not configured a key yet.
|
||||
Assert.That(
|
||||
() => new AnthropicAgentModelClient(Options.Create(new AgentOptions()), NullLogger<AnthropicAgentModelClient>.Instance),
|
||||
Throws.Nothing);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
@@ -75,22 +73,20 @@ public class BeatServiceTests : ServiceTestFixture
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_beat_resolves_its_character_and_scene_names()
|
||||
public async Task A_beat_can_carry_several_characters()
|
||||
{
|
||||
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
|
||||
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
|
||||
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
|
||||
|
||||
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
|
||||
"She burns the atlas",
|
||||
CharacterId: ines.Id,
|
||||
CharacterIds: [ines.Id, mara.Id],
|
||||
WhatHappened: "The pages go up faster than she expected.",
|
||||
WhatsNext: "Nothing to navigate by but memory.",
|
||||
SceneId: scene.Id));
|
||||
WhatsNext: "Nothing to navigate by but memory."));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(beat.Character!.Name, Is.EqualTo("Ines"));
|
||||
Assert.That(beat.Scene!.Title, Is.EqualTo("The dock at dawn"));
|
||||
Assert.That(beat.Characters.Select(c => c.Name), Is.EquivalentTo(new[] { "Ines", "Mara" }));
|
||||
Assert.That(beat.WhatHappened, Does.Contain("faster than she expected"));
|
||||
});
|
||||
}
|
||||
@@ -103,41 +99,10 @@ public class BeatServiceTests : ServiceTestFixture
|
||||
|
||||
Assert.That(
|
||||
async () => await Beats.CreateAsync(
|
||||
_chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id)),
|
||||
_chapterId, new CreateBeatRequest("A beat", CharacterIds: [stranger.Id])),
|
||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_beat_cannot_be_grouped_under_a_scene_from_another_chapter()
|
||||
{
|
||||
var elsewhere = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Elsewhere"));
|
||||
var scene = await Scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene"));
|
||||
|
||||
Assert.That(
|
||||
async () => await Beats.CreateAsync(
|
||||
_chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id)),
|
||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same chapter"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
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))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(survivor.SceneId, Is.Null);
|
||||
Assert.That(survivor.Title, Is.EqualTo("She burns the atlas"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
|
||||
{
|
||||
@@ -164,23 +129,17 @@ public class BeatServiceTests : ServiceTestFixture
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ClearCharacter_and_ClearScene_detach_the_reference_since_a_null_id_means_leave_it_alone()
|
||||
public async Task An_empty_CharacterIds_list_clears_a_beats_characters_since_null_means_leave_it_alone()
|
||||
{
|
||||
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, SceneId: scene.Id));
|
||||
"She burns the atlas", CharacterIds: [ines.Id]));
|
||||
|
||||
var untouched = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She burns it")))!;
|
||||
Assert.That(untouched.Character!.Name, Is.EqualTo("Ines"));
|
||||
Assert.That(untouched.Characters.Select(c => c.Name), Is.EqualTo(new[] { "Ines" }));
|
||||
|
||||
var cleared = (await Beats.UpdateAsync(
|
||||
beat.Id, new UpdateBeatRequest(ClearCharacter: true, ClearScene: true)))!;
|
||||
var cleared = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(CharacterIds: [])))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(cleared.Character, Is.Null);
|
||||
Assert.That(cleared.Scene, Is.Null);
|
||||
});
|
||||
Assert.That(cleared.Characters, Is.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,8 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
/// <summary>Records every entry logged through it, so tests can assert on what a service logged.</summary>
|
||||
public record CapturedLogEntry(LogLevel Level, string Message, Exception? Exception);
|
||||
|
||||
/// <summary>
|
||||
/// A test double for <see cref="ILogger{TCategoryName}"/> that captures entries instead of
|
||||
/// writing them anywhere, so tests can assert a service logged at the right level with the
|
||||
/// right values without standing up a real sink.
|
||||
/// </summary>
|
||||
public class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<CapturedLogEntry> Entries { get; } = [];
|
||||
|
||||
@@ -64,8 +64,6 @@ public class CharacterArcTests : ServiceTestFixture
|
||||
[Test]
|
||||
public async Task Within_a_group_the_lead_comes_before_the_second_lead()
|
||||
{
|
||||
// Both enums are stored as text, so ordering them in SQL orders the spelling and
|
||||
// "Deuteragonist" beats "Protagonist" — burying the character the book is about.
|
||||
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
|
||||
"Mara", CharacterRole.Deuteragonist, CharacterImportance.Main));
|
||||
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
|
||||
@@ -162,7 +160,6 @@ public class CharacterArcTests : ServiceTestFixture
|
||||
|
||||
await Chapters.DeleteAsync(chapter.Id);
|
||||
|
||||
// How a character changes outlives a decision about where the chapter break falls.
|
||||
var survivor = (await Arcs.GetAsync(stage.Id))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
@@ -195,9 +192,9 @@ public class CharacterArcTests : ServiceTestFixture
|
||||
var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1));
|
||||
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
|
||||
|
||||
await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterId: _characterId));
|
||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterId: _characterId));
|
||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterId: mara.Id));
|
||||
await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterIds: [_characterId]));
|
||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterIds: [_characterId]));
|
||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterIds: [mara.Id]));
|
||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat"));
|
||||
|
||||
var beats = (await Beats.ListForCharacterAsync(_characterId))!;
|
||||
|
||||
@@ -5,11 +5,6 @@ using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the exception-handling rework: a missing entity is an ordinary result, not a
|
||||
/// thrown exception; <see cref="Guard"/> rejects missing required arguments; and a
|
||||
/// service re-validates a request even when a direct caller skips the API's own filter.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ExceptionHandlingTests : ServiceTestFixture
|
||||
{
|
||||
|
||||
@@ -64,8 +64,6 @@ public class ImportAgentToolsetTests : ServiceTestFixture
|
||||
[Test]
|
||||
public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for()
|
||||
{
|
||||
// The tool takes no path argument at all — this is the enforcement, not a check
|
||||
// against a supplied path. Confirm the write always lands at exactly the ledger name.
|
||||
await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["project"]}""" }));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
|
||||
@@ -105,7 +105,6 @@ public class ImportServiceTests : ServiceTestFixture
|
||||
|
||||
Assert.That(second.Id, Is.EqualTo(first.Id));
|
||||
|
||||
// Only one job was ever queued.
|
||||
Assert.That(_queue.Reader.TryRead(out _), Is.True);
|
||||
Assert.That(_queue.Reader.TryRead(out _), Is.False);
|
||||
}
|
||||
|
||||
@@ -4,15 +4,9 @@ using Novelly.Api.Agent;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
|
||||
namespace Novelly.Api.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>
|
||||
[TestFixture]
|
||||
public class ListingTests : ServiceTestFixture
|
||||
{
|
||||
@@ -22,7 +16,6 @@ public class ListingTests : ServiceTestFixture
|
||||
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();
|
||||
@@ -41,10 +34,8 @@ public class ListingTests : ServiceTestFixture
|
||||
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"));
|
||||
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Prose: "One two three"));
|
||||
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour", Prose: "Four five"));
|
||||
|
||||
var summary = (await Projects.ListAsync()).Single();
|
||||
|
||||
@@ -71,22 +62,19 @@ public class ListingTests : ServiceTestFixture
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Chapters_are_listed_in_manuscript_order_with_scene_totals()
|
||||
public async Task Chapters_are_listed_in_manuscript_order_with_word_counts()
|
||||
{
|
||||
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2));
|
||||
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2, Prose: "One two three"));
|
||||
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);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" }));
|
||||
Assert.That(listed.Single(c => c.Id == second.Id).Scenes, Has.Count.EqualTo(2));
|
||||
Assert.That(listed.Single(c => c.Id == second.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(3));
|
||||
Assert.That(listed.Single(c => c.Id == first.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(0));
|
||||
Assert.That(listed.Single(c => c.Id == second.Id).WordCount, Is.EqualTo(3));
|
||||
Assert.That(listed.Single(c => c.Id == first.Id).WordCount, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,7 +85,7 @@ public class ListingTests : ServiceTestFixture
|
||||
var agent = new NovelAgentService(
|
||||
Db.Context,
|
||||
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
|
||||
new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
|
||||
new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
|
||||
Options.Create(new AgentOptions()),
|
||||
NullLogger<NovelAgentService>.Instance,
|
||||
new SendAgentMessageRequestValidator());
|
||||
|
||||
@@ -7,10 +7,6 @@ using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the logging behaviour added across the services: a warning fires before a
|
||||
/// not-found is thrown, and prose bodies never leak into a log message.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class LoggingTests : ServiceTestFixture
|
||||
{
|
||||
@@ -82,7 +78,7 @@ public class LoggingTests : ServiceTestFixture
|
||||
BeatLogs.Entries.Clear();
|
||||
|
||||
Assert.That(
|
||||
() => Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Arrival", CharacterId: foreignCharacter.Id)),
|
||||
() => Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Arrival", CharacterIds: [foreignCharacter.Id])),
|
||||
Throws.TypeOf<InvalidOperationException>());
|
||||
|
||||
Assert.Multiple(() =>
|
||||
|
||||
@@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
||||
private NovelAgentToolset _toolset = null!;
|
||||
|
||||
protected override void OnSetUp() =>
|
||||
_toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance);
|
||||
_toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance);
|
||||
|
||||
private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
|
||||
Db.Context,
|
||||
@@ -143,7 +143,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
||||
{
|
||||
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
// A model that only ever asks for more tools would otherwise loop forever.
|
||||
var model = new ScriptedModelClient(
|
||||
Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>(
|
||||
[ToolUse("t", "list_characters", new { })], 20).ToList());
|
||||
@@ -178,7 +177,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
||||
{
|
||||
Assert.That(second.ConversationId, Is.EqualTo(first.ConversationId));
|
||||
|
||||
// The second request replays the earlier turns so the model has the history.
|
||||
Assert.That(model.Transcripts[1], Has.Count.EqualTo(3));
|
||||
Assert.That(
|
||||
model.Transcripts[1].Select(m => m.Role),
|
||||
@@ -209,10 +207,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
||||
new(id, name, JsonSerializer.SerializeToElement(input));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A model stand-in that returns a fixed script of turns and records every transcript it
|
||||
/// was sent, so tests can assert on what the loop actually put in front of the model.
|
||||
/// </summary>
|
||||
internal class ScriptedModelClient(IReadOnlyList<IReadOnlyList<AgentContentBlock>> script)
|
||||
: IAgentModelClient
|
||||
{
|
||||
|
||||
@@ -88,7 +88,6 @@ public class OpenQuestionTests : ServiceTestFixture
|
||||
Assert.That(open.Select(q => q.Question), Is.EqualTo(new[] { "Is this one book or two?" }));
|
||||
Assert.That(everything, Has.Count.EqualTo(2));
|
||||
|
||||
// Still-open questions come first, so the list stays about what is undecided.
|
||||
Assert.That(everything[0].IsResolved, Is.False);
|
||||
Assert.That(everything[1].IsResolved, Is.True);
|
||||
});
|
||||
@@ -128,7 +127,6 @@ public class OpenQuestionTests : ServiceTestFixture
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
// The existing note is kept and the decision lands underneath it.
|
||||
Assert.That(chapter.Notes, Does.StartWith("Runs long."));
|
||||
Assert.That(chapter.Notes, Does.Contain("Where does the chapter break? — After the harbour burns."));
|
||||
Assert.That(character.Notes, Is.EqualTo("Where does the chapter break? — After the harbour burns."));
|
||||
@@ -179,7 +177,6 @@ public class OpenQuestionTests : ServiceTestFixture
|
||||
{
|
||||
Assert.That(detached.ChapterId, Is.Null);
|
||||
|
||||
// Only the chapter was cleared — a null id means "leave alone", not "detach".
|
||||
Assert.That(detached.CharacterId, Is.EqualTo(_characterId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
@@ -69,19 +68,18 @@ public class ProjectDataTests : ServiceTestFixture
|
||||
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"));
|
||||
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||
"Landfall", Prose: "Five words go right here"));
|
||||
|
||||
Assert.That(scene.WordCount, Is.EqualTo(5));
|
||||
Assert.That(chapter.WordCount, Is.EqualTo(5));
|
||||
|
||||
var rewritten = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
|
||||
var rewritten = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(
|
||||
Prose: "Now\nthere are seven words in total")))!;
|
||||
|
||||
Assert.That(rewritten.WordCount, Is.EqualTo(7));
|
||||
|
||||
var cleared = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: "")))!;
|
||||
var cleared = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Prose: "")))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
@@ -91,14 +89,13 @@ public class ProjectDataTests : ServiceTestFixture
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Scene_updates_that_omit_prose_leave_the_draft_untouched()
|
||||
public async Task Chapter_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 chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||
"Landfall", Prose: "The tide came in slow."));
|
||||
|
||||
var updated = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised)))!;
|
||||
var updated = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Status: DraftStatus.Revised)))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
@@ -109,12 +106,11 @@ public class ProjectDataTests : ServiceTestFixture
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Deleting_a_project_takes_its_characters_chapters_and_scenes()
|
||||
public async Task Deleting_a_project_takes_its_characters_and_chapters()
|
||||
{
|
||||
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 Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
|
||||
|
||||
await Projects.DeleteAsync(projectId);
|
||||
|
||||
@@ -125,7 +121,6 @@ public class ProjectDataTests : ServiceTestFixture
|
||||
Assert.That(await verification.Projects.CountAsync(), Is.EqualTo(0));
|
||||
Assert.That(await verification.Characters.CountAsync(), Is.EqualTo(0));
|
||||
Assert.That(await verification.Chapters.CountAsync(), Is.EqualTo(0));
|
||||
Assert.That(await verification.Scenes.CountAsync(), Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,20 +3,10 @@ using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared plumbing for the service tests: a fresh in-memory database and a matching set
|
||||
/// of services per test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The setup lives in <c>[SetUp]</c> rather than a constructor or field initialisers
|
||||
/// because NUnit builds one fixture instance for the whole class — anything created once
|
||||
/// would leak state from one test into the next.
|
||||
/// </remarks>
|
||||
public abstract class ServiceTestFixture
|
||||
{
|
||||
protected TestDatabase Db { get; private set; } = null!;
|
||||
@@ -24,7 +14,6 @@ public abstract class ServiceTestFixture
|
||||
protected ProjectService Projects { get; private set; } = null!;
|
||||
protected CharacterService Characters { get; private set; } = null!;
|
||||
protected ChapterService Chapters { get; private set; } = null!;
|
||||
protected SceneService Scenes { get; private set; } = null!;
|
||||
protected BeatService Beats { get; private set; } = null!;
|
||||
protected CharacterArcService Arcs { get; private set; } = null!;
|
||||
protected OpenQuestionService Questions { get; private set; } = null!;
|
||||
@@ -32,7 +21,6 @@ public abstract class ServiceTestFixture
|
||||
protected CapturingLogger<ProjectService> ProjectLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<SceneService> SceneLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<TagService> TagLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
|
||||
@@ -47,7 +35,6 @@ public abstract class ServiceTestFixture
|
||||
ProjectLogs = new CapturingLogger<ProjectService>();
|
||||
CharacterLogs = new CapturingLogger<CharacterService>();
|
||||
ChapterLogs = new CapturingLogger<ChapterService>();
|
||||
SceneLogs = new CapturingLogger<SceneService>();
|
||||
BeatLogs = new CapturingLogger<BeatService>();
|
||||
ArcLogs = new CapturingLogger<CharacterArcService>();
|
||||
QuestionLogs = new CapturingLogger<OpenQuestionService>();
|
||||
@@ -58,7 +45,6 @@ public abstract class ServiceTestFixture
|
||||
Db.Context, Tags, CharacterLogs,
|
||||
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
|
||||
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
||||
Scenes = new SceneService(Db.Context, SceneLogs, new CreateSceneRequestValidator(), new UpdateSceneRequestValidator());
|
||||
Beats = new BeatService(
|
||||
Db.Context, Tags, BeatLogs,
|
||||
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator());
|
||||
@@ -72,7 +58,6 @@ public abstract class ServiceTestFixture
|
||||
OnSetUp();
|
||||
}
|
||||
|
||||
/// <summary>Runs after the services exist, for per-class seed data.</summary>
|
||||
protected virtual void OnSetUp()
|
||||
{
|
||||
}
|
||||
|
||||
@@ -4,16 +4,6 @@ using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A throwaway SQLite database held in memory. Using real SQLite rather than the
|
||||
/// in-memory provider means the tests exercise the same relational behaviour the app
|
||||
/// ships with — cascade deletes, foreign keys and all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// NUnit reuses one fixture instance across every test in a class, so this must be built
|
||||
/// in <c>[SetUp]</c> and disposed in <c>[TearDown]</c>. A field initialiser would share
|
||||
/// one database for the whole class and let tests see each other's rows.
|
||||
/// </remarks>
|
||||
public class TestDatabase : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
@@ -29,7 +19,6 @@ public class TestDatabase : IDisposable
|
||||
|
||||
public NovelDbContext Context { get; }
|
||||
|
||||
/// <summary>A second context over the same database, for asserting on persisted state.</summary>
|
||||
public NovelDbContext CreateContext() =>
|
||||
new(new DbContextOptionsBuilder<NovelDbContext>().UseSqlite(_connection).Options);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user