From 30e0c6926e9c982d6c95c8c8f572bec7e65597b3 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 6 Aug 2026 03:38:29 +0000 Subject: [PATCH] Migrate the test suite from xUnit + FluentAssertions to NUnit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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().WithMessage("*m*") -> Throws.TypeOf().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 Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw --- .../AnthropicClientTests.cs | 25 +-- tests/NovelSoftware.Tests/BeatServiceTests.cs | 182 +++++++-------- tests/NovelSoftware.Tests/ListingTests.cs | 140 ++++++------ .../NovelAgentServiceTests.cs | 158 +++++++------ .../NovelSoftware.Tests.csproj | 11 +- tests/NovelSoftware.Tests/ProjectDataTests.cs | 168 +++++++------- .../NovelSoftware.Tests/ServiceTestFixture.cs | 45 ++++ tests/NovelSoftware.Tests/TagServiceTests.cs | 209 ++++++++++-------- tests/NovelSoftware.Tests/TestDatabase.cs | 6 + 9 files changed, 510 insertions(+), 434 deletions(-) create mode 100644 tests/NovelSoftware.Tests/ServiceTestFixture.cs diff --git a/tests/NovelSoftware.Tests/AnthropicClientTests.cs b/tests/NovelSoftware.Tests/AnthropicClientTests.cs index a6447be..8c05d53 100644 --- a/tests/NovelSoftware.Tests/AnthropicClientTests.cs +++ b/tests/NovelSoftware.Tests/AnthropicClientTests.cs @@ -1,4 +1,3 @@ -using FluentAssertions; using Microsoft.Extensions.Options; using NovelSoftware.Application; using NovelSoftware.Application.Agent; @@ -6,21 +5,20 @@ using NovelSoftware.Infrastructure.Anthropic; namespace NovelSoftware.Tests; +[TestFixture] public class AnthropicClientTests { - [Fact] - public void Constructing_without_a_key_does_not_throw() - { + [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. - var construct = () => new AnthropicAgentModelClient(Options.Create(new AgentOptions())); + Assert.That( + () => new AnthropicAgentModelClient(Options.Create(new AgentOptions())), + Throws.Nothing); - construct.Should().NotThrow(); - } - - [Fact] - public async Task Sending_without_a_key_reports_a_configuration_problem() + [Test] + public void Sending_without_a_key_reports_a_configuration_problem() { var previous = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"); Environment.SetEnvironmentVariable("ANTHROPIC_API_KEY", null); @@ -29,10 +27,9 @@ public class AnthropicClientTests { var client = new AnthropicAgentModelClient(Options.Create(new AgentOptions())); - var send = async () => await client.CompleteAsync("system", [], []); - - (await send.Should().ThrowAsync()) - .WithMessage("*ANTHROPIC_API_KEY*"); + Assert.That( + async () => await client.CompleteAsync("system", [], []), + Throws.TypeOf().With.Message.Contains("ANTHROPIC_API_KEY")); } finally { diff --git a/tests/NovelSoftware.Tests/BeatServiceTests.cs b/tests/NovelSoftware.Tests/BeatServiceTests.cs index b7412b3..d0e9db9 100644 --- a/tests/NovelSoftware.Tests/BeatServiceTests.cs +++ b/tests/NovelSoftware.Tests/BeatServiceTests.cs @@ -1,159 +1,163 @@ -using FluentAssertions; using NovelSoftware.Application; using NovelSoftware.Application.Dtos; -using NovelSoftware.Application.Services; namespace NovelSoftware.Tests; -public class BeatServiceTests : IDisposable +[TestFixture] +public class BeatServiceTests : ServiceTestFixture { - private readonly TestDatabase _db = new(); - private readonly BeatService _beats; - private readonly CharacterService _characters; - private readonly SceneService _scenes; - private readonly Guid _projectId; - private readonly Guid _chapterId; + private Guid _projectId; + private Guid _chapterId; - public BeatServiceTests() + protected override void OnSetUp() { - var tags = new TagService(_db.Context); - var projects = new ProjectService(_db.Context); - 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; + _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() { - 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("She boards anyway")); + 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("She boards anyway")); - var listed = await _beats.ListAsync(_chapterId); + var listed = await Beats.ListAsync(_chapterId); - listed.Select(b => b.Title) - .Should().Equal("She finds the map", "The harbour burns", "She boards anyway"); - listed.Select(b => b.SortOrder).Should().Equal(1, 2, 3); + Assert.Multiple(() => + { + 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() { - var first = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); - var second = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); - var third = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Third")); + var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); + var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); + 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])); - 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() { - var first = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); - await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); - var third = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Third")); + var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); + await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); + 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] - public async Task Reordering_with_an_unknown_beat_is_refused() + [Test] + 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( - _chapterId, new ReorderBeatsRequest([Guid.NewGuid()])); - - await reorder.Should().ThrowAsync(); + Assert.That( + async () => await Beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([Guid.NewGuid()])), + Throws.TypeOf()); } - [Fact] + [Test] public async Task A_beat_resolves_its_character_and_scene_names() { - var ines = await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); - var scene = await _scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn")); + 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( + var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest( "She burns the atlas", CharacterId: ines.Id, WhatHappened: "The pages go up faster than she expected.", WhatsNext: "Nothing to navigate by but memory.", SceneId: scene.Id)); - beat.CharacterName.Should().Be("Ines"); - beat.SceneTitle.Should().Be("The dock at dawn"); - beat.WhatHappened.Should().Contain("faster than she expected"); + Assert.Multiple(() => + { + 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() { - var projects = new ProjectService(_db.Context); - var other = await projects.CreateAsync(new CreateProjectRequest("Other Book")); - var stranger = await _characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); + var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); + var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); - var create = async () => await _beats.CreateAsync( - _chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id)); - - await create.Should().ThrowAsync() - .WithMessage("*same project*"); + Assert.That( + async () => await Beats.CreateAsync( + _chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id)), + Throws.TypeOf().With.Message.Contains("same project")); } - [Fact] + [Test] 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 scene = await _scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene")); + var elsewhere = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Elsewhere")); + var scene = await Scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene")); - var create = async () => await _beats.CreateAsync( - _chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id)); - - await create.Should().ThrowAsync() - .WithMessage("*same chapter*"); + Assert.That( + async () => await Beats.CreateAsync( + _chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id)), + Throws.TypeOf().With.Message.Contains("same chapter")); } - [Fact] + [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( + 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); + await Scenes.DeleteAsync(scene.Id); // The plan outlives a decision about prose — the beat is simply ungrouped. - var survivor = await _beats.GetAsync(beat.Id); - survivor.SceneId.Should().BeNull(); - survivor.Title.Should().Be("She burns the atlas"); + 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")); + }); } - [Fact] + [Test] public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() { - var beat = await _beats.CreateAsync(_chapterId, new CreateBeatRequest( - "She finds the map", WhatHappened: "Behind the lining of the case.", WhatsNext: "She books passage.")); + var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest( + "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."); - renamed.WhatsNext.Should().Be("She books passage."); + Assert.Multiple(() => + { + 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(); - cleared.WhatHappened.Should().Be("Behind the lining of the case."); + Assert.Multiple(() => + { + Assert.That(cleared.WhatsNext, Is.Null); + Assert.That(cleared.WhatHappened, Is.EqualTo("Behind the lining of the case.")); + }); } - - public void Dispose() => _db.Dispose(); } diff --git a/tests/NovelSoftware.Tests/ListingTests.cs b/tests/NovelSoftware.Tests/ListingTests.cs index d105337..b9d0a25 100644 --- a/tests/NovelSoftware.Tests/ListingTests.cs +++ b/tests/NovelSoftware.Tests/ListingTests.cs @@ -1,9 +1,8 @@ -using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NovelSoftware.Application.Agent; using NovelSoftware.Application.Dtos; -using NovelSoftware.Application.Services; +using NovelSoftware.Domain; namespace NovelSoftware.Tests; @@ -12,94 +11,91 @@ namespace NovelSoftware.Tests; /// 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. /// -public class ListingTests : IDisposable +[TestFixture] +public class ListingTests : ServiceTestFixture { - private readonly TestDatabase _db = new(); - 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] + [Test] public async Task Projects_are_listed_most_recently_updated_first() { - var older = await _projects.CreateAsync(new CreateProjectRequest("Older Book")); - var newer = await _projects.CreateAsync(new CreateProjectRequest("Newer Book")); + 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.")); + 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"); - listed.Select(p => p.Id).Should().Contain(newer.Id); + Assert.Multiple(() => + { + 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() { - 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("Mara")); + 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("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")); + 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")); - var summary = (await _projects.ListAsync()).Single(); + var summary = (await Projects.ListAsync()).Single(); - summary.CharacterCount.Should().Be(2); - summary.ChapterCount.Should().Be(2); - summary.WordCount.Should().Be(5); + Assert.Multiple(() => + { + 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() { - 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); - summary.ChapterCount.Should().Be(0); + Assert.Multiple(() => + { + 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() { - var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road")); - var second = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2)); - 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 project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); + var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2)); + 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); + var listed = await Chapters.ListAsync(project.Id); - listed.Select(c => c.Title).Should().Equal("First", "Second"); - listed.Single(c => c.Id == second.Id).SceneCount.Should().Be(2); - listed.Single(c => c.Id == second.Id).WordCount.Should().Be(3); - listed.Single(c => c.Id == first.Id).WordCount.Should().Be(0); + Assert.Multiple(() => + { + Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" })); + 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() { - var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road")); + var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var agent = new NovelAgentService( - _db.Context, + Db.Context, 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()), NullLogger.Instance); @@ -108,26 +104,24 @@ public class ListingTests : IDisposable var listed = await agent.ListConversationsAsync(project.Id); - listed.Should().HaveCount(2); - listed[0].Title.Should().Be("Second question."); - listed[0].MessageCount.Should().Be(2); + Assert.Multiple(() => + { + 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() { - var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road")); - await _characters.CreateAsync(project.Id, new CreateCharacterRequest( - "Zeno", NovelSoftware.Domain.CharacterRole.Supporting)); - await _characters.CreateAsync(project.Id, new CreateCharacterRequest( - "Ines", NovelSoftware.Domain.CharacterRole.Protagonist)); - await _characters.CreateAsync(project.Id, new CreateCharacterRequest( - "Anders", NovelSoftware.Domain.CharacterRole.Supporting)); + var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); + await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Zeno", CharacterRole.Supporting)); + await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines", CharacterRole.Protagonist)); + await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Anders", 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(); } diff --git a/tests/NovelSoftware.Tests/NovelAgentServiceTests.cs b/tests/NovelSoftware.Tests/NovelAgentServiceTests.cs index 5135b96..f045bd9 100644 --- a/tests/NovelSoftware.Tests/NovelAgentServiceTests.cs +++ b/tests/NovelSoftware.Tests/NovelAgentServiceTests.cs @@ -1,63 +1,51 @@ using System.Text.Json; -using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NovelSoftware.Application.Agent; using NovelSoftware.Application.Dtos; -using NovelSoftware.Application.Services; namespace NovelSoftware.Tests; -public class NovelAgentServiceTests : IDisposable +[TestFixture] +public class NovelAgentServiceTests : ServiceTestFixture { - private readonly TestDatabase _db = new(); - private readonly TagService _tags; - private readonly ProjectService _projects; - private readonly CharacterService _characters; - private readonly NovelAgentToolset _toolset; + private NovelAgentToolset _toolset = null!; - public NovelAgentServiceTests() - { - _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); - } + protected override void OnSetUp() => + _toolset = new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags); private NovelAgentService BuildAgent(ScriptedModelClient model) => new( - _db.Context, + Db.Context, model, _toolset, Options.Create(new AgentOptions { MaxIterations = 4 }), NullLogger.Instance); - [Fact] + [Test] 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 agent = BuildAgent(model); 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); - conversation.Messages.Should().HaveCount(2); - conversation.Messages[0].Content.Should().Be("Where do I start?"); - conversation.Title.Should().Be("Where do I start?"); + + Assert.Multiple(() => + { + 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() { - 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([ [ToolUse("t1", "create_character", new { name = "Ines", role = "Protagonist" })], @@ -67,17 +55,22 @@ public class NovelAgentServiceTests : IDisposable var turn = await BuildAgent(model).SendMessageAsync( projectId, new SendAgentMessageRequest("Add a protagonist called Ines.")); - var characters = await _characters.ListAsync(projectId); - characters.Should().ContainSingle().Which.Name.Should().Be("Ines"); + var characters = await Characters.ListAsync(projectId); - turn.Message.Content.Should().Be("Added Ines as the protagonist."); - turn.Message.ToolCalls.Should().ContainSingle().Which.Name.Should().Be("create_character"); + Assert.Multiple(() => + { + 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() { - 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([ [ @@ -89,18 +82,21 @@ public class NovelAgentServiceTests : IDisposable await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Add two characters.")); - var secondRequest = model.Transcripts[1]; - var resultTurn = secondRequest[^1]; + var resultTurn = model.Transcripts[1][^1]; + var listed = await Characters.ListAsync(projectId); - resultTurn.Role.Should().Be("user"); - resultTurn.Content.OfType().Should().HaveCount(2); - (await _characters.ListAsync(projectId)).Should().HaveCount(2); + Assert.Multiple(() => + { + Assert.That(resultTurn.Role, Is.EqualTo("user")); + Assert.That(resultTurn.Content.OfType().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() { - 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([ [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.")); var errorResult = model.Transcripts[1][^1].Content.OfType().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() { - 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([ [ToolUse("t1", "summon_muse", new { })], @@ -130,14 +129,18 @@ public class NovelAgentServiceTests : IDisposable await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Summon the muse.")); var result = model.Transcripts[1][^1].Content.OfType().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() { - 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. var model = new ScriptedModelClient( @@ -147,14 +150,17 @@ public class NovelAgentServiceTests : IDisposable var turn = await BuildAgent(model).SendMessageAsync( projectId, new SendAgentMessageRequest("Keep going forever.")); - model.Transcripts.Should().HaveCount(4); - turn.Message.Content.Should().Contain("tool-call limit"); + Assert.Multiple(() => + { + 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() { - 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("First answer.")], [new AgentTextBlock("Second answer.")] @@ -165,35 +171,41 @@ public class NovelAgentServiceTests : IDisposable var second = await agent.SendMessageAsync( 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); - 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() { - _toolset.Definitions.Should().NotBeEmpty(); + Assert.That(_toolset.Definitions, Is.Not.Empty); - foreach (var tool in _toolset.Definitions) + Assert.Multiple(() => { - tool.Description.Should().NotBeNullOrWhiteSpace(); - tool.InputSchema.GetProperty("type").GetString().Should().Be("object"); - tool.InputSchema.TryGetProperty("properties", out _).Should().BeTrue(); - } + foreach (var tool in _toolset.Definitions) + { + 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) => new(id, name, JsonSerializer.SerializeToElement(input)); - - public void Dispose() => _db.Dispose(); } /// diff --git a/tests/NovelSoftware.Tests/NovelSoftware.Tests.csproj b/tests/NovelSoftware.Tests/NovelSoftware.Tests.csproj index 1fa50ad..0f2b1ce 100644 --- a/tests/NovelSoftware.Tests/NovelSoftware.Tests.csproj +++ b/tests/NovelSoftware.Tests/NovelSoftware.Tests.csproj @@ -10,14 +10,17 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - + diff --git a/tests/NovelSoftware.Tests/ProjectDataTests.cs b/tests/NovelSoftware.Tests/ProjectDataTests.cs index 3964b4d..6bc39dc 100644 --- a/tests/NovelSoftware.Tests/ProjectDataTests.cs +++ b/tests/NovelSoftware.Tests/ProjectDataTests.cs @@ -1,154 +1,154 @@ -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 +[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 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() { - var id = (await _projects.CreateAsync( + 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")); + 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."); + Assert.Multiple(() => + { + 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(); - afterClear.Logline.Should().Be("A cartographer goes to sea."); + Assert.Multiple(() => + { + 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() { var projectId = await NewProjectAsync(); - var first = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); - var second = await _chapters.CreateAsync(projectId, new CreateChapterRequest("The Harbour")); + 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); + Assert.Multiple(() => + { + 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() { 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")); - 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")); - 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(); - cleared.WordCount.Should().Be(0); + Assert.Multiple(() => + { + 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() { var projectId = await NewProjectAsync(); - var chapter = await _chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); - var scene = await _scenes.CreateAsync(chapter.Id, new CreateSceneRequest( + 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)); + 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); + Assert.Multiple(() => + { + 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() { 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 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); + 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); + using var verification = Db.CreateContext(); + + Assert.Multiple(async () => + { + 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() { 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 stranger = await _characters.CreateAsync(secondProject, new CreateCharacterRequest("Stranger")); + 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() - .WithMessage("*same project*"); + Assert.That( + async () => await Characters.AddRelationshipAsync( + ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")), + Throws.TypeOf().With.Message.Contains("same project")); } - [Fact] + [Test] 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 ines = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines")); + 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.")); - updated.Relationships.Should().ContainSingle() - .Which.RelatedCharacterName.Should().Be("Mara"); + Assert.Multiple(() => + { + Assert.That(updated.Relationships, Has.Count.EqualTo(1)); + Assert.That(updated.Relationships[0].RelatedCharacterName, Is.EqualTo("Mara")); + }); } - [Fact] - public async Task Reading_a_missing_project_reports_not_found() - { - var get = async () => await _projects.GetAsync(Guid.NewGuid()); - - await get.Should().ThrowAsync(); - } - - public void Dispose() => _db.Dispose(); + [Test] + public void Reading_a_missing_project_reports_not_found() => + Assert.That( + async () => await Projects.GetAsync(Guid.NewGuid()), + Throws.TypeOf()); } diff --git a/tests/NovelSoftware.Tests/ServiceTestFixture.cs b/tests/NovelSoftware.Tests/ServiceTestFixture.cs new file mode 100644 index 0000000..dbc3d82 --- /dev/null +++ b/tests/NovelSoftware.Tests/ServiceTestFixture.cs @@ -0,0 +1,45 @@ +using NovelSoftware.Application.Services; + +namespace NovelSoftware.Tests; + +/// +/// Shared plumbing for the service tests: a fresh in-memory database and a matching set +/// of services per test. +/// +/// +/// The setup lives in [SetUp] 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. +/// +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(); + } + + /// Runs after the services exist, for per-class seed data. + protected virtual void OnSetUp() + { + } + + [TearDown] + public void TearDownFixture() => Db.Dispose(); +} diff --git a/tests/NovelSoftware.Tests/TagServiceTests.cs b/tests/NovelSoftware.Tests/TagServiceTests.cs index 55d2338..0899991 100644 --- a/tests/NovelSoftware.Tests/TagServiceTests.cs +++ b/tests/NovelSoftware.Tests/TagServiceTests.cs @@ -1,174 +1,189 @@ -using FluentAssertions; using Microsoft.EntityFrameworkCore; -using NovelSoftware.Application; using NovelSoftware.Application.Dtos; -using NovelSoftware.Application.Services; namespace NovelSoftware.Tests; -public class TagServiceTests : IDisposable +[TestFixture] +public class TagServiceTests : ServiceTestFixture { - private readonly TestDatabase _db = new(); - private readonly TagService _tags; - private readonly CharacterService _characters; - private readonly ChapterService _chapters; - private readonly BeatService _beats; - private readonly Guid _projectId; + private Guid _projectId; - public TagServiceTests() - { - _tags = new TagService(_db.Context); - _characters = new CharacterService(_db.Context, _tags); - _chapters = new ChapterService(_db.Context, _tags); - _beats = new BeatService(_db.Context, _tags); + protected override void OnSetUp() => + _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; - _projectId = new ProjectService(_db.Context) - .CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; - } - - [Fact] + [Test] 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"])); - character.Tags.Select(t => t.Name).Should().BeEquivalentTo(["betrayal", "the sea"]); - (await _tags.ListAsync(_projectId)).Should().HaveCount(2); + Assert.Multiple(async () => + { + 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() { - await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"])); - var chapter = await _chapters.CreateAsync( + await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"])); + var chapter = await Chapters.CreateAsync( _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"); - chapter.Tags.Should().ContainSingle().Which.Id.Should().Be(listed[0].Id); + Assert.Multiple(() => + { + 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() { - var character = await _characters.CreateAsync( + var character = await Characters.CreateAsync( _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"])); - 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() { - var character = await _characters.CreateAsync( + var character = await Characters.CreateAsync( _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); - var updated = await _characters.UpdateAsync( + var updated = await Characters.UpdateAsync( character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")); - updated.Tags.Should().ContainSingle().Which.Name.Should().Be("betrayal"); - updated.Occupation.Should().Be("Cartographer"); + Assert.Multiple(() => + { + 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() { - await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); - var chapter = await _chapters.CreateAsync( + await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); + var chapter = await Chapters.CreateAsync( _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"])); - 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 references = await _tags.GetReferencesAsync(tagId); + var tagId = (await Tags.ListAsync(_projectId)).Single().Id; + var references = await Tags.GetReferencesAsync(tagId); - references.Characters.Should().ContainSingle().Which.Name.Should().Be("Ines"); - references.Chapters.Should().ContainSingle().Which.Title.Should().Be("Landfall"); - references.Beats.Should().ContainSingle(); - references.Beats[0].Title.Should().Be("She burns the atlas"); - references.Beats[0].ChapterTitle.Should().Be("Landfall"); - references.Beats[0].ChapterNumber.Should().Be(1); + Assert.Multiple(() => + { + Assert.That(references.Characters, Has.Count.EqualTo(1)); + Assert.That(references.Characters[0].Name, Is.EqualTo("Ines")); + Assert.That(references.Chapters, Has.Count.EqualTo(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() { - await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"])); - await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara", Tags: ["sea"])); - var chapter = await _chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); - await _beats.CreateAsync(chapter.Id, new CreateBeatRequest("A beat", Tags: ["sea"])); + await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"])); + await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara", Tags: ["sea"])); + var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + 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); - summary.ChapterCount.Should().Be(0); - summary.BeatCount.Should().Be(1); - summary.TotalCount.Should().Be(3); + Assert.Multiple(() => + { + Assert.That(summary.CharacterCount, Is.EqualTo(2)); + 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() { - await _tags.CreateAsync(_projectId, new CreateTagRequest("betrayal")); + await Tags.CreateAsync(_projectId, new CreateTagRequest("betrayal")); - var duplicate = async () => await _tags.CreateAsync(_projectId, new CreateTagRequest("Betrayal")); - await duplicate.Should().ThrowAsync().WithMessage("*already has a tag*"); + Assert.That( + async () => await Tags.CreateAsync(_projectId, new CreateTagRequest("Betrayal")), + Throws.TypeOf().With.Message.Contains("already has a tag")); - 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().WithMessage("*already has a tag*"); + var other = await Tags.CreateAsync(_projectId, new CreateTagRequest("the sea")); + + Assert.That( + async () => await Tags.UpdateAsync(other.Id, new UpdateTagRequest(Name: "betrayal")), + Throws.TypeOf().With.Message.Contains("already has a tag")); } - [Fact] + [Test] public async Task Tags_are_scoped_to_their_project() { - var otherProject = await new ProjectService(_db.Context) - .CreateAsync(new CreateProjectRequest("Other Book")); + var otherProject = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); - await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"])); - await _characters.CreateAsync(otherProject.Id, new CreateCharacterRequest("Someone", Tags: ["sea"])); + await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"])); + await Characters.CreateAsync(otherProject.Id, new CreateCharacterRequest("Someone", Tags: ["sea"])); - (await _tags.ListAsync(_projectId)).Should().ContainSingle(); - (await _tags.ListAsync(otherProject.Id)).Should().ContainSingle(); - (await _db.CreateContext().Tags.CountAsync()).Should().Be(2); + using var verification = Db.CreateContext(); + + 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() { - var character = await _characters.CreateAsync( + var character = await Characters.CreateAsync( _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); - survivor.Name.Should().Be("Ines"); - survivor.Tags.Should().BeEmpty(); + var survivor = await Characters.GetAsync(character.Id); + + 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() { - 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] - public async Task A_blank_tag_name_is_refused() - { - var create = async () => await _tags.CreateAsync(_projectId, new CreateTagRequest(" ")); - - await create.Should().ThrowAsync(); - } - - public void Dispose() => _db.Dispose(); + [Test] + public void A_blank_tag_name_is_refused() => + Assert.That( + async () => await Tags.CreateAsync(_projectId, new CreateTagRequest(" ")), + Throws.TypeOf()); } diff --git a/tests/NovelSoftware.Tests/TestDatabase.cs b/tests/NovelSoftware.Tests/TestDatabase.cs index 89e481b..562d84b 100644 --- a/tests/NovelSoftware.Tests/TestDatabase.cs +++ b/tests/NovelSoftware.Tests/TestDatabase.cs @@ -9,6 +9,11 @@ namespace NovelSoftware.Tests; /// in-memory provider means the tests exercise the same relational behaviour the app /// ships with — cascade deletes, foreign keys and all. /// +/// +/// NUnit reuses one fixture instance across every test in a class, so this must be built +/// in [SetUp] and disposed in [TearDown]. A field initialiser would share +/// one database for the whole class and let tests see each other's rows. +/// public class TestDatabase : IDisposable { private readonly SqliteConnection _connection; @@ -32,5 +37,6 @@ public class TestDatabase : IDisposable { Context.Dispose(); _connection.Dispose(); + GC.SuppressFinalize(this); } }