Release v0.6.28: typo correction, partial sync and selection diagnostics

This commit is contained in:
2026-11-02 11:19:13 +02:00
parent 738c81dce2
commit 55993c0f90
23 changed files with 676 additions and 76 deletions
+48 -5
View File
@@ -599,6 +599,9 @@ int main(int argc, char **argv) {
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
app.resetInput();
app.handleKeyboard(event('A',0x1e),true,0);
if (app.run.keys!=L"a" && app.run.keys!=L"A")
std::cerr << "Input fixture foreground=" << (uintptr_t)GetForegroundWindow()
<< " recorded=" << ks::utf8(app.run.keys) << '\n';
require(app.run.keys==L"a"||app.run.keys==L"A","external scan recorded with current CapsLock");
auto keys=app.run.keys; auto version=app.inputVersion.load();
for(WORD key:{WORD('V'),WORD(VK_SHIFT),WORD(VK_LEFT),WORD(VK_F15),WORD(0xE8)})
@@ -656,6 +659,23 @@ int main(int argc, char **argv) {
require((GetWindowLongPtrW(GetDlgItem(syncWindow,604),GWL_STYLE)&ES_PASSWORD)!=0,
"sync password is masked");
require(!IsWindowEnabled(app.settingsWindow),"sync dialog owns settings interaction");
for(int id:{602,603,604,605,606,607,610,611,612,613,IDOK})
require(!IsWindowEnabled(GetDlgItem(syncWindow,id)),"disabled synchronization disables dependent controls");
require(IsWindowEnabled(GetDlgItem(syncWindow,601)) && IsWindowEnabled(GetDlgItem(syncWindow,609)) &&
IsWindowEnabled(GetDlgItem(syncWindow,IDCANCEL)),"enable, forget password and close remain available");
SendMessageW(syncWindow,WM_COMMAND,612,0);
require(!FindWindowW(L"KSwitcher.SyncConflicts",nullptr),"disabled conflict action is ignored");
SendDlgItemMessageW(syncWindow,601,BM_SETCHECK,BST_CHECKED,0);
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
for(int id:{602,603,604,605,606,607,610,611,613,IDOK})
require(IsWindowEnabled(GetDlgItem(syncWindow,id)),"enabling synchronization immediately enables connection controls");
require(!ks::loadSyncConfig(app.data).enabled,"checking enable alone does not connect or persist");
auto enabledSync=ks::loadSyncConfig(app.data);
enabledSync.enabled=true;
enabledSync.server=L"https://sync.example.test";
enabledSync.username=L"test";
ks::saveSyncConfig(app.data,enabledSync);
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
ks::InputRegressionAccess::sampleConflicts(app);
SendMessageW(syncWindow,WM_COMMAND,612,0);
auto conflictsWindow=FindWindowW(L"KSwitcher.SyncConflicts",nullptr);
@@ -678,7 +698,7 @@ int main(int argc, char **argv) {
require(!IsWindowEnabled(syncWindow),"conflict dialog owns sync interaction");
SendMessageW(conflictsWindow,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(syncWindow),"closing conflict dialog restores sync window");
for(int id:{601,602,603,604,605,606,607,608,609,610,611,612,IDOK,IDCANCEL}) {
for(int id:{601,602,603,604,605,606,607,608,609,610,611,612,613,IDOK,IDCANCEL}) {
RECT control{},client{};
GetWindowRect(GetDlgItem(syncWindow,id),&control);
MapWindowPoints(nullptr,syncWindow,reinterpret_cast<POINT*>(&control),2);
@@ -686,8 +706,12 @@ int main(int argc, char **argv) {
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
"sync controls fit dialog");
}
SendDlgItemMessageW(syncWindow,601,BM_SETCHECK,BST_UNCHECKED,0);
SendMessageW(syncWindow,WM_COMMAND,MAKEWPARAM(601,BN_CLICKED),0);
require(IsWindowEnabled(GetDlgItem(syncWindow,IDOK)),"Save remains available to persist disabling sync");
SendMessageW(syncWindow,WM_COMMAND,IDOK,0);
require(!ks::loadSyncConfig(app.data).enabled,"saving disabled sync does not enable network access");
require(!IsWindowEnabled(GetDlgItem(syncWindow,IDOK)),"Save disables after disabled state is persisted");
SendMessageW(syncWindow,WM_COMMAND,IDCANCEL,0);
require(IsWindowEnabled(app.settingsWindow),"closing sync restores parent");
require(ks::InputRegressionAccess::staleSync(app,false),"in-flight sync cannot overwrite newer local edits");
@@ -704,21 +728,31 @@ int main(int argc, char **argv) {
GetWindowRect(autoOptions,&original);
// Use a client size above the scrolling baseline at every DPI.
UINT dpi=GetDpiForWindow(autoOptions);
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(900,dpi,96),
SetWindowPos(autoOptions,nullptr,0,0,MulDiv(800,dpi,96),MulDiv(1100,dpi,96),
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
RECT clientBefore{}; GetClientRect(autoOptions,&clientBefore);
for(int i=0;i<3;++i) GetWindowRect(GetDlgItem(autoOptions,411+i),&before[i]);
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonBefore);
SetWindowPos(autoOptions,nullptr,0,0,baseline.right-baseline.left+240,
baseline.bottom-baseline.top+120,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
RECT clientAfter{}; GetClientRect(autoOptions,&clientAfter);
// Windows can constrain oversized windows to the current desktop.
// Check layout against the actual available client space.
int extraBeforeX=std::max(0,int(clientBefore.right)-MulDiv(560,dpi,96));
int extraAfterX=std::max(0,int(clientAfter.right)-MulDiv(560,dpi,96));
int extraY=std::max(0,int(clientAfter.bottom)-MulDiv(880,dpi,96))-
std::max(0,int(clientBefore.bottom)-MulDiv(880,dpi,96));
for(int i=0;i<3;++i) {
GetWindowRect(GetDlgItem(autoOptions,411+i),&after[i]);
require(after[i].right-after[i].left==before[i].right-before[i].left+80 &&
after[i].bottom-after[i].top==before[i].bottom-before[i].top+120,
int extraX=(extraAfterX*(i+1)/3-extraAfterX*i/3)-
(extraBeforeX*(i+1)/3-extraBeforeX*i/3);
require(after[i].right-after[i].left==before[i].right-before[i].left+extraX &&
after[i].bottom-after[i].top==before[i].bottom-before[i].top+extraY,
"dictionary columns share extra width and receive extra height");
}
GetWindowRect(GetDlgItem(autoOptions,IDOK),&buttonAfter);
require(buttonAfter.left-buttonBefore.left==240 && buttonAfter.top-buttonBefore.top==120,
require(buttonAfter.left-buttonBefore.left==extraAfterX-extraBeforeX && buttonAfter.top-buttonBefore.top==extraY,
"auto-correction buttons follow bottom-right corner");
require(after[0].right<after[1].left && after[1].right<after[2].left &&
after[2].bottom<buttonAfter.top,
@@ -735,6 +769,9 @@ int main(int argc, char **argv) {
"personal dictionary field fits options window");
}
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
SetDlgItemTextW(autoOptions,416,L" Првиіт = Привіт\r\nhelo=hello");
SendDlgItemMessageW(autoOptions,414,BM_SETCHECK,BST_CHECKED,0);
SendDlgItemMessageW(autoOptions,415,BM_SETCHECK,BST_CHECKED,0);
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
require(!IsWindow(autoOptions),"personal words accepted without file editing");
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
@@ -744,6 +781,12 @@ int main(int argc, char **argv) {
GetDlgItemTextW(autoOptions,412,personalWords,128);
require(std::wstring(personalWords)==L"релізик, словечко",
"personal dictionary normalizes case, newlines and duplicate words");
wchar_t pairs[128]{};
GetDlgItemTextW(autoOptions,416,pairs,128);
require(std::wstring(pairs)==L"helo=hello\r\nпрвиіт=привіт\r\n" &&
SendDlgItemMessageW(autoOptions,414,BM_GETCHECK,0,0)==BST_CHECKED &&
SendDlgItemMessageW(autoOptions,415,BM_GETCHECK,0,0)==BST_CHECKED,
"typo options and normalized replacement pairs survive reopening");
SendMessageW(autoOptions,WM_COMMAND,IDCANCEL,0);
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
ks::showHistory(app,false);
+80 -1
View File
@@ -23,6 +23,7 @@ HWND host{}, field{};
ks::Clip saved;
bool captured = false, failed = false;
bool conversionOnly = false;
bool typosOnly = false;
int pasteBehavior = 0;
int enterPresses = 0;
int copyFailuresRemaining = 0, copyRequests = 0;
@@ -427,8 +428,30 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
case 47:
require(text() == L"OLDsnipping ", "recent Cyrillic suffix recovery preserves older prefix");
require(droppedSelectionKeys == 0, "recent suffix regression dropped four selection keys");
app->settings.writeLog = true;
prepare(L"prefix wrong ", false);
record(L"other ");
app->transform(ks::Direct + ru);
break;
case 48:
{
auto rows = ks::parseOperationLog(ks::wide(diagnosticLog()));
require(text() == L"prefix wrong " && !rows.empty() &&
rows.back().outcome == L"recent-verification-mismatch",
"diagnostic mismatch does not replace unverified text");
auto details = rows.back().details;
require(details.find(L"selection-mismatch") != details.npos &&
details.find(L"Expected selection:\r\nother ") != details.npos &&
details.find(L"Read back:\r\nwrong ") != details.npos,
"failed verification includes expected and copied text in exported details");
require(details.find(L"Copy begin number=") != details.npos &&
details.find(L"Copy result number=") != details.npos &&
details.find(L"Selection initial match=0") != details.npos &&
details.find(L"SelectPrevious") != details.npos,
"incident includes clipboard and selection stages");
finish();
break;
}
case 30:
case 32:
case 34:
@@ -597,6 +620,61 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
case 22:
require(text() == L"робить ", "automatic correction selects and converts the whole word across an internal comma");
app->settings.autoReplace = true;
app->settings.autoReplacements = {{L"helo", L"hello"}};
ActivateKeyboardLayout(app->layouts[en].handle, 0);
prepare(L"(Helo)! ", false);
record(L"(Helo)! ");
app->transform(ks::AutoCorrect);
break;
case 23:
require(text() == L"(Hello)! ", "explicit typo pair replaces verified text with punctuation and capital");
app->transform(ks::Undo);
break;
case 24:
require(text() == L"(Helo)! ", "Undo restores explicit typo replacement");
app->settings.autoReplace = false;
app->settings.autoTypos = true;
app->settings.autoWords[L"en"].push_back(L"kswitcher");
// Use a personal target to avoid bundled dictionary ambiguity.
prepare(L"kswithcer ", false);
record(L"kswithcer ");
app->transform(ks::AutoCorrect);
break;
case 25:
require(text() == L"kswitcher ", "dictionary typo correction replaces text in the current layout");
require(ks::readClipboard(app->window).text() == sentinel, "typo correction restores clipboard");
app->transform(ks::Undo);
break;
case 26:
require(text() == L"kswithcer ", "Undo restores dictionary typo correction");
record(L"kswithcer ");
app->transform(ks::AutoCorrect);
break;
case 27:
require(text() == L"kswithcer ", "Undo suppresses the rejected spelling correction");
app->settings.autoReplace = true;
app->settings.autoReplacements = {{L"tehh", L"the"}};
app->autoPredictor.configure(L"dictionaries", app->settings.autoLanguages);
prepare(L"", false);
{
std::vector<INPUT> inputs;
for (WORD key : std::wstring(L"TEHH X"))
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(),
"custom typo and following word submitted without a pause");
}
break;
case 28:
require(text() == L"the x", "custom replacement through keyboard hook preserves subsequent typing");
app->settings.autoReplace = false;
app->settings.autoTypos = false;
if (typosOnly) { finish(); break; }
stage = 30;
break;
}
@@ -621,6 +699,7 @@ LRESULT CALLBACK procedure(HWND w, UINT msg, WPARAM wp, LPARAM lp)
int main(int argc, char **argv)
{
conversionOnly = argc > 1 && std::string(argv[1]) == "--conversion-only";
typosOnly = argc > 1 && std::string(argv[1]) == "--typos-only";
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
auto root = std::filesystem::temp_directory_path() /
(L"KSwitcher-integration-" + std::to_wstring(GetCurrentProcessId()));
@@ -776,7 +855,7 @@ int main(int argc, char **argv)
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")
if (typosOnly || (argc > 1 && std::string(argv[1]) == "--typing-only"))
stage = 20;
if (argc > 1 && std::string(argv[1]) == "--snippets-only")
stage = 30;
+79 -1
View File
@@ -54,6 +54,10 @@ int main()
rejects([&] { validateSyncConfig(invalid); }, "reject unsupported provider");
saveSyncConfig(root, config);
require(syncVisible(root) && loadSyncConfig(root) == config, "connection round trip");
auto partialConfig = config; partialConfig.keepLocalLayouts = true;
saveSyncConfig(root, partialConfig);
require(loadSyncConfig(root).keepLocalLayouts, "partial sync mode survives restart");
saveSyncConfig(root, config);
auto rawConfig = parse(readFile(root / L"sync.json", 16384));
require(!rawConfig.contains("password") && !rawConfig.contains("token"), "no secrets in config");
Settings local;
@@ -84,9 +88,49 @@ int main()
left = right = j; left["snippets"].push_back({{"trigger", "aaa"}, {"text", "one"}});
right["snippets"].push_back({{"trigger", "bbb"}, {"text", "two"}});
require(parse(mergeSyncSettings(base, bytes(left), bytes(right)))["snippets"].size() == 3, "independent snippet additions merge");
rejects([&] { mergeSyncSettings({}, bytes(left), base); }, "first sync needs explicit choice");
require(parse(mergeSyncSettings({}, bytes(left), base))["snippets"].size() == 2,
"first sync merges independent additions without choosing a whole profile");
require(parse(mergeSyncSettings({}, base, base)) == j, "identical first sync safe");
{
auto l = j, r = j;
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
l["autoCorrectNever"].push_back("localword");
r["autoCorrectNever"].push_back("remoteword");
auto plan = mergeSyncProfile({}, bytes(l), bytes(r));
require(plan.conflicts.size() == 1 && parse(plan.local)["autoCorrectNever"].size() == 2 &&
parse(plan.remote)["autoCorrectNever"].size() == 2,
"first sync merges lists while preserving scalar conflicts");
require(parse(plan.local)["autoCorrectMinLength"] == 4 && parse(plan.remote)["autoCorrectMinLength"] == 5,
"first sync never guesses between different scalar values");
auto again = mergeSyncProfile(plan.base, plan.local, plan.remote);
require(again.conflicts.size() == 1, "first-sync conflicts survive saving the ancestor");
auto resolved = mergeSyncProfile(plan.base, plan.local, plan.remote, {{plan.conflicts[0], false}});
require(resolved.conflicts.empty() && parse(resolved.local)["autoCorrectMinLength"] == 5,
"first-sync conflict can be resolved independently");
}
require(SyncIntervalMs == 600000, "background interval is ten minutes");
{
auto l = j, r = j;
l["autoCorrectReplacements"]["helo"] = "hello";
r["autoCorrectReplacements"]["wrold"] = "world";
auto paired = mergeSyncSettings(base, bytes(l), bytes(r));
require(parse(paired)["autoCorrectReplacements"].size() == 2, "independent replacement pairs merge");
l = r = parse(paired);
l["autoCorrectReplacements"].erase("helo");
r["autoCorrectReplacements"]["wrold"] = "worlds";
auto removed = mergeSyncSettings(paired, bytes(l), bytes(r));
auto restored = restoreSyncSettings(removed, local, {});
require(restored.autoReplacements.size() == 1 && restored.autoReplacements.at(L"wrold") == L"worlds",
"replacement deletion merges with an independent edit and remains loadable");
r["autoCorrectReplacements"]["helo"] = "help";
require(mergeSyncProfile(paired, bytes(l), bytes(r)).conflicts.size() == 1,
"replacement deletion versus edit is a conflict");
auto legacy = j;
for (auto key : {"autoCorrectReplace", "autoCorrectTypos", "autoCorrectReplacements"}) legacy.erase(key);
auto migrated = restoreSyncSettings(bytes(legacy), local, {});
require(!migrated.autoReplace && !migrated.autoTypos && migrated.autoReplacements.empty(),
"older synchronized profiles default new typo modes off");
}
{
auto l = j, r = j;
l["autoCorrectMinLength"] = 4; r["autoCorrectMinLength"] = 5;
@@ -173,6 +217,40 @@ int main()
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload); }, "racing writer protected");
require(provider.writes == 1 && provider.remote.settings == base, "CAS failure preserves remote");
provider.race = false;
{
auto remoteProfile = j;
remoteProfile["layouts"] = Json::array({"win:00000422"});
remoteProfile["layoutHotkeys"] = Json::array({{{"layoutId", "win:00000422"}, {"hotkey", "Ctrl+Alt+9"}}});
remoteProfile["autoCorrectNever"].push_back("remoteword");
FakeProvider partial;
partial.remote = {3, bytes(remoteProfile)};
auto validate = [&](const Bytes &b) { restoreSyncSettings(b, local, {}, true); };
auto result = synchronize(partial, {}, base, SyncAction::Automatic, validate, {}, true);
auto restored = restoreSyncSettings(result.settings, local, {}, true);
require(restored.cycle == local.cycle && restored.direct == local.direct &&
restored.autoNever == std::vector<std::wstring>{L"remoteword"},
"partial sync restores other settings and keeps local layout configuration");
auto next = sharedSyncSettings(restored);
result = synchronize(partial, result.base, next, SyncAction::Automatic, validate, {}, true);
require(parse(partial.remote.settings)["layouts"] == remoteProfile["layouts"] &&
parse(partial.remote.settings)["layoutHotkeys"] == remoteProfile["layoutHotkeys"],
"repeated partial sync never deletes unavailable server layouts or shortcuts");
auto changed = parse(next); changed["autoCorrectMinLength"] = 7;
result = synchronize(partial, result.base, bytes(changed), SyncAction::Upload, validate, {}, true);
require(parse(partial.remote.settings)["autoCorrectMinLength"] == 7 &&
parse(partial.remote.settings)["layoutHotkeys"] == remoteProfile["layoutHotkeys"],
"explicit partial upload preserves server layouts too");
auto downloaded = synchronize(partial, result.base, base, SyncAction::Download, validate, {}, true);
auto downloadedSettings = restoreSyncSettings(downloaded.settings, local, {}, true);
require(downloadedSettings.autoMinLength == 7 && downloadedSettings.cycle == local.cycle &&
downloadedSettings.direct == local.direct, "partial Download applies other settings without replacing local layouts");
try { restoreSyncSettings(bytes(remoteProfile), local, {}); require(false, "full sync must identify missing layouts"); }
catch (const std::runtime_error &e) {
std::string message = e.what();
require(message.find("win:00000422") != message.npos && message.find("Keep this PC's layouts") != message.npos,
"missing-layout error identifies IDs and offers partial sync");
}
}
rejects([&] { synchronize(provider, base, bytes(left), SyncAction::Upload,
[](const Bytes &) { throw std::runtime_error("invalid"); }); }, "validate before upload");
require(provider.writes == 1, "invalid data not uploaded");
+58 -2
View File
@@ -111,8 +111,8 @@ int main(int argc, char **argv)
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);
for (int i = 0; i < 128; ++i)
diagnostics.record(L"bounded-context", false, 30600);
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);
@@ -217,6 +217,13 @@ int main(int argc, char **argv)
require(!autoSettings.autoCorrect, "auto-correction defaults off");
autoSettings.cycle = {L"en", L"uk"};
corrector.configure(L"dictionaries", autoSettings.autoLanguages);
{
auto options = autoSettings;
options.autoTypos = true;
auto typo = corrector.suggest(L"првиіт ", 2, layouts, options);
require(typo && typo->text == L"привіт " && typo->target == 2,
"bundled Ukrainian dictionary corrects a common transposition");
}
auto suggested = corrector.suggest(L"ghbdsn ", 0, layouts, autoSettings);
require(suggested && suggested->text == L"привіт " && suggested->target == 2,
"dictionary corrects Ukrainian and retains trailing space");
@@ -497,6 +504,45 @@ int main(int argc, char **argv)
auto correction = edited.suggest(L"руддщ ", 2, layouts, options);
require(correction && correction->text == L"hello ",
"unsorted dictionaries and duplicates load using lowest duplicate rank");
require(!edited.suggest(L"hlelo ", 0, layouts, options), "spelling correction defaults off");
options.autoTypos = true;
for (auto typo : {L"hlelo ", L"helo ", L"helllo ", L"hezlo "})
{
correction = edited.suggest(typo, 0, layouts, options);
require(correction && correction->text == L"hello " && correction->target == 0,
"single transposition, deletion, insertion and substitution retain the source layout");
}
correction = edited.suggest(L"(Hlelo), ", 0, layouts, options);
require(correction && correction->text == L"(Hello), ", "typos retain case and punctuation");
for (auto text : {L"hello ", L"HLELO ", L"hLelo ", L"h3llo ", L"h.llo ", L"hlo ", L"hello@example.com "})
require(!edited.suggest(text, 0, layouts, options), "known or unsafe typo input is unchanged");
options.autoWords[L"en"] = {L"hella"};
require(!edited.suggest(L"hell ", 0, layouts, options), "personal dictionary alternatives create ambiguity");
options.autoWords.clear();
options.autoReplacements = {{L"hlelo", L"greeting"}, {L"hello", L"greetings"}, {L"првиіт", L"привіт"}};
options.autoReplace = true;
correction = edited.suggest(L"Hlelo! ", 0, layouts, options);
require(correction && correction->text == L"Greeting! ", "explicit replacements override spelling guesses");
correction = edited.suggest(L"hello ", 0, layouts, options);
require(correction && correction->text == L"greetings ", "explicit replacements can override known words");
correction = edited.suggest(L"Првиіт ", 2, layouts, options);
require(correction && correction->text == L"Привіт " && correction->target == 2,
"Ukrainian replacements work without dictionary membership");
options.autoNever = {L"hlelo"};
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
options.autoNever.clear();
edited.reject(L"(Hlelo)! ", options);
require(!edited.suggest(L"hlelo ", 0, layouts, options), "undo rejection suppresses custom replacements");
options.autoReplace = false;
options.autoWords[L"uk"] = {L"привіт"};
correction = edited.suggest(L"првиіт ", 2, layouts, options);
require(correction && correction->text == L"привіт ", "Ukrainian personal words are typo targets");
options.cycle = {L"en"};
require(!edited.suggest(L"првиіт ", 2, layouts, options), "disabled source layouts do not correct typos");
put(L"en.txt", L"hello\t1\nhella\t99999\n");
ks::AutoCorrector ambiguous;
ambiguous.configure(folder, {L"en", L"uk"});
require(!ambiguous.suggest(L"hell ", 0, layouts, options), "frequency filter does not conceal ambiguous typo targets");
}
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}}}};
@@ -542,6 +588,8 @@ int main(int argc, char **argv)
settings.writeLog = false;
settings.elevatedAutostart = true;
settings.autoCorrect = true;
settings.autoReplace = settings.autoTypos = true;
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}};
settings.autoMaxRank = 10000;
settings.autoMinLength = 5;
settings.autoPrefixChars = L"(#";
@@ -554,6 +602,14 @@ int main(int argc, char **argv)
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");
require(restored.autoReplace && restored.autoTypos && restored.autoReplacements == settings.autoReplacements,
"typo options and Unicode pairs survive settings roundtrip");
for (const auto &pair : {std::pair{L"hello", L"привіт"}, {L"hello", L"hello"}, {L"hi!", L"hello"}})
{
auto invalid = settings;
invalid.autoReplacements = {{pair.first, pair.second}};
rejects([&] { ks::validateSettings(invalid); }, "invalid replacement pairs rejected");
}
auto invalidWords = settings;
invalidWords.autoWords[L"uk"] = {L"hello"};
rejects([&] { ks::validateSettings(invalidWords); }, "personal dictionary rejects wrong language letters");