Files
Faset_Engine/tests/build_schema_tests.cpp

370 lines
21 KiB
C++

#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/editor/commands.hpp>
#include <faset/scripting/project.hpp>
#include <iostream>
#include <limits>
using namespace faset;
namespace fs = std::filesystem;
namespace {
void check(bool value, std::string_view message) {
if (!value)
throw std::runtime_error(std::string(message));
}
Json manifest() {
Json type{
{"id", "game.mover"},
{"version", 2},
{"fields",
{{"speed", {{"type", "number"}, {"default", 2.5}, {"min", 0}, {"max", 10}}},
{"enabled", {{"type", "boolean"}, {"default", true}}}}},
{"migrations",
Json::array(
{{{"from_version", 1},
{"fields", {{"speed", {{"scale", 0.01}}}, {"enabled", {{"default", true}}}}}}})}};
return {{"format", "faset.schema"}, {"version", 1}, {"types", Json::array({type})}};
}
void migration_contracts(const fs::path& root, const Json& metadata) {
auto old_scene = authoring::make_scene("Legacy movement", 2);
auto object = authoring::make_entity(authoring::builtin_schemas(), "Mover");
object["id"] = "mover";
object["components"].push_back({{"id", "movement"},
{"type", "game.mover"},
{"version", 1},
{"fields", {{"speed", 300}, {"unknown", "preserve me"}}}});
old_scene["entities"].push_back(object);
atomic_write_json(root / "Scenes/legacy.scene.json", old_scene);
const auto original = read_text(root / "Scenes/legacy.scene.json");
authoring::AuthoringService service(root);
service.replace_external_schemas(metadata);
editor::Commands commands(service);
const auto opened =
commands.call("faset_document_open", {{"path", "Scenes/legacy.scene.json"}});
const auto id = opened.at("id").get<std::string>();
check(opened.at("scene") == old_scene && !opened.at("dirty").get<bool>(),
"Opening an older schema preserves opaque data without implicit migration");
auto apply = [&] {
return commands.call("faset_scene_edit",
{{"document", id},
{"revision", service.query(id).at("revision")},
{"operations", Json::array({{{"op", "entity.rename"},
{"entity", "mover"},
{"name", "Migrated mover"}},
{{"op", "component.migrate"},
{"entity", "mover"},
{"component", "movement"}}})}});
};
const auto migrated = apply();
const auto& value = migrated.at("scene").at("entities")[0].at("components")[1];
check(value.at("id") == "movement" && value.at("version") == 2 &&
value.at("fields").at("speed") == 3.0 && value.at("fields").at("enabled") == true &&
value.at("fields").at("unknown") == "preserve me",
"Exported declarative steps convert values and retain IDs and unknown fields");
check(read_text(root / "Scenes/legacy.scene.json") == original,
"Explicit migration remains unsaved until Save");
const auto undone =
commands.call("faset_undo", {{"document", id}, {"revision", migrated.at("revision")}});
check(undone.at("scene") == old_scene, "One Undo restores the entire migration transaction");
authoring::AuthoringService recovered(root);
recovered.replace_external_schemas(metadata);
check(recovered.recover(id).at("scene") == old_scene,
"Recovery preserves the journal's older version without automatic migration");
commands.call("faset_redo", {{"document", id}, {"revision", undone.at("revision")}});
check(service.query(id).at("scene") == migrated.at("scene"), "Redo restores migrated values");
commands.call("faset_undo", {{"document", id}, {"revision", service.query(id).at("revision")}});
for (const auto* failure : {"missing", "manual", "overflow"}) {
auto rules = metadata;
if (std::string_view(failure) == "missing")
rules["types"][0].erase("migrations");
else if (std::string_view(failure) == "manual")
rules["types"][0]["migrations"][0]["fields"]["speed"] = {{"require_manual", true}};
else
rules["types"][0]["migrations"][0]["fields"]["speed"]["scale"] =
std::numeric_limits<double>::max();
service.replace_external_schemas(rules);
const auto before = service.query(id);
bool rejected{};
try {
(void)apply();
} catch (const Error&) {
rejected = true;
}
check(rejected && service.query(id) == before &&
read_text(root / "Scenes/legacy.scene.json") == original,
"Missing/manual/overflow migration preserves document, revision, Undo and source");
authoring::AuthoringService reopened(root);
reopened.replace_external_schemas(rules);
check(reopened.open("Scenes/legacy.scene.json").at("scene") == old_scene,
"An unavailable migration never prevents opening old data");
}
service.replace_external_schemas(metadata);
auto addition = object;
addition["id"] = "local-mover";
addition["components"][0]["id"] = "local-transform";
addition["components"][1]["id"] = "local-movement";
auto current = service.query(id);
current = service.transact(id, current.at("revision"),
Json::array({{{"op", "template.instance"},
{"instance",
{{"id", "local-instance"},
{"source", "Scenes/unused.scene.json"},
{"additions", Json::array({addition})}}}}}));
const auto local_before = current;
current = commands.call("faset_scene_edit",
{{"document", id},
{"revision", current.at("revision")},
{"operations", Json::array({{{"op", "component.migrate"},
{"instance", "local-instance"},
{"entity", "local-mover"},
{"component", "local-movement"}}})}});
check(current.at("scene")
.at("instances")[0]
.at("additions")[0]
.at("components")[1]
.at("fields")
.at("speed") == 3.0,
"Instance-local additions use the same migration command");
check(service.undo(id, current.at("revision")).at("scene") == local_before.at("scene"),
"Local-addition migration is one undoable edit");
}
int test_main(int argc, char** argv) {
const auto root =
fs::temp_directory_path() / path_from_utf8("Faset schema Café 世界 " + new_id());
try {
check(argc == 3, "Expected native fixture tool and engine root");
editor::BuildConfig config;
config.project_root = root / "project";
config.engine_root = fs::absolute(path_from_utf8(argv[2]));
config.cmake = path_to_utf8(fs::absolute(path_from_utf8(argv[1])));
config.configure_arguments = {"-DCMAKE_CXX_COMPILER=fixture-does-not-compile"};
editor::BuildService builds(config);
builds.scaffold("Schema publication", 2);
authoring::AuthoringService authoring(config.project_root, authoring::builtin_schemas());
const auto valid = manifest();
atomic_write_json(config.project_root / "schema-fixture.json", valid);
const auto first = builds.wait(builds.start_build());
check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error);
const auto directory = path_from_utf8(first.result.at("directory").get<std::string>());
const auto player = path_from_utf8(first.result.at("player").get<std::string>());
const auto schema = path_from_utf8(first.result.at("schema").get<std::string>());
const auto last_build = builds.config().cache_root / "last_build.json";
const auto previous_pointer = read_text(last_build);
const auto previous_player = sha256_file(player);
const auto previous_schema = read_text(schema);
const auto previous_manifest = read_text(directory / "manifest.json");
check(!first.result.at("lua_enabled").get<bool>() &&
!fs::exists(directory / "project.faset.json"),
"C++-only build publishes no Lua sources or project manifest");
check(read_json(config.project_root / "configure-fixture.json").back() ==
"-DFASET_ENABLE_LUA=OFF",
"C++-only build explicitly disables the Lua VM in CMake");
authoring.replace_external_schemas(read_json(schema));
const auto previous_registry = authoring.schemas().manifest();
check(authoring.schemas().schema("game.mover").at("version") == 2,
"Matching custom v2 metadata reaches authoring");
migration_contracts(config.project_root / "migration-contracts", read_json(schema));
Json scene{
{"format", "faset.scene"},
{"version", 1},
{"id", "fixture-scene"},
{"dimension", 2},
{"instances", Json::array()},
{"entities",
Json::array({{{"id", "mover"},
{"components", Json::array({{{"id", "behavior"},
{"type", "game.mover"},
{"version", 2},
{"fields", {{"speed", 2.5}}}}})}}})}};
check(builds.wait(builds.start_cook(scene)).state == "succeeded",
"Matching component v2 cooks against the published schema");
std::vector<Json> invalid;
auto candidate = valid;
candidate["types"][0]["fields"]["speed"]["default"] = "not a number";
invalid.push_back(candidate);
candidate = valid;
candidate["types"][0]["fields"]["speed"]["type"] = "unsupported-kind";
invalid.push_back(candidate);
candidate = valid;
candidate["types"][0]["fields"]["speed"]["min"] = "not a number";
invalid.push_back(candidate);
candidate = valid;
candidate["types"][0]["fields"]["speed"]["enum"] = 2.5;
invalid.push_back(candidate);
candidate = valid;
candidate["types"][0]["fields"]["speed"]["id"] = "different-field-id";
invalid.push_back(candidate);
candidate = valid;
candidate["types"].push_back(candidate["types"][0]);
invalid.push_back(candidate);
candidate = valid;
candidate["types"] = Json::array({authoring::builtin_schemas().schema("faset.transform")});
invalid.push_back(candidate);
for (const Json& version : {Json(0), Json(2.5), Json("2")}) {
candidate = valid;
candidate["types"][0]["version"] = version;
invalid.push_back(candidate);
}
candidate = valid;
candidate["types"][0].erase("fields");
invalid.push_back(candidate);
candidate = valid;
candidate["types"] = Json::object();
invalid.push_back(candidate);
for (const auto& step : Json::array(
{{{"from_version", 2}, {"fields", Json::object()}},
{{"from_version", 0}, {"fields", Json::object()}},
{{"from_version", 1.5}, {"fields", Json::object()}},
{{"from_version", 1}, {"fields", {{"speed", {{"execute", "unsafe"}}}}}},
{{"from_version", 1}, {"fields", {{"speed", {{"scale", "bad"}}}}}},
{{"from_version", 1}, {"fields", {{"speed", {{"scale", nullptr}}}}}},
{{"from_version", 1}, {"fields", {{"speed", {{"require_manual", 1}}}}}},
{{"from_version", 1}, {"fields", Json::object()}, {"unsupported", true}}})) {
candidate = valid;
candidate["types"][0]["migrations"] = Json::array({step});
invalid.push_back(candidate);
}
candidate = valid;
candidate["types"][0]["migrations"].push_back(candidate["types"][0]["migrations"][0]);
invalid.push_back(candidate);
for (std::size_t index = 0; index < invalid.size(); ++index) {
atomic_write_json(config.project_root / "schema-fixture.json", invalid[index]);
atomic_write(config.project_root / "Scripts/Gameplay.cpp",
"// Invalid metadata fixture " + std::to_string(index));
const auto failed = builds.wait(builds.start_build());
check(failed.state == "failed" && failed.result.empty() && !failed.error.empty(),
"Malformed schema must fail before a successful build result is published");
check(read_text(last_build) == previous_pointer,
"Last good build pointer is preserved");
check(sha256_file(player) == previous_player && read_text(schema) == previous_schema &&
read_text(directory / "manifest.json") == previous_manifest,
"Last good binary, schema and build manifest remain unchanged");
std::size_t generations{};
for (const auto& entry : fs::directory_iterator(directory.parent_path())) {
++generations;
check(entry.path() == directory, "Invalid build leaves no staging or generation");
}
check(generations == 1, "Only the validated build generation remains");
bool rejected{};
try {
authoring.replace_external_schemas(invalid[index]);
} catch (const std::exception&) {
rejected = true;
}
check(rejected && authoring.schemas().manifest() == previous_registry,
"Cached-schema validation uses the same contract and preserves the registry");
}
atomic_write_json(config.project_root / "schema-fixture.json", valid);
auto project = read_json(config.project_root / "project.faset.json");
project["scripting"]["lua"]["scripts"] = Json::array({"Scripts/main.lua"});
atomic_write_json(config.project_root / "project.faset.json", project);
const auto lua_source =
"return faset.behavior { id = 'game.mover', version = 2, fields = {} }\n";
atomic_write(config.project_root / "Scripts/main.lua", lua_source);
atomic_write(config.project_root / "Scripts/lib/util.lua", "return {value = 1}\n");
fs::remove(config.project_root / "Scripts/Gameplay.cpp");
fs::remove(config.project_root / "Scripts/Gameplay.hpp");
const auto lua_build = builds.wait(builds.start_build());
check(lua_build.state == "succeeded" && lua_build.result.at("lua_enabled") == true,
"Lua-only project builds without a Gameplay.cpp/Gameplay.hpp pair: " +
lua_build.error);
check(read_json(config.project_root / "configure-fixture.json").back() ==
"-DFASET_ENABLE_LUA=ON",
"Lua declaration enables the module in the native Player");
const auto lua_directory =
path_from_utf8(lua_build.result.at("directory").get<std::string>());
const auto captured = scripting::loadLuaProject(lua_directory);
check(captured.enabled() && captured.sources.size() == 2 &&
captured.fingerprint ==
lua_build.result.at("lua_fingerprint").get<std::string>() &&
read_json(path_from_utf8(lua_build.result.at("schema").get<std::string>()))
.at("lua_fingerprint")
.get<std::string>() == captured.fingerprint,
"Immutable source snapshot and merged schema share a Lua fingerprint");
check(read_json(config.project_root / "exporter-project-fixture.json").at("scripting") ==
project.at("scripting"),
"Schema exporter receives the captured project's Lua entries");
atomic_write(config.project_root / "Scripts/lib/util.lua", "return {value = 2}\n");
const auto changed = builds.wait(builds.start_build());
check(
changed.state == "succeeded" &&
changed.result.at("fingerprint") != lua_build.result.at("fingerprint") &&
changed.result.at("lua_fingerprint") != lua_build.result.at("lua_fingerprint"),
"Module-only Lua edits produce new build provenance without changing native fixtures");
check(scripting::loadLuaProject(lua_directory).fingerprint == captured.fingerprint,
"Later edits never mutate an already published Lua generation");
const auto lua_pointer = read_text(last_build);
for (const auto* marker : {"mutate-lua-during-build", "mutate-lua-snapshot"}) {
atomic_write(config.project_root / marker, "fixture\n");
const auto raced = builds.wait(builds.start_build());
check(raced.state == "failed" && read_text(last_build) == lua_pointer,
"Source or snapshot changes during schema export preserve the last good build");
fs::remove(config.project_root / marker);
atomic_write(config.project_root / "Scripts/main.lua", lua_source);
}
const auto exported = builds.wait(builds.start_export(scene, root / "lua-export"));
check(exported.state == "succeeded",
"Lua export publishes a captured source package: " + exported.error);
const auto packaged = path_from_utf8(exported.result.at("directory").get<std::string>());
const auto package_manifest = read_json(packaged / "manifest.json");
check(scripting::loadLuaProject(packaged).fingerprint ==
package_manifest.at("lua_fingerprint").get<std::string>() &&
package_manifest.at("lua_enabled") == true &&
read_json(packaged / "Notices/dependencies.json").contains("lua") &&
fs::is_regular_file(packaged / "Notices/lua/LICENSE.txt"),
"Export contains source, fingerprint and the selected Lua runtime's license");
check(!fs::exists(packaged / "schema.json") &&
!fs::exists(packaged / "faset_schema_exporter") &&
!fs::exists(packaged / "faset_schema_exporter.exe") &&
!fs::exists(packaged / ".luarc.json") &&
!fs::exists(packaged / "Scripts/Gameplay.cpp"),
"Runtime export omits schema tools, editor configuration and C++ source");
std::size_t packaged_sources{};
for (const auto& file : package_manifest.at("files"))
if (file.at("path").get<std::string>().starts_with("Scripts/")) {
++packaged_sources;
check(sha256_file(packaged / path_from_utf8(file.at("path").get<std::string>())) ==
file.at("sha256").get<std::string>(),
"Every packaged source hash matches the export manifest");
}
check(packaged_sources == 2, "Entry and require module both appear in export provenance");
project.erase("scripting");
atomic_write_json(config.project_root / "project.faset.json", project);
atomic_write(config.project_root / "Scripts/Gameplay.cpp", "// C++ fixture\n");
atomic_write(config.project_root / "Scripts/Gameplay.hpp", "// C++ fixture\n");
const auto cpp_export = builds.wait(builds.start_export(scene, root / "cpp-export"));
check(cpp_export.state == "succeeded", "C++ export still succeeds: " + cpp_export.error);
const auto cpp_package =
path_from_utf8(cpp_export.result.at("directory").get<std::string>());
check(!fs::exists(cpp_package / "Scripts") &&
!fs::exists(cpp_package / "project.faset.json") &&
!read_json(cpp_package / "Notices/dependencies.json").contains("lua") &&
read_json(config.project_root / "configure-fixture.json").back() ==
"-DFASET_ENABLE_LUA=OFF",
"Removing Lua declarations drops scripts, notices and the Lua link dependency");
std::cout << "Valid v2 schema and atomic rejection of " << invalid.size()
<< " malformed metadata generations; Lua snapshots and export contracts passed\n";
fs::remove_all(root);
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
std::error_code ignored;
fs::remove_all(root, ignored);
return 1;
}
}
} // namespace
#ifdef _WIN32
int wmain(int argc, wchar_t** argv) {
return run_utf8_main(argc, argv, test_main);
}
#else
int main(int argc, char** argv) {
return test_main(argc, argv);
}
#endif