完成 StellarX 核心重构、缺陷修复与测试收口
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user