完成 StellarX 核心重构、缺陷修复与测试收口
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32"><Configuration>Debug</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32"><Configuration>Release</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64"><Configuration>Debug</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64"><Configuration>Release</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{8779C678-AD5B-4828-A4A8-57D4C4254733}</ProjectGuid>
|
||||
<RootNamespace>StellarXTests</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
<ProjectSubType>NativeUnitTestProject</ProjectSubType>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Debug'">true</UseDebugLibraries>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Release'">false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<PropertyGroup>
|
||||
<OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)\</IntDir>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(EnableAsan)'=='true' And '$(Platform)'=='x64'">
|
||||
<EnableASAN>true</EnableASAN>
|
||||
<OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)-asan\</OutDir>
|
||||
<IntDir>$(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)-asan\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile>
|
||||
<WarningLevel>Level4</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<PreprocessorDefinitions>STELLARX_TESTING;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)..\include\StellarX;$(ProjectDir)..\src\StellarX\internal;$(VCInstallDir)Auxiliary\VS\UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<AdditionalDependencies>Microsoft.VisualStudio.TestTools.CppUnitTestFramework.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||
<AdditionalLibraryDirectories Condition="'$(Platform)'=='x64'">$(VCInstallDir)Auxiliary\VS\UnitTest\lib\x64;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
|
||||
<AdditionalLibraryDirectories Condition="'$(Platform)'=='Win32'">$(VCInstallDir)Auxiliary\VS\UnitTest\lib\x86;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup><ClCompile Include="StellarXTests.cpp" /></ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellarX.Core.vcxproj"><Project>{28B8D78E-85AF-4F14-AD56-D51AB6958941}</Project></ProjectReference>
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32"><Configuration>Debug</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32"><Configuration>Release</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64"><Configuration>Debug</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64"><Configuration>Release</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion><Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}</ProjectGuid>
|
||||
<RootNamespace>StellarXUiFixture</RootNamespace><WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Debug'">true</UseDebugLibraries>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Release'">false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset><CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<PropertyGroup>
|
||||
<OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile><WarningLevel>Level4</WarningLevel><SDLCheck>true</SDLCheck><ConformanceMode>true</ConformanceMode><LanguageStandard>stdcpp17</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader><AdditionalIncludeDirectories>$(ProjectDir)..\include\StellarX;$(ProjectDir)..\src\StellarX\internal;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories></ClCompile>
|
||||
<Link><SubSystem>Console</SubSystem><GenerateDebugInformation>true</GenerateDebugInformation></Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup><ClCompile Include="UiFixture.cpp" /></ItemGroup>
|
||||
<ItemGroup><ProjectReference Include="..\StellarX.Core.vcxproj"><Project>{28B8D78E-85AF-4F14-AD56-D51AB6958941}</Project></ProjectReference></ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,373 @@
|
||||
#include "CppUnitTest.h"
|
||||
#include "StellarX.h"
|
||||
#include "SxEventLoopInternal.h"
|
||||
#include "SxTextInternal.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
|
||||
|
||||
namespace
|
||||
{
|
||||
// 轻量测试控件只实现生命周期和事件所需的最小行为,避免把所有权测试绑定到真实绘图。
|
||||
class FakeControl final : public Control
|
||||
{
|
||||
public:
|
||||
FakeControl() : Control(0, 0, 10, 10) { id = "Fake"; }
|
||||
void draw() override { dirty = false; }
|
||||
bool handleEvent(const ExMessage&) override { return false; }
|
||||
bool model() const override { return false; }
|
||||
};
|
||||
|
||||
std::filesystem::path MakeTempLogPath(const char* name)
|
||||
{
|
||||
// 使用进程号隔离并行测试产生的日志路径;测试结束后由用例主动清理。
|
||||
return std::filesystem::temp_directory_path() /
|
||||
(std::string("stellarx-") + name + "-" + std::to_string(GetCurrentProcessId()) + ".log");
|
||||
}
|
||||
}
|
||||
|
||||
namespace StellarXNativeTests
|
||||
{
|
||||
// 验证句柄身份和回调期间的延迟变更不会破坏容器遍历或对象生命周期。
|
||||
TEST_CLASS(OwnershipTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(HandlesAreUnique)
|
||||
{
|
||||
// 句柄必须与对象地址解耦,才能在对象被销毁后安全地表示原对象身份。
|
||||
auto first = std::make_unique<FakeControl>();
|
||||
auto second = std::make_unique<FakeControl>();
|
||||
Assert::AreNotEqual(first->getHandle().value, second->getHandle().value);
|
||||
}
|
||||
|
||||
TEST_METHOD(CallbackCanRemoveCurrentControlSafely)
|
||||
{
|
||||
// 回调删除自身后,当前事件栈仍会继续返回,但不能再次访问已释放控件。
|
||||
Window window(200, 120, 1);
|
||||
auto button = std::make_unique<Button>(10, 10, 80, 30, "remove");
|
||||
Button* raw = button.get();
|
||||
const auto handle = window.addControl(std::move(button));
|
||||
raw->setOnClickListener([&]() { Assert::IsTrue(window.removeControl(handle)); });
|
||||
|
||||
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||
ExMessage up = down; up.message = WM_LBUTTONUP;
|
||||
raw->handleEvent(down);
|
||||
raw->handleEvent(up);
|
||||
|
||||
Assert::IsNull(window.findControl(handle));
|
||||
Assert::AreEqual<size_t>(0, window.getControlHandles().size());
|
||||
}
|
||||
|
||||
TEST_METHOD(CallbackCanQueueAddAndClear)
|
||||
{
|
||||
// 同一回调内先添加再清空,提交顺序必须确定且不得让遍历迭代器失效。
|
||||
Window window(200, 120, 1);
|
||||
auto button = std::make_unique<Button>(10, 10, 80, 30, "mutate");
|
||||
Button* raw = button.get();
|
||||
window.addControl(std::move(button));
|
||||
raw->setOnClickListener([&]() {
|
||||
window.addControl(std::make_unique<FakeControl>());
|
||||
window.clearControls();
|
||||
});
|
||||
|
||||
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||
ExMessage up = down; up.message = WM_LBUTTONUP;
|
||||
raw->handleEvent(down);
|
||||
raw->handleEvent(up);
|
||||
Assert::AreEqual<size_t>(0, window.getControlHandles().size());
|
||||
}
|
||||
|
||||
TEST_METHOD(CallbackCanRemoveSiblingAndLeaveRemainingControlUsable)
|
||||
{
|
||||
// 删除兄弟控件后,未受影响的后续控件仍应能够接收下一次点击。
|
||||
Window window(200, 120, 1);
|
||||
auto remover = std::make_unique<Button>(10, 10, 80, 30, "remove sibling");
|
||||
Button* removerRaw = remover.get();
|
||||
window.addControl(std::move(remover));
|
||||
|
||||
auto sibling = std::make_unique<FakeControl>();
|
||||
const auto siblingHandle = window.addControl(std::move(sibling));
|
||||
int remainingClicks = 0;
|
||||
auto remaining = std::make_unique<Button>(100, 10, 80, 30, "remaining");
|
||||
Button* remainingRaw = remaining.get();
|
||||
remainingRaw->setOnClickListener([&]() { ++remainingClicks; });
|
||||
window.addControl(std::move(remaining));
|
||||
|
||||
removerRaw->setOnClickListener([&]() { window.removeControl(siblingHandle); });
|
||||
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||
ExMessage up = down; up.message = WM_LBUTTONUP;
|
||||
removerRaw->handleEvent(down);
|
||||
removerRaw->handleEvent(up);
|
||||
Assert::IsNull(window.findControl(siblingHandle));
|
||||
|
||||
down.x = 120; up.x = 120;
|
||||
remainingRaw->handleEvent(down);
|
||||
remainingRaw->handleEvent(up);
|
||||
Assert::AreEqual(1, remainingClicks);
|
||||
}
|
||||
|
||||
TEST_METHOD(QueuedAddCanBeRemovedBeforeTransactionCommit)
|
||||
{
|
||||
// 队列中的新对象也必须拥有可撤销的句柄,提交前移除不能误伤现有对象。
|
||||
Window window(200, 120, 1);
|
||||
auto canvas = std::make_unique<Canvas>(0, 50, 100, 60);
|
||||
Canvas* canvasRaw = canvas.get();
|
||||
window.addControl(std::move(canvas));
|
||||
|
||||
auto trigger = std::make_unique<Button>(10, 10, 80, 30, "queue");
|
||||
Button* triggerRaw = trigger.get();
|
||||
window.addControl(std::move(trigger));
|
||||
triggerRaw->setOnClickListener([&]() {
|
||||
const auto windowHandle = window.addControl(std::make_unique<FakeControl>());
|
||||
Assert::IsTrue(window.removeControl(windowHandle));
|
||||
const auto canvasHandle = canvasRaw->addControl(std::make_unique<FakeControl>());
|
||||
Assert::IsTrue(canvasRaw->removeControl(canvasHandle));
|
||||
});
|
||||
|
||||
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||
ExMessage up = down; up.message = WM_LBUTTONUP;
|
||||
triggerRaw->handleEvent(down);
|
||||
triggerRaw->handleEvent(up);
|
||||
|
||||
Assert::AreEqual<size_t>(2, window.getControlHandles().size());
|
||||
Assert::AreEqual<size_t>(0, canvasRaw->getControlHandles().size());
|
||||
}
|
||||
|
||||
TEST_METHOD(TabAddedFromCallbackCommitsWithPendingPageControl)
|
||||
{
|
||||
// 页签及其页面控件在同一事务中加入时,页面内部的排队变更也必须一并提交。
|
||||
Window window(300, 200, 1);
|
||||
auto tabs = std::make_unique<TabControl>(0, 50, 200, 140);
|
||||
TabControl* tabsRaw = tabs.get();
|
||||
tabsRaw->addTab(std::make_unique<Button>(0, 0, 80, 24, "first"),
|
||||
std::make_unique<Canvas>(0, 0, 200, 110));
|
||||
window.addControl(std::move(tabs));
|
||||
|
||||
auto trigger = std::make_unique<Button>(10, 10, 80, 30, "add tab");
|
||||
Button* triggerRaw = trigger.get();
|
||||
window.addControl(std::move(trigger));
|
||||
triggerRaw->setOnClickListener([&]() {
|
||||
const size_t index = tabsRaw->addTab(
|
||||
std::make_unique<Button>(0, 0, 80, 24, "second"),
|
||||
std::make_unique<Canvas>(0, 0, 200, 110));
|
||||
auto rejected = tabsRaw->tryAddToPage(index, std::make_unique<FakeControl>());
|
||||
Assert::IsNull(rejected.get());
|
||||
});
|
||||
|
||||
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||
ExMessage up = down; up.message = WM_LBUTTONUP;
|
||||
triggerRaw->handleEvent(down);
|
||||
triggerRaw->handleEvent(up);
|
||||
Assert::AreEqual(2, tabsRaw->count());
|
||||
}
|
||||
};
|
||||
|
||||
// 验证 MBCS 文本所有权、空页签边界、提示延时和密码掩码等纯逻辑行为。
|
||||
TEST_CLASS(TextAndTabTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(ButtonConstructsWithChineseTextStyle)
|
||||
{
|
||||
// Button 的字体样式含有 std::string;构造中文按钮可回归成员初始化顺序错误。
|
||||
Button button(10, 10, 80, 30, "圆");
|
||||
Assert::AreEqual(std::string("圆"), button.getButtonText());
|
||||
Assert::AreEqual(std::string("微软雅黑"), button.getButtonTextStyle().faceName);
|
||||
}
|
||||
|
||||
TEST_METHOD(ControlTextOwnsAndComparesFaceName)
|
||||
{
|
||||
// 字体名应复制字符串内容,而不是保存临时缓冲区地址或比较地址。
|
||||
StellarX::ControlText first;
|
||||
first.faceName = std::string("Temporary Font");
|
||||
StellarX::ControlText second = first;
|
||||
Assert::IsTrue(first == second);
|
||||
second.faceName = "Other Font";
|
||||
Assert::IsTrue(first != second);
|
||||
Assert::AreEqual(std::string("Temporary Font"), first.faceName);
|
||||
}
|
||||
|
||||
TEST_METHOD(TabRejectsInvalidPageWithoutConsumingControl)
|
||||
{
|
||||
// 非法页索引必须原样返还控件所有权,空页签也不能越界访问。
|
||||
TabControl tabs(0, 0, 300, 200);
|
||||
Assert::IsTrue(tabs.setActiveIndex(0));
|
||||
auto rejected = tabs.tryAddToPage(0, std::make_unique<FakeControl>());
|
||||
Assert::IsNotNull(rejected.get());
|
||||
|
||||
const size_t page = tabs.addTab(
|
||||
std::make_unique<Button>(0, 0, 80, 24, "same"),
|
||||
std::make_unique<Canvas>(0, 0, 300, 176));
|
||||
Assert::AreEqual<size_t>(0, page);
|
||||
auto accepted = tabs.tryAddToPage(page, std::move(rejected));
|
||||
Assert::IsNull(accepted.get());
|
||||
}
|
||||
|
||||
TEST_METHOD(TooltipAppearsOnTickWithoutAdditionalMouseMove)
|
||||
{
|
||||
// 鼠标停留后的显示由时钟驱动,不应要求额外的 WM_MOUSEMOVE 才能到期。
|
||||
FakeControl repaintSink;
|
||||
Button button(10, 10, 80, 30, "tooltip");
|
||||
button.setParent(&repaintSink);
|
||||
button.enableTooltip(true);
|
||||
button.setTooltipDelay(1000);
|
||||
ExMessage move{}; move.message = WM_MOUSEMOVE; move.x = 20; move.y = 20;
|
||||
Assert::IsTrue(button.handleEvent(move));
|
||||
Assert::IsFalse(button.isTooltipVisible());
|
||||
|
||||
const ULONGLONG afterMove = GetTickCount64();
|
||||
button.tick(afterMove);
|
||||
Assert::IsFalse(button.isTooltipVisible());
|
||||
button.tick(afterMove + 1000);
|
||||
Assert::IsTrue(button.isTooltipVisible());
|
||||
move.x = 200; move.y = 100;
|
||||
Assert::IsFalse(button.handleEvent(move));
|
||||
Assert::IsFalse(button.isTooltipVisible());
|
||||
|
||||
move.x = 20; move.y = 20;
|
||||
button.handleEvent(move);
|
||||
button.tick(GetTickCount64() + 1000);
|
||||
Assert::IsTrue(button.isTooltipVisible());
|
||||
button.enableTooltip(false);
|
||||
Assert::IsFalse(button.isTooltipVisible());
|
||||
}
|
||||
|
||||
TEST_METHOD(PasswordMaskCountsMbcCharactersWithoutOverrun)
|
||||
{
|
||||
// 掩码按 MBCS 逻辑字符计数,同时覆盖不完整和非法字节序列。
|
||||
Assert::AreEqual(std::string("***"), StellarX::Internal::makePasswordMask("abc"));
|
||||
const std::string gbk = std::string("\xD6\xD0\xCE\xC4", 4) + "A";
|
||||
Assert::AreEqual(std::string("***"), StellarX::Internal::makePasswordMask(gbk));
|
||||
const std::string incomplete = std::string("A\x81", 2);
|
||||
Assert::AreEqual(std::string("**"), StellarX::Internal::makePasswordMask(incomplete));
|
||||
const std::string invalidPair = std::string("\x81\x30", 2);
|
||||
Assert::AreEqual(std::string("**"), StellarX::Internal::makePasswordMask(invalidPair));
|
||||
}
|
||||
};
|
||||
|
||||
// 验证消息批处理上限、空闲休眠策略和关闭消息的停止语义。
|
||||
TEST_CLASS(EventLoopTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(ThousandMessagesDrainInEightActiveRounds)
|
||||
{
|
||||
// 每轮最多处理固定数量消息,活动轮不休眠,1000 条消息应在 8 轮内排空。
|
||||
std::vector<ExMessage> messages(1000);
|
||||
size_t next = 0;
|
||||
size_t handled = 0;
|
||||
int rounds = 0;
|
||||
int activeSleeps = 0;
|
||||
ExMessage current{};
|
||||
while (next < messages.size())
|
||||
{
|
||||
const auto batch = StellarX::Internal::drainMessageBatch(current,
|
||||
[&](ExMessage& out) {
|
||||
if (next >= messages.size()) return false;
|
||||
out = messages[next++];
|
||||
return true;
|
||||
},
|
||||
[&](const ExMessage&) { ++handled; return true; });
|
||||
++rounds;
|
||||
if (batch.shouldSleep()) ++activeSleeps;
|
||||
}
|
||||
|
||||
Assert::AreEqual<size_t>(1000, handled);
|
||||
Assert::AreEqual(8, rounds);
|
||||
Assert::AreEqual(0, activeSleeps);
|
||||
const auto idle = StellarX::Internal::drainMessageBatch(current,
|
||||
[](ExMessage&) { return false; }, [](const ExMessage&) { return true; });
|
||||
Assert::IsTrue(idle.shouldSleep());
|
||||
}
|
||||
|
||||
TEST_METHOD(CloseStopsBatchAfterEarlierResize)
|
||||
{
|
||||
// 批次内遇到 WM_CLOSE 后停止继续取消息,之前的 WM_SIZE 仍必须保留。
|
||||
std::vector<ExMessage> messages(3);
|
||||
messages[0].message = WM_SIZE;
|
||||
messages[1].message = WM_CLOSE;
|
||||
messages[2].message = WM_MOUSEMOVE;
|
||||
size_t next = 0;
|
||||
std::vector<UINT> handled;
|
||||
ExMessage current{};
|
||||
const auto batch = StellarX::Internal::drainMessageBatch(current,
|
||||
[&](ExMessage& out) {
|
||||
if (next >= messages.size()) return false;
|
||||
out = messages[next++];
|
||||
return true;
|
||||
},
|
||||
[&](const ExMessage& message) {
|
||||
handled.push_back(message.message);
|
||||
return message.message != WM_CLOSE;
|
||||
});
|
||||
|
||||
Assert::IsTrue(batch.stopped);
|
||||
Assert::AreEqual<size_t>(2, batch.processed);
|
||||
Assert::AreEqual<size_t>(2, handled.size());
|
||||
Assert::AreEqual<UINT>(WM_SIZE, handled[0]);
|
||||
Assert::AreEqual<UINT>(WM_CLOSE, handled[1]);
|
||||
Assert::AreEqual<size_t>(2, next);
|
||||
}
|
||||
};
|
||||
|
||||
// 验证文件 sink 的原子切换、失败回退和轮换过程不会丢失日志行。
|
||||
TEST_CLASS(LoggingTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(SetConfigOpensAndDisablesFileAtomically)
|
||||
{
|
||||
// 无效的新文件配置不能覆盖当前可用配置,也不能关闭旧 sink。
|
||||
const auto path = MakeTempLogPath("config");
|
||||
std::filesystem::remove(path);
|
||||
auto& logger = StellarX::SxLogger::Get();
|
||||
StellarX::SxLogConfig config;
|
||||
config.fileEnabled = true;
|
||||
config.filePath = path.string();
|
||||
config.fileAppend = false;
|
||||
Assert::IsTrue(logger.setConfig(config));
|
||||
logger.logLine(StellarX::SxLogLevel::Info, "test", __FILE__, __LINE__, __func__, "configured");
|
||||
logger.disableFile();
|
||||
Assert::IsTrue(std::filesystem::exists(path));
|
||||
|
||||
const auto before = logger.getConfigCopy();
|
||||
config.fileEnabled = true;
|
||||
config.filePath.clear();
|
||||
Assert::IsFalse(logger.setConfig(config));
|
||||
Assert::AreEqual(before.filePath, logger.getConfigCopy().filePath);
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
TEST_METHOD(RotationPreservesEveryLine)
|
||||
{
|
||||
// 高频轮换可能发生在同一毫秒内,所有分片和当前文件合计必须保留每一行。
|
||||
const auto path = MakeTempLogPath("rotate");
|
||||
const auto directory = path.parent_path();
|
||||
const auto prefix = path.filename().string() + ".";
|
||||
std::filesystem::remove(path);
|
||||
for (const auto& entry : std::filesystem::directory_iterator(directory))
|
||||
if (entry.path().filename().string().rfind(prefix, 0) == 0) std::filesystem::remove(entry.path());
|
||||
|
||||
StellarX::FileSink sink;
|
||||
sink.setRotateBytes(32);
|
||||
Assert::IsTrue(sink.open(path.string(), false));
|
||||
for (int i = 0; i < 30; ++i) sink.writeLine("line-" + std::to_string(i) + "\n");
|
||||
sink.close();
|
||||
|
||||
int lineCount = 0;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(directory))
|
||||
{
|
||||
const auto name = entry.path().filename().string();
|
||||
if (entry.path() != path && name.rfind(prefix, 0) != 0) continue;
|
||||
{
|
||||
std::ifstream input(entry.path());
|
||||
std::string line;
|
||||
while (std::getline(input, line)) ++lineCount;
|
||||
}
|
||||
std::filesystem::remove(entry.path());
|
||||
}
|
||||
Assert::AreEqual(30, lineCount);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
#include "StellarX.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace
|
||||
{
|
||||
// 场景使用固定纯色,脚本只断言关键像素,不依赖字体抗锯齿或完整截图。
|
||||
constexpr COLORREF Background = RGB(30, 50, 70);
|
||||
constexpr COLORREF ButtonColor = RGB(80, 160, 220);
|
||||
constexpr COLORREF ClickColor = RGB(220, 90, 70);
|
||||
constexpr COLORREF HoverColor = RGB(250, 200, 80);
|
||||
|
||||
void WriteResult(const std::filesystem::path& resultDir, const std::string& scenario,
|
||||
const std::string& payload)
|
||||
{
|
||||
// JSON 是脚本与 fixture 之间的稳定协议;先建目录,再一次性覆盖当前检查点。
|
||||
std::filesystem::create_directories(resultDir);
|
||||
std::ofstream output(resultDir / (scenario + ".json"), std::ios::trunc);
|
||||
output << payload;
|
||||
}
|
||||
|
||||
std::unique_ptr<Button> MakeButton(int x, int y, int w, int h, const std::string& text)
|
||||
{
|
||||
// 统一按钮颜色,使 UI 回归可以区分背景、悬停和点击后的绘制结果。
|
||||
return std::make_unique<Button>(x, y, w, h, text, ClickColor, ButtonColor, HoverColor);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
// fixture 每次只运行一个确定性场景,并用唯一标题让驱动脚本精确绑定 HWND。
|
||||
const std::string scenario = argc > 1 ? argv[1] : "geometry";
|
||||
const std::filesystem::path resultDir = argc > 2 ? argv[2] : "TestResults";
|
||||
Window window(480, 320, 1, Background, "StellarX.UI." + scenario);
|
||||
|
||||
if (scenario == "ownership")
|
||||
{
|
||||
// 点击后删除自身并新增状态标签,覆盖回调期间删除和新增的生命周期路径。
|
||||
auto button = MakeButton(20, 20, 120, 40, "remove self");
|
||||
Button* raw = button.get();
|
||||
const auto handle = window.addControl(std::move(button));
|
||||
raw->setOnClickListener([&]() {
|
||||
window.removeControl(handle);
|
||||
auto status = std::make_unique<Label>(20, 90, "removed safely", WHITE, Background);
|
||||
status->setTextdisap(true);
|
||||
window.addControl(std::move(status));
|
||||
WriteResult(resultDir, scenario, "{\"removed\":true}");
|
||||
});
|
||||
}
|
||||
else if (scenario == "geometry")
|
||||
{
|
||||
// 一次点击同时移动、缩放和隐藏多个控件,用于检查旧区域是否被完整恢复。
|
||||
auto button = MakeButton(20, 20, 120, 40, "move");
|
||||
Button* raw = button.get();
|
||||
auto label = std::make_unique<Label>(20, 85, "hide label", WHITE, RGB(90, 130, 70));
|
||||
Label* labelRaw = label.get();
|
||||
auto textBox = std::make_unique<TextBox>(20, 120, 120, 36, "resize");
|
||||
TextBox* textBoxRaw = textBox.get();
|
||||
textBoxRaw->setTextBoxBk(RGB(230, 230, 210));
|
||||
auto canvas = std::make_unique<Canvas>(20, 175, 120, 50);
|
||||
Canvas* canvasRaw = canvas.get();
|
||||
canvasRaw->setCanvasBkColor(RGB(40, 120, 90));
|
||||
|
||||
raw->setOnClickListener([&, raw, labelRaw, textBoxRaw, canvasRaw]() {
|
||||
raw->setX(220);
|
||||
labelRaw->setIsVisible(false);
|
||||
textBoxRaw->setX(220);
|
||||
textBoxRaw->setWidth(80);
|
||||
canvasRaw->setX(220);
|
||||
canvasRaw->setWidth(80);
|
||||
WriteResult(resultDir, scenario,
|
||||
"{\"moved\":true,\"x\":220,\"textWidth\":80,\"canvasWidth\":80,\"labelVisible\":false}");
|
||||
});
|
||||
window.addControl(std::move(button));
|
||||
window.addControl(std::move(label));
|
||||
window.addControl(std::move(textBox));
|
||||
window.addControl(std::move(canvas));
|
||||
}
|
||||
else if (scenario == "tooltip")
|
||||
{
|
||||
// 场景不再发送第二次鼠标移动,专门验证 Window tick 能让静止提示到期显示。
|
||||
auto button = MakeButton(20, 20, 120, 40, "tooltip");
|
||||
button->enableTooltip(true);
|
||||
button->setTooltipDelay(200);
|
||||
button->setTooltipText("stationary tip");
|
||||
window.addControl(std::move(button));
|
||||
}
|
||||
else if (scenario == "tab")
|
||||
{
|
||||
// 两个页签故意使用相同标题,验证索引身份而不是文本匹配决定激活状态。
|
||||
auto tabs = std::make_unique<TabControl>(20, 20, 300, 180);
|
||||
TabControl* raw = tabs.get();
|
||||
raw->setActiveIndex(0);
|
||||
auto firstButton = MakeButton(0, 0, 100, 30, "same");
|
||||
Button* firstButtonRaw = firstButton.get();
|
||||
auto secondButton = MakeButton(0, 0, 100, 30, "same");
|
||||
Button* secondButtonRaw = secondButton.get();
|
||||
auto firstPage = std::make_unique<Canvas>(0, 0, 300, 150);
|
||||
Canvas* firstPageRaw = firstPage.get();
|
||||
firstPageRaw->setCanvasBkColor(RGB(40, 100, 80));
|
||||
auto secondPage = std::make_unique<Canvas>(0, 0, 300, 150);
|
||||
Canvas* secondPageRaw = secondPage.get();
|
||||
secondPageRaw->setCanvasBkColor(RGB(110, 70, 120));
|
||||
raw->addTab(std::move(firstButton), std::move(firstPage));
|
||||
raw->addTab(std::move(secondButton), std::move(secondPage));
|
||||
window.addControl(std::move(tabs));
|
||||
auto verify = MakeButton(340, 220, 110, 36, "verify");
|
||||
verify->setOnClickListener([&, raw, firstButtonRaw, secondButtonRaw, firstPageRaw, secondPageRaw]() {
|
||||
WriteResult(resultDir, scenario, std::string("{\"active\":") +
|
||||
std::to_string(raw->getActiveIndex()) +
|
||||
",\"firstSelected\":" + (firstButtonRaw->isClicked() ? "true" : "false") +
|
||||
",\"secondSelected\":" + (secondButtonRaw->isClicked() ? "true" : "false") +
|
||||
",\"firstPageVisible\":" + (firstPageRaw->IsVisible() ? "true" : "false") +
|
||||
",\"secondPageVisible\":" + (secondPageRaw->IsVisible() ? "true" : "false") + "}");
|
||||
});
|
||||
window.addControl(std::move(verify));
|
||||
}
|
||||
else if (scenario == "table")
|
||||
{
|
||||
// 长表头和长单元格配合极窄宽度,验证最小宽度夹紧及 coverage 报告范围。
|
||||
auto table = std::make_unique<Table>(20, 20);
|
||||
Table* raw = table.get();
|
||||
raw->setHeaders({ "very-long-header", "second-long-header", "third" });
|
||||
raw->setData({ { "very-long-cell-value", "another-long-value", "value" } });
|
||||
window.addControl(std::move(table));
|
||||
auto resize = MakeButton(340, 250, 110, 36, "narrow");
|
||||
resize->setOnClickListener([&, raw]() {
|
||||
raw->setWidth(40);
|
||||
const RECT coverage = raw->getManagedRepaintCoverageRect();
|
||||
WriteResult(resultDir, scenario, std::string("{\"width\":") +
|
||||
std::to_string(raw->getWidth()) +
|
||||
",\"coverageLeft\":" + std::to_string(coverage.left) +
|
||||
",\"coverageTop\":" + std::to_string(coverage.top) +
|
||||
",\"coverageRight\":" + std::to_string(coverage.right) +
|
||||
",\"coverageBottom\":" + std::to_string(coverage.bottom) + "}");
|
||||
});
|
||||
window.addControl(std::move(resize));
|
||||
}
|
||||
else if (scenario == "dialog")
|
||||
{
|
||||
// 先叠加两个非模态对话框,再只关闭上层,验证下层恢复和交互顺序。
|
||||
struct DialogState { bool upperClosed = false; bool lowerClosed = false; };
|
||||
auto state = std::make_shared<DialogState>();
|
||||
auto writeDialogState = [&, state]() {
|
||||
WriteResult(resultDir, scenario, std::string("{\"upperClosed\":") +
|
||||
(state->upperClosed ? "true" : "false") + ",\"lowerClosed\":" +
|
||||
(state->lowerClosed ? "true" : "false") + "}");
|
||||
};
|
||||
auto open = MakeButton(20, 20, 140, 40, "open dialogs");
|
||||
open->setOnClickListener([&, state, writeDialogState]() {
|
||||
StellarX::MessageBox::showAsync(window, "lower", "lower", StellarX::MessageBoxType::OK,
|
||||
[state, writeDialogState](StellarX::MessageBoxResult) { state->lowerClosed = true; writeDialogState(); });
|
||||
StellarX::MessageBox::showAsync(window, "upper", "upper", StellarX::MessageBoxType::OK,
|
||||
[state, writeDialogState](StellarX::MessageBoxResult) { state->upperClosed = true; writeDialogState(); });
|
||||
});
|
||||
window.addControl(std::move(open));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 未知场景按参数错误处理,避免脚本误把未覆盖场景当成成功。
|
||||
WriteResult(resultDir, scenario, "{\"error\":\"unknown scenario\"}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
window.draw();
|
||||
return window.runEventLoop();
|
||||
}
|
||||
Reference in New Issue
Block a user