Files

418 lines
18 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "StellarX.h"
#include "SxNativeInputInternal.h"
#include <array>
#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);
}
class KeyboardObservationControl final : public Control
{
public:
KeyboardObservationControl() : Control(0, 0, 1, 1) { id = "KeyboardObservation"; }
void draw() override { dirty = false; }
bool handleEvent(const ExMessage& message) override
{
if (message.message == WM_KEYDOWN || message.message == WM_SYSKEYDOWN)
{
++keyEventCount;
++keyDownCount;
}
else if (message.message == WM_KEYUP || message.message == WM_SYSKEYUP)
{
++keyEventCount;
++keyUpCount;
}
return false;
}
StellarX::KeyRouteResult handleKeyEvent(StellarX::RoutedKeyEvent& event) override
{
++routedKeyEventCount;
if (event.key.action == StellarX::KeyAction::Down)
++routedKeyDownCount;
else
++routedKeyUpCount;
return {};
}
bool model() const override { return false; }
std::uint64_t keyEventCount = 0;
std::uint64_t keyDownCount = 0;
std::uint64_t keyUpCount = 0;
std::uint64_t routedKeyEventCount = 0;
std::uint64_t routedKeyDownCount = 0;
std::uint64_t routedKeyUpCount = 0;
};
}
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";
const bool consumeProbeScenario = scenario == "input-consume-probe";
const bool expandedFocusScenario = scenario == "focus";
const int windowWidth = consumeProbeScenario ? 720 : (expandedFocusScenario ? 800 : 480);
const int windowHeight = consumeProbeScenario ? 420 : (expandedFocusScenario ? 520 : 320);
Window window(windowWidth, windowHeight, 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 == "input-probe" || scenario == "input-consume-probe")
{
// 该场景只提供一个可聚焦的 EasyX 顶层窗口,不让控件消费或改写键盘/IME 消息。
const bool consumeProbe = scenario == "input-consume-probe";
const std::array<std::string, 5> instructions = consumeProbe
? std::array<std::string, 5>{
"StellarX stage 0B isolated consume probe",
"This window is driven by the bounded diagnostic runner.",
"Only one message, key and action rule is active.",
"No control receives business keyboard routing.",
"The runner will close this window after capture."
}
: std::array<std::string, 5>{
"StellarX input probe - manual IME run",
"1. Click this window, then switch to the requested IME.",
"2. Type only the agreed non-sensitive test strings.",
"3. Cover commit, candidate select, cancel and language switch.",
"4. Close the window after the checklist is complete."
};
int y = 30;
for (const std::string& line : instructions)
{
auto label = std::make_unique<Label>(20, y, line, WHITE, Background);
label->setTextdisap(true);
window.addControl(std::move(label));
y += 42;
}
if (consumeProbe)
{
// 与 runner 传给 IMM32 的固定客户区坐标完全一致:行区 (200,255)-(680,284),
// caret bottom 为 (225,282)。仅用于人工确认候选框没有落回桌面边缘。
auto anchorLabel = std::make_unique<Label>(20, 260, "IME anchor:", WHITE, Background);
anchorLabel->setTextdisap(true);
window.addControl(std::move(anchorLabel));
auto lineRegion = std::make_unique<Canvas>(200, 255, 480, 29);
lineRegion->setCanvasBkColor(RGB(45, 70, 95));
window.addControl(std::move(lineRegion));
auto caret = std::make_unique<Canvas>(224, 258, 2, 24);
caret->setCanvasBkColor(HoverColor);
window.addControl(std::move(caret));
}
}
else if (scenario == "input-shadow")
{
// Stage 3 production routes the normalized queue while EasyX EX_KEY is
// still drained but never delivered to Control::handleEvent().
auto observer = std::make_unique<KeyboardObservationControl>();
KeyboardObservationControl* observerRaw = observer.get();
observerRaw->setFocusPolicy(StellarX::FocusPolicy::Programmatic);
const auto observerHandle = window.addControl(std::move(observer));
window.getFocusManager().requestFocus({ observerHandle, 0 });
auto verify = MakeButton(20, 40, 160, 42, "verify input shadow");
verify->setOnClickListener([&, observerRaw]() {
const auto snapshot = StellarX::Internal::inspectWindowNativeInputForTesting(window);
WriteResult(resultDir, scenario,
std::string("{\"legacyKeyCount\":") + std::to_string(observerRaw->keyEventCount) +
",\"legacyKeyDownCount\":" + std::to_string(observerRaw->keyDownCount) +
",\"legacyKeyUpCount\":" + std::to_string(observerRaw->keyUpCount) +
",\"routedKeyCount\":" + std::to_string(observerRaw->routedKeyEventCount) +
",\"routedKeyDownCount\":" + std::to_string(observerRaw->routedKeyDownCount) +
",\"routedKeyUpCount\":" + std::to_string(observerRaw->routedKeyUpCount) +
",\"easyXKeyboardMessagesDrained\":" +
std::to_string(snapshot.easyXKeyboardMessagesDrained) +
",\"legacyEasyXKeyboardBusinessDeliveries\":" +
std::to_string(snapshot.legacyEasyXKeyboardBusinessDeliveries) +
",\"pushed\":" + std::to_string(snapshot.queue.pushed) +
",\"popped\":" + std::to_string(snapshot.queue.popped) +
",\"currentDepth\":" + std::to_string(snapshot.queue.currentDepth) +
",\"maximumDepth\":" + std::to_string(snapshot.queue.maximumDepth) +
",\"observed\":" + std::to_string(snapshot.observations.observed) +
",\"keyEvents\":" + std::to_string(snapshot.observations.keyEvents) +
",\"rejectedTextSequences\":" + std::to_string(snapshot.normalization.rejectedTextSequences) +
",\"imeQueryFailures\":" + std::to_string(snapshot.normalization.imeQueryFailures) +
",\"discardedDecoderFragments\":" + std::to_string(snapshot.normalization.discardedDecoderFragments) +
",\"sequenceStrict\":" + (snapshot.observations.sequenceStrict ? "true" : "false") +
",\"faulted\":" + (snapshot.faulted ? "true" : "false") + "}");
});
window.addControl(std::move(verify));
}
else if (scenario == "keyboard-routing")
{
// End-to-end production cutover: WndProc -> NativeInputQueue ->
// KeyboardRouter -> Button, with no EX_KEY business fallback.
int firstClicks = 0;
int secondClicks = 0;
auto first = MakeButton(20, 40, 150, 42, "space target");
Button* firstRaw = first.get();
const auto firstHandle = window.addControl(std::move(first));
auto second = MakeButton(210, 40, 150, 42, "enter target");
Button* secondRaw = second.get();
const auto secondHandle = window.addControl(std::move(second));
firstRaw->setOnClickListener([&]() { ++firstClicks; });
secondRaw->setOnClickListener([&, secondHandle]() {
++secondClicks;
const bool secondFocused = window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ secondHandle, 0 };
WriteResult(resultDir, scenario,
std::string("{\"firstClicks\":") + std::to_string(firstClicks) +
",\"secondClicks\":" + std::to_string(secondClicks) +
",\"secondFocused\":" + (secondFocused ? "true" : "false") + "}");
});
window.getFocusManager().requestFocus({ firstHandle, 0 });
}
else if (scenario == "native-close")
{
// 只创建顶层窗口,由 UI 驱动发送原生关闭命令并验证宿主循环自行退出。
}
else if (scenario == "modal-close")
{
// 窗口首次绘制后立即进入真正的模态循环,由 UI 驱动关闭宿主原生窗口。
// 该场景用于验证模态循环不会在 HWND 已销毁后继续轮询。
}
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 == "focus")
{
// Focus integration: real focus visuals, Table child eligibility, active Tab page
// filtering, background pointer clearing, and same-parent overlay restoration.
auto button = MakeButton(20, 20, 120, 40, "focus button");
Button* buttonRaw = button.get();
bool buttonFocusObserved = false;
button->setOnClickListener([&]() { buttonFocusObserved = buttonRaw->hasLogicalFocus(); });
window.addControl(std::move(button));
auto textBox = std::make_unique<TextBox>(20, 80, 160, 36, "focus text");
TextBox* textBoxRaw = textBox.get();
window.addControl(std::move(textBox));
auto table = std::make_unique<Table>(210, 20);
const auto tableHandle = table->getHandle();
table->setHeaders({ "value" });
table->setRowsPerPage(1);
table->setData({ { "one" }, { "two" } });
window.addControl(std::move(table));
auto tabs = std::make_unique<TabControl>(20, 145, 300, 140);
tabs->setActiveIndex(0);
auto firstPage = std::make_unique<Canvas>(0, 0, 300, 110);
auto firstPageButton = MakeButton(10, 40, 110, 30, "active child");
const auto firstPageChildHandle = firstPageButton->getHandle();
firstPage->addControl(std::move(firstPageButton));
tabs->addTab(MakeButton(0, 0, 100, 30, "first"), std::move(firstPage));
auto secondPage = std::make_unique<Canvas>(0, 0, 300, 110);
auto secondPageButton = MakeButton(10, 40, 110, 30, "inactive child");
const auto secondPageChildHandle = secondPageButton->getHandle();
secondPage->addControl(std::move(secondPageButton));
tabs->addTab(MakeButton(100, 0, 100, 30, "second"), std::move(secondPage));
window.addControl(std::move(tabs));
// Canvas 空白点击必须清焦点。右侧 Canvas 与左侧控件不重叠,便于脚本
// 对焦点框出现/消失做稳定的局部像素比较。
auto focusCanvas = std::make_unique<Canvas>(500, 20, 260, 120);
focusCanvas->setCanvasBkColor(RGB(60, 90, 120));
auto canvasButton = MakeButton(20, 20, 110, 34, "canvas focus");
focusCanvas->addControl(std::move(canvasButton));
window.addControl(std::move(focusCanvas));
// 复现 KEY5 D 区:下层与上层 Canvas 同属一个父 Canvas,且两者相交。
// 从下层 Button 切到上层 Button 时,旧焦点消失和新焦点出现会让两条
// 子树在同一批次变脏;上层必须完整恢复,不能只画自己的脏 Button。
auto overlayRoot = std::make_unique<Canvas>(500, 180, 260, 180);
overlayRoot->setCanvasBkColor(RGB(235, 225, 205));
auto lowerLayer = std::make_unique<Canvas>(10, 20, 170, 110);
lowerLayer->setCanvasBkColor(RGB(90, 120, 150));
lowerLayer->addControl(MakeButton(15, 15, 90, 30, "lower"));
auto upperLayer = std::make_unique<Canvas>(100, 55, 145, 100);
upperLayer->setCanvasBkColor(RGB(200, 160, 120));
upperLayer->addControl(MakeButton(30, 45, 100, 30, "upper"));
overlayRoot->addControl(std::move(lowerLayer));
overlayRoot->addControl(std::move(upperLayer));
window.addControl(std::move(overlayRoot));
auto verify = MakeButton(350, 260, 110, 36, "verify");
verify->setOnClickListener([&, buttonRaw]() {
const auto stops = window.getFocusManager().collectTabStops();
std::size_t tableTabStops = 0;
bool activePageChildPresent = false;
bool inactivePageChildPresent = false;
bool allSubNodesZero = true;
for (const auto& stop : stops)
{
if (stop.logicalParent.owner == tableHandle) ++tableTabStops;
if (stop.id.owner == firstPageChildHandle) activePageChildPresent = true;
if (stop.id.owner == secondPageChildHandle) inactivePageChildPresent = true;
if (stop.id.subNode != 0) allSubNodesZero = false;
}
WriteResult(resultDir, scenario,
std::string("{\"buttonFocusObserved\":") + (buttonFocusObserved ? "true" : "false") +
",\"tableTabStops\":" + std::to_string(tableTabStops) +
",\"activePageChildPresent\":" + (activePageChildPresent ? "true" : "false") +
",\"inactivePageChildPresent\":" + (inactivePageChildPresent ? "true" : "false") +
",\"allSubNodesZero\":" + (allSubNodesZero ? "true" : "false") + "}");
});
window.addControl(std::move(verify));
textBoxRaw->requestFocus();
}
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();
if (scenario == "modal-close")
{
(void)StellarX::MessageBox::showModal(
window, "close the native host window", "modal close", StellarX::MessageBoxType::OK);
}
return window.runEventLoop();
}