Checkpoint 4: harden native paths, authoring workflows and Player lifecycle
This commit is contained in:
+34
-17
@@ -3,6 +3,7 @@
|
||||
#include <chrono>
|
||||
#include <faset/assets/asset_pipeline.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
@@ -102,13 +103,15 @@ void success(const ImportResult& result) {
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
const auto root = fs::temp_directory_path() /
|
||||
("faset-assets-test-" +
|
||||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
const auto root =
|
||||
fs::temp_directory_path() /
|
||||
faset::path_from_utf8(
|
||||
"Faset Café 世界 assets " +
|
||||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
fs::create_directories(root);
|
||||
try {
|
||||
AssetPipeline pipeline(root / "cache");
|
||||
const auto source = root / "door.glb";
|
||||
const auto source = root / faset::path_from_utf8("дверь 世界.glb");
|
||||
glb(source);
|
||||
auto first = pipeline.import_asset({source});
|
||||
success(first);
|
||||
@@ -171,6 +174,19 @@ int main() {
|
||||
require(pipeline.overrides(first.asset_id) == custom, "conflict erased overrides");
|
||||
ImportRequest resolve{source};
|
||||
resolve.allow_removed_outputs = true;
|
||||
resolve.expected_generation = removed.generation;
|
||||
resolve.expected_active_generation = added.generation;
|
||||
glb(source, "Renamed panel", true, false, .5f);
|
||||
auto changed_since_review = pipeline.import_asset(resolve);
|
||||
require(changed_since_review.status == ImportStatus::conflict &&
|
||||
pipeline.load_asset(first.asset_id).generation == added.generation,
|
||||
"review approval must not publish a changed candidate");
|
||||
glb(source, "Renamed panel", true, false, .25f);
|
||||
resolve.expected_active_generation = first.generation;
|
||||
require(pipeline.import_asset(resolve).status == ImportStatus::conflict &&
|
||||
pipeline.load_asset(first.asset_id).generation == added.generation,
|
||||
"review approval must not replace a different active generation");
|
||||
resolve.expected_active_generation = added.generation;
|
||||
auto resolved = pipeline.import_asset(resolve);
|
||||
success(resolved);
|
||||
require(pipeline.load_asset(first.asset_id).nodes.size() == 1,
|
||||
@@ -207,14 +223,14 @@ int main() {
|
||||
"ambiguous rename silently matched");
|
||||
auto duplicate = document("Duplicate", true, true);
|
||||
duplicate["nodes"][1]["extras"]["faset_id"] = "node-door";
|
||||
duplicate["buffers"][0]["uri"] = "mesh.bin";
|
||||
save(root / "mesh.bin", geometry(0));
|
||||
duplicate["buffers"][0]["uri"] = "геометрия.bin";
|
||||
save(root / faset::path_from_utf8("геометрия.bin"), geometry(0));
|
||||
save(root / "duplicate.gltf", duplicate.dump());
|
||||
require(pipeline.import_asset({root / "duplicate.gltf"}).status == ImportStatus::failed,
|
||||
"duplicate source ID accepted");
|
||||
auto external = document("External", true, false);
|
||||
external["buffers"][0]["uri"] = "mesh.bin";
|
||||
external["images"] = Json::array({{{"uri", "pixel.png"}, {"mimeType", "image/png"}}});
|
||||
external["buffers"][0]["uri"] = "геометрия.bin";
|
||||
external["images"] = Json::array({{{"uri", "пиксель.png"}, {"mimeType", "image/png"}}});
|
||||
external["textures"] = Json::array({{{"source", 0}}});
|
||||
external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"] = {{"index", 0}};
|
||||
// Real 1x1 PNG payload, transparent pixel; importer owns encoded bytes.
|
||||
@@ -223,7 +239,7 @@ int main() {
|
||||
0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0,
|
||||
0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0,
|
||||
1, 165, 246, 69, 64, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130};
|
||||
save(root / "pixel.png", png);
|
||||
save(root / faset::path_from_utf8("пиксель.png"), png);
|
||||
save(root / "external.gltf", external.dump());
|
||||
auto ext = pipeline.import_asset({root / "external.gltf"});
|
||||
success(ext);
|
||||
@@ -231,7 +247,7 @@ int main() {
|
||||
require(ext_asset.textures.size() == 1 && ext_asset.textures[0].bytes.size() == png.size(),
|
||||
"external image not extracted");
|
||||
require(ext_asset.materials[0].base_color_texture == 0, "material texture reference lost");
|
||||
save(root / "mesh.bin", geometry(.3f));
|
||||
save(root / faset::path_from_utf8("геометрия.bin"), geometry(.3f));
|
||||
auto dependent = pipeline.import_asset({root / "external.gltf"});
|
||||
success(dependent);
|
||||
require(dependent.generation != ext.generation, "buffer dependency not invalidated");
|
||||
@@ -269,7 +285,7 @@ int main() {
|
||||
const auto dependency_active = pipeline.load_asset(ext.asset_id).generation;
|
||||
ImportJob mutate([&](const ImportProgress& progress) {
|
||||
if (progress.fraction == .5f)
|
||||
save(root / "mesh.bin", geometry(.7f));
|
||||
save(root / faset::path_from_utf8("геометрия.bin"), geometry(.7f));
|
||||
});
|
||||
require(pipeline.import_asset({root / "external.gltf"}, mutate).status ==
|
||||
ImportStatus::failed,
|
||||
@@ -277,7 +293,7 @@ int main() {
|
||||
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";
|
||||
const auto picture = root / faset::path_from_utf8("спрайт Café.PNG");
|
||||
save(picture, faset::test_images::png_red_green);
|
||||
ImportRequest image_request{picture};
|
||||
image_request.settings = {{"pixels_per_unit", 20.0}};
|
||||
@@ -345,15 +361,16 @@ int main() {
|
||||
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");
|
||||
fs::rename(faset::path_from_utf8(faset::path_to_utf8(picture) + ".faset-import.json"),
|
||||
faset::path_from_utf8(faset::path_to_utf8(renamed) + ".faset-import.json"));
|
||||
fs::rename(faset::path_from_utf8(faset::path_to_utf8(picture) + ".faset-overrides.json"),
|
||||
faset::path_from_utf8(faset::path_to_utf8(renamed) + ".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(),
|
||||
require(pipeline.current_manifest(image_first.asset_id)["source"] ==
|
||||
faset::path_to_utf8(renamed),
|
||||
"Image rename retained old source pointer");
|
||||
require(pipeline.overrides(image_first.asset_id).contains(texture_id),
|
||||
"Image rename/reimport lost overrides");
|
||||
|
||||
+136
-22
@@ -11,6 +11,12 @@
|
||||
#ifndef _WIN32
|
||||
#include <csignal>
|
||||
#endif
|
||||
#ifdef __linux__
|
||||
#include <cerrno>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
using namespace faset;
|
||||
namespace fs = std::filesystem;
|
||||
void require(bool value, const char* message) {
|
||||
@@ -38,7 +44,7 @@ int integration(const fs::path& root) {
|
||||
fs::create_directories(root);
|
||||
editor::BuildConfig config;
|
||||
config.project_root = root / "project";
|
||||
config.engine_root = FASET_ENGINE_SOURCE;
|
||||
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
|
||||
config.build_directory = root / "native-build";
|
||||
config.cache_root = root / "project" / ".faset" / "cache";
|
||||
editor::BuildService service(config);
|
||||
@@ -114,18 +120,19 @@ int integration(const fs::path& root) {
|
||||
{"components", components}});
|
||||
auto result =
|
||||
wait(service.start_export(document, root / ("export-" + std::to_string(dimension))));
|
||||
const auto directory = fs::path(result.result.at("directory").get<std::string>());
|
||||
const auto directory = path_from_utf8(result.result.at("directory").get<std::string>());
|
||||
require(result.result.at("configuration") == "Release",
|
||||
"Exports default to the Release profile");
|
||||
require(fs::path(result.result.at("build_directory").get<std::string>()) ==
|
||||
require(path_from_utf8(result.result.at("build_directory").get<std::string>()) ==
|
||||
config.build_directory / "Release",
|
||||
"Export has a separate CMake directory");
|
||||
require(read_json(directory / "manifest.json").at("configuration") == "Release",
|
||||
"Export manifest records the actual profile");
|
||||
Process player({{result.result.at("executable").get<std::string>(), "--headless",
|
||||
"--frames", "3", "--capture", (directory / "verification.ppm").string()},
|
||||
directory,
|
||||
{}});
|
||||
Process player(
|
||||
{{result.result.at("executable").get<std::string>(), "--headless", "--frames", "3",
|
||||
"--capture", path_to_utf8(directory / "verification.ppm")},
|
||||
directory,
|
||||
{}});
|
||||
std::cout << collect(player);
|
||||
require(fs::file_size(directory / "verification.ppm") > 1000,
|
||||
"Exported game rendered a frame");
|
||||
@@ -139,12 +146,12 @@ int integration(const fs::path& root) {
|
||||
auto release_cache = read_text(config.build_directory / "Release" / "CMakeCache.txt");
|
||||
auto rebuilt = wait(service.start_build());
|
||||
require(rebuilt.result.at("configuration") == "Debug", "Development builds remain Debug");
|
||||
require(fs::path(rebuilt.result.at("build_directory").get<std::string>()) ==
|
||||
require(path_from_utf8(rebuilt.result.at("build_directory").get<std::string>()) ==
|
||||
config.build_directory / "Debug",
|
||||
"Development CMake directory is isolated");
|
||||
require(read_text(config.build_directory / "Release" / "CMakeCache.txt") == release_cache,
|
||||
"Development build preserves the Release cache");
|
||||
auto schema = read_json(rebuilt.result.at("schema").get<std::string>());
|
||||
auto schema = read_json(path_from_utf8(rebuilt.result.at("schema").get<std::string>()));
|
||||
bool updated{};
|
||||
for (const auto& type : schema.at("types"))
|
||||
if (type.value("name", "") == "Custom Character")
|
||||
@@ -162,13 +169,104 @@ int integration(const fs::path& root) {
|
||||
"schema and failed-build recovery passed\n";
|
||||
return 0;
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
#ifdef __linux__
|
||||
void descendant_cleanup(const fs::path& executable, const fs::path& directory) {
|
||||
struct Subreaper {
|
||||
int previous{};
|
||||
Subreaper() {
|
||||
require(::prctl(PR_GET_CHILD_SUBREAPER, &previous) == 0 &&
|
||||
::prctl(PR_SET_CHILD_SUBREAPER, 1) == 0,
|
||||
"Install test-only descendant reaper");
|
||||
}
|
||||
~Subreaper() {
|
||||
::prctl(PR_SET_CHILD_SUBREAPER, previous);
|
||||
}
|
||||
} subreaper;
|
||||
for (bool cancel : {false, true}) {
|
||||
Process process(
|
||||
{{path_to_utf8(executable), cancel ? "--tree-sleep" : "--tree-exit"}, directory, {}});
|
||||
std::string output;
|
||||
bool stopped{};
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
auto state = process.poll();
|
||||
output += state.output;
|
||||
if (cancel && output.find("leader-tail\n") != std::string::npos) {
|
||||
process.cancel();
|
||||
state = process.poll();
|
||||
output += state.output;
|
||||
}
|
||||
if (!state.running) {
|
||||
require(cancel || state.exit_code == 0, "Leader exit status remains intact");
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
require(stopped && output.find("leader-tail\n") != std::string::npos,
|
||||
"Leader exit and buffered stdout tail are observed");
|
||||
const auto descendant = static_cast<pid_t>(std::stol(output));
|
||||
int status{};
|
||||
pid_t reaped{};
|
||||
const auto reap_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
do {
|
||||
reaped = ::waitpid(descendant, &status, WNOHANG);
|
||||
if (reaped != 0)
|
||||
break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
} while (std::chrono::steady_clock::now() < reap_deadline);
|
||||
const bool cleaned =
|
||||
reaped == descendant && WIFSIGNALED(status) && WTERMSIG(status) == SIGKILL;
|
||||
if (reaped == 0) {
|
||||
// Clean up the still-owned child even when this regression fails.
|
||||
::kill(descendant, SIGKILL);
|
||||
while (::waitpid(descendant, &status, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
}
|
||||
require(cleaned, "Leader completion terminates its SIGTERM-resistant descendant");
|
||||
process.cancel(); // Completed ownership must not signal any stale process-group ID.
|
||||
}
|
||||
}
|
||||
#endif
|
||||
int test_main(int argc, char** argv) {
|
||||
#ifdef __linux__
|
||||
if (argc > 1 &&
|
||||
(std::string(argv[1]) == "--tree-exit" || std::string(argv[1]) == "--tree-sleep")) {
|
||||
int ready[2];
|
||||
if (::pipe(ready) != 0)
|
||||
return 2;
|
||||
const auto descendant = ::fork();
|
||||
if (descendant < 0)
|
||||
return 3;
|
||||
if (descendant == 0) {
|
||||
::close(ready[0]);
|
||||
std::signal(SIGTERM, SIG_IGN);
|
||||
const char byte = 'r';
|
||||
if (::write(ready[1], &byte, 1) != 1)
|
||||
::_exit(4);
|
||||
::close(ready[1]);
|
||||
while (true)
|
||||
::pause();
|
||||
}
|
||||
::close(ready[1]);
|
||||
char byte{};
|
||||
const auto count = ::read(ready[0], &byte, 1);
|
||||
::close(ready[0]);
|
||||
if (count != 1)
|
||||
return 5;
|
||||
std::cout << descendant << "\nleader-tail\n" << std::flush;
|
||||
if (std::string(argv[1]) == "--tree-sleep")
|
||||
while (true)
|
||||
::pause();
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
if (argc > 1 && std::string(argv[1]) == "--child") {
|
||||
Json args = Json::array();
|
||||
for (int i = 2; i < argc; ++i)
|
||||
args.push_back(argv[i]);
|
||||
std::cout << Json{{"args", args},
|
||||
{"cwd", fs::current_path().string()},
|
||||
{"cwd", path_to_utf8(fs::current_path())},
|
||||
{"env", std::getenv("FASET_PROCESS_TEST")
|
||||
? std::getenv("FASET_PROCESS_TEST")
|
||||
: ""}}
|
||||
@@ -188,32 +286,35 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
if (argc == 3 && std::string(argv[1]) == "--integration") {
|
||||
try {
|
||||
return integration(fs::absolute(argv[2]));
|
||||
return integration(fs::absolute(path_from_utf8(argv[2])));
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
fs::path temporary = fs::temp_directory_path() / ("Faset build test " + new_id());
|
||||
fs::path temporary = fs::temp_directory_path() / path_from_utf8("Faset Café 世界 " + new_id());
|
||||
try {
|
||||
fs::create_directories(temporary);
|
||||
auto executable = fs::absolute(argv[0]);
|
||||
Process child({{executable.string(), "--child", "space argument", "quote\"backslash\\",
|
||||
"$(touch not-executed); & |", ""},
|
||||
const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
|
||||
const auto executable = temporary / original_executable.filename();
|
||||
fs::copy_file(original_executable, executable);
|
||||
Process child({{path_to_utf8(executable), "--child", "space argument", "quote\"backslash\\",
|
||||
"$(touch not-executed); & |", "", "Café 世界 Привет 😀"},
|
||||
temporary,
|
||||
{{"FASET_PROCESS_TEST", "value with spaces"}}});
|
||||
auto text = collect(child);
|
||||
auto result = Json::parse(text.substr(0, text.find('\n')));
|
||||
require(result["args"] == Json::array({"space argument", "quote\"backslash\\",
|
||||
"$(touch not-executed); & |", ""}),
|
||||
require(result["args"] ==
|
||||
Json::array({"space argument", "quote\"backslash\\",
|
||||
"$(touch not-executed); & |", "", "Café 世界 Привет 😀"}),
|
||||
"Arguments must remain literal");
|
||||
require(result["env"] == "value with spaces", "Child environment override");
|
||||
require(fs::equivalent(result["cwd"].get<std::string>(), temporary),
|
||||
require(fs::equivalent(path_from_utf8(result["cwd"].get<std::string>()), temporary),
|
||||
"Child working directory");
|
||||
require(text.find("stderr-sentinel") != std::string::npos && text.size() > 100000,
|
||||
"Combined pipe output drained fully");
|
||||
require(!fs::exists(temporary / "not-executed"), "No shell execution");
|
||||
Process sleeper({{executable.string(), "--sleep"}, temporary, {}});
|
||||
Process sleeper({{path_to_utf8(executable), "--sleep"}, temporary, {}});
|
||||
bool ready{};
|
||||
while (!ready) {
|
||||
auto p = sleeper.poll();
|
||||
@@ -226,9 +327,12 @@ int main(int argc, char** argv) {
|
||||
require(!sleeper.poll().running, "Cancellation must reap the process");
|
||||
require(std::chrono::steady_clock::now() - start < std::chrono::seconds(3),
|
||||
"Cancellation must finish promptly");
|
||||
#ifdef __linux__
|
||||
descendant_cleanup(executable, temporary);
|
||||
#endif
|
||||
editor::BuildConfig config;
|
||||
config.project_root = temporary / "project";
|
||||
config.engine_root = FASET_ENGINE_SOURCE;
|
||||
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
|
||||
editor::BuildService service(config);
|
||||
service.scaffold("Test project", 2);
|
||||
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", "// User code\n");
|
||||
@@ -238,7 +342,7 @@ int main(int argc, char** argv) {
|
||||
auto id = service.start_cook(scene(2));
|
||||
auto cooked = service.wait(id);
|
||||
require(cooked.state == "succeeded", "Cook job succeeds");
|
||||
auto bytes = read_text(cooked.result.at("scene").get<std::string>());
|
||||
auto bytes = read_text(path_from_utf8(cooked.result.at("scene").get<std::string>()));
|
||||
require(bytes.substr(0, 8) == "FASETSCN" && bytes.size() > 20, "Cooked envelope magic");
|
||||
std::uint32_t version{};
|
||||
std::uint64_t size{};
|
||||
@@ -287,3 +391,13 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
return faset::run_utf8_main(argc, argv, test_main);
|
||||
}
|
||||
#else
|
||||
int main(int argc, char** argv) {
|
||||
return test_main(argc, argv);
|
||||
}
|
||||
#endif
|
||||
|
||||
+40
-2
@@ -33,6 +33,44 @@ int main() {
|
||||
faset::sha256(faset::read_text(directory / "state.json")));
|
||||
CHECK(std::filesystem::equivalent(faset::project_path(directory, "assets/../state.json"),
|
||||
directory / "state.json"));
|
||||
const auto unicode = directory / faset::path_from_utf8("Faset Café 世界/Сцена 😀.json");
|
||||
faset::atomic_write_json(unicode, {{"hello", "Unicode paths"}});
|
||||
faset::atomic_write_json(unicode, {{"hello", "Replaced"}});
|
||||
CHECK(faset::read_json(unicode).at("hello") == "Replaced");
|
||||
CHECK(faset::sha256_file(unicode) == faset::sha256(faset::read_text(unicode)));
|
||||
CHECK(faset::path_from_utf8(faset::path_to_utf8(unicode)) == unicode);
|
||||
CHECK(faset::generic_path_to_utf8(unicode.lexically_relative(directory)) ==
|
||||
"Faset Café 世界/Сцена 😀.json");
|
||||
CHECK(std::filesystem::equivalent(
|
||||
faset::project_path(directory, faset::path_from_utf8("Faset Café 世界/Сцена 😀.json")),
|
||||
unicode));
|
||||
for (const auto& invalid :
|
||||
std::vector<std::string>{std::string("a\0b", 3), "\xc0\xaf", "\xed\xa0\x80",
|
||||
"\xf4\x90\x80\x80", "\xe2\x82", "\x80"}) {
|
||||
bool invalid_rejected = false;
|
||||
try {
|
||||
faset::path_from_utf8(invalid);
|
||||
} catch (const faset::Error& error) {
|
||||
invalid_rejected = error.json().at("code") == "path.encoding";
|
||||
}
|
||||
CHECK(invalid_rejected);
|
||||
}
|
||||
auto deep = directory / faset::path_from_utf8("Long Café 世界");
|
||||
while (deep.native().size() < 310)
|
||||
deep /= "directory-segment-0123456789";
|
||||
const auto long_file = deep / faset::path_from_utf8("Сцена 世界.json");
|
||||
faset::atomic_write_json(long_file, {{"long_path", true}});
|
||||
CHECK(faset::read_json(long_file).at("long_path") == true);
|
||||
CHECK(faset::sha256_file(long_file) == faset::sha256(faset::read_text(long_file)));
|
||||
CHECK(std::filesystem::is_regular_file(faset::native_io_path(long_file)));
|
||||
CHECK(faset::path_to_utf8(long_file).find("\\\\?\\") == std::string::npos);
|
||||
#ifdef _WIN32
|
||||
CHECK(faset::native_io_path(long_file).native().starts_with(LR"(\\?\)"));
|
||||
CHECK(faset::native_io_path(std::filesystem::path(LR"(\\server\share\file.txt)")) ==
|
||||
std::filesystem::path(LR"(\\?\UNC\server\share\file.txt)"));
|
||||
CHECK(faset::native_io_path(faset::native_io_path(long_file)) ==
|
||||
faset::native_io_path(long_file));
|
||||
#endif
|
||||
bool rejected = false;
|
||||
try {
|
||||
faset::project_path(directory, "../escape");
|
||||
@@ -47,12 +85,12 @@ int main() {
|
||||
rejected = true;
|
||||
}
|
||||
CHECK(rejected);
|
||||
std::filesystem::remove_all(directory);
|
||||
std::filesystem::remove_all(faset::native_io_path(directory));
|
||||
std::cout << "Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path "
|
||||
"boundaries passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::filesystem::remove_all(directory);
|
||||
std::filesystem::remove_all(faset::native_io_path(directory));
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#include <chrono>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/editor_ui.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
using namespace faset;
|
||||
namespace fs = std::filesystem;
|
||||
namespace {
|
||||
void check(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
Json geometry(const render::DrawItem& draw) {
|
||||
Json positions = Json::array();
|
||||
for (const auto& vertex : draw.mesh->vertices)
|
||||
positions.push_back(vertex.position);
|
||||
return positions;
|
||||
}
|
||||
int run(int argc, char** argv) {
|
||||
try {
|
||||
check(argc == 3, "Usage: faset_blender_editor_probe FIXTURE_DIRECTORY NEW_PROJECT");
|
||||
const auto fixture = path_from_utf8(argv[1]), project = path_from_utf8(argv[2]);
|
||||
check(!fs::exists(project), "Use a new project directory for the Blender UI probe");
|
||||
const auto source = project / "Assets/door";
|
||||
fs::create_directories(source);
|
||||
editor::Session session({project, path_from_utf8(FASET_TEST_ENGINE), project});
|
||||
render::Renderer renderer({.width = 1280,
|
||||
.height = 900,
|
||||
.title = "Blender reimport",
|
||||
.headless = true,
|
||||
.validation = true});
|
||||
editor::EditorUI ui(session, renderer,
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
auto import = [&](const char* stage) {
|
||||
fs::copy(fixture / stage, source,
|
||||
fs::copy_options::recursive | fs::copy_options::overwrite_existing);
|
||||
const auto job = session.commands()
|
||||
.call("faset_import", {{"path", "Assets/door/manifest.json"}})
|
||||
.at("job");
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
ui.frame({});
|
||||
auto result = session.commands().call("faset_job", {{"id", job}});
|
||||
if (result.at("state") != "queued" && result.at("state") != "running")
|
||||
return result;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
throw std::runtime_error("Blender import timed out");
|
||||
};
|
||||
const auto initial = import("initial");
|
||||
check(initial.at("state") == "succeeded", "Initial Blender import failed");
|
||||
const auto id = initial.at("result").at("asset_id");
|
||||
const auto document = ui.current_document();
|
||||
auto current = session.authoring().query(document);
|
||||
Json operations = Json::array();
|
||||
for (int index = 0; index < 2; ++index) {
|
||||
auto entity = authoring::make_entity(session.authoring().schemas(), "Placed door");
|
||||
entity["components"][0]["fields"]["position"] = {index ? 3 : -3, 0, 0};
|
||||
entity["components"].push_back(
|
||||
{{"id", new_id()},
|
||||
{"type", "faset.mesh"},
|
||||
{"version", 1},
|
||||
{"fields",
|
||||
{{"asset", id},
|
||||
{"primitive", "asset"},
|
||||
{"color", index ? Json{0.5, 0.8, 0.3, 1} : Json{0.3, 0.5, 0.9, 1}}}}});
|
||||
// Missing gameplay packages stay opaque; reimport must not remove them.
|
||||
entity["components"].push_back({{"id", new_id()},
|
||||
{"type", "project.door"},
|
||||
{"version", 2},
|
||||
{"fields", {{"locked", index == 0}, {"speed", 2.5}}}});
|
||||
auto body = session.authoring().schemas().default_fields("faset.rigid_body_3d");
|
||||
body["body_type"] = "static";
|
||||
body["friction"] = index ? 0.75 : 0.25;
|
||||
entity["components"].push_back({{"id", new_id()},
|
||||
{"type", "faset.rigid_body_3d"},
|
||||
{"version", 1},
|
||||
{"fields", body}});
|
||||
operations.push_back({{"op", "entity.create"}, {"entity", entity}});
|
||||
}
|
||||
session.authoring().transact(document, current.at("revision"), operations);
|
||||
current = session.authoring().query(document);
|
||||
ui.frame({});
|
||||
check(ui.snapshot().draws.size() >= 2, "Both placed Blender meshes must render");
|
||||
const auto first = ui.snapshot().draws[0], second = ui.snapshot().draws[1];
|
||||
check(first.mesh == second.mesh && first.model != second.model,
|
||||
"Two independent placements must share the imported mesh");
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(project / "initial.ppm");
|
||||
const auto renamed = import("renamed");
|
||||
check(renamed.at("state") == "succeeded", "Renamed Blender import failed");
|
||||
// Exercise the real editor's generation watcher, without manually clearing SceneView.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1100));
|
||||
ui.frame({});
|
||||
const auto first_after = ui.snapshot().draws[0], second_after = ui.snapshot().draws[1];
|
||||
check(geometry(first) != geometry(first_after) &&
|
||||
geometry(second) != geometry(second_after),
|
||||
"Both live Editor instances must receive changed Blender geometry");
|
||||
check(first_after.mesh == second_after.mesh, "New instances share one cooked mesh");
|
||||
check(first.model == first_after.model && second.model == second_after.model &&
|
||||
first.color == first_after.color && second.color == second_after.color,
|
||||
"Reimport must preserve each instance's placement and tint");
|
||||
check(session.authoring().query(document) == current,
|
||||
"Reimport must preserve authoring IDs, gameplay fields and revision");
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(project / "renamed.ppm");
|
||||
const auto removed = import("removed");
|
||||
check(removed.at("state") == "conflict", "Removed Blender output must conflict");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1100));
|
||||
ui.frame({});
|
||||
check(geometry(ui.snapshot().draws[0]) == geometry(first_after) &&
|
||||
geometry(ui.snapshot().draws[1]) == geometry(second_after),
|
||||
"Conflict must keep both last-good visible meshes");
|
||||
check(session.authoring().query(document) == current,
|
||||
"Import conflict must preserve authoring");
|
||||
check(renderer.stats().validation_errors == 0, "Vulkan validation failed");
|
||||
atomic_write_json(project / "probe.json",
|
||||
{{"status", "passed"},
|
||||
{"instances", 2},
|
||||
{"live_geometry_updated", true},
|
||||
{"placement_and_tint_preserved", true},
|
||||
{"opaque_gameplay_preserved", true},
|
||||
{"physics_fields_preserved", true},
|
||||
{"conflict_keeps_visible_generation", true},
|
||||
{"validation_enabled", renderer.stats().validation_enabled},
|
||||
{"validation_errors", renderer.stats().validation_errors}});
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
#ifdef _WIN32
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
return run_utf8_main(argc, argv, run);
|
||||
}
|
||||
#else
|
||||
int main(int argc, char** argv) {
|
||||
return run(argc, argv);
|
||||
}
|
||||
#endif
|
||||
@@ -4,12 +4,21 @@
|
||||
|
||||
int main() {
|
||||
using namespace faset;
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-session-" + new_id());
|
||||
const auto root = std::filesystem::temp_directory_path() /
|
||||
path_from_utf8("Faset Café 世界 session " + new_id());
|
||||
try {
|
||||
editor::Session session({root, FASET_TEST_ENGINE, {}});
|
||||
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), {}});
|
||||
session.scaffold("Settings", 3);
|
||||
auto document = session.authoring().create("Start", 3);
|
||||
session.authoring().save(document.at("id"), "Scenes/main.scene.json");
|
||||
session.commands().call("faset_document_save", {{"document", document.at("id")},
|
||||
{"path", "Scenes/Начало 世界.scene.json"}});
|
||||
require(session.authoring().query(document.at("id")).at("path") ==
|
||||
"Scenes/Начало 世界.scene.json",
|
||||
"test", "Saved path must round-trip as UTF-8");
|
||||
require(std::filesystem::is_regular_file(root /
|
||||
path_from_utf8("Scenes/Начало 世界.scene.json")),
|
||||
"test", "Unicode scene was saved under the wrong filename");
|
||||
session.commands().call("faset_document_open", {{"path", "Scenes/Начало 世界.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",
|
||||
@@ -17,7 +26,7 @@ int main() {
|
||||
{"settings",
|
||||
{{"name", "Проект 世界"},
|
||||
{"dimension", 2},
|
||||
{"start_scene", "Scenes/main.scene.json"}}}});
|
||||
{"start_scene", "Scenes/Начало 世界.scene.json"}}}});
|
||||
require(changed.at("settings").at("name") == "Проект 世界" &&
|
||||
session.project().at("dimension") == 2,
|
||||
"test", "Project settings were not saved");
|
||||
|
||||
@@ -43,11 +43,11 @@ void near(float a, float b, const char* message) {
|
||||
int main() {
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-gizmo-ui-" + new_id());
|
||||
try {
|
||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
||||
editor::Session session({root, path_from_utf8(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");
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
ui.frame({});
|
||||
auto query = [&] { return session.authoring().query(ui.current_document()); };
|
||||
auto parent =
|
||||
@@ -158,10 +158,10 @@ int main() {
|
||||
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';
|
||||
<< path_to_utf8(root) << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << "\nRetained: " << root << '\n';
|
||||
std::cerr << error.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#include <chrono>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/editor_ui.hpp>
|
||||
#include <iostream>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
using namespace faset;
|
||||
namespace {
|
||||
void check(bool value, const std::string& message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
void click(editor::EditorUI& ui, const std::string& id) {
|
||||
for (int attempt = 0; attempt < 40; ++attempt) {
|
||||
const auto* w = ui.widgets().find(id);
|
||||
check(w, "Missing widget: " + id);
|
||||
const auto visible = w->rect.intersection(w->clip);
|
||||
if (visible.width > 2 && visible.height > 2) {
|
||||
render::Event down;
|
||||
down.type = render::Event::Type::MouseDown;
|
||||
down.button = 1;
|
||||
down.x = visible.x + visible.width * .5f;
|
||||
down.y = visible.y + visible.height * .5f;
|
||||
auto up = down;
|
||||
up.type = render::Event::Type::MouseUp;
|
||||
ui.frame({down, up});
|
||||
return;
|
||||
}
|
||||
auto* parent = w->parent;
|
||||
while (parent && !parent->layout.scroll)
|
||||
parent = parent->parent;
|
||||
check(parent, "Cannot scroll to " + id);
|
||||
render::Event move, wheel;
|
||||
move.type = render::Event::Type::MouseMove;
|
||||
move.x = parent->rect.x + 10;
|
||||
move.y = parent->rect.y + 10;
|
||||
wheel.type = render::Event::Type::Wheel;
|
||||
wheel.y = w->rect.y < parent->rect.y ? 1 : -1;
|
||||
ui.frame({move, wheel});
|
||||
}
|
||||
throw std::runtime_error("Widget remains clipped: " + id);
|
||||
}
|
||||
Json job(editor::Session& session, const std::string& id) {
|
||||
return session.commands().call("faset_job", {{"id", id}});
|
||||
}
|
||||
Json wait(editor::Session& session, editor::EditorUI& ui, const std::string& id) {
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
ui.frame({});
|
||||
const auto current = job(session, id);
|
||||
if (current.at("state") != "queued" && current.at("state") != "running")
|
||||
return current;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
throw std::runtime_error("Import did not finish");
|
||||
}
|
||||
std::set<std::string> jobs(editor::Session& session) {
|
||||
std::set<std::string> result;
|
||||
const auto response = session.commands().call("faset_jobs", Json::object());
|
||||
for (const auto& value : response.at("jobs"))
|
||||
result.insert(value.at("id"));
|
||||
return result;
|
||||
}
|
||||
std::string new_job(editor::Session& session, const std::set<std::string>& before) {
|
||||
for (const auto& id : jobs(session))
|
||||
if (!before.contains(id))
|
||||
return id;
|
||||
throw std::runtime_error("UI did not submit an import job");
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-import-ui-" + new_id());
|
||||
try {
|
||||
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||
render::Renderer renderer({1280, 900, "Import conflict workflow", true, true});
|
||||
editor::EditorUI ui(session, renderer,
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
const auto source = root / "Assets/model.gltf";
|
||||
Json gltf = {
|
||||
{"asset", {{"version", "2.0"}}},
|
||||
{"scene", 0},
|
||||
{"scenes", Json::array({{{"nodes", {0, 1}}}})},
|
||||
{"nodes",
|
||||
Json::array({{{"name", "Keep"}, {"extras", {{"faset_id", new_id()}}}},
|
||||
{{"name", "Removed node"}, {"extras", {{"faset_id", new_id()}}}}})}};
|
||||
atomic_write_json(source, gltf);
|
||||
const auto initial_id = session.commands()
|
||||
.call("faset_import", {{"path", "Assets/model.gltf"},
|
||||
{"settings", {{"units", "metres"}}}})
|
||||
.at("job")
|
||||
.get<std::string>();
|
||||
const auto initial = wait(session, ui, initial_id);
|
||||
check(initial.at("state") == "succeeded", "Fixture initial import: " + initial.dump());
|
||||
const auto asset_id = initial.at("result").at("asset_id").get<std::string>();
|
||||
const auto initial_generation = initial.at("result").at("generation");
|
||||
assets::AssetStore store(root / ".faset/cache");
|
||||
const auto document_before = session.authoring().query(ui.current_document());
|
||||
gltf["nodes"].erase(gltf["nodes"].end() - 1);
|
||||
gltf["scenes"][0]["nodes"] = Json::array({0});
|
||||
atomic_write_json(source, gltf);
|
||||
click(ui, "tab-assets");
|
||||
click(ui, "asset-refresh");
|
||||
click(ui, "file-Assets/model.gltf");
|
||||
auto before = jobs(session);
|
||||
click(ui, "asset-import");
|
||||
const auto conflict_id = new_job(session, before);
|
||||
auto conflict = wait(session, ui, conflict_id);
|
||||
check(conflict.at("state") == "conflict", "Deleted output must remain a conflict");
|
||||
click(ui, "tab-jobs");
|
||||
click(ui, "job-review-" + conflict_id);
|
||||
const auto group = "import-conflict-" + conflict_id;
|
||||
const auto removed_id = conflict["result"]["removed_output_ids"][0].get<std::string>();
|
||||
const auto* output = ui.widgets().find(group + "-output-" + removed_id);
|
||||
check(output && output->text.find("Removed node") != std::string::npos,
|
||||
"Review lists exact removed identity and its last-good name");
|
||||
check(store.current_manifest(asset_id).at("generation") == initial_generation,
|
||||
"Showing a conflict must not accept the removal");
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(root / "import-removal-review.ppm");
|
||||
gltf["nodes"][0]["translation"] = {1, 0, 0};
|
||||
atomic_write_json(source, gltf);
|
||||
before = jobs(session);
|
||||
click(ui, group + "-accept");
|
||||
const auto stale_id = new_job(session, before);
|
||||
const auto stale = wait(session, ui, stale_id);
|
||||
check(stale.at("state") == "conflict" &&
|
||||
store.current_manifest(asset_id).at("generation") == initial_generation,
|
||||
"Changed source refuses reviewed acceptance and keeps the old generation: " +
|
||||
stale.dump());
|
||||
click(ui, "tab-conflicts");
|
||||
const auto stale_group = "import-conflict-" + stale_id;
|
||||
check(ui.widgets().find(stale_group + "-diagnostic")->text.find("changed since review") !=
|
||||
std::string::npos,
|
||||
"Stale acceptance exposes the changed-generation diagnostic and new review");
|
||||
before = jobs(session);
|
||||
click(ui, stale_group + "-retry");
|
||||
const auto fresh_id = new_job(session, before);
|
||||
const auto fresh = wait(session, ui, fresh_id);
|
||||
check(fresh.at("state") == "conflict", "Reimport creates a fresh removal review");
|
||||
click(ui, "tab-jobs");
|
||||
click(ui, "job-review-" + fresh_id);
|
||||
check(!ui.widgets().find(group), "New conflict supersedes the explicitly retried review");
|
||||
before = jobs(session);
|
||||
click(ui, "import-conflict-" + fresh_id + "-accept");
|
||||
const auto accepted_id = new_job(session, before);
|
||||
const auto accepted = wait(session, ui, accepted_id);
|
||||
check(accepted.at("state") == "succeeded", "Explicit reviewed removal must publish");
|
||||
check(store.current_manifest(asset_id).at("generation") == fresh["result"]["generation"],
|
||||
"UI publishes exactly the reviewed candidate");
|
||||
check(store.current_manifest(asset_id).at("settings").at("units") == "metres",
|
||||
"Retry preserves the saved import settings");
|
||||
click(ui, "tab-conflicts");
|
||||
check(ui.widgets().find("conflicts-empty"),
|
||||
"Resolved import conflicts leave the review list");
|
||||
check(session.authoring().query(ui.current_document()) == document_before,
|
||||
"Import approval never rewrites the authoring scene or its Undo revision");
|
||||
renderer.render(ui.snapshot());
|
||||
check(renderer.stats().validation_errors == 0, "Vulkan validation");
|
||||
std::cout << "Import UI: removed IDs/names, stale review rejection, explicit retry and "
|
||||
"exact-generation acceptance passed. "
|
||||
<< path_to_utf8(root) << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+106
-19
@@ -1,8 +1,45 @@
|
||||
#include <cstdlib>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/project_launcher.hpp>
|
||||
#include <iostream>
|
||||
using namespace faset;
|
||||
namespace {
|
||||
class ScopedPathEnvironment {
|
||||
public:
|
||||
#ifdef _WIN32
|
||||
ScopedPathEnvironment(const wchar_t* name, const std::filesystem::path& value) : name_(name) {
|
||||
if (const auto* previous = _wgetenv(name))
|
||||
old_ = previous;
|
||||
if (_wputenv_s(name, value.c_str()) != 0)
|
||||
throw std::runtime_error("Cannot set test environment");
|
||||
}
|
||||
~ScopedPathEnvironment() {
|
||||
_wputenv_s(name_.c_str(), old_.c_str());
|
||||
}
|
||||
|
||||
private:
|
||||
std::wstring name_, old_;
|
||||
#else
|
||||
ScopedPathEnvironment(const char* name, const std::filesystem::path& value) : name_(name) {
|
||||
if (const auto* previous = std::getenv(name)) {
|
||||
old_ = previous;
|
||||
existed_ = true;
|
||||
}
|
||||
if (setenv(name, value.c_str(), 1) != 0)
|
||||
throw std::runtime_error("Cannot set test environment");
|
||||
}
|
||||
~ScopedPathEnvironment() {
|
||||
if (existed_)
|
||||
setenv(name_.c_str(), old_.c_str(), 1);
|
||||
else
|
||||
unsetenv(name_.c_str());
|
||||
}
|
||||
|
||||
private:
|
||||
std::string name_, old_;
|
||||
bool existed_ = false;
|
||||
#endif
|
||||
};
|
||||
void check(bool value, const std::string& message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
@@ -41,19 +78,23 @@ void text(editor::ProjectLauncher& launcher, const std::string& id, const std::s
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-launcher-ui-" + new_id());
|
||||
const auto root =
|
||||
std::filesystem::temp_directory_path() / path_from_utf8("faset-проекты-" + new_id());
|
||||
try {
|
||||
const auto existing = root / "Existing project";
|
||||
const auto existing = root / path_from_utf8("Существующий проект");
|
||||
const auto new_project = root / path_from_utf8("Новая игра");
|
||||
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()}));
|
||||
atomic_write_json(recents,
|
||||
Json::array({path_to_utf8(existing), path_to_utf8(root / "Missing"),
|
||||
path_to_utf8(existing)}));
|
||||
render::Renderer renderer({1100, 720, "Project launcher acceptance", true, true});
|
||||
{
|
||||
editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, {}, recents);
|
||||
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), {},
|
||||
recents);
|
||||
launcher.frame({});
|
||||
check(launcher.widgets().find("launcher-create")->selected,
|
||||
"No initial path starts Create");
|
||||
@@ -61,11 +102,11 @@ int main() {
|
||||
!launcher.widgets().find("launcher-recent-1"),
|
||||
"Recent list includes real valid unique projects only");
|
||||
text(launcher, "launcher-name", "Тестовый проект");
|
||||
text(launcher, "launcher-path", existing.string());
|
||||
text(launcher, "launcher-path", path_to_utf8(existing));
|
||||
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());
|
||||
text(launcher, "launcher-path", path_to_utf8(new_project));
|
||||
click(launcher, "launcher-2d");
|
||||
check(launcher.widgets().find("launcher-2d")->selected, "2D project selection");
|
||||
renderer.render(launcher.snapshot());
|
||||
@@ -73,35 +114,36 @@ int main() {
|
||||
launcher.frame({key("Return", true)});
|
||||
check(launcher.selection() && launcher.selection()->create &&
|
||||
launcher.selection()->dimension == 2 &&
|
||||
launcher.selection()->name == "Тестовый проект",
|
||||
launcher.selection()->name == "Тестовый проект" &&
|
||||
launcher.selection()->path == new_project,
|
||||
"Create through keyboard returns typed selection");
|
||||
check(!std::filesystem::exists(root / "New Game"),
|
||||
check(!std::filesystem::exists(new_project),
|
||||
"Launcher does not create a partial project before Session scaffold");
|
||||
}
|
||||
{
|
||||
editor::ProjectLauncher launcher(renderer, FASET_TEST_ENGINE, root / "Missing",
|
||||
recents);
|
||||
editor::ProjectLauncher launcher(renderer, path_from_utf8(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(),
|
||||
check(launcher.widgets().find("launcher-path")->text == path_to_utf8(existing),
|
||||
"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());
|
||||
text(launcher, "browser-path", path_to_utf8(root / "Absent folder"));
|
||||
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());
|
||||
text(launcher, "browser-path", path_to_utf8(existing));
|
||||
click(launcher, "browser-up");
|
||||
check(launcher.widgets().find("browser-path")->text == root.string(),
|
||||
check(launcher.widgets().find("browser-path")->text == path_to_utf8(root),
|
||||
"Folder browser Up navigation");
|
||||
click(launcher, "browser-entry-0");
|
||||
check(launcher.widgets().find("browser-path")->text == existing.string(),
|
||||
check(launcher.widgets().find("browser-path")->text == path_to_utf8(existing),
|
||||
"Directory list navigation");
|
||||
renderer.render(launcher.snapshot());
|
||||
renderer.capture(root / "launcher-browser.ppm");
|
||||
@@ -114,11 +156,56 @@ int main() {
|
||||
launcher.selection()->dimension == 2,
|
||||
"Open reads project metadata");
|
||||
}
|
||||
{
|
||||
// Exercise the path returned by Create after the caller has created its
|
||||
// project, then reopen it through the launcher's UTF-8 text boundary.
|
||||
atomic_write_json(new_project / "project.faset.json", {{"format", "faset.project"},
|
||||
{"version", 1},
|
||||
{"name", "Тестовый проект"},
|
||||
{"dimension", 2}});
|
||||
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), {},
|
||||
recents);
|
||||
launcher.frame({});
|
||||
click(launcher, "launcher-open");
|
||||
text(launcher, "launcher-path", path_to_utf8(new_project / "project.faset.json"));
|
||||
launcher.frame({key("Return", true)});
|
||||
check(launcher.selection() && launcher.selection()->path == new_project &&
|
||||
launcher.selection()->name == "Тестовый проект",
|
||||
"Unicode project directory survives typed manifest path and reopen");
|
||||
}
|
||||
{
|
||||
const auto home = root / path_from_utf8("Дом пользователя");
|
||||
const auto config = root / path_from_utf8("Настройки пользователя");
|
||||
std::filesystem::create_directories(home);
|
||||
#ifdef _WIN32
|
||||
ScopedPathEnvironment user_home(L"USERPROFILE", home), app_config(L"APPDATA", config);
|
||||
const auto recent_file = config / "Faset/recent-projects.json";
|
||||
#else
|
||||
ScopedPathEnvironment user_home("HOME", home), app_config("XDG_CONFIG_HOME", config);
|
||||
const auto recent_file = config / "faset/recent-projects.json";
|
||||
#endif
|
||||
editor::remember_project(existing);
|
||||
check(read_json(recent_file).at(0) == path_to_utf8(existing),
|
||||
"Unicode config directory and recent project are serialized as UTF-8");
|
||||
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE));
|
||||
launcher.frame({});
|
||||
check(launcher.widgets().find("launcher-path")->text ==
|
||||
path_to_utf8(home / "FasetProjects/MyGame"),
|
||||
"Unicode user home forms a native default project path");
|
||||
check(launcher.widgets().find("launcher-recent-0"),
|
||||
"Default recents path reads the Unicode config directory");
|
||||
click(launcher, "launcher-browse");
|
||||
click(launcher, "browser-home");
|
||||
check(launcher.widgets().find("browser-path")->text == path_to_utf8(home),
|
||||
"Home navigation preserves Unicode environment path");
|
||||
launcher.frame({key("Escape")});
|
||||
}
|
||||
{
|
||||
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);
|
||||
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), corrupt,
|
||||
recents);
|
||||
launcher.frame({});
|
||||
click(launcher, "launcher-submit");
|
||||
check(!launcher.selection() && !launcher.widgets().find("launcher-error")->text.empty(),
|
||||
@@ -129,10 +216,10 @@ int main() {
|
||||
check(renderer.stats().validation_errors == 0, "Launcher Vulkan validation");
|
||||
std::cout
|
||||
<< "Launcher Unicode/create/open/validation/recents/directory browser/keyboard passed. "
|
||||
<< root << '\n';
|
||||
<< path_to_utf8(root) << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << "\nRetained: " << root << '\n';
|
||||
std::cerr << e.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,16 +51,17 @@ int main() {
|
||||
{"name", "Original project"},
|
||||
{"dimension", 3},
|
||||
{"custom_metadata", {{"preserve", true}}}});
|
||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
||||
editor::Session session({root, path_from_utf8(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");
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
ui.frame({});
|
||||
click(ui, "add-cube");
|
||||
session.authoring().save(ui.current_document(), "Scenes/Main.scene.json");
|
||||
session.authoring().save(ui.current_document(),
|
||||
path_from_utf8("Scenes/Главная.scene.json"));
|
||||
const auto other = session.authoring().create("Other", 2);
|
||||
session.authoring().save(other.at("id"), "Scenes/Other.scene.json");
|
||||
session.authoring().save(other.at("id"), path_from_utf8("Scenes/Другая.scene.json"));
|
||||
ui.frame({});
|
||||
const auto document_before = session.authoring().query(ui.current_document());
|
||||
const auto project_before = read_json(project_file);
|
||||
@@ -75,12 +76,12 @@ int main() {
|
||||
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",
|
||||
check(ui.widgets().find("project-settings-start")->text == "Scenes/Другая.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",
|
||||
saved["start_scene"] == "Scenes/Другая.scene.json",
|
||||
"Explicit Save project persists typed settings");
|
||||
check(saved["custom_metadata"] == project_before["custom_metadata"] &&
|
||||
saved["id"] == project_before["id"],
|
||||
@@ -113,7 +114,7 @@ int main() {
|
||||
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",
|
||||
check(read_json(project_file)["start_scene"] == "Scenes/Главная.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");
|
||||
@@ -126,10 +127,10 @@ int main() {
|
||||
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';
|
||||
<< path_to_utf8(root) << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << "\nRetained: " << root << '\n';
|
||||
std::cerr << e.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,15 +39,14 @@ int main() {
|
||||
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");
|
||||
auto theme = read_json(path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
auto layout = read_json(path_from_utf8(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});
|
||||
editor::Session session({root / "project", path_from_utf8(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",
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
styles);
|
||||
ui.frame({});
|
||||
click(ui, "add-cube");
|
||||
@@ -118,10 +117,10 @@ int main() {
|
||||
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';
|
||||
<< path_to_utf8(root) << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << "\nRetained: " << root << '\n';
|
||||
std::cerr << e.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,20 +73,20 @@ float position(const Json& object) {
|
||||
int main() {
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-template-ui-" + new_id());
|
||||
try {
|
||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
||||
editor::Session session({root, path_from_utf8(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");
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
path_from_utf8(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");
|
||||
text(ui, "template-path", "Assets/Templates/Дверь.scene.json");
|
||||
click(ui, "save-template");
|
||||
check(std::filesystem::exists(root / "Assets/Templates/Door.scene.json"),
|
||||
check(std::filesystem::exists(root / path_from_utf8("Assets/Templates/Дверь.scene.json")),
|
||||
"Manual source template creation");
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
click(ui, "menu-Scene");
|
||||
@@ -105,12 +105,12 @@ int main() {
|
||||
click(ui, "menu-Scene");
|
||||
click(ui, "instance-template");
|
||||
click(ui, "menu-File");
|
||||
text(ui, "save-path", "Assets/Templates/Outer.scene.json");
|
||||
text(ui, "save-path", "Assets/Templates/Внешняя.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");
|
||||
text(ui, "template-path", "Assets/Templates/Внешняя.scene.json");
|
||||
click(ui, "instance-template");
|
||||
auto nested = leaf(session, main, 2);
|
||||
const auto nested_id = nested.at("id").get<std::string>();
|
||||
@@ -178,6 +178,35 @@ int main() {
|
||||
check(query(session, ui)["scene"]["simulation"]["gravity"][1] == -9.81,
|
||||
"Simulation Undo restores gravity");
|
||||
|
||||
// Save through the same controls a user sees, then open a new editor
|
||||
// session: an in-memory resolver alone cannot prove persistent identity.
|
||||
click(ui, "entity-" + nested_id);
|
||||
click(ui, "object-open-source");
|
||||
click(ui, "save");
|
||||
click(ui, "back-document");
|
||||
click(ui, "menu-File");
|
||||
text(ui, "save-path", "Scenes/Экземпляры.scene.json");
|
||||
click(ui, "save-as-button");
|
||||
const auto saved_scene = query(session, ui).at("scene");
|
||||
const auto saved_resolved = session.commands().resolved_scene(main);
|
||||
{
|
||||
editor::Session reopened({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||
editor::EditorUI reopened_ui(
|
||||
reopened, renderer, path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
reopened_ui.frame({});
|
||||
click(reopened_ui, "menu-File");
|
||||
text(reopened_ui, "open-path", "Scenes/Экземпляры.scene.json");
|
||||
click(reopened_ui, "open-path-button");
|
||||
check(query(reopened, reopened_ui).at("scene") == saved_scene,
|
||||
"Save and reopen preserve instance IDs, overrides and local additions");
|
||||
check(reopened.commands().resolved_scene(reopened_ui.current_document()) ==
|
||||
saved_resolved,
|
||||
"New session resolves saved nested source edits with identical provenance");
|
||||
check(leaf(reopened, reopened_ui.current_document(), 2).at("id") == nested_id,
|
||||
"Resolved nested object identity survives reopening");
|
||||
}
|
||||
|
||||
nested = leaf(session, main, 2);
|
||||
click(ui, "entity-" + nested_id);
|
||||
text(ui, position_field(nested), "7");
|
||||
@@ -218,10 +247,10 @@ int main() {
|
||||
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';
|
||||
<< path_to_utf8(root) << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << "\nRetained: " << root << '\n';
|
||||
std::cerr << error.what() << "\nRetained: " << path_to_utf8(root) << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ void text(editor::EditorUI& ui, const std::string& id, const std::string& value,
|
||||
ui.frame(events);
|
||||
}
|
||||
int main() {
|
||||
auto root = std::filesystem::temp_directory_path() / ("faset-ui-authoring-" + new_id());
|
||||
auto root =
|
||||
std::filesystem::temp_directory_path() / path_from_utf8("faset-ui-проект-" + new_id());
|
||||
try {
|
||||
std::filesystem::create_directories(root);
|
||||
atomic_write_json(root / "project.faset.json", {{"format", "faset.project"},
|
||||
@@ -53,15 +54,16 @@ int main() {
|
||||
{"dimension", 3}});
|
||||
#if defined(FASET_TEST_PLUGIN_DIRECTORY)
|
||||
std::filesystem::create_directories(root / "Plugins");
|
||||
for (const auto& file : std::filesystem::directory_iterator(FASET_TEST_PLUGIN_DIRECTORY))
|
||||
for (const auto& file :
|
||||
std::filesystem::directory_iterator(path_from_utf8(FASET_TEST_PLUGIN_DIRECTORY)))
|
||||
if (file.is_regular_file())
|
||||
std::filesystem::copy_file(file.path(), root / "Plugins" / file.path().filename());
|
||||
#endif
|
||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
||||
editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root});
|
||||
render::Renderer renderer({1280, 800, "Faset editor 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");
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
ui.frame({});
|
||||
click(ui, "add-cube");
|
||||
auto state = session.authoring().query(ui.current_document());
|
||||
@@ -179,7 +181,7 @@ int main() {
|
||||
click(ui, "tab-assets");
|
||||
#endif
|
||||
std::filesystem::create_directories(root / "Assets");
|
||||
const auto image_path = root / "Assets/TwoPixels.png";
|
||||
const auto image_path = root / path_from_utf8("Assets/Два пикселя.png");
|
||||
{
|
||||
std::ofstream stream(image_path, std::ios::binary);
|
||||
const auto& png = faset::test_images::png_red_green;
|
||||
@@ -188,8 +190,8 @@ int main() {
|
||||
}
|
||||
const auto import_job =
|
||||
session.commands()
|
||||
.call("faset_import",
|
||||
{{"path", "Assets/TwoPixels.png"}, {"settings", {{"pixels_per_unit", 1.0}}}})
|
||||
.call("faset_import", {{"path", "Assets/Два пикселя.png"},
|
||||
{"settings", {{"pixels_per_unit", 1.0}}}})
|
||||
.at("job")
|
||||
.get<std::string>();
|
||||
Json imported;
|
||||
@@ -206,6 +208,8 @@ int main() {
|
||||
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");
|
||||
check(image_row->text.find("Два пикселя") != std::string::npos,
|
||||
"Asset browser displays Unicode source filename");
|
||||
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;
|
||||
@@ -253,7 +257,7 @@ int main() {
|
||||
std::cout << "Editor UI: actual events create/select/rename/typed "
|
||||
"fields/Undo/Redo/conflict/one drag transaction passed. "
|
||||
"Screenshot: "
|
||||
<< (root / "editor-ui.ppm") << '\n';
|
||||
<< path_to_utf8(root / "editor-ui.ppm") << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << '\n';
|
||||
|
||||
+13
-7
@@ -44,8 +44,8 @@ def run(executable, project):
|
||||
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
|
||||
if (project / "Scenes/main.scene.json").exists():
|
||||
opened = call("faset_document_open", {"path": "Scenes/main.scene.json"})
|
||||
if (project / "Scenes/Начало Café 世界.scene.json").exists():
|
||||
opened = call("faset_document_open", {"path": "Scenes/Начало Café 世界.scene.json"})
|
||||
assert opened["scene"]["entities"][0]["name"] == "Door 世界"
|
||||
return
|
||||
scene = call("faset_document_create", {"name": "MCP integration", "dimension": 3})
|
||||
@@ -62,7 +62,7 @@ def run(executable, project):
|
||||
assert undone["scene"]["entities"] == []
|
||||
redone = call("faset_redo", {"document": identity, "revision": 2})
|
||||
assert redone["scene"] == changed["scene"]
|
||||
saved = call("faset_document_save", {"document": identity, "path": "Scenes/main.scene.json"})
|
||||
saved = call("faset_document_save", {"document": identity, "path": "Scenes/Начало Café 世界.scene.json"})
|
||||
assert not saved["dirty"]
|
||||
call("faset_document_open", {"path": "../outside.json"}, True)
|
||||
assert request("resources/read", {"uri": "faset://documents"})["result"]["contents"]
|
||||
@@ -109,8 +109,14 @@ def eof_tail(executable, project):
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="faset-mcp-stdio-") as temporary:
|
||||
run(sys.argv[1], pathlib.Path(temporary))
|
||||
run(sys.argv[1], pathlib.Path(temporary))
|
||||
disconnected_output(sys.argv[1], pathlib.Path(temporary))
|
||||
eof_tail(sys.argv[1], pathlib.Path(temporary))
|
||||
project = pathlib.Path(temporary) / "Faset Café 世界"
|
||||
created = subprocess.run([sys.argv[1], "--project", str(project), "--new", "Проект Café 世界",
|
||||
"--command", json.dumps({"name": "faset_project", "arguments": {}}, ensure_ascii=False)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=20)
|
||||
assert created.returncode == 0, created.stderr
|
||||
assert json.loads((project / "project.faset.json").read_text(encoding="utf-8"))["name"] == "Проект Café 世界"
|
||||
run(sys.argv[1], project)
|
||||
run(sys.argv[1], project)
|
||||
disconnected_output(sys.argv[1], project)
|
||||
eof_tail(sys.argv[1], project)
|
||||
print("Real MCP stdio lifecycle, clean stdout, revision conflict, retry, Undo/Redo, disk reopen, broken output pipe and EOF shutdown passed")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "Gameplay.hpp"
|
||||
#include <stdexcept>
|
||||
|
||||
namespace faset::gameplay {
|
||||
void registerGameplay(runtime::Runtime& runtime) {
|
||||
runtime::Behavior behavior;
|
||||
behavior.onStart = [](auto&, auto, double) { throw std::runtime_error("TEST_ON_START"); };
|
||||
behavior.update = [](auto&, auto, double) { throw std::runtime_error("TEST_UPDATE"); };
|
||||
behavior.onDestroy = [](auto&, auto, double) { throw std::runtime_error("TEST_ON_DESTROY"); };
|
||||
runtime.registerBehavior("test.diagnostics", std::move(behavior));
|
||||
}
|
||||
nlohmann::json schema() {
|
||||
return nlohmann::json::array(
|
||||
{{{"id", "test.diagnostics"}, {"version", 2}, {"fields", nlohmann::json::object()}}});
|
||||
}
|
||||
} // namespace faset::gameplay
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
|
||||
namespace faset::gameplay {
|
||||
void registerGameplay(runtime::Runtime& runtime);
|
||||
nlohmann::json schema();
|
||||
} // namespace faset::gameplay
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Normal Player shutdown reports lifecycle failures without losing completed stats."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="faset-player-diagnostics-") as temporary:
|
||||
root = Path(temporary) / "Faset Café 世界"
|
||||
root.mkdir()
|
||||
scene = root / "Сцена.scene.json"
|
||||
profile = root / "Профиль.json"
|
||||
scene.write_text(json.dumps({
|
||||
"format": "faset.scene", "version": 1, "id": "diagnostics-scene",
|
||||
"name": "Lifecycle diagnostics", "dimension": 2, "instances": [],
|
||||
"entities": [{"id": "diagnostics-object", "name": "Throwing behavior", "parent": None,
|
||||
"components": [{"id": "diagnostics-component", "type": "test.diagnostics",
|
||||
"version": 2, "fields": {}}]}]
|
||||
}), encoding="utf-8")
|
||||
result = subprocess.run([sys.argv[1], "--scene", str(scene), "--headless", "--frames", "1",
|
||||
"--profile", str(profile)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=30)
|
||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||
for phase in ["TEST_ON_START", "TEST_UPDATE", "TEST_ON_DESTROY"]:
|
||||
assert result.stderr.count(phase) == 1, (phase, result.stderr)
|
||||
summary = json.loads(result.stdout)
|
||||
assert summary["frames"] == 1 and summary["ticks"] == 1, summary
|
||||
report = json.loads(profile.read_text(encoding="utf-8"))
|
||||
assert report["completed_frames"] == 1 and len(report["samples"]) == 1, report
|
||||
assert report["samples"][0]["tick"] == 1, report["samples"]
|
||||
|
||||
# The same linked v2 schema must validate without registering or invoking behavior.
|
||||
validated = subprocess.run([sys.argv[1], "--scene", str(scene), "--validate"],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=20)
|
||||
assert validated.returncode == 0, validated.stderr
|
||||
assert "TEST_" not in validated.stderr, validated.stderr
|
||||
assert json.loads(validated.stdout)["validated"]
|
||||
print("Player normal shutdown diagnostics, preserved tick/profile stats and callback-free validation passed")
|
||||
@@ -14,11 +14,11 @@ void require(bool value, const char* message) {
|
||||
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()},
|
||||
{},
|
||||
{}});
|
||||
Process process({{FASET_PYTHON_EXECUTABLE, FASET_SHADER_COMPILE_TOOL, "--compiler",
|
||||
FASET_TEST_SLANGC, "--source", path_to_utf8(source), "--entry",
|
||||
"fragmentMain", "--output", path_to_utf8(output)},
|
||||
{},
|
||||
{}});
|
||||
std::string diagnostics;
|
||||
while (true) {
|
||||
auto result = process.poll();
|
||||
@@ -34,12 +34,13 @@ int compile(const fs::path& source, const fs::path& output) {
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
const auto temporary = fs::temp_directory_path() / ("faset-shader-reload-" + new_id());
|
||||
const auto temporary =
|
||||
fs::temp_directory_path() / path_from_utf8("Faset shader Café 世界 " + new_id());
|
||||
struct Cleanup {
|
||||
fs::path path;
|
||||
~Cleanup() {
|
||||
std::error_code ignored;
|
||||
fs::remove_all(path, ignored);
|
||||
fs::remove_all(native_io_path(path), ignored);
|
||||
}
|
||||
} cleanup{temporary};
|
||||
try {
|
||||
@@ -48,10 +49,10 @@ int main() {
|
||||
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);
|
||||
fs::copy_file(path_from_utf8(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_source = read_text(path_from_utf8(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");
|
||||
@@ -63,9 +64,39 @@ int main() {
|
||||
configuration.shader_directory = bundle;
|
||||
render::Renderer renderer(configuration);
|
||||
render::Snapshot scene;
|
||||
scene.ui_quads.push_back({0, 0, 64, 64, {1, .8f, .4f, 1}});
|
||||
scene.ui_quads.push_back({0, 0, 32, 64, {1, .8f, .4f, 1}});
|
||||
scene.sprites.push_back({{.5f, 0, .5f}, {1, 2}, {.2f, 1, .4f, 1}});
|
||||
renderer.render(scene);
|
||||
auto expected = renderer.pixels();
|
||||
// Cooked/exported generations can exceed MAX_PATH even for modest project
|
||||
// names. Exercise the renderer's file boundaries without relying on the
|
||||
// external shader compiler's own long-path policy.
|
||||
const auto deep_bundle = temporary / std::string(96, 'a') / std::string(96, 'b') /
|
||||
std::string(96, 'c') / "shaders";
|
||||
require(deep_bundle.native().size() > 300,
|
||||
"Shader file fixture must exceed the legacy Windows path limit");
|
||||
fs::create_directories(native_io_path(deep_bundle));
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
|
||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||
const auto name = std::string(entry) + extension;
|
||||
atomic_write(deep_bundle / name, read_text(bundle / name));
|
||||
}
|
||||
render::validate_shader_bundle(deep_bundle);
|
||||
{
|
||||
auto deep_configuration = configuration;
|
||||
deep_configuration.shader_directory = deep_bundle;
|
||||
render::Renderer deep_renderer(deep_configuration);
|
||||
deep_renderer.render(scene);
|
||||
require(deep_renderer.pixels() == expected,
|
||||
"Deep Unicode shader bundle must render the same pixels");
|
||||
const auto capture = deep_bundle / path_from_utf8("Capture Café 世界.ppm");
|
||||
deep_renderer.capture(capture);
|
||||
const auto bytes = read_text(capture);
|
||||
require(bytes.starts_with("P6\n64 64\n255\n") && bytes.size() == 13 + 64 * 64 * 3,
|
||||
"Deep Unicode capture must contain the complete rendered image");
|
||||
require(deep_renderer.stats().validation_errors == 0,
|
||||
"Deep shader bundle has no Vulkan validation errors");
|
||||
}
|
||||
require(renderer.stats().gpu_allocated_bytes > 1024 * 1024 &&
|
||||
renderer.stats().texture_count >= 1,
|
||||
"Frame profile reports live Vulkan allocations and textures");
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#include <cmath>
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
using namespace faset::render;
|
||||
namespace {
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
try {
|
||||
Renderer renderer({64, 64, "Faset sprite alpha validation", true, true});
|
||||
Snapshot scene;
|
||||
scene.clear_color = {0, 0, 0, 1};
|
||||
Sprite front{{0, 0, .2f}, {2, 2}, {0, 1, 0, 0}};
|
||||
Sprite back{{0, 0, .8f}, {2, 2}, {1, 0, 0, 1}};
|
||||
auto color = [&](int red, int green, const char* message) {
|
||||
renderer.render(scene);
|
||||
const auto pixels = renderer.pixels();
|
||||
const auto index = (32 * 64 + 32) * 4;
|
||||
require(std::abs(int(pixels[index]) - red) <= 2 &&
|
||||
std::abs(int(pixels[index + 1]) - green) <= 2 && pixels[index + 2] <= 2,
|
||||
message);
|
||||
require(renderer.stats().validation_errors == 0, "Sprite Vulkan validation error");
|
||||
};
|
||||
scene.sprites = {front, back};
|
||||
color(255, 0, "Fully transparent front sprite must not occlude a later opaque sprite");
|
||||
front.color[3] = .5f;
|
||||
scene.sprites = {front, back};
|
||||
color(128, 128, "Partial alpha must composite over the farther sprite");
|
||||
scene.sprites = {back, front};
|
||||
color(128, 128, "Unequal-depth compositing must be independent of submission order");
|
||||
front.position[2] = .9f;
|
||||
front.layer = 5;
|
||||
scene.sprites = {front, back};
|
||||
color(128, 128, "Explicit higher layer must take priority over sprite depth");
|
||||
front.position[2] = back.position[2];
|
||||
front.layer = back.layer;
|
||||
scene.sprites = {front, back};
|
||||
color(255, 0, "Equal-layer/equal-depth sprites must preserve submission order");
|
||||
scene.sprites = {back, front};
|
||||
color(128, 128, "Later equal-depth sprite must composite on top");
|
||||
auto texture = std::make_shared<Texture>();
|
||||
texture->width = texture->height = 1;
|
||||
texture->rgba = {0, 255, 0, 0};
|
||||
front.color = {1, 1, 1, 1};
|
||||
front.texture = texture;
|
||||
front.position[2] = .2f;
|
||||
scene.sprites = {front, back};
|
||||
color(255, 0, "Transparent texture pixels must preserve underlying sprites");
|
||||
texture->rgba[3] = 128;
|
||||
++texture->revision;
|
||||
color(127, 128, "Texture alpha must blend with the already rendered background");
|
||||
auto opaque = std::make_shared<Mesh>();
|
||||
for (const auto point : {Vec3{-1, -1, .4f}, Vec3{1, -1, .4f}, Vec3{1, 1, .4f},
|
||||
Vec3{-1, -1, .4f}, Vec3{1, 1, .4f}, Vec3{-1, 1, .4f}})
|
||||
opaque->vertices.push_back({point, {0, 0, 0}, {1, 0, 0, 1}});
|
||||
scene.draws.push_back({opaque, identity, {1, 1, 1, 1}, .5f, 0, false});
|
||||
front.position[2] = .8f;
|
||||
scene.sprites = {front};
|
||||
color(255, 0, "Sprites behind opaque mesh depth must remain occluded");
|
||||
front.position[2] = .2f;
|
||||
scene.sprites = {front};
|
||||
color(127, 128, "Sprites in front of opaque mesh depth must composite normally");
|
||||
std::cout << "Sprite depth/layer ordering, stable ties, color/texture alpha and opaque "
|
||||
"mesh occlusion passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
#include <SDL3/SDL.h>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace {
|
||||
@@ -66,6 +69,12 @@ int main(int argc, char** argv) {
|
||||
unsigned frame{};
|
||||
auto draw = [&] {
|
||||
renderer.poll_events();
|
||||
const float native_scale = SDL_GetWindowDisplayScale(window);
|
||||
require(std::isfinite(renderer.display_scale()) && renderer.display_scale() > 0.f,
|
||||
"Renderer UI display scale must be finite and positive");
|
||||
if (std::isfinite(native_scale) && native_scale > 0.f)
|
||||
require(std::abs(renderer.display_scale() - native_scale) < 0.001f,
|
||||
"Renderer UI display scale must follow SDL's live window scale");
|
||||
const unsigned channel = frame++ % 2;
|
||||
scene.ui_quads[0].color = channel ? Color{0, 1, 0, 1} : Color{1, 0, 0, 1};
|
||||
renderer.render(scene);
|
||||
@@ -108,6 +117,61 @@ int main(int argc, char** argv) {
|
||||
draw();
|
||||
SDL_Delay(10);
|
||||
}
|
||||
{
|
||||
// Do not replace an image/rich clipboard with its text projection.
|
||||
// Plain text is restored without ever printing the saved contents.
|
||||
std::size_t mime_count{};
|
||||
auto** mime_types = SDL_GetClipboardMimeTypes(&mime_count);
|
||||
bool plain_text_only = true;
|
||||
for (std::size_t i = 0; i < mime_count; ++i) {
|
||||
const std::string type = mime_types[i];
|
||||
if (!type.starts_with("text/plain") && type != "UTF8_STRING" && type != "TEXT" &&
|
||||
type != "STRING" && type != "COMPOUND_TEXT" && type != "TARGETS" &&
|
||||
type != "TIMESTAMP" && type != "MULTIPLE")
|
||||
plain_text_only = false;
|
||||
}
|
||||
SDL_free(mime_types);
|
||||
if (plain_text_only) {
|
||||
char* saved = SDL_GetClipboardText();
|
||||
require(saved != nullptr, "Read clipboard before reversible fixture");
|
||||
struct ClipboardRestore {
|
||||
std::string saved;
|
||||
bool active{true};
|
||||
~ClipboardRestore() {
|
||||
if (active)
|
||||
SDL_SetClipboardText(saved.c_str());
|
||||
}
|
||||
} restore{saved};
|
||||
SDL_free(saved);
|
||||
const std::string fixture = "Faset clipboard Café 世界";
|
||||
renderer.set_clipboard(fixture);
|
||||
require(renderer.clipboard() == fixture, "Native Unicode clipboard roundtrip");
|
||||
require(SDL_SetClipboardText(restore.saved.c_str()), "Restore saved clipboard");
|
||||
restore.active = false;
|
||||
std::cout << "clipboard=unicode_roundtrip_passed\n";
|
||||
} else {
|
||||
std::cout << "clipboard=skipped_to_preserve_non_text_payload\n";
|
||||
}
|
||||
}
|
||||
renderer.set_text_input(true);
|
||||
require(SDL_TextInputActive(window), "Native SDL text input is active");
|
||||
renderer.set_text_input_area(31.5f, 22.5f, 160.5f, 24.5f);
|
||||
int logical_width{}, logical_height{}, pixel_width{}, pixel_height{}, cursor{};
|
||||
SDL_GetWindowSize(window, &logical_width, &logical_height);
|
||||
SDL_GetWindowSizeInPixels(window, &pixel_width, &pixel_height);
|
||||
require(logical_width > 0 && logical_height > 0 && pixel_width > 0 && pixel_height > 0,
|
||||
"Text input area has usable native window dimensions");
|
||||
SDL_Rect native_area{};
|
||||
require(SDL_GetTextInputArea(window, &native_area, &cursor), "Read native text input area");
|
||||
const float sx = float(logical_width) / pixel_width,
|
||||
sy = float(logical_height) / pixel_height;
|
||||
require(native_area.x == int(31.5f * sx) && native_area.y == int(22.5f * sy) &&
|
||||
native_area.w == std::max(1, int(160.5f * sx)) &&
|
||||
native_area.h == std::max(1, int(24.5f * sy)) && cursor == 0,
|
||||
"Drawable-pixel input area must reach SDL in logical window units");
|
||||
renderer.set_text_input(false);
|
||||
require(!SDL_TextInputActive(window), "Native SDL text input stops");
|
||||
std::cout << "text_input_area=coordinate_conversion_passed\n";
|
||||
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";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "Gameplay.hpp"
|
||||
#include <cmath>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <faset/runtime/schema.hpp>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
@@ -174,6 +175,107 @@ void clockAndValidation() {
|
||||
auto schema = faset::gameplay::schema();
|
||||
check(schema.size() == 2, "sample has explicit metadata without world");
|
||||
}
|
||||
void componentSchemaVersions() {
|
||||
auto doc = scene();
|
||||
auto object = entity("versioned");
|
||||
auto custom =
|
||||
component("test.versioned", {{"value", 42}, {"future_payload", {"kept", "opaque"}}});
|
||||
custom["version"] = 2;
|
||||
object["components"].push_back(custom);
|
||||
auto data = component("test.data", {{"description", "no behavior required"}});
|
||||
data["version"] = 3;
|
||||
object["components"].push_back(data);
|
||||
doc["entities"].push_back(object);
|
||||
const auto source_before = doc;
|
||||
const auto metadata =
|
||||
Json::array({{{"id", "test.versioned"}, {"version", 2}, {"fields", Json::object()}},
|
||||
{{"id", "test.data"}, {"version", 3}, {"fields", Json::object()}}});
|
||||
validate_scene_schemas(doc, metadata);
|
||||
validate_scene_schemas(doc, Json{{"types", metadata}});
|
||||
check(doc == source_before, "schema validation must not migrate or mutate source");
|
||||
int starts = 0;
|
||||
Runtime world;
|
||||
Behavior versioned;
|
||||
versioned.onStart = [&](Runtime& runtime, EntityHandle handle, double) {
|
||||
++starts;
|
||||
check(runtime.fields(handle, "test.versioned") == custom["fields"],
|
||||
"versioned behavior receives exact opaque fields");
|
||||
};
|
||||
world.registerBehavior("test.versioned", versioned);
|
||||
validate_scene_schemas(doc, metadata);
|
||||
check(starts == 0, "schema validation does not invoke gameplay");
|
||||
world.load(doc);
|
||||
const auto handle = world.find("versioned");
|
||||
check(starts == 1 && world.fields(handle, "test.data") == data["fields"],
|
||||
"Runtime accepts positive custom versions including data-only components");
|
||||
auto added = component("test.added", {{"value", "deferred"}});
|
||||
added["version"] = 4;
|
||||
world.addComponent(handle, added);
|
||||
world.singleStep();
|
||||
check(world.fields(handle, "test.added") == added["fields"],
|
||||
"deferred custom component accepts its positive version");
|
||||
for (const auto& invalid_version : Json::array({0, -1, 1.5, "2", true, nullptr})) {
|
||||
auto invalid = doc;
|
||||
invalid["entities"][0]["components"][1]["version"] = invalid_version;
|
||||
rejects([&] { world.load(invalid); }, "custom version must be a positive integer");
|
||||
rejects([&] { validate_scene_schemas(invalid, metadata); },
|
||||
"schema gate rejects malformed component version");
|
||||
check(world.valid(handle), "invalid version preserves running world");
|
||||
}
|
||||
for (const std::string builtin :
|
||||
{"faset.transform", "faset.sprite", "faset.mesh", "faset.camera", "faset.light",
|
||||
"faset.rigid_body_2d", "faset.rigid_body_3d"}) {
|
||||
auto invalid = scene();
|
||||
auto native = component(builtin);
|
||||
native["version"] = 2;
|
||||
invalid["entities"].push_back({{"id", "native"}, {"components", Json::array({native})}});
|
||||
rejects([&] { world.load(invalid); },
|
||||
"all builtin components retain strict native version 1");
|
||||
rejects([&] { validate_scene_schemas(invalid, metadata); },
|
||||
"schema gate rejects builtin v2");
|
||||
check(world.valid(handle), "invalid builtin load preserves running world");
|
||||
}
|
||||
auto bad_add = component("faset.camera");
|
||||
bad_add["version"] = 2;
|
||||
world.addComponent(handle, bad_add);
|
||||
world.singleStep();
|
||||
rejects([&] { world.fields(handle, "faset.camera"); },
|
||||
"invalid deferred builtin version cannot enter the world");
|
||||
rejects([&] { validate_scene_schemas(doc, Json::array()); },
|
||||
"Player schema gate rejects missing gameplay module metadata");
|
||||
auto mismatched = metadata;
|
||||
mismatched[0]["version"] = 1;
|
||||
rejects([&] { validate_scene_schemas(doc, mismatched); },
|
||||
"Player schema gate rejects gameplay version mismatch");
|
||||
auto unknown = doc;
|
||||
unknown["entities"][0]["components"][1]["type"] = "test.missing";
|
||||
unknown["entities"][0]["components"][1]["version"] = 1;
|
||||
rejects([&] { validate_scene_schemas(unknown, metadata); },
|
||||
"Unknown custom v1 must not silently run in Player");
|
||||
auto duplicate = metadata;
|
||||
duplicate.push_back(metadata[0]);
|
||||
rejects([&] { validate_scene_schemas(doc, duplicate); },
|
||||
"Ambiguous duplicate metadata TypeIds rejected");
|
||||
for (int version : {1, 2}) {
|
||||
auto overridden_native = metadata;
|
||||
overridden_native.push_back({{"id", "faset.transform"}, {"version", version}});
|
||||
rejects([&] { validate_scene_schemas(doc, overridden_native); },
|
||||
"Gameplay cannot redeclare native TypeIds, even at native version 1");
|
||||
}
|
||||
auto invalid_schema = metadata;
|
||||
invalid_schema[0]["version"] = "2";
|
||||
rejects([&] { validate_scene_schemas(doc, invalid_schema); },
|
||||
"Schema versions also require positive integers");
|
||||
rejects([&] { validate_scene_schemas(doc, Json::object()); },
|
||||
"Malformed schema manifest rejected");
|
||||
auto legacy = scene();
|
||||
legacy["entities"].push_back(entity("legacy"));
|
||||
legacy["entities"][0]["components"][0].erase("version");
|
||||
validate_scene_schemas(legacy, Json::array());
|
||||
world.load(legacy);
|
||||
check(bool(world.find("legacy")),
|
||||
"Omitted component version retains development scene v1 compatibility");
|
||||
}
|
||||
void structuralFailuresAndCallbacks() {
|
||||
Runtime world;
|
||||
auto doc = scene();
|
||||
@@ -315,6 +417,7 @@ int main() {
|
||||
run("Box3D", [] { physics(3); });
|
||||
run("Lifecycle", lifecycle);
|
||||
run("Clock/validation", clockAndValidation);
|
||||
run("Component schema versions", componentSchemaVersions);
|
||||
run("Structural failures", structuralFailuresAndCallbacks);
|
||||
run("Sample gameplay", sampleGameplay);
|
||||
run("Grounded jump", groundedJump);
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
#include <faset/ui/ui.hpp>
|
||||
#include <iostream>
|
||||
using namespace faset;
|
||||
int main(int argc, char** argv) {
|
||||
int ui_render_main(int argc, char** argv) {
|
||||
try {
|
||||
render::Renderer renderer({1280, 800, "Faset UI reference implementation", true, true});
|
||||
ui::Context ui(FASET_TEST_FONT);
|
||||
ui.set_theme(ui::Theme::load(FASET_UI_THEME));
|
||||
ui::Context ui(path_from_utf8(FASET_TEST_FONT));
|
||||
ui.set_theme(ui::Theme::load(path_from_utf8(FASET_UI_THEME)));
|
||||
ui.apply_layout(
|
||||
read_json(std::filesystem::path(FASET_UI_THEME).parent_path() / "editor-layout.json"));
|
||||
read_json(path_from_utf8(FASET_UI_THEME).parent_path() / "editor-layout.json"));
|
||||
auto& menu = ui.find("menubar")->add(ui::Kind::Row, "menuitems");
|
||||
for (const auto& name : {"Faset", "File", "Edit", "Scene", "View", "Help"})
|
||||
menu.add(ui::Kind::Button, "menu-" + std::string(name), name).layout.width = 65;
|
||||
@@ -102,7 +102,7 @@ int main(int argc, char** argv) {
|
||||
snapshot.draws.push_back(crate);
|
||||
ui.draw(snapshot);
|
||||
renderer.render(snapshot);
|
||||
renderer.capture(argc > 1 ? argv[1] : "ui-test.ppm");
|
||||
renderer.capture(path_from_utf8(argc > 1 ? argv[1] : "ui-test.ppm"));
|
||||
if (renderer.stats().validation_errors)
|
||||
throw std::runtime_error("Vulkan validation reported UI rendering errors");
|
||||
std::cout << "UI glyph atlas and retained panels rendered on " << renderer.stats().device
|
||||
@@ -113,3 +113,13 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
return run_utf8_main(argc, argv, ui_render_main);
|
||||
}
|
||||
#else
|
||||
int main(int argc, char** argv) {
|
||||
return ui_render_main(argc, argv);
|
||||
}
|
||||
#endif
|
||||
|
||||
+113
-4
@@ -1,4 +1,7 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/ui/ui.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
@@ -35,9 +38,85 @@ void click(ui::Context& context, const ui::Widget& widget) {
|
||||
context.handle(mouse(render::Event::Type::MouseDown, r.x + 5, r.y + 5));
|
||||
context.handle(mouse(render::Event::Type::MouseUp, r.x + 5, r.y + 5));
|
||||
}
|
||||
void display_scale_contract() {
|
||||
ui::Context context(path_from_utf8(FASET_TEST_FONT));
|
||||
auto& field = context.root().add(ui::Kind::TextField, "dpi-field", "Ж");
|
||||
field.layout.width = 180;
|
||||
field.layout.height = 32;
|
||||
auto& number = context.root().add(ui::Kind::NumberField, "dpi-number");
|
||||
number.layout.height = 32;
|
||||
number.value = 4;
|
||||
number.step = 1;
|
||||
auto& button = context.root().add(ui::Kind::Button, "dpi-action", "Open");
|
||||
button.layout.width = 180;
|
||||
button.layout.height = 32;
|
||||
int clicks = 0, commits = 0, number_commits = 0;
|
||||
button.on_click = [&](ui::Widget&) { ++clicks; };
|
||||
field.on_commit = [&](ui::Widget&) { ++commits; };
|
||||
number.on_commit = [&](ui::Widget&) { ++number_commits; };
|
||||
ui::Rect ime_area;
|
||||
context.set_ime([](bool) {}, [&](ui::Rect rect) { ime_area = rect; });
|
||||
context.layout(320, 240, 1);
|
||||
const auto initial = field.rect;
|
||||
render::Snapshot one;
|
||||
context.draw(one);
|
||||
const auto first_glyph = std::find_if(one.ui_quads.begin(), one.ui_quads.end(),
|
||||
[](const auto& q) { return bool(q.texture); });
|
||||
check(first_glyph != one.ui_quads.end(), "1x rasterized text missing");
|
||||
const auto glyph_width = first_glyph->width, glyph_height = first_glyph->height;
|
||||
const auto revision = context.font().texture()->revision;
|
||||
check(context.focus("dpi-field"), "DPI field focus");
|
||||
context.handle(key("A", true));
|
||||
context.handle(text("Живая сцена"));
|
||||
context.layout(640, 480, 2);
|
||||
check(field.rect.width == initial.width * 2 && field.rect.height == initial.height * 2 &&
|
||||
field.clip.width == initial.width * 2 && ime_area.width == field.rect.width,
|
||||
"2x layout, clipping and IME rectangle use drawable pixels");
|
||||
check(context.focused_id() == "dpi-field" && field.text == "Живая сцена" && commits == 0,
|
||||
"Live display scale preserves focused edit without committing");
|
||||
context.handle(key("Escape"));
|
||||
render::Snapshot two;
|
||||
context.draw(two);
|
||||
const auto second_glyph = std::find_if(two.ui_quads.begin(), two.ui_quads.end(),
|
||||
[](const auto& q) { return bool(q.texture); });
|
||||
check(second_glyph != two.ui_quads.end() && second_glyph->width >= glyph_width * 1.8f &&
|
||||
second_glyph->height >= glyph_height * 1.8f &&
|
||||
context.font().texture()->revision > revision,
|
||||
"2x display requests larger glyph rasterization");
|
||||
check(std::abs(second_glyph->width - (second_glyph->uv_rect[2] - second_glyph->uv_rect[0]) *
|
||||
second_glyph->texture->width) < .1f,
|
||||
"High-DPI glyphs use one atlas texel per pixel, not stretched 1x bitmaps");
|
||||
context.handle(mouse(render::Event::Type::MouseDown, button.rect.x + 300, button.rect.y + 40));
|
||||
context.handle(mouse(render::Event::Type::MouseUp, button.rect.x + 300, button.rect.y + 40));
|
||||
check(clicks == 1, "Hit testing reaches the second half of a scaled control");
|
||||
const auto r = number.rect;
|
||||
context.handle(mouse(render::Event::Type::MouseDown, r.x + 20, r.y + 20));
|
||||
context.handle(mouse(render::Event::Type::MouseMove, r.x + 40, r.y + 20));
|
||||
check(number.value == 14, "Numeric drag uses logical distance at 2x");
|
||||
context.layout(320, 240, 1);
|
||||
context.handle(mouse(render::Event::Type::MouseUp, 40, 52));
|
||||
check(number.value == 4 && number_commits == 0,
|
||||
"Monitor scale change cancels pointer drag without an accidental commit");
|
||||
check(field.rect.width == initial.width && button.layout.width == 180,
|
||||
"Returning to 1x preserves logical layout metrics");
|
||||
ui::Context scroll(path_from_utf8(FASET_TEST_FONT));
|
||||
auto& panel = scroll.root().add(ui::Kind::Column, "scroll");
|
||||
panel.layout.height = 80;
|
||||
panel.layout.scroll = true;
|
||||
panel.layout.gap = 0;
|
||||
for (int i = 0; i < 10; ++i)
|
||||
panel.add(ui::Kind::Button, "row-" + std::to_string(i), "Row").layout.height = 30;
|
||||
scroll.layout(200, 100, 1);
|
||||
panel.scroll_y = 30;
|
||||
scroll.layout(200, 100, 1);
|
||||
scroll.layout(400, 200, 2);
|
||||
check(panel.scroll_y == 60 && panel.children[1]->rect.y == panel.rect.y,
|
||||
"DPI changes retain the logical scroll position");
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
try {
|
||||
display_scale_contract();
|
||||
ui::TextBuffer buffer("Привет");
|
||||
check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint");
|
||||
check(buffer.undo() && buffer.text() == "Привет", "text undo");
|
||||
@@ -53,7 +132,37 @@ int main() {
|
||||
check(buffer.text() == "верца", "UTF-8 delete");
|
||||
buffer.undo();
|
||||
check(buffer.text() == "Дверца", "undo delete");
|
||||
ui::Context context(FASET_TEST_FONT);
|
||||
const auto missing_font = std::filesystem::temp_directory_path() /
|
||||
path_from_utf8("Недоступный шрифт-" + new_id() + ".ttf");
|
||||
bool unicode_font_diagnostic = false;
|
||||
try {
|
||||
ui::FontAtlas missing(missing_font);
|
||||
} catch (const std::exception& error) {
|
||||
unicode_font_diagnostic =
|
||||
std::string(error.what()).find(path_to_utf8(missing_font)) != std::string::npos;
|
||||
}
|
||||
check(unicode_font_diagnostic, "Font errors preserve the UTF-8 path");
|
||||
const auto unicode_font_directory =
|
||||
std::filesystem::temp_directory_path() / path_from_utf8("Faset шрифты-" + new_id()) /
|
||||
std::string(80, 'a') / std::string(80, 'b') / std::string(80, 'c');
|
||||
std::filesystem::create_directories(unicode_font_directory);
|
||||
const auto unicode_font_path =
|
||||
unicode_font_directory / path_from_utf8("Основной шрифт.ttf");
|
||||
std::filesystem::copy_file(path_from_utf8(FASET_TEST_FONT), unicode_font_path);
|
||||
{
|
||||
ui::FontAtlas unicode_font(unicode_font_path);
|
||||
check(std::filesystem::remove(unicode_font_path),
|
||||
"Unicode font file must be closed after loading");
|
||||
check(unicode_font.measure("Живая сцена", 18) > 0,
|
||||
"Shaping uses retained font bytes after Unicode source file is removed");
|
||||
render::Snapshot glyphs;
|
||||
unicode_font.draw(glyphs, "Живая сцена", 0, 0, 18, {1, 1, 1, 1}, {0, 0, 400, 40});
|
||||
check(!glyphs.ui_quads.empty() && unicode_font.texture()->revision > 1,
|
||||
"Rasterization remains valid after Unicode font source disappears");
|
||||
}
|
||||
std::filesystem::remove_all(
|
||||
unicode_font_directory.parent_path().parent_path().parent_path());
|
||||
ui::Context context(path_from_utf8(FASET_TEST_FONT));
|
||||
auto& root = context.root();
|
||||
root.layout.gap = 4;
|
||||
auto& name = root.add(ui::Kind::TextField, "name", "Door");
|
||||
@@ -137,7 +246,7 @@ int main() {
|
||||
context.handle(key("Tab"));
|
||||
check(context.focused_id() == "name", "focus wraps predictably");
|
||||
check(ime_rectangles > 0, "IME rectangle never sent");
|
||||
ui::Context split(FASET_TEST_FONT);
|
||||
ui::Context split(path_from_utf8(FASET_TEST_FONT));
|
||||
auto& row = split.root().add(ui::Kind::Row, "row");
|
||||
row.layout.flex = 1;
|
||||
row.layout.gap = 0;
|
||||
@@ -171,7 +280,7 @@ int main() {
|
||||
context.handle(key("Escape"));
|
||||
check(number.value == 19 && number_commits == 1 && cancellations == 1,
|
||||
"Escape must cancel numeric drag");
|
||||
ui::Context scrolling(FASET_TEST_FONT);
|
||||
ui::Context scrolling(path_from_utf8(FASET_TEST_FONT));
|
||||
auto& list = scrolling.root().add(ui::Kind::Panel, "list");
|
||||
list.layout.height = 80;
|
||||
list.layout.scroll = true;
|
||||
@@ -211,7 +320,7 @@ int main() {
|
||||
rejected = true;
|
||||
}
|
||||
check(rejected && restored.to_json() == state, "invalid dock load mutated existing layout");
|
||||
ui::Context declarative(FASET_TEST_FONT);
|
||||
ui::Context declarative(path_from_utf8(FASET_TEST_FONT));
|
||||
declarative.apply_layout({{"id", "root"},
|
||||
{"kind", "column"},
|
||||
{"children", ui::Json::array({{{"id", "run"},
|
||||
|
||||
Reference in New Issue
Block a user