#include #include #include #include #include #include #include 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(); check(opened.at("scene") == old_scene && !opened.at("dirty").get(), "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::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()); const auto player = path_from_utf8(first.result.at("player").get()); const auto schema = path_from_utf8(first.result.at("schema").get()); 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"); 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 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"); } std::cout << "Valid v2 schema and atomic rejection of " << invalid.size() << " malformed metadata generations 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