Fix character profile age and date-only birth date editing
This commit is contained in:
parent
e06bcc57fe
commit
aa74df89e8
78
PlotLine.Tests/CharacterProfileAgeTests.cs
Normal file
78
PlotLine.Tests/CharacterProfileAgeTests.cs
Normal file
@ -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<ICharacterRepository>((method, args) => method switch {
|
||||
"GetCharacterAsync" => Task.FromResult<Character?>(character),
|
||||
"ListCharactersAsync" => Task.FromResult<IReadOnlyList<Character>>([character]),
|
||||
"GetTimelineAsync" => Task.FromResult(((IReadOnlyList<Character>)[character], (IReadOnlyList<SceneCharacter>)[new SceneCharacter { CharacterID = 1, SceneID = 900 }])),
|
||||
"GetLookupsAsync" => Task.FromResult(new CharacterLookupData()),
|
||||
"SaveCharacterAsync" => Capture((Character)args![0]!),
|
||||
_ => Default<ICharacterRepository>(method) });
|
||||
Task<int> Capture(Character value) { saved = value; return Task.FromResult(1); }
|
||||
var service = new CharacterService(
|
||||
Proxy<IProjectRepository>((_, _) => Task.FromResult<Project?>(timeline.Project)),
|
||||
Proxy<IBookRepository>(), repository, Proxy<IProjectCollaborationRepository>(), Proxy<ISubscriptionService>(),
|
||||
Proxy<IProjectActivityService>(), Proxy<ICurrentUserService>(), Proxy<IVisualIdentityImageService>(), Proxy<ICharacterEnrichmentService>(),
|
||||
Proxy<ITimelineRepository>((_, _) => 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<T>(Func<string, object?[]?, object?>? handler = null) where T : class
|
||||
{
|
||||
var result = DispatchProxy.Create<T, StoryPointProxy>();
|
||||
((StoryPointProxy)(object)result).Handler = handler ?? ((method, _) => Default<T>(method));
|
||||
return result;
|
||||
}
|
||||
private static object? Default<T>(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]);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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<Scene> orderedScenes, IReadOnlyList<SceneCharacter> 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);
|
||||
|
||||
@ -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<CharacterListViewModel?> GetCharactersAsync(int projectId)
|
||||
{
|
||||
@ -9617,6 +9618,14 @@ public sealed class CharacterService(
|
||||
.GroupBy(x => x.CharacterRelationshipID)
|
||||
.ToDictionary(x => x.Key, x => (IReadOnlyList<RelationshipEvent>)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<IReadOnlyList<SceneCharacter>> GetAppearancesByCharacterAsync(int characterId)
|
||||
{
|
||||
var timeline = await characters.GetTimelineAsync((await characters.GetCharacterAsync(characterId))?.ProjectID ?? 0, null);
|
||||
return timeline.Appearances.Where(x => x.CharacterID == characterId).ToList();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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<Character> ProjectCharacters { get; set; } = [];
|
||||
public IReadOnlyList<CharacterImage> Images { get; set; } = [];
|
||||
public string DisplayAge { get; set; } = "Unknown";
|
||||
public string AgeContext { get; set; } = string.Empty;
|
||||
public Scene? AgeReferenceScene { get; set; }
|
||||
public IReadOnlyList<SceneCharacter> Appearances { get; set; } = [];
|
||||
public IReadOnlyList<CharacterAttributeEvent> AttributeEvents { get; set; } = [];
|
||||
public IReadOnlyList<CharacterKnowledgeItem> KnowledgeItems { get; set; } = [];
|
||||
|
||||
@ -105,7 +105,15 @@
|
||||
<h2>Profile</h2>
|
||||
<p><strong>Preferred name:</strong> @Model.Character.CharacterName</p>
|
||||
<p><strong>Sex:</strong> @Model.Character.Sex</p>
|
||||
<p><strong>Age:</strong> @Model.DisplayAge</p>
|
||||
<p><strong>Date of birth:</strong> <span data-character-birth-date>@(Model.Character.BirthDate?.ToString("d MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("en-GB")) ?? "Not recorded")</span></p>
|
||||
<p><strong>Age:</strong> <span data-character-profile-age>@Model.DisplayAge</span>
|
||||
<small class="d-block muted">@Model.AgeContext
|
||||
@if (Model.AgeReferenceScene is { } ageScene)
|
||||
{
|
||||
<a asp-controller="Scenes" asp-action="Edit" asp-route-id="@ageScene.SceneID">@ageScene.SceneTitle · @ageScene.StartDateTime?.ToString("d MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("en-GB"))</a>
|
||||
}
|
||||
</small>
|
||||
</p>
|
||||
<p><strong>Height:</strong> @Model.Character.Height</p>
|
||||
<p><strong>Eye colour:</strong> @Model.Character.EyeColour</p>
|
||||
<p><strong>Story importance:</strong> @storyImportance</p>
|
||||
|
||||
@ -60,7 +60,9 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="BirthDate" class="form-label"></label>
|
||||
<input asp-for="BirthDate" class="form-control" />
|
||||
<input asp-for="BirthDate" type="date" asp-format="{0:yyyy-MM-dd}" class="form-control" />
|
||||
<span asp-validation-for="BirthDate" class="text-danger"></span>
|
||||
<div class="form-text">Optional. Enter a date only; no birth time is needed.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="AgeAtSeriesStart" class="form-label"></label>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user