Files
Faset_Engine/tests/ui_tests.cpp
T

240 lines
11 KiB
C++

#include <chrono>
#include <faset/ui/ui.hpp>
#include <iostream>
#include <stdexcept>
using namespace faset;
namespace {
void check(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
render::Event key(std::string name, bool control = false, bool shift = false) {
render::Event e;
e.type = render::Event::Type::KeyDown;
e.key = std::move(name);
e.control = control;
e.shift = shift;
return e;
}
render::Event text(std::string value) {
render::Event e;
e.type = render::Event::Type::TextInput;
e.text = std::move(value);
return e;
}
render::Event mouse(render::Event::Type type, float x, float y) {
render::Event e;
e.type = type;
e.x = x;
e.y = y;
e.button = 1;
return e;
}
void click(ui::Context& context, const ui::Widget& widget) {
const auto r = widget.rect;
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));
}
} // namespace
int main() {
try {
ui::TextBuffer buffer("Привет");
check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint");
check(buffer.undo() && buffer.text() == "Привет", "text undo");
buffer.select_all();
buffer.insert("Дверь");
buffer.left(true);
check(buffer.selected_text() == "ь", "UTF-8 selection");
buffer.insert("ца");
check(buffer.text() == "Дверца", "selection replacement");
check(!buffer.insert(std::string("\xc0\x80", 2)), "overlong UTF-8 accepted");
buffer.home();
buffer.delete_forward();
check(buffer.text() == "верца", "UTF-8 delete");
buffer.undo();
check(buffer.text() == "Дверца", "undo delete");
ui::Context context(FASET_TEST_FONT);
auto& root = context.root();
root.layout.gap = 4;
auto& name = root.add(ui::Kind::TextField, "name", "Door");
name.layout.height = 32;
int commits = 0;
name.on_commit = [&](ui::Widget&) { ++commits; };
auto& number = root.add(ui::Kind::NumberField, "number");
number.value = 4;
number.step = .5;
number.layout.height = 32;
int number_commits = 0, previews = 0;
number.on_commit = [&](ui::Widget&) { ++number_commits; };
number.on_preview = [&](ui::Widget&) { ++previews; };
auto& checkbox = root.add(ui::Kind::Checkbox, "enabled", "Enabled");
int checks = 0;
checkbox.on_commit = [&](ui::Widget&) { ++checks; };
auto& button = root.add(ui::Kind::Button, "save", "Save");
int clicks = 0;
button.on_click = [&](ui::Widget&) { ++clicks; };
context.layout(320, 240);
std::string clipboard;
context.set_clipboard([&] { return clipboard; },
[&](const std::string& s) { clipboard = s; });
bool ime = false;
int ime_rectangles = 0;
context.set_ime([&](bool enabled) { ime = enabled; },
[&](ui::Rect r) {
check(r.width > 0, "IME area");
++ime_rectangles;
});
check(context.focus("name") && ime, "field focus enables IME");
context.handle(key("A", true));
context.handle(text("Привет"));
check(!context.update_text("name", "External"), "document refresh erased dirty edit");
context.handle(key("C", true));
context.handle(key("A", true));
context.handle(key("C", true));
check(clipboard == "Привет", "clipboard copied wrong selection");
clipboard = "Дверь";
context.handle(key("V", true));
check(name.text == "Дверь", "UTF-8 paste");
context.handle(key("Z", true));
check(name.text == "Привет", "local Ctrl-Z");
context.handle(key("Return"));
check(commits == 1 && name.text == "Привет", "text commits once");
context.handle(key("Return"));
check(commits == 1, "unchanged text recommitted");
context.handle(key("End"));
render::Event composition;
composition.type = render::Event::Type::TextEditing;
composition.text = "й";
composition.edit_length = 1;
context.handle(composition);
check(name.text == "Привет", "IME preedit changed document field");
context.handle(text("й"));
context.handle(key("Return"));
check(name.text == "Приветй" && commits == 2, "IME commit");
context.focus("number");
const auto r = number.rect;
context.handle(mouse(render::Event::Type::MouseDown, r.x + 20, r.y + 12));
context.handle(mouse(render::Event::Type::MouseMove, r.x + 40, r.y + 12));
context.handle(mouse(render::Event::Type::MouseMove, r.x + 50, r.y + 12));
context.handle(mouse(render::Event::Type::MouseUp, r.x + 50, r.y + 12));
check(number.value == 19 && number_commits == 1 && previews == 2,
"numeric drag must commit once at release");
context.focus("save");
check(number_commits == 1, "blur duplicated drag commit");
context.focus("number");
context.handle(key("A", true));
context.handle(text("NaN"));
context.handle(key("Return"));
check(number_commits == 1 && !number.error.empty(), "invalid numeric input committed");
context.handle(key("Escape"));
check(number.value == 19, "cancel numeric input changed value");
click(context, checkbox);
check(checkbox.checked && checks == 1, "checkbox commit");
click(context, button);
check(clicks == 1, "button click");
context.handle(key("Return"));
check(clicks == 2, "keyboard button activation");
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);
auto& row = split.root().add(ui::Kind::Row, "row");
row.layout.flex = 1;
row.layout.gap = 0;
auto& left = row.add(ui::Kind::Panel, "left");
left.layout.width = 100;
left.layout.min_width = 50;
auto& divider = row.add(ui::Kind::Divider, "divider");
divider.layout.width = 5;
auto& right = row.add(ui::Kind::Panel, "right");
right.layout.flex = 1;
right.layout.min_width = 50;
int resize_commit = 0;
divider.on_commit = [&](ui::Widget&) { ++resize_commit; };
split.layout(300, 100);
const auto d = divider.rect;
split.handle(mouse(render::Event::Type::MouseDown, d.x + 2, 20));
split.handle(mouse(render::Event::Type::MouseMove, d.x + 32, 20));
split.handle(mouse(render::Event::Type::MouseUp, d.x + 32, 20));
check(left.layout.width == 130 && right.rect.width == 165 && resize_commit == 1,
"divider resize");
const auto moved_divider = divider.rect;
split.handle(mouse(render::Event::Type::MouseDown, moved_divider.x + 2, 20));
split.handle(mouse(render::Event::Type::MouseMove, moved_divider.x + 42, 20));
split.handle(key("Escape"));
check(left.layout.width == 130 && resize_commit == 1,
"Escape must restore divider without committing");
int cancellations = 0;
number.on_cancel = [&](ui::Widget&) { ++cancellations; };
context.handle(mouse(render::Event::Type::MouseDown, r.x + 10, r.y + 12));
context.handle(mouse(render::Event::Type::MouseMove, r.x + 50, r.y + 12));
context.handle(key("Escape"));
check(number.value == 19 && number_commits == 1 && cancellations == 1,
"Escape must cancel numeric drag");
ui::Context scrolling(FASET_TEST_FONT);
auto& list = scrolling.root().add(ui::Kind::Panel, "list");
list.layout.height = 80;
list.layout.scroll = true;
list.layout.gap = 0;
for (int i = 0; i < 10; ++i)
list.add(ui::Kind::Label, "row" + std::to_string(i), "Объект " + std::to_string(i));
scrolling.layout(300, 200);
scrolling.handle(mouse(render::Event::Type::MouseMove, 30, 30));
render::Event wheel;
wheel.type = render::Event::Type::Wheel;
wheel.y = -2;
check(scrolling.handle(wheel) && list.scroll_y > 0, "scroll event");
render::Snapshot frame;
scrolling.draw(frame);
check(!frame.ui_quads.empty(), "retained UI emitted no quads");
for (const auto& q : frame.ui_quads)
check(q.x >= 0 && q.y >= 0 && q.x + q.width <= 300.01f && q.y + q.height <= 80.01f,
"CPU clipping escaped scroll panel");
check(scrolling.font().measure("Привет", 14) > 20, "Cyrillic shaping failed");
ui::DockLayout docks;
docks.move("Scene", "left", 0);
docks.move("Assets", "left", 1);
docks.move("Assets", "left", 0);
docks.set_size("Scene", 224);
check(docks.panels("left") == std::vector<std::string>({"Assets", "Scene"}),
"dock reordering");
const auto state = docks.to_json();
ui::DockLayout restored;
restored.from_json(state);
check(restored.to_json() == state, "dock serialization");
auto invalid = state;
invalid["areas"]["right"] = {"Scene"};
bool rejected = false;
try {
restored.from_json(invalid);
} catch (...) {
rejected = true;
}
check(rejected && restored.to_json() == state, "invalid dock load mutated existing layout");
ui::Context declarative(FASET_TEST_FONT);
declarative.apply_layout({{"id", "root"},
{"kind", "column"},
{"children", ui::Json::array({{{"id", "run"},
{"kind", "button"},
{"text", "Play"},
{"layout", {{"height", 30}}}}})}});
int action = 0;
declarative.find("run")->on_click = [&](ui::Widget&) { ++action; };
declarative.apply_layout({{"id", "root"},
{"kind", "column"},
{"children", ui::Json::array({{{"id", "run"},
{"kind", "button"},
{"text", "Run"},
{"layout", {{"height", 34}}}}})}});
declarative.layout(200, 100);
click(declarative, *declarative.find("run"));
check(action == 1 && declarative.find("run")->text == "Run", "layout reload lost callback");
std::cout << "UI: UTF-8, shaping, text/IME/clipboard, focus, transactions, "
"layout, clipping, docking OK\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}