PlotDirector/PlotLine.Tests/StoryPointTests.cs

170 lines
15 KiB
C#

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.PovName == "Ari", "POV must resolve the timeline ID from the canonical character batch even when Timeline omits its display name.");
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<IProjectAccessService>((_, _) => Task.FromResult(false));
var timeline = Proxy<ITimelineRepository>((_, _) => { dataCalls++; return Task.FromResult(input.Timeline); });
var unusedCharacters = Proxy<ICharacterRepository>((_, _) => throw new Exception("Unexpected character query"));
var unusedAssets = Proxy<IAssetRepository>((_, _) => throw new Exception("Unexpected asset query"));
var unusedLocations = Proxy<ILocationRepository>((_, _) => throw new Exception("Unexpected location query"));
var unusedHistory = Proxy<IStoryPointRepository>((_, _) => throw new Exception("Unexpected history query"));
var unusedAcknowledgements = Proxy<IContinuityWarningAcknowledgementRepository>((_, _) => 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<IProjectAccessService>((_, _) => 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<string>();
var characters = Proxy<ICharacterRepository>((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<IAssetRepository>((method, _) => { calls.Add(method); return Task.FromResult((input.Assets, input.AssetEvents)); });
var locations = Proxy<ILocationRepository>((method, _) => { calls.Add(method); return Task.FromResult(input.Locations); });
var history = Proxy<IStoryPointRepository>((method, _) => { calls.Add(method); return Task.FromResult(input.History); });
var acknowledgements = Proxy<IContinuityWarningAcknowledgementRepository>((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<T>(Func<string, object?[]?, object?> invoke) where T : class
{ var proxy = DispatchProxy.Create<T, StoryPointProxy>(); ((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, POVCharacterID = 1, 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<string, object?[]?, object?> Handler { get; set; } = null!;
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => Handler(targetMethod!.Name, args);
}