Schema model: catalog, field types, references, checker and line writer shared by tools and validator

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cthangoverandClaude Opus 5.5 committed 2026-10-24 10:01:15 +02:00
commit ce15eccb0f
27 files changed
+1038

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
namespace Cthangover.Mods.Schemas.Tests
{
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Mods.Testing;
/// <summary>
/// Merging schema files of several mods: the later mod replaces an action or command
/// whole and a kind's field by key, and adds fields of its own; broken files are reported.
/// </summary>
public class SchemaCatalogTests
{
private const string First = "{\"Actions\":[{\"Id\":\"x.do\",\"Params\":[{\"Key\":\"a\"}]}],\"Kinds\":[{\"Kind\":\"item\",\"Name\":\"k/item\",\"Folder\":\"items\",\"Fields\":[{\"Key\":\"Name\",\"Type\":\"locale\"},{\"Key\":\"Cost\",\"Type\":\"text\"}]}]}";
private const string Second = "{\"actions\":[{\"id\":\"x.do\",\"params\":[{\"key\":\"b\",\"type\":\"int\"}]}],\"kinds\":[{\"kind\":\"ITEM\",\"fields\":[{\"key\":\"cost\",\"type\":\"int\"},{\"key\":\"Equip\"}]}],\"commands\":[{\"id\":\"check\",\"arrow\":\"k/arrow\"}]}";
[ModTest("catalog/later_mod_wins_and_fields_merge")]
static void LaterModWinsAndFieldsMerge(ModTestContext ctx)
{
var catalog = SchemaCatalog.Build(new List<(string, string, string)> { ("mod_a", "schemas/a.json", First), ("mod_b", "schemas/b.json", Second) });
ModAssert.Equal(0, catalog.Errors.Count, "both files parse (keys in any case)");
var action = catalog.Actions["X.DO"];
ModAssert.Equal("mod_b", action.SourceMod, "the later action replaced the earlier");
ModAssert.Equal(1, action.Params.Count, "replaced whole, not merged");
ModAssert.Equal("b", action.Params[0].Key, "the later parameter");
var item = catalog.Kinds["item"];
ModAssert.Equal("k/item", item.Name, "a kind keeps the name nobody replaced");
ModAssert.Equal("items", item.Folder, "and the folder");
ModAssert.Equal(3, item.Fields.Count, "Name, Cost, Equip");
ModAssert.Equal("Name", item.Fields[0].Key, "order kept");
ModAssert.Equal("int", item.Fields[1].Type, "a field with the same key is replaced in place");
ModAssert.Equal("mod_b", item.Fields[1].SourceMod, "and remembers who described it");
ModAssert.Equal("Equip", item.Fields[2].Key, "a new field is appended");
ModAssert.Equal("mod_a", item.Fields[0].SourceMod, "the untouched field keeps its source");
ModAssert.NotNull(catalog.Commands["check"], "commands are read too");
}
[ModTest("catalog/broken_files_are_reported")]
static void BrokenFilesAreReported(ModTestContext ctx)
{
var catalog = SchemaCatalog.Build(new List<(string, string, string)> { ("mod_a", "schemas/a.json", "{ not json"), ("mod_b", "schemas/b.json", First) });
ModAssert.Equal(1, catalog.Errors.Count, "one broken file");
ModAssert.True(catalog.Errors[0].StartsWith("mod_a:schemas/a.json"), "named by mod and path");
ModAssert.NotNull(catalog.Actions["x.do"], "the good file still counts");
}
[ModTest("catalog/loaded_mods_describe_their_actions_and_kinds")]
static void LoadedModsDescribe(ModTestContext ctx)
{
var catalog = SchemaCatalog.Current;
ModAssert.Equal(0, catalog.Errors.Count, "every shipped schema parses: " + string.Join("; ", catalog.Errors));
foreach (var id in new[] { "stat.add", "stat.exp", "equip.put", "item.add", "rpg.battle", "trade.open" })
ModAssert.True(catalog.Actions.ContainsKey(id), $"action {id} is described");
foreach (var id in new[] { "check", "when", "text", "switch_scene" })
ModAssert.True(catalog.Commands.ContainsKey(id), $"command {id} is described");
var item = catalog.FieldsOf("item").Select(f => f.Key).ToList();
ModAssert.True(item.Contains("Cost") && item.Contains("Equip") && item.Contains("Traits"), "items: the tools' own fields and the equipment mod's merged");
var character = catalog.FieldsOf("character").Select(f => f.Key).ToList();
ModAssert.True(character.Contains("Health") && character.Contains("Archetype") && character.Contains("Stats"), "characters: builtin and stats fields merged");
ModAssert.True(catalog.FieldsOf("action").Any(f => f.Key == "Values" && f.Of.Any(o => o.Key == "Formula")), "battle formulas describe action values");
}
}
}
+53
View File
@@ -0,0 +1,53 @@
namespace Cthangover.Mods.Schemas.Tests
{
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Mods.Testing;
/// <summary>What the validator and the palette report about values.</summary>
public class SchemaCheckerTests
{
private static readonly List<FieldSchema> Fields = new()
{
new() { Key = "id", Type = "ref", Kind = "item", Required = true },
new() { Key = "count", Type = "int" },
new() { Key = "rate", Type = "number" },
new() { Key = "mode", Type = "choice", Options = new List<string> { "fast", "slow" } },
new() { Key = "on", Type = "bool" },
new() { Key = "who", Type = "who" },
new() { Key = "enemies", Type = "ref_list", Kind = "character" },
};
private static bool Exists(string kind, string id) => kind == "item" && id == "sword" || kind == "character" && id is "hero" or "wolf";
private static List<string> Codes(Dictionary<string, string> named) => SchemaChecker.CheckNamed("t", Fields, named, Exists).Select(i => (i.IsError ? "E:" : "W:") + i.Code).ToList();
[ModTest("checker/clean_values_pass")]
static void CleanValuesPass(ModTestContext ctx)
{
var codes = Codes(new Dictionary<string, string> { ["id"] = "sword", ["count"] = "-2", ["rate"] = "0.5", ["mode"] = "FAST", ["on"] = "1", ["who"] = "party", ["enemies"] = "hero, wolf" });
ModAssert.Equal(0, codes.Count, "no issue: " + string.Join(",", codes));
}
[ModTest("checker/each_problem_is_named")]
static void EachProblemIsNamed(ModTestContext ctx)
{
ModAssert.True(Codes(new Dictionary<string, string>()).SequenceEqual(new[] { "E:SCHEMA_MISSING_PARAM" }), "missing required");
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "sword", ["nope"] = "1" }).SequenceEqual(new[] { "W:SCHEMA_UNKNOWN_PARAM" }), "unknown parameter");
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "axe" }).SequenceEqual(new[] { "E:SCHEMA_UNKNOWN_REF" }), "unknown entity");
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "sword", ["count"] = "1.5" }).SequenceEqual(new[] { "E:SCHEMA_BAD_VALUE" }), "not a whole number");
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "sword", ["mode"] = "medium" }).SequenceEqual(new[] { "W:SCHEMA_BAD_VALUE" }), "outside the options");
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "sword", ["who"] = "ghost" }).SequenceEqual(new[] { "E:SCHEMA_UNKNOWN_REF" }), "an unknown character as who");
ModAssert.True(Codes(new Dictionary<string, string> { ["id"] = "sword", ["enemies"] = "wolf,bear" }).SequenceEqual(new[] { "E:SCHEMA_UNKNOWN_REF" }), "one unknown in a list");
}
[ModTest("checker/no_callback_skips_references")]
static void NoCallbackSkipsReferences(ModTestContext ctx)
{
var issues = SchemaChecker.CheckNamed("t", Fields, new Dictionary<string, string> { ["id"] = "anything" }, null);
ModAssert.Equal(0, issues.Count, "references are not checked without a callback");
var positional = SchemaChecker.CheckPositional("t", Fields.Take(2).ToList(), new List<string> { "sword", "x" }, Exists);
ModAssert.Equal(1, positional.Count, "positional values are checked by index");
}
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace Cthangover.Mods.Schemas.Tests
{
using System.Text.Json.Nodes;
using Cthangover.Core.Mods.Testing;
/// <summary>Form text and JSON nodes convert into the shapes the engine's readers expect.</summary>
public class SchemaJsonTests
{
[ModTest("json/typed_nodes")]
static void TypedNodes(ModTestContext ctx)
{
ModAssert.Equal("5", SchemaJson.ToNode(new FieldSchema { Type = "int" }, "5").ToJsonString(), "int");
ModAssert.Equal("2.5", SchemaJson.ToNode(new FieldSchema { Type = "number" }, "2.5").ToJsonString(), "number");
ModAssert.Equal("true", SchemaJson.ToNode(new FieldSchema { Type = "bool" }, "1").ToJsonString(), "bool");
ModAssert.Equal("[\"a\",\"b\"]", SchemaJson.ToNode(new FieldSchema { Type = "ref_list" }, "a, b").ToJsonString(), "list of ids");
ModAssert.Equal("str + 1", SchemaJson.ToNode(new FieldSchema { Type = "formula" }, " str + 1 ").GetValue<string>(), "formula text, trimmed");
ModAssert.Null(SchemaJson.ToNode(new FieldSchema { Type = "int" }, " "), "empty removes the key");
ModAssert.Equal("7", SchemaJson.NumberOrText("7").ToJsonString(), "a map value that is a number");
ModAssert.Equal("6 + level", SchemaJson.NumberOrText("6 + level").GetValue<string>(), "a map value that is a formula");
}
[ModTest("json/texts_for_the_form")]
static void TextsForTheForm(ModTestContext ctx)
{
var node = JsonNode.Parse("{\"a\": 3, \"b\": true, \"c\": [\"x\", \"y\"], \"d\": \"t\", \"e\": {\"k\": 1}}");
ModAssert.Equal("3", SchemaJson.ToText(node["a"]), "number");
ModAssert.Equal("1", SchemaJson.ToText(node["b"]), "true");
ModAssert.Equal("x,y", SchemaJson.ToText(node["c"]), "array of strings");
ModAssert.Equal("t", SchemaJson.ToText(node["d"]), "string");
ModAssert.Equal("{\"k\":1}", SchemaJson.ToText(node["e"]), "object as JSON");
ModAssert.Equal("", SchemaJson.ToText(null), "absent");
}
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace Cthangover.Mods.Schemas.Tests
{
using System.Linq;
using Cthangover.Core.Mods.Testing;
/// <summary>Which entity references a value makes, by its field type.</summary>
public class SchemaReferencesTests
{
private static string Refs(FieldSchema field, string value)
=> string.Join(";", SchemaReferences.Of(field, value).Select(r => r.Kind + ":" + r.Id));
[ModTest("references/by_type")]
static void ByType(ModTestContext ctx)
{
ModAssert.Equal("item:sword", Refs(new FieldSchema { Type = "ref", Kind = "item" }, " sword "), "ref");
ModAssert.Equal("character:a;character:b", Refs(new FieldSchema { Type = "ref_list", Kind = "character" }, "a, b,"), "ref_list");
ModAssert.Equal("character:hero", Refs(new FieldSchema { Type = "who" }, "hero"), "who names a character");
ModAssert.Equal("", Refs(new FieldSchema { Type = "who" }, "party"), "party is no reference");
ModAssert.Equal("", Refs(new FieldSchema { Type = "who" }, "Player"), "player is no reference");
ModAssert.Equal("scene:town", Refs(new FieldSchema { Type = "scene" }, "town"), "scene");
ModAssert.Equal("", Refs(new FieldSchema { Type = "text" }, "anything"), "text makes none");
ModAssert.Equal("", Refs(new FieldSchema { Type = "ref" }, "x"), "a ref without a kind makes none");
ModAssert.Equal("", Refs(new FieldSchema { Type = "ref", Kind = "item" }, "${picked}"), "a run-time value makes none");
}
}
}