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:
James Wampler
2026-08-18 11:36:13 -07:00
parent 4313c8f206
commit c620ddd626
27 changed files with 2380 additions and 44 deletions
@@ -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>());
}