Checkpoint 3: deliver playable samples and complete native authoring workflows

This commit is contained in:
Emil
2026-09-18 04:29:41 +03:00
parent fad5eb4e55
commit d834cfad67
92 changed files with 8335 additions and 353 deletions
+34
View File
@@ -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
View File
@@ -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';
+164
View File
@@ -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");
+110
View File
@@ -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")
+75
View File
@@ -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;
}
}
+167
View File
@@ -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;
}
}
+138
View File
@@ -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;
}
}
+135
View File
@@ -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;
}
}
+127
View File
@@ -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;
}
}
+227
View File
@@ -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;
}
}
+72
View File
@@ -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
View File
@@ -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")
+153
View File
@@ -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;
}
}
+23
View File
@@ -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) {
+180
View File
@@ -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;
}
}
+102
View File
@@ -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");
+110
View File
@@ -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;
}
}
+28
View File
@@ -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;