Files
novelly/tests/Novelly.Api.Tests/BeatServiceTests.cs
T
James Wampler df10b1f99b Add batch character assignment for chapter outline beats
Lets a writer select several beats and add one character to all of them
at once, without disturbing each beat's existing characters. Surfaced
through the REST API, the embedded agent, and the MCP server per the
project's rule that all three share the same service methods.
2026-08-15 20:36:25 -07:00

205 lines
8.4 KiB
C#

using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
namespace Novelly.Api.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 async Task Reordering_with_an_unknown_beat_returns_null_rather_than_throwing()
{
await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
Assert.That(await Beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([Guid.NewGuid()])), Is.Null);
}
[Test]
public async Task A_beat_can_carry_several_characters()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas",
CharacterIds: [ines.Id, mara.Id],
WhatHappened: "The pages go up faster than she expected.",
WhatsNext: "Nothing to navigate by but memory."));
Assert.Multiple(() =>
{
Assert.That(beat.Characters.Select(c => c.Name), Is.EquivalentTo(new[] { "Ines", "Mara" }));
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", CharacterIds: [stranger.Id])),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
}
[Test]
public async Task Assigning_a_character_to_several_beats_leaves_their_other_characters_alone()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id]));
var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var assigned = await Beats.AssignCharacterAsync(
_chapterId, new AssignCharacterToBeatsRequest(mara.Id, [first.Id, second.Id]));
Assert.Multiple(() =>
{
Assert.That(
assigned!.Single(b => b.Id == first.Id).Characters.Select(c => c.Name),
Is.EquivalentTo(new[] { "Ines", "Mara" }));
Assert.That(
assigned.Single(b => b.Id == second.Id).Characters.Select(c => c.Name),
Is.EqualTo(new[] { "Mara" }));
});
}
[Test]
public async Task Assigning_a_character_already_on_a_beat_does_not_duplicate_it()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id]));
var assigned = await Beats.AssignCharacterAsync(
_chapterId, new AssignCharacterToBeatsRequest(ines.Id, [beat.Id]));
Assert.That(assigned!.Single(b => b.Id == beat.Id).Characters.Select(c => c.Name), Is.EqualTo(new[] { "Ines" }));
}
[Test]
public async Task Assigning_a_character_from_another_project_returns_null()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
Assert.That(
await Beats.AssignCharacterAsync(_chapterId, new AssignCharacterToBeatsRequest(stranger.Id, [beat.Id])),
Is.Null);
}
[Test]
public async Task Assigning_to_an_unknown_beat_returns_null_rather_than_partially_applying()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var result = await Beats.AssignCharacterAsync(
_chapterId, new AssignCharacterToBeatsRequest(ines.Id, [beat.Id, Guid.NewGuid()]));
Assert.That(result, Is.Null);
Assert.That((await Beats.GetAsync(beat.Id))!.Characters, Is.Empty);
}
[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."));
});
}
[Test]
public async Task An_empty_CharacterIds_list_clears_a_beats_characters_since_null_means_leave_it_alone()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas", CharacterIds: [ines.Id]));
var untouched = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She burns it")))!;
Assert.That(untouched.Characters.Select(c => c.Name), Is.EqualTo(new[] { "Ines" }));
var cleared = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(CharacterIds: [])))!;
Assert.That(cleared.Characters, Is.Empty);
}
}