Checkpoint 5: complete asset freshness, schema migrations and editor diagnostics
This commit is contained in:
@@ -115,6 +115,8 @@ int main() {
|
||||
glb(source);
|
||||
auto first = pipeline.import_asset({source});
|
||||
success(first);
|
||||
require(pipeline.freshness(first.asset_id).at("state") == "current",
|
||||
"new import must be current");
|
||||
require(first.manifest.at("input_key").at("target_profile") == "desktop-static-pbr-v1" &&
|
||||
first.manifest.at("input_key")
|
||||
.at("toolchain")
|
||||
@@ -151,8 +153,13 @@ int main() {
|
||||
require(unchanged.cache_hit && unchanged.generation == first.generation,
|
||||
"content cache hit");
|
||||
glb(source, "Renamed panel", true, false, .25f);
|
||||
require(pipeline.freshness(first.asset_id).at("state") == "stale" &&
|
||||
pipeline.current_manifest(first.asset_id).at("generation") == first.generation,
|
||||
"source edit must mark stale without publishing a generation");
|
||||
auto modified = pipeline.import_asset({source});
|
||||
success(modified);
|
||||
require(pipeline.freshness(first.asset_id).at("state") == "current",
|
||||
"successful reimport must clear stale state");
|
||||
require(modified.asset_id == first.asset_id && modified.generation != first.generation,
|
||||
"stable asset identity and changed generation");
|
||||
asset = pipeline.load_asset(first.asset_id);
|
||||
@@ -243,11 +250,36 @@ int main() {
|
||||
save(root / "external.gltf", external.dump());
|
||||
auto ext = pipeline.import_asset({root / "external.gltf"});
|
||||
success(ext);
|
||||
const auto sidecar = root / "external.gltf.faset-import.json";
|
||||
const auto recipe = faset::read_json(sidecar);
|
||||
auto changed_recipe = recipe;
|
||||
changed_recipe["settings"]["target"] = "changed-profile";
|
||||
faset::atomic_write_json(sidecar, changed_recipe);
|
||||
require(pipeline.freshness(ext.asset_id).at("state") == "stale",
|
||||
"changed sidecar must mark imported asset stale");
|
||||
faset::atomic_write_json(sidecar, recipe);
|
||||
const auto image_path = root / faset::path_from_utf8("пиксель.png");
|
||||
const auto original_time = fs::last_write_time(image_path);
|
||||
auto altered_image = png;
|
||||
altered_image.back() ^= 1;
|
||||
save(image_path, altered_image);
|
||||
fs::last_write_time(image_path, original_time);
|
||||
require(pipeline.freshness(ext.asset_id).at("state") == "stale",
|
||||
"external dependency content change with same size/time must be detected");
|
||||
fs::remove(image_path);
|
||||
require(pipeline.freshness(ext.asset_id).at("state") == "stale",
|
||||
"missing source dependency must be diagnosed");
|
||||
save(image_path, png);
|
||||
require(pipeline.freshness(ext.asset_id).at("state") == "current",
|
||||
"restoring exact recipe and dependency must clear stale state");
|
||||
auto ext_asset = pipeline.load_asset(ext.asset_id);
|
||||
require(ext_asset.textures.size() == 1 && ext_asset.textures[0].bytes.size() == png.size(),
|
||||
"external image not extracted");
|
||||
require(ext_asset.materials[0].base_color_texture == 0, "material texture reference lost");
|
||||
save(root / faset::path_from_utf8("геометрия.bin"), geometry(.3f));
|
||||
require(pipeline.freshness(ext.asset_id).at("reasons")[0].at("code") ==
|
||||
"dependency.changed",
|
||||
"external geometry change must expose its reason");
|
||||
auto dependent = pipeline.import_asset({root / "external.gltf"});
|
||||
success(dependent);
|
||||
require(dependent.generation != ext.generation, "buffer dependency not invalidated");
|
||||
@@ -263,12 +295,16 @@ int main() {
|
||||
save(bundle_dir / "manifest.json", bundle.dump());
|
||||
auto bundle_first = pipeline.import_asset({bundle_dir / "manifest.json"});
|
||||
success(bundle_first);
|
||||
require(pipeline.freshness(bundle_first.asset_id).at("state") == "current",
|
||||
"new Blender bundle must be current");
|
||||
require(bundle_first.asset_id == "bundle-asset", "bundle identity lost");
|
||||
glb(bundle_dir / "payload" / "second.glb", "Bundle renamed", true, false, .4f);
|
||||
bundle["files"][0] = {
|
||||
{"path", "payload/second.glb"},
|
||||
{"sha256", faset::sha256_file(bundle_dir / "payload" / "second.glb")}};
|
||||
save(bundle_dir / "manifest.json", bundle.dump());
|
||||
require(pipeline.freshness(bundle_first.asset_id).at("state") == "stale",
|
||||
"new bundle publication must invalidate freshness before reimport");
|
||||
auto bundle_second = pipeline.import_asset({bundle_dir / "manifest.json"});
|
||||
success(bundle_second);
|
||||
require(bundle_second.asset_id == bundle_first.asset_id &&
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <faset/editor/commands.hpp>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
|
||||
using namespace faset;
|
||||
namespace fs = std::filesystem;
|
||||
@@ -12,15 +14,120 @@ void check(bool value, std::string_view message) {
|
||||
throw std::runtime_error(std::string(message));
|
||||
}
|
||||
Json manifest() {
|
||||
return {
|
||||
{"format", "faset.schema"},
|
||||
{"version", 1},
|
||||
{"types",
|
||||
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(
|
||||
{{{"id", "game.mover"},
|
||||
{"version", 2},
|
||||
{"fields",
|
||||
{{"speed", {{"type", "number"}, {"default", 2.5}, {"min", 0}, {"max", 10}}}}}}})}};
|
||||
{{{"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 =
|
||||
@@ -51,6 +158,7 @@ int test_main(int argc, char** argv) {
|
||||
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"},
|
||||
@@ -100,6 +208,22 @@ int test_main(int argc, char** argv) {
|
||||
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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "assets_image_fixtures.hpp"
|
||||
#include <bit>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
@@ -380,6 +381,41 @@ int test_main(int argc, char** argv) {
|
||||
"Unknown component type blocks cooking");
|
||||
require(read_text(service.config().cache_root / "last_cook.json") == previous,
|
||||
"Unknown schema preserves last good generation");
|
||||
const auto picture = config.project_root / "Assets" / path_from_utf8("Freshness Café.png");
|
||||
auto write_picture = [&](const auto& bytes) {
|
||||
atomic_write(picture,
|
||||
std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size()));
|
||||
};
|
||||
write_picture(test_images::png_red_green);
|
||||
assets::AssetPipeline importer(service.config().cache_root);
|
||||
const auto imported = importer.import_asset({picture});
|
||||
require(imported.ok(), "Freshness fixture imports a real image");
|
||||
auto textured = scene(2);
|
||||
textured["entities"].push_back(
|
||||
{{"id", "sprite"},
|
||||
{"components", Json::array({{{"type", "faset.sprite"},
|
||||
{"version", 1},
|
||||
{"fields", {{"texture", imported.asset_id}}}}})}});
|
||||
require(service.wait(service.start_cook(textured)).state == "succeeded",
|
||||
"Current referenced assets can be cooked");
|
||||
const auto fresh_pointer = read_text(service.config().cache_root / "last_cook.json");
|
||||
write_picture(test_images::png_blue_white);
|
||||
for (const auto& stale :
|
||||
{service.wait(service.start_cook(textured)),
|
||||
service.wait(service.start_export(textured, temporary / "export"))}) {
|
||||
require(stale.state == "failed" && stale.error.find("Reimport") != std::string::npos,
|
||||
"Stale source blocks cook and export before compilation/publication");
|
||||
require(read_text(service.config().cache_root / "last_cook.json") == fresh_pointer,
|
||||
"Stale source preserves the published cook pointer");
|
||||
}
|
||||
require(!fs::exists(temporary / "export" / "current.json"),
|
||||
"Stale export never publishes a game pointer");
|
||||
require(importer.import_asset({picture}).ok() &&
|
||||
service.wait(service.start_cook(textured)).state == "succeeded",
|
||||
"Explicit reimport permits cooking the refreshed generation");
|
||||
fs::remove(picture);
|
||||
require(service.wait(service.start_cook(textured)).state == "failed",
|
||||
"Unavailable source cannot silently cook old cached content");
|
||||
std::cout << "Literal process arguments, pipes, cancellation, scaffold and atomic cook "
|
||||
"contracts passed\n";
|
||||
fs::remove_all(temporary);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#include <faset/editor/debug_overlay.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
using namespace faset;
|
||||
void require(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
try {
|
||||
render::Renderer renderer({640, 420, "Faset developer diagnostics test", true, true});
|
||||
editor::DebugOverlay overlay;
|
||||
render::Snapshot scene;
|
||||
scene.clear_color = {.05f, .06f, .07f, 1};
|
||||
overlay.append(scene, renderer, 1.f / 60.f);
|
||||
require(!overlay.visible() && scene.ui_triangles.empty(), "Diagnostics start hidden");
|
||||
renderer.render(scene);
|
||||
const auto clean = renderer.pixels();
|
||||
render::Event toggle;
|
||||
toggle.type = render::Event::Type::KeyDown;
|
||||
toggle.key = "F12";
|
||||
require(overlay.process_events(std::span(&toggle, 1)).empty() && overlay.visible(),
|
||||
"F12 is consumed and shows the diagnostic overlay");
|
||||
overlay.append(scene, renderer, 1.f / 60.f);
|
||||
require(!scene.ui_triangles.empty() && scene.ui_triangles.front().texture,
|
||||
"ImGui draw data reaches Faset's textured UI triangle API");
|
||||
renderer.render(scene);
|
||||
auto shown = renderer.pixels();
|
||||
std::size_t changed{};
|
||||
for (std::size_t i = 0; i < shown.size(); ++i)
|
||||
changed += shown[i] != clean[i];
|
||||
require(changed > 5000, "Diagnostic panel and text alter actual Vulkan pixels");
|
||||
render::Event inside;
|
||||
inside.type = render::Event::Type::MouseDown;
|
||||
inside.button = 1;
|
||||
inside.x = 30;
|
||||
inside.y = 65;
|
||||
require(overlay.process_events(std::span(&inside, 1)).empty(),
|
||||
"Pointer clicks in diagnostics must not edit the underlying scene");
|
||||
inside.type = render::Event::Type::MouseUp;
|
||||
inside.x = 600;
|
||||
require(overlay.process_events(std::span(&inside, 1)).empty(),
|
||||
"A diagnostic pointer gesture remains owned through release outside the panel");
|
||||
inside.type = render::Event::Type::MouseDown;
|
||||
require(overlay.process_events(std::span(&inside, 1)).size() == 1,
|
||||
"Pointer events outside diagnostics reach the retained editor");
|
||||
inside.type = render::Event::Type::MouseUp;
|
||||
inside.x = 30;
|
||||
require(overlay.process_events(std::span(&inside, 1)).size() == 1,
|
||||
"Editor-owned release remains forwarded after crossing into diagnostics");
|
||||
render::Event move = inside;
|
||||
move.type = render::Event::Type::MouseMove;
|
||||
render::Event wheel;
|
||||
wheel.type = render::Event::Type::Wheel;
|
||||
wheel.y = 1;
|
||||
const render::Event inside_wheel[] = {move, wheel};
|
||||
require(overlay.process_events(inside_wheel).empty(),
|
||||
"Move and wheel in one event batch must not scroll the underlying editor");
|
||||
move.x = 600;
|
||||
const render::Event outside_wheel[] = {move, wheel};
|
||||
require(overlay.process_events(outside_wheel).size() == 2,
|
||||
"Move outside and wheel immediately return to the retained editor");
|
||||
if (argc > 1)
|
||||
renderer.capture(argv[1]);
|
||||
require(overlay.process_events(std::span(&toggle, 1)).empty() && !overlay.visible(),
|
||||
"F12 hides diagnostics again");
|
||||
scene.ui_triangles.clear();
|
||||
overlay.append(scene, renderer, 1.f / 60.f);
|
||||
require(scene.ui_triangles.empty(), "Hidden diagnostics emit no geometry");
|
||||
renderer.render(scene);
|
||||
require(renderer.pixels() == clean, "Hiding diagnostics restores the underlying frame");
|
||||
require(renderer.stats().validation_errors == 0, "Diagnostic overlay Vulkan validation");
|
||||
std::cout << "ImGui diagnostics, F12, font atlas, clipping and event isolation passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,12 @@ int main() {
|
||||
atomic_write_json(source, gltf);
|
||||
click(ui, "tab-assets");
|
||||
click(ui, "asset-refresh");
|
||||
click(ui, "file-Assets/model.gltf");
|
||||
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);
|
||||
@@ -154,6 +159,10 @@ int main() {
|
||||
"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 "
|
||||
|
||||
@@ -43,6 +43,176 @@ void text(editor::EditorUI& ui, const std::string& id, const std::string& value,
|
||||
events.push_back(key("Return"));
|
||||
ui.frame(events);
|
||||
}
|
||||
void migration_workflow(render::Renderer& renderer, const std::filesystem::path& root) {
|
||||
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||
const auto gameplay_manifest = Json::parse(R"({"types":[
|
||||
{"id":"test.migratable","version":2,"name":"Movement","fields":{
|
||||
"speed":{"id":"speed","type":"number","default":1.0},
|
||||
"enabled":{"id":"enabled","type":"bool","default":true}},
|
||||
"migrations":[{"from_version":1,"fields":{"speed":{"scale":0.01},"enabled":{"default":true}}}]},
|
||||
{"id":"test.no_migration","version":2,"fields":{
|
||||
"speed":{"id":"speed","type":"number","default":1.0}}}
|
||||
]})");
|
||||
session.authoring().replace_external_schemas(gameplay_manifest);
|
||||
auto object = authoring::make_entity(session.authoring().schemas(), "Old movement");
|
||||
const auto cid = new_id();
|
||||
object["components"].push_back({{"id", cid},
|
||||
{"type", "test.migratable"},
|
||||
{"version", 1},
|
||||
{"fields", {{"speed", 250.0}, {"unknown", "retained"}}}});
|
||||
const auto old = object["components"].back();
|
||||
auto source_scene = authoring::make_scene("Old source");
|
||||
source_scene["entities"].push_back(object);
|
||||
atomic_write_json(root / "Assets/old-source.fscene", source_scene);
|
||||
const auto doc = session.authoring().create("Migration test").at("id").get<std::string>();
|
||||
const auto instance = new_id();
|
||||
session.authoring().transact(
|
||||
doc, 0,
|
||||
Json::array({{{"op", "entity.create"}, {"entity", object}},
|
||||
{{"op", "template.instance"},
|
||||
{"instance", {{"id", instance}, {"source", "Assets/old-source.fscene"}}}}}));
|
||||
session.authoring().save(doc, "Scenes/migration.fscene");
|
||||
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({});
|
||||
// Explicit keyboard activation also exercises focus reveal in a long Inspector.
|
||||
auto activate = [&](const std::string& id) {
|
||||
check(ui.widgets().focus(id), "Migration workflow action must be focusable");
|
||||
ui.frame({key("Return")});
|
||||
};
|
||||
ui.select_entity(object.at("id"));
|
||||
ui.frame({});
|
||||
check(ui.widgets().find("opaque-fields-" + cid) &&
|
||||
!ui.widgets().find("opaque-fields-" + cid)->enabled,
|
||||
"Older component opens preserved and read-only before explicit migration");
|
||||
const auto before = session.authoring().query(doc);
|
||||
activate("component-migrate-" + cid);
|
||||
auto after = session.authoring().query(doc);
|
||||
const auto migrated = after["scene"]["entities"][0]["components"].back();
|
||||
check(after["revision"] == before["revision"].get<std::uint64_t>() + 1 &&
|
||||
migrated["id"] == cid && migrated["version"] == 2 &&
|
||||
migrated["fields"]["speed"] == 2.5 && migrated["fields"]["enabled"] == true &&
|
||||
migrated["fields"]["unknown"] == "retained",
|
||||
"Inspector Migrate applies declared rules atomically while preserving identity and data");
|
||||
check(ui.widgets().find("field-" + cid + "-speed-value") &&
|
||||
!ui.widgets().find("component-migrate-" + cid),
|
||||
"Successful migration replaces opaque presentation with typed fields");
|
||||
activate("undo");
|
||||
check(session.authoring().query(doc)["scene"]["entities"][0]["components"].back() == old &&
|
||||
ui.widgets().find("component-migrate-" + cid),
|
||||
"One Undo restores the original version and opaque Inspector");
|
||||
auto unresolved = object;
|
||||
unresolved["id"] = new_id();
|
||||
unresolved["name"] = "Missing rule";
|
||||
for (auto& c : unresolved["components"])
|
||||
c["id"] = new_id();
|
||||
unresolved["components"].back()["type"] = "test.no_migration";
|
||||
const auto unresolved_cid = unresolved["components"].back()["id"].get<std::string>();
|
||||
auto local = object;
|
||||
local["id"] = new_id();
|
||||
for (auto& c : local["components"])
|
||||
c["id"] = new_id();
|
||||
after = session.authoring().query(doc);
|
||||
session.authoring().transact(
|
||||
doc, after["revision"],
|
||||
Json::array({{{"op", "entity.create"}, {"entity", unresolved}},
|
||||
{{"op", "template.add"}, {"instance", instance}, {"value", local}}}));
|
||||
ui.select_entity(unresolved.at("id"));
|
||||
ui.frame({});
|
||||
const auto failed_before = session.authoring().query(doc);
|
||||
activate("component-migrate-" + unresolved_cid);
|
||||
check(session.authoring().query(doc) == failed_before &&
|
||||
ui.widgets().find("status")->text.find("migration") != std::string::npos,
|
||||
"Missing migration reports an actionable error without revision or source changes");
|
||||
auto resolved = session.commands().resolved_scene(doc).at("scene").at("entities");
|
||||
for (const auto& e : resolved) {
|
||||
if (!e.contains("origin") || e["origin"].value("path", Json::array()).empty())
|
||||
continue;
|
||||
if (e["origin"].value("local", false)) {
|
||||
ui.select_entity(e.at("id"));
|
||||
ui.frame({});
|
||||
activate("component-migrate-" + e["components"].back()["id"].get<std::string>());
|
||||
const auto record =
|
||||
session.authoring().query(doc)["scene"]["instances"][0]["additions"][0];
|
||||
check(
|
||||
record["id"] == local["id"] &&
|
||||
record["components"].back()["id"] == local["components"].back()["id"] &&
|
||||
record["components"].back()["version"] == 2,
|
||||
"Local-addition migration addresses original source IDs, not resolved runtime IDs");
|
||||
activate("undo");
|
||||
check(session.authoring().query(doc)["scene"]["instances"][0]["additions"][0] == local,
|
||||
"Local-addition migration is one undoable transaction");
|
||||
}
|
||||
}
|
||||
for (const auto& e : resolved) {
|
||||
if (!e.contains("origin") || e["origin"].value("path", Json::array()).empty() ||
|
||||
e["origin"].value("local", false))
|
||||
continue;
|
||||
ui.select_entity(e.at("id"));
|
||||
ui.frame({});
|
||||
const auto action = "component-migrate-" + e["components"].back()["id"].get<std::string>();
|
||||
check(ui.widgets().find(action)->text == "Open source to migrate",
|
||||
"Inherited migration explicitly navigates to the owning source");
|
||||
const auto main_before = session.authoring().query(doc);
|
||||
activate(action);
|
||||
check(ui.current_document() == source_scene.at("id").get<std::string>() &&
|
||||
session.authoring().query(doc) == main_before &&
|
||||
session.authoring()
|
||||
.query(ui.current_document())["scene"]["entities"][0]["components"]
|
||||
.back() == old &&
|
||||
ui.widgets().find("component-migrate-" + cid),
|
||||
"Opening source keeps old data unchanged and offers explicit migration there");
|
||||
break;
|
||||
}
|
||||
// A metadata rebuild must invalidate the cached resolved scene even when
|
||||
// neither the document nor its open source has changed revision.
|
||||
auto v1 = gameplay_manifest;
|
||||
v1["types"][0]["version"] = 1;
|
||||
v1["types"][0].erase("migrations");
|
||||
v1["types"][0]["fields"].erase("enabled");
|
||||
session.authoring().replace_external_schemas(v1);
|
||||
auto main = session.authoring().query(doc);
|
||||
session.authoring().transact(doc, main.at("revision"),
|
||||
Json::array({{{"op", "template.override"},
|
||||
{"instance", instance},
|
||||
{"address",
|
||||
{{"path", Json::array()},
|
||||
{"object", object.at("id")},
|
||||
{"component", cid},
|
||||
{"field", "speed"}}},
|
||||
{"value", 900.0}}}));
|
||||
ui.select_document(doc);
|
||||
std::string inherited_id, inherited_cid;
|
||||
for (const auto& e : resolved)
|
||||
if (e.contains("origin") && !e["origin"].value("path", Json::array()).empty() &&
|
||||
!e["origin"].value("local", false)) {
|
||||
inherited_id = e.at("id");
|
||||
inherited_cid = e["components"].back().at("id");
|
||||
break;
|
||||
}
|
||||
check(!inherited_id.empty(), "Migration schema-cache fixture has an inherited object");
|
||||
ui.select_entity(inherited_id);
|
||||
ui.frame({});
|
||||
const auto field_id = "field-" + inherited_cid + "-speed-value";
|
||||
check(ui.widgets().find(field_id) && ui.widgets().find(field_id)->value == 900.0 &&
|
||||
!ui.widgets().find("conflict-0-text"),
|
||||
"V1 schema applies and caches the inherited field override");
|
||||
const auto documents_before = session.authoring().documents();
|
||||
session.authoring().replace_external_schemas(gameplay_manifest);
|
||||
ui.frame({});
|
||||
const auto* conflict = ui.widgets().find("conflict-0-text");
|
||||
check(session.authoring().documents() == documents_before && conflict &&
|
||||
conflict->text.starts_with("override.schema_version") &&
|
||||
!ui.widgets().find(field_id) && ui.widgets().find("opaque-fields-" + inherited_cid),
|
||||
"Schema-only rebuild refreshes resolved conflicts and opaque fields without authoring "
|
||||
"edits");
|
||||
session.authoring().replace_external_schemas(v1);
|
||||
ui.frame({});
|
||||
check(!ui.widgets().find("conflict-0-text") && ui.widgets().find(field_id) &&
|
||||
ui.widgets().find(field_id)->value == 900.0,
|
||||
"Restoring compatible metadata clears cached conflicts and reapplies retained override");
|
||||
}
|
||||
int main() {
|
||||
auto root =
|
||||
std::filesystem::temp_directory_path() / path_from_utf8("faset-ui-проект-" + new_id());
|
||||
@@ -253,6 +423,7 @@ int main() {
|
||||
check(ui.project_switch_requested(), "Confirmed project switch is exposed to application");
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(root / "editor-ui.ppm");
|
||||
migration_workflow(renderer, root / "migration-workflow");
|
||||
check(renderer.stats().validation_errors == 0, "Vulkan validation errors");
|
||||
std::cout << "Editor UI: actual events create/select/rename/typed "
|
||||
"fields/Undo/Redo/conflict/one drag transaction passed. "
|
||||
|
||||
@@ -41,6 +41,11 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
bool visible = argc > 1 && std::string(argv[1]) == "--visible";
|
||||
Renderer renderer({320, 240, "Faset render validation", !visible, true});
|
||||
if (!visible) {
|
||||
renderer.set_clipboard("Offscreen Café 世界");
|
||||
require(renderer.clipboard() == "Offscreen Café 世界",
|
||||
"Offscreen clipboard is local and does not require SDL video");
|
||||
}
|
||||
Snapshot scene;
|
||||
scene.eye = {4, 3, 5};
|
||||
scene.view_projection =
|
||||
@@ -58,14 +63,27 @@ int main(int argc, char** argv) {
|
||||
texture->width = texture->height = 1;
|
||||
texture->rgba = {20, 220, 40, 255};
|
||||
scene.ui_quads.push_back({260, 8, 40, 20, {1, 1, 1, 1}, texture});
|
||||
UiTriangles diagnostic;
|
||||
diagnostic.vertices = {{{80, 8, 0}, {}, {0, 0, 1, 1}},
|
||||
{{150, 8, 0}, {}, {0, 0, 1, 1}},
|
||||
{{80, 40, 0}, {}, {0, 0, 1, 1}}};
|
||||
diagnostic.clip_rect = {90, 8, 30, 32};
|
||||
scene.ui_triangles.push_back(diagnostic);
|
||||
renderer.render(scene);
|
||||
require(renderer.stats().validation_errors == 0, "Vulkan validation reported an error");
|
||||
if (renderer.stats().validation_enabled)
|
||||
require(renderer.stats().gpu_labels_enabled,
|
||||
"Validation context exposes GPU pass labels");
|
||||
require(!renderer.stats().gpu_labels_enabled || renderer.stats().gpu_label_count >= 3,
|
||||
"Every submitted render graph pass receives a GPU label");
|
||||
auto pixels = renderer.pixels();
|
||||
require(pixels.size() == 320 * 240 * 4, "Readback dimensions");
|
||||
auto index = (10 * 320 + 10) * 4;
|
||||
require(pixels[index] > 190 && pixels[index + 1] < 50, "Colored UI pixel");
|
||||
index = (10 * 320 + 270) * 4;
|
||||
require(pixels[index] < 30 && pixels[index + 1] > 200, "Textured UI pixel");
|
||||
require(pixels[(12 * 320 + 95) * 4 + 2] > 240 && pixels[(12 * 320 + 85) * 4 + 2] < 200,
|
||||
"UI diagnostic triangles obey their per-command clip rectangle");
|
||||
texture->srgb = true;
|
||||
++texture->revision;
|
||||
renderer.render(scene);
|
||||
|
||||
@@ -113,10 +113,113 @@ void display_scale_contract() {
|
||||
check(panel.scroll_y == 60 && panel.children[1]->rect.y == panel.rect.y,
|
||||
"DPI changes retain the logical scroll position");
|
||||
}
|
||||
void keyboard_scroll_contract() {
|
||||
for (float scale : {1.f, 2.f}) {
|
||||
ui::Context context(path_from_utf8(FASET_TEST_FONT));
|
||||
auto& columns = context.root().add(ui::Kind::Row, "panels");
|
||||
columns.layout.height = 150;
|
||||
auto& inspector = columns.add(ui::Kind::Column, "inspector");
|
||||
inspector.layout.width = 200;
|
||||
inspector.layout.padding = 8;
|
||||
inspector.layout.gap = 4;
|
||||
inspector.layout.scroll = true;
|
||||
auto& assets = columns.add(ui::Kind::Column, "assets");
|
||||
assets.layout.width = 200;
|
||||
assets.layout.padding = 8;
|
||||
assets.layout.gap = 3;
|
||||
assets.layout.scroll = true;
|
||||
int commits = 0, activations = 0;
|
||||
for (int i = 0; i < 30; ++i) {
|
||||
auto& field = inspector.add(ui::Kind::TextField, "property-" + std::to_string(i),
|
||||
"Property " + std::to_string(i));
|
||||
field.layout.height = 30;
|
||||
field.on_commit = [&](ui::Widget&) { ++commits; };
|
||||
auto& asset = assets.add(ui::Kind::Button, "asset-" + std::to_string(i), "Model.glb");
|
||||
asset.layout.height = 28;
|
||||
asset.on_click = [&](ui::Widget&) { ++activations; };
|
||||
}
|
||||
ui::Rect ime;
|
||||
context.set_ime([](bool) {}, [&](ui::Rect rect) { ime = rect; });
|
||||
context.layout(440 * scale, 200 * scale, scale);
|
||||
check(context.find("property-29")->clip.height == 0 &&
|
||||
context.find("asset-29")->clip.height == 0,
|
||||
"Long Inspector and Assets start with offscreen controls");
|
||||
auto visible = [&](const std::string& id) {
|
||||
const auto* widget = context.find(id);
|
||||
const auto r = widget->rect.intersection(widget->clip);
|
||||
check(context.focused_id() == id && std::abs(r.height - widget->rect.height) < .01f &&
|
||||
std::abs(r.width - widget->rect.width) < .01f,
|
||||
"Keyboard focus must reveal the entire control in its clipped panel");
|
||||
};
|
||||
for (int i = 0; i < 30; ++i) {
|
||||
context.handle(key("Tab"));
|
||||
visible("property-" + std::to_string(i));
|
||||
check(ime.y == context.find(context.focused_id())->rect.y,
|
||||
"IME rectangle follows the newly scrolled field");
|
||||
}
|
||||
context.handle(key("A", true));
|
||||
context.handle(text("Изменено"));
|
||||
const float inspector_scroll = inspector.scroll_y;
|
||||
for (int i = 0; i < 30; ++i) {
|
||||
context.handle(key("Tab"));
|
||||
visible("asset-" + std::to_string(i));
|
||||
context.handle(key("Return"));
|
||||
}
|
||||
check(commits == 1 && activations == 30 && inspector.scroll_y == inspector_scroll,
|
||||
"Focus scroll commits one draft, activates assets and leaves unrelated panel alone");
|
||||
for (int i = 28; i >= 0; --i) {
|
||||
context.handle(key("Tab", false, true));
|
||||
visible("asset-" + std::to_string(i));
|
||||
}
|
||||
for (int i = 29; i >= 0; --i) {
|
||||
context.handle(key("Tab", false, true));
|
||||
visible("property-" + std::to_string(i));
|
||||
}
|
||||
check(inspector.scroll_y == 0 && assets.scroll_y == 0 && commits == 1,
|
||||
"Reverse keyboard traversal returns both panels to the top without edits");
|
||||
context.handle(text(" draft"));
|
||||
inspector.scroll_y = inspector_scroll;
|
||||
context.layout(440 * scale, 200 * scale, scale);
|
||||
check(context.find("property-0")->clip.height == 0,
|
||||
"A manual scroll may move an active field out of view");
|
||||
check(context.focus("property-0"), "Refocus active field");
|
||||
visible("property-0");
|
||||
check(context.find("property-0")->text == "Property 0 draft" && commits == 1,
|
||||
"Revealing the current focus preserves its uncommitted edit");
|
||||
}
|
||||
ui::Context nested(path_from_utf8(FASET_TEST_FONT));
|
||||
auto& outer = nested.root().add(ui::Kind::Column, "outer");
|
||||
outer.layout.height = 120;
|
||||
outer.layout.padding = 6;
|
||||
outer.layout.scroll = true;
|
||||
outer.add(ui::Kind::Label, "spacer").layout.height = 240;
|
||||
auto& inner = outer.add(ui::Kind::Column, "inner");
|
||||
inner.layout.height = 85;
|
||||
inner.layout.padding = 5;
|
||||
inner.layout.scroll = true;
|
||||
for (int i = 0; i < 15; ++i)
|
||||
inner.add(ui::Kind::TextField, "nested-" + std::to_string(i)).layout.height = 30;
|
||||
nested.layout(260, 160);
|
||||
check(nested.focus("nested-14"), "Focus deeply clipped control");
|
||||
auto* target = nested.find("nested-14");
|
||||
check(outer.scroll_y > 0 && inner.scroll_y > 0 && target->clip.height == target->rect.height,
|
||||
"Focus reveals a control through both initially clipped scroll ancestors");
|
||||
auto& invalid = inner.add(ui::Kind::NumberField, "invalid-number");
|
||||
invalid.layout.height = 30;
|
||||
nested.layout(260, 160);
|
||||
nested.focus("invalid-number");
|
||||
nested.handle(text("bad number"));
|
||||
const auto old_outer = outer.scroll_y, old_inner = inner.scroll_y;
|
||||
nested.handle(key("Tab"));
|
||||
check(nested.focused_id() == "invalid-number" && !invalid.error.empty() &&
|
||||
outer.scroll_y == old_outer && inner.scroll_y == old_inner,
|
||||
"Rejected numeric commit retains focus and scroll instead of hiding the error");
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
try {
|
||||
display_scale_contract();
|
||||
keyboard_scroll_contract();
|
||||
ui::TextBuffer buffer("Привет");
|
||||
check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint");
|
||||
check(buffer.undo() && buffer.text() == "Привет", "text undo");
|
||||
|
||||
Reference in New Issue
Block a user