#include "app.h" #include "ocr.h" #include "screenshot.h" #include "lan_pairing.h" #include #include namespace { int passed{}; void require(bool ok, const char *message) { if (!ok) throw std::runtime_error(message); ++passed; } void contextChecks() { ks::Settings scoped; scoped.snippets = {{L";sig", L"Global", L"global"}, {L";sig", L"Work", L"teams", {L"MS-Teams.EXE", L"OUTLOOK.exe"}}, {L";sig", L"Personal", L"telegram", {L"telegram"}}}; ks::validateSettings(scoped); require(ks::snippetAppPriority(scoped.snippets[1], L"ms-teams") > ks::snippetAppPriority(scoped.snippets[0], L"ms-teams"), "app snippet precedes global regardless of list order"); require(ks::snippetAppPriority(scoped.snippets[1], L"Telegram.exe") == 0, "restricted snippet cannot run in another app"); require(ks::snippetAppPriority(scoped.snippets[1], L"Outlook.EXE") == 2, "restricted snippet also runs in its second application"); require(ks::snippetAppPriority(scoped.snippets[0], L"") == 1, "legacy/global snippets remain available"); auto scopedRoundtrip = ks::decodeSettings(ks::encodeSettings(scoped), {}); require(scopedRoundtrip.snippets[1].apps == std::vector{L"ms-teams", L"outlook"}, "scopes normalize and persist"); require(scopedRoundtrip.snippets[0].apps.empty(), "absent scope remains global"); auto collision = scoped; collision.snippets.push_back({L";SIG", L"Ambiguous", L"other", {L"outlook.exe"}}); bool collisionRejected=false;try { ks::validateSettings(collision); } catch(...) { collisionRejected=true; } require(collisionRejected, "overlapping app lists with same trigger rejected"); auto emptyScope = scoped; emptyScope.snippets[1].apps={L""}; bool emptyRejected=false;try { ks::validateSettings(emptyScope); } catch(...) { emptyRejected=true; } require(emptyRejected, "empty explicit restriction cannot become global"); require(ks::telegramWebKey(L"https://web.telegram.org/k/#@Example") == L"telegram-web\n@example", "Telegram Web username normalized"); require(ks::telegramWebKey(L"web.telegram.org/a/#-123456") == L"telegram-web\n-123456", "Telegram Web numeric peer"); for (const auto *url : {L"https://web.telegram.org/k/", L"https://web.telegram.org.evil/k/#@example", L"http://web.telegram.org/k/#@example", L"https://evil/web.telegram.org/k/#@example", L"https://web.telegram.org/k/#@example?foo", L"https://web.telegram.org/k/#@", L"https://web.telegram.org/k/#-", L"https://web.telegram.org/k/#settings"}) require(ks::telegramWebKey(url).empty(), "Reject non-chat or untrusted Telegram Web URL"); const auto key = ks::chatKey(L"Telegram.exe", L"\u200eТестовий чат \u2013 (12)"); require(key == L"telegram\nТестовий чат", "Telegram unread suffix"); require(ks::chatKey(L"Telegram", L"Тестовий чат") == key, "stable unread profile"); require(ks::chatKey(L"Telegram", L"Team (2026)") == L"telegram\nTeam (2026)", "preserve parentheses"); require(ks::chatKey(L"Telegram", L"Telegram").empty(), "no profile on home"); require(ks::chatKey(L"Viber", L"Rakuten Viber").empty(), "Viber generic window title is not a chat"); require(ks::chatKey(L"ms-teams", L"Chat | Alice | Microsoft Teams") == L"ms-teams\nAlice", "Teams chat"); require(ks::chatKey(L"ms-teams", L"Calendar | Alice | Microsoft Teams").empty(), "Teams calendar excluded"); require(ks::chatKey(L"ms-teams", L"Chat | Microsoft Teams").empty(), "Teams chat home excluded"); require(ks::chatKey(L"other", L"Chat | Alice | Microsoft Teams").empty(), "app identity required"); ks::Settings settings; settings.cycle = {L"win:04090409", L"win:04220422", L"win:04190419"}; settings.chatCycles[key] = {settings.cycle[1], settings.cycle[0]}; settings.chatCycles[L"viber\nТестовий контакт"] = {settings.cycle[1], settings.cycle[0]}; settings.chatCycles[L"telegram-web\n@example"] = {settings.cycle[0], settings.cycle[1]}; std::vector installed(3); for (size_t i = 0; i < installed.size(); ++i) { installed[i].id = settings.cycle[i]; installed[i].handle = (HKL)(uintptr_t)(i + 1); } auto subset = settings.chatCycles[key]; require(ks::nextChatLayout(subset, installed, installed[1].handle, false) == installed[0].handle, "cycle within subset"); require(ks::nextChatLayout(subset, installed, installed[0].handle, false) == installed[1].handle, "wrap skips excluded language"); require(ks::nextChatLayout(subset, installed, installed[2].handle, false) == installed[1].handle, "outside subset enters first language"); require(ks::nextChatLayout(settings.cycle, installed, installed[0].handle, true) == installed[2].handle, "reverse wraps"); require(ks::nextChatLayout({settings.cycle[1]}, installed, installed[0].handle, false) == installed[1].handle, "single language selects allowed layout"); require(!ks::nextChatLayout({L"win:04070407"}, installed, installed[0].handle, false), "missing layout is not activated"); require(ks::nextConversionLayout(L"", installed, {1}, 0) == 1, "conversion uses single allowed language"); require(ks::chatCycle(settings, key, installed) == settings.chatCycles[key], "profile order"); require(ks::chatCycle(settings, L"telegram\nOther", installed) == settings.cycle, "unknown fallback"); require(ks::decodeSettings(ks::encodeSettings(settings), installed).chatCycles == settings.chatCycles, "Unicode roundtrip"); require(ks::decodeSettings({}, installed).chatCycles.empty(), "old defaults without profiles"); auto withoutProfiles = settings; withoutProfiles.chatCycles.clear(); require(ks::sharedSyncSettings(settings) != ks::sharedSyncSettings(withoutProfiles), "profiles included in sync"); auto restored = ks::restoreSyncSettings(ks::sharedSyncSettings(settings), withoutProfiles, installed, false); require(restored.chatCycles == settings.chatCycles, "sync transfers chat profiles to another PC"); settings.chatCycles[key] = {settings.cycle[1]}; require(ks::chatCycle(settings, key, installed).size() == 1, "one-language profile"); settings.chatCycles[key] = {L"win:04070407"}; require(ks::chatCycle(settings, key, installed) == settings.cycle, "missing layouts fallback"); settings.chatCycles[key] = {}; bool rejected = false; try { ks::encodeSettings(settings); } catch (...) { rejected = true; } require(rejected, "empty profile rejected"); settings.chatCycles[key] = {settings.cycle[0], settings.cycle[0]}; rejected = false; try { ks::encodeSettings(settings); } catch (...) { rejected = true; } require(rejected, "duplicate profile layout rejected"); ks::ChatSwitchHook hook; require(hook.update(VK_SPACE, true, MOD_WIN, false) == 0, "unprofiled Win+Space passes"); require(hook.update(VK_SPACE, true, MOD_WIN, true) == 1, "Win+Space forward"); require(hook.update(VK_SPACE, true, MOD_WIN, true) == -1, "consume repeat"); require(hook.update(VK_SPACE, false, 0, false) == -1, "release after profile change"); require(hook.update(VK_SPACE, true, MOD_WIN | MOD_SHIFT, true) == 2, "reverse cycle"); hook.update(VK_SPACE, false, 0, true); require(hook.update(VK_LSHIFT, true, MOD_ALT, true) == 1, "Alt then Shift"); hook.update(VK_LSHIFT, false, 0, true); require(hook.update(VK_LMENU, true, MOD_SHIFT, true) == 1, "Shift then Alt"); hook.update(VK_LMENU, false, 0, true); require(hook.update(VK_LSHIFT, true, MOD_ALT | MOD_CONTROL, true) == 0, "do not consume Ctrl+Alt+Shift"); require(hook.update('A', true, MOD_ALT, true) == 0, "ordinary keys pass"); } } int main() { CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); auto root = std::filesystem::temp_directory_path() / (L"KSwitcher-chat-tests-" + std::to_wstring(GetCurrentProcessId())); try { contextChecks(); { ks::App app(GetModuleHandleW(nullptr), true, root); require(!app.layouts.empty(), "installed layouts available"); app.settings.toggle = app.settings.casing = app.settings.translit = app.settings.undo = L""; app.settings.clipboard = app.settings.screenshot = L""; app.settings.direct.assign(app.layouts.size(), L""); app.settings.history = app.settings.autoCorrect = false; const std::wstring key = L"telegram\n! Fixture chat"; app.settings.chatCycles[key] = {app.layouts[0].id}; ks::showSettings(app); HWND parent = app.settingsWindow; auto saveEnabled=[&] { return IsWindowEnabled(GetDlgItem(parent,IDOK))!=FALSE; }; auto status=[&] { wchar_t text[256]{}; GetDlgItemTextW(parent,261,text,256); return std::wstring(text); }; require(!saveEnabled() && status()==L"No unsaved changes.","new Settings shows clean state with Save disabled"); SendMessageW(parent,WM_COMMAND,IDOK,0); require(IsWindow(parent),"Save command with no changes does not apply or close Settings"); bool originalLog=IsDlgButtonChecked(parent,208)==BST_CHECKED; CheckDlgButton(parent,208,originalLog?BST_UNCHECKED:BST_CHECKED); SendMessageW(parent,WM_COMMAND,MAKEWPARAM(208,BN_CLICKED),0); require(saveEnabled() && status().find(L"Unsaved changes") == 0,"checkbox edit immediately marks Settings dirty"); CheckDlgButton(parent,208,originalLog?BST_CHECKED:BST_UNCHECKED); SendMessageW(parent,WM_COMMAND,MAKEWPARAM(208,BN_CLICKED),0); require(!saveEnabled(),"reverting checkbox disables Save again"); wchar_t originalLimit[32]{}; GetDlgItemTextW(parent,240,originalLimit,32); SetDlgItemTextW(parent,240,L"invalid"); require(saveEnabled(),"invalid text remains an unsaved edit"); SetDlgItemTextW(parent,240,originalLimit); require(!saveEnabled(),"restoring valid original text clears dirty state"); SendMessageW(parent,WM_COMMAND,257,0); HWND ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr); require(ocr!=nullptr,"OCR preferences open"); wchar_t languages[128]{}; GetDlgItemTextW(ocr,503,languages,128); SetDlgItemTextW(ocr,503,L"eng"); SendMessageW(ocr,WM_COMMAND,IDCANCEL,0); require(!saveEnabled(),"cancelled child changes leave Settings clean"); SendMessageW(parent,WM_COMMAND,257,0); ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr); SetDlgItemTextW(ocr,503,std::wstring(languages)==L"eng"?L"ukr":L"eng"); SendMessageW(ocr,WM_COMMAND,IDOK,0); require(!IsWindow(ocr) && saveEnabled(),"accepted OCR edits enable parent Save"); SendMessageW(parent,WM_COMMAND,257,0); ocr=FindWindowW(L"KSwitcher.OcrOptions",nullptr); SetDlgItemTextW(ocr,503,languages); SendMessageW(ocr,WM_COMMAND,IDOK,0); require(!saveEnabled(),"reverting OCR draft in child clears parent dirty state"); SendMessageW(parent,WM_COMMAND,255,0); HWND automatic=FindWindowW(L"KSwitcher.AutoOptions",nullptr); require(automatic!=nullptr,"automatic correction preferences open"); bool replace=IsDlgButtonChecked(automatic,414)==BST_CHECKED; CheckDlgButton(automatic,414,replace?BST_UNCHECKED:BST_CHECKED); SendMessageW(automatic,WM_COMMAND,IDOK,0); require(!IsWindow(automatic) && saveEnabled(),"accepted auto-correction edits enable Save"); SendMessageW(parent,WM_COMMAND,255,0); automatic=FindWindowW(L"KSwitcher.AutoOptions",nullptr); CheckDlgButton(automatic,414,replace?BST_CHECKED:BST_UNCHECKED); SendMessageW(automatic,WM_COMMAND,IDOK,0); require(!saveEnabled(),"reverting auto-correction draft disables Save"); HWND tabs = GetDlgItem(parent, 260); require(tabs && TabCtrl_GetItemCount(tabs) == 5, "five settings categories"); for (int page = 0; page < 5; ++page) { TabCtrl_SetCurSel(tabs, page); NMHDR notification{tabs, 260, TCN_SELCHANGE}; SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)¬ification); std::vector boxes; for (HWND child = GetWindow(parent, GW_CHILD); child; child = GetWindow(child, GW_HWNDNEXT)) { if (!(GetWindowLongPtrW(child, GWL_STYLE) & WS_VISIBLE)) continue; RECT rect{}; GetWindowRect(child, &rect); for (const auto &other : boxes) { RECT overlap{}; require(!IntersectRect(&overlap, &rect, &other), "visible settings controls never overlap"); } boxes.push_back(rect); } RedrawWindow(parent, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN); RECT rect{}; GetClientRect(parent, &rect); HDC dc = GetDC(parent); auto clip = ks::screenshotClip(dc, rect.right, rect.bottom, {}); ReleaseDC(parent, dc); ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / (L"settings-tab-" + std::to_wstring(page) + L".png"), ks::encodePng(ks::decodeImage(clip))); } TabCtrl_SetCurSel(tabs, 1); NMHDR notification{tabs, 260, TCN_SELCHANGE}; SendMessageW(parent, WM_NOTIFY, 260, (LPARAM)¬ification); require(!saveEnabled(),"changing tabs leaves Settings clean"); SendMessageW(parent, WM_COMMAND, 259, 0); HWND dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr); require(dialog && !IsWindowEnabled(parent), "chat dialog opens with disabled parent"); int index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat"); require(index >= 0, "saved chat listed"); SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0); SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0); require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "saved single language selected"); RedrawWindow(dialog, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN); RECT rect{}; GetClientRect(dialog, &rect); HDC dc = GetDC(dialog); auto clip = ks::screenshotClip(dc, rect.right, rect.bottom, {}); ReleaseDC(dialog, dc); ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"chat-languages.png", ks::encodePng(ks::decodeImage(clip))); SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1); SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0); require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog"); require(!saveEnabled(),"cancelled chat edits do not enable Save"); SendMessageW(parent, WM_COMMAND, 259, 0); dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr); index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat"); SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0); SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0); require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits"); SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1); SendMessageW(dialog, WM_COMMAND, IDOK, 0); require(!IsWindow(dialog) && IsWindowEnabled(parent), "profile applies to settings draft"); require(saveEnabled() == (app.layouts.size()>1),"accepted chat edits refresh Save immediately"); SendMessageW(parent, WM_COMMAND, IDOK, 0); require(!app.settingsWindow && app.settings.chatCycles.at(key).size() == app.layouts.size(), "settings saves selected languages"); require(ks::loadSettings(root / L"settings.json", app.layouts).chatCycles == app.settings.chatCycles, "profile saved to disk"); ks::showSettings(app); parent = app.settingsWindow; require(!saveEnabled(),"reopened saved Settings is clean"); SendMessageW(parent, WM_COMMAND, 259, 0); dialog = FindWindowW(L"KSwitcher.ChatLanguages", nullptr); index = (int)SendDlgItemMessageW(dialog, 710, LB_FINDSTRINGEXACT, -1, (LPARAM)L"Telegram: ! Fixture chat"); SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0); SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0); SendMessageW(dialog, WM_COMMAND, 713, 0); require(saveEnabled() && status().find(L"Unsaved changes")==0,"removing child profile immediately marks parent dirty"); RedrawWindow(parent,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN); GetClientRect(parent,&rect); dc=GetDC(parent); clip=ks::screenshotClip(dc,rect.right,rect.bottom,{}); ReleaseDC(parent,dc); ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"settings-unsaved.png", ks::encodePng(ks::decodeImage(clip))); SendMessageW(parent, WM_COMMAND, IDOK, 0); require(!app.settings.chatCycles.contains(key), "remove profile restores default"); app.settings.snippets = {{L";scope", L"Example", L"scope-fixture"}}; ks::showSettings(app); parent=app.settingsWindow; require(!saveEnabled(),"Settings starts clean before separately saved snippet edit"); ks::showSnippets(app); HWND snippets = app.snippetsWindow; SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0); SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0); require(IsDlgButtonChecked(snippets,314)==BST_UNCHECKED, "old snippet shows everywhere"); CheckDlgButton(snippets,314,BST_CHECKED); SendMessageW(snippets,WM_COMMAND,314,0); SetDlgItemTextW(snippets,315,L"Notepad.exe"); auto secondApp = SendDlgItemMessageW(snippets,316,CB_ADDSTRING,0,(LPARAM)L"MS-Teams.EXE"); SendDlgItemMessageW(snippets,316,CB_SETCURSEL,secondApp,0); SendMessageW(snippets,WM_COMMAND,317,0); SendMessageW(snippets,WM_COMMAND,317,0); wchar_t appList[128]{}; GetDlgItemTextW(snippets,315,appList,128); require(std::wstring(appList)==L"Notepad.exe, MS-Teams.EXE", "Add app appends a second application without duplicates"); SendMessageW(snippets,WM_COMMAND,IDOK,0); require(!app.snippetsWindow && app.settings.snippets[0].apps==std::vector{L"notepad",L"ms-teams"}, "snippet editor saves selected applications"); require(!saveEnabled() && status()==L"No unsaved changes.","separately saved snippet edit leaves Settings clean"); SendMessageW(parent,WM_COMMAND,IDCANCEL,0); ks::showSnippets(app);snippets=app.snippetsWindow; SendDlgItemMessageW(snippets,301,LB_SETCURSEL,0,0); SendMessageW(snippets,WM_COMMAND,MAKEWPARAM(301,LBN_SELCHANGE),0); require(IsDlgButtonChecked(snippets,314)==BST_CHECKED, "scope restored when reopening editor"); RedrawWindow(snippets,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN); RECT snippetRect{};GetClientRect(snippets,&snippetRect); HDC snippetDc=GetDC(snippets); auto snippetImage=ks::screenshotClip(snippetDc,snippetRect.right,snippetRect.bottom,{}); ReleaseDC(snippets,snippetDc); ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"snippet-scopes.png",ks::encodePng(ks::decodeImage(snippetImage))); SetDlgItemTextW(snippets,315,L"chrome.exe"); SendMessageW(snippets,WM_COMMAND,IDCANCEL,0); require(app.settings.snippets[0].apps==std::vector{L"notepad",L"ms-teams"}, "cancel preserves existing scope"); ks::showSettings(app);parent=app.settingsWindow; SendMessageW(parent,WM_COMMAND,262,0); HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr); require(lan && !IsWindowEnabled(parent),"LAN pairing opens a native child window"); require(!IsWindowEnabled(GetDlgItem(lan,705)),"pairing disabled without discovered peer"); require(!IsWindowEnabled(GetDlgItem(lan,706)),"no cancellation without pending pairing"); require(!IsWindowEnabled(GetDlgItem(lan,707)),"cannot forget nonexistent pair"); require(!IsWindowEnabled(GetDlgItem(lan,715)),"input sharing requires a paired computer"); require(!IsWindowEnabled(GetDlgItem(lan,716)),"stop input disabled when sharing is off"); for(int id:{711,712,713,714}) require(!IsWindowEnabled(GetDlgItem(lan,id)),"screen positions unavailable until peer selected"); require(IsDlgButtonChecked(lan,712)==BST_CHECKED,"default screen position is Right"); require(SendDlgItemMessageW(lan,702,LB_GETCOUNT,0,0)==0,"disabled discovery lists no generic network devices"); require(!std::filesystem::exists(root/L"lan-pairing.bin"),"opening LAN settings does not enable or save discovery"); RedrawWindow(lan,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN); RECT lanRect{};GetClientRect(lan,&lanRect);HDC lanDc=GetDC(lan); auto lanImage=ks::screenshotClip(lanDc,lanRect.right,lanRect.bottom,{});ReleaseDC(lan,lanDc); ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path()/L"lan-pairing.png",ks::encodePng(ks::decodeImage(lanImage))); SendMessageW(lan,WM_COMMAND,IDCANCEL,0); require(IsWindowEnabled(parent)&&!saveEnabled(),"closing discovery restores Settings without false unsaved state"); SendMessageW(parent,WM_COMMAND,IDCANCEL,0); } { ks::lan::Json fixture={{"version",1},{"id",std::string(32,'a')},{"enabled",false}, {"trusted",{{std::string(32,'b'),{{"name","Test paired KSwitcher"},{"key",std::string(64,'f')}}}}}}; auto text=fixture.dump();ks::atomicWrite(root/L"lan-pairing.bin",ks::protect(ks::Bytes(text.begin(),text.end()))); ks::App pairedApp(GetModuleHandleW(nullptr),true,root);ks::showSettings(pairedApp); SendMessageW(pairedApp.settingsWindow,WM_COMMAND,262,0); HWND lan=FindWindowW(L"KSwitcher.LAN",nullptr); SendDlgItemMessageW(lan,702,LB_SETCURSEL,0,0); SendMessageW(lan,WM_COMMAND,MAKEWPARAM(702,LBN_SELCHANGE),0); for(int id:{711,712,713,714})require(IsWindowEnabled(GetDlgItem(lan,id)),"paired computer exposes all four layout positions"); SendDlgItemMessageW(lan,713,BM_CLICK,0,0); require(IsDlgButtonChecked(lan,713)==BST_CHECKED&&IsDlgButtonChecked(lan,712)==BST_UNCHECKED,"Above selection changes radio group"); SendMessageW(lan,WM_TIMER,1,0); require(IsDlgButtonChecked(lan,713)==BST_CHECKED,"status refresh preserves unsaved position choice"); require(!IsWindowEnabled(GetDlgItem(lan,715)),"input cannot start while discovery is disabled"); // Let the native radio-button theme animation finish before visual capture. auto repaintUntil=GetTickCount64()+200; while(GetTickCount64()