Files
Faset_Engine/tests/editor_ui_import_conflicts.cpp
T

177 lines
8.8 KiB
C++

#include <chrono>
#include <faset/core/io.hpp>
#include <faset/editor/editor_ui.hpp>
#include <iostream>
#include <set>
#include <thread>
using namespace faset;
namespace {
void check(bool value, const std::string& message) {
if (!value)
throw std::runtime_error(message);
}
void click(editor::EditorUI& ui, const std::string& id) {
for (int attempt = 0; attempt < 40; ++attempt) {
const auto* w = ui.widgets().find(id);
check(w, "Missing widget: " + id);
const auto visible = w->rect.intersection(w->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;
ui.frame({down, up});
return;
}
auto* parent = w->parent;
while (parent && !parent->layout.scroll)
parent = parent->parent;
check(parent, "Cannot scroll to " + id);
render::Event move, wheel;
move.type = render::Event::Type::MouseMove;
move.x = parent->rect.x + 10;
move.y = parent->rect.y + 10;
wheel.type = render::Event::Type::Wheel;
wheel.y = w->rect.y < parent->rect.y ? 1 : -1;
ui.frame({move, wheel});
}
throw std::runtime_error("Widget remains clipped: " + id);
}
Json job(editor::Session& session, const std::string& id) {
return session.commands().call("faset_job", {{"id", id}});
}
Json wait(editor::Session& session, editor::EditorUI& ui, const std::string& id) {
for (int i = 0; i < 1000; ++i) {
ui.frame({});
const auto current = job(session, id);
if (current.at("state") != "queued" && current.at("state") != "running")
return current;
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
throw std::runtime_error("Import did not finish");
}
std::set<std::string> jobs(editor::Session& session) {
std::set<std::string> result;
const auto response = session.commands().call("faset_jobs", Json::object());
for (const auto& value : response.at("jobs"))
result.insert(value.at("id"));
return result;
}
std::string new_job(editor::Session& session, const std::set<std::string>& before) {
for (const auto& id : jobs(session))
if (!before.contains(id))
return id;
throw std::runtime_error("UI did not submit an import job");
}
} // namespace
int main() {
const auto root = std::filesystem::temp_directory_path() / ("faset-import-ui-" + new_id());
try {
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
render::Renderer renderer({1280, 900, "Import conflict workflow", 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");
const auto source = root / "Assets/model.gltf";
Json gltf = {
{"asset", {{"version", "2.0"}}},
{"scene", 0},
{"scenes", Json::array({{{"nodes", {0, 1}}}})},
{"nodes",
Json::array({{{"name", "Keep"}, {"extras", {{"faset_id", new_id()}}}},
{{"name", "Removed node"}, {"extras", {{"faset_id", new_id()}}}}})}};
atomic_write_json(source, gltf);
const auto initial_id = session.commands()
.call("faset_import", {{"path", "Assets/model.gltf"},
{"settings", {{"units", "metres"}}}})
.at("job")
.get<std::string>();
const auto initial = wait(session, ui, initial_id);
check(initial.at("state") == "succeeded", "Fixture initial import: " + initial.dump());
const auto asset_id = initial.at("result").at("asset_id").get<std::string>();
const auto initial_generation = initial.at("result").at("generation");
assets::AssetStore store(root / ".faset/cache");
const auto document_before = session.authoring().query(ui.current_document());
gltf["nodes"].erase(gltf["nodes"].end() - 1);
gltf["scenes"][0]["nodes"] = Json::array({0});
atomic_write_json(source, gltf);
click(ui, "tab-assets");
click(ui, "asset-refresh");
const auto freshness = session.commands().call("faset_assets", Json::object());
check(freshness.at("assets")[0].at("freshness").at("state") == "stale",
"MCP exposes changed-source freshness before reimport");
check(ui.widgets().find("asset-" + asset_id)->text.starts_with("Stale"),
"Manual asset list marks the changed source stale");
click(ui, "asset-" + asset_id);
auto before = jobs(session);
click(ui, "asset-import");
const auto conflict_id = new_job(session, before);
auto conflict = wait(session, ui, conflict_id);
check(conflict.at("state") == "conflict", "Deleted output must remain a conflict");
click(ui, "tab-jobs");
click(ui, "job-review-" + conflict_id);
const auto group = "import-conflict-" + conflict_id;
const auto removed_id = conflict["result"]["removed_output_ids"][0].get<std::string>();
const auto* output = ui.widgets().find(group + "-output-" + removed_id);
check(output && output->text.find("Removed node") != std::string::npos,
"Review lists exact removed identity and its last-good name");
check(store.current_manifest(asset_id).at("generation") == initial_generation,
"Showing a conflict must not accept the removal");
renderer.render(ui.snapshot());
renderer.capture(root / "import-removal-review.ppm");
gltf["nodes"][0]["translation"] = {1, 0, 0};
atomic_write_json(source, gltf);
before = jobs(session);
click(ui, group + "-accept");
const auto stale_id = new_job(session, before);
const auto stale = wait(session, ui, stale_id);
check(stale.at("state") == "conflict" &&
store.current_manifest(asset_id).at("generation") == initial_generation,
"Changed source refuses reviewed acceptance and keeps the old generation: " +
stale.dump());
click(ui, "tab-conflicts");
const auto stale_group = "import-conflict-" + stale_id;
check(ui.widgets().find(stale_group + "-diagnostic")->text.find("changed since review") !=
std::string::npos,
"Stale acceptance exposes the changed-generation diagnostic and new review");
before = jobs(session);
click(ui, stale_group + "-retry");
const auto fresh_id = new_job(session, before);
const auto fresh = wait(session, ui, fresh_id);
check(fresh.at("state") == "conflict", "Reimport creates a fresh removal review");
click(ui, "tab-jobs");
click(ui, "job-review-" + fresh_id);
check(!ui.widgets().find(group), "New conflict supersedes the explicitly retried review");
before = jobs(session);
click(ui, "import-conflict-" + fresh_id + "-accept");
const auto accepted_id = new_job(session, before);
const auto accepted = wait(session, ui, accepted_id);
check(accepted.at("state") == "succeeded", "Explicit reviewed removal must publish");
check(store.current_manifest(asset_id).at("generation") == fresh["result"]["generation"],
"UI publishes exactly the reviewed candidate");
check(store.current_manifest(asset_id).at("settings").at("units") == "metres",
"Retry preserves the saved import settings");
click(ui, "tab-conflicts");
check(ui.widgets().find("conflicts-empty"),
"Resolved import conflicts leave the review list");
check(session.authoring().query(ui.current_document()) == document_before,
"Import approval never rewrites the authoring scene or its Undo revision");
click(ui, "tab-assets");
click(ui, "asset-refresh");
check(ui.widgets().find("asset-" + asset_id)->text.starts_with("Imported"),
"Successful import clears stale status in the same Editor session");
renderer.render(ui.snapshot());
check(renderer.stats().validation_errors == 0, "Vulkan validation");
std::cout << "Import UI: removed IDs/names, stale review rejection, explicit retry and "
"exact-generation acceptance 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;
}
}