292 lines
16 KiB
C++
292 lines
16 KiB
C++
#include "MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h"
|
|
#include "MetaCoreFoundation/MetaCoreGeneratedReflection.h"
|
|
#include "MetaCoreScene/MetaCoreSceneSerializer.h"
|
|
|
|
#include <cstdlib>
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <filesystem>
|
|
#include <iostream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
void Expect(bool value, const char* message) {
|
|
if (!value) {
|
|
std::cerr << "MetaCoreRuntimeUiTests: " << message << '\n';
|
|
std::exit(1);
|
|
}
|
|
}
|
|
|
|
MetaCore::MetaCoreUiDocument BuildDocument() {
|
|
using namespace MetaCore;
|
|
MetaCoreUiDocument document;
|
|
document.Name = "Controls";
|
|
document.ReferenceWidth = 1920;
|
|
document.ReferenceHeight = 1080;
|
|
document.ScaleMode = MetaCoreUiCanvasScaleMode::ScaleWithScreenSize;
|
|
document.MatchWidthOrHeight = 0.5F;
|
|
|
|
MetaCoreUiNodeDocument root;
|
|
root.Id = "Root";
|
|
root.Name = "Root";
|
|
root.Type = MetaCoreUiNodeType::Panel;
|
|
root.RectTransform.AnchorMin = {0.0F, 0.0F, 0.0F};
|
|
root.RectTransform.AnchorMax = {1.0F, 1.0F, 0.0F};
|
|
root.RectTransform.Size = {0.0F, 0.0F, 0.0F};
|
|
|
|
const std::vector<MetaCoreUiNodeType> types{
|
|
MetaCoreUiNodeType::Text, MetaCoreUiNodeType::Image, MetaCoreUiNodeType::Button,
|
|
MetaCoreUiNodeType::Input, MetaCoreUiNodeType::List, MetaCoreUiNodeType::Scroll
|
|
};
|
|
for (std::size_t index = 0; index < types.size(); ++index) {
|
|
MetaCoreUiNodeDocument node;
|
|
node.Id = "Node" + std::to_string(index);
|
|
node.Name = node.Id;
|
|
node.ParentId = root.Id;
|
|
node.Type = types[index];
|
|
node.Text = "Safe <text>";
|
|
node.Interactable = types[index] == MetaCoreUiNodeType::Button || types[index] == MetaCoreUiNodeType::Input;
|
|
node.NavigationOrder = static_cast<std::int32_t>(index);
|
|
node.RectTransform.AnchorMin = {0.25F, 0.25F, 0.0F};
|
|
node.RectTransform.AnchorMax = {0.75F, 0.25F, 0.0F};
|
|
node.RectTransform.Position = {10.0F, 20.0F + static_cast<float>(index) * 50.0F, 0.0F};
|
|
if (types[index] == MetaCoreUiNodeType::Input) {
|
|
node.Placeholder = "Value";
|
|
node.MaxLength = 32;
|
|
node.Bindings.push_back({"script.form.value", MetaCoreUiBindingTarget::Value,
|
|
MetaCoreUiBindingMode::TwoWay, {}, {}, "default"});
|
|
}
|
|
if (types[index] == MetaCoreUiNodeType::List) {
|
|
node.Bindings.push_back({"script.rows", MetaCoreUiBindingTarget::Items,
|
|
MetaCoreUiBindingMode::OneWay, {}, {}, {}});
|
|
node.StaticItems = {{"first", "First", "1", {}}, {"second", "Second", "2", {}}};
|
|
node.SelectedIndex = 1;
|
|
}
|
|
if (types[index] == MetaCoreUiNodeType::Button) {
|
|
node.Style.ThemeToken = "primary";
|
|
node.LocalizationKey = "button.ok";
|
|
node.Animations.push_back({
|
|
"hover-opacity",
|
|
MetaCoreUiAnimationTrigger::Hover,
|
|
MetaCoreUiAnimationProperty::Opacity,
|
|
{0.7F, 0.0F, 0.0F},
|
|
{1.0F, 0.0F, 0.0F},
|
|
0.15F,
|
|
0.0F,
|
|
MetaCoreUiEasing::EaseOut
|
|
});
|
|
}
|
|
root.Children.push_back(node.Id);
|
|
document.Nodes.push_back(std::move(node));
|
|
}
|
|
document.Nodes.insert(document.Nodes.begin(), root);
|
|
document.RootNodeIds.push_back(root.Id);
|
|
return document;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
using namespace MetaCore;
|
|
const MetaCoreLegacyUiImportResult legacyImport = MetaCoreImportLegacyUiDocument(
|
|
R"(<rml><head><link type="text/rcss" href="legacy.rcss"/></head><body>
|
|
<div id="menu"><button id="play">Play & Go</button><input id="name" placeholder="Name"/>
|
|
<img id="logo" src="logo.png"/><custom id="ignored"/></div></body></rml>)",
|
|
"#menu { width: 400px; }",
|
|
"LegacyMenu"
|
|
);
|
|
Expect(legacyImport.Succeeded, "legacy RML structural import failed");
|
|
Expect(legacyImport.Document.Nodes.size() == 5, "legacy importer produced unexpected node count");
|
|
const auto importedButton = std::find_if(
|
|
legacyImport.Document.Nodes.begin(),
|
|
legacyImport.Document.Nodes.end(),
|
|
[](const MetaCoreUiNodeDocument& node) { return node.Id == "play"; }
|
|
);
|
|
Expect(importedButton != legacyImport.Document.Nodes.end() &&
|
|
importedButton->Type == MetaCoreUiNodeType::Button &&
|
|
importedButton->Text == "Play & Go", "legacy button semantics were not imported");
|
|
Expect(std::any_of(
|
|
legacyImport.Diagnostics.begin(),
|
|
legacyImport.Diagnostics.end(),
|
|
[](const MetaCoreUiDiagnostic& item) {
|
|
return !item.IsError && item.Message.find("UiTheme") != std::string::npos;
|
|
}
|
|
), "legacy RCSS migration diagnostic was not emitted");
|
|
const MetaCoreLegacyUiImportResult brokenLegacy = MetaCoreImportLegacyUiDocument("<rml><body><div", {});
|
|
Expect(!brokenLegacy.Succeeded, "malformed legacy RML import was accepted");
|
|
|
|
const MetaCoreUiDocument document = BuildDocument();
|
|
Expect(MetaCoreValidateUiDocument(document).empty(), "valid document was rejected");
|
|
const MetaCoreCompiledUiDocument first = MetaCoreCompileUiDocument(document);
|
|
const MetaCoreCompiledUiDocument second = MetaCoreCompileUiDocument(document);
|
|
Expect(first.Succeeded, "document compilation failed");
|
|
Expect(first.Rml == second.Rml && first.Rcss == second.Rcss, "compiler output is not deterministic");
|
|
Expect(first.Rml.find("<input") != std::string::npos, "Input control was not compiled");
|
|
Expect(first.Rml.find("role=\"listbox\"") != std::string::npos, "List control was not compiled");
|
|
Expect(first.Rml.find("<option id=\"Node4-item-first\"") != std::string::npos,
|
|
"static List items were not compiled");
|
|
Expect(first.Rml.find("mcui-scroll") != std::string::npos, "Scroll control was not compiled");
|
|
Expect(first.Rml.find("Safe <text>") != std::string::npos, "text was not escaped");
|
|
Expect(first.Rcss.find("left:calc(25") != std::string::npos, "anchors were not compiled");
|
|
Expect(first.Rml.find("data-metacore-bind-mode-value=\"two-way\"") != std::string::npos, "two-way binding was not compiled");
|
|
Expect(first.Rcss.find("@keyframes mcui-Node2-hover-opacity") != std::string::npos, "basic animation was not compiled");
|
|
|
|
MetaCoreUiCompileResources resources;
|
|
MetaCoreUiThemeDocument theme;
|
|
theme.Name = "Dark";
|
|
MetaCoreUiThemeStyleDocument primaryStyle;
|
|
primaryStyle.Token = "primary";
|
|
primaryStyle.BackgroundColor = {0.1F, 0.2F, 0.3F};
|
|
primaryStyle.BorderRadius = 5.0F;
|
|
primaryStyle.FontSize = 18.0F;
|
|
theme.Styles.push_back(primaryStyle);
|
|
resources.Theme = theme;
|
|
MetaCoreUiLocalizationTableDocument localization;
|
|
localization.DefaultLocale = "en-US";
|
|
localization.FallbackLocale = "en-US";
|
|
localization.Locales = {
|
|
{"en-US", {{"button.ok", "OK"}}},
|
|
{"zh-CN", {{"button.ok", "确定"}}}
|
|
};
|
|
resources.Localization = localization;
|
|
MetaCoreUiFontFamilyDocument compileFont;
|
|
compileFont.FamilyName = "UI";
|
|
compileFont.FallbackFamilies = {"sans-serif"};
|
|
compileFont.Faces.push_back({MetaCoreAssetGuid::Generate(), 400, false, "中文ABC"});
|
|
resources.FontFamily = compileFont;
|
|
const MetaCoreCompiledUiDocument localized = MetaCoreCompileUiDocument(document, resources);
|
|
Expect(localized.Succeeded, "resource-aware compilation failed");
|
|
Expect(localized.Rml.find("data-metacore-theme=\"Dark\"") != std::string::npos, "theme name was not compiled");
|
|
Expect(localized.Rcss.find("theme-primary") != std::string::npos, "theme token state was not compiled");
|
|
Expect(localized.Rml.find(">OK</button>") != std::string::npos, "default locale text was not compiled");
|
|
Expect(localized.LocalizationJson.find("确定") != std::string::npos, "localization sidecar was not compiled");
|
|
Expect(localized.Rml.find("mcui-font-prewarm") != std::string::npos &&
|
|
localized.Rml.find("中文ABC") != std::string::npos, "required font characters were not prewarmed for glyph atlas Cook");
|
|
Expect(MetaCoreResolveUiLocalizedText(localization, "button.ok", "zh-CN").value_or("") == "确定",
|
|
"locale lookup failed");
|
|
MetaCoreUiDocument atlasDocument = document;
|
|
const MetaCoreAssetGuid atlasImage = MetaCoreAssetGuid::Generate();
|
|
atlasDocument.Nodes[2].Style.ImageAssetGuid = atlasImage;
|
|
resources.AtlasFilename = "atlas.tga";
|
|
resources.AtlasWidth = 64;
|
|
resources.AtlasHeight = 64;
|
|
resources.AtlasEntries.push_back({atlasImage, 1, 1, 32, 32});
|
|
const MetaCoreCompiledUiDocument atlased = MetaCoreCompileUiDocument(atlasDocument, resources);
|
|
Expect(atlased.Succeeded && atlased.Rcss.find("@spritesheet metacore-ui-atlas") != std::string::npos,
|
|
"image atlas spritesheet was not compiled");
|
|
Expect(atlased.Rcss.find("decorator:image(" + atlasImage.ToString()) != std::string::npos,
|
|
"atlased image did not reference its deterministic sprite");
|
|
Expect(atlased.Rml.find("Assets/" + atlasImage.ToString() + ".png") == std::string::npos,
|
|
"atlased image should not load a loose texture");
|
|
|
|
MetaCoreUiCanvasEnvironment environment{2560.0F, 1440.0F, 144.0F, 100.0F, 20.0F, 100.0F, 20.0F};
|
|
MetaCoreUiCanvasLayout canvas = MetaCoreComputeUiCanvasLayout(document, environment);
|
|
Expect(canvas.Scale > 1.0F && canvas.ViewportLeft == 100.0F, "safe-area screen scaling failed");
|
|
MetaCoreUiDocument physical = document;
|
|
physical.ScaleMode = MetaCoreUiCanvasScaleMode::ConstantPhysicalSize;
|
|
canvas = MetaCoreComputeUiCanvasLayout(physical, environment);
|
|
Expect(std::abs(canvas.Scale - 1.5F) < 0.001F, "physical DPI scaling failed");
|
|
const auto worldSurface = MetaCoreComputeUiWorldSurfaceLayout({1.92F, 1.08F, 0.0F}, 1000.0F);
|
|
Expect(worldSurface.TargetWidth == 1920 && worldSurface.TargetHeight == 1080,
|
|
"World Space PixelsPerUnit sizing failed");
|
|
const auto centerUv = MetaCoreMapWorldUiHitToUv(glm::mat4(1.0F), {2.0F, 1.0F, 0.0F}, {0.0F, 0.0F, 0.0F});
|
|
Expect(centerUv.has_value() && std::abs(centerUv->x - 0.5F) < 0.001F &&
|
|
std::abs(centerUv->y - 0.5F) < 0.001F, "World Space ray hit UV mapping failed");
|
|
Expect(!MetaCoreMapWorldUiHitToUv(glm::mat4(1.0F), {2.0F, 1.0F, 0.0F}, {2.0F, 0.0F, 0.0F}).has_value(),
|
|
"World Space hit outside the UI plane should be rejected");
|
|
|
|
MetaCoreUiDocument benchmark;
|
|
benchmark.Name = "UiSteadyStateBenchmark";
|
|
benchmark.ReferenceWidth = 1920;
|
|
benchmark.ReferenceHeight = 1080;
|
|
MetaCoreUiNodeDocument benchmarkRoot;
|
|
benchmarkRoot.Id = "root";
|
|
benchmarkRoot.Type = MetaCoreUiNodeType::Panel;
|
|
benchmark.RootNodeIds.push_back(benchmarkRoot.Id);
|
|
benchmark.Nodes.push_back(benchmarkRoot);
|
|
for (int index = 1; index < 2000; ++index) {
|
|
MetaCoreUiNodeDocument node;
|
|
node.Id = "node-" + std::to_string(index);
|
|
node.ParentId = "root";
|
|
node.Type = index == 1 ? MetaCoreUiNodeType::List : MetaCoreUiNodeType::Text;
|
|
node.Text = "Row " + std::to_string(index);
|
|
if (index <= 500) node.Bindings.push_back({"runtime.perf." + std::to_string(index),
|
|
MetaCoreUiBindingTarget::Text, MetaCoreUiBindingMode::OneWay, {}, {}, "0"});
|
|
benchmark.Nodes.front().Children.push_back(node.Id);
|
|
benchmark.Nodes.push_back(std::move(node));
|
|
}
|
|
for (int row = 0; row < 100; ++row)
|
|
benchmark.Nodes[1].StaticItems.push_back({"row-" + std::to_string(row),
|
|
"Row " + std::to_string(row), std::to_string(row), {}});
|
|
std::vector<double> compileSamples;
|
|
for (int sample = 0; sample < 5; ++sample) {
|
|
const auto start = std::chrono::steady_clock::now();
|
|
const auto result = MetaCoreCompileUiDocument(benchmark);
|
|
const auto finish = std::chrono::steady_clock::now();
|
|
Expect(result.Succeeded, "2,000-node benchmark document failed to compile");
|
|
compileSamples.push_back(std::chrono::duration<double, std::milli>(finish - start).count());
|
|
}
|
|
std::sort(compileSamples.begin(), compileSamples.end());
|
|
const double compileP95 = compileSamples.back();
|
|
Expect(compileP95 < 2000.0, "2,000-node deterministic compiler p95 exceeded the regression guard");
|
|
std::cout << "MetaCoreRuntimeUi benchmark compile_p95_ms=" << compileP95
|
|
<< " nodes=2000 bindings=500 list_rows=100\n";
|
|
|
|
MetaCoreUiDocument invalid = document;
|
|
invalid.Nodes.back().Id = invalid.Nodes.front().Id;
|
|
Expect(!MetaCoreValidateUiDocument(invalid).empty(), "duplicate stable ID was accepted");
|
|
invalid = document;
|
|
invalid.Nodes[1].Bindings.push_back({"unknown.path", MetaCoreUiBindingTarget::Text,
|
|
MetaCoreUiBindingMode::OneWay, {}, {}, {}});
|
|
Expect(!MetaCoreValidateUiDocument(invalid).empty(), "invalid binding namespace was accepted");
|
|
invalid = document;
|
|
invalid.Nodes[1].Bindings.push_back({"runtime.value", MetaCoreUiBindingTarget::Text,
|
|
MetaCoreUiBindingMode::OneWay, {}, "arbitrary_expression()", {}});
|
|
Expect(!MetaCoreValidateUiDocument(invalid).empty(), "arbitrary binding converter was accepted");
|
|
|
|
MetaCoreUiDocument legacy = document;
|
|
legacy.SchemaVersion = 1;
|
|
legacy.Nodes[1].Bindings.clear();
|
|
legacy.Nodes[1].DataBinding = "runtime.temperature";
|
|
legacy.Nodes[1].DataFormat = "1";
|
|
const MetaCoreUiDocument migrated = MetaCoreMigrateUiDocument(legacy);
|
|
Expect(migrated.SchemaVersion == METACORE_UI_SCHEMA_VERSION, "legacy schema was not migrated");
|
|
Expect(migrated.Nodes[1].Bindings.size() == 1, "legacy binding was not migrated");
|
|
|
|
const std::filesystem::path jsonPath = std::filesystem::temp_directory_path() / "MetaCoreRuntimeUiV2.mcui.json";
|
|
MetaCoreTypeRegistry registry;
|
|
MetaCoreRegisterFoundationGeneratedTypes(registry);
|
|
MetaCoreRegisterSceneGeneratedTypes(registry);
|
|
Expect(MetaCoreSerializeToBytes(document, registry).has_value(), "v2 binary serialization failed");
|
|
Expect(MetaCoreSceneSerializer::SaveUiToJson(jsonPath, document, registry), "v2 JSON write failed");
|
|
const auto loaded = MetaCoreSceneSerializer::LoadUiFromJson(jsonPath, registry);
|
|
Expect(loaded.has_value() && loaded->SchemaVersion == 2, "v2 JSON schema did not round-trip");
|
|
Expect(loaded->Nodes[4].Placeholder == "Value", "Input settings did not round-trip");
|
|
Expect(loaded->Nodes[5].StaticItems.size() == 2, "List static items did not round-trip");
|
|
std::filesystem::remove(jsonPath);
|
|
|
|
const std::filesystem::path themePath = std::filesystem::temp_directory_path() / "MetaCoreUiTheme.mcuitheme.json";
|
|
const std::filesystem::path fontPath = std::filesystem::temp_directory_path() / "MetaCoreUiFont.mcuifont.json";
|
|
const std::filesystem::path localizationPath = std::filesystem::temp_directory_path() / "MetaCoreUiLoc.mculoc.json";
|
|
MetaCoreUiFontFamilyDocument font;
|
|
font.FamilyName = "UI";
|
|
font.FallbackFamilies = {"sans-serif"};
|
|
font.Faces.push_back({MetaCoreAssetGuid::Generate(), 400, false, "中文ABC"});
|
|
Expect(MetaCoreSceneSerializer::SaveUiThemeToJson(themePath, theme), "theme JSON write failed");
|
|
Expect(MetaCoreSceneSerializer::SaveUiFontFamilyToJson(fontPath, font), "font family JSON write failed");
|
|
Expect(MetaCoreSceneSerializer::SaveUiLocalizationTableToJson(localizationPath, localization), "localization JSON write failed");
|
|
Expect(MetaCoreSceneSerializer::LoadUiThemeFromJson(themePath)->Styles.size() == 1, "theme JSON round-trip failed");
|
|
Expect(MetaCoreSceneSerializer::LoadUiFontFamilyFromJson(fontPath)->Faces.size() == 1, "font JSON round-trip failed");
|
|
Expect(MetaCoreSceneSerializer::LoadUiLocalizationTableFromJson(localizationPath)->Locales.size() == 2,
|
|
"localization JSON round-trip failed");
|
|
std::filesystem::remove(themePath);
|
|
std::filesystem::remove(fontPath);
|
|
std::filesystem::remove(localizationPath);
|
|
|
|
std::cout << "MetaCoreRuntimeUiTests passed\n";
|
|
return 0;
|
|
}
|