using System.Reflection; using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using PlotLine.Data; using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; internal static class RelationshipDevelopmentTests { public static void SceneReviewAndAcceptance() => Run().GetAwaiter().GetResult(); private static async Task Run() { foreach (var names in new[] { ("Rob", "Maggie"), ("Iris", "Jon") }) { var accepted = new List(); var lookups = new CharacterLookupData { RelationshipTypes = [new() { RelationshipTypeID=1,TypeName="Other" },new() { RelationshipTypeID=2,TypeName="Sibling" }], RelationshipStates = [new() { RelationshipStateID=1,StateName="Unknown" },new() { RelationshipStateID=2,StateName="Friendly" },new() { RelationshipStateID=3,StateName="Distrustful" },new() { RelationshipStateID=4,StateName="Romantic" }] }; var characters = Stub((m,a) => m switch { "ListCharactersAsync" => Task.FromResult>([new() { CharacterID=1, CharacterName=names.Item1 },new() { CharacterID=2,CharacterName=names.Item2 }]), "ListAliasesAsync" => Task.FromResult>([]), "ListRelationshipsByProjectAsync" => Task.FromResult>([]), "ListRelationshipEventsBySceneAsync" => Task.FromResult>([]), "GetLookupsAsync" => Task.FromResult(lookups), "AcceptRelationshipDevelopmentAsync" => Save((RelationshipDevelopmentAcceptance)a![0]!), _ => throw new Exception(m) }); Task Save(RelationshipDevelopmentAcceptance p) { accepted.Add(p); return Task.FromResult(77); } SceneIntelligenceRelationship Proposal(string kind,string state,bool mutual,string evidence,int? intensity=null) => new() { CharacterA=names.Item1,CharacterB=names.Item2,ProposalKind=kind,RelationshipState=state, IsReciprocal=mutual,IsKnownToOtherCharacter=false,Intensity=intensity,Confidence=.95m, Development=evidence,Evidence=evidence,RelationshipSignal="affection",FactualRelationshipType=kind=="Factual"?"Sibling":null }; var results = new List { Result(30,[Proposal("Development","Romantic",false,"A privately develops feelings for B",3),Proposal("Factual","Unknown",true,"They are siblings")]), Result(10,[Proposal("Development","Friendly",true,"They help each other escape",6),Proposal("Factual","Unknown",true,"They are siblings")]), Result(20,[Proposal("Development","Distrustful",false,"A discovers B has lied"),new() { CharacterA=names.Item1,CharacterB=names.Item2,RelationshipSignal="trust",Evidence="They merely appear together with no interaction" }]) }; var runs=Stub((m,a)=>m switch { "GetRunAsync"=>Task.FromResult(new() { Status=StoryIntelligenceRunStatuses.Completed }), "ListSceneResultsAsync"=>Task.FromResult>(results), _=>throw new Exception(m) }); var scenes=Stub((m,a)=> Task.FromResult>([ new() { SceneID=30,SceneNumber=3,ChapterNumber=1 },new() { SceneID=10,SceneNumber=1,ChapterNumber=1 },new() { SceneID=20,SceneNumber=2,ChapterNumber=1 }])); var pipeline=Stub((m,a)=>Task.FromResult(null)); var service=new StoryIntelligenceRelationshipImportService(runs,characters,scenes,pipeline,NullLogger.Instance); var batch=new OnboardingStoryIntelligenceBatch { ProjectID=1,BookID=2,UserID=3,Items=[new() { RunID=1,ChapterID=1 }] }; var review=await service.BuildReviewAsync(batch); Check(review.Candidates.Count==4,"Meaningful proposals must stay separate by scene, with co-presence excluded."); Check(review.Candidates.Select(x=>x.FirstSceneID).SequenceEqual(new int?[]{10,10,20,30}),"Review must follow scene order."); Check(review.Candidates.Last().IsReciprocal==false && review.Candidates.Last().RelationshipState=="Romantic","One-sided feelings must not become mutual."); Check(review.Candidates.First(x=>x.ProposalKind=="Factual").RelationshipType=="Sibling","Factual ties must stay separate."); var form=new StoryIntelligenceRelationshipImportForm { Relationships=review.Candidates.Select(x=>new StoryIntelligenceRelationshipImportChoiceForm { Key=x.Key,RelationshipTypeID=x.RelationshipTypeID,RelationshipStateID=x.RelationshipStateID,IsReciprocal=x.IsReciprocal, IsKnownToOtherCharacter=x.IsKnownToOtherCharacter,Intensity=x.Intensity,Development=x.Development }).ToList() }; form.Relationships.Last().ReverseDirection=true; var result=await service.ImportAsync(batch,form,false); Check(result.Success && accepted.Count==4,"Acceptance must save all scene proposals."); Check(accepted.Last().CharacterAID==2 && accepted.Last().CharacterBID==1 && !accepted.Last().IsReciprocal,"Direction correction must reach persistence."); Check(accepted.Last().Intensity==3 && accepted.Any(x=>x.Intensity is null),"Confidence must never become intensity."); Check(accepted.Single(x=>x.IsPermanent).RelationshipTypeID==2,"Only factual ties should be permanent."); Check(form.Relationships.All(x=>x.AcceptedRelationshipID==77),"Review decisions must reference the canonical relationship ID, not the type ID."); accepted.Clear(); form.Relationships.Last().RelationshipStateID=999; Check(!(await service.ImportAsync(batch,form,false)).Success && accepted.Count==0,"Invalid later rows must prevent all writes."); } Check(Key(10,1,2,false)!=Key(10,2,1,false),"Directional keys must distinguish reverse feelings."); Check(Key(10,1,2,true)==Key(10,2,1,true),"Mutual duplicate proposals must share identity."); Check(Key(10,1,2,false)!=Key(20,1,2,false),"Later scenes must retain independent identity."); } private static string Key(int scene,int a,int b,bool reciprocal) => (string)typeof(StoryIntelligenceRelationshipImportService).GetMethod("DevelopmentKey",BindingFlags.NonPublic|BindingFlags.Static)!.Invoke(null,[scene,a,b,reciprocal,"Development","trust","Evidence"])!; private static StoryIntelligenceSavedSceneResult Result(int scene,List proposals) => new() { SceneID=scene,ParsedJson=JsonSerializer.Serialize(new SceneIntelligenceScene { SchemaVersion="1.0",Relationships=proposals }) }; private static void Check(bool value,string message) { if(!value)throw new Exception(message); } private static T Stub(Func handler) where T:class { var proxy=DispatchProxy.Create(); ((RelationshipTestProxy)(object)proxy).Handler=handler; return proxy; } } public class RelationshipTestProxy:DispatchProxy { public Func Handler { get; set; }=null!; protected override object? Invoke(MethodInfo? method,object?[]? args)=>Handler(method!.Name,args); }