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:
@@ -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>());
|
||||
}
|
||||
Reference in New Issue
Block a user