From b8ac8e0b00a7cac0bc13b36b84a28b41cfd55429 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Sat, 5 Sep 2026 17:21:10 +0900 Subject: [PATCH] test: cover the layout core with unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本仓库此前没有任何测试,CI 只能证明它编译得过。布局系统是这个框架里被所有控件依赖、也最容易在重构中被改坏的部分,而且是纯几何计算,不需要 D2D 设备即可测。 新增 13 个用例,覆盖 Visual 的测量与排列语义(margin 收缩可用尺寸并回填外框、显式尺寸覆盖测量值、min/max 夹取、可用空间封顶、测量缓存的命中与失效、Stretch/Center/Trailing 对齐定位),以及 StackPanel 与 HorizontalStackPanel 的堆叠、间距和 padding 行为。 断言值全部按 src/Layout.cpp 的现有实现推导,用于锁定当前行为,不是按我认为应该如何来写。测试框架沿用 MSIME-Server 的极简写法,但去掉了 fmt 依赖,本仓库不引入任何新依赖。 Grid、WrapPanel 和 ScrollViewer 本次未覆盖,留作后续。 --- ui/.github/workflows/ci.yml | 3 + ui/CMakeLists.txt | 6 + ui/tests/CMakeLists.txt | 18 +++ ui/tests/includes/test_framework.h | 49 +++++++ ui/tests/src/main.cpp | 39 +++++ ui/tests/src/test_layout.cpp | 224 +++++++++++++++++++++++++++++ 6 files changed, 339 insertions(+) create mode 100644 ui/tests/CMakeLists.txt create mode 100644 ui/tests/includes/test_framework.h create mode 100644 ui/tests/src/main.cpp create mode 100644 ui/tests/src/test_layout.cpp diff --git a/ui/.github/workflows/ci.yml b/ui/.github/workflows/ci.yml index 8aaa354b..75678338 100644 --- a/ui/.github/workflows/ci.yml +++ b/ui/.github/workflows/ci.yml @@ -21,3 +21,6 @@ jobs: - name: Build shell: pwsh run: cmake --build build --config Release --parallel + - name: Test + shell: pwsh + run: ctest --test-dir build -C Release --output-on-failure --timeout 60 diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt index d087be3f..b40c0cb3 100644 --- a/ui/CMakeLists.txt +++ b/ui/CMakeLists.txt @@ -2,6 +2,8 @@ cmake_minimum_required(VERSION 3.25) project(msimeui VERSION 0.1.0 LANGUAGES CXX) +include(CTest) + set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) @@ -133,3 +135,7 @@ if(MSVC) target_compile_options(msimeui-keyboard-demo PRIVATE /W4 /permissive-) target_compile_options(msimeui-handwriting-demo PRIVATE /W4 /permissive-) endif() + +if(BUILD_TESTING) + add_subdirectory(tests) +endif() diff --git a/ui/tests/CMakeLists.txt b/ui/tests/CMakeLists.txt new file mode 100644 index 00000000..5c5cb737 --- /dev/null +++ b/ui/tests/CMakeLists.txt @@ -0,0 +1,18 @@ +set(TEST_EXECUTABLE_NAME "msimeui-tests") + +add_executable(${TEST_EXECUTABLE_NAME} + src/main.cpp + src/test_layout.cpp + includes/test_framework.h +) + +# The test sources include their headers as "tests/includes/...", mirroring how +# MSIME-Server lays its tests out. +target_include_directories(${TEST_EXECUTABLE_NAME} PRIVATE ${CMAKE_SOURCE_DIR}) +target_link_libraries(${TEST_EXECUTABLE_NAME} PRIVATE msimeui) + +if(MSVC) + target_compile_options(${TEST_EXECUTABLE_NAME} PRIVATE /W4 /permissive-) +endif() + +add_test(NAME ${TEST_EXECUTABLE_NAME} COMMAND ${TEST_EXECUTABLE_NAME}) diff --git a/ui/tests/includes/test_framework.h b/ui/tests/includes/test_framework.h new file mode 100644 index 00000000..c9560318 --- /dev/null +++ b/ui/tests/includes/test_framework.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include + +namespace test +{ +struct TestCase +{ + std::string name; + std::function fn; +}; + +std::vector ®istry(); + +struct Registrar +{ + Registrar(const char *name, std::function fn); +}; +} // namespace test + +#define TEST_CASE(name) \ + static void name(); \ + static test::Registrar name##_registrar(#name, name); \ + static void name() + +#define REQUIRE(expr) \ + do \ + { \ + if (!(expr)) \ + { \ + throw std::runtime_error("Requirement failed: " #expr); \ + } \ + } while (false) + +// Layout maths runs in floats, so exact comparison would make the tests fragile +// for reasons that have nothing to do with the behaviour under test. +#define REQUIRE_NEAR(actual, expected) \ + do \ + { \ + const float _a = static_cast(actual); \ + const float _e = static_cast(expected); \ + if (!(std::fabs(_a - _e) <= 0.01f)) \ + { \ + throw std::runtime_error("Requirement failed: " #actual " ~= " #expected); \ + } \ + } while (false) diff --git a/ui/tests/src/main.cpp b/ui/tests/src/main.cpp new file mode 100644 index 00000000..19409e8b --- /dev/null +++ b/ui/tests/src/main.cpp @@ -0,0 +1,39 @@ +#include "tests/includes/test_framework.h" + +#include +#include + +namespace test +{ +std::vector ®istry() +{ + static std::vector tests; + return tests; +} + +Registrar::Registrar(const char *name, std::function fn) +{ + registry().push_back({name, std::move(fn)}); +} +} // namespace test + +int main() +{ + int failures = 0; + for (const auto &test_case : test::registry()) + { + try + { + test_case.fn(); + std::printf("[PASS] %s\n", test_case.name.c_str()); + } + catch (const std::exception &ex) + { + ++failures; + std::printf("[FAIL] %s: %s\n", test_case.name.c_str(), ex.what()); + } + } + + std::printf("%d test(s) failed\n", failures); + return failures == 0 ? 0 : 1; +} diff --git a/ui/tests/src/test_layout.cpp b/ui/tests/src/test_layout.cpp new file mode 100644 index 00000000..18a49e33 --- /dev/null +++ b/ui/tests/src/test_layout.cpp @@ -0,0 +1,224 @@ +#include "tests/includes/test_framework.h" + +#include "msimeui/Layout.h" + +#include +#include + +using namespace msimeui; + +namespace +{ +// A leaf visual with a fixed desired size. It reports its size verbatim rather +// than clamping to the available space, so the tests can observe the clamping +// that Visual::MeasureInLayout is responsible for. +class FixedVisual : public Visual +{ + public: + explicit FixedVisual(SizeF size) : size_(size) + { + } + + SizeF Measure(const SizeF &) override + { + ++measureCount; + return size_; + } + + void Arrange(const RectF &finalRect) override + { + bounds_ = finalRect; + } + + void Render(DeviceResources &) override + { + } + + int measureCount = 0; + + private: + SizeF size_; +}; + +std::shared_ptr MakeFixed(float width, float height) +{ + return std::make_shared(SizeF{width, height}); +} +} // namespace + +TEST_CASE(margin_deflates_the_available_size_and_inflates_the_reported_size) +{ + auto visual = MakeFixed(100.0f, 50.0f); + visual->SetMargin(10.0f); + + const SizeF outer = visual->MeasureInLayout({200.0f, 200.0f}); + + REQUIRE_NEAR(outer.width, 120.0f); + REQUIRE_NEAR(outer.height, 70.0f); +} + +TEST_CASE(explicit_size_overrides_the_measured_size) +{ + auto visual = MakeFixed(100.0f, 50.0f); + visual->SetWidth(80.0f); + visual->SetHeight(20.0f); + + const SizeF outer = visual->MeasureInLayout({200.0f, 200.0f}); + + REQUIRE_NEAR(outer.width, 80.0f); + REQUIRE_NEAR(outer.height, 20.0f); +} + +TEST_CASE(min_and_max_constraints_clamp_the_desired_size) +{ + auto visual = MakeFixed(100.0f, 50.0f); + visual->SetMinWidth(150.0f); + visual->SetMaxHeight(30.0f); + + const SizeF outer = visual->MeasureInLayout({400.0f, 400.0f}); + + REQUIRE_NEAR(outer.width, 150.0f); + REQUIRE_NEAR(outer.height, 30.0f); +} + +TEST_CASE(the_desired_size_never_exceeds_the_available_space) +{ + auto visual = MakeFixed(500.0f, 500.0f); + + const SizeF outer = visual->MeasureInLayout({200.0f, 100.0f}); + + REQUIRE_NEAR(outer.width, 200.0f); + REQUIRE_NEAR(outer.height, 100.0f); +} + +TEST_CASE(measuring_twice_with_the_same_available_size_reuses_the_cache) +{ + auto visual = MakeFixed(100.0f, 50.0f); + + visual->MeasureInLayout({200.0f, 200.0f}); + visual->MeasureInLayout({200.0f, 200.0f}); + REQUIRE(visual->measureCount == 1); + + // A different available size has to invalidate the cached measurement. + visual->MeasureInLayout({300.0f, 200.0f}); + REQUIRE(visual->measureCount == 2); +} + +TEST_CASE(stretch_alignment_fills_the_arranged_slot) +{ + auto visual = MakeFixed(40.0f, 20.0f); + visual->SetHorizontalAlignment(HorizontalAlignment::Stretch); + visual->SetVerticalAlignment(VerticalAlignment::Stretch); + + visual->MeasureInLayout({200.0f, 100.0f}); + visual->ArrangeInLayout({0.0f, 0.0f, 200.0f, 100.0f}); + + const RectF &bounds = visual->GetBounds(); + REQUIRE_NEAR(bounds.width, 200.0f); + REQUIRE_NEAR(bounds.height, 100.0f); +} + +TEST_CASE(center_alignment_positions_the_visual_inside_the_slot) +{ + auto visual = MakeFixed(40.0f, 20.0f); + visual->SetHorizontalAlignment(HorizontalAlignment::Center); + visual->SetVerticalAlignment(VerticalAlignment::Center); + + visual->MeasureInLayout({200.0f, 100.0f}); + visual->ArrangeInLayout({0.0f, 0.0f, 200.0f, 100.0f}); + + const RectF &bounds = visual->GetBounds(); + REQUIRE_NEAR(bounds.x, 80.0f); + REQUIRE_NEAR(bounds.y, 40.0f); + REQUIRE_NEAR(bounds.width, 40.0f); + REQUIRE_NEAR(bounds.height, 20.0f); +} + +TEST_CASE(trailing_alignment_pushes_the_visual_to_the_far_edge) +{ + auto visual = MakeFixed(40.0f, 20.0f); + visual->SetHorizontalAlignment(HorizontalAlignment::Trailing); + visual->SetVerticalAlignment(VerticalAlignment::Trailing); + + visual->MeasureInLayout({200.0f, 100.0f}); + visual->ArrangeInLayout({0.0f, 0.0f, 200.0f, 100.0f}); + + const RectF &bounds = visual->GetBounds(); + REQUIRE_NEAR(bounds.x, 160.0f); + REQUIRE_NEAR(bounds.y, 80.0f); +} + +TEST_CASE(margin_offsets_the_arranged_bounds) +{ + auto visual = MakeFixed(40.0f, 20.0f); + visual->SetMargin(Thickness{10.0f, 5.0f, 0.0f, 0.0f}); + visual->SetHorizontalAlignment(HorizontalAlignment::Leading); + visual->SetVerticalAlignment(VerticalAlignment::Leading); + + visual->MeasureInLayout({200.0f, 100.0f}); + visual->ArrangeInLayout({0.0f, 0.0f, 200.0f, 100.0f}); + + const RectF &bounds = visual->GetBounds(); + REQUIRE_NEAR(bounds.x, 10.0f); + REQUIRE_NEAR(bounds.y, 5.0f); +} + +TEST_CASE(stack_panel_reports_the_children_height_plus_spacing) +{ + auto panel = std::make_shared(8.0f); + panel->AddChild(MakeFixed(60.0f, 20.0f)); + panel->AddChild(MakeFixed(40.0f, 30.0f)); + + const SizeF outer = panel->MeasureInLayout({200.0f, 200.0f}); + + // Widest child, and both heights with one gap between them. + REQUIRE_NEAR(outer.width, 60.0f); + REQUIRE_NEAR(outer.height, 58.0f); +} + +TEST_CASE(stack_panel_advances_each_child_by_its_height_and_the_spacing) +{ + auto first = MakeFixed(60.0f, 20.0f); + auto second = MakeFixed(40.0f, 30.0f); + auto panel = std::make_shared(8.0f); + panel->AddChild(first); + panel->AddChild(second); + + panel->MeasureInLayout({200.0f, 200.0f}); + panel->ArrangeInLayout({0.0f, 0.0f, 200.0f, 100.0f}); + + REQUIRE_NEAR(first->GetBounds().y, 0.0f); + REQUIRE_NEAR(first->GetBounds().height, 20.0f); + REQUIRE_NEAR(second->GetBounds().y, 28.0f); + REQUIRE_NEAR(second->GetBounds().height, 30.0f); +} + +TEST_CASE(stack_panel_padding_insets_its_children) +{ + auto child = MakeFixed(40.0f, 20.0f); + auto panel = std::make_shared(); + panel->SetPadding(12.0f); + panel->AddChild(child); + + panel->MeasureInLayout({200.0f, 200.0f}); + panel->ArrangeInLayout({0.0f, 0.0f, 200.0f, 100.0f}); + + REQUIRE_NEAR(child->GetBounds().x, 12.0f); + REQUIRE_NEAR(child->GetBounds().y, 12.0f); +} + +TEST_CASE(horizontal_stack_panel_advances_along_x) +{ + auto first = MakeFixed(60.0f, 20.0f); + auto second = MakeFixed(40.0f, 30.0f); + auto panel = std::make_shared(6.0f); + panel->AddChild(first); + panel->AddChild(second); + + const SizeF outer = panel->MeasureInLayout({300.0f, 200.0f}); + panel->ArrangeInLayout({0.0f, 0.0f, 300.0f, 100.0f}); + + REQUIRE_NEAR(outer.width, 106.0f); + REQUIRE_NEAR(first->GetBounds().x, 0.0f); + REQUIRE_NEAR(second->GetBounds().x, 66.0f); +}