Replace chapter setting with multi-select locations; add chapter character summary
Chapters now carry many Locations (new Tags-style entity with cross-referencing) instead of a single free-text Setting field, with a Locations tab on the novel for browsing them and seeing every chapter set at each one. Also surfaces the distinct characters appearing in a chapter's beats, linked, under the beat/word count on the outline tab.
This commit is contained in:
@@ -85,7 +85,7 @@ public class ListingTests : ServiceTestFixture
|
||||
var agent = new NovelAgentService(
|
||||
Db.Context,
|
||||
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
|
||||
new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
|
||||
new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance),
|
||||
Options.Create(new AgentOptions()),
|
||||
NullLogger<NovelAgentService>.Instance,
|
||||
new SendAgentMessageRequestValidator());
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class LocationServiceTests : ServiceTestFixture
|
||||
{
|
||||
private Guid _novelId;
|
||||
|
||||
protected override void OnSetUp() =>
|
||||
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
|
||||
|
||||
[Test]
|
||||
public async Task Applying_an_unknown_location_by_name_creates_it()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(
|
||||
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour", "the wreck"]));
|
||||
|
||||
Assert.Multiple(async () =>
|
||||
{
|
||||
Assert.That(
|
||||
chapter!.Locations.Select(l => l.Name),
|
||||
Is.EquivalentTo(new[] { "the harbour", "the wreck" }));
|
||||
Assert.That(await Locations.ListAsync(_novelId), Has.Count.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_same_name_resolves_to_one_location_regardless_of_casing()
|
||||
{
|
||||
var first = await Chapters.CreateAsync(
|
||||
_novelId, new CreateChapterRequest("Landfall", Locations: ["The Harbour"]));
|
||||
var second = await Chapters.CreateAsync(
|
||||
_novelId, new CreateChapterRequest("Departure", Locations: ["the harbour"]));
|
||||
|
||||
var listed = await Locations.ListAsync(_novelId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(listed, Has.Count.EqualTo(1));
|
||||
Assert.That(listed[0].Name, Is.EqualTo("The Harbour"));
|
||||
Assert.That(first!.Locations, Has.Count.EqualTo(1));
|
||||
Assert.That(second!.Locations[0].Id, Is.EqualTo(first.Locations[0].Id));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Supplying_a_location_list_replaces_the_existing_locations()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(
|
||||
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour", "the wreck"]));
|
||||
|
||||
var updated = (await Chapters.UpdateAsync(
|
||||
chapter!.Id, new UpdateChapterRequest(Locations: ["the wreck", "the cliffs"])))!;
|
||||
|
||||
Assert.That(updated.Locations.Select(l => l.Name), Is.EquivalentTo(new[] { "the wreck", "the cliffs" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Omitting_the_location_list_leaves_locations_alone()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(
|
||||
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
|
||||
|
||||
var updated = (await Chapters.UpdateAsync(
|
||||
chapter!.Id, new UpdateChapterRequest(Summary: "Ships come in.")))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(updated.Locations, Has.Count.EqualTo(1));
|
||||
Assert.That(updated.Locations[0].Name, Is.EqualTo("the harbour"));
|
||||
Assert.That(updated.Summary, Is.EqualTo("Ships come in."));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Cross_reference_gathers_every_chapter_at_a_location()
|
||||
{
|
||||
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
|
||||
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Unrelated chapter"));
|
||||
|
||||
var locationId = (await Locations.ListAsync(_novelId)).Single().Id;
|
||||
var references = (await Locations.GetReferencesAsync(locationId))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(references.Chapters, Has.Count.EqualTo(1));
|
||||
Assert.That(references.Chapters[0].Title, Is.EqualTo("Landfall"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Usage_counts_are_reported()
|
||||
{
|
||||
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
|
||||
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Departure", Locations: ["the harbour"]));
|
||||
|
||||
var summary = (await Locations.ListAsync(_novelId)).Single();
|
||||
|
||||
Assert.That(summary.ChapterCount, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Duplicate_location_names_are_refused_on_create_and_rename()
|
||||
{
|
||||
await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour"));
|
||||
|
||||
Assert.That(
|
||||
async () => await Locations.CreateAsync(_novelId, new CreateLocationRequest("The Harbour")),
|
||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a location"));
|
||||
|
||||
var other = await Locations.CreateAsync(_novelId, new CreateLocationRequest("the wreck"));
|
||||
|
||||
Assert.That(
|
||||
async () => await Locations.UpdateAsync(other!.Id, new UpdateLocationRequest(Name: "the harbour")),
|
||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a location"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Locations_are_scoped_to_their_novel()
|
||||
{
|
||||
var otherNovel = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
|
||||
|
||||
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
|
||||
await Chapters.CreateAsync(otherNovel.Id, new CreateChapterRequest("Somewhere", Locations: ["the harbour"]));
|
||||
|
||||
using var verification = Db.CreateContext();
|
||||
|
||||
Assert.Multiple(async () =>
|
||||
{
|
||||
Assert.That(await Locations.ListAsync(_novelId), Has.Count.EqualTo(1));
|
||||
Assert.That(await Locations.ListAsync(otherNovel.Id), Has.Count.EqualTo(1));
|
||||
Assert.That(await verification.Locations.CountAsync(), Is.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Deleting_a_location_leaves_the_chapter_intact()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(
|
||||
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
|
||||
var locationId = (await Locations.ListAsync(_novelId)).Single().Id;
|
||||
|
||||
await Locations.DeleteAsync(locationId);
|
||||
|
||||
var survivor = (await Chapters.GetAsync(chapter!.Id))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(survivor.Title, Is.EqualTo("Landfall"));
|
||||
Assert.That(survivor.Locations, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Deleting_a_novel_takes_its_locations()
|
||||
{
|
||||
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
|
||||
|
||||
await Novels.DeleteAsync(_novelId);
|
||||
|
||||
using var verification = Db.CreateContext();
|
||||
Assert.That(await verification.Locations.CountAsync(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_blank_location_name_is_refused() =>
|
||||
Assert.That(
|
||||
async () => await Locations.CreateAsync(_novelId, new CreateLocationRequest(" ")),
|
||||
Throws.TypeOf<ArgumentException>());
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
||||
private NovelAgentToolset _toolset = null!;
|
||||
|
||||
protected override void OnSetUp() =>
|
||||
_toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance);
|
||||
_toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance);
|
||||
|
||||
private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
|
||||
Db.Context,
|
||||
|
||||
@@ -2,6 +2,7 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Genres;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
@@ -15,6 +16,7 @@ public abstract class ServiceTestFixture
|
||||
protected TestUserContext UserContext { get; private set; } = null!;
|
||||
protected NovelAccessService Access { get; private set; } = null!;
|
||||
protected TagService Tags { get; private set; } = null!;
|
||||
protected LocationService Locations { get; private set; } = null!;
|
||||
protected NovelService Novels { get; private set; } = null!;
|
||||
protected CharacterService Characters { get; private set; } = null!;
|
||||
protected ChapterService Chapters { get; private set; } = null!;
|
||||
@@ -28,6 +30,7 @@ public abstract class ServiceTestFixture
|
||||
protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<TagService> TagLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<LocationService> LocationLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<GenreService> GenreLogs { get; private set; } = null!;
|
||||
@@ -50,6 +53,7 @@ public abstract class ServiceTestFixture
|
||||
Db.Context.SaveChanges();
|
||||
|
||||
TagLogs = new CapturingLogger<TagService>();
|
||||
LocationLogs = new CapturingLogger<LocationService>();
|
||||
NovelLogs = new CapturingLogger<NovelService>();
|
||||
CharacterLogs = new CapturingLogger<CharacterService>();
|
||||
ChapterLogs = new CapturingLogger<ChapterService>();
|
||||
@@ -59,13 +63,14 @@ public abstract class ServiceTestFixture
|
||||
GenreLogs = new CapturingLogger<GenreService>();
|
||||
|
||||
Tags = new TagService(Db.Context, Access, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
|
||||
Locations = new LocationService(Db.Context, Access, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator());
|
||||
Novels = new NovelService(
|
||||
Db.Context, Access, UserContext, NovelLogs, new CreateNovelRequestValidator(), new UpdateNovelRequestValidator());
|
||||
Characters = new CharacterService(
|
||||
Db.Context, Access, Tags, CharacterLogs,
|
||||
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(),
|
||||
new LinkCharacterIdentityRequestValidator());
|
||||
Chapters = new ChapterService(Db.Context, Access, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
||||
Chapters = new ChapterService(Db.Context, Access, Tags, Locations, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
||||
Beats = new BeatService(
|
||||
Db.Context, Access, Tags, BeatLogs,
|
||||
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
|
||||
|
||||
Reference in New Issue
Block a user