#include #include #include #include using namespace faset; namespace { void check(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); } render::Event key(std::string name, bool control = false) { render::Event e; e.type = render::Event::Type::KeyDown; e.key = std::move(name); e.control = control; return e; } void click(editor::EditorUI& editor, const std::string& id) { for (int attempts = 0; attempts < 30; ++attempts) { auto* widget = editor.widgets().find(id); check(widget, "Missing widget: " + id); const auto visible = widget->rect.intersection(widget->clip); if (visible.width > 2 && visible.height > 2) { render::Event down; down.type = render::Event::Type::MouseDown; down.button = 1; down.x = visible.x + visible.width * .5f; down.y = visible.y + visible.height * .5f; auto up = down; up.type = render::Event::Type::MouseUp; editor.frame({down, up}); return; } auto* parent = widget->parent; while (parent && !parent->layout.scroll) parent = parent->parent; check(parent, "Widget is hidden: " + id); render::Event move; move.type = render::Event::Type::MouseMove; move.x = parent->rect.x + 20; move.y = parent->rect.y + 20; render::Event wheel; wheel.type = render::Event::Type::Wheel; wheel.y = widget->rect.y < parent->rect.y ? 2 : -2; editor.frame({move, wheel}); } throw std::runtime_error("Cannot scroll to widget: " + id); } void text(editor::EditorUI& editor, const std::string& id, const std::string& value) { click(editor, id); render::Event input; input.type = render::Event::Type::TextInput; input.text = value; editor.frame({key("A", true), input, key("Return")}); } Json query(editor::Session& session, editor::EditorUI& editor) { return session.authoring().query(editor.current_document()); } Json leaf(editor::Session& session, const std::string& document, std::size_t depth) { const auto resolved = session.commands().resolved_scene(document); for (const auto& e : resolved.at("scene").at("entities")) if (e.at("origin").at("path").size() == depth) return e; throw std::runtime_error("Resolved template leaf missing"); } std::string position_field(const Json& object) { return "field-" + object["components"][0]["id"].get() + "-position-0"; } float position(const Json& object) { return object["components"][0]["fields"]["position"][0].get(); } } // namespace int main() { const auto root = std::filesystem::temp_directory_path() / ("faset-template-ui-" + new_id()); try { editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root}); render::Renderer renderer({1280, 900, "Template workflow test", true, true}); editor::EditorUI ui(session, renderer, path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf", path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json"); ui.frame({}); const auto main = ui.current_document(); click(ui, "add-cube"); text(ui, "object-name", "Door"); const auto local = ui.selected_entity(); click(ui, "menu-Scene"); text(ui, "template-path", "Assets/Templates/Дверь.scene.json"); click(ui, "save-template"); check(std::filesystem::exists(root / path_from_utf8("Assets/Templates/Дверь.scene.json")), "Manual source template creation"); for (int i = 0; i < 2; ++i) { click(ui, "menu-Scene"); click(ui, "instance-template"); } check(query(session, ui)["scene"]["instances"].size() == 2, "Two instances from manual Scene menu"); ui.select_entity(local); ui.frame({}); ui.frame({key("Delete")}); check(query(session, ui)["scene"]["entities"].empty(), "Delete local original independently of template"); click(ui, "menu-File"); click(ui, "new-3d"); const auto outer_document = ui.current_document(); click(ui, "menu-Scene"); click(ui, "instance-template"); click(ui, "menu-File"); text(ui, "save-path", "Assets/Templates/Внешняя.scene.json"); click(ui, "save-as-button"); click(ui, "back-document"); check(ui.current_document() == main, "Open source/back document navigation"); click(ui, "menu-Scene"); text(ui, "template-path", "Assets/Templates/Внешняя.scene.json"); click(ui, "instance-template"); auto nested = leaf(session, main, 2); const auto nested_id = nested.at("id").get(); click(ui, "entity-" + nested_id); text(ui, position_field(nested), "5"); auto state = query(session, ui); const auto& record = state["scene"]["instances"].back()["overrides"][0]; check(record["address"]["path"].size() == 1, "Nested override uses relative instance path"); check(position(leaf(session, main, 2)) == 5, "Nested Inspector override"); const auto revert = "field-" + nested["components"][0]["id"].get() + "-position-revert"; check(ui.widgets().find(revert)->enabled, "Override provenance enables Revert"); click(ui, revert); check(position(leaf(session, main, 2)) == 0, "Revert reads source value"); click(ui, "object-open-source"); check(ui.current_document() != main && ui.current_document() != outer_document, "Nested Open source reaches inner source"); text(ui, "object-name", "Renamed Door"); auto source = query(session, ui)["scene"]["entities"][0]; text(ui, position_field(source), "2"); click(ui, "back-document"); check(ui.current_document() == main, "Back returns to instance scene"); nested = leaf(session, main, 2); check(nested["id"] == nested_id && nested["name"] == "Renamed Door" && position(nested) == 2, "Dirty open source propagates while identity survives rename"); click(ui, "entity-" + nested_id); ui.frame({key("Delete")}); state = query(session, ui); const auto top = state["scene"]["instances"].back()["id"]; check(state["scene"]["instances"].back()["suppressed"].size() == 1, "Delete inherited object records suppression"); click(ui, "instance-" + Json::array({top}).dump()); click(ui, "instance-restore-0"); check(leaf(session, main, 2)["id"] == nested_id, "Restore suppression keeps identity"); click(ui, "instance-add-local"); const auto additions = session.commands().resolved_scene(main).at("scene").at("entities"); std::string addition_id; for (const auto& object : additions) if (object.at("origin").value("local", false) && object.at("origin").at("path").size() == 1) addition_id = object.at("id"); check(!addition_id.empty(), "Instance-local addition is resolved"); click(ui, "entity-" + addition_id); text(ui, "object-name", "Local lamp"); click(ui, "add-component"); click(ui, "component-choice-faset.mesh"); const auto addition = query(session, ui)["scene"]["instances"].back()["additions"][0]; check(addition["name"] == "Local lamp" && addition["components"].size() == 2 && addition["components"].back()["type"] == "faset.mesh", "Local addition supports manual rename and component creation"); click(ui, "simulation-settings"); const auto before = query(session, ui)["revision"].get(); text(ui, "simulation-tick-rate", "120"); check(std::abs(query(session, ui)["scene"]["simulation"]["fixed_delta"].get() - 1.0 / 120) < 1e-8, "Simulation UI converts Hz to seconds"); check(query(session, ui)["revision"] == before + 1, "Simulation field is one Undo transaction"); text(ui, "simulation-gravity-1", "-3"); check(query(session, ui)["scene"]["simulation"]["gravity"][1] == -3, "Simulation gravity field"); click(ui, "simulation-close"); click(ui, "undo"); check(query(session, ui)["scene"]["simulation"]["gravity"][1] == -9.81, "Simulation Undo restores gravity"); // Save through the same controls a user sees, then open a new editor // session: an in-memory resolver alone cannot prove persistent identity. click(ui, "entity-" + nested_id); click(ui, "object-open-source"); click(ui, "save"); click(ui, "back-document"); click(ui, "menu-File"); text(ui, "save-path", "Scenes/Экземпляры.scene.json"); click(ui, "save-as-button"); const auto saved_scene = query(session, ui).at("scene"); const auto saved_resolved = session.commands().resolved_scene(main); { editor::Session reopened({root, path_from_utf8(FASET_TEST_ENGINE), root}); editor::EditorUI reopened_ui( reopened, renderer, path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf", path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json"); reopened_ui.frame({}); click(reopened_ui, "menu-File"); text(reopened_ui, "open-path", "Scenes/Экземпляры.scene.json"); click(reopened_ui, "open-path-button"); check(query(reopened, reopened_ui).at("scene") == saved_scene, "Save and reopen preserve instance IDs, overrides and local additions"); check(reopened.commands().resolved_scene(reopened_ui.current_document()) == saved_resolved, "New session resolves saved nested source edits with identical provenance"); check(leaf(reopened, reopened_ui.current_document(), 2).at("id") == nested_id, "Resolved nested object identity survives reopening"); } nested = leaf(session, main, 2); click(ui, "entity-" + nested_id); text(ui, position_field(nested), "7"); click(ui, "object-open-source"); source = query(session, ui)["scene"]["entities"][0]; click(ui, "component-remove-" + source["components"][0]["id"].get()); click(ui, "back-document"); const auto conflicted = session.commands().resolved_scene(main); check(!conflicted.at("conflicts").empty() && query(session, ui)["scene"]["instances"].back()["overrides"].size() == 1, "Source deletion retains unapplied override as an explicit conflict"); click(ui, "tab-conflicts"); click(ui, "conflict-0-discard"); check(query(session, ui)["scene"]["instances"].back()["overrides"].empty(), "Conflict discard is an explicit authoring action"); click(ui, "undo"); check(query(session, ui)["scene"]["instances"].back()["overrides"].size() == 1, "Conflict discard is undoable"); auto opaque = authoring::make_entity(session.authoring().schemas(), "Future transform"); opaque["components"][0]["version"] = 2; opaque["components"][0]["fields"] = {{"future", "untouched"}}; session.authoring().transact(main, query(session, ui).at("revision"), Json::array({{{"op", "entity.create"}, {"entity", opaque}}})); ui.frame({}); click(ui, "entity-" + opaque.at("id").get()); const auto opaque_component = opaque["components"][0]["id"].get(); const auto* raw = ui.widgets().find("opaque-fields-" + opaque_component); check(raw && !raw->enabled && raw->text == opaque["components"][0]["fields"].dump(), "Unsupported component version stays opaque and read-only"); check(!ui.widgets().find("field-" + opaque_component + "-future"), "Current-schema Inspector does not interpret future component fields"); check(query(session, ui)["scene"]["entities"].back() == opaque, "Opaque component data survives Inspector and viewport updates"); renderer.render(ui.snapshot()); renderer.capture(root / "templates.ppm"); check(renderer.stats().validation_errors == 0, "Vulkan validation"); std::cout << "Manual templates: create/two instances/nesting/overrides/Revert/source " "rename/suppression restore/local additions/conflicts/opaque versions; " "simulation UI+Undo passed. " << path_to_utf8(root) << '\n'; return 0; } catch (const std::exception& error) { std::cerr << error.what() << "\nRetained: " << path_to_utf8(root) << '\n'; return 1; } }