using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; using Xunit; // tools/blender/aetheria_ships/ship_cc.py edits a ship file's HullData and ShipAuthoring records by MessagePack slot // number and by hardpoint member position. These tests bind that file to the C# types that own the numbers: any renumbered, renamed or // reordered member, enum value, or nested array shape fails here instead of corrupting a ship the next time Blender saves. public sealed class ShipSchemaPinTests { private static readonly string Source = File.ReadAllText(Locate("tools", "blender", "aetheria_ships", "ship_cc.py")); [Theory] [InlineData("MODEL_ASSET_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.ModelAsset))] [InlineData("ANCHORS_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.Anchors))] [InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))] [InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))] [InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))] [InlineData("HULL_TYPE_SLOT", typeof(HullData), nameof(HullData.HullType))] [InlineData("PRODUCT_MANUFACTURER_SLOT", typeof(FactionProductData), nameof(FactionProductData.Manufacturer))] public void PythonSlotConstantsAreTheKeysOfTheMembersTheyNameInTheirComments(string constant, Type owner, string member) { Assert.Equal(KeyOf(owner, member), Integer(constant)); Assert.Matches(new Regex($@"^{constant} = \d+ # \w+\.{member}\r?$", RegexOptions.Multiline), Source); } [Fact] public void PythonSchemaNamesAreTheDocumentsTheyNameAndTheRetiredSlotIsNotReused() { Assert.Equal(typeof(ShipAuthoring).GetCustomAttribute().SchemaName, Text("SCHEMA")); Assert.Equal(typeof(HullData).GetCustomAttribute().SchemaName, Text("HULL_SCHEMA")); Assert.Equal(typeof(FactionProductData).GetCustomAttribute().SchemaName, Text("PRODUCT_SCHEMA")); Assert.Equal(1, Integer("RETIRED_HULL_SLOT")); // Key 1 held the embedded hull until S1. A later member must not take it, or an old file would decode as it. Assert.DoesNotContain(typeof(ShipAuthoring).GetMembers(BindingFlags.Public | BindingFlags.Instance), member => member.GetCustomAttribute()?.IntKey == 1); } // HullData.Visual is the one binding between a hull and its visual; its slot is the wire contract. [Fact] public void HullVisualIsKey32() { Assert.Equal(32, KeyOf(typeof(HullData), nameof(HullData.Visual))); var taken = typeof(HullData).GetMembers(BindingFlags.Public | BindingFlags.Instance) .Select(member => member.GetCustomAttribute()?.IntKey).Where(key => key != null).ToArray(); Assert.Equal(taken.Length, taken.Distinct().Count()); } [Fact] public void PythonHardpointMembersAreHardpointDataMembersAtTheirOwnPositions() { var members = Strings("HARDPOINT_MEMBERS"); Assert.Equal(7, members.Length); for (var slot = 0; slot < members.Length; slot++) Assert.Equal(slot, KeyOf(typeof(HardpointData), members[slot])); } [Fact] public void PythonAnchorMembersAreShipAnchorMembersAtTheirPositions() { var members = Strings("ANCHOR_MEMBERS"); Assert.Equal(5, members.Length); for (var slot = 0; slot < members.Length; slot++) Assert.Equal(slot, KeyOf(typeof(ShipAnchor), members[slot])); } // ship_cc.py edits slots 2 to 4 of a ship and the first five slots of an anchor row, and carries every later slot (replace_visual // by anchor Id). The rig lives in those later slots; their numbers are the contract that carry-through keeps. [Fact] public void TheRigLivesInTheSlotsPastTheOnesPythonEdits() { Assert.Equal(Integer("SCHEMATIC_LINES_SLOT") + 1, KeyOf(typeof(ShipAuthoring), nameof(ShipAuthoring.Joints))); Assert.Equal(Strings("ANCHOR_MEMBERS").Length, KeyOf(typeof(ShipAnchor), nameof(ShipAnchor.Joint))); } // New Ship refuses a bad ship ID before it touches anything, with ship_cc.valid_ship_id; these two constants are that // rule's data, so the C# rule that create and Validate share must agree with them on every character and every stem. [Fact] public void PythonShipIdConstantsAreTheRuleRequireShipIdEnforces() { var allowed = Text("SHIP_ID_CHARS"); var reserved = Strings("RESERVED_SHIP_IDS"); for (var code = 0; code < 256; code++) { var c = (char)code; Assert.Equal(allowed.Contains(c), Accepts("a" + c + "a")); Assert.Equal(allowed.Contains(c) && c != '.' && c != '_' && c != '-', Accepts(c + "a")); } var stems = new List(); for (var a = 'a'; a <= 'z'; a++) for (var b = 'a'; b <= 'z'; b++) for (var c = 'a'; c <= 'z'; c++) stems.Add($"{a}{b}{c}"); for (var digit = 0; digit <= 9; digit++) stems.AddRange(new[] { $"com{digit}", $"lpt{digit}" }); foreach (var stem in stems) { Assert.Equal(!reserved.Contains(stem), Accepts(stem)); Assert.Equal(!reserved.Contains(stem), Accepts(stem + ".x")); } } // A faction language file echoes its Faction's record key as the last element of its lang (hull_source.Language.maker); Package // compares it to each product's Manufacturer. The catalog owns the key, so a regenerated record fails here, not at Package. [Theory] [InlineData("zhestokost.scad", "Zhestokost")] [InlineData("pirate.scad", "Pirates")] [InlineData("rnd.scad", "Rossum & Douglas")] public void FactionLanguageFilesNameTheirFactionsRecordKeyInTheCatalog(string file, string faction) { var scad = File.ReadAllText(Locate("tools", "blender", "aetheria_ships", "factions", file)); var literal = Regex.Match(scad, @"^[A-Z]+_LANG = \[[^;]*""([^""]+)""\];", RegexOptions.Multiline); Assert.True(literal.Success, $"{file} has no *_LANG with a maker as its last element"); var db = AetherDb.Open(root: AetherDb.FindRoot()); try { var record = Assert.Single(db.Cache.GetAll().Where(f => f.Name == faction)); Assert.Equal(db.Cache.RefOf(record).Key.ToString(), literal.Groups[1].Value); } finally { db.Cache.Dispose(); } } private static bool Accepts(string id) { try { ShipAuthoringStore.RequireShipId(id); return true; } catch (InvalidOperationException) { return false; } } [Fact] public void PythonEnumNamesAreTheEnumsInValueOrder() { Assert.Equal(EnumNames(), Strings("HARDPOINT_TYPE_NAMES")); Assert.Equal(EnumNames(), Strings("ROTATION_NAMES")); Assert.Equal(EnumNames(), Strings("HULL_TYPE_NAMES")); } [Fact] public void SerializedShipHasTheNestedArrayShapesPythonIndexes() { var (hull, visual) = ShipAuthoringTests.Fixture(); var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(ShipAuthoring).Assembly); object[] Body(object document) => Assert.IsType(MessagePackSerializer.Deserialize( MessagePackSerializer.Serialize(document.GetType(), document, options), MessagePackSerializerOptions.Standard)); var body = Body(visual); Assert.Equal("mod.skiff", body[0]); var lines = Assert.IsType(body[Integer("SCHEMATIC_LINES_SLOT")]); Assert.Equal("Hull", Assert.IsType(Assert.Single(lines))[0]); var hullBody = Body(hull); // A schematic is [[width, height, cells]] with cells flattened x-major (index x * height + y). var hullShape = Assert.IsType(Assert.IsType(hullBody[Integer("HULL_SHAPE_SLOT")])[0]); Assert.Equal(new object[] { 2, 2 }, hullShape.Take(2).Select(value => (object)Convert.ToInt32(value))); Assert.Equal(new[] { true, false, true, false }, Assert.IsType(hullShape[2]).Cast()); var hardpoints = Assert.IsType(hullBody[Integer("HULL_HARDPOINTS_SLOT")]); var row = Assert.IsType(Assert.Single(hardpoints)); var members = Strings("HARDPOINT_MEMBERS"); Assert.True(row.Length >= members.Length); Assert.Equal("thruster.port", row[Array.IndexOf(members, "Transform")]); var footprint = Assert.IsType(Assert.IsType(row[Array.IndexOf(members, "Shape")])[0]); Assert.Equal(3, footprint.Length); Assert.Equal(new[] { true }, Assert.IsType(footprint[2]).Cast()); Assert.Equal(new[] { 1, 0 }, Assert.IsType(row[Array.IndexOf(members, "Position")]).Select(value => Convert.ToInt32(value))); } private static int KeyOf(Type owner, string member) { var found = owner.GetMember(member, BindingFlags.Public | BindingFlags.Instance).Single(); return Assert.IsType(found.GetCustomAttribute().IntKey); } private static int Integer(string constant) => int.Parse(Regex.Match(Source, $@"^{constant} = (\d+)", RegexOptions.Multiline).Groups[1].Value); private static string Text(string constant) { var match = Regex.Match(Source, $@"^{constant} = ""([^""]*)""", RegexOptions.Multiline); Assert.True(match.Success, $"{constant} is not a string in ship_cc.py"); return match.Groups[1].Value; } private static string[] Strings(string constant) { var block = Regex.Match(Source, $@"^{constant} = \((.*?)\)\r?$", RegexOptions.Multiline | RegexOptions.Singleline); Assert.True(block.Success, $"{constant} is not a tuple in ship_cc.py"); return Regex.Matches(block.Groups[1].Value, "\"([^\"]*)\"").Select(match => match.Groups[1].Value).ToArray(); } private static string[] EnumNames() where T : struct, Enum { var values = Enum.GetValues().OrderBy(value => Convert.ToInt32(value)).ToArray(); Assert.Equal(Enumerable.Range(0, values.Length), values.Select(value => Convert.ToInt32(value))); return values.Select(value => value.ToString()).ToArray(); } private static string Locate(params string[] relative) { for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory != null; directory = directory.Parent) { var candidate = Path.Combine(new[] { directory.FullName }.Concat(relative).ToArray()); if (File.Exists(candidate)) return candidate; } throw new FileNotFoundException("Repository file not found above the test binary: " + Path.Combine(relative)); } }