Files
hrynco-kswitcher/tests/integration.cpp
T

495 lines
23 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 "app.h"
#include <algorithm>
#include <iostream>
#include <fstream>
#include <commctrl.h>
namespace ks
{
struct InputRegressionAccess
{
static bool deferring(App &app)
{
std::lock_guard lock(app.stateMutex);
return app.typingTarget != nullptr;
}
};
}
namespace
{
ks::App *app{};
HWND host{}, field{};
ks::Clip saved;
bool captured = false, failed = false;
int stage = 0, ru = -1, en = -1;
size_t punctuationCase = 0;
bool typeDuringCorrection = false, typedDuringCorrection = false;
const std::vector<std::pair<std::wstring, std::wstring>> punctuationCases{
{L"(ljvf) ", L"(дома) "}, {L"[ljvf] ", L"[дома] "}, {L"{ljvf} ", L"{дома} "},
{L"ljvf: ", L"дома: "}, {L"ljvf; ", L"дома; "}, {L"#ljvf%; ", L"#дома%; "}};
ULONGLONG deadline{};
std::wstring sentinel = L"KSwitcher integration clipboard sentinel";
std::string diagnosticLog()
{
std::ifstream file(ks::dataDirectory() / L"kswitcher.log");
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
}
size_t diagnosticStart{};
void require(bool ok, const char *name)
{
if (!ok)
{
wchar_t observed[2048]{};
GetWindowTextW(field, observed, 2048);
std::cerr << "Editor at failure: " << ks::utf8(observed)
<< "; foregroundSame=" << (GetForegroundWindow() == host)
<< "; inputVersion=" << app->inputVersion.load() << '\n';
throw std::runtime_error(name);
}
std::cout << "PASS " << name << '\n';
}
std::wstring text()
{
wchar_t buffer[2048]{};
GetWindowTextW(field, buffer, 2048);
return buffer;
}
void prepare(const wchar_t *value, bool select)
{
app->resetInput();
SetWindowTextW(field, value);
ks::activateWindow(host);
SetFocus(field);
int end = GetWindowTextLengthW(field);
SendMessageW(field, EM_SETSEL, select ? 0 : end, end);
if (GetForegroundWindow() != host)
throw std::runtime_error("Test editor could not acquire foreground; no input was sent");
}
void record(const std::wstring &keys)
{
std::lock_guard lock(app->stateMutex);
for (wchar_t c : keys)
app->run.record(c, host, app->layouts[en].handle, GetTickCount64());
}
void finish()
{
KillTimer(host, 1);
if (captured)
{
try
{
app->ownSequence = ks::writeClipboard(app->window, saved);
}
catch (...)
{
failed = true;
}
}
DestroyWindow(host);
PostMessageW(app->window, WM_CLOSE, 0, 0);
}
LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
// Model a host that does not forward layout requests to its embedded editor.
if (msg == WM_INPUTLANGCHANGEREQUEST)
return 0;
if (msg == WM_TIMER)
{
try
{
if (GetTickCount64() > deadline)
throw std::runtime_error("Native integration timed out");
if (app->busy)
{
if (typeDuringCorrection && ks::InputRegressionAccess::deferring(*app))
{
typeDuringCorrection = false;
typedDuringCorrection = true;
std::vector<INPUT> inputs;
for (auto [key, up] : std::vector<std::pair<WORD, bool>>{
{'F', false}, {'F', true}, {VK_LSHIFT, false},
{'F', false}, {'F', true}, {VK_LSHIFT, true},
{'B', false}, {'B', true}, {VK_BACK, false}, {VK_BACK, true}})
{
auto input = ks::keyboardInput(key, up ? KEYEVENTF_KEYUP : 0);
input.ki.dwExtraInfo = 123; // External input must traverse the actual hook.
inputs.push_back(input);
}
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
"concurrent typing submitted during selection/paste transaction");
}
return 0;
}
switch (stage++)
{
case 0:
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
captured = true;
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
prepare(L"ghbdtn", true);
app->transform(ks::Direct + ru);
break;
case 1:
std::cout << "Observed editor: " << ks::utf8(text()) << "; busy=" << app->busy
<< "; foreground=" << (GetForegroundWindow() == host) << "\n";
require(text() == L"привет", "selection conversion through actual Ctrl+C / Ctrl+V");
require(ks::readClipboard(app->window).text() == sentinel, "clipboard restored");
app->transform(ks::Undo);
break;
case 2:
require(text() == L"ghbdtn", "verified undo");
prepare(L"hello world", true);
app->transform(ks::Case);
break;
case 3:
require(text() == L"Hello World", "case conversion");
app->transform(ks::Case);
break;
case 4:
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
prepare(L"OLDghbdtn", false);
record(L"ghbdtn");
app->transform(ks::Direct + ru);
break;
case 5:
std::cout << "Recent observed: " << ks::utf8(text()) << "\n";
require(text() == L"OLDпривет", "recent-input conversion preserves adjacent older text");
prepare(L"OLDabcdef", false);
record(L"ghbdtn");
diagnosticStart = diagnosticLog().size();
app->busy = true;
app->transform(ks::Direct + ru);
app->transform(ks::Direct + ru);
app->busy = false;
app->transform(ks::Direct + ru);
break;
case 6:
require(text() == L"OLDabcdef", "recent-input mismatch refuses replacement");
{
auto diagnostics = diagnosticLog();
diagnostics = diagnostics.substr(std::min(diagnosticStart, diagnostics.size()));
require(diagnostics.find("reason=busy") != std::string::npos,
"busy conversion attempt is logged");
require(diagnostics.find("repeat=3") != std::string::npos,
"three repeated conversion attempts are logged");
require(diagnostics.find("reason=recent-verification-mismatch") != std::string::npos,
"failed recent-word verification has explicit outcome");
require(diagnostics.find("copied=\"abcdef\"") != std::string::npos &&
diagnostics.find("physicalKeys=\"ghbdtn\"") != std::string::npos,
"conversion diagnostics contain actual text and physical input for reproduction");
}
app->settings.snippets = {{L";addr", L"LINE1\r\n{cursor}LINE2"}};
prepare(L"prefix ;addr", false);
record(L";addr");
app->transform(0, 0);
break;
case 7:
{
require(text() == L"prefix LINE1\r\nLINE2", "multiline snippet expansion");
DWORD start{}, end{};
SendMessageW(field, EM_GETSEL, (WPARAM)&start, (LPARAM)&end);
require(start == end && start == 14, "verified snippet cursor placement");
require(ks::readClipboard(app->window).text() == sentinel,
"clipboard restored after repeated copy transactions");
app->settings.autoCorrect = true;
app->settings.switchLayout = true;
app->settings.autoLanguages = {L"en", L"ru"};
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"ghbdtn ", false);
record(L"ghbdtn ");
app->transform(ks::AutoCorrect);
break;
}
case 8:
require(text() == L"привет ", "automatic dictionary correction in native editor");
require(ks::windowLayout(host) == app->layouts[ru].handle,
"auto-correction switches focused editor layout even when host ignores requests");
app->transform(ks::Undo);
break;
case 9:
require(text() == L"ghbdtn ", "automatic correction undo retains space");
require(ks::windowLayout(host) == app->layouts[en].handle,
"undo restores the original keyboard layout");
prepare(L"ghbdtn ", false);
record(L"ghbdtn ");
app->transform(ks::AutoCorrect);
break;
case 10:
require(text() == L"ghbdtn ", "undo prevents repeated automatic correction");
prepare(L"hello ", false);
record(L"hello ");
app->transform(ks::AutoCorrect);
break;
case 11:
require(text() == L"hello ", "known word stays unchanged in editor");
require(ks::readClipboard(app->window).text() == sentinel,
"automatic correction restores clipboard");
app->autoCorrector = ks::AutoCorrector{};
prepare(L"OLDghbdtn ", false);
record(L"ghbdtn ");
app->transform(ks::AutoCorrect);
break;
case 12:
require(text() == L"OLDghbdtn ", "automatic correction refuses partial word replacement");
DestroyWindow(field);
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_PASSWORD, 20, 20,
590, 30, host, nullptr, GetModuleHandleW(nullptr), nullptr);
prepare(L"ghbdtn ", false);
record(L"ghbdtn ");
app->transform(ks::AutoCorrect);
break;
case 13:
require(text() == L"ghbdtn ", "password input is never automatically corrected");
require(ks::readClipboard(app->window).text() == sentinel,
"password input never copied to clipboard");
DestroyWindow(field);
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER, 20, 20, 590, 30, host,
nullptr, GetModuleHandleW(nullptr), nullptr);
prepare(L"(ljvf ", false);
record(L"(ljvf ");
app->transform(ks::AutoCorrect);
break;
case 14:
require(text() == L"(дома ", "automatic correction preserves opening parenthesis in editor");
app->transform(ks::Undo);
break;
case 15:
require(text() == L"(ljvf ", "undo preserves opening parenthesis in editor");
require(ks::readClipboard(app->window).text() == sentinel,
"parenthesized correction restores clipboard");
app->settings.autoPrefixChars += L"#";
app->settings.autoSuffixChars += L"%";
app->autoCorrector = ks::AutoCorrector{};
prepare(punctuationCases[0].first.c_str(), false);
record(punctuationCases[0].first);
app->transform(ks::AutoCorrect);
break;
case 16:
require(text() == punctuationCases[punctuationCase].second,
"configured punctuation preserved in editor");
if (++punctuationCase < punctuationCases.size())
{
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(punctuationCases[punctuationCase].first.c_str(), false);
record(punctuationCases[punctuationCase].first);
app->transform(ks::AutoCorrect);
stage = 16;
}
else
app->transform(ks::Undo);
break;
case 17:
require(text() == punctuationCases.back().first, "undo preserves custom punctuation");
[[fallthrough]];
case 20:
if (!captured)
{
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
captured = true;
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
}
app->settings.autoCorrect = true;
app->settings.switchLayout = true;
app->settings.autoLanguages = {L"en", L"ru"};
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
app->autoCorrector = ks::AutoCorrector{};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"ghbdtn ", false);
record(L"ghbdtn ");
typeDuringCorrection = true;
app->transform(ks::AutoCorrect);
stage = 18;
break;
case 18:
require(typedDuringCorrection && text() == L"привет аА",
"typing during correction replays in order with shift and backspace in new layout");
require(ks::readClipboard(app->window).text() == sentinel,
"concurrent typing preserves clipboard restoration");
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"OLDghbdtn ", false);
record(L"ghbdtn ");
typedDuringCorrection = false;
typeDuringCorrection = true;
app->transform(ks::AutoCorrect);
break;
case 19:
require(typedDuringCorrection && text() == L"OLDghbdtn fF",
"rejected correction collapses selection before replaying typing in original layout");
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"", false);
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
{
std::vector<INPUT> inputs;
for (WORD key : std::wstring(L"GHBDTN F"))
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
{
auto input = ks::keyboardInput(key == L' ' ? VK_SPACE : key, flags);
input.ki.dwExtraInfo = 123;
inputs.push_back(input);
}
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
"continuous typing submitted in a single batch without a pause after Space");
}
stage = 21;
break;
case 21:
{
require(text() == L"привет а", "automatic correction preserves immediate next-word typing without a pause");
auto uk = std::find_if(app->layouts.begin(), app->layouts.end(), [](const auto &layout) {
return ks::layoutLanguage(layout) == L"uk";
});
require(uk != app->layouts.end(), "Ukrainian layout available for punctuation-key regression");
app->settings.autoLanguages = {L"en", L"uk"};
app->settings.cycle = {app->layouts[en].id, uk->id};
app->settings.autoWords[L"uk"] = {L"робить"};
app->autoCorrector = ks::AutoCorrector{};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"hj,bnm ", false);
record(L"hj,bnm ");
app->transform(ks::AutoCorrect);
stage = 22;
break;
}
case 22:
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
finish();
break;
}
}
catch (const std::exception &e)
{
failed = true;
std::cerr << "FAIL " << e.what() << '\n';
finish();
}
return 0;
}
if (msg == WM_CLOSE)
{
failed = true;
finish();
return 0;
}
return DefWindowProcW(w, msg, wp, lp);
}
} // namespace
int main(int argc, char **argv)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
auto root = std::filesystem::temp_directory_path() /
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
try
{
{
ks::App instance(GetModuleHandleW(nullptr), false, root);
app = &instance;
app->settings.history = false;
app->settings.switchLayout = false;
if (argc > 1 && std::string(argv[1]) == "--logs-only")
{
ks::showLogs(instance);
HWND viewer = instance.logWindow;
HWND rows = GetDlgItem(viewer, 602);
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
"log viewer uses report ListView");
require(Header_GetItemCount(ListView_GetHeader(rows)) == 4, "log viewer has four columns");
require(GetDlgItem(viewer, IDCANCEL) != rows, "log rows and Close have different IDs");
SetFocus(rows);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, EN_SETFOCUS), (LPARAM)rows);
NMHDR clicked{rows, 602, NM_CLICK};
SendMessageW(viewer, WM_NOTIFY, 602, (LPARAM)&clicked);
require(IsWindow(viewer), "focus and click notifications do not close log viewer");
ks::showLogs(instance);
require(instance.logWindow == viewer, "opening logs reuses the existing window");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 603));
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "refresh populates log rows");
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
require(IsWindow(viewer) && ListView_GetItemCount(rows) > 0, "previous log or empty state is displayed");
if (argc > 2 && std::string(argv[2]) == "--clear-test")
{
require(std::filesystem::path(ks::executable()).parent_path().filename() == L"log-clear-test",
"clear test runs in isolated executable directory");
const auto logRoot = ks::dataDirectory();
require(logRoot == std::filesystem::path(ks::executable()).parent_path() / L"data",
"clear test uses isolated portable data");
std::ofstream(logRoot / L"kswitcher.log") << "current-marker\n";
std::ofstream(logRoot / L"kswitcher.log.1") << "previous-marker\n";
ks::diagnosticEvent(L"buffered-before-clear");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606));
require(!std::filesystem::exists(logRoot / L"kswitcher.log.1") &&
ks::readDiagnosticLog(false).find(L"current-marker") != std::wstring::npos,
"Clear log removes selected previous log only");
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 0, 0);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606));
require(!std::filesystem::exists(logRoot / L"kswitcher.log") && IsWindow(viewer),
"Clear log clears current file without closing viewer");
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(606, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 606));
ks::diagnosticEvent(L"new-problem", true);
auto newLog = ks::readDiagnosticLog(false);
require(newLog.find(L"new-problem") != std::wstring::npos &&
newLog.find(L"buffered-before-clear") == std::wstring::npos,
"logging resumes after clear without stale context");
}
auto savedClipboard = ks::readClipboard(viewer);
instance.window = viewer;
try
{
ListView_SetItemState(rows, 0, LVIS_SELECTED, LVIS_SELECTED);
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(605, BN_CLICKED), (LPARAM)GetDlgItem(viewer, 605));
require(ks::readClipboard(viewer).text().find(L'\t') != std::wstring::npos,
"selected log row copies as tab-separated columns");
}
catch (...)
{
ks::writeClipboard(viewer, savedClipboard);
throw;
}
ks::writeClipboard(viewer, savedClipboard);
instance.window = nullptr;
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(IDCANCEL, BN_CLICKED), (LPARAM)GetDlgItem(viewer, IDCANCEL));
require(!IsWindow(viewer) && !instance.logWindow, "Close button closes and releases viewer");
CoUninitialize();
return 0;
}
for (int i = 0; i < (int)app->layouts.size(); ++i)
{
auto language = LOWORD((uintptr_t)app->layouts[i].handle);
if (language == 0x409)
en = i;
if (language == 0x419)
ru = i;
}
if (en < 0 || ru < 0)
throw std::runtime_error(
"Integration test requires installed English US and Russian layouts");
WNDCLASSW cls{};
cls.hInstance = GetModuleHandleW(nullptr);
cls.lpfnWndProc = procedure;
cls.lpszClassName = L"KSwitcher.TestEditor";
cls.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);
RegisterClassW(&cls);
host = CreateWindowW(cls.lpszClassName, L"KSwitcher — automated test editor",
WS_OVERLAPPEDWINDOW | WS_VISIBLE, 100, 100, 650, 320, nullptr, nullptr,
cls.hInstance, nullptr);
field =
CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER | ES_MULTILINE | ES_WANTRETURN,
20, 20, 590, 230, host, nullptr, cls.hInstance, nullptr);
deadline = GetTickCount64() + 30000;
if (argc > 1 && std::string(argv[1]) == "--typing-only")
stage = 20;
SetTimer(host, 1, 100, nullptr);
app->execute();
}
std::filesystem::remove_all(root);
}
catch (const std::exception &e)
{
std::cerr << e.what() << '\n';
failed = true;
}
CoUninitialize();
return failed ? 1 : 0;
}