Checkpoint 3: deliver playable samples and complete native authoring workflows
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
// Deterministic two-pixel test images generated locally; no external media.
|
||||
namespace faset::test_images {
|
||||
inline const std::vector<unsigned char> png_red_green = {
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0,
|
||||
2, 0, 0, 0, 1, 8, 6, 0, 0, 0, 244, 34, 127, 138, 0, 0, 0, 17, 73,
|
||||
68, 65, 84, 120, 218, 99, 248, 207, 192, 240, 159, 225, 63, 67, 3, 0, 16, 121, 3,
|
||||
126, 92, 47, 98, 147, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130};
|
||||
inline const std::vector<unsigned char> png_blue_white = {
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0,
|
||||
2, 0, 0, 0, 1, 8, 6, 0, 0, 0, 244, 34, 127, 138, 0, 0, 0, 17, 73,
|
||||
68, 65, 84, 120, 218, 99, 100, 96, 248, 255, 255, 255, 127, 6, 6, 0, 18, 0, 3,
|
||||
254, 224, 181, 163, 188, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130};
|
||||
inline const std::vector<unsigned char> jpeg_red_green = {
|
||||
255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 0, 1, 1, 0, 0, 1, 0, 1, 0,
|
||||
0, 255, 219, 0, 67, 0, 2, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2, 2, 2,
|
||||
2, 2, 4, 3, 2, 2, 2, 2, 5, 4, 4, 3, 4, 6, 5, 6, 6, 6, 5,
|
||||
6, 6, 6, 7, 9, 8, 6, 7, 9, 7, 6, 6, 8, 11, 8, 9, 10, 10, 10,
|
||||
10, 10, 6, 8, 11, 12, 11, 10, 12, 9, 10, 10, 10, 255, 219, 0, 67, 1, 2,
|
||||
2, 2, 2, 2, 2, 5, 3, 3, 5, 10, 7, 6, 7, 10, 10, 10, 10, 10, 10,
|
||||
10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
|
||||
10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
|
||||
10, 10, 10, 10, 10, 10, 255, 192, 0, 17, 8, 0, 1, 0, 2, 3, 1, 34, 0,
|
||||
2, 17, 1, 3, 17, 1, 255, 196, 0, 21, 0, 1, 1, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 255, 196, 0, 27, 16, 1, 0, 0, 7,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 4, 6, 7, 55, 118,
|
||||
180, 255, 196, 0, 20, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 9, 255, 196, 0, 32, 17, 0, 0, 3, 9, 1, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 2, 4, 1, 3, 5, 7, 52, 55, 114, 116, 177, 179,
|
||||
255, 218, 0, 12, 3, 1, 0, 2, 17, 3, 17, 0, 63, 0, 179, 179, 24, 122, 147,
|
||||
214, 164, 57, 224, 0, 92, 174, 173, 123, 145, 186, 208, 70, 205, 27, 153, 28, 220, 83,
|
||||
236, 113, 255, 217};
|
||||
} // namespace faset::test_images
|
||||
+112
-1
@@ -1,3 +1,4 @@
|
||||
#include "assets_image_fixtures.hpp"
|
||||
#include <bit>
|
||||
#include <chrono>
|
||||
#include <faset/assets/asset_pipeline.hpp>
|
||||
@@ -111,6 +112,16 @@ int main() {
|
||||
glb(source);
|
||||
auto first = pipeline.import_asset({source});
|
||||
success(first);
|
||||
require(first.manifest.at("input_key").at("target_profile") == "desktop-static-pbr-v1" &&
|
||||
first.manifest.at("input_key")
|
||||
.at("toolchain")
|
||||
.at("cgltf")
|
||||
.get<std::string>()
|
||||
.size() == 40,
|
||||
"cache recipe omits the desktop profile or pinned importer toolchain");
|
||||
require(first.manifest.at("materials").at(0).at("format") == "faset.material" &&
|
||||
first.manifest.at("materials").at(0).at("version") == 1,
|
||||
"material data is not explicitly versioned");
|
||||
require(!first.asset_id.empty(), "persistent identity missing");
|
||||
auto asset = pipeline.load_asset(first.asset_id);
|
||||
require(asset.meshes.size() == 1 && asset.nodes.size() == 1, "mesh/node extraction");
|
||||
@@ -125,6 +136,13 @@ int main() {
|
||||
{"physics", {{"mass", 12}}},
|
||||
{"material", "custom-brass"}}}};
|
||||
pipeline.set_overrides(first.asset_id, custom);
|
||||
AssetPipeline rebuilt_cache(root / "rebuilt-cache");
|
||||
const auto recovered = rebuilt_cache.import_asset({source});
|
||||
success(recovered);
|
||||
require(recovered.asset_id == first.asset_id && recovered.generation == first.generation &&
|
||||
rebuilt_cache.load_asset(first.asset_id).nodes[0].id == node_id &&
|
||||
rebuilt_cache.overrides(first.asset_id) == custom,
|
||||
"fresh cache changed source identity, outputs, or authoring overrides");
|
||||
auto unchanged = pipeline.import_asset({source});
|
||||
success(unchanged);
|
||||
require(unchanged.cache_hit && unchanged.generation == first.generation,
|
||||
@@ -258,6 +276,99 @@ int main() {
|
||||
"concurrent dependency edit accepted");
|
||||
require(pipeline.load_asset(ext.asset_id).generation == dependency_active,
|
||||
"concurrent edit changed active");
|
||||
// Standalone images share the same identity, generation and failure guarantees.
|
||||
const auto picture = root / "sprite.PNG";
|
||||
save(picture, faset::test_images::png_red_green);
|
||||
ImportRequest image_request{picture};
|
||||
image_request.settings = {{"pixels_per_unit", 20.0}};
|
||||
auto image_first = pipeline.import_asset(image_request);
|
||||
success(image_first);
|
||||
auto image_asset = pipeline.load_asset(image_first.asset_id);
|
||||
require(image_asset.meshes.empty() && image_asset.textures.size() == 1,
|
||||
"Standalone PNG should produce one owned texture");
|
||||
require(image_asset.textures[0].mime_type == "image/png" &&
|
||||
image_first.manifest["kind"] == "image",
|
||||
"PNG kind/MIME");
|
||||
require(image_first.manifest["image"]["width"] == 2 &&
|
||||
image_first.manifest["image"]["height"] == 1 &&
|
||||
image_first.manifest["image"]["pixels_per_unit"] == 20.0,
|
||||
"PNG dimensions and sprite scale recipe");
|
||||
const auto texture_id = image_asset.textures[0].id;
|
||||
pipeline.set_overrides(image_first.asset_id, {{texture_id, {{"gameplay", "preserved"}}}});
|
||||
auto image_cache = pipeline.import_asset({picture});
|
||||
success(image_cache);
|
||||
require(image_cache.cache_hit && image_cache.generation == image_first.generation,
|
||||
"PNG cache/recipe persistence");
|
||||
save(picture, faset::test_images::png_blue_white);
|
||||
auto image_changed = pipeline.import_asset({picture});
|
||||
success(image_changed);
|
||||
require(image_changed.asset_id == image_first.asset_id &&
|
||||
image_changed.generation != image_first.generation,
|
||||
"PNG reimport content invalidation");
|
||||
require(pipeline.load_asset(image_first.asset_id).textures[0].id == texture_id,
|
||||
"Image subasset ID changed after content edit");
|
||||
image_request.settings = {{"pixels_per_unit", 40.0}};
|
||||
auto image_recipe = pipeline.import_asset(image_request);
|
||||
success(image_recipe);
|
||||
require(image_recipe.generation != image_changed.generation,
|
||||
"Image settings omitted from recipe hash");
|
||||
save(picture, std::string("broken PNG"));
|
||||
require(pipeline.import_asset({picture}).status == ImportStatus::failed,
|
||||
"Broken PNG published");
|
||||
require(pipeline.load_asset(image_first.asset_id).generation == image_recipe.generation,
|
||||
"Broken PNG replaced successful generation");
|
||||
save(picture, faset::test_images::jpeg_red_green);
|
||||
require(pipeline.import_asset({picture}).status == ImportStatus::failed,
|
||||
"Mismatched JPEG content accepted as PNG");
|
||||
auto oversized = faset::test_images::png_red_green;
|
||||
oversized[16] = 0;
|
||||
oversized[17] = 1;
|
||||
oversized[18] = 0;
|
||||
oversized[19] = 0;
|
||||
save(picture, oversized);
|
||||
require(pipeline.import_asset({picture}).status == ImportStatus::failed,
|
||||
"Oversized image accepted");
|
||||
save(picture, faset::test_images::png_red_green);
|
||||
ImportJob* image_job_pointer = nullptr;
|
||||
ImportJob image_job([&](const ImportProgress& progress) {
|
||||
if (progress.fraction >= .5f)
|
||||
image_job_pointer->cancel();
|
||||
});
|
||||
image_job_pointer = &image_job;
|
||||
require(pipeline.import_asset({picture}, image_job).status == ImportStatus::cancelled,
|
||||
"PNG cancellation ignored");
|
||||
require(pipeline.load_asset(image_first.asset_id).generation == image_recipe.generation,
|
||||
"Cancelled PNG replaced active generation");
|
||||
image_request.settings = {{"pixels_per_unit", 0}};
|
||||
require(pipeline.import_asset(image_request).status == ImportStatus::failed,
|
||||
"Zero pixels_per_unit accepted");
|
||||
save(picture, faset::test_images::png_blue_white);
|
||||
const auto renamed = root / "renamed.PNG";
|
||||
fs::rename(picture, renamed);
|
||||
fs::rename(picture.string() + ".faset-import.json",
|
||||
renamed.string() + ".faset-import.json");
|
||||
fs::rename(picture.string() + ".faset-overrides.json",
|
||||
renamed.string() + ".faset-overrides.json");
|
||||
auto image_rename = pipeline.import_asset({renamed});
|
||||
success(image_rename);
|
||||
require(image_rename.cache_hit && image_rename.asset_id == image_first.asset_id,
|
||||
"Image source rename with sidecar lost identity/cache");
|
||||
require(pipeline.current_manifest(image_first.asset_id)["source"] == renamed.string(),
|
||||
"Image rename retained old source pointer");
|
||||
require(pipeline.overrides(image_first.asset_id).contains(texture_id),
|
||||
"Image rename/reimport lost overrides");
|
||||
const auto jpeg = root / "sprite.JPEG";
|
||||
save(jpeg, faset::test_images::jpeg_red_green);
|
||||
auto jpeg_import = pipeline.import_asset({jpeg});
|
||||
success(jpeg_import);
|
||||
require(jpeg_import.manifest["image"]["width"] == 2 &&
|
||||
pipeline.load_asset(jpeg_import.asset_id).textures[0].mime_type == "image/jpeg",
|
||||
"JPEG decoding/dimensions");
|
||||
save(root / "same.png", faset::test_images::png_blue_white);
|
||||
auto other_image = pipeline.import_asset({root / "same.png"});
|
||||
success(other_image);
|
||||
require(other_image.asset_id != image_first.asset_id,
|
||||
"Independent same-content source must have independent AssetId");
|
||||
fs::remove_all(root / "cache");
|
||||
auto restored = pipeline.import_asset({source});
|
||||
success(restored);
|
||||
@@ -268,7 +379,7 @@ int main() {
|
||||
"cleared cache lost authoring overrides");
|
||||
fs::remove_all(root);
|
||||
std::cout << "assets: geometry/PBR/texture, GLB/glTF, cache, rename, deletion, overrides, "
|
||||
"failure, cancellation OK\n";
|
||||
"failure, cancellation, standalone PNG/JPEG OK\n";
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << "\nFixtures retained: " << root << '\n';
|
||||
|
||||
@@ -80,6 +80,28 @@ int main() {
|
||||
atomic_write(root / "Scenes/courtyard.scene.json",
|
||||
read_text(root / "Scenes/courtyard.scene.json") + "\n");
|
||||
fails([&] { restarted.save(id); }, "save.disk_conflict");
|
||||
const auto configured = service.create("Configured scene");
|
||||
const std::string configured_id = configured.at("id");
|
||||
const auto setting = service.transact(
|
||||
configured_id, 0,
|
||||
Json::array({{{"op", "scene.simulation"},
|
||||
{"value", {{"fixed_delta", 0.02}, {"gravity", {0, -10, 0}}}}}}));
|
||||
CHECK(setting["scene"]["simulation"]["fixed_delta"] == 0.02);
|
||||
fails(
|
||||
[&] {
|
||||
service.transact(configured_id, 1,
|
||||
Json::array({{{"op", "scene.simulation"},
|
||||
{"value", {{"max_catch_up_ticks", 1.5}}}}}));
|
||||
},
|
||||
"simulation.integer");
|
||||
fails(
|
||||
[&] {
|
||||
service.transact(
|
||||
configured_id, 1,
|
||||
Json::array({{{"op", "scene.simulation"}, {"value", {{"fixed_delta", 0}}}}}));
|
||||
},
|
||||
"simulation.fixed_delta");
|
||||
CHECK(!service.undo(configured_id, 1)["scene"].contains("simulation"));
|
||||
// Parent cycles are rejected atomically; names never provide identity.
|
||||
fails(
|
||||
[&] {
|
||||
@@ -90,6 +112,25 @@ int main() {
|
||||
},
|
||||
"entity.cycle");
|
||||
CHECK(service.query(id)["revision"] == 5);
|
||||
// A failed recovery publication rejects the entire mutation and its Undo record.
|
||||
const auto fault = service.create("Journal failure");
|
||||
const std::string fault_id = fault["id"];
|
||||
const auto journal = root / ".faset/recovery" / (fault_id + ".json");
|
||||
const auto saved_journal = read_text(journal);
|
||||
std::filesystem::remove(journal);
|
||||
std::filesystem::create_directory(journal);
|
||||
bool journal_failed = false;
|
||||
try {
|
||||
service.transact(
|
||||
fault_id, 0,
|
||||
Json::array({{{"op", "entity.create"}, {"name", "Must not publish"}}}));
|
||||
} catch (const std::exception&) {
|
||||
journal_failed = true;
|
||||
}
|
||||
CHECK(journal_failed);
|
||||
CHECK(service.query(fault_id) == fault);
|
||||
std::filesystem::remove(journal);
|
||||
atomic_write(journal, saved_journal);
|
||||
// Unavailable extension data is retained, including fields unknown to this SDK.
|
||||
auto unknown = make_entity(schemas, "Plugin object");
|
||||
unknown["components"].push_back({{"id", new_id()},
|
||||
@@ -127,6 +168,82 @@ int main() {
|
||||
source["entities"] = Json::array();
|
||||
CHECK(resolve_templates(outer, schemas, loader).conflicts.size() == 1);
|
||||
CHECK(outer["instances"][0]["overrides"].size() == 1);
|
||||
// Template deletion, suppression restore and source repair are normal Undo transactions.
|
||||
auto template_doc = service.create("Template management");
|
||||
const std::string template_id = template_doc["id"];
|
||||
service.transact(
|
||||
template_id, 0,
|
||||
Json::array({{{"op", "template.instance"},
|
||||
{"instance", {{"id", "inst"}, {"source", "Scenes/absent.json"}}}}}));
|
||||
const Json suppressed = {{"path", Json::array()}, {"object", entity_id}};
|
||||
service.transact(
|
||||
template_id, 1,
|
||||
Json::array(
|
||||
{{{"op", "template.suppress"}, {"instance", "inst"}, {"value", suppressed}}}));
|
||||
auto restored = service.transact(
|
||||
template_id, 2,
|
||||
Json::array(
|
||||
{{{"op", "template.restore"}, {"instance", "inst"}, {"address", suppressed}},
|
||||
{{"op", "template.source_set"},
|
||||
{"instance", "inst"},
|
||||
{"source", "Scenes/courtyard.scene.json"}}}));
|
||||
CHECK(restored["scene"]["instances"][0]["suppressed"].empty());
|
||||
CHECK(service
|
||||
.transact(template_id, 3,
|
||||
Json::array({{{"op", "template.remove"},
|
||||
{"instance", "inst"}}}))["scene"]["instances"]
|
||||
.empty());
|
||||
CHECK(service.undo(template_id, 4)["scene"]["instances"].size() == 1);
|
||||
const auto added = make_entity(schemas, "Local object");
|
||||
service.transact(
|
||||
template_id, 5,
|
||||
Json::array({{{"op", "template.add"}, {"instance", "inst"}, {"value", added}}}));
|
||||
auto replacement = added;
|
||||
replacement["name"] = "Edited local object";
|
||||
replacement["components"].push_back({{"id", new_id()},
|
||||
{"type", "faset.mesh"},
|
||||
{"version", 1},
|
||||
{"fields", schemas.default_fields("faset.mesh")}});
|
||||
const auto replaced = service.transact(
|
||||
template_id, 6,
|
||||
Json::array(
|
||||
{{{"op", "template.addition_set"}, {"instance", "inst"}, {"value", replacement}}}));
|
||||
CHECK(replaced["scene"]["instances"][0]["additions"][0]["components"].size() == 2);
|
||||
auto malformed = replacement;
|
||||
malformed["components"][1]["fields"]["primitive"] = "unsupported";
|
||||
fails(
|
||||
[&] {
|
||||
service.transact(template_id, 7,
|
||||
Json::array({{{"op", "template.addition_set"},
|
||||
{"instance", "inst"},
|
||||
{"value", malformed}}}));
|
||||
},
|
||||
"validation.enum");
|
||||
// Malformed address records never reach journals; valid unresolved targets stay
|
||||
// recoverable.
|
||||
for (const auto& broken : Json::array(
|
||||
{{{"id", "bad"},
|
||||
{"source", "missing"},
|
||||
{"overrides", Json::array({{{"address", "oops"}, {"value", 42}}})}},
|
||||
{{"id", "bad"},
|
||||
{"source", "missing"},
|
||||
{"suppressed", Json::array({{{"object", "ok"}, {"path", 42}}})}},
|
||||
{{"id", "bad"},
|
||||
{"source", "missing"},
|
||||
{"reparents",
|
||||
Json::array({{{"object", {{"object", "ok"}}}, {"parent", false}}})}}})) {
|
||||
const auto before = service.query(template_id);
|
||||
bool rejected = false;
|
||||
try {
|
||||
service.transact(
|
||||
template_id, before["revision"],
|
||||
Json::array({{{"op", "template.instance"}, {"instance", broken}}}));
|
||||
} catch (const Error&) {
|
||||
rejected = true;
|
||||
}
|
||||
CHECK(rejected);
|
||||
CHECK(service.query(template_id) == before);
|
||||
}
|
||||
// Stable FieldId survives a label rename; incompatible migrations require an explicit
|
||||
// decision.
|
||||
SchemaRegistry newer;
|
||||
@@ -147,6 +264,53 @@ int main() {
|
||||
CHECK(migrated["fields"]["speed"] == 3.0);
|
||||
CHECK(migrated["fields"]["enabled"] == true);
|
||||
CHECK(migrated["fields"]["unrecognized"] == "preserve");
|
||||
// Local additions and overrides remap references within their own instance; future schemas
|
||||
// stay opaque.
|
||||
auto refs = builtin_schemas();
|
||||
refs.register_schema({{"id", "ref"},
|
||||
{"version", 1},
|
||||
{"fields", {{"target", {{"type", "entity_ref"}, {"default", ""}}}}}});
|
||||
auto ref_entity = [&](const std::string& name, const std::string& target, int version = 1) {
|
||||
return Json{
|
||||
{"id", name},
|
||||
{"name", name},
|
||||
{"parent", nullptr},
|
||||
{"components",
|
||||
Json::array({{{"id", name + "-ref"},
|
||||
{"type", "ref"},
|
||||
{"version", version},
|
||||
{"fields", {{"target", target}, {"opaque", {{"saved", true}}}}}}})}};
|
||||
};
|
||||
auto ref_source = make_scene("References");
|
||||
ref_source["entities"] =
|
||||
Json::array({ref_entity("a", "b"), ref_entity("b", "a"), ref_entity("future", "a", 2)});
|
||||
auto ref_outer = make_scene("Instances");
|
||||
ref_outer["instances"] = Json::array(
|
||||
{{{"id", "ref-instance"},
|
||||
{"source", "ref-source"},
|
||||
{"additions", Json::array({ref_entity("local", "b")})},
|
||||
{"overrides",
|
||||
Json::array(
|
||||
{{{"address", {{"object", "a"}, {"component", "a-ref"}, {"field", "target"}}},
|
||||
{"value", "local"}}})}}});
|
||||
const auto ref_result =
|
||||
resolve_templates(ref_outer, refs, [&](const std::string&) { return ref_source; });
|
||||
CHECK(ref_result.conflicts.empty());
|
||||
const auto& re = ref_result.scene["entities"];
|
||||
CHECK(re[0]["components"][0]["fields"]["target"] == re[3]["id"]);
|
||||
CHECK(re[3]["components"][0]["fields"]["target"] == re[1]["id"]);
|
||||
CHECK(re[2]["components"][0]["fields"] ==
|
||||
ref_source["entities"][2]["components"][0]["fields"]);
|
||||
AuthoringService opaque(root / "opaque", refs);
|
||||
const auto opaque_doc = opaque.create("Future");
|
||||
const std::string opaque_id = opaque_doc["id"];
|
||||
opaque.transact(
|
||||
opaque_id, 0,
|
||||
Json::array({{{"op", "entity.create"}, {"entity", ref_entity("a", "a", 2)}}}));
|
||||
const auto duplicated = opaque.transact(
|
||||
opaque_id, 1, Json::array({{{"op", "entity.duplicate"}, {"entity", "a"}}}));
|
||||
CHECK(duplicated["scene"]["entities"][1]["components"][0]["fields"] ==
|
||||
duplicated["scene"]["entities"][0]["components"][0]["fields"]);
|
||||
// Full TRS reparent, including parent rotation/scale, preserves world placement.
|
||||
auto hierarchy = make_scene("Transforms");
|
||||
auto parent = make_entity(schemas, "Parent");
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Real native Editor + stdio MCP regression; requires a desktop and Vulkan.
|
||||
|
||||
Repeated fresh captures cover presentation back-pressure in hidden/occluded windows.
|
||||
The test deliberately terminates its owned GUI process after assertions; closing a
|
||||
client must not close the user's interactive Editor.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
|
||||
def run(executable, project):
|
||||
process = subprocess.Popen(
|
||||
[executable, "--project", str(project), "--new", "GUI MCP acceptance",
|
||||
"--dimension", "3", "--mcp", "--gui"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, encoding="utf-8")
|
||||
replies, diagnostics = queue.Queue(), []
|
||||
|
||||
def read_stdout():
|
||||
try:
|
||||
for line in process.stdout:
|
||||
replies.put(json.loads(line))
|
||||
except Exception as error:
|
||||
replies.put(error)
|
||||
finally:
|
||||
replies.put(None)
|
||||
|
||||
def read_stderr():
|
||||
for line in process.stderr:
|
||||
diagnostics.append(line)
|
||||
|
||||
readers = [threading.Thread(target=read_stdout, daemon=True),
|
||||
threading.Thread(target=read_stderr, daemon=True)]
|
||||
for reader in readers:
|
||||
reader.start()
|
||||
sequence = 0
|
||||
|
||||
def request(method, params):
|
||||
nonlocal sequence
|
||||
sequence += 1
|
||||
process.stdin.write(json.dumps({"jsonrpc": "2.0", "id": sequence,
|
||||
"method": method, "params": params}) + "\n")
|
||||
process.stdin.flush()
|
||||
response = replies.get(timeout=60)
|
||||
assert isinstance(response, dict) and response.get("id") == sequence, response
|
||||
assert "error" not in response, response
|
||||
return response["result"]
|
||||
|
||||
def call(name, arguments=None, error=False):
|
||||
result = request("tools/call", {"name": name, "arguments": arguments or {}})
|
||||
assert result.get("isError", False) == error, result
|
||||
return result
|
||||
|
||||
try:
|
||||
request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "faset-gui-acceptance", "version": "1"}})
|
||||
process.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n')
|
||||
process.stdin.flush()
|
||||
document = call("faset_documents")["structuredContent"]["documents"][0]
|
||||
edited = call("faset_scene_edit", {
|
||||
"document": document["id"], "revision": document["revision"],
|
||||
"operations": [{"op": "entity.create", "entity": {
|
||||
"id": "mcp-cube", "name": "Shared GUI and MCP cube 世界", "parent": None,
|
||||
"components": [
|
||||
{"id": "cube-transform", "type": "faset.transform", "version": 1,
|
||||
"fields": {"position": [0, 1, 0], "rotation": [0, 0, 0], "scale": [1, 1, 1]}},
|
||||
{"id": "cube-mesh", "type": "faset.mesh", "version": 1,
|
||||
"fields": {"primitive": "cube", "asset": "", "color": [0.65, 0.5, 0.85, 1]}}
|
||||
]}}]})["structuredContent"]
|
||||
call("faset_scene_edit", {"document": document["id"], "revision": document["revision"],
|
||||
"operations": [{"op": "scene.rename", "name": "Stale"}]}, True)
|
||||
images = []
|
||||
for index in range(12):
|
||||
relative = ".faset/screenshots/full.png" if index == 0 else ".faset/screenshots/view.png"
|
||||
result = call("faset_editor_capture", {"path": relative, "viewport_only": index != 0})
|
||||
image = next(item for item in result["content"] if item["type"] == "image")
|
||||
data = base64.b64decode(image["data"], validate=True)
|
||||
assert data.startswith(b"\x89PNG\r\n\x1a\n") and len(data) > 1000
|
||||
assert (project / relative).read_bytes() == data
|
||||
images.append(data)
|
||||
assert call("faset_capabilities")["structuredContent"]["viewport_capture"]
|
||||
call("faset_editor_capture", {"path": "../outside.png"}, True)
|
||||
call("faset_editor_capture", {"path": "wrong.jpg"}, True)
|
||||
undone = call("faset_undo", {"document": document["id"],
|
||||
"revision": edited["revision"]})["structuredContent"]
|
||||
assert all(entity["id"] != "mcp-cube" for entity in undone["scene"]["entities"])
|
||||
assert process.poll() is None
|
||||
finally:
|
||||
process.stdin.close()
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=20)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
for reader in readers:
|
||||
reader.join(timeout=5)
|
||||
errors = "".join(diagnostics)
|
||||
assert "Validation Error" not in errors, errors
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="faset-gui-mcp-") as directory:
|
||||
run(sys.argv[1], Path(directory))
|
||||
print("Native GUI + MCP shared authoring, revision conflict, Undo, 12 fresh PNG captures and continued responsiveness passed")
|
||||
@@ -0,0 +1,75 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/session.hpp>
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
using namespace faset;
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-session-" + new_id());
|
||||
try {
|
||||
editor::Session session({root, FASET_TEST_ENGINE, {}});
|
||||
session.scaffold("Settings", 3);
|
||||
auto document = session.authoring().create("Start", 3);
|
||||
session.authoring().save(document.at("id"), "Scenes/main.scene.json");
|
||||
auto& commands = session.commands();
|
||||
const auto initial = commands.call("faset_project_settings_get", Json::object());
|
||||
auto changed = commands.call("faset_project_settings_set",
|
||||
{{"revision", initial.at("revision")},
|
||||
{"settings",
|
||||
{{"name", "Проект 世界"},
|
||||
{"dimension", 2},
|
||||
{"start_scene", "Scenes/main.scene.json"}}}});
|
||||
require(changed.at("settings").at("name") == "Проект 世界" &&
|
||||
session.project().at("dimension") == 2,
|
||||
"test", "Project settings were not saved");
|
||||
require(session.authoring().query(document.at("id")).at("scene").at("dimension") == 3,
|
||||
"test", "Project defaults changed the existing scene");
|
||||
auto rejects = [&](const Json& request, const std::string& code) {
|
||||
try {
|
||||
commands.call("faset_project_settings_set", request);
|
||||
} catch (const Error& error) {
|
||||
require(error.json().at("code") == code, "test", "Wrong project error");
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("Invalid project edit was accepted");
|
||||
};
|
||||
rejects({{"revision", initial.at("revision")}, {"settings", {{"name", "Stale"}}}},
|
||||
"revision.conflict");
|
||||
for (const auto& [field, value, code] :
|
||||
std::vector<std::tuple<std::string, Json, std::string>>{
|
||||
{"name", "", "project.name"},
|
||||
{"dimension", 4, "project.dimension"},
|
||||
{"id", "replace-id", "project.setting"},
|
||||
{"start_scene", "Scenes/missing.scene.json", "project.start_scene"}}) {
|
||||
rejects({{"revision", changed.at("revision")}, {"settings", {{field, value}}}}, code);
|
||||
require(commands.call("faset_project_settings_get", Json::object()) == changed, "test",
|
||||
"Rejected project edit changed state");
|
||||
}
|
||||
auto external = session.project();
|
||||
external["custom_tool"] = {{"keep", true}};
|
||||
atomic_write_json(root / "project.faset.json", external);
|
||||
rejects({{"revision", changed.at("revision")}, {"settings", {{"name", "Race"}}}},
|
||||
"revision.conflict");
|
||||
const auto reloaded = commands.call("faset_project_settings_get", Json::object());
|
||||
changed =
|
||||
commands.call("faset_project_settings_set", {{"revision", reloaded.at("revision")},
|
||||
{"settings", {{"name", "Preserved"}}}});
|
||||
require(changed.at("settings").at("custom_tool").at("keep") == true, "test",
|
||||
"Saving settings erased unknown project metadata");
|
||||
external["version"] = 999;
|
||||
atomic_write_json(root / "project.faset.json", external);
|
||||
bool rejected = false;
|
||||
try {
|
||||
session.project();
|
||||
} catch (const Error& error) {
|
||||
rejected = error.json().at("code") == "project.version";
|
||||
}
|
||||
require(rejected, "test", "Future project version was reinterpreted");
|
||||
std::filesystem::remove_all(root);
|
||||
std::cout << "Project settings validation, save, external revision conflicts and opaque "
|
||||
"metadata passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << "\nFixture retained at " << root << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
#include <cmath>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/editor_ui.hpp>
|
||||
#include <iostream>
|
||||
using namespace faset;
|
||||
namespace {
|
||||
void check(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
render::Event mouse(render::Event::Type type, render::Vec2 p) {
|
||||
render::Event result;
|
||||
result.type = type;
|
||||
result.button = 1;
|
||||
result.x = p[0];
|
||||
result.y = p[1];
|
||||
return result;
|
||||
}
|
||||
void click(editor::EditorUI& ui, const std::string& id) {
|
||||
const auto* widget = ui.widgets().find(id);
|
||||
check(widget, "Missing gizmo test widget");
|
||||
const auto r = widget->rect.intersection(widget->clip);
|
||||
check(r.width > 0 && r.height > 0, "Clipped gizmo test widget");
|
||||
const render::Vec2 p{r.x + r.width * .5f, r.y + r.height * .5f};
|
||||
ui.frame({mouse(render::Event::Type::MouseDown, p), mouse(render::Event::Type::MouseUp, p)});
|
||||
}
|
||||
render::Vec2 project(editor::EditorUI& ui, render::Vec3 p) {
|
||||
const auto& s = ui.snapshot();
|
||||
const auto& m = s.view_projection;
|
||||
const float w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15];
|
||||
return {s.scene_rect[0] +
|
||||
((m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]) / w + 1) * s.scene_rect[2] * .5f,
|
||||
s.scene_rect[1] + ((m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]) / w + 1) *
|
||||
s.scene_rect[3] * .5f};
|
||||
}
|
||||
render::Vec2 along(render::Vec2 a, render::Vec2 b, float t) {
|
||||
return {a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t};
|
||||
}
|
||||
void near(float a, float b, const char* message) {
|
||||
check(std::abs(a - b) < .002f, message);
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-gizmo-ui-" + new_id());
|
||||
try {
|
||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
||||
render::Renderer renderer({1280, 900, "Parented gizmo acceptance", true, true});
|
||||
editor::EditorUI ui(session, renderer,
|
||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
ui.frame({});
|
||||
auto query = [&] { return session.authoring().query(ui.current_document()); };
|
||||
auto parent =
|
||||
authoring::make_entity(session.authoring().schemas(), "Rotated scaled parent");
|
||||
parent["components"][0]["fields"] = {
|
||||
{"position", {.5, .6, 0}}, {"rotation", {0, .8, 0}}, {"scale", {2, 2, 2}}};
|
||||
auto child =
|
||||
authoring::make_entity(session.authoring().schemas(), "Child", parent.at("id"));
|
||||
child["components"].push_back(
|
||||
{{"id", new_id()},
|
||||
{"type", "faset.mesh"},
|
||||
{"version", 1},
|
||||
{"fields", {{"asset", ""}, {"primitive", "cube"}, {"color", {.6, .6, .65, 1.}}}}});
|
||||
session.authoring().transact(ui.current_document(), query().at("revision"),
|
||||
Json::array({{{"op", "entity.create"}, {"entity", parent}},
|
||||
{{"op", "entity.create"}, {"entity", child}}}));
|
||||
ui.select_entity(child.at("id"));
|
||||
ui.frame({});
|
||||
const auto original_world = render::transform({.5f, .6f, 0}, {0, .8f, 0}, {2, 2, 2});
|
||||
auto check_parent = [&] {
|
||||
check(query()["scene"]["entities"][0] == parent, "Child gizmo must not mutate parent");
|
||||
};
|
||||
auto check_world = [&](render::Mat4 expected) {
|
||||
check(!ui.snapshot().draws.empty(), "Rendered child mesh missing");
|
||||
for (std::size_t i = 0; i < 16; ++i)
|
||||
near(ui.snapshot().draws[0].model[i], expected[i],
|
||||
"Rendered child world transform mismatch");
|
||||
};
|
||||
check_world(original_world);
|
||||
const auto center = project(ui, {.5f, .6f, 0});
|
||||
const auto tip = project(ui, {1.94f, .6f, 0});
|
||||
const auto before = query()["revision"].get<std::uint64_t>();
|
||||
ui.frame({mouse(render::Event::Type::MouseDown, along(center, tip, .65f))});
|
||||
ui.frame({mouse(render::Event::Type::MouseMove, along(center, tip, .9f))});
|
||||
ui.frame({mouse(render::Event::Type::MouseMove, along(center, tip, 1.15f))});
|
||||
check(query()["revision"] == before, "Multi-frame Move only previews before release");
|
||||
auto moved = original_world;
|
||||
moved[12] += .72f;
|
||||
check_world(moved);
|
||||
ui.frame({mouse(render::Event::Type::MouseUp, along(center, tip, 1.15f))});
|
||||
check(query()["revision"] == before + 1, "Move commits exactly one transaction");
|
||||
check_world(moved);
|
||||
check_parent();
|
||||
click(ui, "undo");
|
||||
check_world(original_world);
|
||||
|
||||
// Rotate and Scale expose the selected object's local axes, including its parent.
|
||||
const render::Vec3 local_x{std::cos(.8f), 0, -std::sin(.8f)};
|
||||
const auto local_tip = project(ui, {.5f + local_x[0] * 1.44f, .6f, local_x[2] * 1.44f});
|
||||
for (const std::string mode : {"Rotate", "Scale"}) {
|
||||
click(ui, "gizmo-" + mode);
|
||||
const auto revision = query()["revision"].get<std::uint64_t>();
|
||||
ui.frame({mouse(render::Event::Type::MouseDown, along(center, local_tip, .7f))});
|
||||
ui.frame({mouse(render::Event::Type::MouseMove, along(center, local_tip, .85f))});
|
||||
ui.frame({mouse(render::Event::Type::MouseMove, along(center, local_tip, 1.f))});
|
||||
check(query()["revision"] == revision, "Local-axis gizmo preview does not author");
|
||||
ui.frame({mouse(render::Event::Type::MouseUp, along(center, local_tip, 1.f))});
|
||||
check(query()["revision"] == revision + 1, "Local-axis gizmo commits one transaction");
|
||||
const auto fields = query()["scene"]["entities"][1]["components"][0]["fields"];
|
||||
if (mode == "Rotate") {
|
||||
near(fields["rotation"][0], .3f * 3.141593f, "Rotate edits local X radians");
|
||||
near(fields["rotation"][1], 0, "Rotate preserves local Y");
|
||||
check_world(render::multiply(original_world,
|
||||
render::transform({}, {.3f * 3.141593f, 0, 0})));
|
||||
} else {
|
||||
near(fields["scale"][0], 1.3f, "Scale edits local X");
|
||||
near(fields["scale"][1], 1, "Scale preserves local Y");
|
||||
check_world(
|
||||
render::multiply(original_world, render::transform({}, {}, {1.3f, 1, 1})));
|
||||
}
|
||||
check_parent();
|
||||
click(ui, "undo");
|
||||
check_world(original_world);
|
||||
}
|
||||
click(ui, "gizmo-Move");
|
||||
const auto cancelled = query()["revision"];
|
||||
ui.frame({mouse(render::Event::Type::MouseDown, along(center, tip, .65f))});
|
||||
ui.frame({mouse(render::Event::Type::MouseMove, along(center, tip, 1.15f))});
|
||||
render::Event escape;
|
||||
escape.type = render::Event::Type::KeyDown;
|
||||
escape.key = "Escape";
|
||||
ui.frame({escape, mouse(render::Event::Type::MouseUp, along(center, tip, 1.15f))});
|
||||
check(query()["revision"] == cancelled, "Escape cancels gizmo without authoring");
|
||||
check_world(original_world);
|
||||
session.authoring().transact(ui.current_document(), query().at("revision"),
|
||||
Json::array({{{"op", "component.set"},
|
||||
{"entity", child.at("id")},
|
||||
{"component", child["components"][0]["id"]},
|
||||
{"field", "rotation"},
|
||||
{"value", {.3, .4, .2}}}}));
|
||||
ui.frame({});
|
||||
click(ui, "gizmo-Rotate");
|
||||
const auto rotated_local = render::transform({}, {.3f, .4f, .2f});
|
||||
const auto rotated_world = render::multiply(original_world, rotated_local);
|
||||
check_world(rotated_world);
|
||||
const auto y_tip = project(ui, {.5f + rotated_world[4] * .72f,
|
||||
.6f + rotated_world[5] * .72f, rotated_world[6] * .72f});
|
||||
ui.frame({mouse(render::Event::Type::MouseDown, along(center, y_tip, .7f))});
|
||||
ui.frame({mouse(render::Event::Type::MouseMove, along(center, y_tip, .9f))});
|
||||
ui.frame({mouse(render::Event::Type::MouseUp, along(center, y_tip, .9f))});
|
||||
check_world(
|
||||
render::multiply(rotated_world, render::transform({}, {0, .2f * 3.141593f, 0})));
|
||||
check_parent();
|
||||
click(ui, "undo");
|
||||
check_world(rotated_world);
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(root / "gizmos.ppm");
|
||||
check(renderer.stats().validation_errors == 0, "Vulkan validation");
|
||||
std::cout << "Parented world Move/local Rotate/local Scale, multi-frame preview, Undo, "
|
||||
"Escape and composed local rotation passed. "
|
||||
<< root << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << "\nRetained: " << root << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/project_launcher.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::ProjectLauncher& launcher, const std::string& id) {
|
||||
const auto* w = launcher.widgets().find(id);
|
||||
check(w, "Missing launcher widget: " + id);
|
||||
const auto rect = w->rect.intersection(w->clip);
|
||||
check(rect.width > 0 && rect.height > 0, "Hidden launcher widget: " + id);
|
||||
render::Event down;
|
||||
down.type = render::Event::Type::MouseDown;
|
||||
down.button = 1;
|
||||
down.x = rect.x + rect.width * .5f;
|
||||
down.y = rect.y + rect.height * .5f;
|
||||
auto up = down;
|
||||
up.type = render::Event::Type::MouseUp;
|
||||
launcher.frame({down, up});
|
||||
}
|
||||
void text(editor::ProjectLauncher& launcher, const std::string& id, const std::string& value,
|
||||
bool commit = true) {
|
||||
click(launcher, id);
|
||||
render::Event input;
|
||||
input.type = render::Event::Type::TextInput;
|
||||
input.text = value;
|
||||
std::vector<render::Event> events{key("A", true), input};
|
||||
if (commit)
|
||||
events.push_back(key("Return"));
|
||||
launcher.frame(events);
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-launcher-ui-" + new_id());
|
||||
try {
|
||||
const auto existing = root / "Existing project";
|
||||
atomic_write_json(existing / "project.faset.json", {{"format", "faset.project"},
|
||||
{"version", 1},
|
||||
{"name", "Existing project"},
|
||||
{"dimension", 2}});
|
||||
const auto recents = root / "recent-projects.json";
|
||||
atomic_write_json(recents, Json::array({existing.string(), (root / "Missing").string(),
|
||||
existing.string()}));
|
||||
render::Renderer renderer({1100, 720, "Project launcher acceptance", true, true});
|
||||
{
|
||||
editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, {}, recents);
|
||||
launcher.frame({});
|
||||
check(launcher.widgets().find("launcher-create")->selected,
|
||||
"No initial path starts Create");
|
||||
check(launcher.widgets().find("launcher-recent-0") &&
|
||||
!launcher.widgets().find("launcher-recent-1"),
|
||||
"Recent list includes real valid unique projects only");
|
||||
text(launcher, "launcher-name", "Тестовый проект");
|
||||
text(launcher, "launcher-path", existing.string());
|
||||
click(launcher, "launcher-submit");
|
||||
check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(),
|
||||
"Create must reject nonempty existing project");
|
||||
text(launcher, "launcher-path", (root / "New Game").string());
|
||||
click(launcher, "launcher-2d");
|
||||
check(launcher.widgets().find("launcher-2d")->selected, "2D project selection");
|
||||
renderer.render(launcher.snapshot());
|
||||
renderer.capture(root / "launcher-create.ppm");
|
||||
launcher.frame({key("Return", true)});
|
||||
check(launcher.selection() && launcher.selection()->create &&
|
||||
launcher.selection()->dimension == 2 &&
|
||||
launcher.selection()->name == "Тестовый проект",
|
||||
"Create through keyboard returns typed selection");
|
||||
check(!std::filesystem::exists(root / "New Game"),
|
||||
"Launcher does not create a partial project before Session scaffold");
|
||||
}
|
||||
{
|
||||
editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, root / "Missing",
|
||||
recents);
|
||||
launcher.frame({});
|
||||
click(launcher, "launcher-submit");
|
||||
check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(),
|
||||
"Open validates missing directory");
|
||||
click(launcher, "launcher-recent-0");
|
||||
check(launcher.widgets().find("launcher-path")->text == existing.string(),
|
||||
"Recent selection fills actual path");
|
||||
click(launcher, "launcher-browse");
|
||||
check(launcher.widgets().find("launcher-browser")->visible,
|
||||
"Native retained directory browser opens");
|
||||
text(launcher, "browser-path", (root / "Absent folder").string());
|
||||
launcher.frame({key("Return", true)});
|
||||
check(launcher.widgets().find("launcher-browser")->visible &&
|
||||
!launcher.widgets().find("browser-choose")->enabled,
|
||||
"Invalid typed directory cannot silently select prior directory");
|
||||
text(launcher, "browser-path", existing.string());
|
||||
click(launcher, "browser-up");
|
||||
check(launcher.widgets().find("browser-path")->text == root.string(),
|
||||
"Folder browser Up navigation");
|
||||
click(launcher, "browser-entry-0");
|
||||
check(launcher.widgets().find("browser-path")->text == existing.string(),
|
||||
"Directory list navigation");
|
||||
renderer.render(launcher.snapshot());
|
||||
renderer.capture(root / "launcher-browser.ppm");
|
||||
click(launcher, "browser-choose");
|
||||
check(!launcher.widgets().find("launcher-browser")->visible,
|
||||
"Choose closes directory browser");
|
||||
launcher.frame({key("Return", true)});
|
||||
check(launcher.selection() && !launcher.selection()->create &&
|
||||
launcher.selection()->path == existing &&
|
||||
launcher.selection()->dimension == 2,
|
||||
"Open reads project metadata");
|
||||
}
|
||||
{
|
||||
const auto corrupt = root / "Corrupt";
|
||||
atomic_write_json(corrupt / "project.faset.json",
|
||||
{{"format", "faset.project"}, {"version", 9}, {"name", "Future"}});
|
||||
editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, corrupt, recents);
|
||||
launcher.frame({});
|
||||
click(launcher, "launcher-submit");
|
||||
check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(),
|
||||
"Unsupported project version is rejected");
|
||||
launcher.frame({key("Escape")});
|
||||
check(launcher.cancelled(), "Escape cancels launcher");
|
||||
}
|
||||
check(renderer.stats().validation_errors == 0, "Launcher Vulkan validation");
|
||||
std::cout
|
||||
<< "Launcher Unicode/create/open/validation/recents/directory browser/keyboard passed. "
|
||||
<< root << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << "\nRetained: " << root << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#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& ui, const std::string& id) {
|
||||
const auto* w = ui.widgets().find(id);
|
||||
check(w, "Missing widget: " + id);
|
||||
const auto r = w->rect.intersection(w->clip);
|
||||
check(r.width > 0 && r.height > 0, "Hidden widget: " + id);
|
||||
render::Event down;
|
||||
down.type = render::Event::Type::MouseDown;
|
||||
down.button = 1;
|
||||
down.x = r.x + r.width * .5f;
|
||||
down.y = r.y + r.height * .5f;
|
||||
auto up = down;
|
||||
up.type = render::Event::Type::MouseUp;
|
||||
ui.frame({down, up});
|
||||
}
|
||||
void text(editor::EditorUI& ui, const std::string& id, const std::string& value) {
|
||||
click(ui, id);
|
||||
render::Event input;
|
||||
input.type = render::Event::Type::TextInput;
|
||||
input.text = value;
|
||||
ui.frame({key("A", true), input, key("Return")});
|
||||
}
|
||||
void open(editor::EditorUI& ui) {
|
||||
click(ui, "menu-File");
|
||||
click(ui, "project-settings");
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
const auto root =
|
||||
std::filesystem::temp_directory_path() / ("faset-project-settings-ui-" + new_id());
|
||||
try {
|
||||
const auto project_file = root / "project.faset.json";
|
||||
atomic_write_json(project_file, {{"format", "faset.project"},
|
||||
{"version", 1},
|
||||
{"id", new_id()},
|
||||
{"name", "Original project"},
|
||||
{"dimension", 3},
|
||||
{"custom_metadata", {{"preserve", true}}}});
|
||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
||||
render::Renderer renderer({1280, 800, "Project settings acceptance", true, true});
|
||||
editor::EditorUI ui(session, renderer,
|
||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
ui.frame({});
|
||||
click(ui, "add-cube");
|
||||
session.authoring().save(ui.current_document(), "Scenes/Main.scene.json");
|
||||
const auto other = session.authoring().create("Other", 2);
|
||||
session.authoring().save(other.at("id"), "Scenes/Other.scene.json");
|
||||
ui.frame({});
|
||||
const auto document_before = session.authoring().query(ui.current_document());
|
||||
const auto project_before = read_json(project_file);
|
||||
open(ui);
|
||||
text(ui, "project-settings-name", "Unsaved project name");
|
||||
click(ui, "project-settings-cancel");
|
||||
check(read_json(project_file) == project_before,
|
||||
"Cancel discards only form draft without writing project");
|
||||
open(ui);
|
||||
check(ui.widgets().find("project-settings-name")->text == "Original project",
|
||||
"Opening settings reloads current saved values");
|
||||
text(ui, "project-settings-name", "Новый проект");
|
||||
click(ui, "project-settings-2d");
|
||||
click(ui, "project-scene-choice-1");
|
||||
check(ui.widgets().find("project-settings-start")->text == "Scenes/Other.scene.json",
|
||||
"Saved scene chooser sets project-relative path");
|
||||
click(ui, "project-settings-save");
|
||||
auto saved = read_json(project_file);
|
||||
check(saved["name"] == "Новый проект" && saved["dimension"] == 2 &&
|
||||
saved["start_scene"] == "Scenes/Other.scene.json",
|
||||
"Explicit Save project persists typed settings");
|
||||
check(saved["custom_metadata"] == project_before["custom_metadata"] &&
|
||||
saved["id"] == project_before["id"],
|
||||
"Project settings preserve unknown metadata and project identity");
|
||||
check(session.authoring().query(ui.current_document()) == document_before,
|
||||
"Project settings do not change current document, dimension, revision or Undo");
|
||||
open(ui);
|
||||
text(ui, "project-settings-start", "Scenes/Missing.scene.json");
|
||||
click(ui, "project-settings-save");
|
||||
check(ui.widgets().find("project-settings-panel")->visible &&
|
||||
!ui.widgets().find("project-settings-error")->text.empty() &&
|
||||
read_json(project_file) == saved,
|
||||
"Invalid start scene keeps saved project and form open");
|
||||
click(ui, "project-settings-reload");
|
||||
text(ui, "project-settings-name", "Local draft");
|
||||
const auto external = session.commands().call("faset_project_settings_get", Json::object());
|
||||
session.commands().call(
|
||||
"faset_project_settings_set",
|
||||
{{"revision", external.at("revision")}, {"settings", {{"name", "External edit"}}}});
|
||||
click(ui, "project-settings-save");
|
||||
check(ui.widgets().find("project-settings-panel")->visible &&
|
||||
read_json(project_file)["name"] == "External edit" &&
|
||||
ui.widgets().find("project-settings-name")->text == "Local draft",
|
||||
"Revision conflict preserves external project and local draft");
|
||||
check(ui.widgets().find("project-settings-error")->text.find("reload") != std::string::npos,
|
||||
"Conflict directs user to reload");
|
||||
click(ui, "project-settings-reload");
|
||||
check(ui.widgets().find("project-settings-name")->text == "External edit",
|
||||
"Explicit Reload saved replaces stale form draft");
|
||||
click(ui, "project-settings-3d");
|
||||
click(ui, "project-scene-choice-0");
|
||||
click(ui, "project-settings-save");
|
||||
check(read_json(project_file)["start_scene"] == "Scenes/Main.scene.json",
|
||||
"Save after explicit reload uses new content revision");
|
||||
check(session.authoring().query(ui.current_document()) == document_before,
|
||||
"Project settings remain outside scene Undo throughout conflicts");
|
||||
open(ui);
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(root / "project-settings.ppm");
|
||||
ui.frame({key("Escape")});
|
||||
check(!ui.widgets().find("project-settings-panel")->visible,
|
||||
"Escape closes project settings without saving");
|
||||
check(renderer.stats().validation_errors == 0, "Project settings Vulkan validation");
|
||||
std::cout << "Project settings name/type/start-scene Save/Cancel/Reload/conflict, metadata "
|
||||
"preservation and separate scene Undo passed. "
|
||||
<< root << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << "\nRetained: " << root << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/editor_ui.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
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& ui, const std::string& id) {
|
||||
const auto* w = ui.widgets().find(id);
|
||||
check(w, "Missing widget: " + id);
|
||||
const auto r = w->rect.intersection(w->clip);
|
||||
check(r.width > 0 && r.height > 0, "Hidden widget: " + id);
|
||||
render::Event down;
|
||||
down.type = render::Event::Type::MouseDown;
|
||||
down.button = 1;
|
||||
down.x = r.x + r.width * .5f;
|
||||
down.y = r.y + r.height * .5f;
|
||||
auto up = down;
|
||||
up.type = render::Event::Type::MouseUp;
|
||||
ui.frame({down, up});
|
||||
}
|
||||
void poll(editor::EditorUI& ui) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(550));
|
||||
ui.frame({});
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-ui-reload-" + new_id());
|
||||
try {
|
||||
const auto styles = root / "styles/dark.json",
|
||||
layout_path = root / "styles/editor-layout.json";
|
||||
auto theme = read_json(std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
auto layout =
|
||||
read_json(std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/editor-layout.json");
|
||||
atomic_write_json(styles, theme);
|
||||
atomic_write_json(layout_path, layout);
|
||||
editor::Session session({root / "project", FASET_TEST_ENGINE, root});
|
||||
render::Renderer renderer({1280, 800, "Style reload acceptance", true, true});
|
||||
editor::EditorUI ui(session, renderer,
|
||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
styles);
|
||||
ui.frame({});
|
||||
click(ui, "add-cube");
|
||||
click(ui, "object-name");
|
||||
render::Event input;
|
||||
input.type = render::Event::Type::TextInput;
|
||||
input.text = "Unfinished name";
|
||||
ui.frame({key("A", true), input});
|
||||
const auto revision = session.authoring().query(ui.current_document()).at("revision");
|
||||
const auto width = ui.widgets().find("scene_panel")->rect.width;
|
||||
renderer.render(ui.snapshot());
|
||||
const auto pixels_before = renderer.pixels();
|
||||
theme["surface"] = {.18, .24, .12, 1};
|
||||
layout["root"]["children"][2]["children"][0]["layout"]["width"] = 270;
|
||||
atomic_write_json(styles, theme);
|
||||
atomic_write_json(layout_path, layout);
|
||||
poll(ui);
|
||||
check(ui.widgets().find("scene_panel")->rect.width == 270 && width != 270,
|
||||
"Valid layout edit changes live panel geometry");
|
||||
check(ui.widgets().focused_id() == "object-name" &&
|
||||
ui.widgets().find("object-name")->text == "Unfinished name",
|
||||
"Valid reload preserves focus and unfinished text");
|
||||
renderer.render(ui.snapshot());
|
||||
const auto pixels_after = renderer.pixels();
|
||||
const auto pixel = (500 * renderer.width() + 10) * 4;
|
||||
check(pixels_before.at(pixel) != pixels_after.at(pixel) ||
|
||||
pixels_before.at(pixel + 1) != pixels_after.at(pixel + 1),
|
||||
"Valid theme edit changes actual Vulkan pixels");
|
||||
check(session.authoring().query(ui.current_document()).at("revision") == revision,
|
||||
"Presentation reload does not author the scene");
|
||||
const auto working_theme = ui.widgets().theme().to_json();
|
||||
const auto working_menubar = ui.widgets().find("menubar")->layout.height;
|
||||
auto bad_layout = layout;
|
||||
bad_layout["root"]["children"][0]["layout"]["height"] = 68;
|
||||
bad_layout["root"]["children"].back()["text"] = 7;
|
||||
theme["surface"] = {.3, .08, .2, 1};
|
||||
atomic_write_json(styles, theme);
|
||||
atomic_write_json(layout_path, bad_layout);
|
||||
poll(ui);
|
||||
check(ui.widgets().theme().to_json() == working_theme &&
|
||||
ui.widgets().find("menubar")->layout.height == working_menubar,
|
||||
"Invalid layout rejects both candidate files atomically");
|
||||
check(ui.widgets().find("scene_panel")->rect.width == 270 &&
|
||||
ui.widgets().focused_id() == "object-name" &&
|
||||
ui.widgets().find("object-name")->text == "Unfinished name",
|
||||
"Invalid reload retains last good layout and editing state");
|
||||
const auto log_count = session.logs().size();
|
||||
poll(ui);
|
||||
check(session.logs().size() == log_count,
|
||||
"Unchanged invalid file does not flood editor logs");
|
||||
atomic_write_json(layout_path, layout);
|
||||
poll(ui);
|
||||
check(ui.widgets().theme().surface[0] == .3f,
|
||||
"Fixing invalid layout publishes pending theme candidate");
|
||||
atomic_write(styles, "{ broken json");
|
||||
poll(ui);
|
||||
check(ui.widgets().theme().surface[0] == .3f,
|
||||
"Partially written JSON keeps last working theme");
|
||||
atomic_write_json(styles, theme);
|
||||
poll(ui);
|
||||
ui.frame({key("Return")});
|
||||
const auto saved = session.authoring().query(ui.current_document());
|
||||
check(saved.at("revision") == revision.get<std::uint64_t>() + 1 &&
|
||||
saved["scene"]["entities"][0]["name"] == "Unfinished name",
|
||||
"Retained callback commits preserved edit once after failed and valid reloads");
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(root / "reloaded.ppm");
|
||||
check(renderer.stats().validation_errors == 0, "Reload Vulkan validation");
|
||||
std::cout << "Theme/layout live reload pixels+geometry, atomic rejection, focus+draft "
|
||||
"preservation, callback and diagnostic dedup passed. "
|
||||
<< root << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << "\nRetained: " << root << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
#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, FASET_TEST_ENGINE, root});
|
||||
render::Renderer renderer({1280, 900, "Template workflow test", true, true});
|
||||
editor::EditorUI ui(session, renderer,
|
||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
std::filesystem::path(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/Door.scene.json");
|
||||
click(ui, "save-template");
|
||||
check(std::filesystem::exists(root / "Assets/Templates/Door.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/Outer.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/Outer.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");
|
||||
|
||||
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. "
|
||||
<< root << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << "\nRetained: " << root << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
#include "assets_image_fixtures.hpp"
|
||||
#include <cmath>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/editor_ui.hpp>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
using namespace faset;
|
||||
void check(bool value, const char* message) {
|
||||
if (!value)
|
||||
@@ -175,6 +178,75 @@ int main() {
|
||||
"Plugin panel action must author Beacon through Commands");
|
||||
click(ui, "tab-assets");
|
||||
#endif
|
||||
std::filesystem::create_directories(root / "Assets");
|
||||
const auto image_path = root / "Assets/TwoPixels.png";
|
||||
{
|
||||
std::ofstream stream(image_path, std::ios::binary);
|
||||
const auto& png = faset::test_images::png_red_green;
|
||||
stream.write(reinterpret_cast<const char*>(png.data()),
|
||||
static_cast<std::streamsize>(png.size()));
|
||||
}
|
||||
const auto import_job =
|
||||
session.commands()
|
||||
.call("faset_import",
|
||||
{{"path", "Assets/TwoPixels.png"}, {"settings", {{"pixels_per_unit", 1.0}}}})
|
||||
.at("job")
|
||||
.get<std::string>();
|
||||
Json imported;
|
||||
for (int wait = 0; wait < 500; ++wait) {
|
||||
session.poll();
|
||||
imported = session.commands().call("faset_job", {{"id", import_job}});
|
||||
if (imported.at("state") != "queued" && imported.at("state") != "running")
|
||||
break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
check(imported.at("state") == "succeeded", "Actual asynchronous PNG importer job");
|
||||
click(ui, "asset-refresh");
|
||||
ui.frame({});
|
||||
const auto image_id = imported.at("result").at("asset_id").get<std::string>();
|
||||
auto* image_row = ui.widgets().find("asset-" + image_id);
|
||||
check(image_row, "Imported image in asset browser");
|
||||
const auto asset_rect = image_row->rect.intersection(image_row->clip);
|
||||
const auto viewport_rect = ui.widgets().find("viewport")->rect;
|
||||
down.x = asset_rect.x + 100;
|
||||
down.y = asset_rect.y + 12;
|
||||
move = down;
|
||||
move.type = render::Event::Type::MouseMove;
|
||||
move.x = viewport_rect.x + viewport_rect.width * .5f;
|
||||
move.y = viewport_rect.y + viewport_rect.height * .5f;
|
||||
up = move;
|
||||
up.type = render::Event::Type::MouseUp;
|
||||
ui.frame({down, move, up});
|
||||
state = session.authoring().query(ui.current_document());
|
||||
const auto& sprite = state["scene"]["entities"].back()["components"].back();
|
||||
check(sprite["type"] == "faset.sprite" && sprite["fields"]["texture"] == image_id,
|
||||
"Image drag/drop must author Sprite with AssetId");
|
||||
check(sprite["fields"]["size"] == Json::array({2.0, 1.0}),
|
||||
"Image drag/drop preserves dimensions through pixels_per_unit");
|
||||
check(!ui.snapshot().sprites.empty() && ui.snapshot().sprites.back().texture &&
|
||||
ui.snapshot().sprites.back().texture->width == 2,
|
||||
"PNG sprite must decode into actual render texture");
|
||||
const auto before_switch = state.at("revision");
|
||||
click(ui, "menu-File");
|
||||
click(ui, "open-project");
|
||||
check(ui.widgets().find("project-switch-dialog")->visible && !ui.project_switch_requested(),
|
||||
"Dirty project switch requires an explicit recovery warning action");
|
||||
ui.frame({key("Delete")});
|
||||
check(session.authoring().query(ui.current_document()).at("revision") == before_switch,
|
||||
"Switch dialog captures editor shortcuts");
|
||||
click(ui, "project-switch-cancel");
|
||||
check(!ui.project_switch_requested(), "Cancelling project switch keeps editor open");
|
||||
ui.set_project_switch_enabled(false);
|
||||
click(ui, "menu-File");
|
||||
check(!ui.widgets().find("open-project")->enabled,
|
||||
"Project switch disabled while an MCP client owns the session");
|
||||
click(ui, "open-project");
|
||||
check(!ui.project_switch_requested(), "Disabled project switch cannot request exit");
|
||||
ui.set_project_switch_enabled(true);
|
||||
ui.frame({});
|
||||
click(ui, "open-project");
|
||||
click(ui, "project-switch-continue");
|
||||
check(ui.project_switch_requested(), "Confirmed project switch is exposed to application");
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(root / "editor-ui.ppm");
|
||||
check(renderer.stats().validation_errors == 0, "Vulkan validation errors");
|
||||
|
||||
+33
-1
@@ -40,6 +40,7 @@ def run(executable, project):
|
||||
assert initialized["result"]["serverInfo"]["name"] == "faset-editor"
|
||||
process.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n')
|
||||
process.stdin.flush()
|
||||
assert call("faset_schema_status")["stale"]
|
||||
names = {tool["name"] for tool in request("tools/list")["result"]["tools"]}
|
||||
assert {"faset_scene_edit", "faset_export", "faset_job_cancel", "faset_plugins"} <= names
|
||||
assert "faset_runtime_query" not in names and "faset_editor_capture" not in names
|
||||
@@ -78,7 +79,38 @@ def run(executable, project):
|
||||
assert code == 0, errors
|
||||
|
||||
|
||||
def disconnected_output(executable, project):
|
||||
process = subprocess.Popen([executable, "--project", str(project), "--mcp"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, encoding="utf-8")
|
||||
# Keep input open: output failure itself must close the transport. Before
|
||||
# the fix this terminated the POSIX Editor with SIGPIPE (return code -13).
|
||||
process.stdout.close()
|
||||
try:
|
||||
process.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": "2025-06-18"}}) + "\n")
|
||||
process.stdin.flush()
|
||||
assert process.wait(timeout=20) == 0, process.stderr.read()
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
process.stdin.close()
|
||||
|
||||
|
||||
def eof_tail(executable, project):
|
||||
# A final buffered request must still receive its reply when input closes.
|
||||
message = json.dumps({"jsonrpc": "2.0", "id": "tail", "method": "ping"})
|
||||
result = subprocess.run([executable, "--project", str(project), "--mcp"],
|
||||
input=message, capture_output=True, text=True,
|
||||
encoding="utf-8", timeout=20)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout)["id"] == "tail", result.stdout
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="faset-mcp-stdio-") as temporary:
|
||||
run(sys.argv[1], pathlib.Path(temporary))
|
||||
run(sys.argv[1], pathlib.Path(temporary))
|
||||
print("Real MCP stdio lifecycle, clean stdout, revision conflict, retry, Undo/Redo, disk reopen and process shutdown passed")
|
||||
disconnected_output(sys.argv[1], pathlib.Path(temporary))
|
||||
eof_tail(sys.argv[1], pathlib.Path(temporary))
|
||||
print("Real MCP stdio lifecycle, clean stdout, revision conflict, retry, Undo/Redo, disk reopen, broken output pipe and EOF shutdown passed")
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
#include <chrono>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
using namespace faset;
|
||||
namespace fs = std::filesystem;
|
||||
namespace {
|
||||
void require(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
int compile(const fs::path& source, const fs::path& output) {
|
||||
Process process(
|
||||
{{FASET_PYTHON_EXECUTABLE, FASET_SHADER_COMPILE_TOOL, "--compiler", FASET_TEST_SLANGC,
|
||||
"--source", source.string(), "--entry", "fragmentMain", "--output", output.string()},
|
||||
{},
|
||||
{}});
|
||||
std::string diagnostics;
|
||||
while (true) {
|
||||
auto result = process.poll();
|
||||
diagnostics += result.output;
|
||||
if (!result.running) {
|
||||
if (result.exit_code != 0)
|
||||
require(diagnostics.find("intentional_shader_compile_failure") != std::string::npos,
|
||||
"Slang failure must preserve its useful compiler diagnostic");
|
||||
return result.exit_code.value_or(1);
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
const auto temporary = fs::temp_directory_path() / ("faset-shader-reload-" + new_id());
|
||||
struct Cleanup {
|
||||
fs::path path;
|
||||
~Cleanup() {
|
||||
std::error_code ignored;
|
||||
fs::remove_all(path, ignored);
|
||||
}
|
||||
} cleanup{temporary};
|
||||
try {
|
||||
const auto bundle = temporary / "shaders";
|
||||
fs::create_directories(bundle);
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
|
||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||
const auto name = std::string(entry) + extension;
|
||||
fs::copy_file(fs::path(FASET_TEST_SHADER_DIRECTORY) / name, bundle / name);
|
||||
}
|
||||
const auto source = temporary / "reload.slang";
|
||||
const auto original_source = read_text(FASET_TEST_SHADER_SOURCE);
|
||||
const auto original_spirv = read_text(bundle / "fragmentMain.spv");
|
||||
const auto original_reflection = read_text(bundle / "fragmentMain.reflection.json");
|
||||
const auto original_fingerprint = Json::parse(original_reflection).at("layout_fingerprint");
|
||||
render::validate_shader_bundle(bundle);
|
||||
render::RendererConfig configuration;
|
||||
configuration.width = configuration.height = 64;
|
||||
configuration.headless = true;
|
||||
configuration.validation = true;
|
||||
configuration.shader_directory = bundle;
|
||||
render::Renderer renderer(configuration);
|
||||
render::Snapshot scene;
|
||||
scene.ui_quads.push_back({0, 0, 64, 64, {1, .8f, .4f, 1}});
|
||||
renderer.render(scene);
|
||||
auto expected = renderer.pixels();
|
||||
require(renderer.stats().gpu_allocated_bytes > 1024 * 1024 &&
|
||||
renderer.stats().texture_count >= 1,
|
||||
"Frame profile reports live Vulkan allocations and textures");
|
||||
auto restore = [&] {
|
||||
atomic_write(bundle / "fragmentMain.spv", original_spirv);
|
||||
atomic_write(bundle / "fragmentMain.reflection.json", original_reflection);
|
||||
};
|
||||
auto retained = [&] {
|
||||
std::string error;
|
||||
require(!renderer.reload_shaders(error) && !error.empty(),
|
||||
"Unsafe shader reload must fail with a diagnostic");
|
||||
renderer.render(scene);
|
||||
require(renderer.pixels() == expected,
|
||||
"Rejected shader reload must retain working pixels");
|
||||
require(renderer.stats().validation_errors == 0,
|
||||
"Rejected bytecode must not reach Vulkan validation");
|
||||
};
|
||||
atomic_write(bundle / "fragmentMain.spv", "damaged bytecode");
|
||||
retained();
|
||||
restore();
|
||||
auto malformed = original_spirv;
|
||||
for (int i = 0; i < 4; ++i)
|
||||
malformed[20 + i] = 0; // zero-word SPIR-V instruction
|
||||
auto metadata = Json::parse(original_reflection);
|
||||
metadata["spirv_sha256"] = sha256(malformed);
|
||||
atomic_write(bundle / "fragmentMain.spv", malformed);
|
||||
atomic_write_json(bundle / "fragmentMain.reflection.json", metadata);
|
||||
retained();
|
||||
restore();
|
||||
auto incompatible = original_source;
|
||||
auto at = incompatible.find("[[vk::binding(2,0)]]");
|
||||
require(at != std::string::npos, "Shader descriptor fixture exists");
|
||||
incompatible.replace(at, std::string("[[vk::binding(2,0)]]").size(),
|
||||
"[[vk::binding(7,0)]]");
|
||||
atomic_write(source, incompatible);
|
||||
require(compile(source, bundle) == 0, "Compile real incompatible descriptor layout");
|
||||
require(read_json(bundle / "fragmentMain.reflection.json").at("layout_fingerprint") !=
|
||||
original_fingerprint,
|
||||
"Descriptor edit changes normalized layout fingerprint");
|
||||
retained();
|
||||
restore();
|
||||
incompatible = original_source;
|
||||
at = incompatible.find("column_major float4x4");
|
||||
require(at != std::string::npos, "Shader matrix fixture exists");
|
||||
incompatible.replace(at, std::string("column_major float4x4").size(), "row_major float4x4");
|
||||
atomic_write(source, incompatible);
|
||||
require(compile(source, bundle) == 0, "Compile real incompatible matrix storage");
|
||||
retained();
|
||||
restore();
|
||||
auto compatible = original_source;
|
||||
at = compatible.find(" float4 sampled =");
|
||||
require(at != std::string::npos, "Shader fragment fixture exists");
|
||||
compatible.insert(at, " v.color.rgb *= 0.5;\n");
|
||||
atomic_write(source, compatible);
|
||||
require(compile(source, bundle) == 0, "Compile real compatible shader edit");
|
||||
require(read_json(bundle / "fragmentMain.reflection.json").at("layout_fingerprint") ==
|
||||
original_fingerprint,
|
||||
"Source-only behavior edit preserves normalized layout fingerprint");
|
||||
std::string error;
|
||||
require(renderer.reload_shaders(error), "Compatible shader edit reloads successfully");
|
||||
renderer.render(scene);
|
||||
const auto changed = renderer.pixels();
|
||||
require(changed[0] + 50 < expected[0], "Compatible reload changes actual rendered pixels");
|
||||
expected = changed;
|
||||
const auto last_spirv = read_text(bundle / "fragmentMain.spv");
|
||||
const auto last_reflection = read_text(bundle / "fragmentMain.reflection.json");
|
||||
atomic_write(source, compatible + "\n#error intentional_shader_compile_failure\n");
|
||||
require(compile(source, bundle) != 0, "Real Slang compile failure is reported");
|
||||
require(read_text(bundle / "fragmentMain.spv") == last_spirv &&
|
||||
read_text(bundle / "fragmentMain.reflection.json") == last_reflection,
|
||||
"Compile failure preserves both published shader artifacts");
|
||||
require(renderer.reload_shaders(error),
|
||||
"Last successfully compiled artifacts remain reloadable");
|
||||
renderer.render(scene);
|
||||
require(renderer.pixels() == expected, "Compile failure preserves last good rendering");
|
||||
require(renderer.stats().validation_errors == 0,
|
||||
"Shader reload regression has no Vulkan validation errors");
|
||||
std::cout << "Normalized reflection, malformed SPIR-V, incompatible descriptors/matrices, "
|
||||
"real compile failure and compatible pixel reload passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
#include <SDL3/SDL.h>
|
||||
#include <cmath>
|
||||
#include <faset/render/render_graph.hpp>
|
||||
#include <faset/render/renderer.hpp>
|
||||
@@ -122,6 +123,28 @@ int main(int argc, char** argv) {
|
||||
renderer.render(scene);
|
||||
require(renderer.width() == 400 && renderer.height() == 300, "Render target resize");
|
||||
require(renderer.stats().validation_errors == 0, "Resize validation error");
|
||||
if (visible) {
|
||||
int window_count{};
|
||||
auto windows = SDL_GetWindows(&window_count);
|
||||
require(windows && window_count == 1, "Visible test owns exactly one SDL window");
|
||||
auto* window = windows[0];
|
||||
SDL_free(windows);
|
||||
require(SDL_HideWindow(window), "Hide the test window");
|
||||
const auto before = renderer.stats().frame;
|
||||
// Exhaust any compositor buffers without an application event poll. Captures
|
||||
// must still render fresh content when presentation is unavailable.
|
||||
for (int frame = 0; frame < 12; ++frame) {
|
||||
scene.ui_quads[0].color = frame % 2 ? Color{0, 1, 0, 1} : Color{1, 0, 0, 1};
|
||||
renderer.render(scene);
|
||||
auto capture = renderer.pixels();
|
||||
const auto at = (10 * renderer.width() + 10) * 4;
|
||||
require(capture.at(at + (frame % 2 ? 1 : 0)) > 240,
|
||||
"Hidden-window capture must contain the latest frame");
|
||||
}
|
||||
require(renderer.stats().frame == before + 12,
|
||||
"Hidden-window capture must progress without swapchain images");
|
||||
require(renderer.stats().validation_errors == 0, "Hidden-window validation error");
|
||||
}
|
||||
std::cout << "Vulkan frame, shadow/PBR, atlas upload, readback and resize passed on "
|
||||
<< renderer.stats().device << '\n';
|
||||
} catch (const std::exception& e) {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#include <SDL3/SDL.h>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
namespace {
|
||||
using namespace faset::render;
|
||||
using Clock = std::chrono::steady_clock;
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
struct WindowEvents {
|
||||
SDL_WindowID id{};
|
||||
unsigned minimized{}, restored{}, resized{}, focus_lost{};
|
||||
static bool watch(void* context, SDL_Event* event) {
|
||||
auto& state = *static_cast<WindowEvents*>(context);
|
||||
if (event->window.windowID == state.id) {
|
||||
if (event->type == SDL_EVENT_WINDOW_MINIMIZED)
|
||||
++state.minimized;
|
||||
if (event->type == SDL_EVENT_WINDOW_RESTORED)
|
||||
++state.restored;
|
||||
if (event->type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED)
|
||||
++state.resized;
|
||||
if (event->type == SDL_EVENT_WINDOW_FOCUS_LOST)
|
||||
++state.focus_lost;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
// Some window systems can block inside native window calls. Terminate only this
|
||||
// fixture if that happens, so an acceptance test cannot strand its own process.
|
||||
std::jthread watchdog([](std::stop_token stop) {
|
||||
for (int i = 0; i < 150 && !stop.stop_requested(); ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
if (!stop.stop_requested()) {
|
||||
std::cerr << "Window fixture exceeded 30 seconds; terminating its own process\n";
|
||||
std::_Exit(70);
|
||||
}
|
||||
});
|
||||
try {
|
||||
Renderer renderer({320, 240, "Faset owned window lifecycle test", false, true});
|
||||
int count{};
|
||||
auto windows = SDL_GetWindows(&count);
|
||||
require(windows && count == 1, "Fixture must own exactly one SDL window");
|
||||
auto* window = windows[0];
|
||||
SDL_free(windows);
|
||||
WindowEvents events{SDL_GetWindowID(window)};
|
||||
require(SDL_AddEventWatch(WindowEvents::watch, &events), "Register lifecycle event watch");
|
||||
struct WatchGuard {
|
||||
WindowEvents& events;
|
||||
~WatchGuard() {
|
||||
SDL_RemoveEventWatch(WindowEvents::watch, &events);
|
||||
}
|
||||
} watch{events};
|
||||
std::cout << "driver=" << SDL_GetCurrentVideoDriver() << " stage=created\n" << std::flush;
|
||||
Snapshot scene;
|
||||
scene.ui_quads.push_back({0, 0, 4096, 4096, {1, 0, 0, 1}});
|
||||
unsigned frame{};
|
||||
auto draw = [&] {
|
||||
renderer.poll_events();
|
||||
const unsigned channel = frame++ % 2;
|
||||
scene.ui_quads[0].color = channel ? Color{0, 1, 0, 1} : Color{1, 0, 0, 1};
|
||||
renderer.render(scene);
|
||||
const auto pixels = renderer.pixels();
|
||||
require(pixels.size() == std::size_t(renderer.width()) * renderer.height() * 4,
|
||||
"Window capture dimensions must match the current target");
|
||||
require(pixels.at((8 * renderer.width() + 8) * 4 + channel) > 240,
|
||||
"Window capture must contain the current frame, including while minimized");
|
||||
require(renderer.stats().validation_errors == 0, "Vulkan validation error");
|
||||
};
|
||||
auto await = [&](auto condition, int milliseconds) {
|
||||
const auto deadline = Clock::now() + std::chrono::milliseconds(milliseconds);
|
||||
do {
|
||||
draw();
|
||||
if (condition())
|
||||
return true;
|
||||
SDL_Delay(10);
|
||||
} while (Clock::now() < deadline);
|
||||
return false;
|
||||
};
|
||||
for (const auto& [width, height] : {std::pair{480, 270}, std::pair{360, 300}}) {
|
||||
renderer.resize(width, height);
|
||||
require(await(
|
||||
[&] {
|
||||
int logical_width{}, logical_height{}, pixel_width{}, pixel_height{};
|
||||
SDL_GetWindowSize(window, &logical_width, &logical_height);
|
||||
SDL_GetWindowSizeInPixels(window, &pixel_width, &pixel_height);
|
||||
return logical_width == width && logical_height == height &&
|
||||
renderer.width() == static_cast<unsigned>(pixel_width) &&
|
||||
renderer.height() == static_cast<unsigned>(pixel_height);
|
||||
},
|
||||
2000),
|
||||
"Window/target must converge to the requested resize");
|
||||
}
|
||||
std::cout << "stage=resized width=" << renderer.width() << " height=" << renderer.height()
|
||||
<< " resize_events=" << events.resized << '\n'
|
||||
<< std::flush;
|
||||
// Allow initial configure/focus events to settle before requesting a new state.
|
||||
for (int i = 0; i < 25; ++i) {
|
||||
draw();
|
||||
SDL_Delay(10);
|
||||
}
|
||||
require(SDL_MinimizeWindow(window), "Request minimizing the owned fixture window");
|
||||
if (!await([&] { return SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED; }, 2000)) {
|
||||
std::cout << "SKIP: window system declined the minimize request\n";
|
||||
return 77;
|
||||
}
|
||||
const auto before = renderer.stats().frame;
|
||||
for (int i = 0; i < 12; ++i) {
|
||||
draw();
|
||||
SDL_Delay(10);
|
||||
}
|
||||
require(renderer.stats().frame == before + 12, "Minimized capture must keep progressing");
|
||||
std::cout << "stage=minimized fresh_frames=12 minimize_events=" << events.minimized
|
||||
<< " focus_lost_events=" << events.focus_lost << '\n'
|
||||
<< std::flush;
|
||||
if (argc > 1) {
|
||||
std::filesystem::create_directories(argv[1]);
|
||||
renderer.capture(std::filesystem::path(argv[1]) / "minimized.ppm");
|
||||
}
|
||||
const auto restored_before = events.restored;
|
||||
require(SDL_RestoreWindow(window), "Request restoring the owned fixture window");
|
||||
bool restored = await(
|
||||
[&] {
|
||||
return !(SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) &&
|
||||
events.restored > restored_before;
|
||||
},
|
||||
1000);
|
||||
bool activation_required{};
|
||||
if (!restored) {
|
||||
activation_required = true;
|
||||
require(SDL_RaiseWindow(window), "Request activation of the owned fixture window");
|
||||
restored = await(
|
||||
[&] {
|
||||
return !(SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) &&
|
||||
events.restored > restored_before;
|
||||
},
|
||||
2000);
|
||||
}
|
||||
if (!restored) {
|
||||
std::cout << "SKIP: resize and 12 minimized captures passed; compositor did not "
|
||||
"confirm restore/activation. Programmatic restoration is not supported "
|
||||
"by every Wayland compositor.\n";
|
||||
return 77;
|
||||
}
|
||||
renderer.resize(400, 320);
|
||||
require(await(
|
||||
[&] {
|
||||
int w{}, h{}, logical_width{}, logical_height{};
|
||||
SDL_GetWindowSize(window, &logical_width, &logical_height);
|
||||
SDL_GetWindowSizeInPixels(window, &w, &h);
|
||||
return logical_width == 400 && logical_height == 320 &&
|
||||
renderer.width() == static_cast<unsigned>(w) &&
|
||||
renderer.height() == static_cast<unsigned>(h);
|
||||
},
|
||||
2000),
|
||||
"Restore followed by resize must rebuild the target");
|
||||
for (int i = 0; i < 12; ++i)
|
||||
draw();
|
||||
if (argc > 1)
|
||||
renderer.capture(std::filesystem::path(argv[1]) / "restored.ppm");
|
||||
std::cout << "stage=restored restored_events=" << events.restored
|
||||
<< " activation_required=" << activation_required
|
||||
<< " frames=" << renderer.stats().frame
|
||||
<< " validation_enabled=" << renderer.stats().validation_enabled
|
||||
<< " validation_errors=" << renderer.stats().validation_errors << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <faset/player/SceneView.hpp>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <iostream>
|
||||
#include <numbers>
|
||||
#include <stdexcept>
|
||||
|
||||
using Json = nlohmann::json;
|
||||
@@ -28,6 +29,90 @@ Json component(std::string type, Json fields) {
|
||||
Json entity(std::string id, Json parent, Json components) {
|
||||
return {{"id", id}, {"name", id}, {"parent", parent}, {"components", components}};
|
||||
}
|
||||
void physicsDebug(faset::player::SceneView& view) {
|
||||
for (int dimension : {2, 3}) {
|
||||
const std::string bodyName = dimension == 2 ? "rigid_body_2d" : "rigid_body_3d";
|
||||
Json body{{"body_type", "dynamic"},
|
||||
{"half_extents", dimension == 2 ? Json{.75, .5} : Json{.75, .5, .25}},
|
||||
{"linear_velocity", dimension == 2 ? Json{2, 0} : Json{2, 0, 0}}};
|
||||
const Json authoredPose{{"position", {1, 2, 3}},
|
||||
{"rotation", {0, 0, std::numbers::pi_v<float> / 2}},
|
||||
{"scale", {-2, 3, -4}}};
|
||||
Json scene{{"format", "faset.scene"},
|
||||
{"version", 1},
|
||||
{"id", "physics"},
|
||||
{"name", "physics"},
|
||||
{"dimension", dimension},
|
||||
{"instances", Json::array()},
|
||||
{"entities",
|
||||
Json::array({entity("body", nullptr,
|
||||
Json::array({component("faset.transform", authoredPose),
|
||||
component("faset." + bodyName, body)}))})}};
|
||||
faset::runtime::RuntimeConfig config;
|
||||
config.gravity = {0, 0, 0};
|
||||
faset::runtime::Runtime runtime(config);
|
||||
runtime.load(scene);
|
||||
runtime.advance(config.fixedDelta * 1.5);
|
||||
const auto handle = runtime.find("body");
|
||||
const auto physical = runtime.transform(handle);
|
||||
const auto displayed = runtime.presentation(handle);
|
||||
check(physical.position[0] > displayed.position[0] + .01f,
|
||||
"Fixture separates actual body pose from interpolated presentation");
|
||||
// This is the same explicit current-pose contract used by the Player;
|
||||
// snapshotJson's presentation pose is intentionally unsuitable here.
|
||||
Json physics{
|
||||
{"dimension", dimension},
|
||||
{"entities", Json::array({{{"parent", nullptr},
|
||||
{bodyName, runtime.fields(handle, "faset." + bodyName)},
|
||||
{"transform",
|
||||
{{"position", physical.position},
|
||||
{"rotation", physical.rotation},
|
||||
{"scale", physical.scale}}}}})}};
|
||||
faset::render::Snapshot debug;
|
||||
const auto originalCamera = debug.view_projection;
|
||||
view.appendPhysicsDebug(debug, physics, .02f);
|
||||
check(debug.draws.size() == (dimension == 2 ? 4 : 12), "Box outline edge count");
|
||||
check(debug.view_projection == originalCamera, "Debug overlay leaves camera unchanged");
|
||||
faset::render::Vec3 center{};
|
||||
for (const auto& edge : debug.draws) {
|
||||
check(!edge.cast_shadow && edge.mesh, "Debug edges are unshadowed meshes");
|
||||
for (const auto& vertex : edge.mesh->vertices)
|
||||
check(vertex.normal == faset::render::Vec3{0, 0, 0}, "Debug edge color is unlit");
|
||||
for (int axis = 0; axis < 3; ++axis)
|
||||
center[axis] += edge.model[12 + axis] / float(debug.draws.size());
|
||||
}
|
||||
for (int axis = 0; axis < 3; ++axis)
|
||||
check(std::abs(center[axis] - physical.position[axis]) < .0001f,
|
||||
"Outlines center on current Box2D/Box3D pose");
|
||||
// Rz(pi/2) turns the first X edge into world Y. Abs(scale) gives a
|
||||
// three-unit X edge and 1.5-unit local Y half extent in both adapters.
|
||||
const auto& first = debug.draws.front().model;
|
||||
check(std::abs(first[0]) < .0001f && std::abs(first[1] - 3) < .0001f &&
|
||||
std::abs(first[12] - physical.position[0] - 1.5f) < .0001f,
|
||||
"Negative nonuniform scale and box rotation match physics shape policy");
|
||||
if (dimension == 3)
|
||||
check(std::abs(first[14] - physical.position[2] + 1) < .0001f,
|
||||
"Box3D Z half extent includes absolute Z scale");
|
||||
auto invalid = physics;
|
||||
invalid["entities"][0]["parent"] = "another-body";
|
||||
rejects([&] { view.appendPhysicsDebug(debug, invalid); },
|
||||
"Debug respects runtime root-only physical bodies");
|
||||
if (dimension == 2) {
|
||||
invalid = physics;
|
||||
invalid["entities"][0]["transform"]["rotation"][0] = .5;
|
||||
rejects([&] { view.appendPhysicsDebug(debug, invalid); },
|
||||
"Box2D debug rejects rotation outside Z");
|
||||
}
|
||||
rejects([&] { view.appendPhysicsDebug(debug, physics, 0); },
|
||||
"Debug thickness must be positive");
|
||||
auto opaque = scene;
|
||||
opaque["entities"][0]["components"][1]["version"] = 2;
|
||||
opaque["entities"][0]["components"][1]["fields"] = "future representation";
|
||||
faset::render::Snapshot skipped;
|
||||
view.appendPhysicsDebug(skipped, opaque);
|
||||
check(skipped.draws.empty(), "Debug does not interpret unknown body schema versions");
|
||||
}
|
||||
}
|
||||
void run() {
|
||||
const auto folder =
|
||||
std::filesystem::temp_directory_path() / ("faset-player-test-" + faset::new_id());
|
||||
@@ -74,6 +159,7 @@ void run() {
|
||||
faset::atomic_write(folder / "version.fscene", version);
|
||||
rejects([&] { faset::player::readScene(folder / "version.fscene"); }, "reject cooked version");
|
||||
faset::player::SceneView view(folder);
|
||||
physicsDebug(view);
|
||||
auto snapshot = view.build(scene, 16.f / 9.f);
|
||||
check(snapshot.draws.size() == 1, "SceneView builtin mesh");
|
||||
check(snapshot.draws[0].model[12] == 3 && snapshot.draws[0].model[13] == 2 &&
|
||||
@@ -95,6 +181,22 @@ void run() {
|
||||
view.build(bad, 1);
|
||||
check(!view.diagnostics().empty() && view.diagnostics()[0].starts_with("error:"),
|
||||
"missing asset is diagnostic, not silent success");
|
||||
auto opaque = scene;
|
||||
opaque["entities"][1]["components"][1]["version"] = 2;
|
||||
opaque["entities"][1]["components"][1]["fields"] = {{"asset", 42}, {"color", "future-format"}};
|
||||
const auto unchanged = opaque;
|
||||
check(view.build(opaque, 1).draws.empty(), "Future mesh fields are opaque in editor preview");
|
||||
check(!view.diagnostics().empty() &&
|
||||
view.diagnostics()[0].find("unsupported faset.mesh") != std::string::npos,
|
||||
"Opaque preview component has an actionable diagnostic");
|
||||
check(opaque == unchanged, "Preview preserves unknown component bytes");
|
||||
rejects([&] { world.load(opaque); }, "Player runtime rejects unknown component versions");
|
||||
opaque = scene;
|
||||
opaque["entities"][1]["components"][0]["version"] = 2;
|
||||
opaque["entities"][1]["components"][0]["fields"] = {{"position", "future-format"}};
|
||||
snapshot = view.build(opaque, 1);
|
||||
check(snapshot.draws[0].model[12] == 1 && snapshot.draws[0].model[13] == 2,
|
||||
"Future transform fields are not interpreted as v1 coordinates");
|
||||
auto camera = faset::player::CameraSettings{};
|
||||
camera.eye = camera.target;
|
||||
rejects([&] { view.build(scene, 1, camera); }, "reject degenerate camera");
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "Gameplay.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
using namespace faset::runtime;
|
||||
void check(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
nlohmann::json read(const std::filesystem::path& path) {
|
||||
nlohmann::json value;
|
||||
std::ifstream input(path);
|
||||
input >> value;
|
||||
return value;
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
try {
|
||||
const std::filesystem::path project = FASET_EXAMPLE_PROJECT;
|
||||
const auto manifest = read(project / "project.faset.json");
|
||||
const auto scene = read(project / manifest.at("start_scene").get<std::string>());
|
||||
check(manifest.at("dimension") == FASET_EXAMPLE_DIMENSION,
|
||||
"project dimension matches test");
|
||||
std::set<std::string> ids;
|
||||
for (const auto& entity : scene.at("entities")) {
|
||||
check(ids.insert(entity.at("id").get<std::string>()).second, "unique entity IDs");
|
||||
for (const auto& component : entity.at("components"))
|
||||
check(ids.insert(component.at("id").get<std::string>()).second,
|
||||
"unique component IDs");
|
||||
}
|
||||
const auto schemas = faset::gameplay::schema();
|
||||
check(schemas.size() == (FASET_EXAMPLE_DIMENSION == 3 ? 2u : 1u),
|
||||
"3D module also exports independent Beacon component");
|
||||
Runtime world;
|
||||
faset::gameplay::registerGameplay(world);
|
||||
world.load(scene);
|
||||
auto player = world.find("player");
|
||||
const auto start = world.transform(player);
|
||||
for (int i = 0; i < 15; ++i)
|
||||
world.singleStep();
|
||||
check(world.grounded(player), "player starts supported by actual physics");
|
||||
check(world.transform(world.find("win_marker")).position[1] < -40,
|
||||
"victory hidden until completion");
|
||||
auto walk = [&](Vec3 target, bool allowJump, float tolerance = .15f) {
|
||||
for (int tick = 0; tick < 650; ++tick) {
|
||||
const auto p = world.transform(player).position;
|
||||
const float dx = target[0] - p[0],
|
||||
dz = FASET_EXAMPLE_DIMENSION == 3 ? target[2] - p[2] : 0;
|
||||
if (std::hypot(dx, dz) < tolerance) {
|
||||
for (int i = 0; i < 20; ++i)
|
||||
world.singleStep();
|
||||
return;
|
||||
}
|
||||
InputState input;
|
||||
input.horizontal = std::clamp(dx * 4.0f, -1.0f, 1.0f);
|
||||
input.vertical = -std::clamp(dz * 4.0f, -1.0f, 1.0f);
|
||||
input.jumpPressed = allowJump && world.grounded(player);
|
||||
world.singleStep(input);
|
||||
}
|
||||
const auto p = world.transform(player).position;
|
||||
throw std::runtime_error("Cannot reach waypoint " + std::to_string(target[0]) + "," +
|
||||
std::to_string(target[2]) + " from " + std::to_string(p[0]) +
|
||||
"," + std::to_string(p[1]) + "," + std::to_string(p[2]));
|
||||
};
|
||||
// Drive the published level through input. No teleports are used to collect
|
||||
// its tokens or bypass the gate; this exercises level reachability as well
|
||||
// as the same C++ module linked into the actual Player.
|
||||
const auto first = world.transform(world.find("token_1")).position;
|
||||
walk(first, false);
|
||||
check(world.transform(world.find("token_1")).position[1] > 1.5f,
|
||||
"first token collected through movement");
|
||||
const auto middle = world.transform(world.find("token_2")).position;
|
||||
walk(middle, true);
|
||||
check(world.transform(world.find("token_2")).position[1] > 2,
|
||||
"platform token collected with grounded jumps");
|
||||
const auto third = world.transform(world.find("token_3")).position;
|
||||
walk(third, false);
|
||||
check(world.transform(world.find("gate")).position[1] < -20,
|
||||
"three pickups open physical gate");
|
||||
const auto goal = world.transform(world.find("goal")).position;
|
||||
if (FASET_EXAMPLE_DIMENSION == 3)
|
||||
walk({4, 0, goal[2]}, false);
|
||||
walk(goal, false, .95f);
|
||||
check(world.transform(world.find("win_marker")).position[1] > 2,
|
||||
"exit completes objective and reveals victory marker");
|
||||
world.singleStep({0, 0, false, true});
|
||||
check(world.transform(world.find("gate")).position[1] > 0, "E resets gate");
|
||||
check(world.transform(world.find("win_marker")).position[1] < -40, "E clears victory");
|
||||
check(std::abs(world.transform(player).position[0] - start.position[0]) < .01f,
|
||||
"E resets player spawn");
|
||||
check(world.transform(world.find("token_1")).position[1] < 1, "E restores pickups");
|
||||
const auto previousPlayer = player;
|
||||
world.load(scene);
|
||||
check(!world.valid(previousPlayer) && world.valid(world.find("player")),
|
||||
"restart clears captured state and stale handles");
|
||||
check(world.diagnostics().empty(), "playthrough must not hide gameplay exceptions");
|
||||
std::cout << "Playable " << FASET_EXAMPLE_DIMENSION
|
||||
<< "D project reached its objective and reset\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,34 @@ int main() {
|
||||
declarative.layout(200, 100);
|
||||
click(declarative, *declarative.find("run"));
|
||||
check(action == 1 && declarative.find("run")->text == "Run", "layout reload lost callback");
|
||||
const auto initial_height = declarative.find("run")->layout.height;
|
||||
const auto invalid_patch =
|
||||
ui::Json{{"id", "root"},
|
||||
{"children",
|
||||
ui::Json::array({{{"id", "run"}, {"layout", {{"height", 99}}}},
|
||||
{{"id", "bad-label"}, {"kind", "label"}, {"text", 123}}})}};
|
||||
rejected = false;
|
||||
try {
|
||||
declarative.apply_layout(invalid_patch);
|
||||
} catch (...) {
|
||||
rejected = true;
|
||||
}
|
||||
check(rejected && declarative.find("run")->layout.height == initial_height &&
|
||||
!declarative.find("bad-label"),
|
||||
"Layout validation must reject all changes before mutation");
|
||||
rejected = false;
|
||||
try {
|
||||
declarative.apply_layout(
|
||||
{{"id", "root"},
|
||||
{"children",
|
||||
ui::Json::array({{{"id", "new-parent"},
|
||||
{"kind", "column"},
|
||||
{"children", ui::Json::array({{{"id", "run"}}})}}})}});
|
||||
} catch (...) {
|
||||
rejected = true;
|
||||
}
|
||||
check(rejected && !declarative.find("new-parent"),
|
||||
"Hot layout must not duplicate/reparent a retained widget ID");
|
||||
std::cout << "UI: UTF-8, shaping, text/IME/clipboard, focus, transactions, "
|
||||
"layout, clipping, docking OK\n";
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user