MetaCore/tests/MetaCoreRuntimeUiTests.cpp
2026-07-27 15:01:03 +08:00

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 &amp; 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 &lt;text&gt;") != 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;
}