using System.Reflection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using PlotLine.Controllers; using PlotLine.Data; using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; internal static class StoryPointTests { private static void Check(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } private static StoryPointSnapshot Snapshot(StoryPointInputs? input = null, int scene = 20) => StoryPointContextService.Compose(input ?? Fixture(), scene) ?? throw new Exception("Missing snapshot"); public static void PeopleAndChronology() { var point = Snapshot(); Check(point.Characters.Select(x => x.Appearance.CharacterID).SequenceEqual([1, 2]), "Only selected SceneCharacters belong in presence."); Check(point.Characters[0].PreviousAppearance?.SceneID == 900, "Previous appearance must cross books and exclude selected scene, regardless of IDs."); Check(point.Characters[1].PreviousAppearance is null, "First appearance has no previous source."); Check(point.Characters[0].Age.DisplayText == CharacterAgeDisplayService.GetAgeForScene(Fixture().Characters[0], "Exact Date", new DateTime(2001, 2, 5)).DisplayText, "Age must use existing service and selected date."); Check(point.Characters[0].Age.DisplayText == "20 years, 1 month", "Exact age is incorrect."); var input = Fixture(); input.Timeline.Scenes.Single(x => x.SceneID == 20).StartDateTime = null; Check(Snapshot(input).Characters[0].Age.DisplayText == "About 19", "Undated chronology must use authoritative approximate age."); Check(Snapshot(input).Characters[1].Age.DisplayText is null, "Unknown age must stay unknown."); Check(point.PreviousLocationUse?.SceneID == 900, "Previous location use must cross book boundaries."); } public static void KnowledgeBoundary() { var point = Snapshot(); Check(point.Characters[0].Knowledge.Single().Description == "The gate code", "Prior knowledge must be retained."); Check(point.Characters[0].Knowledge.Single().KnowledgeStateName == "Knows", "Later knowledge regression must not change earlier state."); Check(point.Characters[1].Knowledge.Count == 0, "Future knowledge must not leak."); var later = Snapshot(scene: 1); Check(later.Characters.Single(x => x.Appearance.CharacterID == 1).Knowledge.Any(x => x.KnowledgeStateName == "Unaware"), "Later knowledge transition must be reflected."); Check(point.Sources.Keys.ToHashSet().SetEquals([900, 20]), "Snapshot sources must exclude future scenes."); } public static void RelationshipsAndAssets() { var point = Snapshot(); Check(point.Relationships.Count == 1 && point.Relationships[0].CurrentStateName == "Trusted", "Earlier relationship event must win; future/undated relationships excluded."); Check(point.Relationships[0].History.All(x => x.SceneID != 1), "Future relationship history must not leak."); var asset = point.Assets.Single(); Check(asset.Holder == "Ari" && asset.Location == "Harbour" && asset.Status == "Sealed", "Custody, location and status must be independently historical."); Check(asset.LastConfirmedSceneID == 20, "Latest asset event must stop at selected scene."); Check(point.Location!.AssetsPresent.Contains("Case"), "Custody must not erase known asset location."); var initialInput = Fixture(); var undated = initialInput.Relationships.Relationships.Single(x => x.CharacterRelationshipID == 3); undated.IsInitialRelationship = true; undated.StartSceneID = 1; Check(Snapshot(initialInput).Relationships.Count == 1, "Future start must override initial flag."); undated.StartSceneID = null; undated.InitialBookID = 999; Check(Snapshot(initialInput).Relationships.Count == 1, "Unplaced initial-book boundary must fail closed."); undated.InitialBookID = null; undated.EndSceneID = 900; Check(Snapshot(initialInput).Relationships.Count == 1, "Already-ended relationship must not be active."); var later = Snapshot(scene: 1); Check(later.Assets.Single(x => x.StoryAssetID == 10).Holder == "Outsider", "Later custody event must take effect only later."); Check(later.Relationships[0].CurrentStateName == "Hostile", "Later relationship state must take effect."); } public static void ThreadsAndWarnings() { var point = Snapshot(); Check(point.Threads.Select(x => x.PlotThreadID).ToHashSet().SetEquals([1, 4]), "Active threads include earlier introduction and bounded event introduction; exclude future and already resolved threads."); Check(point.Threads.Single(x => x.PlotThreadID == 1).State == "Introduced", "Global resolved status must not leak backwards."); Check(point.Warnings.Any(x => x.CharacterID == 2 && x.WarningType.Contains("Unknown")), "Future location evidence must not suppress current unknown-location warning."); Check(point.Warnings.All(x => x.SceneID != 1 && x.PreviousSceneId != 1), "Warnings must not contain future evidence."); Check(Snapshot(scene: 1).Threads.All(x => x.PlotThreadID != 1), "Resolved-at-selected-scene thread is no longer active."); var input = Fixture(); input.History.ThreadEvents.Single(x => x.PlotThreadID == 4).EventTypeName = "Resolved"; Check(Snapshot(input).Threads.All(x => x.PlotThreadID != 4), "Resolved event must close thread even when resolution field is absent."); } public static void OwnershipAndNoAi() { var input = Fixture(); int dataCalls = 0; var access = Proxy((_, _) => Task.FromResult(false)); var timeline = Proxy((_, _) => { dataCalls++; return Task.FromResult(input.Timeline); }); var unusedCharacters = Proxy((_, _) => throw new Exception("Unexpected character query")); var unusedAssets = Proxy((_, _) => throw new Exception("Unexpected asset query")); var unusedLocations = Proxy((_, _) => throw new Exception("Unexpected location query")); var unusedHistory = Proxy((_, _) => throw new Exception("Unexpected history query")); var unusedAcknowledgements = Proxy((_, _) => throw new Exception("Unexpected acknowledgement query")); var service = new StoryPointContextService(access, timeline, unusedCharacters, unusedAssets, unusedLocations, unusedHistory, unusedAcknowledgements); Check(service.GetAsync(1, 20).Result is null && dataCalls == 0, "Denied project must not load any data."); access = Proxy((_, _) => Task.FromResult(true)); service = new StoryPointContextService(access, timeline, unusedCharacters, unusedAssets, unusedLocations, unusedHistory, unusedAcknowledgements); Check(service.GetAsync(1, 9999).Result is null, "A scene outside the requested project must be rejected before loading history."); var calls = new List(); var characters = Proxy((method, _) => { calls.Add(method); return method switch { nameof(ICharacterRepository.GetTimelineAsync) => Task.FromResult((input.Characters, input.Appearances)), nameof(ICharacterRepository.GetRelationshipMapAsync) => Task.FromResult(input.Relationships), _ => throw new Exception("Unexpected per-character query") }; }); var assets = Proxy((method, _) => { calls.Add(method); return Task.FromResult((input.Assets, input.AssetEvents)); }); var locations = Proxy((method, _) => { calls.Add(method); return Task.FromResult(input.Locations); }); var history = Proxy((method, _) => { calls.Add(method); return Task.FromResult(input.History); }); var acknowledgements = Proxy((method, _) => { calls.Add(method); return Task.FromResult(input.Acknowledgements); }); service = new StoryPointContextService(access, timeline, characters, assets, locations, history, acknowledgements); Check(service.GetAsync(1, 20).Result?.Snapshot?.Scene.SceneID == 20 && calls.Count == 6, "Page load must compose six history batches with no per-entity calls or AI dependency."); Check(StoryPointContextService.Compose(input, 9999) is null, "Invalid scene projection must fail closed."); var controller = new StoryPointController(service) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; controller.Request.Headers["X-Requested-With"] = "XMLHttpRequest"; Check(controller.Index(1, 20).Result is PartialViewResult { ViewName: "_Snapshot" }, "AJAX route must render reusable snapshot."); Check(controller.Index(1, 9999).Result is NotFoundResult, "Controller must reject cross-project/unknown scene."); Check(controller.Index(0, 20).Result is BadRequestObjectResult, "Missing project must fail."); } public static void CanonicalTieBreaks() { var input = Fixture(); input.Timeline.Books[0].SortOrder = 1; input.Timeline.Books[1].SortOrder = 1; Check(StoryStateService.OrderScenes(input.Timeline).Select(x => x.SceneID).SequenceEqual([900, 20, 1]), "BookNumber tie breaker must match NarrativeSceneOrder, not input or ID order."); Check(Snapshot(input).Relationships.Single().CurrentStateName == "Trusted", "Relationship projection must use the same canonical boundary."); } private static T Proxy(Func invoke) where T : class { var proxy = DispatchProxy.Create(); ((StoryPointProxy)(object)proxy).Handler = invoke; return proxy; } private static StoryPointInputs Fixture() => new() { Timeline = new TimelineData { Project = new Project { ProjectID = 1 }, Books = [new Book { BookID = 2, BookNumber = 2, SortOrder = 2, BookTitle = "Voyage" }, new Book { BookID = 9, BookNumber = 1, SortOrder = 1, BookTitle = "Departure" }], Chapters = [new Chapter { ChapterID = 2, BookID = 2, ChapterNumber = 1, SortOrder = 1 }, new Chapter { ChapterID = 9, BookID = 9, ChapterNumber = 1, SortOrder = 1 }], Scenes = [new Scene { SceneID = 1, ChapterID = 2, SortOrder = 2, SceneNumber = 2, PrimaryLocationID = 5 }, new Scene { SceneID = 20, ChapterID = 2, SortOrder = 1, SceneNumber = 1, PrimaryLocationID = 5, TimeModeName = "Exact Date", StartDateTime = new DateTime(2001, 2, 5) }, new Scene { SceneID = 900, ChapterID = 9, SortOrder = 1, SceneNumber = 1, PrimaryLocationID = 5 }] }, Characters = [new Character { CharacterID = 1, CharacterName = "Ari", BirthDate = new DateTime(1981, 1, 5), AgeAtSeriesStart = 19 }, new Character { CharacterID = 2, CharacterName = "Bo" }], Appearances = [new SceneCharacter { CharacterID = 1, CharacterName = "Ari", SceneID = 900, LocationID = 5, LocationPath = "Harbour" }, new SceneCharacter { CharacterID = 1, CharacterName = "Ari", SceneID = 20, LocationID = 5, LocationPath = "Harbour" }, new SceneCharacter { CharacterID = 2, CharacterName = "Bo", SceneID = 20 }, new SceneCharacter { CharacterID = 1, CharacterName = "Ari", SceneID = 1, LocationID = 6, LocationPath = "Tower" }, new SceneCharacter { CharacterID = 2, CharacterName = "Bo", SceneID = 1, LocationID = 6, LocationPath = "Tower" }], Assets = [new StoryAsset { StoryAssetID = 10, AssetName = "Case" }, new StoryAsset { StoryAssetID = 11, AssetName = "Future object" }], AssetEvents = [new AssetEvent { AssetEventID = 1, StoryAssetID = 10, SceneID = 900, ToStateName = "Sealed" }, new AssetEvent { AssetEventID = 2, StoryAssetID = 10, SceneID = 1, ToStateName = "Opened" }], Locations = [new LocationItem { LocationID = 5, LocationName = "Harbour", LocationPath = "Harbour" }], Relationships = new RelationshipMapData { Relationships = [ new CharacterRelationship { CharacterRelationshipID = 1, CharacterAID = 1, CharacterBID = 2, CharacterAName = "Ari", CharacterBName = "Bo" }, new CharacterRelationship { CharacterRelationshipID = 2, CharacterAID = 1, CharacterBID = 3 }, new CharacterRelationship { CharacterRelationshipID = 3, CharacterAID = 1, CharacterBID = 4 }], RelationshipEvents = [new RelationshipEvent { CharacterRelationshipID = 1, SceneID = 900, RelationshipStateName = "Trusted" }, new RelationshipEvent { CharacterRelationshipID = 1, SceneID = 1, RelationshipStateName = "Hostile" }, new RelationshipEvent { CharacterRelationshipID = 2, SceneID = 1, RelationshipStateName = "Married" }] }, History = new StoryPointHistory { Knowledge = [new CharacterKnowledgeItem { CharacterKnowledgeID = 1, CharacterID = 1, SceneID = 900, Description = "The gate code", KnowledgeStateName = "Knows" }, new CharacterKnowledgeItem { CharacterKnowledgeID = 2, CharacterID = 1, SceneID = 1, Description = "The gate code", KnowledgeStateName = "Unaware" }, new CharacterKnowledgeItem { CharacterKnowledgeID = 3, CharacterID = 2, SceneID = 1, Description = "Future secret", KnowledgeStateName = "Knows" }], AssetLocations = [new SceneAssetLocation { StoryAssetID = 10, SceneID = 900, LocationID = 5, LocationPath = "Harbour" }, new SceneAssetLocation { StoryAssetID = 10, SceneID = 1, LocationID = 6, LocationPath = "Tower" }], Custody = [new AssetCustodyEvent { AssetCustodyEventID = 1, StoryAssetID = 10, SceneID = 20, CustodianSummary = "Ari" }, new AssetCustodyEvent { AssetCustodyEventID = 2, StoryAssetID = 10, SceneID = 1, CustodianSummary = "Outsider" }], Custodians = [new AssetCustodyCharacter { AssetCustodyEventID = 1, StoryAssetID = 10, SceneID = 20, CharacterID = 1 }, new AssetCustodyCharacter { AssetCustodyEventID = 2, StoryAssetID = 10, SceneID = 1, CharacterID = 3 }], Threads = [new PlotThread { PlotThreadID = 1, ThreadTitle = "Open question", ThreadTypeName = "Question", IntroducedSceneID = 900, ActualResolutionSceneID = 1, ThreadStatusName = "Resolved" }, new PlotThread { PlotThreadID = 2, ThreadTitle = "Future", IntroducedSceneID = 1 }, new PlotThread { PlotThreadID = 3, ThreadTitle = "Closed", IntroducedSceneID = 900, ActualResolutionSceneID = 900 }, new PlotThread { PlotThreadID = 4, ThreadTitle = "Clue", ThreadTypeName = "Clue" }], ThreadEvents = [new ThreadEvent { PlotThreadID = 4, SceneID = 20, EventTypeName = "Introduced" }] } }; } public class StoryPointProxy : DispatchProxy { public Func Handler { get; set; } = null!; protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => Handler(targetMethod!.Name, args); }