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); } }