418 lines
18 KiB
C++
418 lines
18 KiB
C++
#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();
|
||
}
|