From aa74df89e86b015c61caa28228e8c8f95e0d99dc Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 8 Sep 2026 22:01:10 +0000 Subject: [PATCH] Fix character profile age and date-only birth date editing --- PlotLine.Tests/CharacterProfileAgeTests.cs | 78 +++++++++++++++++++ PlotLine.Tests/Program.cs | 2 + .../Services/CharacterAgeDisplayService.cs | 21 +++++ PlotLine/Services/CoreServices.cs | 26 ++++--- PlotLine/ViewModels/CoreViewModels.cs | 3 + PlotLine/Views/Characters/Details.cshtml | 10 ++- PlotLine/Views/Characters/Edit.cshtml | 4 +- 7 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 PlotLine.Tests/CharacterProfileAgeTests.cs diff --git a/PlotLine.Tests/CharacterProfileAgeTests.cs b/PlotLine.Tests/CharacterProfileAgeTests.cs new file mode 100644 index 0000000..b1d5f24 --- /dev/null +++ b/PlotLine.Tests/CharacterProfileAgeTests.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.Services; +using PlotLine.ViewModels; + +internal static class CharacterProfileAgeTests +{ + private static void Check(bool value, string message) { if (!value) throw new Exception(message); } + public static void ProfileUsesStoryDate() + { + var character = new Character { CharacterID = 1, BirthDate = new DateTime(1965, 9, 1) }; + var first = new Scene { SceneID = 900, TimeModeName = "Exact Date", StartDateTime = new DateTime(1980, 5, 7) }; + var later = new Scene { SceneID = 2, TimeModeName = "Exact Date", StartDateTime = new DateTime(1982, 12, 24) }; + SceneCharacter[] appearances = [new() { CharacterID = 1, SceneID = 2 }, new() { CharacterID = 1, SceneID = 900 }]; + var result = CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances); + Check(result.DisplayText == "14 years, 8 months" && result.ReferenceScene?.SceneID == 900, "Age must use first narrative dated appearance, not current date or minimum scene ID."); + character.AgeReferenceSceneID = 2; + result = CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances); + Check(result.DisplayText == "17 years, 3 months" && result.Context == "At age reference scene", "Explicit age reference must take precedence."); + character.AgeReferenceSceneID = null; + first.StartDateTime = null; + Check(CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances).ReferenceScene?.SceneID == 2, "Undated appearance must not invent a reference date."); + later.StartDateTime = null; + result = CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances); + Check(result.ReferenceScene is null && result.Context.Contains("dated story scene"), "Known DOB without chronology must explain why age is unavailable."); + character.AgeAtSeriesStart = 17; + Check(CharacterAgeDisplayService.GetProfileAge(character, [], []).DisplayText == "About 17", "Approximate fallback must stay available."); + character.BirthDate = null; character.AgeAtSeriesStart = null; + Check(CharacterAgeDisplayService.GetProfileAge(character, [], []).DisplayText == "Unknown", "Missing date/age must stay unknown."); + } + + public static void DetailAndSaveUseCanonicalBirthDate() + { + var character = new Character { CharacterID = 1, ProjectID = 1, CharacterName = "Nora", BirthDate = new DateTime(1965, 9, 1), AgeReferenceSceneID = 900 }; + var timeline = new TimelineData { Project = new Project { ProjectID = 1 }, Books = [new Book { BookID = 1 }], Chapters = [new Chapter { ChapterID = 1, BookID = 1 }], + Scenes = [new Scene { SceneID = 900, ChapterID = 1, TimeModeName = "Exact Date", StartDateTime = new DateTime(1980, 5, 7) }] }; + Character? saved = null; + var repository = Proxy((method, args) => method switch { + "GetCharacterAsync" => Task.FromResult(character), + "ListCharactersAsync" => Task.FromResult>([character]), + "GetTimelineAsync" => Task.FromResult(((IReadOnlyList)[character], (IReadOnlyList)[new SceneCharacter { CharacterID = 1, SceneID = 900 }])), + "GetLookupsAsync" => Task.FromResult(new CharacterLookupData()), + "SaveCharacterAsync" => Capture((Character)args![0]!), + _ => Default(method) }); + Task Capture(Character value) { saved = value; return Task.FromResult(1); } + var service = new CharacterService( + Proxy((_, _) => Task.FromResult(timeline.Project)), + Proxy(), repository, Proxy(), Proxy(), + Proxy(), Proxy(), Proxy(), Proxy(), + Proxy((_, _) => Task.FromResult(timeline))); + var detail = service.GetCharacterDetailAsync(1).GetAwaiter().GetResult()!; + Check(detail.DisplayAge == "14 years, 8 months" && detail.AgeReferenceScene?.SceneID == 900, "Detail runtime must supply canonical chronology to age service."); + Check(detail.Character.BirthDate == character.BirthDate, "Detail must keep authored DOB independently of generated analysis."); + service.SaveCharacterAsync(new CharacterEditViewModel { CharacterID = 1, ProjectID = 1, CharacterName = "Nora", BirthDate = new DateTime(1965, 9, 1) }).GetAwaiter().GetResult(); + Check(saved?.BirthDate == new DateTime(1965, 9, 1) && saved.AgeReferenceSceneID == 900, "Date-only save must preserve date and existing reference scene."); + service.SaveCharacterAsync(new CharacterEditViewModel { CharacterID = 1, ProjectID = 1, CharacterName = "Nora", BirthDate = null }).GetAwaiter().GetResult(); + Check(saved?.BirthDate is null, "Birth date must remain optional and clearable."); + } + + private static T Proxy(Func? handler = null) where T : class + { + var result = DispatchProxy.Create(); + ((StoryPointProxy)(object)result).Handler = handler ?? ((method, _) => Default(method)); + return result; + } + private static object? Default(string name) + { + var type = typeof(T).GetMethods().First(x => x.Name == name).ReturnType; + if (type == typeof(Task)) return Task.CompletedTask; + if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(Task<>)) return type.IsValueType ? Activator.CreateInstance(type) : null; + var valueType = type.GenericTypeArguments[0]; + object? value = valueType.IsGenericType && valueType.GetGenericTypeDefinition() == typeof(IReadOnlyList<>) + ? Array.CreateInstance(valueType.GenericTypeArguments[0], 0) + : valueType.IsValueType ? Activator.CreateInstance(valueType) : null; + return typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(valueType).Invoke(null, [value]); + } +} diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index fad3f1f..eff3bda 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -19,6 +19,8 @@ using PlotLine.ViewModels; var tests = new (string Name, Action Test)[] { + ("Character profile age uses canonical reference chronology", CharacterProfileAgeTests.ProfileUsesStoryDate), + ("Character detail and date-only save retain canonical DOB", CharacterProfileAgeTests.DetailAndSaveUseCanonicalBirthDate), ("Story Point presence, previous appearances and age cross book boundaries", StoryPointTests.PeopleAndChronology), ("Story Point knowledge stops at selected narrative position", StoryPointTests.KnowledgeBoundary), ("Story Point relationships and independent asset state are historical", StoryPointTests.RelationshipsAndAssets), diff --git a/PlotLine/Services/CharacterAgeDisplayService.cs b/PlotLine/Services/CharacterAgeDisplayService.cs index c001e7d..526178c 100644 --- a/PlotLine/Services/CharacterAgeDisplayService.cs +++ b/PlotLine/Services/CharacterAgeDisplayService.cs @@ -9,8 +9,29 @@ public sealed class CharacterAgeDisplayResult public string? DisplayText { get; init; } } +public sealed record CharacterProfileAgeDisplayResult(string DisplayText, string Context, Scene? ReferenceScene); + public static class CharacterAgeDisplayService { + public static CharacterProfileAgeDisplayResult GetProfileAge(Character character, + IReadOnlyList orderedScenes, IReadOnlyList appearances) + { + var appearanceIds = appearances.Where(x => x.CharacterID == character.CharacterID).Select(x => x.SceneID).ToHashSet(); + var reference = character.AgeReferenceSceneID.HasValue + ? orderedScenes.FirstOrDefault(x => x.SceneID == character.AgeReferenceSceneID) + : null; + if (reference is null || !ResolveEffectiveSceneDate(reference.TimeModeName, reference.StartDateTime).HasValue) + reference = orderedScenes.FirstOrDefault(x => appearanceIds.Contains(x.SceneID) + && ResolveEffectiveSceneDate(x.TimeModeName, x.StartDateTime).HasValue + && (!character.BirthDate.HasValue || x.StartDateTime!.Value.Date >= character.BirthDate.Value.Date)); + var age = GetAgeForScene(character, reference?.TimeModeName, reference?.StartDateTime); + return new CharacterProfileAgeDisplayResult(age.DisplayText ?? "Unknown", + age.HasExactAge ? (reference?.SceneID == character.AgeReferenceSceneID ? "At age reference scene" : "At first dated appearance") + : character.AgeAtSeriesStart.HasValue ? "Approximate age at series start" + : character.BirthDate.HasValue ? "A dated story scene is needed to calculate age." : "No birth date or approximate age recorded.", + age.HasExactAge ? reference : null); + } + public static CharacterAgeDisplayResult GetAgeForScene(SceneCharacter character, string? sceneTimeModeName, DateTime? sceneStartDateTime) { var effectiveDate = ResolveEffectiveSceneDate(sceneTimeModeName, sceneStartDateTime); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 5db3f19..98698f5 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -9537,7 +9537,8 @@ public sealed class CharacterService( IProjectActivityService activity, ICurrentUserService currentUser, IVisualIdentityImageService visualIdentityImages, - ICharacterEnrichmentService characterEnrichment) : ICharacterService + ICharacterEnrichmentService characterEnrichment, + ITimelineRepository timelineRepository) : ICharacterService { public async Task GetCharactersAsync(int projectId) { @@ -9617,6 +9618,14 @@ public sealed class CharacterService( .GroupBy(x => x.CharacterRelationshipID) .ToDictionary(x => x.Key, x => (IReadOnlyList)x.ToList()); + var characterTimeline = await characters.GetTimelineAsync(project.ProjectID, null); + var timeline = await timelineRepository.GetByProjectAsync(project.ProjectID, null, false, [], false); + var orderedScenes = StoryStateService.OrderScenes(timeline).ToList(); + var sceneIndexes = orderedScenes.Select((scene, index) => (scene.SceneID, index)).ToDictionary(x => x.SceneID, x => x.index); + var appearances = characterTimeline.Appearances.Where(x => x.CharacterID == character.CharacterID && sceneIndexes.ContainsKey(x.SceneID)) + .OrderBy(x => sceneIndexes[x.SceneID]).ToList(); + var profileAge = CharacterAgeDisplayService.GetProfileAge(character, orderedScenes, appearances); + return new CharacterDetailViewModel { Project = project, @@ -9624,8 +9633,10 @@ public sealed class CharacterService( Aliases = await characters.ListAliasesAsync(character.CharacterID), ProjectCharacters = projectCharacters, Images = await characters.ListCharacterImagesAsync(character.CharacterID), - DisplayAge = DisplayAge(character), - Appearances = await GetAppearancesByCharacterAsync(character.CharacterID), + DisplayAge = profileAge.DisplayText, + AgeContext = profileAge.Context, + AgeReferenceScene = profileAge.ReferenceScene, + Appearances = appearances, AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(character.CharacterID), KnowledgeItems = await characters.ListKnowledgeByCharacterAsync(character.CharacterID), InitialRelationships = initialRelationships, @@ -9746,8 +9757,9 @@ public sealed class CharacterService( ShortName = existing?.ShortName, SexValueID = sex.SexValueID, Sex = sex.SexName, - BirthDate = model.BirthDate, + BirthDate = model.BirthDate?.Date, AgeAtSeriesStart = model.AgeAtSeriesStart, + AgeReferenceSceneID = existing?.AgeReferenceSceneID, Height = model.Height, EyeColour = model.EyeColour, CharacterImportance = model.CharacterImportance, @@ -10205,9 +10217,5 @@ public sealed class CharacterService( .ToList(); } - private async Task> GetAppearancesByCharacterAsync(int characterId) - { - var timeline = await characters.GetTimelineAsync((await characters.GetCharacterAsync(characterId))?.ProjectID ?? 0, null); - return timeline.Appearances.Where(x => x.CharacterID == characterId).ToList(); - } + } diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 65f3d4d..2b8462d 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -1922,6 +1922,7 @@ public sealed class CharacterEditViewModel public string? CustomSex { get; set; } [Display(Name = "Birth date")] + [DataType(DataType.Date)] public DateTime? BirthDate { get; set; } [Display(Name = "Age at series start")] @@ -1974,6 +1975,8 @@ public sealed class CharacterDetailViewModel public IReadOnlyList ProjectCharacters { get; set; } = []; public IReadOnlyList Images { get; set; } = []; public string DisplayAge { get; set; } = "Unknown"; + public string AgeContext { get; set; } = string.Empty; + public Scene? AgeReferenceScene { get; set; } public IReadOnlyList Appearances { get; set; } = []; public IReadOnlyList AttributeEvents { get; set; } = []; public IReadOnlyList KnowledgeItems { get; set; } = []; diff --git a/PlotLine/Views/Characters/Details.cshtml b/PlotLine/Views/Characters/Details.cshtml index e5a5d26..63eef35 100644 --- a/PlotLine/Views/Characters/Details.cshtml +++ b/PlotLine/Views/Characters/Details.cshtml @@ -105,7 +105,15 @@

Profile

Preferred name: @Model.Character.CharacterName

Sex: @Model.Character.Sex

-

Age: @Model.DisplayAge

+

Date of birth: @(Model.Character.BirthDate?.ToString("d MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("en-GB")) ?? "Not recorded")

+

Age: @Model.DisplayAge + @Model.AgeContext + @if (Model.AgeReferenceScene is { } ageScene) + { + @ageScene.SceneTitle ยท @ageScene.StartDateTime?.ToString("d MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("en-GB")) + } + +

Height: @Model.Character.Height

Eye colour: @Model.Character.EyeColour

Story importance: @storyImportance

diff --git a/PlotLine/Views/Characters/Edit.cshtml b/PlotLine/Views/Characters/Edit.cshtml index 373bead..a858a6e 100644 --- a/PlotLine/Views/Characters/Edit.cshtml +++ b/PlotLine/Views/Characters/Edit.cshtml @@ -60,7 +60,9 @@
- + + +
Optional. Enter a date only; no birth time is needed.