Files
hrynco-kswitcher/tests/tests.cpp
T

630 lines
40 KiB
C++
Raw 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 "autocorrect.h"
#include "ocr.h"
#include "richtext.h"
#include "storage.h"
#include <algorithm>
#include <iostream>
#include <map>
#include <richedit.h>
namespace
{
int passed = 0;
void require(bool ok, const char *name)
{
if (!ok)
throw std::runtime_error(name);
++passed;
}
void rejects(const std::function<void()> &f, const char *name)
{
bool thrown = false;
try
{
f();
}
catch (...)
{
thrown = true;
}
require(thrown, name);
}
} // namespace
int main(int argc, char **argv)
{
if (argc > 1 && std::string(argv[1]) == "--hidden-start-window")
{
HWND w = CreateWindowExW(0, L"STATIC", L"KSwitcher visibility regression", WS_POPUP, 0, 0, 1, 1,
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
ks::showRequestedWindow(w);
bool visible = w && IsWindowVisible(w);
if (w)
DestroyWindow(w);
return visible ? 0 : 1;
}
try
{
require(ks::diagnosticText(L"word") == L"\"word\" units=4 truncated=0",
"diagnostic word preserved");
require(ks::diagnosticText(L"\"\\\n\r\t") ==
L"\"\\\"\\\\\\u000a\\u000d\\u0009\" units=5 truncated=0",
"diagnostic control characters cannot create log lines");
require(ks::diagnosticText(L"\u0457\u200b") == L"\"\\u0457\\u200b\" units=2 truncated=0",
"diagnostic unicode and invisible characters preserved exactly");
require(ks::diagnosticText(std::wstring(513, L'a')).find(L"units=513 truncated=1") != std::wstring::npos,
"large diagnostic selections are explicitly truncated");
STARTUPINFOW startup{sizeof(startup)};
ks::DiagnosticBuffer diagnostics;
require(diagnostics.record(L"successful-attempt", false, 100).empty(),
"successful diagnostics stay in memory");
auto incident = diagnostics.record(L"failed-attempt", true, 200);
require(incident.find(L"successful-attempt") != std::wstring::npos &&
incident.find(L"failed-attempt") != std::wstring::npos,
"incident includes preceding successful context");
require(diagnostics.record(L"next-failure", true, 300).find(L"successful-attempt") == std::wstring::npos,
"flushed context is not duplicated");
diagnostics.record(L"expired-context", false, 400);
require(diagnostics.record(L"later-failure", true, 30500).find(L"expired-context") == std::wstring::npos,
"context expires after thirty seconds");
diagnostics.record(L"oldest-context", false, 30600);
for (int i = 0; i < 40; ++i)
diagnostics.record(L"bounded-context", false, 30600 + i);
require(diagnostics.record(L"bounded-failure", true, 30700).find(L"oldest-context") == std::wstring::npos,
"context buffer has a fixed size");
diagnostics.record(L"disabled-context", false, 30800);
diagnostics.clear();
require(diagnostics.record(L"new-failure", true, 30900).find(L"disabled-context") == std::wstring::npos,
"clearing diagnostics discards sensitive context");
startup.dwFlags = STARTF_USESHOWWINDOW;
startup.wShowWindow = SW_HIDE;
PROCESS_INFORMATION child{};
std::wstring command = L"\"" + ks::executable() + L"\" --hidden-start-window";
require(CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr,
&startup, &child),
"start visibility test with hidden startup state");
DWORD wait = WaitForSingleObject(child.hProcess, 10000), exitCode = 1;
GetExitCodeProcess(child.hProcess, &exitCode);
CloseHandle(child.hThread);
CloseHandle(child.hProcess);
require(wait == WAIT_OBJECT_0 && exitCode == 0,
"first explicit window request is visible after hidden process launch");
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
HWND richParent = CreateWindowExW(0, L"STATIC", L"Rich preview test", WS_POPUP, 0, 0, 300, 200,
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
HWND rich = ks::createRichText(richParent, 1, false);
require(rich != nullptr, "native RichEdit loads without a browser runtime");
auto styled = ks::textClip(L"plain bold italic under\nnext");
std::string html = "Version:1.0\r\n<html><!--StartFragment--><div>plain <strong>bold</strong> "
"<em>italic</em> <u>under</u><br>next</div><!--EndFragment--></html>";
styled.formats.push_back({0, L"HTML Format", ks::Bytes(html.begin(), html.end())});
require(ks::setRichText(rich, ks::richPreview(styled)),
"HTML clipboard preview streams into RichEdit");
auto effects = [&](LONG first, LONG last)
{
CHARRANGE range{first, last};
SendMessageW(rich, EM_EXSETSEL, 0, (LPARAM)&range);
CHARFORMAT2W format{};
format.cbSize = sizeof(format);
SendMessageW(rich, EM_GETCHARFORMAT, SCF_SELECTION, (LPARAM)&format);
return format.dwEffects;
};
require((effects(6, 10) & CFE_BOLD) != 0, "HTML bold is displayed as native bold");
require((effects(11, 17) & CFE_ITALIC) != 0, "HTML italic is displayed as native italic");
require((effects(18, 23) & CFE_UNDERLINE) != 0, "HTML underline is displayed natively");
require((effects(0, 5) & (CFE_BOLD | CFE_ITALIC | CFE_UNDERLINE)) == 0,
"inline formatting does not leak into plain text");
wchar_t rendered[256]{};
GetWindowTextW(rich, rendered, 256);
require(std::wstring(rendered).find(L"\r\nnext") != std::wstring::npos ||
std::wstring(rendered).find(L"\rnext") != std::wstring::npos,
"HTML line breaks are retained");
html = "<!--StartFragment--><span style='font-weight:700'>Привіт &amp; &#x1F30D;</span>"
"<script>never displayed</script><!--EndFragment-->";
styled.formats.back().data = ks::Bytes(html.begin(), html.end());
ks::setRichText(rich, ks::richPreview(styled));
GetWindowTextW(rich, rendered, 256);
require(std::wstring(rendered) == L"Привіт & 🌍",
"HTML entities and Unicode survive without script text");
require((effects(0, 6) & CFE_BOLD) != 0, "inline CSS bold is rendered");
auto plain = ks::textClip(L"{\\literal} Привіт");
ks::setRichText(rich, ks::richPreview(plain));
GetWindowTextW(rich, rendered, 256);
require(std::wstring(rendered) == plain.text(), "plain Unicode and RTF metacharacters are escaped");
std::string rtf = "{\\rtf1\\ansi \\b native}";
plain.formats.push_back({0, L"Rich Text Format", ks::Bytes(rtf.begin(), rtf.end())});
ks::setRichText(rich, ks::richPreview(plain));
require((effects(0, 6) & CFE_BOLD) != 0, "original RTF formatting is preserved");
DestroyWindow(richParent);
ks::Layout en = ks::physicalLayout();
en.id = L"en";
ks::Layout ru{nullptr, L"ru", L"Russian", L"ё1234567890-=йцукенгшщзхъфывапролджэячсмитьбю.\\",
L"Ё!\"№;%:?*()_+ЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,/"};
ks::Layout uk{nullptr, L"uk", L"Ukrainian", L"'1234567890-=йцукенгшщзхїфівапролджєячсмитьбю.\\",
L"₴!\"№;%:?*()_+ЙЦУКЕНГШЩЗХЇФІВАПРОЛДЖЄЯЧСМИТЬБЮ,/"};
require(en.normal.size() == ru.normal.size(), "physical maps align");
require(en.normal.size() == uk.normal.size(), "Ukrainian maps align");
require(ks::convert(L"ghbdtn", en, ru) == L"привет", "English to Russian");
require(ks::convert(L"привіт", uk, en) == L"ghbdsn", "Ukrainian to English");
require(ks::convert(L"hello привет", en, ru) == L"руддщ привет", "mixed text preserves target words");
require(ks::convert(L"htdm.dftim", en, ru) == L"ревьюваешь", "punctuation in words");
std::vector<ks::Layout> layouts{en, ru, uk};
ks::AutoCorrector corrector;
ks::Settings autoSettings;
require(!autoSettings.autoCorrect, "auto-correction defaults off");
autoSettings.cycle = {L"en", L"uk"};
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
auto suggested = corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings);
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
"dictionary corrects Ukrainian and retains trailing space");
suggested = corrector.suggest(L"lsv ", 0, layouts, autoSettings);
require(suggested && suggested->text == L"дім " && suggested->target == 2,
"three-letter Ukrainian word is corrected");
autoSettings.autoMinLength = 4;
require(!corrector.suggest(L"lsv ", 0, layouts, autoSettings),
"configured minimum excludes short words");
require(!corrector.suggest(L"(lsv); ", 0, layouts, autoSettings),
"punctuation does not count towards minimum length");
autoSettings.autoMinLength = 3;
require(!corrector.suggest(L"the ", 0, layouts, autoSettings),
"known three-letter English word stays unchanged");
require(!corrector.suggest(L"ls ", 0, layouts, autoSettings),
"two-letter input remains below automatic correction minimum");
suggested = corrector.suggest(L"ljvfiyz ", 0, layouts, autoSettings);
require(suggested && suggested->text == L"домашня " && suggested->target == 2,
"less frequent Ukrainian inflections are eligible for correction");
require(autoSettings.autoMaxRank == 0, "default frequency limit includes entire dictionary");
autoSettings.autoMaxRank = 21944;
require(!corrector.suggest(L"ljvfiyz ", 0, layouts, autoSettings), "rank above limit is excluded");
autoSettings.autoMaxRank = 21945;
require(corrector.suggest(L"ljvfiyz ", 0, layouts, autoSettings).has_value(),
"rank limit is inclusive");
autoSettings.autoMaxRank = 1;
require(!corrector.suggest(L"hello ", 0, layouts, autoSettings),
"rank limit never disables known-word protection");
autoSettings.autoMaxRank = 0;
suggested = corrector.suggest(L"(ljvf ", 0, layouts, autoSettings);
require(suggested && suggested->text == L"(дома ", "opening parenthesis is preserved");
for (auto pair : {std::pair{L"(ljvf) ", L"(дома) "},
{L"[ljvf] ", L"[дома] "},
{L"{ljvf} ", L"{дома} "},
{L"«ljvf» ", L"«дома» "},
{L"“ljvf” ", L"“дома” "},
{L"ljvf) ", L"дома) "},
{L"([{ljvf}]), ", L"([{дома}]), "}})
{
suggested = corrector.suggest(pair.first, 0, layouts, autoSettings);
require(suggested && suggested->text == pair.second, "paired and closing delimiters preserved");
}
require(ks::autoClosingSelection(L")] "), "closing-only selection can be extended");
require(!ks::autoClosingSelection(L"word) ") && !ks::autoClosingSelection(L"@ "),
"selection extension excludes words and arbitrary punctuation");
require(!corrector.suggest(L"user@(ljvf) ", 0, layouts, autoSettings),
"wrapped email suffix is not a word");
autoSettings.autoPrefixChars += L"#";
autoSettings.autoSuffixChars += L"%";
suggested = corrector.suggest(L"#ljvf%; ", 0, layouts, autoSettings);
require(suggested && suggested->text == L"#дома%; ", "custom delimiters are preserved");
require(ks::autoClosingSelection(L"%; ", autoSettings), "custom closing selection is supported");
autoSettings.autoPrefixChars.clear();
require(!corrector.suggest(L"(ljvf ", 0, layouts, autoSettings),
"removed prefix delimiter is respected");
autoSettings.autoSuffixChars.clear();
require(!corrector.suggest(L"ljvf; ", 0, layouts, autoSettings),
"removed suffix delimiter is respected");
autoSettings.autoPrefixChars = ks::Settings{}.autoPrefixChars;
autoSettings.autoSuffixChars = ks::Settings{}.autoSuffixChars;
ks::Run parenthesized;
parenthesized.keys = L"(ljvf ";
require(ks::matchAutoInput(parenthesized, L"ljvf ", layouts, en) == 0,
"editor selection may omit the opening parenthesis");
require(ks::matchAutoInput(parenthesized, L"(ljvf ", layouts, en) == 0,
"editor selection may include the opening parenthesis");
parenthesized.keys = L"OLDljvf ";
require(ks::matchAutoInput(parenthesized, L"ljvf ", layouts, en) < 0,
"automatic verification never accepts an arbitrary word suffix");
suggested = corrector.suggest(L"Ghbdsn! ", 0, layouts, autoSettings);
require(suggested && suggested->text == L"Привіт! ", "title case and punctuation retained");
suggested = corrector.suggest(L"руддщ ", 2, layouts, autoSettings);
require(suggested && suggested->text == L"hello ", "dictionary corrects English");
for (auto word : {L"hello ", L"привіт ", L"GHBDSN ", L"ghBdsn ", L"ghbdsn123 ", L"user@ghbdsn ",
L"https://ghbdsn ", L"gh ", L"qzxqzxqzx ", L"oldghbdsn "})
require(!corrector.suggest(word, 0, layouts, autoSettings), "unsafe or known word unchanged");
autoSettings.autoNever = {L"ghbdsn"};
require(!corrector.suggest(L"Ghbdsn ", 0, layouts, autoSettings), "personal exception ignores case");
autoSettings.autoNever.clear();
autoSettings.cycle = {L"en"};
require(!corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings), "disabled target layout excluded");
autoSettings.cycle = {L"en", L"uk"};
corrector.reject(L"Ghbdsn; ");
require(!corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings),
"undo rejection survives punctuation");
require(corrector.memoryBytes() < 3 * 1024 * 1024, "two dictionaries fit within 3 MiB");
require(!ks::autoCorrectionAllowed(nullptr, autoSettings), "missing focused editor fails closed");
ks::AutoCorrector missing;
{
ks::AutoCorrector supplemental;
auto options = autoSettings;
options.autoLanguages = {L"en", L"uk", L"ru"};
options.cycle = {L"en", L"uk", L"ru"};
supplemental.configure(L"dictionaries", options.autoLanguages);
for (auto [typed, expected] : {std::pair{L"ntcn ", L"тест "}, std::pair{L"vfkj ", L"мало "}})
{
auto shared = supplemental.suggest(typed, 0, layouts, options);
require(shared && shared->text == expected && shared->target == 2,
"identical Russian and Ukrainian spelling uses preferred Ukrainian layout");
}
require(!supplemental.suggest(L"lsv ", 0, layouts, options),
"different dictionary results remain ambiguous");
auto release = supplemental.suggest(L"релыз ", 1, layouts, options);
require(release && release->text == L"реліз " && release->target == 2,
"supplemental Ukrainian release word corrects from Russian layout");
require(!supplemental.suggest(L"реліз ", 2, layouts, options),
"correct Ukrainian release word stays unchanged");
require(!supplemental.suggest(L"релиз ", 1, layouts, options),
"valid Russian release word stays unchanged");
options.autoMaxRank = 50000;
require(!supplemental.suggest(L"релыз ", 1, layouts, options),
"supplemental vocabulary respects explicit frequency limit");
options.autoWords[L"uk"] = {L"релізик"};
options.autoMaxRank = 1;
auto custom = supplemental.suggest(L"релызик ", 1, layouts, options);
require(custom && custom->text == L"релізик " && custom->target == 2,
"personal word enables correction without editing dictionary files");
require(!supplemental.suggest(L"релізик ", 2, layouts, options),
"personal word is protected from correction");
options.autoNever = {L"релызик"};
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
"never-correct list takes precedence over personal word");
options.autoNever.clear();
options.autoWords[L"uk"].push_back(L"робить");
auto commaWord = supplemental.suggest(L"hj,bnm ", 0, layouts, options);
require(commaWord && commaWord->text == L"робить " && commaWord->target == 2,
"personal Ukrainian word accepts a punctuation key that maps to a letter");
require(!supplemental.suggest(L"hj,bnm123 ", 0, layouts, options),
"punctuation-key correction still rejects digits");
options.autoMinLength = 1;
require(!supplemental.suggest(L"ye ", 0, layouts, options),
"known English ye remains protected at minimum length one");
options.cycle = {L"en", L"ru"};
require(!supplemental.suggest(L"релызик ", 1, layouts, options),
"personal dictionary respects disabled target layouts");
}
rejects([&] { missing.configure(L"missing-dictionaries", {L"en"}); }, "missing dictionary rejected");
require(ks::convertTo(L"ghbdtn", layouts, 1, 0) == L"привет", "hint conversion");
require(ks::convertTo(L"привет", layouts, 1, 1) == L"привет", "same source is unchanged");
require(ks::convertTo(L"?", layouts, 1, 0) == L",", "punctuation conversion");
require(ks::cycleCase(L"hello world") == L"Hello World", "title case");
require(ks::cycleCase(L"Hello World") == L"HELLO WORLD", "upper case");
require(ks::cycleCase(L"ПРИВІТ") == L"привіт", "Unicode lower case");
require(ks::transliterate(L"Привет") == L"Privet", "forward transliteration");
require(ks::transliterate(L"Київ") == L"Kyiv", "Ukrainian transliteration");
require(ks::transliterate(L"Shchuka") == L"Щука", "reverse transliteration");
require(ks::parseTemplate(L"a{cursor}b").suffix == L"b", "snippet suffix");
require(ks::parseTemplate(L"{{cursor}}").text == L"{cursor}", "escaped cursor");
require(ks::parseTemplate(L"rider .{enter}").text == L"rider ." &&
ks::parseTemplate(L"rider .{enter}").enters == 1, "Enter is an action, not inserted text");
require(ks::parseTemplate(L"a{cursor}b{enter}{enter}").suffix == L"b" &&
ks::parseTemplate(L"a{cursor}b{enter}{enter}").enters == 2, "Enter actions preserve cursor suffix");
require(ks::parseTemplate(L"{{enter}}").text == L"{enter}" &&
ks::parseTemplate(L"{{enter}}").enters == 0, "escaped Enter is literal");
require(ks::parseTemplate(L"{cursor}{cursor}").cursors == 2, "duplicate cursor detection");
require(ks::Hotkey::parse(L"Ctrl+Alt+Shift+Space")->key == VK_SPACE, "hotkey parse");
require(!ks::Hotkey::parse(L"Ctrl+F25"), "invalid function key");
require(!ks::Hotkey::parse(L"Ctrl+A+B"), "multiple keys rejected");
require(ks::Hotkey::parse(L"")->key == 0, "disabled shortcut");
ks::HotkeyHook hook;
std::vector<std::pair<int, ks::Hotkey>> bindings{
{5, {MOD_WIN, 'V'}}, {1, {MOD_ALT, VK_F12}}, {100, {MOD_CONTROL | MOD_SHIFT, 'U'}}};
require(hook.update('V', true, MOD_WIN, bindings) == 5, "Win+V dispatches configured action");
require(hook.update('V', true, MOD_WIN, bindings) == -1, "held hotkey does not repeat");
require(hook.update('V', false, 0, {}) == -1, "key-up consumed after settings change");
require(hook.update('V', true, MOD_CONTROL, bindings) == 0, "unassigned Ctrl+V unaffected");
require(hook.update('V', true, MOD_WIN | MOD_SHIFT, bindings) == 0, "modifiers match exactly");
require(hook.update('V', true, MOD_WIN, {}) == 0, "disabled binding leaves OS shortcut intact");
require(hook.update(VK_F12, true, MOD_ALT, bindings) == 1, "conversion uses same hook");
require(hook.update('U', true, MOD_CONTROL | MOD_SHIFT, bindings) == 100,
"direct layout uses same hook");
require(hook.update(VK_F12, false, 0, bindings) == -1, "independent key release consumed");
require(hook.update('U', false, 0, bindings) == -1, "second key release consumed");
require(hook.update('V', true, MOD_WIN, bindings) == 5, "hotkey activates again after release");
bindings.push_back({2, {MOD_WIN | MOD_SHIFT, 'S'}});
require(hook.update('S', true, MOD_WIN | MOD_SHIFT, bindings) == 2,
"Win+Shift+S can replace the Snipping Tool shortcut");
require(hook.update('S', false, 0, bindings) == -1, "Snipping Tool shortcut release consumed");
for (UINT key :
{UINT('K'), UINT(VK_F12), UINT(VK_SPACE), UINT(VK_OEM_PLUS), UINT(VK_LEFT), UINT(VK_NUMPAD7)})
{
ks::Hotkey recorded{MOD_CONTROL | MOD_ALT | MOD_SHIFT, key};
require(ks::Hotkey::parse(recorded.format()) == recorded, "recorded shortcut roundtrip");
}
ks::Run run;
run.record(L'g', (HWND)1, (HKL)1, 1);
run.record(L'h', (HWND)1, (HKL)1, 2);
require(run.match(L"пр", layouts, en) == 1, "recent input verification");
require(run.match(L"xx", layouts, en) == -1, "unrelated selected text rejected");
require(run.match(L"xпр", layouts, en) == -1,
"word selection including older text is rejected before replacement");
require(run.match(L"р", layouts, en) == -1,
"coalesced Teams selection of only the final character is rejected");
require(run.match(L"пр ", layouts, en) == -1,
"word selection including unrecorded whitespace is rejected");
run.record(L'b', (HWND)1, (HKL)1, 2002);
require(run.keys == L"b", "pause resets run");
run.record(L'c', (HWND)2, (HKL)1, 2003);
require(run.keys == L"c", "window resets run");
run.record(L'd', (HWND)2, (HKL)2, 2004);
require(run.keys == L"d", "layout resets run");
run.record(L' ', (HWND)2, (HKL)2, 2005);
run.record(L'e', (HWND)2, (HKL)2, 2006);
require(run.keys == L"e", "word boundary resets run");
for (int i = 0; i < 513; ++i)
run.record(L'x', (HWND)2, (HKL)2, 2007 + i);
require(run.overflow && run.keys.empty(), "overflow does not select partial input");
std::map<DWORD, std::pair<DWORD, std::wstring>> processes{
{10, {1, L"ms-teams.exe"}}, {20, {10, L"msedgewebview2.exe"}},
{30, {20, L"msedgewebview2.exe"}}, {40, {1, L"msedgewebview2.exe"}},
{50, {10, L"other.exe"}}, {60, {60, L"msedgewebview2.exe"}}};
auto read = [&](DWORD id) -> std::optional<std::pair<DWORD, std::wstring>>
{
auto it = processes.find(id);
return it == processes.end() ? std::nullopt : std::optional(it->second);
};
require(ks::webViewDescendant(30, 10, read), "Teams renderer belongs to host");
require(!ks::webViewDescendant(40, 10, read), "other WebView rejected");
require(!ks::webViewDescendant(50, 10, read), "arbitrary child rejected");
require(!ks::webViewDescendant(60, 10, read), "process cycles rejected");
require(!ks::webViewDescendant(0, 0, read), "unknown process rejected");
auto clip = ks::textClip(L"Привіт 🌍\r\nline 2");
clip.formats.push_back({0, L"HTML Format", {1, 2, 3, 0}});
auto encoded = ks::encodeClip(clip);
require(ks::decodeClip(encoded).text() == clip.text(), "clipboard Unicode roundtrip");
require(ks::decodeClip(encoded).formats[1].name == L"HTML Format", "registered format identity");
auto encrypted = ks::protect(encoded);
require(encrypted != encoded, "history ciphertext differs");
require(ks::unprotect(encrypted) == encoded, "Windows DPAPI roundtrip");
encrypted[encrypted.size() / 2] ^= 0x40;
rejects([&] { ks::unprotect(encrypted); }, "tampering rejected");
encoded.pop_back();
rejects([&] { ks::decodeClip(encoded); }, "truncated clip rejected");
auto temp = std::filesystem::temp_directory_path() /
(L"KSwitcher-tests-" + std::to_wstring(GetCurrentProcessId()));
std::filesystem::create_directories(temp);
{
auto folder = temp / L"dictionaries";
std::filesystem::create_directories(folder);
auto put = [&](const wchar_t *name, const std::wstring &text)
{
auto data = ks::utf8(text);
ks::atomicWrite(folder / name, ks::Bytes(data.begin(), data.end()));
};
put(L"en.txt", L"zoo\t3\nhello\t99\nhello\t1\n");
put(L"uk.txt", L"дім\t3\n");
ks::AutoCorrector edited;
edited.configure(folder, {L"en", L"uk"});
auto options = ks::Settings{};
options.cycle = {L"en", L"uk"};
options.autoMaxRank = 1;
auto correction = edited.suggest(L"руддщ ", 2, layouts, options);
require(correction && correction->text == L"hello ",
"unsorted dictionaries and duplicates load using lowest duplicate rank");
}
ks::Pixels pixels{2, 1, {10, 20, 30, 255, 40, 50, 60, 255}};
ks::Clip firstImage{{ks::imageDib(pixels), {0, L"Ole Private Data", {1, 2, 3}}}};
auto thumbnail = ks::decodeImage(firstImage, 1, 1);
require(thumbnail.width == 1 && thumbnail.height == 1 && thumbnail.bgra.size() == 4,
"thumbnail decoding limits the resident pixel buffer");
thumbnail = ks::decodeImage(firstImage, 208, 128);
require(thumbnail.width == 2 && thumbnail.height == 1,
"thumbnail decoding does not upscale small images");
auto secondImage = firstImage;
secondImage.formats[1].data = {4, 5, 6};
secondImage.formats.push_back({0, L"DataObject", {9, 8, 7}});
require(ks::sameClipboardImage(firstImage, secondImage), "OLE metadata does not duplicate an image");
std::reverse(secondImage.formats.begin(), secondImage.formats.end());
require(ks::sameClipboardImage(firstImage, secondImage),
"format ordering does not duplicate an image");
auto recoded = firstImage;
recoded.formats[0].data[24] = 1; // Different bitmap resolution metadata; identical pixels.
require(ks::sameClipboardImage(firstImage, recoded), "decoded image ignores resolution metadata");
pixels.bgra[0]++;
ks::Clip changedImage{{ks::imageDib(pixels)}};
require(!ks::sameClipboardImage(firstImage, changedImage),
"a changed pixel remains a distinct image");
auto withText = firstImage;
withText.formats.push_back(ks::textClip(L"caption").formats.front());
require(!ks::sameClipboardImage(firstImage, withText),
"image deduplication preserves accompanying text");
auto withFiles = firstImage;
withFiles.formats.push_back({CF_HDROP, L"", {1}});
require(!ks::sameClipboardImage(firstImage, withFiles), "image deduplication preserves file copies");
{
ks::History history(temp / L"images");
ks::Settings defaults;
history.add(firstImage, L"SnippingTool", defaults);
history.pin(history.entries().front().id);
history.add(secondImage, L"SnippingTool", defaults);
require(history.entries().size() == 1 && history.entries().front().pinned,
"Snipping Tool republish retains one pinned history entry");
history.add(changedImage, L"SnippingTool", defaults);
require(history.entries().size() == 2, "a new screenshot is retained");
}
ks::Settings settings;
settings.writeLog = false;
settings.elevatedAutostart = true;
settings.autoCorrect = true;
settings.autoMaxRank = 10000;
settings.autoMinLength = 5;
settings.autoPrefixChars = L"(#";
settings.autoSuffixChars = L");%";
settings.autoNever = {L"myWord"};
settings.autoWords = {{L"uk", {L"релізик"}}, {L"en", {L"kswitcher"}}};
settings.autoExcluded = {L"Code.exe"};
settings.cycle = {L"en", L"uk"};
settings.snippets = {{L";address", L"Line 1\r\n{cursor}Line 2"}};
ks::saveSettings(temp / L"settings.json", settings);
auto restored = ks::loadSettings(temp / L"settings.json", {});
require(restored.autoWords == settings.autoWords, "personal dictionaries survive settings roundtrip");
auto invalidWords = settings;
invalidWords.autoWords[L"uk"] = {L"hello"};
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");
require(ks::encodeSettings(ks::decodeSettings(ks::encodeSettings(settings), {})) == ks::encodeSettings(settings),
"configurator snapshot survives serialization without changing the concurrency token");
rejects([&] { ks::decodeSettings(ks::Bytes{'{', '}'}, {}); },
"configurator rejects missing settings format");
require(!restored.writeLog, "logging setting persists when disabled");
require(ks::loadSettings(temp / L"defaults.json", {}).writeLog,
"logging defaults on for existing diagnostic installations");
require(restored.snippets[0].text == settings.snippets[0].text, "settings snippet roundtrip");
require(restored.cycle == settings.cycle, "layout order roundtrip");
require(restored.elevatedAutostart, "elevated autostart setting roundtrip");
require(restored.autoMaxRank == 10000, "frequency rank limit roundtrip");
require(restored.autoMinLength == 5, "minimum word length roundtrip");
require(ks::loadSettings(temp / L"defaults.json", {}).autoMinLength == 3,
"missing minimum length defaults to three");
for (int badLength : {0, 33})
{
auto invalidLength = settings;
invalidLength.autoMinLength = badLength;
rejects([&] { ks::validateSettings(invalidLength); }, "invalid minimum word length rejected");
}
require(restored.autoPrefixChars == settings.autoPrefixChars &&
restored.autoSuffixChars == settings.autoSuffixChars,
"custom punctuation settings roundtrip");
auto invalidCharacters = settings;
invalidCharacters.autoPrefixChars = L"a";
rejects([&] { ks::validateSettings(invalidCharacters); },
"letters cannot be treated as word delimiters");
require(ks::loadSettings(temp / L"defaults.json", {}).autoMaxRank == 0,
"missing frequency limit defaults to all words");
auto invalidRank = settings;
invalidRank.autoMaxRank = -1;
rejects([&] { ks::validateSettings(invalidRank); }, "negative frequency limit rejected");
require(restored.autoCorrect && restored.autoLanguages == settings.autoLanguages &&
restored.autoNever == settings.autoNever &&
restored.autoExcluded == settings.autoExcluded,
"auto-correction settings roundtrip");
require(!ks::loadSettings(temp / L"defaults.json", {}).elevatedAutostart,
"elevated autostart is opt-in for existing and default settings");
auto invalid = settings;
auto captureSettings = settings;
captureSettings.screenshot = L"Ctrl+Shift+F9";
require(!captureSettings.screenshotAutoOpenEditor,"notification-first capture is the default");
captureSettings.screenshotAutoOpenEditor=true;
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
"explicit automatic editor preference survives settings roundtrip");
captureSettings.screenshotAutoOpenEditor=false;
require(!ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshotAutoOpenEditor,
"notification-first preference survives settings roundtrip");
require(ks::decodeSettings(ks::encodeSettings(captureSettings), {}).screenshot == captureSettings.screenshot,
"screen capture shortcut roundtrip");
captureSettings.screenshot = captureSettings.clipboard;
rejects([&] { ks::validateSettings(captureSettings); }, "screen capture shortcut duplicates rejected");
captureSettings.screenshot.clear();
ks::validateSettings(captureSettings);
require(ks::loadSettings(temp / L"defaults.json", {}).screenshot == L"Win+Shift+S",
"screen capture default for existing settings");
invalid.undo = invalid.toggle;
rejects([&] { ks::validateSettings(invalid); }, "duplicate shortcut rejected");
invalid = settings;
invalid.snippets[0].text = L"{cursor}{cursor}";
rejects([&] { ks::validateSettings(invalid); }, "invalid snippet rejected");
{
ks::History history(temp / L"history");
history.add(clip, L"Test", settings);
history.add(clip, L"Test", settings);
require(history.entries().size() == 1, "history consecutive deduplication");
auto id = history.entries()[0].id;
history.pin(id);
require(history.entries()[0].pinned, "pin persisted in memory");
require(history.search(L"line 2").size() == 1, "full text search");
require(history.load(id).text() == clip.text(), "history payload roundtrip");
}
{
ks::History history(temp / L"history");
require(history.entries().size() == 1 && history.entries()[0].pinned, "history index reload");
rejects([&] { history.load(L"../other"); }, "history path traversal rejected");
history.clear();
require(history.entries().empty(), "clear history");
}
{
ks::History history(temp / L"search");
history.add(ks::textClip(std::wstring(260, L'x') + L" hidden needle"), L"Test", settings);
history.add(ks::textClip(L"first\nsecond\tthird"), L"Test", settings);
require(history.search(L"hidden needle").size() == 1, "search beyond truncated preview");
require(history.search(L"first\nsecond").size() == 1, "search retains original newlines");
require(history.search(L"second\tthird").size() == 1, "search retains original tabs");
require(history.search(L"absent").empty(), "short previews reject unmatched queries");
require(history.search(L"FIRST").size() == 1, "short preview search ignores case");
}
{
auto folder = temp / L"ocr-history";
ks::History history(folder);
ks::Clip image{{ks::imageDib(ks::Pixels{2, 2, ks::Bytes(16, 255)})}};
history.add(image, L"OCR test", settings);
auto id = history.entries().front().id;
require(history.entries().front().ocrState == 1, "new images await background OCR");
require(ks::History(folder).entries().front().ocrState == 1, "pending OCR survives restart");
auto original = ks::encodeClip(history.load(id));
auto beforeBytes = history.entries().front().bytes;
require(history.saveOcr(id, L"Розпізнаний текст HELLO", settings), "save image OCR");
require(history.entries().front().bytes > beforeBytes, "OCR storage counts against history size");
require(ks::encodeClip(history.load(id)) == original, "OCR preserves original image formats");
require(!history.saveOcr(id, L"replacement", settings), "completed OCR is not overwritten");
ks::History restored(folder);
require(restored.search(L"розпізнаний").size() == 1, "persisted Ukrainian OCR is searchable");
require(restored.ocrText(id) == L"Розпізнаний текст HELLO", "OCR text survives restart");
auto raw = ks::readFile(folder / (id + L".ocr"), 2 * 1024 * 1024);
require(std::string(raw.begin(), raw.end()).find("HELLO") == std::string::npos, "OCR sidecar is encrypted");
restored.remove(id);
require(!std::filesystem::exists(folder / (id + L".ocr")), "deleting image removes OCR sidecar");
require(!restored.saveOcr(id, L"late result", settings), "late OCR cannot resurrect deleted image");
restored.add(image, L"OCR test", settings);
id = restored.entries().front().id;
restored.saveOcr(id, L"", settings);
require(restored.ocrText(id).has_value() && restored.ocrText(id)->empty(), "empty OCR is a completed result");
restored.clear();
require(!std::filesystem::exists(folder / (id + L".ocr")), "clear removes OCR sidecars");
restored.add(image, L"OCR test", settings);
id = restored.entries().front().id;
restored.saveOcr(id, std::nullopt, settings);
require(ks::History(folder).entries().front().ocrState == 3, "failed OCR is not retried on every search or restart");
restored.saveOcr(id, L"manual retry", settings);
require(restored.search(L"manual retry").size() == 1, "explicit retry can recover failed OCR");
restored.add(ks::textClip(L"newer"), L"test", settings);
auto limited = settings; limited.maxItems = 1;
restored.add(ks::textClip(L"newest"), L"test", limited);
require(!std::filesystem::exists(folder / (id + L".ocr")), "pruning image removes OCR sidecar");
}
{
ks::Settings options;
require(!options.useTesseract, "Tesseract is opt-in by default");
options.useTesseract = true;
options.tesseractPath = L"C:\\Program Files\\Tesseract-OCR\\tesseract.exe";
options.tesseractLanguages = L"ukr+eng+rus";
auto restored = ks::decodeSettings(ks::encodeSettings(options), {});
require(restored.useTesseract && restored.tesseractPath == options.tesseractPath &&
restored.tesseractLanguages == options.tesseractLanguages, "OCR settings roundtrip");
options.tesseractLanguages = L"ukr+../eng";
rejects([&] { ks::validateSettings(options); }, "OCR language paths rejected");
options.tesseractLanguages = L"ukr++eng";
rejects([&] { ks::validateSettings(options); }, "empty OCR language rejected");
options.tesseractLanguages = L"ukr+eng";
options.tesseractPath = L"C:\\missing-tesseract\\tesseract.exe";
rejects([&] { ks::validateTesseract(options); }, "missing optional Tesseract reports clear failure");
options.useTesseract = false;
ks::validateTesseract(options);
require(true, "Windows OCR works without optional installation");
}
// Test-owned directory only; never touch the user's clipboard or stored history.
std::filesystem::remove_all(temp);
CoUninitialize();
std::cout << "Passed " << passed << " native regression checks\n";
return 0;
}
catch (const std::exception &e)
{
std::cerr << "FAILED: " << e.what() << '\n';
return 1;
}
}