Files
novelly/tests/NovelSoftware.Tests/BeatServiceTests.cs
T
James WamplerandClaude Opus 5 30e0c6926e Migrate the test suite from xUnit + FluentAssertions to NUnit
All 44 tests, same names, same coverage — verified by diffing the runner's test
list before and after.

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

Assertions are now Assert.That with the constraint model:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
2026-08-06 12:11:20 -07:00

164 lines
6.3 KiB
C#

using NovelSoftware.Application;
using NovelSoftware.Application.Dtos;
namespace NovelSoftware.Tests;
[TestFixture]
public class BeatServiceTests : ServiceTestFixture
{
private Guid _projectId;
private Guid _chapterId;
protected override void OnSetUp()
{
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
_chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id;
}
[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"));
var listed = await Beats.ListAsync(_chapterId);
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 }));
});
}
[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 reordered = await Beats.ReorderAsync(
_chapterId, new ReorderBeatsRequest([third.Id, first.Id, second.Id]));
Assert.That(
reordered.Select(b => b.Title),
Is.EqualTo(new[] { "Third", "First", "Second" }));
}
[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 reordered = await Beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([third.Id, first.Id]));
Assert.That(
reordered.Select(b => b.Title),
Is.EqualTo(new[] { "Third", "First", "Second" }));
}
[Test]
public void Reordering_with_an_unknown_beat_is_refused()
{
Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")).Wait();
Assert.That(
async () => await Beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([Guid.NewGuid()])),
Throws.TypeOf<NotFoundException>());
}
[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 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));
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"));
});
}
[Test]
public async Task A_beat_cannot_borrow_a_character_from_another_project()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
Assert.That(
async () => await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
}
[Test]
public async Task A_beat_cannot_be_grouped_under_a_scene_from_another_chapter()
{
var elsewhere = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Elsewhere"));
var scene = await Scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene"));
Assert.That(
async () => await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same chapter"));
}
[Test]
public async Task Deleting_a_scene_leaves_its_beats_alone()
{
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("She burns the atlas", SceneId: scene.Id));
await Scenes.DeleteAsync(scene.Id);
// The plan outlives a decision about prose — the beat is simply ungrouped.
var survivor = await Beats.GetAsync(beat.Id);
Assert.Multiple(() =>
{
Assert.That(survivor.SceneId, Is.Null);
Assert.That(survivor.Title, Is.EqualTo("She burns the atlas"));
});
}
[Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
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"));
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: ""));
Assert.Multiple(() =>
{
Assert.That(cleared.WhatsNext, Is.Null);
Assert.That(cleared.WhatHappened, Is.EqualTo("Behind the lining of the case."));
});
}
}