2026-10-16 18:47:30 +02:00
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
using GameCult.Caching ;
using GameCult.Caching.MessagePack ;
using Xunit ;
// AetherDb's product-lines command (cut product-lines-weapons), run on a scratch copy of the shipped catalog with the weapon
// line's twenty products removed from it. Apply must write what the shipped catalog holds, only its own records, finish a
// catalog that holds part of the line, refuse a name that is another product's, and accept a maker that already sells the design.
public sealed class ProductLinesCommandTests : IDisposable
{
private static readonly string [ ] Names = ProductLinesCommand . Table ( "weapons" ) . Select ( r = > r . Name ) . ToArray ( ) ;
private static readonly string [ ] Line = { "weapons" } ;
private static readonly string [ ] LineApply = { "weapons" , "apply" } ;
private readonly string _root = Path . Combine ( Path . GetTempPath ( ) , "product-lines-command-" + Guid . NewGuid ( ) . ToString ( "N" ) ) ;
private string CatalogPath = > Path . Combine ( _root , "GameData" , "Aetheria.cc" ) ;
// The rows as the shipped catalog holds them, taken before they are removed from the scratch copy.
private readonly string [ ] _shipped ;
2026-10-16 19:28:00 +02:00
private readonly string [ ] _shippedKeys ;
2026-10-16 18:47:30 +02:00
private static string Describe ( CultCache cache , FactionProductData product ) = >
$"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " +
string . Join ( "," , product . Roles . OrderBy ( r = > r . Role , StringComparer . Ordinal ) . Select ( r = > $"{r.Role}:{r.Mean}:{r.StandardDeviation}" ) ) ;
public ProductLinesCommandTests ( )
{
Directory . CreateDirectory ( Path . Combine ( _root , "GameData" ) ) ;
Directory . CreateDirectory ( Path . Combine ( _root , "Assets" , "Resources" ) ) ;
var shipped = AetherDb . FindRoot ( ) ;
File . Copy ( Path . Combine ( shipped , "GameData" , "Aetheria.cc" ) , CatalogPath ) ;
File . Copy ( Path . Combine ( shipped , "Assets" , "Resources" , "Settings.asset" ) , Path . Combine ( _root , "Assets" , "Resources" , "Settings.asset" ) ) ;
using var cache = Open ( ) ;
var products = cache . GetAll < FactionProductData > ( ) . Where ( p = > Names . Contains ( p . Name ) ) . ToArray ( ) ;
Assert . Equal ( Names . Length , products . Length ) ;
_shipped = products . Select ( p = > Describe ( cache , p ) ) . OrderBy ( x = > x , StringComparer . Ordinal ) . ToArray ( ) ;
2026-10-16 19:28:00 +02:00
_shippedKeys = products . Select ( p = > cache . RefOf ( p ) . Key . Value ) . OrderBy ( x = > x , StringComparer . Ordinal ) . ToArray ( ) ;
2026-10-16 18:47:30 +02:00
foreach ( var product in products ) Assert . True ( cache . Remove ( cache . RefOf ( product ) . Key ) ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
public void Dispose ( ) = > Directory . Delete ( _root , true ) ;
private CultCache Open ( )
{
var cache = new CultCache ( TestCatalog . Registry ( ) ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( CatalogPath ) , AetheriaStores . CatalogTypes ) ;
return cache ;
}
private string [ ] Line20 ( )
{
using var cache = Open ( ) ;
return cache . GetAll < FactionProductData > ( ) . Where ( p = > Names . Contains ( p . Name ) ) . Select ( p = > Describe ( cache , p ) ) . OrderBy ( x = > x , StringComparer . Ordinal ) . ToArray ( ) ;
}
2026-10-16 19:28:00 +02:00
private string [ ] Line20Keys ( )
{
using var cache = Open ( ) ;
return cache . GetAll < FactionProductData > ( ) . Where ( p = > Names . Contains ( p . Name ) ) . Select ( p = > cache . RefOf ( p ) . Key . Value ) . OrderBy ( x = > x , StringComparer . Ordinal ) . ToArray ( ) ;
}
2026-10-16 18:47:30 +02:00
[Fact]
public void A_dry_run_writes_nothing_and_apply_lands_the_shipped_rows_once ( )
{
var before = File . ReadAllBytes ( CatalogPath ) ;
Assert . Empty ( Line20 ( ) ) ;
Assert . Equal ( 0 , ProductLinesCommand . Run ( Line , _root ) ) ;
Assert . Equal ( before , File . ReadAllBytes ( CatalogPath ) ) ;
Assert . Empty ( Line20 ( ) ) ;
Assert . Equal ( 0 , ProductLinesCommand . Run ( LineApply , _root ) ) ;
var landed = File . ReadAllBytes ( CatalogPath ) ;
Assert . NotEqual ( before , landed ) ;
2026-10-16 19:28:00 +02:00
// The command derives each record's key from the product's name, so applying the table again lands the keys the
// shipped catalog holds.
2026-10-16 18:47:30 +02:00
Assert . Equal ( _shipped , Line20 ( ) ) ;
2026-10-16 19:28:00 +02:00
Assert . Equal ( _shippedKeys , Line20Keys ( ) ) ;
2026-10-16 18:47:30 +02:00
// A second apply finds everything authored and writes nothing; so does a dry run.
Assert . Equal ( 0 , ProductLinesCommand . Run ( LineApply , _root ) ) ;
Assert . Equal ( landed , File . ReadAllBytes ( CatalogPath ) ) ;
Assert . Equal ( 0 , ProductLinesCommand . Run ( Line , _root ) ) ;
Assert . Equal ( landed , File . ReadAllBytes ( CatalogPath ) ) ;
}
// Re-spell every record's payload in a longer, equally valid MessagePack form (an array16 header for the fixarray one). A
// write that re-encodes records it did not author shows as changed bytes; one that lands onto the file as it is leaves them.
private int WidenEveryPayload ( )
{
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( CatalogPath ) ) ;
var widened = 0 ;
foreach ( var record in snapshot . Records )
{
var payload = record . Payload ;
if ( payload . Length = = 0 | | ( payload [ 0 ] & 0xF0 ) ! = 0x90 ) continue ;
record . Payload = new byte [ ] { 0xDC , 0x00 , ( byte ) ( payload [ 0 ] & 0x0F ) } . Concat ( payload . Skip ( 1 ) ) . ToArray ( ) ;
widened + + ;
}
File . WriteAllBytes ( CatalogPath , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
return widened ;
}
private Dictionary < string , CultPersistedRecord > Records ( ) = >
CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( CatalogPath ) ) . Records . ToDictionary ( r = > r . Key , StringComparer . Ordinal ) ;
[Fact]
public void Apply_writes_only_the_records_it_authors ( )
{
Assert . True ( WidenEveryPayload ( ) > 100 , "the scratch catalog should hold many records in the widened spelling" ) ;
var before = Records ( ) ;
Assert . Equal ( 0 , ProductLinesCommand . Run ( LineApply , _root ) ) ;
var after = Records ( ) ;
Assert . Equal ( before . Count + Names . Length , after . Count ) ;
foreach ( var ( key , record ) in before )
{
Assert . True ( after . TryGetValue ( key , out var kept ) , "a record the command did not author is gone" ) ;
Assert . Equal ( record . SchemaId , kept . SchemaId ) ;
Assert . Equal ( record . StoredAt , kept . StoredAt ) ;
Assert . True ( record . Payload . AsSpan ( ) . SequenceEqual ( kept . Payload ) , "a record the command did not author has changed bytes" ) ;
}
}
[Fact]
public void Apply_finishes_a_catalog_that_holds_part_of_the_line ( )
{
Assert . Equal ( 0 , ProductLinesCommand . Run ( LineApply , _root ) ) ;
using ( var cache = Open ( ) )
{
foreach ( var product in cache . GetAll < FactionProductData > ( ) . Where ( p = > p . Name = = "Merlin" | | p . Name = = "Tripwire" ) . ToArray ( ) )
Assert . True ( cache . Remove ( cache . RefOf ( product ) . Key ) ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
Assert . Equal ( Names . Length - 2 , Line20 ( ) . Length ) ;
Assert . Equal ( 0 , ProductLinesCommand . Run ( LineApply , _root ) ) ;
Assert . Equal ( _shipped , Line20 ( ) ) ;
}
[Fact]
public void Apply_refuses_a_row_name_that_is_another_products_and_writes_nothing ( )
{
using ( var cache = Open ( ) )
{
var other = cache . GetAll < WeaponItemData > ( ) . First ( w = > w . Name = = "Light Machine Gun" ) ;
var maker = cache . GetAll < Faction > ( ) . First ( f = > f . ShortName = = "Zhestokost" ) ;
cache . Upsert ( new FactionProductData { Name = "Sparrow" , Design = new CultRecordRef < CraftedItemData > ( cache . RefOf ( other ) . Key ) , Manufacturer = cache . RefOf ( maker ) } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
var conflicted = File . ReadAllBytes ( CatalogPath ) ;
Assert . Throws < InvalidOperationException > ( ( ) = > ProductLinesCommand . Run ( LineApply , _root ) ) ;
Assert . Equal ( conflicted , File . ReadAllBytes ( CatalogPath ) ) ;
}
// Ruling maker-design-tiers-allowed: a maker that already sells a design may sell another product of it.
[Fact]
public void Apply_accepts_a_maker_that_already_sells_the_design ( )
{
using ( var cache = Open ( ) )
{
var laser = cache . GetAll < WeaponItemData > ( ) . First ( w = > w . Name = = "Light Laser" ) ;
var lucent = cache . GetAll < Faction > ( ) . First ( f = > f . ShortName = = "Lucent" ) ;
cache . Upsert ( new FactionProductData { Name = "Scratch tier" , Design = new CultRecordRef < CraftedItemData > ( cache . RefOf ( laser ) . Key ) , Manufacturer = cache . RefOf ( lucent ) } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
Assert . Equal ( 0 , ProductLinesCommand . Run ( LineApply , _root ) ) ;
Assert . Equal ( _shipped , Line20 ( ) ) ;
using var after = Open ( ) ;
Assert . Contains ( after . GetAll < FactionProductData > ( ) , p = > p . Name = = "Scratch tier" ) ;
}
2026-10-16 20:50:47 +02:00
// Master's committed catalog holds records written on a schema older than today's (HullData lost a member after the last
// catalog write). The scratch copy gets that shape: the hull records point at an earlier schema id the loader resolves to
// the current type.
private string MakeHullsStale ( )
{
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( CatalogPath ) ) ;
var current = snapshot . SchemaCatalog . Single ( e = > e . SchemaName = = "aetheria.hulldata" ) ;
var stale = new CultSchemaCatalogEntry
{
SchemaId = "sha256:earlier-hulldata" , SchemaName = current . SchemaName , SchemaVersion = current . SchemaVersion ,
ContentHash = "sha256:earlier-hulldata" , CanonicalSchemaJson = current . CanonicalSchemaJson , Members = current . Members ,
} ;
snapshot . SchemaCatalog = snapshot . SchemaCatalog . Append ( stale ) . ToArray ( ) ;
var hulls = snapshot . Records . Where ( r = > r . SchemaId = = current . SchemaId ) . ToArray ( ) ;
Assert . NotEmpty ( hulls ) ;
foreach ( var hull in hulls ) hull . SchemaId = stale . SchemaId ;
File . WriteAllBytes ( CatalogPath , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
return stale . SchemaId ;
}
[Fact]
public void Apply_lands_on_a_catalog_that_holds_records_of_an_older_schema_and_replays_to_the_same_bytes ( )
{
var stale = MakeHullsStale ( ) ;
var before = Records ( ) ;
Assert . Equal ( 0 , ProductLinesCommand . Run ( LineApply , _root ) ) ;
var landed = File . ReadAllBytes ( CatalogPath ) ;
Assert . Equal ( _shipped , Line20 ( ) ) ;
var after = Records ( ) ;
foreach ( var ( key , record ) in before )
Assert . True ( record . Payload . AsSpan ( ) . SequenceEqual ( after [ key ] . Payload ) & & record . SchemaId = = after [ key ] . SchemaId , "a record the command did not author changed" ) ;
Assert . Contains ( after . Values , r = > r . SchemaId = = stale ) ;
Assert . Equal ( 0 , ProductLinesCommand . Run ( LineApply , _root ) ) ;
Assert . Equal ( landed , File . ReadAllBytes ( CatalogPath ) ) ;
}
private void AddProduct ( string name , string design , string maker )
{
using var cache = Open ( ) ;
var designRecord = cache . GetAll < WeaponItemData > ( ) . First ( w = > w . Name = = design ) ;
var makerRecord = cache . GetAll < Faction > ( ) . First ( f = > f . ShortName = = maker ) ;
cache . Upsert ( new FactionProductData { Name = name , Design = new CultRecordRef < CraftedItemData > ( cache . RefOf ( designRecord ) . Key ) , Manufacturer = cache . RefOf ( makerRecord ) } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
// Sparrow is the Light Laser by Finch. A product of that name is the row only when design and maker both match.
[Theory]
[InlineData("Sparrow", "Light Machine Gun", "Finch")]
[InlineData("Sparrow", "Light Laser", "Zhestokost")]
[InlineData("sparrow", "Light Laser", "Finch")]
public void Apply_refuses_a_name_unless_design_and_maker_both_match_it_ignoring_case ( string name , string design , string maker )
{
AddProduct ( name , design , maker ) ;
var conflicted = File . ReadAllBytes ( CatalogPath ) ;
Assert . Throws < InvalidOperationException > ( ( ) = > ProductLinesCommand . Run ( LineApply , _root ) ) ;
Assert . Equal ( conflicted , File . ReadAllBytes ( CatalogPath ) ) ;
}
[Fact]
public void Apply_refuses_a_row_name_that_is_a_designs_and_writes_nothing ( )
{
using ( var cache = Open ( ) )
{
cache . Upsert ( new WeaponItemData { Name = "Tripwire" } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
var conflicted = File . ReadAllBytes ( CatalogPath ) ;
Assert . Throws < InvalidOperationException > ( ( ) = > ProductLinesCommand . Run ( LineApply , _root ) ) ;
Assert . Equal ( conflicted , File . ReadAllBytes ( CatalogPath ) ) ;
}
// The command is reached as AetherDb's product-lines word, with the line after it; the shipped catalog holds the line.
[Fact]
public void The_product_lines_word_runs_the_named_line_and_refuses_an_unknown_one ( )
{
Assert . Equal ( 0 , Program . Main ( new [ ] { "product-lines" , "weapons" } ) ) ;
Assert . Equal ( 1 , Program . Main ( new [ ] { "product-lines" , "nonesuch" } ) ) ;
}
2026-10-16 18:47:30 +02:00
}