feat(registry): commands, watches, samples, the timeline and conditions
The app side of Crafter.Debug, minus the session:
- Command(name, description, handler): the handler takes nothing or
one argument struct and returns void, a value, Result<T> or
Async<T>. Arguments are decoded before it runs, so a mistyped call
is answered with every mistake and never reaches it; a throwing
handler becomes an Exception error instead of unwinding into the
frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
read-only, and Sample(path, value) from any thread with a
frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
and set the same way, writing a member writes the whole value back.
Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
movable, so neither is its owner: its watches refer to the owner's
members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
timeline, with source locations captured without macros. A check
failing every frame is counted every time but stored sparsely, and
the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
condition that cannot be decided reports why instead of being false,
which would make a wait hang.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
89a12bcd61
commit
3500b73ecf
12 changed files with 1829 additions and 7 deletions
80
tests/ShouldEvaluateConditions/main.cpp
Normal file
80
tests/ShouldEvaluateConditions/main.cpp
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The conditions `wait` and `pause --when` take: comparisons, && || ! and
|
||||
// parentheses with the usual precedence, truthiness of a lone operand,
|
||||
// strings in either quote, paths with indices, and short-circuiting. A
|
||||
// condition that cannot be decided says why instead of being false, since a
|
||||
// silently false condition makes `wait` hang: an unknown path, ordering a
|
||||
// string against a number. Malformed text is refused with its offset, and a
|
||||
// lone = is caught as the typo it is.
|
||||
import std;
|
||||
import Crafter.Debug;
|
||||
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;
|
||||
}
|
||||
|
||||
std::expected<Value, std::string> Resolve(std::string_view path) {
|
||||
static const Value state = Value::Parse(R"({"hp": 0, "scene": "Game", "paused": false, "bodies": [{"y": 2}, {"y": -12}], "name": "", "lives": 3})");
|
||||
if (path.starts_with("bodies[")) {
|
||||
const std::size_t index = static_cast<std::size_t>(path[7] - '0');
|
||||
return state.Find("bodies")->Items()[index].Find("y") ? *state.Find("bodies")->Items()[index].Find("y") : Value();
|
||||
}
|
||||
if (const Value* value = state.Find(path)) return *value;
|
||||
return std::unexpected(std::format("no watch '{}'", path));
|
||||
}
|
||||
|
||||
std::expected<bool, std::string> Evaluate(std::string_view text) {
|
||||
return Condition::Parse(text).Evaluate(Resolve);
|
||||
}
|
||||
|
||||
bool Is(std::string_view text, bool expected) {
|
||||
const auto result = Evaluate(text);
|
||||
return result.has_value() && *result == expected;
|
||||
}
|
||||
|
||||
std::size_t RefusedAt(std::string_view text) {
|
||||
try {
|
||||
(void)Condition::Parse(text);
|
||||
} catch (const ConditionError& error) {
|
||||
return error.offset;
|
||||
}
|
||||
return std::string_view::npos;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
Check(Is("hp <= 0", true), "a comparison with a literal");
|
||||
Check(Is("scene == \"Game\" && !paused", true), "&& and ! combine");
|
||||
Check(Is("scene == 'Menu' || lives >= 3", true), "|| and single-quoted strings");
|
||||
Check(Is("hp > 0 || lives > 5", false), "false || false is false");
|
||||
Check(Is("!(hp == 0 && lives == 3)", false), "parentheses group");
|
||||
Check(Is("hp == 1 || hp == 0 && lives == 4", false), "&& binds tighter than ||");
|
||||
Check(Is("bodies[1].y < -10", true), "a path with an index, against a negative number");
|
||||
Check(Is("lives", true) && Is("name", false) && Is("paused", false), "a lone operand tests truthiness");
|
||||
Check(Is("lives == 3.0", true), "an integer equals the same number");
|
||||
Check(Is("hp > 0 && missing == 1", false), "&& short-circuits past an unknown path");
|
||||
|
||||
const auto unknown = Evaluate("missing > 1");
|
||||
Check(!unknown && unknown.error() == "no watch 'missing'", "an unknown path is an error, not false");
|
||||
const auto ordering = Evaluate("scene < 3");
|
||||
Check(!ordering && ordering.error().find("cannot order") != std::string::npos && ordering.error().find("scene < 3") != std::string::npos,
|
||||
"ordering a string against a number is an error that quotes the comparison");
|
||||
Check(Is("scene == 3", false), "== between different kinds is false, not an error");
|
||||
|
||||
Check(RefusedAt("hp = 0") == 3, "a lone = is refused where it stands");
|
||||
Check(RefusedAt("(hp == 0") == 8, "an unclosed parenthesis is refused at the end");
|
||||
Check(RefusedAt("hp == 0 lives") == 8, "two conditions without && or || are refused");
|
||||
Check(RefusedAt("hp == ") == 6, "a missing operand is refused");
|
||||
|
||||
const Condition parsed = Condition::Parse("fort1.core.hp <= 0 && bodies[3].pos.y > 1");
|
||||
Check(parsed.Paths().size() == 2 && parsed.Paths()[0] == "fort1.core.hp" && parsed.Paths()[1] == "bodies[3].pos.y", "a condition lists the paths it reads");
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
115
tests/ShouldInvokeCommands/main.cpp
Normal file
115
tests/ShouldInvokeCommands/main.cpp
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Commands registered with Debug::Command are found by name and invoked with
|
||||
// JSON parameters decoded into their argument struct: defaults fill what the
|
||||
// call leaves out, a bad call is refused with InvalidParams before the
|
||||
// handler runs, a throwing handler becomes an Exception error instead of
|
||||
// unwinding into the app, Result errors pass through with their code, an
|
||||
// Async result is polled until it yields, and a Group's commands disappear
|
||||
// with the Group. Guards what replaces 3DForts' hand-written dispatch table.
|
||||
import std;
|
||||
import Crafter.Debug;
|
||||
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;
|
||||
}
|
||||
|
||||
struct AimArgs {
|
||||
Required<std::int32_t> slot;
|
||||
float yaw = 10;
|
||||
float pitch = 0;
|
||||
};
|
||||
|
||||
struct Aimed {
|
||||
std::int32_t slot = 0;
|
||||
float yaw = 0;
|
||||
};
|
||||
|
||||
Detail::CommandOutcome Call(std::string_view name, std::string_view params) {
|
||||
const auto command = Detail::Registry::Instance().FindCommand(name);
|
||||
if (!command) throw std::runtime_error(std::format("no command {}", name));
|
||||
return command->invoke(Value::Parse(params));
|
||||
}
|
||||
|
||||
bool Failed(const Detail::CommandOutcome& outcome, std::string_view code) {
|
||||
return outcome.done && !outcome.done->Ok() && outcome.done->GetError().code == code;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::int32_t fired = 0;
|
||||
Command("Player_Fire", "Fire the weapon.", [&] { ++fired; });
|
||||
Command("Player_SetAim", "Aim the turret, in degrees.", [](const AimArgs& args) -> Result<Aimed> {
|
||||
if (*args.slot < 0) return Error{ "NoSlot", "slots start at 0" };
|
||||
return Aimed{ *args.slot, args.yaw };
|
||||
});
|
||||
Command("Game_Score", "The score.", [] { return 42; });
|
||||
Command("Game_Crash", "Throws.", []() -> std::int32_t { throw std::runtime_error("boom"); });
|
||||
std::int32_t polls = 0;
|
||||
Command("Sim_StateHash", "A hash that takes frames.", [&]() -> Async<std::string> {
|
||||
return Async<std::string>([&]() -> std::optional<std::string> {
|
||||
if (++polls < 3) return std::nullopt;
|
||||
return std::string("cafe");
|
||||
});
|
||||
});
|
||||
|
||||
const auto fire = Call("Player_Fire", "{}");
|
||||
Check(fire.done && fire.done->Ok() && fired == 1, "a void command runs and succeeds");
|
||||
Check(Failed(Call("Player_Fire", R"({"x":1})"), "InvalidParams"), "a command without arguments refuses some");
|
||||
|
||||
const auto aim = Call("Player_SetAim", R"({"slot": 2})");
|
||||
Check(aim.done && aim.done->Ok() && aim.done->Get().Find("yaw")->AsNumber() == 10 && aim.done->Get().Find("slot")->AsInteger() == 2,
|
||||
"a field left out takes its default, and the result is encoded");
|
||||
const auto mistyped = Call("Player_SetAim", R"({"yw": 1})");
|
||||
Check(Failed(mistyped, "InvalidParams") && mistyped.done->GetError().message.find("did you mean 'yaw'") != std::string::npos
|
||||
&& mistyped.done->GetError().message.find("slot: missing") != std::string::npos,
|
||||
"a mistyped call is refused with every mistake in it");
|
||||
const auto refused = Call("Player_SetAim", R"({"slot": -1})");
|
||||
Check(Failed(refused, "NoSlot") && refused.done->GetError().message == "slots start at 0", "a Result error keeps its code and message");
|
||||
|
||||
const auto score = Call("Game_Score", "null");
|
||||
Check(score.done && score.done->Ok() && score.done->Get().AsInteger() == 42, "a plain return value is encoded");
|
||||
|
||||
const auto crash = Call("Game_Crash", "{}");
|
||||
Check(Failed(crash, "Exception") && crash.done->GetError().message.find("boom") != std::string::npos, "an exception becomes an Exception error");
|
||||
|
||||
auto hash = Call("Sim_StateHash", "{}");
|
||||
Check(!hash.done && hash.pending, "an Async result is pending");
|
||||
std::optional<Result<Value>> polled;
|
||||
std::int32_t pumps = 0;
|
||||
while (!polled && pumps < 10) {
|
||||
polled = hash.pending();
|
||||
++pumps;
|
||||
}
|
||||
Check(polled && polled->Ok() && polled->Get().AsString() == "cafe" && pumps == 3, "an Async result is polled until it yields");
|
||||
|
||||
const auto described = Detail::Registry::Instance().FindCommand("Player_SetAim");
|
||||
Check(described->args.Find("fields")->Size() == 3 && described->returns.Find("fields")->Size() == 2 && described->description == "Aim the turret, in degrees.",
|
||||
"a command describes its arguments and its result");
|
||||
Check(Detail::Registry::Instance().FindCommand("Player_Fire")->args.IsNull(), "a command without arguments describes none");
|
||||
Check(Detail::Registry::Instance().SuggestCommand("Player_SetAm") == std::optional<std::string>("Player_SetAim"), "a mistyped command name gets a suggestion");
|
||||
|
||||
{
|
||||
Group fort("fort1");
|
||||
fort.Command("Explode", "Blow it up.", [] {});
|
||||
Check(Detail::Registry::Instance().FindCommand("fort1.Explode") != nullptr, "a Group prefixes its commands");
|
||||
}
|
||||
Check(Detail::Registry::Instance().FindCommand("fort1.Explode") == nullptr, "a Group's commands go with it");
|
||||
Check(Detail::Registry::Instance().Commands("Player_*").size() == 2, "commands can be listed by glob");
|
||||
|
||||
bool threw = false;
|
||||
try {
|
||||
Command("has space", "", [] {});
|
||||
} catch (const std::invalid_argument&) {
|
||||
threw = true;
|
||||
}
|
||||
Check(threw, "a name with whitespace is refused at registration");
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
105
tests/ShouldRecordTimeline/main.cpp
Normal file
105
tests/ShouldRecordTimeline/main.cpp
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The timeline records Log, LogWarn, LogError, Check and Emit with frame,
|
||||
// category and source location, and answers the queries `crafter-debug log`
|
||||
// makes: by level, category glob, substring, frame and sequence, newest N.
|
||||
// A check that fails every frame is counted every time but stored only
|
||||
// sparsely, so it cannot push everything else out, while the listener the
|
||||
// session pauses from still hears every failure. Appends from many threads
|
||||
// keep one increasing sequence.
|
||||
import std;
|
||||
import Crafter.Debug;
|
||||
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;
|
||||
}
|
||||
|
||||
struct MatchEnded {
|
||||
std::int32_t winnerSlot = 0;
|
||||
};
|
||||
|
||||
Detail::Timeline& timeline() {
|
||||
return Detail::Timeline::Instance();
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::atomic<std::int32_t> heard = 0;
|
||||
timeline().SetListener([&](const TimelineEntry&) { heard.fetch_add(1); });
|
||||
|
||||
Detail::SetFrame(10);
|
||||
Log("net", "peer {} joined slot {}", "b1", 2);
|
||||
Detail::SetFrame(11);
|
||||
LogWarn("physics", "brace {} strain {:.1f}", 17, 4.2);
|
||||
LogError("render", "lost device");
|
||||
Emit("MatchEnded", MatchEnded{ .winnerSlot = 1 });
|
||||
|
||||
const auto all = timeline().Query({});
|
||||
Check(all.size() == 4, std::format("four entries ({})", all.size()));
|
||||
Check(all[0].message == "peer b1 joined slot 2" && all[0].category == "net" && all[0].frame == 10 && all[0].kind == EntryKind::Log,
|
||||
"a log entry has its message, category and frame");
|
||||
Check(all[0].file.ends_with("main.cpp") && all[0].line > 0, "an entry carries the caller's source location");
|
||||
Check(all[1].message == "brace 17 strain 4.2" && all[1].kind == EntryKind::Warn && all[1].frame == 11, "a warning is formatted and stamped");
|
||||
Check(all[3].kind == EntryKind::Event && all[3].category == "MatchEnded" && all[3].payload.Find("winnerSlot")->AsInteger() == 1, "an event carries its payload");
|
||||
Check(all[1].sequence > all[0].sequence, "sequences increase");
|
||||
|
||||
Detail::TimelineQuery warnings;
|
||||
warnings.minLevel = 1;
|
||||
Check(timeline().Query(warnings).size() == 2, "--level warn keeps warnings and errors");
|
||||
Detail::TimelineQuery physics;
|
||||
physics.category = "phys*";
|
||||
Check(timeline().Query(physics).size() == 1, "a category glob filters");
|
||||
Detail::TimelineQuery grep;
|
||||
grep.grep = "device";
|
||||
Check(timeline().Query(grep).size() == 1, "a substring filters");
|
||||
Detail::TimelineQuery since;
|
||||
since.sinceFrame = 11;
|
||||
Check(timeline().Query(since).size() == 3, "--since-frame filters");
|
||||
Detail::TimelineQuery after;
|
||||
after.afterSequence = all[1].sequence;
|
||||
Check(timeline().Query(after).size() == 2, "afterSequence returns only newer entries");
|
||||
Detail::TimelineQuery last;
|
||||
last.last = 2;
|
||||
const auto newest = timeline().Query(last);
|
||||
Check(newest.size() == 2 && newest.back().kind == EntryKind::Event, "last returns the newest entries, in order");
|
||||
|
||||
heard = 0;
|
||||
const std::uint64_t before = timeline().LastSequence();
|
||||
std::int32_t passed = 0;
|
||||
for (std::int32_t i = 0; i < 100; ++i) {
|
||||
if (Crafter::Debug::Check(i % 2 == 1, "physics", "body {} has mass {}", i, -1)) ++passed;
|
||||
}
|
||||
Check(passed == 50, "Check returns its condition");
|
||||
Detail::TimelineQuery checks;
|
||||
checks.afterSequence = before;
|
||||
const auto stored = timeline().Query(checks);
|
||||
Check(stored.size() == 12, std::format("50 failures at one site store 12 entries ({})", stored.size()));
|
||||
Check(stored.back().count == 32 && stored.back().kind == EntryKind::Check, "a stored check carries its site's count");
|
||||
Check(heard == 50, std::format("the listener hears every failure ({})", heard.load()));
|
||||
const auto sites = timeline().CheckSites();
|
||||
Check(sites.size() == 1 && sites[0].failures == 50 && sites[0].category == "physics", "check sites are counted");
|
||||
|
||||
const std::uint64_t start = timeline().LastSequence();
|
||||
std::vector<std::thread> threads;
|
||||
for (std::int32_t t = 0; t < 8; ++t) {
|
||||
threads.emplace_back([t] {
|
||||
for (std::int32_t i = 0; i < 200; ++i) Log("threads", "thread {} line {}", t, i);
|
||||
});
|
||||
}
|
||||
for (std::thread& thread : threads) thread.join();
|
||||
Detail::TimelineQuery fromThreads;
|
||||
fromThreads.afterSequence = start;
|
||||
const auto lines = timeline().Query(fromThreads);
|
||||
bool increasing = lines.size() == 1600;
|
||||
for (std::size_t i = 1; i < lines.size(); ++i) increasing = increasing && lines[i].sequence == lines[i - 1].sequence + 1;
|
||||
Check(increasing, "appends from eight threads keep one increasing sequence");
|
||||
|
||||
timeline().SetListener(nullptr);
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
102
tests/ShouldResolveWatchPaths/main.cpp
Normal file
102
tests/ShouldResolveWatchPaths/main.cpp
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Watches and samples resolve by path, including into their values
|
||||
// ("bodies[1].pos.y" against the watch "bodies"), and set the same way:
|
||||
// writing one member of a watched struct writes the whole struct back. Sets
|
||||
// respect bounds and read-only watches, computed watches set through their
|
||||
// setter (which may refuse), samples keep a frame-stamped history, unknown
|
||||
// paths get a suggestion, and a Group's watches go with it.
|
||||
import std;
|
||||
import Crafter.Debug;
|
||||
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;
|
||||
}
|
||||
|
||||
struct Vec3 {
|
||||
float x = 0;
|
||||
float y = 0;
|
||||
float z = 0;
|
||||
};
|
||||
|
||||
struct Body {
|
||||
std::int32_t id = 0;
|
||||
Vec3 pos;
|
||||
};
|
||||
|
||||
float gravity = -9.81F;
|
||||
const std::int32_t build = 7;
|
||||
std::vector<Body> bodies = { { 1, { 0, 1, 0 } }, { 2, { 3, 4, 5 } } };
|
||||
std::int32_t lives = 3;
|
||||
|
||||
Detail::Registry& registry() {
|
||||
return Detail::Registry::Instance();
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
Watch("physics.gravity", gravity, { .min = -50, .max = 0, .help = "m/s^2" });
|
||||
Watch("build", build);
|
||||
Watch("bodies", bodies);
|
||||
Watch("lives", [] { return lives; }, [](const std::int32_t& value) -> Result<> {
|
||||
if (value < 0) return Error{ "Negative", "lives cannot be negative" };
|
||||
lives = value;
|
||||
return {};
|
||||
});
|
||||
Watch("score", [] { return 1234; });
|
||||
|
||||
Check(registry().Resolve("physics.gravity").value.AsNumber() == static_cast<double>(-9.81F), "a variable watch reads the variable");
|
||||
Check(registry().Resolve("bodies[1].pos.y").value.AsNumber() == 4, "a path reads into a watched value");
|
||||
Check(registry().Resolve("bodies[1]").value.Find("id")->AsInteger() == 2, "an index reads an item");
|
||||
|
||||
Check(!registry().Set("physics.gravity", Value(-3.5)) && gravity == -3.5F, "a set writes the variable");
|
||||
const auto bounded = registry().Set("physics.gravity", Value(10));
|
||||
Check(bounded && bounded->find("outside") != std::string::npos && gravity == -3.5F, "a set outside the bounds is refused");
|
||||
Check(registry().Set("build", Value(8)) == std::optional<std::string>("'build' is read-only") && build == 7, "a const variable's watch is read-only");
|
||||
Check(!registry().Set("bodies[0].pos.x", Value(9)) && bodies[0].pos.x == 9 && bodies[0].pos.y == 1, "a set into a member writes back the whole value");
|
||||
const auto wrongType = registry().Set("physics.gravity", Value("down"));
|
||||
Check(wrongType && wrongType->find("expected a number") != std::string::npos, "a set of the wrong type is refused");
|
||||
|
||||
Check(!registry().Set("lives", Value(5)) && lives == 5, "a computed watch sets through its setter");
|
||||
const auto negative = registry().Set("lives", Value(-1));
|
||||
Check(negative && negative->find("lives cannot be negative") != std::string::npos && lives == 5, "a setter can refuse");
|
||||
Check(registry().Set("score", Value(1)) == std::optional<std::string>("'score' is read-only"), "a getter-only watch is read-only");
|
||||
|
||||
const auto missing = registry().Resolve("physics.gravty");
|
||||
Check(!missing.Ok() && missing.error.find("did you mean 'physics.gravity'") != std::string::npos, "an unknown path gets a suggestion");
|
||||
const auto badMember = registry().Resolve("bodies[0].pos.w");
|
||||
Check(!badMember.Ok() && badMember.error.find("has no member 'w'") != std::string::npos, "an unknown member is named");
|
||||
const auto outOfRange = registry().Resolve("bodies[5]");
|
||||
Check(!outOfRange.Ok() && outOfRange.error.find("2 items") != std::string::npos, "an index out of range gives the size");
|
||||
|
||||
for (std::uint64_t frame = 1; frame <= 3; ++frame) {
|
||||
Detail::SetFrame(frame);
|
||||
Sample("xpbd.residual", static_cast<double>(frame) / 10);
|
||||
}
|
||||
Check(registry().Resolve("xpbd.residual").value.AsNumber() == 0.3, "a sample resolves to its latest value");
|
||||
const auto history = registry().SampleHistory("xpbd.residual");
|
||||
Check(history.size() == 3 && history.front().frame == 1 && history.back().frame == 3, "a sample keeps a frame-stamped history");
|
||||
|
||||
Check(registry().Watches("physics.*").size() == 1 && registry().Watches("*").size() == 5, "watches can be listed by glob");
|
||||
Check(registry().Watches("physics.gravity").front()->help == "m/s^2" && registry().Watches("physics.gravity").front()->type.Find("maximum") != nullptr,
|
||||
"a watch carries its help and bounds");
|
||||
|
||||
{
|
||||
struct Fort {
|
||||
std::int32_t hp = 100;
|
||||
Group debug{ "fort1" };
|
||||
Fort() { debug.Watch("core.hp", hp); }
|
||||
};
|
||||
Fort fort;
|
||||
Check(registry().Resolve("fort1.core.hp").value.AsInteger() == 100, "a Group prefixes its watches");
|
||||
}
|
||||
Check(!registry().Resolve("fort1.core.hp").Ok(), "a Group's watches go with it");
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
Loading…
Reference in a new issue