feat(core): Value, reflection, codecs and results, shared by app and client
Crafter.Debug.Core is what the app side and the crafter-debug client both need, without the session: - Value: a JSON value with its own parser and printer, so no consumer interface sees a JSON library. Integers stay integers and whole doubles stay doubles; NaN and the infinities, which JSON cannot spell, are written as strings rather than hidden, because a debugger must not lose a NaN; invalid UTF-8 is replaced so output always parses. Objects keep insertion order, the order an agent reads. - Reflect: aggregate members and names through structured-binding packs and source_location, taken from Crafter.DB, plus scoped-enum names by probing a range of values. Self-tested with static_asserts so a compiler that changes function_name()'s shape fails the build. - Codec<T>: encode, decode and describe for bool, integers, floating point, strings, enums, optional, vector, array, string-keyed maps, Value, Required<T> and every aggregate. Decode reports every problem in a call with its path and, for a typo, the likely intended name, and never throws: a bad call cannot take an app down, which a wrongly typed parameter could in 3DForts' hand-written from_json. - Result, Error and Async: handler results, including ones that take frames to arrive, so callers need no ready:false polling. project.cpp now builds Crafter.Debug.Core as its own library, used by both the app library and the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fb2c06c32e
commit
89a12bcd61
13 changed files with 1951 additions and 11 deletions
67
tests/ShouldDescribeDefaultsAndRequired/main.cpp
Normal file
67
tests/ShouldDescribeDefaultsAndRequired/main.cpp
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Describe gives `crafter-debug help` what an agent needs to call a command
|
||||
// right the first time: each field's name and type, its default taken from
|
||||
// the member initializer, Required fields marked with no default, a narrow
|
||||
// integer's range, an enum's names, a nested aggregate's own fields. A type
|
||||
// that contains itself is described a few levels deep instead of forever.
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
using namespace Crafter::Debug;
|
||||
|
||||
namespace Game {
|
||||
enum class Mode : std::uint8_t { Idle, Build };
|
||||
|
||||
struct Vec3 {
|
||||
float x = 0;
|
||||
float y = 1;
|
||||
float z = 0;
|
||||
};
|
||||
|
||||
struct Aim {
|
||||
Required<std::uint8_t> slot;
|
||||
float yaw = 45;
|
||||
Vec3 at;
|
||||
std::optional<std::string> label;
|
||||
Mode mode = Mode::Build;
|
||||
std::vector<std::int32_t> ids;
|
||||
};
|
||||
|
||||
struct Node {
|
||||
std::vector<Node> children;
|
||||
};
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const Value description = Describe<Game::Aim>();
|
||||
std::println("{}", description.DumpPretty());
|
||||
Check(description.Find("type")->AsString() == "object" && description.Find("name")->AsString().ends_with("Aim"), "an aggregate describes as a named object");
|
||||
const std::span<const Value> fields = description.Find("fields")->Items();
|
||||
Check(fields.size() == 6, "one entry per field");
|
||||
const auto field = [&](std::string_view name) -> const Value& {
|
||||
for (const Value& f : fields) {
|
||||
if (f.Find("name")->AsString() == name) return f;
|
||||
}
|
||||
throw std::runtime_error(std::format("no field {}", name));
|
||||
};
|
||||
Check(field("slot").Find("required") != nullptr && field("slot").Find("default") == nullptr, "a Required field is marked required, with no default");
|
||||
Check(field("slot").Find("type")->Find("maximum")->AsInteger() == 255, "a narrow integer carries its range");
|
||||
Check(field("yaw").Find("default")->AsNumber() == 45, "a default comes from the member initializer");
|
||||
Check(field("at").Find("type")->Find("fields")->Size() == 3 && field("at").Find("default")->Find("y")->AsNumber() == 1, "a nested aggregate describes its fields and default");
|
||||
Check(field("label").Find("type")->Find("nullable") != nullptr && field("label").Find("default")->IsNull(), "an optional is nullable and defaults to null");
|
||||
Check(field("mode").Find("type")->Find("values")->Size() == 2 && field("mode").Find("default")->AsString() == "Build", "an enum lists its names");
|
||||
Check(field("ids").Find("type")->Find("items")->Find("type")->AsString() == "integer", "a vector describes its items");
|
||||
Check(!Describe<Game::Node>().Dump().empty(), "a type that contains itself is described without recursing forever");
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
78
tests/ShouldReportDecodeErrorsWithPaths/main.cpp
Normal file
78
tests/ShouldReportDecodeErrorsWithPaths/main.cpp
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// A command call with several mistakes gets one reply that names each of
|
||||
// them: a missing Required field, a wrong type naming both kinds, a nested
|
||||
// path ("path[1].x"), an out-of-range integer with the range, and for an
|
||||
// unknown field or enum name the one the caller most likely meant. Decoding
|
||||
// never throws, so a bad call cannot take the app down. Guards the replies an
|
||||
// agent corrects its calls from.
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
using namespace Crafter::Debug;
|
||||
|
||||
namespace Game {
|
||||
enum class Mode : std::uint8_t { Idle, Build, Fight };
|
||||
|
||||
struct Vec3 {
|
||||
float x = 0;
|
||||
float y = 0;
|
||||
float z = 0;
|
||||
};
|
||||
|
||||
struct Aim {
|
||||
Required<std::uint8_t> slot;
|
||||
float yaw = 0;
|
||||
float pitch = 0;
|
||||
std::vector<Vec3> path;
|
||||
Mode mode = Mode::Idle;
|
||||
};
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
bool Has(const DecodeContext& context, std::string_view path, std::string_view text) {
|
||||
return std::ranges::any_of(context.Issues(), [&](const DecodeIssue& issue) {
|
||||
return issue.path == path && issue.message.find(text) != std::string::npos;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
Game::Aim aim;
|
||||
DecodeContext context;
|
||||
Decode(Value::Parse(R"({"yw": 1, "pitch": "up", "path": [{"x": 1}, {"x": "no"}], "mode": "Fihgt"})"), aim, context);
|
||||
std::println("report: {}", context.Report());
|
||||
Check(context.Issues().size() == 5, std::format("five mistakes, five issues ({})", context.Issues().size()));
|
||||
Check(Has(context, "slot", "required"), "a missing Required field is reported");
|
||||
Check(Has(context, "pitch", "expected a number, got a string"), "a wrong type names both kinds");
|
||||
Check(Has(context, "path[1].x", "expected a number"), "a nested path names the item and the member");
|
||||
Check(Has(context, "mode", "did you mean 'Fight'"), "a mistyped enum name gets a suggestion");
|
||||
Check(Has(context, "yw", "did you mean 'yaw'"), "an unknown field gets a suggestion");
|
||||
Check(context.Report().find("slot: missing") != std::string::npos, "the report prefixes each issue with its path");
|
||||
|
||||
Game::Aim outOfRange;
|
||||
DecodeContext rangeContext;
|
||||
Decode(Value::Parse(R"({"slot": 300})"), outOfRange, rangeContext);
|
||||
Check(Has(rangeContext, "slot", "300 is out of range (0..255)"), "an out-of-range integer names the range");
|
||||
|
||||
Game::Aim notAnObject;
|
||||
DecodeContext objectContext;
|
||||
Decode(Value::Parse("[1]"), notAnObject, objectContext);
|
||||
Check(objectContext.Issues().size() == 1 && objectContext.Issues()[0].path.empty()
|
||||
&& objectContext.Issues()[0].message == "expected an object, got an array",
|
||||
"a non-object is one issue at the root");
|
||||
|
||||
Game::Aim farOff;
|
||||
DecodeContext farContext;
|
||||
Decode(Value::Parse(R"({"slot": 1, "zzzzzz": 1})"), farOff, farContext);
|
||||
Check(Has(farContext, "zzzzzz", "unknown field (fields: slot, yaw, pitch, path, mode)"), "an unknown field with no close match lists the fields");
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
94
tests/ShouldRoundTripAggregates/main.cpp
Normal file
94
tests/ShouldRoundTripAggregates/main.cpp
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Codec round-trips an aggregate built from every supported field type
|
||||
// (strings, nested aggregates in a vector, optional, std::array, string-keyed
|
||||
// maps, a scoped enum, Required) through Value and its text, with no
|
||||
// hand-written serialization. Fields the input leaves out keep their default
|
||||
// member initializers, null clears an optional, and an enum also accepts its
|
||||
// integer. Guards the reflection that command arguments and results rely on.
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
using namespace Crafter::Debug;
|
||||
|
||||
namespace Game {
|
||||
enum class Mode : std::uint8_t { Idle, Build, Fight = 5 };
|
||||
|
||||
struct Vec3 {
|
||||
float x = 0;
|
||||
float y = 0;
|
||||
float z = 0;
|
||||
};
|
||||
|
||||
struct Loadout {
|
||||
std::string name = "default";
|
||||
std::vector<Vec3> points;
|
||||
std::optional<std::int32_t> target;
|
||||
std::array<bool, 3> flags{};
|
||||
std::map<std::string, double> weights;
|
||||
Mode mode = Mode::Idle;
|
||||
Required<std::uint16_t> slot;
|
||||
};
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
Game::Loadout in;
|
||||
in.name = "fort \"one\"";
|
||||
in.points = { { 1, 2, 3 }, { -0.5F, 0, 1e10F } };
|
||||
in.target = -7;
|
||||
in.flags = { true, false, true };
|
||||
in.weights = { { "cannon", 0.25 }, { "laser", 1 } };
|
||||
in.mode = Game::Mode::Fight;
|
||||
*in.slot = 3;
|
||||
|
||||
const Value encoded = Encode(in);
|
||||
Check(encoded.IsObject() && encoded.Size() == 7, "an aggregate encodes as an object with one member per field");
|
||||
Check(encoded.Find("mode")->AsString() == "Fight", "a scoped enum encodes by name");
|
||||
Check(encoded.Find("slot")->AsInteger() == 3, "Required encodes as its value");
|
||||
Check(encoded.Find("target")->AsInteger() == -7, "an optional with a value encodes as the value");
|
||||
Check(encoded.Find("points")->Items()[1].Find("z")->AsNumber() == 1e10F, "nested aggregates encode inside a vector");
|
||||
|
||||
Game::Loadout out;
|
||||
DecodeContext context;
|
||||
Decode(Value::Parse(encoded.Dump()), out, context);
|
||||
Check(context.Ok(), std::format("decodes what it encoded ({})", context.Report()));
|
||||
Check(out.name == in.name && out.points.size() == 2 && out.points[1].z == 1e10F && out.target == -7 && out.flags[2]
|
||||
&& out.weights.at("laser") == 1 && out.mode == Game::Mode::Fight && *out.slot == 3,
|
||||
"every field survives the round trip");
|
||||
|
||||
Game::Loadout partial;
|
||||
partial.target = 9;
|
||||
DecodeContext partialContext;
|
||||
Decode(Value::Parse(R"({"slot": 1, "target": null, "mode": 1})"), partial, partialContext);
|
||||
Check(partialContext.Ok(), std::format("a partial object decodes ({})", partialContext.Report()));
|
||||
Check(partial.name == "default" && partial.points.empty(), "fields left out keep their defaults");
|
||||
Check(!partial.target.has_value(), "null clears an optional");
|
||||
Check(partial.mode == Game::Mode::Build, "an enum accepts its integer");
|
||||
|
||||
float nan = 0;
|
||||
DecodeContext nanContext;
|
||||
Decode(Encode(std::numeric_limits<float>::quiet_NaN()), nan, nanContext);
|
||||
Check(nanContext.Ok() && std::isnan(nan), "NaN survives the round trip");
|
||||
|
||||
std::vector<bool> bits;
|
||||
DecodeContext bitsContext;
|
||||
Decode(Value::Parse("[true,false,true]"), bits, bitsContext);
|
||||
Check(bitsContext.Ok() && bits.size() == 3 && bits[2] && !bits[1], "std::vector<bool> decodes");
|
||||
|
||||
const std::uint64_t large = (std::uint64_t{ 1 } << 63) + 2048;
|
||||
std::uint64_t decoded = 0;
|
||||
DecodeContext largeContext;
|
||||
Decode(Value::Parse(Encode(large).Dump()), decoded, largeContext);
|
||||
Check(largeContext.Ok() && decoded == large, "a uint64 above the int64 range survives");
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
81
tests/ShouldRoundTripJson/main.cpp
Normal file
81
tests/ShouldRoundTripJson/main.cpp
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Value's JSON text, the format the app and crafter-debug exchange. Parse
|
||||
// and Dump round-trip every kind, escapes and \u sequences (surrogate pairs
|
||||
// included) decode to UTF-8, integers stay integers and whole doubles stay
|
||||
// doubles, non-finite doubles are written as the strings NaN and Infinity,
|
||||
// invalid UTF-8 in a string is replaced so the output always parses, and
|
||||
// malformed text is refused with the offset of the problem.
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
using namespace Crafter::Debug;
|
||||
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
bool Refused(std::string_view text) {
|
||||
try {
|
||||
(void)Value::Parse(text);
|
||||
return false;
|
||||
} catch (const ParseError&) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const Value parsed = Value::Parse(R"({"a":1,"b":[true,false,null],"c":"x\"y\\z\n","d":-2.5,"e":{"f":"éé😀"}})");
|
||||
Check(parsed.IsObject() && parsed.Size() == 5, "parses an object with five members");
|
||||
Check(parsed.Find("a") != nullptr && parsed.Find("a")->IsInteger() && parsed.Find("a")->AsInteger() == 1, "an integer stays an integer");
|
||||
Check(parsed.Find("d")->Kind() == ValueKind::Number && parsed.Find("d")->AsNumber() == -2.5, "a fraction is a number");
|
||||
Check(parsed.Find("c")->AsString() == "x\"y\\z\n", "escapes decode");
|
||||
Check(parsed.Find("e")->Find("f")->AsString() == "\xC3\xA9\xC3\xA9\xF0\x9F\x98\x80", "\\u escapes and surrogate pairs decode to UTF-8");
|
||||
Check(Value::Parse(parsed.Dump()) == parsed, "compact text round-trips");
|
||||
Check(Value::Parse(parsed.DumpPretty()) == parsed, "pretty text round-trips");
|
||||
Check(parsed.Dump().starts_with(R"({"a":1,"b":[true,false,null],)"), "members keep their order");
|
||||
|
||||
Check(Value(1.0).Dump() == "1.0", "a whole double keeps a fraction");
|
||||
Check(Value::Parse("1.0").Kind() == ValueKind::Number, "1.0 reads back as a number");
|
||||
Check(Value(1.0) == Value(1), "Integer 1 equals Number 1.0");
|
||||
Check(Value(std::numeric_limits<double>::quiet_NaN()).Dump() == "\"NaN\"", "NaN is written as a string");
|
||||
Check(Value(-std::numeric_limits<double>::infinity()).Dump() == "\"-Infinity\"", "-Infinity is written as a string");
|
||||
Check(Value(std::numeric_limits<std::uint64_t>::max()).Kind() == ValueKind::Number, "a uint64 above the int64 range becomes a number");
|
||||
Check(Value::Parse("9223372036854775807").AsInteger() == std::numeric_limits<std::int64_t>::max(), "int64 max parses as an integer");
|
||||
Check(Value::Parse("9223372036854775808").Kind() == ValueKind::Number, "beyond int64 parses as a number");
|
||||
|
||||
Check(Value(std::string("ok\xff\xc3")).Dump() == "\"ok\xEF\xBF\xBD\xEF\xBF\xBD\"", "invalid UTF-8 becomes U+FFFD");
|
||||
Check(Value(std::string("\x01\x7f")).Dump() == "\"\\u0001\\u007f\"", "control characters are escaped");
|
||||
Check(Value::Parse(Value(std::string("tab\tquote\"")).Dump()).AsString() == "tab\tquote\"", "escaped text reads back");
|
||||
|
||||
Check(Refused(R"({"a":1,})"), "a trailing comma is refused");
|
||||
Check(Refused("[1 2]"), "a missing comma is refused");
|
||||
Check(Refused("01"), "a leading zero is refused");
|
||||
Check(Refused(R"("\ud800")"), "an unpaired surrogate is refused");
|
||||
Check(Refused("\"a\nb\""), "a raw newline in a string is refused");
|
||||
Check(Refused("1e999"), "an out-of-range number is refused");
|
||||
Check(Refused(std::string(300, '[') + std::string(300, ']')), "nesting beyond the limit is refused");
|
||||
Check(Refused(""), "empty text is refused");
|
||||
try {
|
||||
(void)Value::Parse("[1, x]");
|
||||
Check(false, "a parse error carries its offset");
|
||||
} catch (const ParseError& error) {
|
||||
Check(error.offset == 4, std::format("a parse error carries its offset ({})", error.offset));
|
||||
}
|
||||
|
||||
Value object;
|
||||
object["z"] = Value(1);
|
||||
object["a"] = Value("two");
|
||||
object.Set("z", Value(3));
|
||||
Check(object.Dump() == R"({"z":3,"a":"two"})", "Set replaces in place, keeping the order");
|
||||
Check(object.Erase("z") && object.Dump() == R"({"a":"two"})", "Erase removes a member");
|
||||
Check(Value::Parse(R"({"a":1,"b":2})") == Value::Parse(R"({"b":2,"a":1})"), "member order does not affect equality");
|
||||
Check(!(Value::Parse("[1,2]") == Value::Parse("[2,1]")), "item order does affect equality");
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
Loading…
Reference in a new issue