Migrate the test suite from xUnit + FluentAssertions to NUnit

All 44 tests, same names, same coverage — verified by diffing the runner's test
list before and after.

The load-bearing change is fixture lifecycle. xUnit builds a new test-class
instance per test, so a `readonly TestDatabase _db = new()` field gave every
test its own database. NUnit reuses one instance for the whole class, so those
field initialisers and constructors would have shared a single database across a
class and let tests read each other's rows. Setup moved into [SetUp]/[TearDown]
via a new ServiceTestFixture base class, which also collapses the per-class
service wiring that was duplicated five times.

Assertions are now Assert.That with the constraint model:

  Should().Be(x)              -> Is.EqualTo(x)
  Should().BeNull()           -> Is.Null
  Should().HaveCount(n)       -> Has.Count.EqualTo(n)
  Should().Equal(a, b)        -> Is.EqualTo(new[] { a, b })
  Should().BeEquivalentTo(..) -> Is.EquivalentTo(..)
  Should().Contain("x")       -> Does.Contain("x")
  Should().OnlyHaveUniqueItems() -> Is.Unique
  ThrowAsync<T>().WithMessage("*m*")
      -> Throws.TypeOf<T>().With.Message.Contains("m")

FluentAssertions' `.Which` chains became plain indexed asserts, grouped in
Assert.Multiple so a failure reports every broken expectation in the case rather
than stopping at the first.

Because a framework migration can quietly produce vacuously-passing tests,
spot-checked four conversions by mutation — breaking the code under an async
Assert.Multiple block, a sync one, a Throws constraint, and a collection
ordering assert. All four failed as they should, confirming the assertions are
live and that NUnit bound the async lambdas to AsyncTestDelegate rather than
silently accepting them as async void.

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 8394843255
commit 30e0c6926e
9 changed files with 510 additions and 434 deletions
@@ -1,4 +1,3 @@
using FluentAssertions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NovelSoftware.Application; using NovelSoftware.Application;
using NovelSoftware.Application.Agent; using NovelSoftware.Application.Agent;
@@ -6,21 +5,20 @@ using NovelSoftware.Infrastructure.Anthropic;
namespace NovelSoftware.Tests; namespace NovelSoftware.Tests;
[TestFixture]
public class AnthropicClientTests public class AnthropicClientTests
{ {
[Fact] [Test]
public void Constructing_without_a_key_does_not_throw() public void Constructing_without_a_key_does_not_throw() =>
{
// The agent service takes this as a dependency and also serves read-only endpoints // The agent service takes this as a dependency and also serves read-only endpoints
// (listing conversations, reading a transcript). Throwing at construction would // (listing conversations, reading a transcript). Throwing at construction would
// take those down on any install that has not configured a key yet. // take those down on any install that has not configured a key yet.
var construct = () => new AnthropicAgentModelClient(Options.Create(new AgentOptions())); Assert.That(
() => new AnthropicAgentModelClient(Options.Create(new AgentOptions())),
Throws.Nothing);
construct.Should().NotThrow(); [Test]
} public void Sending_without_a_key_reports_a_configuration_problem()
[Fact]
public async Task Sending_without_a_key_reports_a_configuration_problem()
{ {
var previous = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"); var previous = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY");
Environment.SetEnvironmentVariable("ANTHROPIC_API_KEY", null); Environment.SetEnvironmentVariable("ANTHROPIC_API_KEY", null);
@@ -29,10 +27,9 @@ public class AnthropicClientTests
{ {
var client = new AnthropicAgentModelClient(Options.Create(new AgentOptions())); var client = new AnthropicAgentModelClient(Options.Create(new AgentOptions()));
var send = async () => await client.CompleteAsync("system", [], []); Assert.That(
async () => await client.CompleteAsync("system", [], []),
(await send.Should().ThrowAsync<AgentNotConfiguredException>()) Throws.TypeOf<AgentNotConfiguredException>().With.Message.Contains("ANTHROPIC_API_KEY"));
.WithMessage("*ANTHROPIC_API_KEY*");
} }
finally finally
{ {
+93 -89
View File
@@ -1,159 +1,163 @@
using FluentAssertions;
using NovelSoftware.Application; using NovelSoftware.Application;
using NovelSoftware.Application.Dtos; using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
namespace NovelSoftware.Tests; namespace NovelSoftware.Tests;
public class BeatServiceTests : IDisposable [TestFixture]
public class BeatServiceTests : ServiceTestFixture
{ {
private readonly TestDatabase _db = new(); private Guid _projectId;
private readonly BeatService _beats; private Guid _chapterId;
private readonly CharacterService _characters;
private readonly SceneService _scenes;
private readonly Guid _projectId;
private readonly Guid _chapterId;
public BeatServiceTests() protected override void OnSetUp()
{ {
var tags = new TagService(_db.Context); _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
var projects = new ProjectService(_db.Context); _chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id;
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] [Test]
public async Task Beats_are_appended_in_order_and_listed_that_way() 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("She finds the map"));
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("The harbour burns")); await Beats.CreateAsync(_chapterId, new CreateBeatRequest("The harbour burns"));
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("She boards anyway")); await Beats.CreateAsync(_chapterId, new CreateBeatRequest("She boards anyway"));
var listed = await _beats.ListAsync(_chapterId); var listed = await Beats.ListAsync(_chapterId);
listed.Select(b => b.Title) Assert.Multiple(() =>
.Should().Equal("She finds the map", "The harbour burns", "She boards anyway"); {
listed.Select(b => b.SortOrder).Should().Equal(1, 2, 3); Assert.That(
listed.Select(b => b.Title),
Is.EqualTo(new[] { "She finds the map", "The harbour burns", "She boards anyway" }));
Assert.That(listed.Select(b => b.SortOrder), Is.EqualTo(new[] { 1, 2, 3 }));
});
} }
[Fact] [Test]
public async Task Reordering_renumbers_to_match_the_order_given() public async Task Reordering_renumbers_to_match_the_order_given()
{ {
var first = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var second = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var third = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Third")); var third = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Third"));
var reordered = await _beats.ReorderAsync( var reordered = await Beats.ReorderAsync(
_chapterId, new ReorderBeatsRequest([third.Id, first.Id, second.Id])); _chapterId, new ReorderBeatsRequest([third.Id, first.Id, second.Id]));
reordered.Select(b => b.Title).Should().Equal("Third", "First", "Second"); Assert.That(
reordered.Select(b => b.Title),
Is.EqualTo(new[] { "Third", "First", "Second" }));
} }
[Fact] [Test]
public async Task Beats_left_out_of_a_reorder_keep_their_relative_position_at_the_end() 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")); var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var third = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Third")); var third = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Third"));
var reordered = await _beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([third.Id, first.Id])); var reordered = await Beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([third.Id, first.Id]));
reordered.Select(b => b.Title).Should().Equal("Third", "First", "Second"); Assert.That(
reordered.Select(b => b.Title),
Is.EqualTo(new[] { "Third", "First", "Second" }));
} }
[Fact] [Test]
public async Task Reordering_with_an_unknown_beat_is_refused() public void Reordering_with_an_unknown_beat_is_refused()
{ {
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")).Wait();
var reorder = async () => await _beats.ReorderAsync( Assert.That(
_chapterId, new ReorderBeatsRequest([Guid.NewGuid()])); async () => await Beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([Guid.NewGuid()])),
Throws.TypeOf<NotFoundException>());
await reorder.Should().ThrowAsync<NotFoundException>();
} }
[Fact] [Test]
public async Task A_beat_resolves_its_character_and_scene_names() public async Task A_beat_resolves_its_character_and_scene_names()
{ {
var ines = await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var scene = await _scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn")); var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await _beats.CreateAsync(_chapterId, new CreateBeatRequest( var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas", "She burns the atlas",
CharacterId: ines.Id, CharacterId: ines.Id,
WhatHappened: "The pages go up faster than she expected.", WhatHappened: "The pages go up faster than she expected.",
WhatsNext: "Nothing to navigate by but memory.", WhatsNext: "Nothing to navigate by but memory.",
SceneId: scene.Id)); SceneId: scene.Id));
beat.CharacterName.Should().Be("Ines"); Assert.Multiple(() =>
beat.SceneTitle.Should().Be("The dock at dawn"); {
beat.WhatHappened.Should().Contain("faster than she expected"); Assert.That(beat.CharacterName, Is.EqualTo("Ines"));
Assert.That(beat.SceneTitle, Is.EqualTo("The dock at dawn"));
Assert.That(beat.WhatHappened, Does.Contain("faster than she expected"));
});
} }
[Fact] [Test]
public async Task A_beat_cannot_borrow_a_character_from_another_project() 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 other = await projects.CreateAsync(new CreateProjectRequest("Other Book")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
var stranger = await _characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
var create = async () => await _beats.CreateAsync( Assert.That(
_chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id)); async () => await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id)),
await create.Should().ThrowAsync<InvalidOperationException>() Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
.WithMessage("*same project*");
} }
[Fact] [Test]
public async Task A_beat_cannot_be_grouped_under_a_scene_from_another_chapter() 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 elsewhere = await chapters.CreateAsync(_projectId, new CreateChapterRequest("Elsewhere")); var scene = await Scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene"));
var scene = await _scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene"));
var create = async () => await _beats.CreateAsync( Assert.That(
_chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id)); async () => await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id)),
await create.Should().ThrowAsync<InvalidOperationException>() Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same chapter"));
.WithMessage("*same chapter*");
} }
[Fact] [Test]
public async Task Deleting_a_scene_leaves_its_beats_alone() public async Task Deleting_a_scene_leaves_its_beats_alone()
{ {
var scene = await _scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn")); var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await _beats.CreateAsync( var beat = await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("She burns the atlas", SceneId: scene.Id)); _chapterId, new CreateBeatRequest("She burns the atlas", SceneId: scene.Id));
await _scenes.DeleteAsync(scene.Id); await Scenes.DeleteAsync(scene.Id);
// The plan outlives a decision about prose — the beat is simply ungrouped. // The plan outlives a decision about prose — the beat is simply ungrouped.
var survivor = await _beats.GetAsync(beat.Id); var survivor = await Beats.GetAsync(beat.Id);
survivor.SceneId.Should().BeNull();
survivor.Title.Should().Be("She burns the atlas"); Assert.Multiple(() =>
{
Assert.That(survivor.SceneId, Is.Null);
Assert.That(survivor.Title, Is.EqualTo("She burns the atlas"));
});
} }
[Fact] [Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{ {
var beat = await _beats.CreateAsync(_chapterId, new CreateBeatRequest( var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She finds the map", WhatHappened: "Behind the lining of the case.", WhatsNext: "She books passage.")); "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")); var renamed = await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She finds it"));
renamed.WhatHappened.Should().Be("Behind the lining of the case."); Assert.Multiple(() =>
renamed.WhatsNext.Should().Be("She books passage."); {
Assert.That(renamed.WhatHappened, Is.EqualTo("Behind the lining of the case."));
Assert.That(renamed.WhatsNext, Is.EqualTo("She books passage."));
});
var cleared = await _beats.UpdateAsync(beat.Id, new UpdateBeatRequest(WhatsNext: "")); var cleared = await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(WhatsNext: ""));
cleared.WhatsNext.Should().BeNull(); Assert.Multiple(() =>
cleared.WhatHappened.Should().Be("Behind the lining of the case."); {
Assert.That(cleared.WhatsNext, Is.Null);
Assert.That(cleared.WhatHappened, Is.EqualTo("Behind the lining of the case."));
});
} }
public void Dispose() => _db.Dispose();
} }
+67 -73
View File
@@ -1,9 +1,8 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NovelSoftware.Application.Agent; using NovelSoftware.Application.Agent;
using NovelSoftware.Application.Dtos; using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services; using NovelSoftware.Domain;
namespace NovelSoftware.Tests; namespace NovelSoftware.Tests;
@@ -12,94 +11,91 @@ namespace NovelSoftware.Tests;
/// SQLite is fussier than the in-memory provider about what it will translate — ordering /// 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. /// by a DateTimeOffset, for one — so these have to run against real SQLite to be worth anything.
/// </summary> /// </summary>
public class ListingTests : IDisposable [TestFixture]
public class ListingTests : ServiceTestFixture
{ {
private readonly TestDatabase _db = new(); [Test]
private readonly TagService _tags;
private readonly ProjectService _projects;
private readonly ChapterService _chapters;
private readonly SceneService _scenes;
private readonly CharacterService _characters;
public ListingTests()
{
_tags = new TagService(_db.Context);
_projects = new ProjectService(_db.Context);
_chapters = new ChapterService(_db.Context, _tags);
_scenes = new SceneService(_db.Context);
_characters = new CharacterService(_db.Context, _tags);
}
[Fact]
public async Task Projects_are_listed_most_recently_updated_first() public async Task Projects_are_listed_most_recently_updated_first()
{ {
var older = await _projects.CreateAsync(new CreateProjectRequest("Older Book")); var older = await Projects.CreateAsync(new CreateProjectRequest("Older Book"));
var newer = await _projects.CreateAsync(new CreateProjectRequest("Newer Book")); var newer = await Projects.CreateAsync(new CreateProjectRequest("Newer Book"));
// Touching the older project should float it to the top. // Touching the older project should float it to the top.
await _projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised.")); await Projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised."));
var listed = await _projects.ListAsync(); var listed = await Projects.ListAsync();
listed.Select(p => p.Title).Should().Equal("Older Book", "Newer Book"); Assert.Multiple(() =>
listed.Select(p => p.Id).Should().Contain(newer.Id); {
Assert.That(listed.Select(p => p.Title), Is.EqualTo(new[] { "Older Book", "Newer Book" }));
Assert.That(listed.Select(p => p.Id), Does.Contain(newer.Id));
});
} }
[Fact] [Test]
public async Task Project_summaries_aggregate_counts_and_words_across_chapters() public async Task Project_summaries_aggregate_counts_and_words_across_chapters()
{ {
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
await _characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines")); await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines"));
await _characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara")); await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara"));
var first = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall")); var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
var second = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour")); 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(first.Id, new CreateSceneRequest("Dawn", Prose: "One two three"));
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five")); await Scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five"));
var summary = (await _projects.ListAsync()).Single(); var summary = (await Projects.ListAsync()).Single();
summary.CharacterCount.Should().Be(2); Assert.Multiple(() =>
summary.ChapterCount.Should().Be(2); {
summary.WordCount.Should().Be(5); Assert.That(summary.CharacterCount, Is.EqualTo(2));
Assert.That(summary.ChapterCount, Is.EqualTo(2));
Assert.That(summary.WordCount, Is.EqualTo(5));
});
} }
[Fact] [Test]
public async Task A_project_with_no_chapters_reports_zero_words_rather_than_failing() public async Task A_project_with_no_chapters_reports_zero_words_rather_than_failing()
{ {
await _projects.CreateAsync(new CreateProjectRequest("Empty")); await Projects.CreateAsync(new CreateProjectRequest("Empty"));
var summary = (await _projects.ListAsync()).Single(); var summary = (await Projects.ListAsync()).Single();
summary.WordCount.Should().Be(0); Assert.Multiple(() =>
summary.ChapterCount.Should().Be(0); {
Assert.That(summary.WordCount, Is.EqualTo(0));
Assert.That(summary.ChapterCount, Is.EqualTo(0));
});
} }
[Fact] [Test]
public async Task Chapters_are_listed_in_manuscript_order_with_scene_totals() public async Task Chapters_are_listed_in_manuscript_order_with_scene_totals()
{ {
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road")); 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));
var first = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1)); 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("A", Prose: "One two"));
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("B", Prose: "Three")); await Scenes.CreateAsync(second.Id, new CreateSceneRequest("B", Prose: "Three"));
var listed = await _chapters.ListAsync(project.Id); var listed = await Chapters.ListAsync(project.Id);
listed.Select(c => c.Title).Should().Equal("First", "Second"); Assert.Multiple(() =>
listed.Single(c => c.Id == second.Id).SceneCount.Should().Be(2); {
listed.Single(c => c.Id == second.Id).WordCount.Should().Be(3); Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" }));
listed.Single(c => c.Id == first.Id).WordCount.Should().Be(0); Assert.That(listed.Single(c => c.Id == second.Id).SceneCount, Is.EqualTo(2));
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));
});
} }
[Fact] [Test]
public async Task Conversations_are_listed_most_recently_updated_first() public async Task Conversations_are_listed_most_recently_updated_first()
{ {
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var agent = new NovelAgentService( var agent = new NovelAgentService(
_db.Context, Db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(_projects, _characters, _chapters, new BeatService(_db.Context, _tags), _scenes, _tags), new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags),
Options.Create(new AgentOptions()), Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance); NullLogger<NovelAgentService>.Instance);
@@ -108,26 +104,24 @@ public class ListingTests : IDisposable
var listed = await agent.ListConversationsAsync(project.Id); var listed = await agent.ListConversationsAsync(project.Id);
listed.Should().HaveCount(2); Assert.Multiple(() =>
listed[0].Title.Should().Be("Second question."); {
listed[0].MessageCount.Should().Be(2); Assert.That(listed, Has.Count.EqualTo(2));
Assert.That(listed[0].Title, Is.EqualTo("Second question."));
Assert.That(listed[0].MessageCount, Is.EqualTo(2));
});
} }
[Fact] [Test]
public async Task Characters_are_listed_by_role_then_name() public async Task Characters_are_listed_by_role_then_name()
{ {
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
await _characters.CreateAsync(project.Id, new CreateCharacterRequest( await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Zeno", CharacterRole.Supporting));
"Zeno", NovelSoftware.Domain.CharacterRole.Supporting)); await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines", CharacterRole.Protagonist));
await _characters.CreateAsync(project.Id, new CreateCharacterRequest( await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Anders", CharacterRole.Supporting));
"Ines", NovelSoftware.Domain.CharacterRole.Protagonist));
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
"Anders", NovelSoftware.Domain.CharacterRole.Supporting));
var listed = await _characters.ListAsync(project.Id); var listed = await Characters.ListAsync(project.Id);
listed.Select(c => c.Name).Should().Equal("Ines", "Anders", "Zeno"); Assert.That(listed.Select(c => c.Name), Is.EqualTo(new[] { "Ines", "Anders", "Zeno" }));
} }
public void Dispose() => _db.Dispose();
} }
@@ -1,63 +1,51 @@
using System.Text.Json; using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NovelSoftware.Application.Agent; using NovelSoftware.Application.Agent;
using NovelSoftware.Application.Dtos; using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
namespace NovelSoftware.Tests; namespace NovelSoftware.Tests;
public class NovelAgentServiceTests : IDisposable [TestFixture]
public class NovelAgentServiceTests : ServiceTestFixture
{ {
private readonly TestDatabase _db = new(); private NovelAgentToolset _toolset = null!;
private readonly TagService _tags;
private readonly ProjectService _projects;
private readonly CharacterService _characters;
private readonly NovelAgentToolset _toolset;
public NovelAgentServiceTests() protected override void OnSetUp() =>
{ _toolset = new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags);
_tags = new TagService(_db.Context);
_projects = new ProjectService(_db.Context);
_characters = new CharacterService(_db.Context, _tags);
_toolset = new NovelAgentToolset(
_projects,
_characters,
new ChapterService(_db.Context, _tags),
new BeatService(_db.Context, _tags),
new SceneService(_db.Context),
_tags);
}
private NovelAgentService BuildAgent(ScriptedModelClient model) => new( private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
_db.Context, Db.Context,
model, model,
_toolset, _toolset,
Options.Create(new AgentOptions { MaxIterations = 4 }), Options.Create(new AgentOptions { MaxIterations = 4 }),
NullLogger<NovelAgentService>.Instance); NullLogger<NovelAgentService>.Instance);
[Fact] [Test]
public async Task A_plain_reply_is_persisted_as_a_conversation() public async Task A_plain_reply_is_persisted_as_a_conversation()
{ {
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([[new AgentTextBlock("Tell me about the ending.")]]); var model = new ScriptedModelClient([[new AgentTextBlock("Tell me about the ending.")]]);
var agent = BuildAgent(model); var agent = BuildAgent(model);
var turn = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Where do I start?")); var turn = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Where do I start?"));
turn.Message.Content.Should().Be("Tell me about the ending."); Assert.That(turn.Message.Content, Is.EqualTo("Tell me about the ending."));
var conversation = await agent.GetConversationAsync(turn.ConversationId); var conversation = await agent.GetConversationAsync(turn.ConversationId);
conversation.Messages.Should().HaveCount(2);
conversation.Messages[0].Content.Should().Be("Where do I start?"); Assert.Multiple(() =>
conversation.Title.Should().Be("Where do I start?"); {
Assert.That(conversation.Messages, Has.Count.EqualTo(2));
Assert.That(conversation.Messages[0].Content, Is.EqualTo("Where do I start?"));
Assert.That(conversation.Title, Is.EqualTo("Where do I start?"));
});
} }
[Fact] [Test]
public async Task Tool_calls_are_executed_against_real_project_data() public async Task Tool_calls_are_executed_against_real_project_data()
{ {
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([ var model = new ScriptedModelClient([
[ToolUse("t1", "create_character", new { name = "Ines", role = "Protagonist" })], [ToolUse("t1", "create_character", new { name = "Ines", role = "Protagonist" })],
@@ -67,17 +55,22 @@ public class NovelAgentServiceTests : IDisposable
var turn = await BuildAgent(model).SendMessageAsync( var turn = await BuildAgent(model).SendMessageAsync(
projectId, new SendAgentMessageRequest("Add a protagonist called Ines.")); projectId, new SendAgentMessageRequest("Add a protagonist called Ines."));
var characters = await _characters.ListAsync(projectId); var characters = await Characters.ListAsync(projectId);
characters.Should().ContainSingle().Which.Name.Should().Be("Ines");
turn.Message.Content.Should().Be("Added Ines as the protagonist."); Assert.Multiple(() =>
turn.Message.ToolCalls.Should().ContainSingle().Which.Name.Should().Be("create_character"); {
Assert.That(characters, Has.Count.EqualTo(1));
Assert.That(characters[0].Name, Is.EqualTo("Ines"));
Assert.That(turn.Message.Content, Is.EqualTo("Added Ines as the protagonist."));
Assert.That(turn.Message.ToolCalls, Has.Count.EqualTo(1));
Assert.That(turn.Message.ToolCalls[0].Name, Is.EqualTo("create_character"));
});
} }
[Fact] [Test]
public async Task Every_tool_result_comes_back_in_a_single_user_turn() public async Task Every_tool_result_comes_back_in_a_single_user_turn()
{ {
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([ var model = new ScriptedModelClient([
[ [
@@ -89,18 +82,21 @@ public class NovelAgentServiceTests : IDisposable
await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Add two characters.")); await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Add two characters."));
var secondRequest = model.Transcripts[1]; var resultTurn = model.Transcripts[1][^1];
var resultTurn = secondRequest[^1]; var listed = await Characters.ListAsync(projectId);
resultTurn.Role.Should().Be("user"); Assert.Multiple(() =>
resultTurn.Content.OfType<AgentToolResultBlock>().Should().HaveCount(2); {
(await _characters.ListAsync(projectId)).Should().HaveCount(2); Assert.That(resultTurn.Role, Is.EqualTo("user"));
Assert.That(resultTurn.Content.OfType<AgentToolResultBlock>().Count(), Is.EqualTo(2));
Assert.That(listed, Has.Count.EqualTo(2));
});
} }
[Fact] [Test]
public async Task A_failing_tool_is_reported_back_rather_than_thrown() public async Task A_failing_tool_is_reported_back_rather_than_thrown()
{ {
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([ var model = new ScriptedModelClient([
[ToolUse("t1", "update_character", new { character_id = Guid.NewGuid().ToString(), name = "Ines" })], [ToolUse("t1", "update_character", new { character_id = Guid.NewGuid().ToString(), name = "Ines" })],
@@ -111,16 +107,19 @@ public class NovelAgentServiceTests : IDisposable
projectId, new SendAgentMessageRequest("Rename her.")); projectId, new SendAgentMessageRequest("Rename her."));
var errorResult = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single(); var errorResult = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single();
errorResult.IsError.Should().BeTrue();
errorResult.Content.Should().Contain("was not found");
turn.Message.Content.Should().Contain("does not exist yet"); Assert.Multiple(() =>
{
Assert.That(errorResult.IsError, Is.True);
Assert.That(errorResult.Content, Does.Contain("was not found"));
Assert.That(turn.Message.Content, Does.Contain("does not exist yet"));
});
} }
[Fact] [Test]
public async Task Unknown_tools_are_reported_without_breaking_the_loop() public async Task Unknown_tools_are_reported_without_breaking_the_loop()
{ {
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([ var model = new ScriptedModelClient([
[ToolUse("t1", "summon_muse", new { })], [ToolUse("t1", "summon_muse", new { })],
@@ -130,14 +129,18 @@ public class NovelAgentServiceTests : IDisposable
await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Summon the muse.")); await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Summon the muse."));
var result = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single(); var result = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single();
result.IsError.Should().BeTrue();
result.Content.Should().Contain("No such tool"); Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.True);
Assert.That(result.Content, Does.Contain("No such tool"));
});
} }
[Fact] [Test]
public async Task The_loop_stops_at_the_iteration_ceiling() public async Task The_loop_stops_at_the_iteration_ceiling()
{ {
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
// A model that only ever asks for more tools would otherwise loop forever. // A model that only ever asks for more tools would otherwise loop forever.
var model = new ScriptedModelClient( var model = new ScriptedModelClient(
@@ -147,14 +150,17 @@ public class NovelAgentServiceTests : IDisposable
var turn = await BuildAgent(model).SendMessageAsync( var turn = await BuildAgent(model).SendMessageAsync(
projectId, new SendAgentMessageRequest("Keep going forever.")); projectId, new SendAgentMessageRequest("Keep going forever."));
model.Transcripts.Should().HaveCount(4); Assert.Multiple(() =>
turn.Message.Content.Should().Contain("tool-call limit"); {
Assert.That(model.Transcripts, Has.Count.EqualTo(4));
Assert.That(turn.Message.Content, Does.Contain("tool-call limit"));
});
} }
[Fact] [Test]
public async Task Follow_up_messages_continue_the_same_conversation() public async Task Follow_up_messages_continue_the_same_conversation()
{ {
var projectId = (await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([ var model = new ScriptedModelClient([
[new AgentTextBlock("First answer.")], [new AgentTextBlock("First answer.")],
[new AgentTextBlock("Second answer.")] [new AgentTextBlock("Second answer.")]
@@ -165,35 +171,41 @@ public class NovelAgentServiceTests : IDisposable
var second = await agent.SendMessageAsync( var second = await agent.SendMessageAsync(
projectId, new SendAgentMessageRequest("Question two.", first.ConversationId)); projectId, new SendAgentMessageRequest("Question two.", first.ConversationId));
second.ConversationId.Should().Be(first.ConversationId);
// The second request replays the earlier turns so the model has the history.
model.Transcripts[1].Should().HaveCount(3);
model.Transcripts[1].Select(m => m.Role).Should().Equal("user", "assistant", "user");
var conversation = await agent.GetConversationAsync(first.ConversationId); var conversation = await agent.GetConversationAsync(first.ConversationId);
conversation.Messages.Should().HaveCount(4);
Assert.Multiple(() =>
{
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),
Is.EqualTo(new[] { "user", "assistant", "user" }));
Assert.That(conversation.Messages, Has.Count.EqualTo(4));
});
} }
[Fact] [Test]
public void Every_tool_declares_an_object_schema_and_a_description() public void Every_tool_declares_an_object_schema_and_a_description()
{ {
_toolset.Definitions.Should().NotBeEmpty(); Assert.That(_toolset.Definitions, Is.Not.Empty);
foreach (var tool in _toolset.Definitions) Assert.Multiple(() =>
{ {
tool.Description.Should().NotBeNullOrWhiteSpace(); foreach (var tool in _toolset.Definitions)
tool.InputSchema.GetProperty("type").GetString().Should().Be("object"); {
tool.InputSchema.TryGetProperty("properties", out _).Should().BeTrue(); Assert.That(string.IsNullOrWhiteSpace(tool.Description), Is.False, tool.Name);
} Assert.That(tool.InputSchema.GetProperty("type").GetString(), Is.EqualTo("object"), tool.Name);
Assert.That(tool.InputSchema.TryGetProperty("properties", out _), Is.True, tool.Name);
}
_toolset.Definitions.Select(t => t.Name).Should().OnlyHaveUniqueItems(); Assert.That(_toolset.Definitions.Select(t => t.Name), Is.Unique);
});
} }
private static AgentToolUseBlock ToolUse(string id, string name, object input) => private static AgentToolUseBlock ToolUse(string id, string name, object input) =>
new(id, name, JsonSerializer.SerializeToElement(input)); new(id, name, JsonSerializer.SerializeToElement(input));
public void Dispose() => _db.Dispose();
} }
/// <summary> /// <summary>
@@ -10,14 +10,17 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="NUnit.Analyzers" Version="4.14.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="NUnit.Framework" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+84 -84
View File
@@ -1,154 +1,154 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application; using NovelSoftware.Application;
using NovelSoftware.Application.Dtos; using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
using NovelSoftware.Domain; using NovelSoftware.Domain;
namespace NovelSoftware.Tests; namespace NovelSoftware.Tests;
public class ProjectDataTests : IDisposable [TestFixture]
public class ProjectDataTests : ServiceTestFixture
{ {
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() => private async Task<Guid> NewProjectAsync() =>
(await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
[Fact] [Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{ {
var id = (await _projects.CreateAsync( var id = (await Projects.CreateAsync(
new CreateProjectRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id; new CreateProjectRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id;
var afterPartialUpdate = await _projects.UpdateAsync(id, new UpdateProjectRequest(Title: "The Salt Road")); var afterPartialUpdate = await Projects.UpdateAsync(id, new UpdateProjectRequest(Title: "The Salt Road"));
afterPartialUpdate.Title.Should().Be("The Salt Road"); Assert.Multiple(() =>
afterPartialUpdate.Genre.Should().Be("Fantasy"); {
afterPartialUpdate.Logline.Should().Be("A cartographer goes to sea."); Assert.That(afterPartialUpdate.Title, Is.EqualTo("The Salt Road"));
Assert.That(afterPartialUpdate.Genre, Is.EqualTo("Fantasy"));
Assert.That(afterPartialUpdate.Logline, Is.EqualTo("A cartographer goes to sea."));
});
var afterClear = await _projects.UpdateAsync(id, new UpdateProjectRequest(Genre: "")); var afterClear = await Projects.UpdateAsync(id, new UpdateProjectRequest(Genre: ""));
afterClear.Genre.Should().BeNull(); Assert.Multiple(() =>
afterClear.Logline.Should().Be("A cartographer goes to sea."); {
Assert.That(afterClear.Genre, Is.Null);
Assert.That(afterClear.Logline, Is.EqualTo("A cartographer goes to sea."));
});
} }
[Fact] [Test]
public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given() public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given()
{ {
var projectId = await NewProjectAsync(); var projectId = await NewProjectAsync();
var first = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); var first = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var second = await _chapters.CreateAsync(projectId, new CreateChapterRequest("The Harbour")); var second = await Chapters.CreateAsync(projectId, new CreateChapterRequest("The Harbour"));
first.Number.Should().Be(1); Assert.Multiple(() =>
second.Number.Should().Be(2); {
Assert.That(first.Number, Is.EqualTo(1));
Assert.That(second.Number, Is.EqualTo(2));
});
} }
[Fact] [Test]
public async Task Word_count_is_recomputed_whenever_prose_changes() public async Task Word_count_is_recomputed_whenever_prose_changes()
{ {
var projectId = await NewProjectAsync(); var projectId = await NewProjectAsync();
var chapter = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var scene = await _scenes.CreateAsync(chapter.Id, new CreateSceneRequest( var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
"The dock at dawn", Prose: "Five words go right here")); "The dock at dawn", Prose: "Five words go right here"));
scene.WordCount.Should().Be(5); Assert.That(scene.WordCount, Is.EqualTo(5));
var rewritten = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest( var rewritten = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
Prose: "Now\nthere are seven words in total")); Prose: "Now\nthere are seven words in total"));
rewritten.WordCount.Should().Be(7); Assert.That(rewritten.WordCount, Is.EqualTo(7));
var cleared = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: "")); var cleared = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: ""));
cleared.Prose.Should().BeNull(); Assert.Multiple(() =>
cleared.WordCount.Should().Be(0); {
Assert.That(cleared.Prose, Is.Null);
Assert.That(cleared.WordCount, Is.EqualTo(0));
});
} }
[Fact] [Test]
public async Task Scene_updates_that_omit_prose_leave_the_draft_untouched() public async Task Scene_updates_that_omit_prose_leave_the_draft_untouched()
{ {
var projectId = await NewProjectAsync(); var projectId = await NewProjectAsync();
var chapter = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var scene = await _scenes.CreateAsync(chapter.Id, new CreateSceneRequest( var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
"The dock at dawn", Prose: "The tide came in slow.")); "The dock at dawn", Prose: "The tide came in slow."));
var updated = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised)); var updated = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised));
updated.Prose.Should().Be("The tide came in slow."); Assert.Multiple(() =>
updated.WordCount.Should().Be(5); {
updated.Status.Should().Be(DraftStatus.Revised); Assert.That(updated.Prose, Is.EqualTo("The tide came in slow."));
Assert.That(updated.WordCount, Is.EqualTo(5));
Assert.That(updated.Status, Is.EqualTo(DraftStatus.Revised));
});
} }
[Fact] [Test]
public async Task Deleting_a_project_takes_its_characters_chapters_and_scenes() public async Task Deleting_a_project_takes_its_characters_chapters_and_scenes()
{ {
var projectId = await NewProjectAsync(); var projectId = await NewProjectAsync();
await _characters.CreateAsync(projectId, new CreateCharacterRequest("Ines")); await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
var chapter = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
await _scenes.CreateAsync(chapter.Id, new CreateSceneRequest("The dock at dawn")); await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest("The dock at dawn"));
await _projects.DeleteAsync(projectId); await Projects.DeleteAsync(projectId);
using var verification = _db.CreateContext(); using var verification = Db.CreateContext();
(await verification.Projects.CountAsync()).Should().Be(0);
(await verification.Characters.CountAsync()).Should().Be(0); Assert.Multiple(async () =>
(await verification.Chapters.CountAsync()).Should().Be(0); {
(await verification.Scenes.CountAsync()).Should().Be(0); 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));
});
} }
[Fact] [Test]
public async Task Relating_characters_across_projects_is_refused() public async Task Relating_characters_across_projects_is_refused()
{ {
var firstProject = await NewProjectAsync(); var firstProject = await NewProjectAsync();
var secondProject = (await _projects.CreateAsync(new CreateProjectRequest("Other Book"))).Id; var secondProject = (await Projects.CreateAsync(new CreateProjectRequest("Other Book"))).Id;
var ines = await _characters.CreateAsync(firstProject, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(firstProject, new CreateCharacterRequest("Ines"));
var stranger = await _characters.CreateAsync(secondProject, new CreateCharacterRequest("Stranger")); var stranger = await Characters.CreateAsync(secondProject, new CreateCharacterRequest("Stranger"));
var relate = async () => await _characters.AddRelationshipAsync( Assert.That(
ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")); async () => await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")),
await relate.Should().ThrowAsync<InvalidOperationException>() Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
.WithMessage("*same project*");
} }
[Fact] [Test]
public async Task Relationships_resolve_the_other_character_by_name() public async Task Relationships_resolve_the_other_character_by_name()
{ {
var projectId = await NewProjectAsync(); var projectId = await NewProjectAsync();
var ines = await _characters.CreateAsync(projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
var mara = await _characters.CreateAsync(projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Mara"));
var updated = await _characters.AddRelationshipAsync( var updated = await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire.")); ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire."));
updated.Relationships.Should().ContainSingle() Assert.Multiple(() =>
.Which.RelatedCharacterName.Should().Be("Mara"); {
Assert.That(updated.Relationships, Has.Count.EqualTo(1));
Assert.That(updated.Relationships[0].RelatedCharacterName, Is.EqualTo("Mara"));
});
} }
[Fact] [Test]
public async Task Reading_a_missing_project_reports_not_found() public void Reading_a_missing_project_reports_not_found() =>
{ Assert.That(
var get = async () => await _projects.GetAsync(Guid.NewGuid()); async () => await Projects.GetAsync(Guid.NewGuid()),
Throws.TypeOf<NotFoundException>());
await get.Should().ThrowAsync<NotFoundException>();
}
public void Dispose() => _db.Dispose();
} }
@@ -0,0 +1,45 @@
using NovelSoftware.Application.Services;
namespace NovelSoftware.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!;
protected TagService Tags { get; private set; } = null!;
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!;
[SetUp]
public void SetUpFixture()
{
Db = new TestDatabase();
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);
Beats = new BeatService(Db.Context, Tags);
OnSetUp();
}
/// <summary>Runs after the services exist, for per-class seed data.</summary>
protected virtual void OnSetUp()
{
}
[TearDown]
public void TearDownFixture() => Db.Dispose();
}
+112 -97
View File
@@ -1,174 +1,189 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application;
using NovelSoftware.Application.Dtos; using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
namespace NovelSoftware.Tests; namespace NovelSoftware.Tests;
public class TagServiceTests : IDisposable [TestFixture]
public class TagServiceTests : ServiceTestFixture
{ {
private readonly TestDatabase _db = new(); private Guid _projectId;
private readonly TagService _tags;
private readonly CharacterService _characters;
private readonly ChapterService _chapters;
private readonly BeatService _beats;
private readonly Guid _projectId;
public TagServiceTests() protected override void OnSetUp() =>
{ _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
_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) [Test]
.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
}
[Fact]
public async Task Applying_an_unknown_tag_by_name_creates_it() public async Task Applying_an_unknown_tag_by_name_creates_it()
{ {
var character = await _characters.CreateAsync( var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"])); _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
character.Tags.Select(t => t.Name).Should().BeEquivalentTo(["betrayal", "the sea"]); Assert.Multiple(async () =>
(await _tags.ListAsync(_projectId)).Should().HaveCount(2); {
Assert.That(
character.Tags.Select(t => t.Name),
Is.EquivalentTo(new[] { "betrayal", "the sea" }));
Assert.That(await Tags.ListAsync(_projectId), Has.Count.EqualTo(2));
});
} }
[Fact] [Test]
public async Task The_same_name_resolves_to_one_tag_regardless_of_casing() public async Task The_same_name_resolves_to_one_tag_regardless_of_casing()
{ {
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"])); await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"]));
var chapter = await _chapters.CreateAsync( var chapter = await Chapters.CreateAsync(
_projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"])); _projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"]));
var listed = await _tags.ListAsync(_projectId); var listed = await Tags.ListAsync(_projectId);
listed.Should().ContainSingle().Which.Name.Should().Be("Betrayal"); Assert.Multiple(() =>
chapter.Tags.Should().ContainSingle().Which.Id.Should().Be(listed[0].Id); {
Assert.That(listed, Has.Count.EqualTo(1));
Assert.That(listed[0].Name, Is.EqualTo("Betrayal"));
Assert.That(chapter.Tags, Has.Count.EqualTo(1));
Assert.That(chapter.Tags[0].Id, Is.EqualTo(listed[0].Id));
});
} }
[Fact] [Test]
public async Task Supplying_a_tag_list_replaces_the_existing_tags() public async Task Supplying_a_tag_list_replaces_the_existing_tags()
{ {
var character = await _characters.CreateAsync( var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"])); _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
var updated = await _characters.UpdateAsync( var updated = await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"])); character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"]));
updated.Tags.Select(t => t.Name).Should().BeEquivalentTo(["the sea", "maps"]); Assert.That(updated.Tags.Select(t => t.Name), Is.EquivalentTo(new[] { "the sea", "maps" }));
} }
[Fact] [Test]
public async Task Omitting_the_tag_list_leaves_tags_alone() public async Task Omitting_the_tag_list_leaves_tags_alone()
{ {
var character = await _characters.CreateAsync( var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var updated = await _characters.UpdateAsync( var updated = await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")); character.Id, new UpdateCharacterRequest(Occupation: "Cartographer"));
updated.Tags.Should().ContainSingle().Which.Name.Should().Be("betrayal"); Assert.Multiple(() =>
updated.Occupation.Should().Be("Cartographer"); {
Assert.That(updated.Tags, Has.Count.EqualTo(1));
Assert.That(updated.Tags[0].Name, Is.EqualTo("betrayal"));
Assert.That(updated.Occupation, Is.EqualTo("Cartographer"));
});
} }
[Fact] [Test]
public async Task Cross_reference_gathers_everything_carrying_a_tag() public async Task Cross_reference_gathers_everything_carrying_a_tag()
{ {
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var chapter = await _chapters.CreateAsync( var chapter = await Chapters.CreateAsync(
_projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"])); _projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"]));
await _beats.CreateAsync(chapter.Id, new CreateBeatRequest( await Beats.CreateAsync(chapter.Id, new CreateBeatRequest(
"She burns the atlas", WhatHappened: "In the galley stove.", Tags: ["betrayal"])); "She burns the atlas", WhatHappened: "In the galley stove.", Tags: ["betrayal"]));
await _beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat")); await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat"));
var tagId = (await _tags.ListAsync(_projectId)).Single().Id; var tagId = (await Tags.ListAsync(_projectId)).Single().Id;
var references = await _tags.GetReferencesAsync(tagId); var references = await Tags.GetReferencesAsync(tagId);
references.Characters.Should().ContainSingle().Which.Name.Should().Be("Ines"); Assert.Multiple(() =>
references.Chapters.Should().ContainSingle().Which.Title.Should().Be("Landfall"); {
references.Beats.Should().ContainSingle(); Assert.That(references.Characters, Has.Count.EqualTo(1));
references.Beats[0].Title.Should().Be("She burns the atlas"); Assert.That(references.Characters[0].Name, Is.EqualTo("Ines"));
references.Beats[0].ChapterTitle.Should().Be("Landfall"); Assert.That(references.Chapters, Has.Count.EqualTo(1));
references.Beats[0].ChapterNumber.Should().Be(1); Assert.That(references.Chapters[0].Title, Is.EqualTo("Landfall"));
Assert.That(references.Beats, Has.Count.EqualTo(1));
Assert.That(references.Beats[0].Title, Is.EqualTo("She burns the atlas"));
Assert.That(references.Beats[0].ChapterTitle, Is.EqualTo("Landfall"));
Assert.That(references.Beats[0].ChapterNumber, Is.EqualTo(1));
});
} }
[Fact] [Test]
public async Task Usage_counts_are_reported_per_kind() public async Task Usage_counts_are_reported_per_kind()
{ {
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"])); await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"]));
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara", Tags: ["sea"])); await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara", Tags: ["sea"]));
var chapter = await _chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
await _beats.CreateAsync(chapter.Id, new CreateBeatRequest("A beat", Tags: ["sea"])); await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("A beat", Tags: ["sea"]));
var summary = (await _tags.ListAsync(_projectId)).Single(); var summary = (await Tags.ListAsync(_projectId)).Single();
summary.CharacterCount.Should().Be(2); Assert.Multiple(() =>
summary.ChapterCount.Should().Be(0); {
summary.BeatCount.Should().Be(1); Assert.That(summary.CharacterCount, Is.EqualTo(2));
summary.TotalCount.Should().Be(3); Assert.That(summary.ChapterCount, Is.EqualTo(0));
Assert.That(summary.BeatCount, Is.EqualTo(1));
Assert.That(summary.TotalCount, Is.EqualTo(3));
});
} }
[Fact] [Test]
public async Task Duplicate_tag_names_are_refused_on_create_and_rename() public async Task Duplicate_tag_names_are_refused_on_create_and_rename()
{ {
await _tags.CreateAsync(_projectId, new CreateTagRequest("betrayal")); await Tags.CreateAsync(_projectId, new CreateTagRequest("betrayal"));
var duplicate = async () => await _tags.CreateAsync(_projectId, new CreateTagRequest("Betrayal")); Assert.That(
await duplicate.Should().ThrowAsync<InvalidOperationException>().WithMessage("*already has a tag*"); async () => await Tags.CreateAsync(_projectId, new CreateTagRequest("Betrayal")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a tag"));
var other = await _tags.CreateAsync(_projectId, new CreateTagRequest("the sea")); 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*"); Assert.That(
async () => await Tags.UpdateAsync(other.Id, new UpdateTagRequest(Name: "betrayal")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a tag"));
} }
[Fact] [Test]
public async Task Tags_are_scoped_to_their_project() public async Task Tags_are_scoped_to_their_project()
{ {
var otherProject = await new ProjectService(_db.Context) var otherProject = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
.CreateAsync(new CreateProjectRequest("Other Book"));
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"])); await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"]));
await _characters.CreateAsync(otherProject.Id, new CreateCharacterRequest("Someone", Tags: ["sea"])); await Characters.CreateAsync(otherProject.Id, new CreateCharacterRequest("Someone", Tags: ["sea"]));
(await _tags.ListAsync(_projectId)).Should().ContainSingle(); using var verification = Db.CreateContext();
(await _tags.ListAsync(otherProject.Id)).Should().ContainSingle();
(await _db.CreateContext().Tags.CountAsync()).Should().Be(2); Assert.Multiple(async () =>
{
Assert.That(await Tags.ListAsync(_projectId), Has.Count.EqualTo(1));
Assert.That(await Tags.ListAsync(otherProject.Id), Has.Count.EqualTo(1));
Assert.That(await verification.Tags.CountAsync(), Is.EqualTo(2));
});
} }
[Fact] [Test]
public async Task Deleting_a_tag_leaves_what_carried_it_intact() public async Task Deleting_a_tag_leaves_what_carried_it_intact()
{ {
var character = await _characters.CreateAsync( var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var tagId = (await _tags.ListAsync(_projectId)).Single().Id; var tagId = (await Tags.ListAsync(_projectId)).Single().Id;
await _tags.DeleteAsync(tagId); await Tags.DeleteAsync(tagId);
var survivor = await _characters.GetAsync(character.Id); var survivor = await Characters.GetAsync(character.Id);
survivor.Name.Should().Be("Ines");
survivor.Tags.Should().BeEmpty(); Assert.Multiple(() =>
{
Assert.That(survivor.Name, Is.EqualTo("Ines"));
Assert.That(survivor.Tags, Is.Empty);
});
} }
[Fact] [Test]
public async Task Deleting_a_project_takes_its_tags() public async Task Deleting_a_project_takes_its_tags()
{ {
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
await new ProjectService(_db.Context).DeleteAsync(_projectId); await Projects.DeleteAsync(_projectId);
(await _db.CreateContext().Tags.CountAsync()).Should().Be(0); using var verification = Db.CreateContext();
Assert.That(await verification.Tags.CountAsync(), Is.EqualTo(0));
} }
[Fact] [Test]
public async Task A_blank_tag_name_is_refused() public void A_blank_tag_name_is_refused() =>
{ Assert.That(
var create = async () => await _tags.CreateAsync(_projectId, new CreateTagRequest(" ")); async () => await Tags.CreateAsync(_projectId, new CreateTagRequest(" ")),
Throws.TypeOf<ArgumentException>());
await create.Should().ThrowAsync<ArgumentException>();
}
public void Dispose() => _db.Dispose();
} }
@@ -9,6 +9,11 @@ namespace NovelSoftware.Tests;
/// in-memory provider means the tests exercise the same relational behaviour the app /// in-memory provider means the tests exercise the same relational behaviour the app
/// ships with — cascade deletes, foreign keys and all. /// ships with — cascade deletes, foreign keys and all.
/// </summary> /// </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 public class TestDatabase : IDisposable
{ {
private readonly SqliteConnection _connection; private readonly SqliteConnection _connection;
@@ -32,5 +37,6 @@ public class TestDatabase : IDisposable
{ {
Context.Dispose(); Context.Dispose();
_connection.Dispose(); _connection.Dispose();
GC.SuppressFinalize(this);
} }
} }