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)[]
|
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 presence, previous appearances and age cross book boundaries", StoryPointTests.PeopleAndChronology),
|
||||||
("Story Point knowledge stops at selected narrative position", StoryPointTests.KnowledgeBoundary),
|
("Story Point knowledge stops at selected narrative position", StoryPointTests.KnowledgeBoundary),
|
||||||
("Story Point relationships and independent asset state are historical", StoryPointTests.RelationshipsAndAssets),
|
("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 string? DisplayText { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record CharacterProfileAgeDisplayResult(string DisplayText, string Context, Scene? ReferenceScene);
|
||||||
|
|
||||||
public static class CharacterAgeDisplayService
|
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)
|
public static CharacterAgeDisplayResult GetAgeForScene(SceneCharacter character, string? sceneTimeModeName, DateTime? sceneStartDateTime)
|
||||||
{
|
{
|
||||||
var effectiveDate = ResolveEffectiveSceneDate(sceneTimeModeName, sceneStartDateTime);
|
var effectiveDate = ResolveEffectiveSceneDate(sceneTimeModeName, sceneStartDateTime);
|
||||||
|
|||||||
@ -9537,7 +9537,8 @@ public sealed class CharacterService(
|
|||||||
IProjectActivityService activity,
|
IProjectActivityService activity,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IVisualIdentityImageService visualIdentityImages,
|
IVisualIdentityImageService visualIdentityImages,
|
||||||
ICharacterEnrichmentService characterEnrichment) : ICharacterService
|
ICharacterEnrichmentService characterEnrichment,
|
||||||
|
ITimelineRepository timelineRepository) : ICharacterService
|
||||||
{
|
{
|
||||||
public async Task<CharacterListViewModel?> GetCharactersAsync(int projectId)
|
public async Task<CharacterListViewModel?> GetCharactersAsync(int projectId)
|
||||||
{
|
{
|
||||||
@ -9617,6 +9618,14 @@ public sealed class CharacterService(
|
|||||||
.GroupBy(x => x.CharacterRelationshipID)
|
.GroupBy(x => x.CharacterRelationshipID)
|
||||||
.ToDictionary(x => x.Key, x => (IReadOnlyList<RelationshipEvent>)x.ToList());
|
.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
|
return new CharacterDetailViewModel
|
||||||
{
|
{
|
||||||
Project = project,
|
Project = project,
|
||||||
@ -9624,8 +9633,10 @@ public sealed class CharacterService(
|
|||||||
Aliases = await characters.ListAliasesAsync(character.CharacterID),
|
Aliases = await characters.ListAliasesAsync(character.CharacterID),
|
||||||
ProjectCharacters = projectCharacters,
|
ProjectCharacters = projectCharacters,
|
||||||
Images = await characters.ListCharacterImagesAsync(character.CharacterID),
|
Images = await characters.ListCharacterImagesAsync(character.CharacterID),
|
||||||
DisplayAge = DisplayAge(character),
|
DisplayAge = profileAge.DisplayText,
|
||||||
Appearances = await GetAppearancesByCharacterAsync(character.CharacterID),
|
AgeContext = profileAge.Context,
|
||||||
|
AgeReferenceScene = profileAge.ReferenceScene,
|
||||||
|
Appearances = appearances,
|
||||||
AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(character.CharacterID),
|
AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(character.CharacterID),
|
||||||
KnowledgeItems = await characters.ListKnowledgeByCharacterAsync(character.CharacterID),
|
KnowledgeItems = await characters.ListKnowledgeByCharacterAsync(character.CharacterID),
|
||||||
InitialRelationships = initialRelationships,
|
InitialRelationships = initialRelationships,
|
||||||
@ -9746,8 +9757,9 @@ public sealed class CharacterService(
|
|||||||
ShortName = existing?.ShortName,
|
ShortName = existing?.ShortName,
|
||||||
SexValueID = sex.SexValueID,
|
SexValueID = sex.SexValueID,
|
||||||
Sex = sex.SexName,
|
Sex = sex.SexName,
|
||||||
BirthDate = model.BirthDate,
|
BirthDate = model.BirthDate?.Date,
|
||||||
AgeAtSeriesStart = model.AgeAtSeriesStart,
|
AgeAtSeriesStart = model.AgeAtSeriesStart,
|
||||||
|
AgeReferenceSceneID = existing?.AgeReferenceSceneID,
|
||||||
Height = model.Height,
|
Height = model.Height,
|
||||||
EyeColour = model.EyeColour,
|
EyeColour = model.EyeColour,
|
||||||
CharacterImportance = model.CharacterImportance,
|
CharacterImportance = model.CharacterImportance,
|
||||||
@ -10205,9 +10217,5 @@ public sealed class CharacterService(
|
|||||||
.ToList();
|
.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; }
|
public string? CustomSex { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Birth date")]
|
[Display(Name = "Birth date")]
|
||||||
|
[DataType(DataType.Date)]
|
||||||
public DateTime? BirthDate { get; set; }
|
public DateTime? BirthDate { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Age at series start")]
|
[Display(Name = "Age at series start")]
|
||||||
@ -1974,6 +1975,8 @@ public sealed class CharacterDetailViewModel
|
|||||||
public IReadOnlyList<Character> ProjectCharacters { get; set; } = [];
|
public IReadOnlyList<Character> ProjectCharacters { get; set; } = [];
|
||||||
public IReadOnlyList<CharacterImage> Images { get; set; } = [];
|
public IReadOnlyList<CharacterImage> Images { get; set; } = [];
|
||||||
public string DisplayAge { get; set; } = "Unknown";
|
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<SceneCharacter> Appearances { get; set; } = [];
|
||||||
public IReadOnlyList<CharacterAttributeEvent> AttributeEvents { get; set; } = [];
|
public IReadOnlyList<CharacterAttributeEvent> AttributeEvents { get; set; } = [];
|
||||||
public IReadOnlyList<CharacterKnowledgeItem> KnowledgeItems { get; set; } = [];
|
public IReadOnlyList<CharacterKnowledgeItem> KnowledgeItems { get; set; } = [];
|
||||||
|
|||||||
@ -105,7 +105,15 @@
|
|||||||
<h2>Profile</h2>
|
<h2>Profile</h2>
|
||||||
<p><strong>Preferred name:</strong> @Model.Character.CharacterName</p>
|
<p><strong>Preferred name:</strong> @Model.Character.CharacterName</p>
|
||||||
<p><strong>Sex:</strong> @Model.Character.Sex</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>Height:</strong> @Model.Character.Height</p>
|
||||||
<p><strong>Eye colour:</strong> @Model.Character.EyeColour</p>
|
<p><strong>Eye colour:</strong> @Model.Character.EyeColour</p>
|
||||||
<p><strong>Story importance:</strong> @storyImportance</p>
|
<p><strong>Story importance:</strong> @storyImportance</p>
|
||||||
|
|||||||
@ -60,7 +60,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label asp-for="BirthDate" class="form-label"></label>
|
<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>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label asp-for="AgeAtSeriesStart" class="form-label"></label>
|
<label asp-for="AgeAtSeriesStart" class="form-label"></label>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user