257 lines
13 KiB
C++
257 lines
13 KiB
C++
#include <cmath>
|
|
#include <faset/core/io.hpp>
|
|
#include <faset/editor/editor_ui.hpp>
|
|
#include <iostream>
|
|
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<std::string>() + "-position-0";
|
|
}
|
|
float position(const Json& object) {
|
|
return object["components"][0]["fields"]["position"][0].get<float>();
|
|
}
|
|
} // 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<std::string>();
|
|
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<std::string>() + "-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<std::uint64_t>();
|
|
text(ui, "simulation-tick-rate", "120");
|
|
check(std::abs(query(session, ui)["scene"]["simulation"]["fixed_delta"].get<double>() -
|
|
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<std::string>());
|
|
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<std::string>());
|
|
const auto opaque_component = opaque["components"][0]["id"].get<std::string>();
|
|
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;
|
|
}
|
|
}
|