test: cover the layout core with unit tests

本仓库此前没有任何测试,CI 只能证明它编译得过。布局系统是这个框架里被所有控件依赖、也最容易在重构中被改坏的部分,而且是纯几何计算,不需要 D2D 设备即可测。

新增 13 个用例,覆盖 Visual 的测量与排列语义(margin 收缩可用尺寸并回填外框、显式尺寸覆盖测量值、min/max 夹取、可用空间封顶、测量缓存的命中与失效、Stretch/Center/Trailing 对齐定位),以及 StackPanel 与 HorizontalStackPanel 的堆叠、间距和 padding 行为。

断言值全部按 src/Layout.cpp 的现有实现推导,用于锁定当前行为,不是按我认为应该如何来写。测试框架沿用 MSIME-Server 的极简写法,但去掉了 fmt 依赖,本仓库不引入任何新依赖。

Grid、WrapPanel 和 ScrollViewer 本次未覆盖,留作后续。
This commit is contained in:
Evan Hu committed 2026-09-05 17:21:10 +09:00
1 parent 98fba9320d
commit b8ac8e0b00
6 files changed
+339

No files matched your search

+3
View File
@@ -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
+6
View File
@@ -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()
+18
View File
@@ -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})
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <functional>
#include <stdexcept>
#include <string>
#include <vector>
namespace test
{
struct TestCase
{
std::string name;
std::function<void()> fn;
};
std::vector<TestCase> &registry();
struct Registrar
{
Registrar(const char *name, std::function<void()> 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<float>(actual); \
const float _e = static_cast<float>(expected); \
if (!(std::fabs(_a - _e) <= 0.01f)) \
{ \
throw std::runtime_error("Requirement failed: " #actual " ~= " #expected); \
} \
} while (false)
+39
View File
@@ -0,0 +1,39 @@
#include "tests/includes/test_framework.h"
#include <cstdio>
#include <exception>
namespace test
{
std::vector<TestCase> &registry()
{
static std::vector<TestCase> tests;
return tests;
}
Registrar::Registrar(const char *name, std::function<void()> 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;
}
+224
View File
@@ -0,0 +1,224 @@
#include "tests/includes/test_framework.h"
#include "msimeui/Layout.h"
#include <cmath>
#include <memory>
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<FixedVisual> MakeFixed(float width, float height)
{
return std::make_shared<FixedVisual>(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<StackPanel>(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<StackPanel>(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<StackPanel>();
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<HorizontalStackPanel>(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);
}