using System.Globalization; using System.IO.Pipes; using System.Text.Json; using XFEEDToolBox.Core.Galaxy; using XFEExtension.NetCore.XUnit.Attributes; namespace XFEEDToolBox.Test; [TestFixture] public sealed class NativeGalaxyCivilizationServicesWireTests { private const long First=9007199254740993; private static object Civilization(string id="9007199254740995",string key="guardian",bool defaulted=false)=>new{definitionId=id,key,defaulted}; private static object Services(int mask=513,bool defaulted=false)=>new{mask,defaulted}; private static object Row(long address,params (string Name,object? Value)[] fields) { var row=new Dictionary{{"systemAddress",address.ToString(CultureInfo.InvariantCulture)}}; foreach(var field in fields)row.Add(field.Name,field.Value); return row; } private static object Page(string origin,int total,bool done,params object[] rows)=>new {generation=17UL,contextSystemAddress=origin,total,done,error="",items=rows}; private static async Task Serve(int pid,object result,CancellationToken token,Action? inspect=null) { await using var server=new NamedPipeServerStream($"XFEEDToolBox.Navigation.{pid}",PipeDirection.InOut,1,PipeTransmissionMode.Byte,PipeOptions.Asynchronous); await server.WaitForConnectionAsync(token);var buffer=new byte[8192];var count=0; // The byte pipe has no message boundary. Accumulate a complete JSON // request rather than assuming one ReadAsync returns every byte. while(true) { if(count==buffer.Length)throw new InvalidDataException("Fixture request exceeds its bound."); var read=await server.ReadAsync(buffer.AsMemory(count),token); if(read==0)throw new EndOfStreamException();count+=read; JsonDocument request; try{request=JsonDocument.Parse(buffer.AsMemory(0,count));}catch(JsonException){continue;} using(request)inspect?.Invoke(request.RootElement); break; } await server.WriteAsync(JsonSerializer.SerializeToUtf8Bytes(new{ok=true,result}),token); await server.WriteAsync(new byte[]{10},token);await server.FlushAsync(token); if(OperatingSystem.IsWindows())server.WaitForPipeDrain(); } private static void InspectRequest(JsonElement request,bool civilization,bool services) { Check.Equal("galaxy.facts",request.GetProperty("op").GetString()); Check.Equal(civilization,request.GetProperty("includeCivilization").GetBoolean()); Check.Equal(services,request.GetProperty("includeServices").GetBoolean()); Check.Equal(First.ToString(CultureInfo.InvariantCulture),request.GetProperty("systemAddresses")[0].GetString()); } private static async Task ReadOne(object row,bool civilization,bool services) { var pid=Random.Shared.Next(190000000,200000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(8)); var server=Task.Run(()=>Serve(pid,Page("42",1,true,row),stop.Token,r=>InspectRequest(r,civilization,services))); var result=await new NativeGalaxyFilterFactsCatalog(pid).ReadAsync([First],stop.Token,includeCivilization:civilization,includeServices:services); await server;Check.Equal(42L,result.OriginSystemAddress);return result.Rows.Single(); } private static async Task RejectAndRecover(object row,bool civilization=true,bool services=true) { var pid=Random.Shared.Next(190000000,200000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(8)); var server=Task.Run(async()=> { await Serve(pid,Page("42",1,true,row),stop.Token,r=>InspectRequest(r,civilization,services)); await Serve(pid,new{cancelled=true},stop.Token,r=> {Check.Equal("galaxy.facts.cancel",r.GetProperty("op").GetString());Check.Equal(17UL,r.GetProperty("generation").GetUInt64());}); await Serve(pid,Page("42",1,true,Row(First,("civilization",null),("services",null))),stop.Token,r=>InspectRequest(r,civilization,services)); }); var catalog=new NativeGalaxyFilterFactsCatalog(pid);var rejected=false; try{await catalog.ReadAsync([First],stop.Token,includeCivilization:civilization,includeServices:services);}catch(InvalidDataException){rejected=true;} Check.That(rejected,"Malformed or unsolicited classifier receipt was accepted."); // Reusing the same client verifies both original-generation cleanup // and release of the client's semaphore after rejection. var recovered=await catalog.ReadAsync([First],stop.Token,includeCivilization:civilization,includeServices:services); await server;var result=recovered.Rows.Single(); Check.That(result.Civilization is null&&result.ServiceMask is null&&result.ServicesDefaulted is null); Check.Equal(civilization,result.CivilizationRequested);Check.Equal(services,result.ServicesRequested); } [Test] public async Task RequestedExplicitNullKeepsIndependentQueryBindingsWithoutInventingNegativeFacts() { foreach(var request in new[]{(Civilization:true,Services:false),(Civilization:false,Services:true),(Civilization:true,Services:true)}) { var fact=await ReadOne(Row(First,("civilization",null),("services",null)),request.Civilization,request.Services); Check.Equal(request.Civilization,fact.CivilizationRequested);Check.Equal(request.Services,fact.ServicesRequested); Check.That(fact.Civilization is null&&fact.ServiceMask is null&&fact.ServicesDefaulted is null); } } [Test] public async Task MissingRequestedFieldsAndUnsolicitedNonNullResultsAreRejectedWithCleanup() { await RejectAndRecover(Row(First,("services",Services()))); await RejectAndRecover(Row(First,("civilization",Civilization()))); await RejectAndRecover(Row(First,("civilization",Civilization()),("services",null)),civilization:false,services:true); await RejectAndRecover(Row(First,("civilization",null),("services",Services())),civilization:true,services:false); await RejectAndRecover(Row(First,("civilization",Civilization())),civilization:false,services:false); await RejectAndRecover(Row(First,("services",Services())),civilization:false,services:false); } [Test] public async Task UnrequestedMissingOrExplicitNullFieldsNeverRetainPreviousClientResults() { var pid=Random.Shared.Next(190000000,200000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(8)); var server=Task.Run(async()=> { await Serve(pid,Page("42",1,true,Row(First,("civilization",Civilization()),("services",Services()))),stop.Token,r=>InspectRequest(r,true,true)); await Serve(pid,Page("42",1,true,Row(First)),stop.Token,r=>InspectRequest(r,false,false)); await Serve(pid,Page("42",1,true,Row(First,("civilization",null),("services",null))),stop.Token,r=>InspectRequest(r,false,false)); }); var catalog=new NativeGalaxyFilterFactsCatalog(pid); var known=(await catalog.ReadAsync([First],stop.Token,includeCivilization:true,includeServices:true)).Rows.Single(); Check.That(known.Civilization?.Key=="guardian"&&known.ServiceMask==513); for(var i=0;i<2;i++) { var fact=(await catalog.ReadAsync([First],stop.Token)).Rows.Single(); Check.That(!fact.CivilizationRequested&&!fact.ServicesRequested&&fact.Civilization is null&&fact.ServiceMask is null&&fact.ServicesDefaulted is null); } await server; } [Test] public async Task ServiceMaskAndDefaultedMustBeCompleteExactAndConsistent() { foreach(var invalid in new[]{"{}","{\"mask\":0}","{\"mask\":null,\"defaulted\":false}","{\"mask\":-1,\"defaulted\":false}", "{\"mask\":1024,\"defaulted\":false}","{\"mask\":65536,\"defaulted\":false}","{\"mask\":1.5,\"defaulted\":false}", "{\"mask\":\"1\",\"defaulted\":false}","{\"mask\":1,\"defaulted\":true}","{\"mask\":0,\"defaulted\":null}", "{\"mask\":0,\"defaulted\":1}","{\"mask\":0,\"defaulted\":\"true\"}","[]","false"}) await RejectAndRecover(Row(First,("civilization",null),("services",Check.Json(invalid)))); foreach(var sample in new[]{(Mask:0,Defaulted:true),(Mask:0,Defaulted:false),(Mask:1023,Defaulted:false)}) { var result=await ReadOne(Row(First,("services",Services(sample.Mask,sample.Defaulted))),false,true); Check.Equal((ushort?)sample.Mask,result.ServiceMask);Check.Equal((bool?)sample.Defaulted,result.ServicesDefaulted); Check.That(result.ServicesRequested&&!result.CivilizationRequested&&result.Civilization is null); } } [Test] public async Task CivilizationDefinitionIdsStayU64AndInvalidDefaultsCannotBecomeNone() { foreach(var sample in new[]{(Id:"9007199254740995",Key:"guardian",Defaulted:false),(Id:"9223372036854775809",Key:"human",Defaulted:false), (Id:"18446744073709551615",Key:"none",Defaulted:true)}) { var result=await ReadOne(Row(First,("civilization",Civilization(sample.Id,sample.Key,sample.Defaulted))),true,false); Check.Equal(ulong.Parse(sample.Id,CultureInfo.InvariantCulture),result.Civilization!.DefinitionId); Check.Equal(sample.Key,result.Civilization.Key);Check.Equal(sample.Defaulted,result.Civilization.Defaulted); Check.That(result.CivilizationRequested&&!result.ServicesRequested&&result.ServiceMask is null); } var future=await ReadOne(Row(First,("civilization",Civilization("9007199254740995","future"))),true,false); Check.That(future.Civilization is{DefinitionId:9007199254740995UL,Key:null,Defaulted:false}); foreach(var invalid in new[]{"{}","{\"definitionId\":9007199254740995,\"key\":\"human\"}","{\"definitionId\":\"18446744073709551616\",\"key\":\"none\"}", "{\"definitionId\":\"-1\",\"key\":\"none\"}","{\"definitionId\":\"1.0\",\"key\":\"human\"}","{\"definitionId\":null,\"key\":\"human\"}", "{\"definitionId\":\"1\"}","{\"definitionId\":\"1\",\"key\":1}","{\"definitionId\":\"1\",\"key\":\"none\",\"defaulted\":true}", "{\"definitionId\":\"18446744073709551615\",\"key\":\"human\",\"defaulted\":true}","{\"definitionId\":\"1\",\"key\":\"human\",\"defaulted\":\"true\"}","[]"}) await RejectAndRecover(Row(First,("civilization",Check.Json(invalid)),("services",null))); } [Test] public async Task ProgressivePagesKeepEachClassifierIndependentForEachAddress() { var pid=Random.Shared.Next(190000000,200000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(8)); var server=Task.Run(async()=> { await Serve(pid,Page("42",2,false,Row(First,("civilization",Civilization()),("services",null))),stop.Token,r=>InspectRequest(r,true,true)); await Serve(pid,Page("42",2,true,Row(First+1,("civilization",null),("services",Services(0,true)))),stop.Token,r=> {Check.Equal("galaxy.facts.result",r.GetProperty("op").GetString());Check.Equal(1,r.GetProperty("offset").GetInt32());Check.Equal(17UL,r.GetProperty("generation").GetUInt64());}); }); var result=await new NativeGalaxyFilterFactsCatalog(pid).ReadAsync([First,First+1],stop.Token,includeCivilization:true,includeServices:true);await server; Check.That(result.Rows.All(row=>row.CivilizationRequested&&row.ServicesRequested)); Check.That(result.Rows[0] is{SystemAddress:First,Civilization.Key:"guardian",ServiceMask:null,ServicesDefaulted:null}); Check.That(result.Rows[1] is{Civilization:null,ServiceMask:0,ServicesDefaulted:true});Check.Equal(First+1,result.Rows[1].SystemAddress); } [Test] public async Task ChangedOriginCannotPublishMixedClassifierPagesAndNextReadStartsClean() { var pid=Random.Shared.Next(190000000,200000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(8)); var server=Task.Run(async()=> { await Serve(pid,Page("42",2,false,Row(First,("civilization",Civilization()),("services",Services()))),stop.Token); await Serve(pid,Page("43",2,true,Row(First+1,("civilization",Civilization(key:"human")),("services",Services(1023)))),stop.Token); await Serve(pid,new{cancelled=true},stop.Token,r=>Check.Equal("galaxy.facts.cancel",r.GetProperty("op").GetString())); await Serve(pid,Page("43",1,true,Row(First,("civilization",null),("services",null))),stop.Token); }); var catalog=new NativeGalaxyFilterFactsCatalog(pid);var rejected=false; try{await catalog.ReadAsync([First,First+1],stop.Token,includeCivilization:true,includeServices:true);}catch(InvalidDataException){rejected=true;} Check.That(rejected);var fresh=await catalog.ReadAsync([First],stop.Token,includeCivilization:true,includeServices:true);await server; Check.Equal(43L,fresh.OriginSystemAddress);Check.That(fresh.Rows.Single() is{Civilization:null,ServiceMask:null,ServicesDefaulted:null}); } private static GalaxySystemData Data(GalaxyNativeFilterFact row,string account="F1",int pid=42,long origin=100)=>new() {NativeFilterFacts=new(account,pid,origin){Civilization=row.Civilization,CivilizationRequested=row.CivilizationRequested, ServiceMask=row.ServiceMask,ServicesRequested=row.ServicesRequested,ServicesDefaulted=row.ServicesDefaulted}}; [Test] public async Task IndependentWireUpdatesPreserveUnrequestedFactsButExplicitUnknownClearsThem() { var initial=await ReadOne(Row(First,("civilization",Civilization()),("services",Services(0,true))),true,true); var civilizationOnly=await ReadOne(Row(First,("civilization",Civilization(key:"human"))),true,false); var serviceOnly=await ReadOne(Row(First,("services",Services(513))),false,true); var explicitUnknown=await ReadOne(Row(First,("civilization",null),("services",null)),true,true); var state=Data(initial).Merge(Data(civilizationOnly)); Check.That(state.NativeFilterFacts is{CivilizationRequested:true,Civilization.Key:"human",ServicesRequested:true,ServiceMask:0,ServicesDefaulted:true}); state=state.Merge(Data(serviceOnly)); Check.That(state.NativeFilterFacts is{CivilizationRequested:true,Civilization.Key:"human",ServicesRequested:true,ServiceMask:513,ServicesDefaulted:false}); state=state.Merge(Data(explicitUnknown)); Check.That(state.NativeFilterFacts is{CivilizationRequested:true,Civilization:null,ServicesRequested:true,ServiceMask:null,ServicesDefaulted:null}); } [Test] public async Task AccountProcessAndOriginResetsNeverCarryUnrequestedClassifierFacts() { var original=Data(await ReadOne(Row(First,("civilization",Civilization()),("services",Services())),true,true)); var unrelated=await ReadOne(Row(First),false,false); foreach(var context in new[]{(Account:"F2",Pid:42,Origin:100L),(Account:"F1",Pid:43,Origin:100L),(Account:"F1",Pid:42,Origin:101L)}) { var reset=original.ForNativeContext(context.Account,context.Pid,context.Origin);Check.That(reset.NativeFilterFacts is null); var replaced=original.Merge(Data(unrelated,context.Account,context.Pid,context.Origin)); Check.That(replaced.NativeFilterFacts is{CivilizationRequested:false,Civilization:null,ServicesRequested:false,ServiceMask:null,ServicesDefaulted:null}); } } }