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<JsonException>(() => MarketParser.ParseLocalMarket(Check.Json("{\"MarketID\":1,\"Items\":[]}")));
[Test] public void MissingItemArrayIsNotAnEmptySnapshot() => Check.Throws<JsonException>(() => 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<InvalidDataException>(() => 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<string>()}));
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);
}
}
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<JsonException>(() => MarketParser.ParseLocalMarket(Check.Json("{\"MarketID\":1,\"Items\":[]}")));
[Test] public void MissingItemArrayIsNotAnEmptySnapshot() => Check.Throws<JsonException>(() => 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<InvalidDataException>(() => 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<string>()}));
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);
}
}