796 lines
40 KiB
C++
796 lines
40 KiB
C++
#include "app.h"
|
||
#include "diagnostics.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;
|
||
bool conversionOnly = false;
|
||
int pasteBehavior = 0;
|
||
int enterPresses = 0;
|
||
int copyFailuresRemaining = 0, copyRequests = 0;
|
||
int droppedSelectionKeys = 0;
|
||
bool temporaryClipboardExcluded = true;
|
||
bool clipboardAccessTimedOut = false;
|
||
LRESULT CALLBACK observeEnter(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR)
|
||
{
|
||
if (message == WM_KEYDOWN && wp == VK_LEFT && droppedSelectionKeys > 0 &&
|
||
(GetKeyState(VK_SHIFT) & 0x8000))
|
||
{
|
||
--droppedSelectionKeys;
|
||
return 0;
|
||
}
|
||
// This bare EDIT control is our test fixture, not a full editor with
|
||
// clipboard retries. Keep clipboard access on its owning UI thread while
|
||
// it handles Ctrl+C/Ctrl+V, so unrelated Windows clipboard listeners cannot
|
||
// make the fixture silently discard the operation under test.
|
||
struct ClipboardAccess
|
||
{
|
||
bool opened = false;
|
||
~ClipboardAccess() { if (opened) CloseClipboard(); }
|
||
} access;
|
||
if (message == WM_CHAR && (wp == 3 || wp == 22))
|
||
{
|
||
auto deadline = GetTickCount64() + 1000;
|
||
do
|
||
{
|
||
if (OpenClipboard(w)) { access.opened = true; break; }
|
||
Sleep(10);
|
||
} while (GetTickCount64() < deadline);
|
||
if (!access.opened)
|
||
{
|
||
clipboardAccessTimedOut = true;
|
||
return 0;
|
||
}
|
||
}
|
||
if ((message == WM_CHAR && wp == 3) || message == WM_PASTE)
|
||
{
|
||
temporaryClipboardExcluded &= IsClipboardFormatAvailable(
|
||
RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing")) != FALSE;
|
||
if (message == WM_CHAR)
|
||
{
|
||
++copyRequests;
|
||
// Native EDIT silently ignores Ctrl+C when OpenClipboard fails.
|
||
// Reproduce that outcome deterministically, without depending on
|
||
// the timing of the Windows clipboard-history service.
|
||
if (copyFailuresRemaining > 0)
|
||
{
|
||
--copyFailuresRemaining;
|
||
return 0;
|
||
}
|
||
}
|
||
}
|
||
if (message == WM_PASTE && pasteBehavior)
|
||
{
|
||
if (pasteBehavior == 2) SendMessageW(w, EM_REPLACESEL, TRUE, (LPARAM)L"other!");
|
||
return 0;
|
||
}
|
||
if (message == WM_KEYDOWN && wp == VK_RETURN) ++enterPresses;
|
||
return DefSubclassProc(w, message, wp, lp);
|
||
}
|
||
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 || clipboardAccessTimedOut)
|
||
{
|
||
wchar_t observed[2048]{};
|
||
GetWindowTextW(field, observed, 2048);
|
||
std::cerr << "Editor at failure: " << ks::utf8(observed)
|
||
<< "; foregroundSame=" << (GetForegroundWindow() == host)
|
||
<< "; inputVersion=" << app->inputVersion.load() << '\n';
|
||
if (clipboardAccessTimedOut)
|
||
throw std::runtime_error("Test editor could not acquire clipboard within 1000 ms");
|
||
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)
|
||
{
|
||
// Input tests must not start a network update check. Its close veto can
|
||
// otherwise leave a completed test running after the editor is gone.
|
||
if (app->window) KillTimer(app->window, 5);
|
||
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");
|
||
droppedSelectionKeys = 5;
|
||
app->transform(ks::Case);
|
||
break;
|
||
case 4:
|
||
require(text() == L"HELLO WORLD", "consecutive command reselects verified previous result");
|
||
require(droppedSelectionKeys == 0, "partial suffix selection recovered after dropped arrow keys");
|
||
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("\"outcome\":\"recent-verification-mismatch\"") != std::string::npos,
|
||
"failed recent-word verification has explicit outcome");
|
||
require(diagnostics.find("\"original\":\"abcdef\"") != std::string::npos &&
|
||
diagnostics.find("physicalKeys=\\\"ghbdtn\\\"") != std::string::npos,
|
||
"conversion diagnostics contain actual text and physical input for reproduction");
|
||
}
|
||
if (conversionOnly)
|
||
{
|
||
pasteBehavior = 1;
|
||
prepare(L"ghbdtn", true);
|
||
app->transform(ks::Direct + ru);
|
||
stage = 40;
|
||
break;
|
||
}
|
||
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);
|
||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||
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);
|
||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||
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");
|
||
stage = 20;
|
||
break;
|
||
case 40:
|
||
{
|
||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||
require(text() == L"ghbdtn" && rows.back().outcome == L"Original text remained",
|
||
"blocked paste records original text remaining in the actual editor");
|
||
require(ks::readClipboard(app->window).text() == sentinel, "readback preserves clipboard after blocked paste");
|
||
pasteBehavior = 2;
|
||
prepare(L"ghbdtn", true);
|
||
app->transform(ks::Direct + ru);
|
||
break;
|
||
}
|
||
case 41:
|
||
{
|
||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||
require(text() == L"other!" && rows.back().outcome == L"Readback differs",
|
||
"unexpected editor content records readback mismatch");
|
||
pasteBehavior = 0;
|
||
prepare(L"привет", true);
|
||
app->transform(ks::Direct + ru);
|
||
break;
|
||
}
|
||
case 42:
|
||
{
|
||
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
|
||
require(text() == L"привет" && rows.back().outcome == L"Conversion unchanged" && rows.back().problem,
|
||
"manual unchanged conversion is recorded as a problem");
|
||
diagnosticStart = diagnosticLog().size();
|
||
app->settings.writeLog = false;
|
||
ks::setLoggingEnabled(false);
|
||
prepare(L"ghbdtn", true);
|
||
app->transform(ks::Direct + ru);
|
||
break;
|
||
}
|
||
case 43:
|
||
require(text() == L"привет" && diagnosticLog().size() == diagnosticStart,
|
||
"disabled diagnostics preserves conversion and writes no records");
|
||
ks::setLoggingEnabled(true);
|
||
prepare(L"PuntoSwitcher ", true);
|
||
app->transform(ks::Direct + ru);
|
||
break;
|
||
case 44:
|
||
app->transform(ks::Direct + en);
|
||
break;
|
||
case 45:
|
||
require(text() == L"PuntoSwitcher ", "previous conversion restores CamelCase and trailing space");
|
||
droppedSelectionKeys = 5;
|
||
app->transform(ks::Direct + ru);
|
||
break;
|
||
case 46:
|
||
require(text() == ks::convertTo(L"PuntoSwitcher ", app->layouts, ru, en),
|
||
"repeat conversion recovers Switcher suffix and preserves full result");
|
||
require(droppedSelectionKeys == 0, "CamelCase suffix regression dropped five selection keys");
|
||
require(ks::readClipboard(app->window).text() == sentinel,
|
||
"suffix recovery restores original clipboard");
|
||
// Derive the actual text from the physical keys, preserving an older prefix.
|
||
prepare((L"OLD" + ks::convertTo(L"snipping ", app->layouts, ru, en)).c_str(), false);
|
||
record(L"snipping ");
|
||
droppedSelectionKeys = 4;
|
||
app->transform(ks::Direct + en);
|
||
break;
|
||
case 47:
|
||
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
|
||
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
|
||
finish();
|
||
break;
|
||
case 30:
|
||
case 32:
|
||
case 34:
|
||
case 37:
|
||
// Earlier password/word tests replace the original multiline
|
||
// editor with a single-line EDIT. Restore the snippet fixture:
|
||
// a single-line control cannot insert the tested Enter action.
|
||
if (!(GetWindowLongPtrW(field, GWL_STYLE) & ES_MULTILINE))
|
||
{
|
||
DestroyWindow(field);
|
||
field = CreateWindowW(L"EDIT", L"", WS_CHILD | WS_VISIBLE | WS_BORDER |
|
||
ES_MULTILINE | ES_WANTRETURN, 20, 20, 590, 230, host, nullptr,
|
||
GetModuleHandleW(nullptr), nullptr);
|
||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||
}
|
||
if (!captured)
|
||
{
|
||
saved = ks::readClipboard(app->window, 64 * 1024 * 1024);
|
||
captured = true;
|
||
}
|
||
app->ownSequence = ks::writeClipboard(app->window, ks::textClip(sentinel));
|
||
app->settings.autoCorrect = stage == 35;
|
||
app->settings.autoLanguages = {L"en", L"ru"};
|
||
app->settings.cycle = {app->layouts[en].id, app->layouts[ru].id};
|
||
app->settings.switchLayout = false;
|
||
app->autoCorrector = ks::AutoCorrector{};
|
||
app->autoPredictor = ks::AutoCorrector{};
|
||
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
|
||
app->settings.snippets = {{L";r", stage == 31 ? L"rider .{cursor}" : L"rider .{cursor}{enter}"}};
|
||
enterPresses = 0;
|
||
copyRequests = 0;
|
||
copyFailuresRemaining = stage == 38 ? 10 : stage == 33 ? 1 : 0;
|
||
temporaryClipboardExcluded = true;
|
||
ActivateKeyboardLayout(app->layouts[en].handle, 0);
|
||
prepare(L"prefix ", false);
|
||
{
|
||
std::vector<INPUT> inputs;
|
||
const std::vector<WORD> keys = stage == 35
|
||
? std::vector<WORD>{VK_OEM_1, 'R', 'G', 'H', 'B', 'D', 'T', 'N', VK_SPACE}
|
||
: std::vector<WORD>{VK_OEM_1, 'R', VK_SPACE, 'X'};
|
||
for (WORD key : keys)
|
||
for (DWORD flags : {DWORD(0), DWORD(KEYEVENTF_KEYUP)})
|
||
{
|
||
auto input = ks::keyboardInput(key, flags);
|
||
input.ki.dwExtraInfo = 123;
|
||
inputs.push_back(input);
|
||
}
|
||
require(SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)) == inputs.size(),
|
||
"snippet trigger and following text submitted without a pause");
|
||
}
|
||
break;
|
||
case 31:
|
||
require(text() == L"prefix rider . x", "snippet expands through keyboard hook and preserves following typing");
|
||
require(ks::readClipboard(app->window).text() == sentinel, "hook snippet restores clipboard");
|
||
require(enterPresses == 0, "snippet without Enter marker sends no Enter");
|
||
stage = 32;
|
||
break;
|
||
case 33:
|
||
require(text() == L"prefix rider .\r\n x", "Enter action precedes buffered typing");
|
||
require(copyRequests == 2 && copyFailuresRemaining == 0,
|
||
"snippet retries a silently dropped copy without duplicating input");
|
||
require(temporaryClipboardExcluded,
|
||
"temporary snippet clipboard contents are excluded from Windows history");
|
||
require(enterPresses == 1, "Enter marker sends an actual Enter key press");
|
||
require(ks::readClipboard(app->window).text() == sentinel, "Enter snippet restores clipboard");
|
||
stage = 34;
|
||
break;
|
||
case 35:
|
||
require(text() == L"prefix rider .\r\nghbdtn ", "snippet replay does not trigger automatic correction");
|
||
require(app->settings.autoCorrect, "snippet preserves global auto-correction setting");
|
||
require(enterPresses == 1, "snippet punctuation and Enter survive with auto-correction enabled");
|
||
{
|
||
std::vector<INPUT> inputs;
|
||
for (WORD key : std::wstring(L"GHBDTN "))
|
||
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(),
|
||
"normal typing submitted after snippet completes");
|
||
}
|
||
break;
|
||
case 36:
|
||
require(text() == L"prefix rider .\r\nghbdtn привет ", "automatic correction resumes for subsequent normal typing");
|
||
stage = 37;
|
||
break;
|
||
case 38:
|
||
require(copyRequests == 3, "copy retries are bounded to three attempts");
|
||
require(text() == L"prefix ;r x" && enterPresses == 0,
|
||
"exhausted copy retries preserve trigger and following input without Enter");
|
||
require(ks::readClipboard(app->window).text() == sentinel,
|
||
"exhausted copy retries restore the clipboard");
|
||
copyFailuresRemaining = 0;
|
||
finish();
|
||
break;
|
||
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");
|
||
stage = 30;
|
||
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)
|
||
{
|
||
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
|
||
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);
|
||
if (argc > 2 && std::string(argv[2]) == "--preview")
|
||
{
|
||
MSG message{};
|
||
while (IsWindow(viewer) && GetMessageW(&message, nullptr, 0, 0) > 0)
|
||
{
|
||
TranslateMessage(&message); DispatchMessageW(&message);
|
||
}
|
||
CoUninitialize();
|
||
return 0;
|
||
}
|
||
require(rows && (GetWindowLongPtrW(rows, GWL_STYLE) & LVS_TYPEMASK) == LVS_REPORT,
|
||
"log viewer uses report ListView");
|
||
require(Header_GetItemCount(ListView_GetHeader(rows)) == 6, "log viewer has six sortable columns");
|
||
require(GetDlgItem(viewer, 607) && GetDlgItem(viewer, 608) && GetDlgItem(viewer, 609) &&
|
||
GetDlgItem(viewer, 610) && GetDlgItem(viewer, 611),
|
||
"log viewer exposes program, command, result, problems filter and detail panel");
|
||
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), "refresh keeps log viewer open");
|
||
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 1, 0);
|
||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(601, CBN_SELCHANGE), (LPARAM)GetDlgItem(viewer, 601));
|
||
require(IsWindow(viewer) && GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0,
|
||
"previous log or explicit 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");
|
||
ks::DiagnosticJson operation{{"attempt", "1"}, {"process", "alpha.exe"},
|
||
{"command", "Convert / cycle"}, {"source", "English"}, {"target", "Ukrainian"},
|
||
{"outcome", "Paste verified"}, {"problem", false}, {"original", "ghbdsn"},
|
||
{"expected", ks::utf8(L"привіт")}, {"actual", ks::utf8(L"привіт")}};
|
||
ks::clearDiagnosticLog(false);
|
||
const auto successfulOperation = operation;
|
||
for (int attempt = 0; attempt < 1000; ++attempt)
|
||
ks::diagnosticOperation(operation.dump());
|
||
require(!std::filesystem::exists(logRoot / L"kswitcher.log"),
|
||
"one thousand successful operations create no log file");
|
||
operation["attempt"] = "2"; operation["process"] = "beta.exe";
|
||
operation["outcome"] = "Original text remained"; operation["problem"] = true;
|
||
operation["actual"] = operation["original"];
|
||
ks::diagnosticOperation(operation.dump());
|
||
auto problems = ks::parseOperationLog(ks::readDiagnosticLog(false));
|
||
require(problems.size() == 1 && problems[0].process == L"beta.exe" &&
|
||
problems[0].details.find(L"Completed operation") != std::wstring::npos &&
|
||
problems[0].details.find(L"Paste verified") != std::wstring::npos,
|
||
"failure writes one row with bounded preceding successful context");
|
||
// Pre-existing successful records remain viewable and filterable.
|
||
ks::log(L"Operation " + ks::wide(successfulOperation.dump()));
|
||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
|
||
require(ListView_GetItemCount(rows) == 2, "one visible row per recorded operation");
|
||
CheckDlgButton(viewer, 610, BST_CHECKED);
|
||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(610, BN_CLICKED), 0);
|
||
require(ListView_GetItemCount(rows) == 1, "only problems hides verified operations");
|
||
SendDlgItemMessageW(viewer, 607, CB_SETCURSEL, 1, 0);
|
||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(607, CBN_SELCHANGE), 0);
|
||
require(ListView_GetItemCount(rows) == 0, "program filter combines with problems filter");
|
||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(612, BN_CLICKED), 0);
|
||
require(ListView_GetItemCount(rows) == 2, "reset restores all operation rows");
|
||
NMLISTVIEW sort{}; sort.hdr = {rows, 602, LVN_COLUMNCLICK}; sort.iSubItem = 1;
|
||
SendMessageW(viewer, WM_NOTIFY, 602, (LPARAM)&sort);
|
||
wchar_t firstProgram[64]{};
|
||
ListView_GetItemText(rows, 0, 1, firstProgram, 64);
|
||
require(std::wstring(firstProgram) == L"alpha.exe", "clicking program column sorts rows");
|
||
require(GetWindowTextLengthW(GetDlgItem(viewer, 611)) > 0, "selected operation populates detail panel");
|
||
}
|
||
SendDlgItemMessageW(viewer, 601, CB_SETCURSEL, 0, 0);
|
||
SendMessageW(viewer, WM_COMMAND, MAKEWPARAM(603, BN_CLICKED), 0);
|
||
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;
|
||
SetWindowSubclass(field, observeEnter, 1, 0);
|
||
if (argc > 1 && std::string(argv[1]) == "--typing-only")
|
||
stage = 20;
|
||
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
|
||
stage = 30;
|
||
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;
|
||
}
|