using FluentAssertions; using NovelSoftware.Application; using NovelSoftware.Application.Dtos; using NovelSoftware.Application.Services; namespace NovelSoftware.Tests; public class BeatServiceTests : IDisposable { 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; public BeatServiceTests() { 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; } [Fact] 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); 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); } [Fact] 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])); reordered.Select(b => b.Title).Should().Equal("Third", "First", "Second"); } [Fact] 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])); reordered.Select(b => b.Title).Should().Equal("Third", "First", "Second"); } [Fact] public async Task Reordering_with_an_unknown_beat_is_refused() { await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); var reorder = async () => await _beats.ReorderAsync( _chapterId, new ReorderBeatsRequest([Guid.NewGuid()])); await reorder.Should().ThrowAsync(); } [Fact] 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)); beat.CharacterName.Should().Be("Ines"); beat.SceneTitle.Should().Be("The dock at dawn"); beat.WhatHappened.Should().Contain("faster than she expected"); } [Fact] 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 create = async () => await _beats.CreateAsync( _chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id)); await create.Should().ThrowAsync() .WithMessage("*same project*"); } [Fact] 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 create = async () => await _beats.CreateAsync( _chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id)); await create.Should().ThrowAsync() .WithMessage("*same chapter*"); } [Fact] 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); survivor.SceneId.Should().BeNull(); survivor.Title.Should().Be("She burns the atlas"); } [Fact] 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")); renamed.WhatHappened.Should().Be("Behind the lining of the case."); renamed.WhatsNext.Should().Be("She books passage."); var cleared = await _beats.UpdateAsync(beat.Id, new UpdateBeatRequest(WhatsNext: "")); cleared.WhatsNext.Should().BeNull(); cleared.WhatHappened.Should().Be("Behind the lining of the case."); } public void Dispose() => _db.Dispose(); }