483 lines
30 KiB
C++
483 lines
30 KiB
C++
#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'>Привіт & 🌍</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;
|
||
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"{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);
|
||
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.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(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");
|
||
}
|
||
// 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;
|
||
}
|
||
}
|