Checkpoint 5: complete asset freshness, schema migrations and editor diagnostics

This commit is contained in:
Emil
2026-09-18 05:43:02 +03:00
parent e0b965166e
commit 0f34b03631
37 changed files with 1438 additions and 52 deletions
+36
View File
@@ -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 &&
+132 -8
View File
@@ -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",
+36
View File
@@ -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);
+80
View File
@@ -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;
}
}
+10 -1
View File
@@ -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 "
+171
View File
@@ -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. "
+18
View File
@@ -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);
+103
View File
@@ -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");