Sort locations as hierarchy

This commit is contained in:
Nick Beckley 2026-09-02 20:33:58 +00:00
parent 56236104bc
commit 738c1d118f
2 changed files with 87 additions and 2 deletions

View File

@ -49,6 +49,7 @@ var tests = new (string Name, Action Test)[]
("Location canonical keys merge punctuation and safe plurals", LocationCanonicalKeysMergePunctuationAndSafePlurals), ("Location canonical keys merge punctuation and safe plurals", LocationCanonicalKeysMergePunctuationAndSafePlurals),
("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants), ("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants),
("Location candidate identity is parent-aware for generic sub-locations", LocationCandidateIdentityIsParentAwareForGenericSubLocations), ("Location candidate identity is parent-aware for generic sub-locations", LocationCandidateIdentityIsParentAwareForGenericSubLocations),
("Locations page uses hierarchical sort order", LocationsPageUsesHierarchicalSortOrder),
("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets), ("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets),
("Story Intelligence auto resolves exact known locations before review", StoryIntelligenceAutoResolvesExactKnownLocationsBeforeReview), ("Story Intelligence auto resolves exact known locations before review", StoryIntelligenceAutoResolvesExactKnownLocationsBeforeReview),
("Story Intelligence location review uses single identity decision", StoryIntelligenceLocationReviewUsesSingleIdentityDecision), ("Story Intelligence location review uses single identity decision", StoryIntelligenceLocationReviewUsesSingleIdentityDecision),
@ -2746,6 +2747,28 @@ static void LocationCandidateIdentityIsParentAwareForGenericSubLocations()
Assert(persisted.Contains("ParentLocationOptions = parentOptions", StringComparison.Ordinal), "Persisted Location GET should hydrate parent options without rebuilding Scene JSON."); Assert(persisted.Contains("ParentLocationOptions = parentOptions", StringComparison.Ordinal), "Persisted Location GET should hydrate parent options without rebuilding Scene JSON.");
} }
static void LocationsPageUsesHierarchicalSortOrder()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Locations/Index.cshtml"));
var listMethod = ExtractBetween(service, "public async Task<LocationListViewModel?> GetLocationsAsync", "public async Task<LocationEditViewModel?> GetCreateAsync");
var treeMethod = ExtractBetween(service, "private IReadOnlyList<LocationItem> OrderLocationsAsTree", "private async Task SyncLocationAliasesAsync");
Assert(listMethod.Contains("OrderLocationsAsTree(projectLocations)", StringComparison.Ordinal), "Locations index should use parent-aware tree ordering instead of repository row order.");
Assert(!listMethod.Contains("\n Locations = await locations.ListByProjectAsync(projectId)", StringComparison.Ordinal), "Locations index should not flatten directly from globally ordered repository rows.");
Assert(treeMethod.Contains("ParentLocationID is null", StringComparison.Ordinal), "Tree ordering should begin with root locations.");
Assert(treeMethod.Contains("GroupBy(location => location.ParentLocationID", StringComparison.Ordinal), "Tree ordering should group direct children by parent.");
Assert(treeMethod.Contains("OrderBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal), "Tree ordering should sort siblings case-insensitively by location name.");
Assert(treeMethod.Contains("AddLocationAndChildren(child, depth + 1)", StringComparison.Ordinal), "Tree ordering should recurse through descendants.");
Assert(treeMethod.Contains("visited.Add(location.LocationID)", StringComparison.Ordinal), "Tree ordering should render each location once.");
Assert(treeMethod.Contains("stack.Add(location.LocationID)", StringComparison.Ordinal)
&& treeMethod.Contains("logger.LogWarning(\"Location hierarchy cycle", StringComparison.Ordinal), "Tree ordering should guard and log circular parent data.");
Assert(view.Contains("@foreach (var location in Model.Locations)", StringComparison.Ordinal), "Locations view should preserve service-provided hierarchical order.");
Assert(view.Contains("style=\"--location-depth:@location.Depth\"", StringComparison.Ordinal), "Locations view should keep existing indentation.");
}
static void LocationReviewUxSupportsLargeCandidateSets() static void LocationReviewUxSupportsLargeCandidateSets()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -4,6 +4,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PlotLine.Data; using PlotLine.Data;
using PlotLine.Models; using PlotLine.Models;
@ -7926,16 +7927,18 @@ public sealed class LocationService(
IProjectRepository projects, IProjectRepository projects,
ILocationRepository locations, ILocationRepository locations,
IProjectActivityService activity, IProjectActivityService activity,
ICurrentUserService currentUser) : ILocationService ICurrentUserService currentUser,
ILogger<LocationService> logger) : ILocationService
{ {
public async Task<LocationListViewModel?> GetLocationsAsync(int projectId) public async Task<LocationListViewModel?> GetLocationsAsync(int projectId)
{ {
var project = await projects.GetAsync(projectId); var project = await projects.GetAsync(projectId);
var lookupData = await locations.GetLookupsAsync(); var lookupData = await locations.GetLookupsAsync();
var projectLocations = await locations.ListByProjectAsync(projectId);
return project is null ? null : new LocationListViewModel return project is null ? null : new LocationListViewModel
{ {
Project = project, Project = project,
Locations = await locations.ListByProjectAsync(projectId), Locations = OrderLocationsAsTree(projectLocations),
RelationshipTypeOptions = ChapterService.ToSelectList(lookupData.RelationshipTypes, x => x.LocationRelationshipTypeID, x => x.TypeName) RelationshipTypeOptions = ChapterService.ToSelectList(lookupData.RelationshipTypes, x => x.LocationRelationshipTypeID, x => x.TypeName)
}; };
} }
@ -8140,6 +8143,65 @@ public sealed class LocationService(
return model; return model;
} }
private IReadOnlyList<LocationItem> OrderLocationsAsTree(IReadOnlyList<LocationItem> projectLocations)
{
var byId = projectLocations.ToDictionary(location => location.LocationID);
var childrenByParent = projectLocations
.Where(location => location.ParentLocationID.HasValue && byId.ContainsKey(location.ParentLocationID.Value))
.GroupBy(location => location.ParentLocationID!.Value)
.ToDictionary(
group => group.Key,
group => group.OrderBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase).ThenBy(location => location.LocationID).ToList());
var ordered = new List<LocationItem>(projectLocations.Count);
var visited = new HashSet<int>();
var stack = new HashSet<int>();
foreach (var root in projectLocations
.Where(location => location.ParentLocationID is null || !byId.ContainsKey(location.ParentLocationID.Value))
.OrderBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)
.ThenBy(location => location.LocationID))
{
AddLocationAndChildren(root, 0);
}
foreach (var remaining in projectLocations
.Where(location => !visited.Contains(location.LocationID))
.OrderBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)
.ThenBy(location => location.LocationID))
{
logger.LogWarning("Location hierarchy cycle or disconnected parent chain encountered for LocationID {LocationID} in ProjectID {ProjectID}.", remaining.LocationID, remaining.ProjectID);
AddLocationAndChildren(remaining, Math.Max(0, remaining.Depth));
}
return ordered;
void AddLocationAndChildren(LocationItem location, int depth)
{
if (!stack.Add(location.LocationID))
{
logger.LogWarning("Location hierarchy cycle encountered at LocationID {LocationID} in ProjectID {ProjectID}.", location.LocationID, location.ProjectID);
return;
}
if (visited.Add(location.LocationID))
{
location.Depth = depth;
ordered.Add(location);
if (childrenByParent.TryGetValue(location.LocationID, out var children))
{
foreach (var child in children)
{
AddLocationAndChildren(child, depth + 1);
}
}
}
stack.Remove(location.LocationID);
}
}
private async Task SyncLocationAliasesAsync(int locationId, IEnumerable<string> postedAliases, string preferredName) private async Task SyncLocationAliasesAsync(int locationId, IEnumerable<string> postedAliases, string preferredName)
{ {
var aliases = AliasInput.Clean(postedAliases.Append(preferredName)); var aliases = AliasInput.Clean(postedAliases.Append(preferredName));