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]); } }