Files
hrynco-kswitcher/tests/input.cpp
T

852 lines
68 KiB
C++

#include "app.h"
#include "ui_scroll.h"
#include "ui_geometry.h"
#include "screenshot.h"
#include <commctrl.h>
#include <iostream>
#include <cmath>
LRESULT CALLBACK countControlMoves(HWND w, UINT message, WPARAM wp, LPARAM lp, UINT_PTR, DWORD_PTR data)
{
if (message == WM_WINDOWPOSCHANGING) ++*reinterpret_cast<int *>(data);
return DefSubclassProc(w, message, wp, lp);
}
namespace ks {
struct InputRegressionAccess {
static inline bool delivery = true;
static inline int notifications = 0;
static bool notify(const std::wstring &) { ++notifications; return delivery; }
static void sampleConflicts(App &a) {
a.conflictConnection=loadSyncConfig(a.data);
a.pendingSyncConflicts={{"/autoCorrectMinLength",L"Minimum word length",{'4'},{'5'}}};
}
static bool staleSync(App &a, bool connectionChanged) {
auto before=encodeSettings(a.settings);
a.syncConnection=loadSyncConfig(a.data);
a.syncSnapshot=before;
if(connectionChanged) a.syncConnection.enabled=!a.syncConnection.enabled;
else a.syncSnapshot.clear();
auto remote=a.settings; remote.recent=!remote.recent;
a.syncResult={true,sharedSyncSettings(remote),2,L"test"};
a.syncWorker=std::thread([]{});
a.syncRunning=true; a.syncDone=true;
a.pollSync();
return encodeSettings(a.settings)==before && !a.syncRunning &&
!std::filesystem::exists(a.data/L"sync-base.bin");
}
static bool syncBackoff(App &a) {
a.syncRetryAfter=GetTickCount64()+15*60000;
auto previous=a.settings;
auto next=previous;
next.toggle=next.casing=next.translit=next.undo=next.clipboard=next.screenshot=L"";
next.direct.clear(); next.directIds.clear();
// This harness deliberately has no global keyboard hook.
a.applySettings(next);
bool preserved=a.nextSync==a.syncRetryAfter;
a.settings=previous;
a.syncRetryAfter=0;
return preserved;
}
static void notificationSetup(App &a) { a.updateNotifier = notify; }
static void setup(App &a) { a.hookBindings = {{Toggle,{MOD_CONTROL,VK_F12}}, {Case,{MOD_CONTROL|MOD_SHIFT,'U'}}}; }
static bool updateTimer(App &a) {
a.updateRunning=true; // Exercise timer scheduling without launching a network request.
App::windowProc(a.window,WM_TIMER,5,0);
bool first=KillTimer(a.window,5)!=FALSE;
App::windowProc(a.window,WM_TIMER,5,0);
bool repeated=KillTimer(a.window,5)!=FALSE;
a.updateRunning=false;
return first && repeated && !a.updateWorker.joinable();
}
static std::wstring updateResult(App &a,const wchar_t *state,const wchar_t *version) {
auto result=new UpdateResult; result->state=state; result->version=version;
App::windowProc(a.window,UpdateMessage,0,(LPARAM)result);
return a.notifiedUpdate;
}
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
};
}
int main(int argc, char **argv) {
if (argc == 2 && std::string(argv[1]) == "--update-check") {
try {
auto result = ks::runUpdate(false, {});
std::cout << "Live update check: " << ks::utf8(result.state) << " " << ks::utf8(result.version)
<< " " << ks::utf8(result.error) << '\n';
return result.state == L"current" || result.state == L"available" ? 0 : 1;
} catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; }
}
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-input-tests-"+std::to_wstring(GetCurrentProcessId()));
int checks=0;
auto require=[&](bool ok,const char* text) { if(!ok) throw std::runtime_error(text); ++checks; };
try {
INITCOMMONCONTROLSEX controls{sizeof(controls),ICC_STANDARD_CLASSES|ICC_LISTVIEW_CLASSES};
InitCommonControlsEx(&controls);
{
ks::App app(GetModuleHandleW(nullptr),true,temp);
ks::InputRegressionAccess::setup(app);
ks::InputRegressionAccess::notificationSetup(app);
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
require(app.window!=nullptr,"message-only test window");
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
require(!ks::confirmUpdatedStartup(startedName),"missing updater handshake does not report success");
auto started=CreateEventW(nullptr,TRUE,FALSE,startedName.c_str());
require(started&&ks::confirmUpdatedStartup(startedName)&&WaitForSingleObject(started,0)==WAIT_OBJECT_0,
"successful startup signals the updater before reporting completion");
CloseHandle(started);
require(ks::UpdateCheckIntervalMs==21600000,"automatic update interval is six hours");
require(ks::InputRegressionAccess::updateTimer(app),"update timer rearms and avoids overlapping checks");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","background update records announced version");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.0")==L"99.0.0","same available version remains deduplicated");
require(ks::InputRegressionAccess::notifications==1,"successful background notification is submitted only once");
require(ks::InputRegressionAccess::updateResult(app,L"error",L"")==L"99.0.0","network error preserves announced version");
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.1")==L"99.0.1","new version can be announced after an error");
ks::InputRegressionAccess::delivery=false;
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.1","failed notification is not marked delivered");
require(KillTimer(app.window,5)!=FALSE,"failed notification schedules a retry");
ks::InputRegressionAccess::delivery=true;
require(ks::InputRegressionAccess::updateResult(app,L"available",L"99.0.2")==L"99.0.2","notification can succeed on retry");
auto toast=ks::updateToastXml(L"1&2<3\"");
require(toast.find(L"1&amp;2&lt;3&quot;")!=toast.npos,"update version is XML escaped");
require(toast.find(L"launch=\"check-update\"")!=toast.npos && toast.find(L"arguments=\"check-update\"")!=toast.npos,
"toast body and action both review the update instead of installing automatically");
for(int variant=0;variant<3;++variant) for(int icon=0;icon<15;++icon)
for(int size:{22,44,55}) {
auto handle=(HICON)LoadImageW(app.instance,MAKEINTRESOURCEW(icon<9?300+variant*10+icon:400+variant*10+icon-9),IMAGE_ICON,size,size,0);
require(handle!=nullptr,"Lucide toolbar icon loads at standard and high DPI");
DestroyIcon(handle);
}
{
ks::ScreenshotHistory history;
std::vector<ks::ScreenshotMark> marks;
require(!history.undo(marks) && !history.redo(marks),"empty annotation history is safe");
history.remember(marks);
marks.push_back({{20,20},{20,20},1,24});
history.remember(marks);
marks.push_back({{44,20},{100,20},0,15,8,1,0});
require(history.undo(marks) && marks.size()==1 && history.canRedo(),"undo preserves state for redo");
require(history.undo(marks) && marks.empty(),"multiple undo steps restore empty canvas");
require(history.redo(marks) && marks[0].radius==24,"redo restores marker size");
require(history.redo(marks) && marks.size()==2 && marks[1].thickness==8 && marks[1].fromNumber==1,
"redo restores arrow style and endpoint links");
require(!history.canRedo(),"redo becomes unavailable after last replay");
history.undo(marks);
history.remember(marks); marks[0].radius=40;
require(!history.canRedo() && !history.redo(marks),"new edit discards redo branch");
require(history.undo(marks) && marks[0].radius==24,"new branch remains undoable");
ks::ScreenshotHistory bounded;
for(int i=0;i<110;++i) { bounded.remember(marks); marks[0].radius=i; }
int count=0; while(bounded.undo(marks)) ++count;
require(count==100,"annotation undo history stays bounded");
count=0; while(bounded.redo(marks)) ++count;
require(count==100 && marks[0].radius==109,"bounded history can replay all retained edits");
}
{
HDC dc=CreateCompatibleDC(nullptr);
BITMAPINFO info{};
info.bmiHeader={sizeof(BITMAPINFOHEADER),160,-100,1,32,BI_RGB};
void *pixels{};
auto bitmap=CreateDIBSection(dc,&info,DIB_RGB_COLORS,&pixels,nullptr,0);
auto old=SelectObject(dc,bitmap);
PatBlt(dc,0,0,160,100,WHITENESS);
auto plain=ks::screenshotClip(dc,160,100,{});
auto marked=ks::screenshotClip(dc,160,100,{{{30,30},{30,30},1},{{60,70},{130,70},0}});
auto &bytes=marked.formats[0].data;
BITMAPINFOHEADER header{}; memcpy(&header,bytes.data(),sizeof(header));
require(header.biWidth==160&&header.biHeight==-100,"capture preserves source resolution and orientation");
require(bytes.size()==sizeof(header)+160*100*4,"capture exports complete 32-bit DIB");
auto pixel=[&](int x,int y) { auto offset=sizeof(header)+(y*160+x)*4; return RGB(bytes[offset+2],bytes[offset+1],bytes[offset]); };
require(pixel(30,20)==RGB(225,42,50),"number badge renders in exported image");
require(pixel(90,70)==RGB(225,42,50),"arrow shaft renders in exported image");
require(pixel(5,5)==RGB(255,255,255),"unmarked pixels preserved");
ks::ScreenshotMark blueArrow{{20,50},{140,50},0}; blueArrow.color=RGB(0,80,220);
ks::ScreenshotMark greenBadge{{30,30},{30,30},1}; greenBadge.color=RGB(0,200,80);
auto colored=ks::screenshotClip(dc,160,100,{blueArrow,greenBadge});
auto colorAt=[&](int x,int y) {
auto &b=colored.formats[0].data; auto at=sizeof(BITMAPINFOHEADER)+(y*160+x)*4;
return RGB(b[at+2],b[at+1],b[at]);
};
require(colorAt(70,50)==blueArrow.color && colorAt(132,50)==blueArrow.color,
"arrow shaft and head use object color");
require(colorAt(30,20)==greenBadge.color,"badge fill uses object color");
ks::ScreenshotHistory recolors;
std::vector<ks::ScreenshotMark> coloredMarks{blueArrow,greenBadge};
recolors.remember(coloredMarks); coloredMarks[0].color=RGB(200,0,100);
recolors.remember(coloredMarks); coloredMarks[1].color=RGB(255,255,0);
require(recolors.undo(coloredMarks) && coloredMarks[1].color==greenBadge.color,"undo restores badge color");
require(recolors.undo(coloredMarks) && coloredMarks[0].color==blueArrow.color,"undo restores arrow color");
require(recolors.redo(coloredMarks) && coloredMarks[0].color==RGB(200,0,100),"redo restores arrow recoloring");
require(ks::cloneScreenshotMark(greenBadge,{greenBadge},160,100).color==greenBadge.color,
"cloned badge retains customized color");
ks::ScreenshotMark thickArrow{{20,50},{140,50},0,15,12};
auto grip=ks::screenshotThicknessHandle(thickArrow);
require(grip.x==80 && grip.y==72,"thickness grip sits beside arrow midpoint");
auto thicker=ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x,grip.y+4},160,100);
require(thicker.thickness==20 && thicker.from.x==20 && thicker.to.x==140,
"dragging thickness grip changes width without changing endpoints");
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x+20,grip.y},160,100).thickness==12,
"movement along arrow does not change thickness");
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::Thickness,grip,{grip.x,0},160,100).thickness==1,
"thickness grip respects minimum width");
auto cloned=ks::cloneScreenshotMark(thickArrow,{},160,100);
require(cloned.thickness==12 && cloned.from.x!=thickArrow.from.x && cloned.to.x-cloned.from.x==120,
"cloned arrow is offset and preserves dimensions and style");
auto thick=ks::screenshotClip(dc,160,100,{thickArrow});
auto shaftEdge=sizeof(BITMAPINFOHEADER)+(54*160+40)*4;
require(thick.formats[0].data[shaftEdge+2]==225 && thick.formats[0].data[shaftEdge+1]==42,
"export renders edited arrow thickness");
thickArrow.thickness=1;
auto thin=ks::screenshotClip(dc,160,100,{thickArrow});
require(thin.formats[0].data[shaftEdge+1]==255,"thin arrow leaves surrounding pixels untouched");
thickArrow.thickness=12;
require(ks::hitScreenshotMark(thickArrow,{60,55},1)==ks::MarkPart::Body,
"thick arrow can be selected along its visible edge");
require(ks::dragScreenshotMark(thickArrow,ks::MarkPart::End,{140,50},{145,70},160,100).thickness==12,
"editing arrow geometry preserves thickness");
ks::ScreenshotMark badge{{60,50},{60,50},2};
auto clonedBadge=ks::cloneScreenshotMark(badge,{badge,{{90,20},{90,20},5}},160,100);
require(clonedBadge.number==1 && clonedBadge.radius==15 && clonedBadge.from.x==76,
"cloned badge fills first numbering gap and keeps diameter");
require(ks::nextScreenshotNumber({})==1,"empty capture starts numbering at one");
std::vector<ks::ScreenshotMark> numbered{
{{10,10},{10,10},4},{{20,10},{20,10},1},{{30,10},{30,10},3},{{0,0},{100,0},0}};
require(ks::nextScreenshotNumber(numbered)==2,"new badge fills interior gap regardless of stacking order");
numbered.push_back({{40,10},{40,10},2});
require(ks::nextScreenshotNumber(numbered)==5,"full sequence advances past existing numbers");
numbered.erase(numbered.begin()+1);
require(ks::nextScreenshotNumber(numbered)==1,"deleted first number is reused");
require(numbered[0].number==4 && numbered[1].number==3,"number allocation never renumbers existing objects");
std::vector<ks::ScreenshotMark> linked{
{{30,50},{30,50},1}, {{130,50},{130,50},2}, {{30,50},{130,50},0}};
ks::linkScreenshotEndpoint(linked[2],true,{30,50},linked,6);
ks::linkScreenshotEndpoint(linked[2],false,{130,50},linked,6);
ks::resolveScreenshotLinks(linked);
require(linked[2].fromNumber==1 && linked[2].toNumber==2 && linked[2].from.x==45 && linked[2].to.x==115,
"arrow endpoints attach to badge rims");
auto clonedLinked=ks::cloneScreenshotMark(linked[2],linked,160,100);
require(clonedLinked.fromNumber==0 && clonedLinked.toNumber==0,"cloned arrow does not retain original links");
auto widthLinked=ks::dragScreenshotMark(linked[2],ks::MarkPart::Thickness,{80,70},{80,74},160,100);
require(widthLinked.fromNumber==1 && widthLinked.toNumber==2,"thickness handle preserves endpoint links");
linked[0].from.y=20; linked[0].to.y=20;
linked[1].radius=25;
ks::resolveScreenshotLinks(linked);
require(linked[2].from.y>20 && linked[2].from.y<50 && linked[2].to.x<110,
"linked arrows follow badge movement and resize");
auto detached=ks::dragScreenshotMark(linked[2],ks::MarkPart::Start,linked[2].from,{10,90},160,100);
require(detached.fromNumber==0 && detached.toNumber==2,"endpoint drag detaches only that endpoint");
ks::linkScreenshotEndpoint(detached,true,{10,90},linked,6);
require(detached.fromNumber==0,"dropping away from badges remains detached");
auto translated=ks::dragScreenshotMark(linked[2],ks::MarkPart::Body,{80,35},{85,40},160,100);
require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints");
for(auto shape : {ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
std::vector<ks::ScreenshotMark> objects={{{100,50},{160,110},0},{{10,80},{100,80},0},{{10,140},{55,80},0}};
objects[0].shape=shape;
ks::linkScreenshotEndpoint(objects[1],false,{100,80},objects,6);
ks::resolveScreenshotLinks(objects);
require(objects[1].toLink.target==objects[0].id && objects[1].to.x==100,"arrow attaches to shape boundary");
ks::linkScreenshotEndpoint(objects[2],false,{55,80},objects,6);
ks::resolveScreenshotLinks(objects);
require(objects[2].toLink.target==objects[1].id,"arrow attaches to another arrow body");
auto targetId=objects[0].id,arrowId=objects[1].id;
auto snapshot=objects; ks::ScreenshotHistory linksHistory; linksHistory.remember(objects);
// Reverse storage order so dependency resolution cannot rely on insertion order.
std::reverse(objects.begin(),objects.end());
objects[2]=ks::dragScreenshotMark(objects[2],ks::MarkPart::Body,{0,0},{20,20},400,300);
ks::resolveScreenshotLinks(objects);
require(objects[1].to.x==120 && objects[1].to.y==100 && objects[0].to.x==65 && objects[0].to.y==90,
"shape-arrow-arrow chain updates in dependency order");
require(linksHistory.undo(objects) && objects[1].to.x==100 && objects[2].toLink.target==arrowId,
"undo restores geometry and generic links");
require(linksHistory.redo(objects) && objects[0].to.x==65,"redo restores moved chain");
auto resized=objects;
auto corner=resized[2].to;
resized[2]=ks::dragScreenshotMark(resized[2],ks::MarkPart::End,corner,{corner.x+20,corner.y+20},400,300);
ks::resolveScreenshotLinks(resized);
require(resized[1].to.x==120 && resized[1].to.y==110 && resized[0].to.y==95,
"resizing shapes updates both levels of attached arrows");
ks::linkScreenshotEndpoint(objects[1],true,objects[0].from,objects,6);
require(!objects[1].fromLink.target,"cyclic arrow links are rejected");
auto clone=ks::cloneScreenshotMark(objects[1],objects,400,300);
require(!clone.id && !clone.fromLink.target && !clone.toLink.target,"cloned connector has independent identity and no links");
auto endpoint=objects[1].to;
objects.erase(objects.begin()+2); ks::resolveScreenshotLinks(objects);
require(!objects[1].toLink.target && objects[1].to.x==endpoint.x && objects[0].toLink.target==arrowId,
"deleting shape detaches only its dependents without moving endpoints");
objects.push_back(snapshot[0]); objects.back().id=0; ks::resolveScreenshotLinks(objects);
require(objects.back().id!=targetId && !objects[1].toLink.target,"new objects cannot revive deleted links");
}
{
std::vector<ks::ScreenshotMark> objects={{{80,40},{80,40},0},{{10,40},{80,40},0}};
objects[0].text=L"Target"; objects[0].fontSize=20;
auto bounds=ks::screenshotTextBounds(objects[0]);
ks::linkScreenshotEndpoint(objects[1],false,{bounds.left,(bounds.top+bounds.bottom)/2},objects,6);
ks::resolveScreenshotLinks(objects);
require(objects[1].toLink.target==objects[0].id,"arrow attaches to text block");
auto old=objects[1].to;
objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{30,20},400,300);
ks::resolveScreenshotLinks(objects);
require(objects[1].to.x==old.x+30 && objects[1].to.y==old.y+20,"text attachment follows movement");
objects[0].rotation=90; ks::resolveScreenshotLinks(objects);
require(objects[1].to.x!=old.x+30 || objects[1].to.y!=old.y+20,"text attachment follows rotation");
}
for(bool freehand : {false,true}) {
std::vector<ks::ScreenshotMark> objects={{{80,60},{160,80},0},{{10,140},{110,60},0}};
objects[0].points={{80,60},{140,60},{160,80}}; objects[0].freehand=freehand;
ks::linkScreenshotEndpoint(objects[1],false,{110,60},objects,6);
ks::resolveScreenshotLinks(objects);
require(objects[1].toLink.target==objects[0].id,"arrow attaches to freehand and highlight strokes");
objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{20,20},400,300);
ks::resolveScreenshotLinks(objects);
require(objects[1].to.x==130 && objects[1].to.y==80,"stroke attachment follows the original segment");
}
auto lastPoint=linked[2].from;
linked.erase(linked.begin());
ks::resolveScreenshotLinks(linked);
require(linked[1].fromNumber==0 && linked[1].toNumber==2 && linked[1].from.x==lastPoint.x,
"deleting badge detaches without moving orphaned endpoint");
linked.push_back({{10,10},{10,10},1});
ks::resolveScreenshotLinks(linked);
require(linked[1].fromNumber==0,"reused number cannot reconnect a deleted link");
require(ks::hitScreenshotMark(badge,{60,50},20)==ks::MarkPart::Body,"badge center moves even with large hit tolerance");
require(ks::hitScreenshotMark(badge,{75,50},6)==ks::MarkPart::Radius,"badge rim exposes resize handle");
auto enlarged=ks::dragScreenshotMark(badge,ks::MarkPart::Radius,{75,50},{95,50},160,100);
require(enlarged.radius==35 && enlarged.number==2,"badge resizes without changing its number");
auto large=ks::screenshotClip(dc,160,100,{enlarged});
const auto &largeBytes=large.formats[0].data;
auto red=sizeof(BITMAPINFOHEADER)+(50*160+90)*4;
require(largeBytes[red+2]==225 && largeBytes[red+1]==42,"export uses edited badge diameter");
require(ks::dragScreenshotMark(badge,ks::MarkPart::Radius,{75,50},{60,50},160,100).radius==8,
"badge diameter has a usable minimum");
ks::ScreenshotMark arrow{{20,20},{100,60},0};
require(ks::hitScreenshotMark(arrow,{60,40},5)==ks::MarkPart::Body,"arrow shaft selects whole arrow");
require(ks::hitScreenshotMark(arrow,{100,60},5)==ks::MarkPart::End,"arrow tip selects endpoint");
require(ks::hitScreenshotMark(arrow,{60,80},5)==ks::MarkPart::None,"empty canvas misses arrow");
auto moved=ks::dragScreenshotMark(arrow,ks::MarkPart::Body,{60,40},{150,90},160,100);
require(moved.to.x==159 && moved.to.y==99 && moved.to.x-moved.from.x==80 && moved.to.y-moved.from.y==40,
"moving arrow preserves shape at image boundary");
auto stretched=ks::dragScreenshotMark(arrow,ks::MarkPart::End,{100,60},{140,80},160,100);
require(stretched.from.x==20 && stretched.from.y==20 && stretched.to.x==140 && stretched.to.y==80,
"endpoint drag leaves opposite endpoint fixed");
require(ks::screenshotClip(dc,160,100,{}).formats[0].data==plain.formats[0].data,
"annotations do not modify original capture");
ks::ScreenshotMark highlight{{20,50},{120,50},0,15,24};
highlight.points={{20,50},{120,50}}; highlight.color=RGB(255,255,0);
SetPixel(dc,60,50,RGB(0,0,0));
auto yellow=ks::screenshotClip(dc,160,100,{highlight});
auto readPixel=[](const ks::Clip &clip,int x,int y) {
auto &b=clip.formats[0].data; auto offset=sizeof(BITMAPINFOHEADER)+(y*160+x)*4;
return RGB(b[offset+2],b[offset+1],b[offset]);
};
require(readPixel(yellow,40,50)==RGB(255,255,153),"highlight is translucent in exported image");
require(readPixel(yellow,60,50)==RGB(0,0,0),"highlight keeps black text legible");
require(readPixel(yellow,40,10)==RGB(255,255,255),"highlight leaves pixels outside brush unchanged");
highlight.points.push_back({20,50});
require(readPixel(ks::screenshotClip(dc,160,100,{highlight}),40,50)==RGB(255,255,153),
"overlapping a stroke with itself does not accumulate opacity");
ks::ScreenshotHistory colorHistory;
std::vector<ks::ScreenshotMark> strokes{highlight};
colorHistory.remember(strokes); strokes[0].color=RGB(0,255,255);
require(readPixel(ks::screenshotClip(dc,160,100,strokes),40,50)==RGB(153,255,255),
"changing highlight color rerenders original stroke");
require(colorHistory.undo(strokes) && strokes[0].color==RGB(255,255,0),"undo restores highlight color");
require(colorHistory.redo(strokes) && strokes[0].color==RGB(0,255,255),"redo restores highlight recolor");
require(ks::hitScreenshotMark(highlight,{80,55},1)==ks::MarkPart::Body,"highlight stroke is selectable");
require(ks::hitScreenshotMark(highlight,{80,10},1)==ks::MarkPart::None,"highlight hit test excludes empty area");
auto movedHighlight=ks::dragScreenshotMark(highlight,ks::MarkPart::Body,{20,50},{30,60},160,100);
require(movedHighlight.points[1].x==130 && movedHighlight.points[1].y==60,"moving highlight translates entire stroke");
auto cloneHighlight=ks::cloneScreenshotMark(highlight,{highlight},160,100);
require(cloneHighlight.points[0].x!=highlight.points[0].x && cloneHighlight.color==highlight.color &&
cloneHighlight.thickness==24 && cloneHighlight.number==0,"cloning highlight preserves brush and color");
PatBlt(dc,0,0,160,100,WHITENESS);
ks::ScreenshotMark line{{20,50},{140,50},0}; line.ends=ks::LineEnds::None;
ks::ScreenshotMark textMark{{5,5},{5,5},0}; textMark.text=L"Hello &\r\nСвіт";
textMark.fontSize=14; textMark.color=RGB(0,80,180);
auto textBounds=ks::screenshotTextBounds(textMark);
require(textBounds.right>30 && textBounds.bottom>30,"multiline Unicode text is measured");
auto textImage=ks::screenshotClip(dc,160,100,{textMark});
int textInk=0;
for(int y=5;y<textBounds.bottom;++y) for(int x=5;x<textBounds.right;++x)
if(readPixel(textImage,x,y)==textMark.color) ++textInk;
require(textInk>20,"text exports visible colored glyphs");
require(ks::hitScreenshotMark(textMark,{6,6},2)==ks::MarkPart::Body,"text is movable inside its bounds");
require(ks::hitScreenshotMark(textMark,{150,90},2)==ks::MarkPart::None,"text excludes empty canvas");
auto bigText=textMark; bigText.fontSize=28;
auto bigBounds=ks::screenshotTextBounds(bigText);
require(bigBounds.bottom>textBounds.bottom && bigBounds.right>textBounds.right,"font size scales text bounds");
auto serifText=textMark; serifText.fontName=L"Courier New"; serifText.fontWeight=FW_BOLD; serifText.fontItalic=true;
require(ks::screenshotClip(dc,160,100,{serifText}).formats[0].data!=textImage.formats[0].data,
"font family and style affect exported glyphs");
auto movedText=ks::dragScreenshotMark(textMark,ks::MarkPart::Body,{6,6},{16,16},160,100);
auto rotatedText=textMark; rotatedText.rotation=90;
auto rotatedBounds=ks::screenshotTextBounds(rotatedText);
require(std::abs((rotatedBounds.right-rotatedBounds.left)-(textBounds.bottom-textBounds.top))<=1,
"rotation transforms text bounds");
POINT center{(textBounds.left+textBounds.right)/2,(textBounds.top+textBounds.bottom)/2};
require(ks::hitScreenshotMark(rotatedText,center,2)==ks::MarkPart::Body,"rotated text hit test follows text");
auto rotationGrip=ks::screenshotTextRotationHandle(textMark);
auto rotation=ks::dragScreenshotMark(textMark,ks::MarkPart::Rotation,rotationGrip,{center.x+40,center.y-25},160,100,true);
require(std::abs(std::remainder(rotation.rotation,15))<0.001 && rotation.rotation!=0,"Shift snaps rotation to 15 degrees");
auto reverseRotation=ks::dragScreenshotMark(textMark,ks::MarkPart::Rotation,rotationGrip,{center.x-40,center.y-25},160,100,false);
require(reverseRotation.rotation<0,"text rotates in the opposite direction");
require(ks::screenshotClip(dc,160,100,{rotatedText}).formats[0].data!=textImage.formats[0].data,
"rotation is included in exported text pixels");
require(ks::cloneScreenshotMark(rotatedText,{rotatedText},160,100).rotation==90,"cloning preserves text rotation");
ks::ScreenshotMark labeled{{20,70},{140,70},0}; labeled.label=L"Label"; labeled.fontSize=12;
auto label=ks::screenshotArrowLabel(labeled);
auto labelBounds=ks::screenshotTextBounds(label);
POINT labelCenter{(labelBounds.left+labelBounds.right)/2,(labelBounds.top+labelBounds.bottom)/2};
auto labelGrip=ks::screenshotTextRotationHandle(label);
auto diagonal=labeled; diagonal.to={100,150};
for(int degrees:{0,45,90,135,180,-135,-90,-45}) {
double angle=degrees*3.141592653589793/180;
auto turning=labeled; turning.from={100,100};
turning.to={100+LONG(std::lround(80*std::cos(angle))),100+LONG(std::lround(80*std::sin(angle)))};
auto box=ks::screenshotTextBounds(ks::screenshotArrowLabel(turning));
double x=(box.left+box.right)/2.0-(turning.from.x+turning.to.x)/2.0;
double y=(box.top+box.bottom)/2.0-(turning.from.y+turning.to.y)/2.0;
require(std::abs(-x*std::sin(angle)+y*std::cos(angle)+24)<1.5,
"caption keeps its perpendicular distance and side as line rotates");
require(std::abs(x*std::cos(angle)+y*std::sin(angle))<1.5,
"caption remains centered along rotated line");
}
auto beforeDrag=ks::screenshotTextBounds(ks::screenshotArrowLabel(diagonal));
auto offsetDiagonal=ks::dragScreenshotMark(diagonal,ks::MarkPart::Label,{0,0},{12,-8},200,200);
require(!offsetDiagonal.labelRotationManual,"moving caption preserves automatic rotation");
auto movedCaptionSlope=ks::dragScreenshotMark(offsetDiagonal,ks::MarkPart::End,offsetDiagonal.to,{20,150},200,200);
require(std::abs(ks::screenshotArrowLabel(movedCaptionSlope).rotation-90)<0.001,
"repositioned caption continues following line angle");
auto afterDrag=ks::screenshotTextBounds(ks::screenshotArrowLabel(offsetDiagonal));
require(std::abs(afterDrag.left-beforeDrag.left-12)<=1 && std::abs(afterDrag.top-beforeDrag.top+8)<=1,
"dragging diagonal caption follows pointer in screen coordinates");
require(std::abs(ks::screenshotArrowLabel(diagonal).rotation-45)<0.001,"caption defaults to line angle");
auto changedSlope=ks::dragScreenshotMark(diagonal,ks::MarkPart::End,diagonal.to,{20,150},200,200);
require(std::abs(ks::screenshotArrowLabel(changedSlope).rotation-90)<0.001,"automatic caption follows endpoint movement");
auto reversed=diagonal; std::swap(reversed.from,reversed.to);
require(std::abs(ks::screenshotArrowLabel(reversed).rotation-45)<0.001,"automatic caption remains upright on reversed line");
auto diagonalLabel=ks::screenshotArrowLabel(diagonal);
auto diagonalGrip=ks::screenshotTextRotationHandle(diagonalLabel);
auto clickOnly=ks::dragScreenshotMark(diagonal,ks::MarkPart::Rotation,diagonalGrip,diagonalGrip,200,200,true);
require(!clickOnly.labelRotationManual,"clicking rotation handle without dragging preserves automatic angle");
auto manual=ks::dragScreenshotMark(diagonal,ks::MarkPart::Rotation,diagonalGrip,{diagonalGrip.x+12,diagonalGrip.y},200,200,false);
require(manual.labelRotationManual && manual.rotation>45 && manual.rotation<90,
"manual rotation starts at displayed automatic angle without jumping");
auto manualResized=ks::dragScreenshotMark(manual,ks::MarkPart::End,manual.to,{20,150},200,200);
require(ks::screenshotArrowLabel(manualResized).rotation==manual.rotation,"manual angle survives line resizing");
require(ks::cloneScreenshotMark(manual,{manual},200,200).labelRotationManual,"clone preserves manual rotation mode");
ks::ScreenshotHistory autoAngleHistory; std::vector<ks::ScreenshotMark> autoAngles{diagonal};
autoAngleHistory.remember(autoAngles); autoAngles[0]=manual;
require(autoAngleHistory.undo(autoAngles) && !autoAngles[0].labelRotationManual,"undo restores automatic angle mode");
require(autoAngleHistory.redo(autoAngles) && autoAngles[0].labelRotationManual,"redo restores manual angle mode");
auto curvedCaption=diagonal; curvedCaption.freehand=true;
curvedCaption.points={{20,70},{60,70},{60,110},{100,150}};
require(std::abs(ks::screenshotArrowLabel(curvedCaption).rotation-90)<0.001,"curve caption follows its middle segment");
auto rotatedCaption=ks::dragScreenshotMark(labeled,ks::MarkPart::Rotation,labelGrip,
{labelCenter.x+35,labelCenter.y-20},160,100,true);
require(rotatedCaption.rotation!=0 && std::abs(std::remainder(rotatedCaption.rotation,15))<0.001,
"arrow caption rotation snaps to 15 degrees");
require(rotatedCaption.from.x==labeled.from.x && rotatedCaption.from.y==labeled.from.y &&
rotatedCaption.to.x==labeled.to.x && rotatedCaption.to.y==labeled.to.y &&
rotatedCaption.labelOffset.x==labeled.labelOffset.x && rotatedCaption.labelOffset.y==labeled.labelOffset.y,
"rotating caption preserves arrow geometry and caption position");
require(ks::screenshotArrowLabel(rotatedCaption).rotation==rotatedCaption.rotation,
"linked caption uses the regular text rotation");
require(ks::screenshotClip(dc,160,100,{rotatedCaption}).formats[0].data!=ks::screenshotClip(dc,160,100,{labeled}).formats[0].data,
"caption rotation is exported");
require(ks::cloneScreenshotMark(rotatedCaption,{rotatedCaption},160,100).rotation==rotatedCaption.rotation,
"cloning retains caption angle");
ks::ScreenshotHistory captionHistory; std::vector<ks::ScreenshotMark> captionMarks{labeled};
captionHistory.remember(captionMarks); captionMarks[0]=rotatedCaption;
require(captionHistory.undo(captionMarks) && captionMarks[0].rotation==0,"undo restores caption angle");
require(captionHistory.redo(captionMarks) && captionMarks[0].rotation==rotatedCaption.rotation,"redo restores caption angle");
require(ks::hitScreenshotMark(labeled,labelCenter,2)==ks::MarkPart::Label,"arrow label is independently draggable");
auto repositioned=ks::dragScreenshotMark(labeled,ks::MarkPart::Label,labelCenter,{labelCenter.x+10,labelCenter.y-8},160,100);
require(repositioned.labelOffset.x==10 && repositioned.labelOffset.y==-32 && repositioned.from.x==20,
"label drag changes only its offset");
auto movedArrow=ks::dragScreenshotMark(labeled,ks::MarkPart::Body,{20,70},{25,75},160,100);
auto movedLabel=ks::screenshotArrowLabel(movedArrow);
require(movedLabel.from.x==label.from.x+5 && movedLabel.from.y==label.from.y+5,"label follows arrow movement");
auto resizedArrow=ks::dragScreenshotMark(labeled,ks::MarkPart::End,labeled.to,{120,70},160,100);
require(ks::screenshotArrowLabel(resizedArrow).from.x==label.from.x-10,"label follows arrow resizing");
auto noLabel=labeled; noLabel.label.clear();
require(ks::screenshotClip(dc,160,100,{labeled}).formats[0].data!=ks::screenshotClip(dc,160,100,{noLabel}).formats[0].data,
"arrow label is included in clipboard export");
require(ks::cloneScreenshotMark(labeled,{labeled},160,100).label==labeled.label,"cloning retains arrow label");
ks::ScreenshotHistory annotationHistory; std::vector<ks::ScreenshotMark> annotations{rotatedText,labeled};
annotationHistory.remember(annotations); annotations[0]=rotation; annotations[1]=repositioned;
require(annotationHistory.undo(annotations) && annotations[0].rotation==90 && annotations[1].labelOffset.x==0,
"undo restores rotation and label position");
require(annotationHistory.redo(annotations) && annotations[0].rotation==rotation.rotation && annotations[1].labelOffset.x==10,
"redo restores rotation and label position");
require(movedText.from.x==15 && movedText.from.y==15 && movedText.text==textMark.text,"text movement preserves content");
auto oversizedText=textMark; oversizedText.fontSize=100;
auto oversizedBounds=ks::screenshotTextBounds(oversizedText);
require(oversizedBounds.right-oversizedBounds.left>160 && oversizedBounds.bottom-oversizedBounds.top>100,
"large text exceeds both canvas dimensions");
for(int direction:{-1,1}) {
auto moved=ks::dragScreenshotMark(oversizedText,ks::MarkPart::Body,{0,0},{direction*20,direction*20},160,100);
require(moved.from.x==oversizedText.from.x+direction*20 && moved.from.y==oversizedText.from.y+direction*20,
"oversized text moves in either direction on both axes");
auto limited=ks::dragScreenshotMark(oversizedText,ks::MarkPart::Body,{0,0},{direction*10000,direction*10000},160,100);
auto bounds=ks::screenshotTextBounds(limited);
require(bounds.right>=16 && bounds.left<=144 && bounds.bottom>=16 && bounds.top<=84,
"oversized text remains selectable at canvas edges");
}
auto clonedText=ks::cloneScreenshotMark(serifText,{serifText},160,100);
require(clonedText.text==serifText.text && clonedText.fontName==serifText.fontName &&
clonedText.fontWeight==FW_BOLD && clonedText.fontItalic,"text cloning preserves font and content");
ks::ScreenshotHistory textHistory; std::vector<ks::ScreenshotMark> texts{textMark};
textHistory.remember(texts); texts[0]=serifText; texts[0].text=L"Changed";
require(textHistory.undo(texts) && texts[0].text==textMark.text && texts[0].fontName==textMark.fontName,
"undo restores text and font");
require(textHistory.redo(texts) && texts[0].text==L"Changed" && texts[0].fontItalic,
"redo restores text edits");
for(auto shape:{ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
ks::ScreenshotMark figure{{30,20},{90,80},0}; figure.shape=shape;
if(shape==ks::ScreenshotShape::Ellipse) figure.to.x=130;
figure.color=RGB(0,140,80); figure.thickness=4;
auto image=ks::screenshotClip(dc,160,100,{figure});
int mid=(figure.from.x+figure.to.x)/2;
require(readPixel(image,mid,20)==figure.color,"shape contour renders in chosen color");
require(readPixel(image,mid,50)==RGB(255,255,255),"shape interior remains transparent");
require(ks::hitScreenshotMark(figure,{mid,20},2)==ks::MarkPart::Body,"shape contour is movable");
require(ks::hitScreenshotMark(figure,{mid,50},2)==ks::MarkPart::None,"shape interior does not intercept objects");
auto resized=ks::dragScreenshotMark(figure,ks::MarkPart::End,figure.to,{100,85},160,100);
require(resized.shape==shape && resized.to.x==100,"shape corner can resize object");
if(shape==ks::ScreenshotShape::Circle)
require(resized.to.x-resized.from.x==resized.to.y-resized.from.y,"circle remains round when resized");
auto moved=ks::dragScreenshotMark(figure,ks::MarkPart::Body,{mid,20},{mid+5,25},160,100);
require(moved.from.x==35 && moved.to.x==figure.to.x+5,"moving shape preserves dimensions");
auto duplicate=ks::cloneScreenshotMark(figure,{figure},160,100);
require(duplicate.shape==shape && duplicate.color==figure.color,"cloning preserves shape and color");
ks::ScreenshotHistory history; std::vector<ks::ScreenshotMark> figures{figure};
history.remember(figures); figures[0]=resized;
require(history.undo(figures) && figures[0].to.x==figure.to.x,"shape resize can be undone");
require(history.redo(figures) && figures[0].to.x==resized.to.x,"shape resize can be redone");
}
auto circleCorner=ks::constrainScreenshotShape(ks::ScreenshotShape::Circle,{60,60},{0,10},160,100);
require(circleCorner.x==0 && circleCorner.y==0,"circle supports reverse drawing and canvas boundaries");
auto solidLine=ks::screenshotClip(dc,160,100,{line});
require(readPixel(solidLine,28,47)==RGB(255,255,255) && readPixel(solidLine,132,47)==RGB(255,255,255),
"plain line has no arrowheads");
for(auto ends:{ks::LineEnds::End,ks::LineEnds::Start,ks::LineEnds::Both}) {
line.ends=ends;
auto image=ks::screenshotClip(dc,160,100,{line});
require((readPixel(image,28,47)==line.color)==(ends!=ks::LineEnds::End),"start arrowhead matches ends setting");
require((readPixel(image,132,47)==line.color)==(ends!=ks::LineEnds::Start),"end arrowhead matches ends setting");
}
line.ends=ks::LineEnds::None;
std::vector<ks::Bytes> styleImages;
ks::ScreenshotMark shaky{{20,50},{140,50},0};
shaky.freehand=true; shaky.ends=ks::LineEnds::Both;
shaky.points={{20,50},{19,50},{40,50},{120,50},{141,50},{140,50}};
auto shakyImage=ks::screenshotClip(dc,160,100,{shaky});
require(readPixel(shakyImage,28,47)==shaky.color && readPixel(shakyImage,12,47)==RGB(255,255,255),
"start arrowhead points outward despite initial mouse jitter");
require(readPixel(shakyImage,132,47)==shaky.color && readPixel(shakyImage,148,47)==RGB(255,255,255),
"end arrowhead points outward despite final mouse jitter");
for(auto style:{ks::LineStyle::Solid,ks::LineStyle::Dotted,ks::LineStyle::Dashed,ks::LineStyle::DashDot}) {
line.style=style;
auto image=ks::screenshotClip(dc,160,100,{line});
int ink=0,gaps=0;
for(int x=25;x<135;++x) { if(readPixel(image,x,50)==line.color) ++ink; else ++gaps; }
require(ink>0 && (style==ks::LineStyle::Solid?gaps==0:gaps>0),"line pattern exports visible ink and expected gaps");
for(auto &previous:styleImages) require(previous!=image.formats[0].data,"line styles have distinct output");
styleImages.push_back(image.formats[0].data);
}
ks::ScreenshotMark curve{{20,80},{140,30},0}; curve.freehand=true; curve.ends=ks::LineEnds::None;
curve.points={{20,80},{50,20},{90,70},{140,30}}; curve.color=RGB(0,80,220);
auto curveImage=ks::screenshotClip(dc,160,100,{curve});
require(!curve.isHighlight() && readPixel(curveImage,50,20)==curve.color,"freehand line exports opaque curved path");
require(ks::hitScreenshotMark(curve,{50,20},3)==ks::MarkPart::Body,"curve hit test follows actual stroke");
auto curveMoved=ks::dragScreenshotMark(curve,ks::MarkPart::Body,{50,20},{55,25},160,100);
require(curveMoved.points[1].x==55 && curveMoved.points[1].y==25,"curve movement preserves all intermediate points");
auto curveEnd=ks::dragScreenshotMark(curve,ks::MarkPart::End,curve.to,{150,40},160,100);
require(curveEnd.points.back().x==150 && curveEnd.points.back().y==40 && curveEnd.points[1].y==20,
"curve endpoint editing preserves intermediate shape");
curve.style=ks::LineStyle::DashDot; curve.ends=ks::LineEnds::Both;
auto curveCopy=ks::cloneScreenshotMark(curve,{curve},160,100);
require(curveCopy.freehand && curveCopy.style==curve.style && curveCopy.ends==curve.ends,"curve cloning preserves style and arrowheads");
ks::ScreenshotHistory lineHistory;
std::vector<ks::ScreenshotMark> lines{curve}; lineHistory.remember(lines);
lines[0].style=ks::LineStyle::Dotted; lines[0].ends=ks::LineEnds::None;
require(lineHistory.undo(lines) && lines[0].style==ks::LineStyle::DashDot && lines[0].ends==ks::LineEnds::Both,
"undo restores line pattern and endpoints");
require(lineHistory.redo(lines) && lines[0].style==ks::LineStyle::Dotted && lines[0].ends==ks::LineEnds::None,
"redo restores line pattern and endpoints");
SelectObject(dc,old); DeleteObject(bitmap); DeleteDC(dc);
}
auto event=[](DWORD vk,DWORD scan=0,bool own=false) {
KBDLLHOOKSTRUCT k{}; k.vkCode=vk; k.scanCode=scan;
k.flags=LLKHF_INJECTED|LLKHF_LOWER_IL_INJECTED;
k.dwExtraInfo=own?ks::inputMarker():123; return k;
};
auto commands=[&] { MSG m{}; int n=0; while(PeekMessageW(&m,app.window,WM_HOTKEY,WM_HOTKEY,PM_REMOVE)) ++n; return n; };
auto f12=event(VK_F12);
require(!app.handleKeyboard(event(VK_LCONTROL),true,0),"remote modifier passes through");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote Ctrl+F12 handled");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"remote repeat suppressed");
require(commands()==1,"one command per press");
require(!app.handleKeyboard(event(VK_F12,0,true),false,0),"own key-up bypasses latch");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"own key-up leaves remote latch intact");
require(commands()==0,"no repeat after own key-up");
require(app.handleKeyboard(f12,false,0),"remote release consumed after modifier release");
require(app.handleKeyboard(f12,true,MOD_CONTROL),"second remote press handled");
require(commands()==1,"second remote press dispatches");
app.handleKeyboard(f12,false,0);
require(app.handleKeyboard(event('U'),true,MOD_CONTROL|MOD_SHIFT),"combined modifiers");
app.handleKeyboard(event('U'),false,0);
require(!app.handleKeyboard(f12,true,MOD_CONTROL|MOD_SHIFT),"exact modifiers only");
app.resetInput();
app.handleKeyboard(event('A',0x1e),true,0);
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)})
for(bool up:{false,true}) {
auto i=ks::keyboardInput(key,up?KEYEVENTF_KEYUP:0);
require(i.ki.dwExtraInfo==ks::inputMarker(),"all generated events tagged");
require(!app.handleKeyboard(event(key,0,true),!up,MOD_CONTROL),"own events bypass handling");
}
require(app.run.keys==keys&&app.inputVersion==version,"own input preserves transaction and run");
app.handleKeyboard(event(VK_BACK),true,0);
require(app.run.keys.empty(),"remote backspace recorded");
app.handleKeyboard(event('B'),true,MOD_SHIFT);
require(!app.run.keys.empty(),"VK-only remote input resolves scan code");
require(app.inputVersion>version,"remote input invalidates pending transaction");
app.handleKeyboard(event(VK_PACKET,0x1e),true,0);
require(app.run.keys.empty(),"Unicode packet is not a physical scan code");
app.handleKeyboard(event('A',0x1e),true,0);
ks::InputRegressionAccess::click();
require(app.run.keys.empty(),"remote mouse resets recorded input");
auto physical=f12; physical.flags=0; physical.dwExtraInfo=ks::inputMarker();
require(app.handleKeyboard(physical,true,MOD_CONTROL),"physical hotkey still works");
app.handleKeyboard(physical,false,0);
auto fitted=ks::fitWindowRect({-2500,-800,-500,1200},{0,0,1200,800});
require(fitted.left==0&&fitted.top==0&&fitted.right==1200&&fitted.bottom==800,"oversized disconnected-monitor window fits work area");
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false);
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync available without local opt-in");
DestroyWindow(app.settingsWindow);
ks::saveSyncConfig(app.data,ks::SyncConfig{});
ks::showSettings(app,false);
require(GetDlgItem(app.settingsWindow,258)!=nullptr,"sync button available after local opt-in");
SendMessageW(app.settingsWindow,WM_COMMAND,258,0);
auto syncWindow=FindWindowW(L"KSwitcher.Sync",nullptr);
require(syncWindow!=nullptr,"private sync dialog opens");
ShowWindow(syncWindow,SW_HIDE);
require((GetWindowLongPtrW(GetDlgItem(syncWindow,604),GWL_STYLE)&ES_PASSWORD)!=0,
"sync password is masked");
require(!IsWindowEnabled(app.settingsWindow),"sync dialog owns settings interaction");
ks::InputRegressionAccess::sampleConflicts(app);
SendMessageW(syncWindow,WM_COMMAND,612,0);
auto conflictsWindow=FindWindowW(L"KSwitcher.SyncConflicts",nullptr);
require(conflictsWindow!=nullptr,"per-setting conflict dialog opens");
ShowWindow(conflictsWindow,SW_HIDE);
require(SendDlgItemMessageW(conflictsWindow,620,LB_GETCOUNT,0,0)==1,"conflict shown as separate setting");
wchar_t conflictValue[20]{}; GetDlgItemTextW(conflictsWindow,621,conflictValue,20);
require(std::wstring(conflictValue)==L"4","local conflict value shown");
GetDlgItemTextW(conflictsWindow,622,conflictValue,20);
require(std::wstring(conflictValue)==L"5","remote conflict value shown");
for(int id:{620,621,622,623,624,IDOK,IDCANCEL}) {
RECT control{},client{};
GetWindowRect(GetDlgItem(conflictsWindow,id),&control);
MapWindowPoints(nullptr,conflictsWindow,reinterpret_cast<POINT*>(&control),2);
GetClientRect(conflictsWindow,&client);
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
"conflict controls fit dialog");
}
SendMessageW(conflictsWindow,WM_COMMAND,623,0);
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}) {
RECT control{},client{};
GetWindowRect(GetDlgItem(syncWindow,id),&control);
MapWindowPoints(nullptr,syncWindow,reinterpret_cast<POINT*>(&control),2);
GetClientRect(syncWindow,&client);
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
"sync controls fit dialog");
}
SendMessageW(syncWindow,WM_COMMAND,IDOK,0);
require(!ks::loadSyncConfig(app.data).enabled,"saving disabled sync does not enable network access");
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");
require(ks::InputRegressionAccess::staleSync(app,true),"disconnect discards in-flight downloaded profile");
require(ks::InputRegressionAccess::syncBackoff(app),"saving settings preserves authentication error backoff");
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
auto autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
require(autoOptions!=nullptr,"auto-correction options open");
ShowWindow(autoOptions,SW_HIDE);
{
require((GetWindowLongPtrW(autoOptions,GWL_STYLE)&WS_THICKFRAME)!=0,
"auto-correction dialog supports resizing");
RECT original{}, before[3]{}, after[3]{}, buttonBefore{}, buttonAfter{};
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),
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
RECT baseline{}; GetWindowRect(autoOptions,&baseline);
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);
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,
"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,
"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,
"resized dictionary fields and footer do not overlap");
SetWindowPos(autoOptions,nullptr,0,0,original.right-original.left,original.bottom-original.top,
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
}
for(int id:{411,412,413}) {
RECT control{},client{};
GetWindowRect(GetDlgItem(autoOptions,id),&control);
MapWindowPoints(nullptr,autoOptions,reinterpret_cast<POINT*>(&control),2);
GetClientRect(autoOptions,&client);
require(control.left>=0&&control.top>=0&&control.right<=client.right&&control.bottom<=client.bottom,
"personal dictionary field fits options window");
}
SetDlgItemTextW(autoOptions,412,L"Релізик\r\nрелізик, словечко");
SendMessageW(autoOptions,WM_COMMAND,IDOK,0);
require(!IsWindow(autoOptions),"personal words accepted without file editing");
SendMessageW(app.settingsWindow,WM_COMMAND,255,0);
autoOptions=FindWindowW(L"KSwitcher.AutoOptions",nullptr);
ShowWindow(autoOptions,SW_HIDE);
wchar_t personalWords[128]{};
GetDlgItemTextW(autoOptions,412,personalWords,128);
require(std::wstring(personalWords)==L"релізик, словечко",
"personal dictionary normalizes case, newlines and duplicate words");
SendMessageW(autoOptions,WM_COMMAND,IDCANCEL,0);
if (!app.history) app.history=std::make_unique<ks::History>(temp/L"history-layout");
ks::showHistory(app,false);
auto history=app.historyWindow;
require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
{
app.history=std::make_unique<ks::History>(temp/L"history-search");
app.history->add(ks::textClip(L"згенеруй приклад"),L"test",app.settings);
app.history->add(ks::textClip(L"unrelated entry"),L"test",app.settings);
// This App has no general worker: a search on that queue can never finish.
auto originalFocus=GetFocus();
auto pumpUntil=[&](int count) {
auto deadline=GetTickCount64()+3000;
do {
MSG message{};
while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
TranslateMessage(&message); DispatchMessageW(&message);
}
if(SendDlgItemMessageW(history,102,LB_GETCOUNT,0,0)==count) return true;
Sleep(5);
} while(GetTickCount64()<deadline);
return false;
};
ks::refreshHistory(app);
require(pumpUntil(2),"history loads independently of general worker queue");
SetDlgItemTextW(history,101,L"згенеруй");
require(pumpUntil(1),"typing filters history via debounce without focus change");
SetDlgItemTextW(history,101,L"missing query");
require(pumpUntil(0),"unmatched search clears history list");
SetDlgItemTextW(history,101,L"");
require(pumpUntil(2),"clearing search restores history");
require(GetFocus()==originalFocus,"history search preserves focus");
}
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left+120,historyFrame.bottom-historyFrame.top+180,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
auto listAfter=childRect(102),searchAfter=childRect(101),buttonAfter=childRect(110);
require(listAfter.right-listBefore.right==120 && listAfter.bottom-listBefore.bottom==180,"history list fills added width and height");
require(searchAfter.right-searchBefore.right==120 && searchAfter.top==searchBefore.top,"history search grows horizontally only");
require(buttonAfter.top-buttonBefore.top==180 && buttonAfter.bottom-listAfter.bottom==buttonBefore.bottom-listBefore.bottom,"history actions stay below list after resize");
SetWindowPos(history,nullptr,0,0,historyFrame.right-historyFrame.left,historyFrame.bottom-historyFrame.top,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"history resize roundtrip restores geometry");
// Reproduce a row height left over from another DPI without changing desktop settings.
UINT historyDpi=GetDpiForWindow(history);
for (int staleDpi : {96, 144, 192, 240}) {
SendDlgItemMessageW(history,102,LB_SETITEMHEIGHT,0,MulDiv(88,staleDpi,96));
SendMessageW(history,WM_DPICHANGED,MAKELONG(historyDpi,historyDpi),(LPARAM)&historyFrame);
require(SendDlgItemMessageW(history,102,LB_GETITEMHEIGHT,0,0)==MulDiv(88,historyDpi,96),
"DPI change updates owner-drawn history row height through layout subclass");
require(EqualRect(&listBefore,&(listAfter=childRect(102))),"DPI refresh preserves history list geometry");
}
auto settings=app.settingsWindow;
require(SendDlgItemMessageW(settings,209,BM_GETCHECK,0,0)==BST_UNCHECKED,"automatic screenshot editor checkbox defaults off");
require(settings&&!IsWindowVisible(settings),"settings layout tested without changing user focus");
for(auto pair:{std::pair{209,215},std::pair{209,207},std::pair{209,220},std::pair{209,232},std::pair{207,215},std::pair{207,232},std::pair{207,220},std::pair{232,230},std::pair{230,231},std::pair{231,253}}) {
RECT first{},second{},overlap{};
GetWindowRect(GetDlgItem(settings,pair.first),&first); GetWindowRect(GetDlgItem(settings,pair.second),&second);
require(!IntersectRect(&overlap,&first,&second),"capture hotkey and layout controls do not overlap");
}
RECT frame{},client{},cancel{};
GetWindowRect(settings,&frame);
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromWindow(settings,MONITOR_DEFAULTTONEAREST),&monitor);
require(frame.left>=monitor.rcWork.left&&frame.top>=monitor.rcWork.top&&frame.right<=monitor.rcWork.right&&frame.bottom<=monitor.rcWork.bottom,"initial settings frame fits active work area");
UINT dpi=GetDpiForWindow(settings);
std::cout << "Hidden Settings layout checked at " << dpi << " DPI\n";
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
require(cancel.right-cancel.left==MulDiv(100,dpi,96),"controls use actual window DPI");
SetWindowPos(settings,nullptr,0,0,480,360,SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
SCROLLINFO scroll{sizeof(scroll),SIF_ALL};
GetScrollInfo(settings,SB_VERT,&scroll);
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has vertical scrolling");
GetScrollInfo(settings,SB_HORZ,&scroll);
require(scroll.nMax+1>(int)scroll.nPage,"small settings window has horizontal scrolling");
int controlMoves = 0;
auto trackedControl = GetDlgItem(settings,201);
SetWindowSubclass(trackedControl,countControlMoves,99,(DWORD_PTR)&controlMoves);
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
controlMoves=0;
ValidateRect(settings,nullptr);
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
require(controlMoves==0 && !GetUpdateRect(settings,nullptr,FALSE),"wheel at top does not move controls or erase the window");
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
require(controlMoves==0,"partial wheel step does not rearrange controls");
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,WORD(short(-WHEEL_DELTA/2))),0);
require(controlMoves==0,"wheel changes wait for the shared frame timer");
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
require(controlMoves==1,"wheel scroll batches each control movement once");
SendMessageW(settings,WM_VSCROLL,SB_BOTTOM,0);
SendMessageW(settings,WM_HSCROLL,SB_RIGHT,0);
controlMoves=0;
SendMessageW(settings,WM_MOUSEWHEEL,MAKEWPARAM(0,-WHEEL_DELTA),0);
SendMessageW(settings,WM_MOUSEHWHEEL,MAKEWPARAM(0,WHEEL_DELTA),0);
SendMessageW(settings,WM_TIMER,ks::ScrollFrameTimer,0);
require(controlMoves==0,"wheel at bottom and right boundaries does not rearrange controls");
require((GetWindowLongPtrW(settings,GWL_STYLE)&WS_CLIPCHILDREN)!=0,"parent background excludes child controls");
RemoveWindowSubclass(trackedControl,countControlMoves,99);
GetClientRect(settings,&client);
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
require(cancel.left>=0&&cancel.top>=0&&cancel.right<=client.right&&cancel.bottom<=client.bottom,"bottom actions reachable by scrolling");
SendMessageW(settings,WM_VSCROLL,SB_TOP,0);
SendMessageW(settings,WM_HSCROLL,SB_LEFT,0);
SendMessageW(GetDlgItem(settings,IDCANCEL),WM_SETFOCUS,0,0);
GetWindowRect(GetDlgItem(settings,IDCANCEL),&cancel);
MapWindowPoints(nullptr,settings,(POINT*)&cancel,2);
require(cancel.left>=0&&cancel.top>=0&&cancel.right<=client.right&&cancel.bottom<=client.bottom,"keyboard focus reveals off-screen action");
SetWindowPos(settings,nullptr,-20000,-20000,0,0,SWP_NOSIZE|SWP_NOZORDER|SWP_NOACTIVATE);
SendMessageW(settings,WM_DISPLAYCHANGE,0,0);
GetWindowRect(settings,&frame);
GetMonitorInfoW(MonitorFromWindow(settings,MONITOR_DEFAULTTONEAREST),&monitor);
require(frame.left>=monitor.rcWork.left&&frame.top>=monitor.rcWork.top&&frame.right<=monitor.rcWork.right&&frame.bottom<=monitor.rcWork.bottom,"display change recovers off-screen window");
DestroyWindow(settings);
DestroyWindow(app.window); app.window=nullptr;
}
std::filesystem::remove_all(temp);
std::cout<<"Passed "<<checks<<" input regression checks\n"; return 0;
} catch(const std::exception &e) { std::cerr<<e.what()<<'\n'; return 1; }
}