Files
hrynco-kswitcher/tests/integration.cpp
T

382 lines
18 KiB
C++

#include "app.h"
#include <iostream>
#include <fstream>
#include <commctrl.h>
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;
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)
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)
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");
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;
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;
}