using System.IO.Compression; using System.Text; using System.Text.Json; using XFEExtension.NetCore.XUnit.Attributes; using XFEEDToolBox.Core.Data; using XFEEDToolBox.Core.Models; namespace XFEEDToolBox.Test; [TestFixture] public class DataTests { [Test] public void ParsesObservedMarketWithoutChangingPriceMeaning() { using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "market-observed.json"))); var market = MarketParser.ParseLocalMarket(doc.RootElement); Check.Equal(3224123392L, market.Station.MarketId); Check.Equal("platinum", market.Quotes[0].Commodity); Check.Equal(0L, market.Quotes[0].BuyPrice); Check.Equal(223785L, market.Quotes[0].SellPrice); Check.Equal(1520L, market.Quotes[0].Demand); } [Test] public void NormalizesRenamedCommodityByIdAndDisplayName() { Check.Equal("agriculturalmedicines", CommodityCatalog.Key("Agri-Medicines", 128049208)); Check.Equal("agriculturalmedicines", CommodityCatalog.Key("Agri-Medicines")); } [Test] public void MissingTimestampIsNotMadeFresh() => Check.Throws(() => MarketParser.ParseLocalMarket(Check.Json("{\"MarketID\":1,\"Items\":[]}"))); [Test] public void MissingItemArrayIsNotAnEmptySnapshot() => Check.Throws(() => MarketParser.ParseLocalMarket(Check.Json("{\"timestamp\":\"2026-10-20T12:00:00Z\",\"MarketID\":1}"))); [Test] public void RejectsEddnTestAndLegacySchemas() { Check.That(MarketParser.ParseEddn(Check.Json("{\"$schemaRef\":\"https://eddn.edcd.io/schemas/commodity/3/test\"}")) == null); Check.That(MarketParser.ParseEddn(Check.Json("{\"$schemaRef\":\"https://eddn.edcd.io/schemas/commodity/3\",\"header\":{\"gameversion\":\"3.8.1\"}}")) == null); } [Test] public void DecodesZlibEddnFrame() { using var output = new MemoryStream(); using (var zip = new ZLibStream(output, CompressionLevel.Fastest, true)) zip.Write(Encoding.UTF8.GetBytes("{\"ok\":true}")); using var doc = EddnSubscriber.Decode(output.ToArray()); Check.That(doc.RootElement.GetProperty("ok").GetBoolean()); } [Test] public void OversizedEddnFrameIsRejected() => Check.Throws(() => EddnSubscriber.Decode(new byte[4 * 1024 * 1024 + 1])); [Test] public async Task AtomicSnapshotDeletesDisappearedGoods() { using var temp = new TemporaryDirectory(); var r = temp.Repository(); var t = DateTimeOffset.UtcNow.AddMinutes(-2); await r.SaveSnapshotAsync(new(Check.Station(1), [Check.Quote(1, "a", 1, 2), Check.Quote(1, "b", 2, 3)], t, DataSource.Journal)); await r.SaveSnapshotAsync(new(Check.Station(1), [Check.Quote(1, "a", 1, 2)], t.AddMinutes(1), DataSource.Journal)); Check.Equal(1L, r.Statistics().Quotes); } [Test] public async Task HistoricalImportCannotOverwriteNewerMarket() { using var temp = new TemporaryDirectory(); var r = temp.Repository(); var t = DateTimeOffset.UtcNow; await r.SaveSnapshotAsync(new(Check.Station(1), [Check.Quote(1, "a", 10, 20)], t, DataSource.Eddn)); Check.That(!await r.SaveSnapshotAsync(new(Check.Station(1), [Check.Quote(1, "a", 1, 2)], t.AddDays(-1), DataSource.Capture))); Check.Equal(10L, r.Search("a", 1).Single().Quote.BuyPrice); } [Test] public async Task EmptyObservedMarketClearsOldQuotes() { using var temp = new TemporaryDirectory(); var r = temp.Repository(); var t = DateTimeOffset.UtcNow.AddSeconds(-1); await r.SaveSnapshotAsync(new(Check.Station(1), [Check.Quote(1, "a", 10, 20)], t, DataSource.Journal)); await r.SaveSnapshotAsync(new(Check.Station(1), [], t.AddSeconds(1), DataSource.Journal)); Check.Equal(0L, r.Statistics().Quotes); } [Test] public async Task SystemMetadataSurvivesQuoteUpdate() { using var temp = new TemporaryDirectory(); var r = temp.Repository(); await r.SaveStationAsync(Check.Station(1)); await r.SaveSnapshotAsync(new(new() { MarketId = 1, Name = "Station 1", SystemName = "System 1", UpdatedAt = DateTimeOffset.UtcNow }, [], DateTimeOffset.UtcNow, DataSource.Eddn)); var s = r.GetStation(1)!; Check.That(s.Position != null); Check.Equal(SecurityLevel.High, s.Security); Check.Equal(LandingPad.Large, s.MaxLandingPad); } [Test] public async Task ChangedCarrierSystemDoesNotReuseOldCoordinates() { using var temp = new TemporaryDirectory(); var r = temp.Repository(); await r.SaveStationAsync(Check.Station(1) with { IsFleetCarrier = true, UpdatedAt = DateTimeOffset.UtcNow.AddHours(-1) }); await r.SaveStationAsync(new() { MarketId = 1, Name = "Carrier", SystemName = "New system", IsFleetCarrier = true, UpdatedAt = DateTimeOffset.UtcNow }); Check.That(r.GetStation(1)!.Position == null); } [Test] public async Task QueryIsParameterised() { using var temp = new TemporaryDirectory(); var r = temp.Repository(); await r.SaveSnapshotAsync(new(Check.Station(1), [Check.Quote(1, "a", 10, 20)], DateTimeOffset.UtcNow, DataSource.Journal)); Check.Equal(0, r.Search("' OR 1=1 --", null).Count); Check.Equal(1L, r.Statistics().Quotes); } [Test] public async Task RegionAppliesSphericalDistance() { using var temp = new TemporaryDirectory(); var r = temp.Repository(); await r.SaveStationAsync(Check.Station(1)); await r.SaveStationAsync(Check.Station(2, 10)); await r.SaveStationAsync(Check.Station(3, 90)); Check.Equal(2, r.LoadRegion(1, 20).Stations.Count); } [Test] public async Task ImportsSpanshSymbolAndMarketTimestamp() { using var temp = new TemporaryDirectory(); var r = temp.Repository(); using var http = new HttpClient(); var importer = new SpanshImporter(r, http); var system = Check.Json("""{"name":"A","security":"High","coords":{"x":0,"y":0,"z":0},"stations":[{"id":1,"name":"S","distanceToArrival":10,"landingPads":{"large":1},"updateTime":"2026-10-20T01:00:00Z","market":{"updateTime":"2026-10-19T01:00:00Z","commodities":[{"name":"Agri-Medicines","symbol":"AgriculturalMedicines","buyPrice":1,"sellPrice":2,"supply":3,"demand":4}]}}]}"""); var result = await importer.SaveSystemAsync(system); Check.Equal(1, result.Quotes); var q = r.Search("", 1).Single().Quote; Check.Equal("agriculturalmedicines", q.Commodity); Check.Equal(DateTimeOffset.Parse("2026-10-19T01:00:00Z"), q.CollectedAt); } } [TestFixture] public class PlayerTests { [Test] public void StationServiceAvailabilityNeverLeaksAcrossUndockingAnotherStationOrLogin() { var projector=new PlayerProjector(); projector.Apply(Check.Event(new{@event="Docked",MarketID=1,StationServices=new[]{"Refuel","Repair","Rearm","repair"}})); Check.That(projector.State.StationServicesKnown);Check.Equal(3,projector.State.StationServices.Count); projector.Apply(Check.Event(new{@event="Undocked"}));Check.That(!projector.State.StationServicesKnown&&projector.State.StationServices.Count==0); projector.Apply(Check.Event(new{@event="Docked",MarketID=2}));Check.That(!projector.State.StationServicesKnown&&projector.State.StationServices.Count==0); projector.Apply(Check.Event(new{@event="Location",Docked=true,MarketID=2,StationServices=Array.Empty()})); Check.That(projector.State.StationServicesKnown&&projector.State.StationServices.Count==0); projector.Apply(Check.Event(new{@event="Docked",MarketID=2,StationServices=new[]{"refuel"}})); projector.Apply(Check.Event(new{@event="LoadGame",FID="F1"}));Check.That(!projector.State.StationServicesKnown&&projector.State.StationServices.Count==0); } [Test] public void BuyAndSellUpdateBothCashAndInventory() { var p = new PlayerProjector(); p.Apply(Check.Json("""{"event":"LoadGame","FID":"test","Credits":1000} """)); p.Apply(Check.Json("""{"event":"MarketBuy","Type":"gold","Count":3,"TotalCost":300} """)); p.Apply(Check.Json("""{"event":"MarketSell","Type":"gold","Count":1,"TotalSale":200} """)); Check.Equal(900m, p.State.Credits!.Value); Check.Equal(2, p.State.Inventory.Single().Count); Check.Equal(2, p.State.CargoCount); } [Test] public void CommanderSwitchDoesNotKeepPreviousCargo() { var p = new PlayerProjector(); p.Apply(Check.Json("""{"event":"LoadGame","FID":"a","Credits":1000} """)); p.Apply(Check.Json("""{"event":"Cargo","Inventory":[{"Name":"gold","Count":3}],"Count":3} """)); p.Apply(Check.Json("""{"event":"LoadGame","FID":"b","Credits":50} """)); Check.Equal(0, p.State.Inventory.Count); Check.Equal(50m, p.State.Credits!.Value); } [Test] public void OldCargoFileCannotRollBackNewerTrade() { var p = new PlayerProjector(); p.Apply(Check.Json("""{"timestamp":"2026-10-20T02:00:00Z","event":"MarketBuy","Type":"gold","Count":2,"TotalCost":20} """)); p.Apply(Check.Json("""{"timestamp":"2026-10-20T01:00:00Z","event":"Cargo","Inventory":[],"Count":0} """)); Check.Equal(2, p.State.CargoCount); } [Test] public void CargoPreservesMissionAndStolenFlags() { var p = new PlayerProjector(); p.Apply(Check.Json("""{"event":"Cargo","Count":5,"Inventory":[{"Name":"gold","Count":5,"Stolen":2,"MissionID":42}]}""")); Check.Equal(2, p.State.Inventory[0].Stolen); Check.Equal(42L, p.State.Inventory[0].MissionId!.Value); } [Test] public void UnmodelledFinancialTransactionMarksCashUncertain() { var p = new PlayerProjector(); p.Apply(Check.Json("""{"event":"LoadGame","FID":"a","Credits":100} """)); p.Apply(Check.Json("""{"event":"CarrierBankTransfer"} """)); Check.That(!p.State.CreditsReliable); } [Test] public void ModuleTradeInAndShipPurchaseUseTheirActualPriceFields() { var p = new PlayerProjector(); p.Apply(Check.Json("""{"event":"LoadGame","FID":"test","Credits":1000}""")); p.Apply(Check.Json("""{"event":"ModuleBuy","BuyPrice":200,"SellPrice":50}""")); p.Apply(Check.Json("""{"event":"ShipyardBuy","ShipPrice":300}""")); Check.Equal(550m, p.State.Credits!.Value); Check.That(p.State.CreditsReliable); p.Apply(Check.Json("""{"event":"Repair"}""")); Check.That(!p.State.CreditsReliable); } [Test] public async Task ProhibitedSpanshCommodityUsesTheSameNormalizedIdentityAsItsQuote() { using var temp = new TemporaryDirectory(); var repo = temp.Repository(); using var http = new HttpClient(); await new SpanshImporter(repo, http).SaveSystemAsync(Check.Json("""{"name":"A","stations":[{"id":1,"name":"S","prohibitedCommodities":["Agri-Medicines"],"market":{"updateTime":"2026-10-20T01:00:00Z","commodities":[{"name":"Agri-Medicines","symbol":"AgriculturalMedicines","buyPrice":100,"sellPrice":200,"supply":10,"demand":10}]}}]}""")); Check.That(repo.Search("", 1).Single().Quote.Illegal); } [Test] public void DangerBitIsRecognised() { var p = new PlayerProjector(); p.Apply(Check.Json("""{"event":"Status","Flags":4194304} """)); Check.That(p.State.InDanger); p.Apply(Check.Json("""{"event":"Status","Flags":1} """)); Check.That(!p.State.InDanger); } [Test] public void ReadsRawManufacturedAndEncodedMaterials() { var p = new PlayerProjector(); p.Apply(Check.Json("""{"event":"Materials","Raw":[{"Name":"iron","Count":3}],"Manufactured":[{"Name":"a","Count":4}],"Encoded":[{"Name":"b","Count":5}]}""")); Check.Equal(3, p.State.Materials.Count); Check.Equal(12, p.State.Materials.Sum(x => x.Count)); } [Test] public async Task JournalTailRetainsFragmentedUtf8Line() { using var temp = new TemporaryDirectory(); var path = temp.File("Journal.2026-10-20T010000.01.log"); await File.WriteAllTextAsync(path, ""); await using var monitor = new JournalMonitor(temp.Path); await monitor.StartAsync(); var data = Encoding.UTF8.GetBytes("{\"event\":\"LoadGame\",\"FID\":\"test\",\"Commander\":\"测试指挥官\",\"Credits\":42}\n"); await using (var file = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) await file.WriteAsync(data.AsMemory(0, 61)); await Task.Delay(950); Check.That(monitor.State.Credits == null); await using (var file = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) await file.WriteAsync(data.AsMemory(61)); await Task.Delay(950); Check.Equal("测试指挥官", monitor.State.Commander); Check.Equal(42m, monitor.State.Credits!.Value); } [Test] public async Task InitialReplayDoesNotEmitExecutionEvents() { using var temp = new TemporaryDirectory(); await File.WriteAllTextAsync(temp.File("Journal.2026-10-20T010000.01.log"), "{\"event\":\"MarketBuy\",\"Type\":\"gold\",\"Count\":2}\n"); await using var monitor = new JournalMonitor(temp.Path); var count = 0; monitor.JournalEvent += _ => count++; await monitor.StartAsync(); Check.Equal(0, count); Check.Equal(2, monitor.State.CargoCount); } }