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:
1 parent
98fba9320d
commit
b8ac8e0b00
6 files changed
+339
No files matched your search
Vendored
+3
@@ -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
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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> ®istry();
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "tests/includes/test_framework.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <exception>
|
||||
|
||||
namespace test
|
||||
{
|
||||
std::vector<TestCase> ®istry()
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user