mirror of
https://github.com/reactos/reactos.git
synced 2025-08-01 18:13:48 +00:00
[user32_apitest]
- Add support for logging messages in different threads - Add several tests for AttachThreadInput svn path=/trunk/; revision=57074
This commit is contained in:
parent
8d682ab52c
commit
933cb2d66d
9 changed files with 689 additions and 59 deletions
614
rostests/apitests/user32/AttachThreadInput.c
Normal file
614
rostests/apitests/user32/AttachThreadInput.c
Normal file
|
@ -0,0 +1,614 @@
|
|||
/*
|
||||
* PROJECT: ReactOS api tests
|
||||
* LICENSE: GPL - See COPYING in the top level directory
|
||||
* PURPOSE: Test for AttachThreadInput
|
||||
* PROGRAMMERS: Giannis Adamopoulos
|
||||
*/
|
||||
|
||||
#include <windows.h>
|
||||
#include <stdio.h>
|
||||
#include <wine/test.h>
|
||||
#include "helper.h"
|
||||
#include <undocuser.h>
|
||||
|
||||
#define DESKTOP_ALL_ACCESS 0x01ff
|
||||
|
||||
typedef struct {
|
||||
DWORD tid;
|
||||
HANDLE hThread;
|
||||
HWND hWnd;
|
||||
WCHAR* Desktop;
|
||||
HANDLE StartEvent;
|
||||
HANDLE QueueStatusEvent;
|
||||
DWORD LastQueueStatus;
|
||||
|
||||
MSG_CACHE cache;
|
||||
} THREAD_DATA;
|
||||
|
||||
DWORD tidMouseMove;
|
||||
THREAD_DATA data[5];
|
||||
HHOOK hMouseHookLL = NULL;
|
||||
HHOOK hKbdHookLL = NULL;
|
||||
|
||||
|
||||
#define EXPECT_FOREGROUND(expected) ok(GetForegroundWindow() == expected, \
|
||||
"Expected hwnd%d at the foreground, got hwnd%d\n", \
|
||||
get_iwnd(expected), get_iwnd(GetForegroundWindow()));
|
||||
|
||||
#define EXPECT_ACTIVE(expected) ok(GetActiveWindow() == expected, \
|
||||
"Expected hwnd%d to be active, got hwnd%d\n", \
|
||||
get_iwnd(expected), get_iwnd(GetActiveWindow()));
|
||||
|
||||
/*
|
||||
* Helper functions
|
||||
*/
|
||||
|
||||
static int get_iwnd(HWND hWnd)
|
||||
{
|
||||
if(hWnd == data[0].hWnd) return 0;
|
||||
else if(hWnd == data[1].hWnd) return 1;
|
||||
else if(hWnd == data[2].hWnd) return 2;
|
||||
else if(hWnd == data[3].hWnd) return 3;
|
||||
else if(hWnd == data[4].hWnd) return 4;
|
||||
else return -1;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK TestProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
int iwnd = get_iwnd(hWnd);
|
||||
|
||||
if(iwnd >= 0 && message > 0 && message < WM_APP && message != WM_TIMER)
|
||||
record_message(&data[iwnd].cache, iwnd, message, SENT, wParam,0);
|
||||
|
||||
return DefWindowProc(hWnd, message, wParam, lParam);
|
||||
}
|
||||
|
||||
static void FlushMessages()
|
||||
{
|
||||
MSG msg;
|
||||
|
||||
while (PeekMessage( &msg, 0, 0, 0, PM_REMOVE ))
|
||||
{
|
||||
int iwnd = get_iwnd(msg.hwnd);
|
||||
if( iwnd >= 0 && msg.message > 0 && msg.message < WM_APP && msg.message != WM_TIMER)
|
||||
record_message(&data[0].cache, iwnd, msg.message, POST, msg.wParam,0);
|
||||
DispatchMessageA( &msg );
|
||||
}
|
||||
|
||||
/* Use SendMessage to sync with the other queues */
|
||||
SendMessage(data[1].hWnd, WM_APP, 0,0);
|
||||
SendMessage(data[2].hWnd, WM_APP, 0,0);
|
||||
SendMessage(data[3].hWnd, WM_APP, 0,0);
|
||||
SendMessage(data[4].hWnd, WM_APP, 0,0);
|
||||
}
|
||||
|
||||
static DWORD WINAPI thread_proc(void *param)
|
||||
{
|
||||
THREAD_DATA* current_data = (THREAD_DATA*)param;
|
||||
MSG msg;
|
||||
HDESK hdesk = NULL;
|
||||
int iwnd;
|
||||
|
||||
if(current_data->Desktop)
|
||||
{
|
||||
hdesk = CreateDesktopW(current_data->Desktop, NULL, NULL, 0, DESKTOP_ALL_ACCESS, NULL );
|
||||
SetThreadDesktop(hdesk);
|
||||
ok(GetThreadDesktop(current_data->tid) == hdesk, "Thread in wrong desktop. Following results may be bogus!\n");
|
||||
}
|
||||
|
||||
/* create test window */
|
||||
current_data->hWnd = CreateWindowW(L"TestClass", L"test", WS_OVERLAPPEDWINDOW,
|
||||
100, 100, 500, 500, NULL, NULL, 0, NULL);
|
||||
SetEvent( current_data->StartEvent );
|
||||
|
||||
iwnd = get_iwnd(current_data->hWnd);
|
||||
|
||||
/* Use MsgWaitForMultipleObjects to let the thread process apcs */
|
||||
while( GetMessage(&msg, 0,0,0) )
|
||||
{
|
||||
if(msg.message > 0 && msg.message < WM_APP && msg.message != WM_TIMER )
|
||||
record_message(&data[iwnd].cache, iwnd, msg.message, POST, msg.wParam,0);
|
||||
DispatchMessage(&msg);
|
||||
}
|
||||
|
||||
if(hdesk)
|
||||
CloseDesktop(hdesk);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
BOOL CreateTestThread(int i, WCHAR* Desktop)
|
||||
{
|
||||
DWORD ret;
|
||||
|
||||
data[i].StartEvent = CreateEventW(NULL, 0, 0, NULL);
|
||||
data[i].Desktop = Desktop;
|
||||
data[i].hThread = CreateThread(NULL, 0, thread_proc, &data[i], 0, &data[i].tid);
|
||||
if(!data[i].hThread) goto fail;
|
||||
ret = WaitForSingleObject(data[i].StartEvent, 1000);
|
||||
CloseHandle(data[i].StartEvent);
|
||||
if(ret == WAIT_TIMEOUT)
|
||||
{
|
||||
fail:
|
||||
win_skip("child thread failed to initialize\n");
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK MouseLLHookProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
LRESULT ret;
|
||||
MSLLHOOKSTRUCT* params = (MSLLHOOKSTRUCT*) lParam;
|
||||
|
||||
ret = CallNextHookEx(hMouseHookLL, nCode, wParam, lParam);
|
||||
|
||||
if((params->flags & LLKHF_INJECTED) == 0)
|
||||
return TRUE;
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK KbdLLHookProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
LRESULT ret;
|
||||
KBDLLHOOKSTRUCT* params = (KBDLLHOOKSTRUCT*) lParam;
|
||||
|
||||
ret = CallNextHookEx(hMouseHookLL, nCode, wParam, lParam);
|
||||
|
||||
if((params->flags & LLKHF_INJECTED) == 0)
|
||||
return TRUE;
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
BOOLEAN InitThreads()
|
||||
{
|
||||
/* Create a LL hook that drops any physical keyboard and mouse action
|
||||
and prevent the user from interfering with the test results */
|
||||
if(!IsDebuggerPresent())
|
||||
{
|
||||
hMouseHookLL = SetWindowsHookExW(WH_MOUSE_LL, MouseLLHookProc, GetModuleHandleW( NULL ), 0);
|
||||
ok(hMouseHookLL!=NULL,"failed to set hook\n");
|
||||
hKbdHookLL = SetWindowsHookExW(WH_KEYBOARD_LL, KbdLLHookProc, GetModuleHandleW( NULL ), 0);
|
||||
ok(hKbdHookLL!=NULL,"failed to set hook\n");
|
||||
}
|
||||
|
||||
/* create test clases */
|
||||
RegisterSimpleClass(TestProc, L"TestClass");
|
||||
|
||||
memset(&data[0], 0, sizeof(data[0]));
|
||||
|
||||
data[0].tid = GetCurrentThreadId();
|
||||
|
||||
/* create test window */
|
||||
data[0].hWnd = CreateWindowW(L"TestClass", L"test", WS_OVERLAPPEDWINDOW,
|
||||
100, 100, 500, 500, NULL, NULL, 0, NULL);
|
||||
if(!data[0].hWnd)
|
||||
{
|
||||
win_skip("CreateWindowW failed\n");
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* create thread1(same desktop) */
|
||||
if(!CreateTestThread(1, NULL)) return FALSE;
|
||||
|
||||
/* create thread2(same desktop) */
|
||||
if(!CreateTestThread(2, NULL)) return FALSE;
|
||||
|
||||
/* create thread3(different desktop) */
|
||||
if(!CreateTestThread(3, L"ThreadTestDesktop")) return FALSE;
|
||||
|
||||
/* create thread4(different desktop) */
|
||||
if(!CreateTestThread(4, L"ThreadTestDesktop")) return FALSE;
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* The actual tests
|
||||
*/
|
||||
|
||||
void Test_SimpleParameters()
|
||||
{
|
||||
BOOL ret;
|
||||
/* FIXME: acording to msdn xp doesn't set last error but vista+ does*/
|
||||
|
||||
/* test wrong thread */
|
||||
ret = AttachThreadInput( 0, 1, TRUE);
|
||||
ok(ret==0, "expected AttachThreadInput to fail\n");
|
||||
|
||||
/* test same thread */
|
||||
ret = AttachThreadInput( data[1].tid, data[1].tid, TRUE);
|
||||
ok(ret==0, "expected AttachThreadInput to fail\n");
|
||||
|
||||
/* try to attach to a thread on another desktop*/
|
||||
ret = AttachThreadInput( data[2].tid,data[3].tid, TRUE);
|
||||
ok(ret==0, "expected AttachThreadInput to fail\n");
|
||||
|
||||
/* test other desktop to this */
|
||||
ret = AttachThreadInput( data[3].tid,data[2].tid, TRUE);
|
||||
ok(ret==0, "expected AttachThreadInput to fail\n");
|
||||
|
||||
/* attach two threads that are both in ThreadTestDesktop */
|
||||
{
|
||||
/* Attach thread 3 and 4 */
|
||||
ret = AttachThreadInput( data[3].tid,data[4].tid, TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
|
||||
/* cleanup previous attachment */
|
||||
ret = AttachThreadInput( data[3].tid,data[4].tid, FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
|
||||
{
|
||||
/* Attach thread 1 and 2 */
|
||||
ret = AttachThreadInput( data[1].tid,data[2].tid, TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
|
||||
/* attach already attached*/
|
||||
ret = AttachThreadInput( data[1].tid,data[2].tid, TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
|
||||
/* Now try to detach 0 from 1 */
|
||||
ret = AttachThreadInput( data[0].tid,data[1].tid, FALSE);
|
||||
ok(ret==0, "expected AttachThreadInput to fail\n");
|
||||
|
||||
/* also try to detach 3 from 2 */
|
||||
ret = AttachThreadInput( data[2].tid,data[1].tid, FALSE);
|
||||
ok(ret==0, "expected AttachThreadInput to fail\n");
|
||||
|
||||
/* cleanup previous attachment */
|
||||
ret = AttachThreadInput( data[1].tid,data[2].tid, FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
|
||||
/*too bad this causes a crash in win32k */
|
||||
#if 0
|
||||
/* test triple attach */
|
||||
{
|
||||
ret = AttachThreadInput( data[0].tid, data[1].tid, TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
ret = AttachThreadInput( data[1].tid, data[2].tid, TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
|
||||
/* try to detach 2 and 0 */
|
||||
ret = AttachThreadInput( data[0].tid, data[2].tid, FALSE);
|
||||
ok(ret==0, "expected AttachThreadInput to fail\n");
|
||||
ret = AttachThreadInput( data[2].tid, data[0].tid, FALSE);
|
||||
ok(ret==0, "expected AttachThreadInput to fail\n");
|
||||
|
||||
/* try to to attach 0 to 2. it works! */
|
||||
ret = AttachThreadInput( data[0].tid, data[2].tid, TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
|
||||
ret = AttachThreadInput( data[0].tid, data[2].tid, FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
|
||||
/* detach in inverse order */
|
||||
ret = AttachThreadInput( data[0].tid, data[1].tid, FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
ret = AttachThreadInput( data[1].tid, data[2].tid, FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void Test_Focus() //Focus Active Capture Foreground Capture
|
||||
{
|
||||
BOOL ret;
|
||||
|
||||
/* Window 1 is in the foreground */
|
||||
SetForegroundWindow(data[1].hWnd);
|
||||
SetActiveWindow(data[0].hWnd);
|
||||
FlushMessages();
|
||||
|
||||
EXPECT_FOREGROUND(data[1].hWnd);
|
||||
EXPECT_ACTIVE(data[0].hWnd);
|
||||
|
||||
/* attach thread 0 to 1 */
|
||||
{
|
||||
ret = AttachThreadInput( data[0].tid, data[1].tid , TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
FlushMessages();
|
||||
|
||||
EXPECT_FOREGROUND(data[1].hWnd);
|
||||
EXPECT_ACTIVE(data[1].hWnd);
|
||||
|
||||
ret = AttachThreadInput( data[0].tid, data[1].tid , FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
|
||||
EXPECT_FOREGROUND(data[1].hWnd);
|
||||
EXPECT_ACTIVE(0);
|
||||
|
||||
SetForegroundWindow(data[1].hWnd);
|
||||
SetActiveWindow(data[0].hWnd);
|
||||
FlushMessages();
|
||||
|
||||
EXPECT_FOREGROUND(data[1].hWnd);
|
||||
EXPECT_ACTIVE(data[0].hWnd);
|
||||
|
||||
/* attach thread 1 to 0 */
|
||||
{
|
||||
ret = AttachThreadInput( data[1].tid, data[0].tid , TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
FlushMessages();
|
||||
|
||||
EXPECT_FOREGROUND(data[1].hWnd);
|
||||
EXPECT_ACTIVE(data[1].hWnd);
|
||||
|
||||
ret = AttachThreadInput( data[1].tid, data[0].tid , FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
|
||||
/* Window 0 is in the foreground */
|
||||
SetForegroundWindow(data[0].hWnd);
|
||||
SetActiveWindow(data[1].hWnd);
|
||||
FlushMessages();
|
||||
|
||||
EXPECT_FOREGROUND(data[0].hWnd);
|
||||
EXPECT_ACTIVE(data[0].hWnd);
|
||||
|
||||
/* attach thread 0 to 1 */
|
||||
{
|
||||
ret = AttachThreadInput( data[0].tid, data[1].tid , TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
FlushMessages();
|
||||
|
||||
EXPECT_FOREGROUND(data[0].hWnd);
|
||||
EXPECT_ACTIVE(data[0].hWnd);
|
||||
|
||||
SetForegroundWindow(data[0].hWnd);
|
||||
SetActiveWindow(data[1].hWnd);
|
||||
FlushMessages();
|
||||
|
||||
EXPECT_FOREGROUND(data[1].hWnd);
|
||||
EXPECT_ACTIVE(data[1].hWnd);
|
||||
|
||||
ret = AttachThreadInput( data[0].tid, data[1].tid , FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
|
||||
EXPECT_FOREGROUND(data[1].hWnd);
|
||||
EXPECT_ACTIVE(0);
|
||||
|
||||
SetForegroundWindow(data[0].hWnd);
|
||||
SetActiveWindow(data[1].hWnd);
|
||||
FlushMessages();
|
||||
|
||||
EXPECT_FOREGROUND(data[0].hWnd);
|
||||
EXPECT_ACTIVE(data[0].hWnd);
|
||||
|
||||
/* attach thread 1 to 0 */
|
||||
{
|
||||
ret = AttachThreadInput( data[1].tid, data[0].tid , TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
FlushMessages();
|
||||
|
||||
EXPECT_FOREGROUND(data[0].hWnd);
|
||||
EXPECT_ACTIVE(data[0].hWnd);
|
||||
|
||||
SetForegroundWindow(data[0].hWnd);
|
||||
SetActiveWindow(data[1].hWnd);
|
||||
FlushMessages();
|
||||
|
||||
EXPECT_FOREGROUND(data[1].hWnd);
|
||||
EXPECT_ACTIVE(data[1].hWnd);
|
||||
|
||||
ret = AttachThreadInput( data[1].tid, data[0].tid , FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
}
|
||||
|
||||
/* test some functions like PostMessage and SendMessage that shouldn't be affected */
|
||||
void Test_UnaffectedMessages()
|
||||
{
|
||||
BOOL ret;
|
||||
|
||||
EMPTY_CACHE_(&data[0].cache);
|
||||
EMPTY_CACHE_(&data[1].cache);
|
||||
|
||||
/* test that messages posted before and after attachment are unaffected
|
||||
and that we don't receive a meassage from a window we shouldn't */
|
||||
PostMessage(data[0].hWnd, WM_USER, 0,0);
|
||||
PostMessage(data[1].hWnd, WM_USER, 1,0);
|
||||
|
||||
{
|
||||
MSG_ENTRY Thread0_chain[]={
|
||||
{0,WM_USER, POST, 0, 0},
|
||||
{0,WM_USER, POST, 2, 0},
|
||||
{0,0}};
|
||||
MSG_ENTRY Thread1_chain[]={
|
||||
{1,WM_USER, POST, 1, 0},
|
||||
{1,WM_USER, POST, 3, 0},
|
||||
{0,0}};
|
||||
|
||||
ret = AttachThreadInput( data[1].tid, data[0].tid , TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
|
||||
PostMessage(data[0].hWnd, WM_USER, 2,0);
|
||||
PostMessage(data[1].hWnd, WM_USER, 3,0);
|
||||
|
||||
FlushMessages();
|
||||
Sleep(100);
|
||||
|
||||
COMPARE_CACHE_(&data[0].cache, Thread0_chain);
|
||||
COMPARE_CACHE_(&data[1].cache, Thread1_chain);
|
||||
|
||||
ret = AttachThreadInput( data[1].tid, data[0].tid , FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
|
||||
/* test messages send to the wrong thread */
|
||||
SendMessage(data[0].hWnd, WM_USER, 0,0);
|
||||
SendMessage(data[1].hWnd, WM_USER, 1,0);
|
||||
|
||||
{
|
||||
MSG_ENTRY Thread0_chain[]={
|
||||
{0,WM_USER, SENT, 0, 0},
|
||||
{0,WM_USER, SENT, 2, 0},
|
||||
{0,0}};
|
||||
MSG_ENTRY Thread1_chain[]={
|
||||
{1,WM_USER, SENT, 1, 0},
|
||||
{1,WM_USER, SENT, 3, 0},
|
||||
{1,WM_MOUSEMOVE, SENT, 0, 0},
|
||||
{0,0}};
|
||||
|
||||
ret = AttachThreadInput( data[2].tid, data[1].tid , TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
|
||||
SendMessage(data[0].hWnd, WM_USER, 2,0);
|
||||
SendMessage(data[1].hWnd, WM_USER, 3,0);
|
||||
|
||||
/* Try to send a fake input message */
|
||||
SendMessage(data[1].hWnd, WM_MOUSEMOVE, 0,0);
|
||||
|
||||
COMPARE_CACHE_(&data[0].cache, Thread0_chain);
|
||||
COMPARE_CACHE_(&data[1].cache, Thread1_chain);
|
||||
|
||||
ret = AttachThreadInput( data[2].tid, data[1].tid , FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
|
||||
/* todo: test keyboard layout that shouldn't be affected */
|
||||
}
|
||||
|
||||
void Test_SendInput()
|
||||
{
|
||||
MSG_ENTRY Thread1_chain[]={
|
||||
{1,WM_KEYDOWN, POST, VK_SHIFT, 0},
|
||||
{1,WM_KEYUP, POST, VK_SHIFT, 0},
|
||||
{0,0}};
|
||||
MSG_ENTRY Thread0_chain[]={
|
||||
{0,WM_KEYDOWN, POST, VK_SHIFT, 0},
|
||||
{0,WM_KEYUP, POST, VK_SHIFT, 0},
|
||||
{0,0}};
|
||||
|
||||
BOOL ret;
|
||||
|
||||
/* First try sending input without attaching. It will go to the foreground */
|
||||
{
|
||||
SetForegroundWindow(data[1].hWnd);
|
||||
SetActiveWindow(data[0].hWnd);
|
||||
|
||||
ok(GetForegroundWindow() == data[1].hWnd, "wrong foreground\n");
|
||||
ok(GetActiveWindow() == data[0].hWnd, "wrong active\n");
|
||||
|
||||
FlushMessages();
|
||||
EMPTY_CACHE_(&data[0].cache);
|
||||
EMPTY_CACHE_(&data[1].cache);
|
||||
|
||||
keybd_event(VK_SHIFT, 0,0,0);
|
||||
keybd_event(VK_SHIFT, 0,KEYEVENTF_KEYUP,0);
|
||||
Sleep(100);
|
||||
FlushMessages();
|
||||
|
||||
COMPARE_CACHE_(&data[0].cache, empty_chain);
|
||||
COMPARE_CACHE_(&data[1].cache, Thread1_chain);
|
||||
}
|
||||
|
||||
/* Next attach and send input. It will go to the same thread as before */
|
||||
{
|
||||
ret = AttachThreadInput( data[1].tid, data[0].tid , TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
|
||||
FlushMessages();
|
||||
EMPTY_CACHE_(&data[0].cache);
|
||||
EMPTY_CACHE_(&data[1].cache);
|
||||
|
||||
keybd_event(VK_SHIFT, 0,0,0);
|
||||
keybd_event(VK_SHIFT, 0,KEYEVENTF_KEYUP,0);
|
||||
Sleep(100);
|
||||
FlushMessages();
|
||||
|
||||
COMPARE_CACHE_(&data[0].cache, empty_chain);
|
||||
COMPARE_CACHE_(&data[1].cache, Thread1_chain);
|
||||
}
|
||||
|
||||
/* Now set foregroung and active again. Input will go to thread 0 */
|
||||
{
|
||||
SetForegroundWindow(data[1].hWnd);
|
||||
SetActiveWindow(data[0].hWnd);
|
||||
|
||||
FlushMessages();
|
||||
EMPTY_CACHE_(&data[0].cache);
|
||||
EMPTY_CACHE_(&data[1].cache);
|
||||
|
||||
keybd_event(VK_SHIFT, 0,0,0);
|
||||
keybd_event(VK_SHIFT, 0,KEYEVENTF_KEYUP,0);
|
||||
Sleep(100);
|
||||
FlushMessages();
|
||||
|
||||
COMPARE_CACHE_(&data[0].cache, Thread0_chain);
|
||||
COMPARE_CACHE_(&data[1].cache, empty_chain);
|
||||
|
||||
ret = AttachThreadInput( data[1].tid, data[0].tid , FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
|
||||
/* Attach in the opposite order and send input */
|
||||
{
|
||||
ret = AttachThreadInput( data[0].tid, data[1].tid , TRUE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
|
||||
FlushMessages();
|
||||
EMPTY_CACHE_(&data[0].cache);
|
||||
EMPTY_CACHE_(&data[1].cache);
|
||||
|
||||
keybd_event(VK_SHIFT, 0,0,0);
|
||||
keybd_event(VK_SHIFT, 0,KEYEVENTF_KEYUP,0);
|
||||
Sleep(100);
|
||||
FlushMessages();
|
||||
|
||||
COMPARE_CACHE_(&data[0].cache, Thread0_chain);
|
||||
COMPARE_CACHE_(&data[1].cache, empty_chain);
|
||||
}
|
||||
|
||||
/* Now set foregroung and active again. Input will go to thread 0 */
|
||||
{
|
||||
SetForegroundWindow(data[1].hWnd);
|
||||
SetActiveWindow(data[0].hWnd);
|
||||
|
||||
FlushMessages();
|
||||
EMPTY_CACHE_(&data[0].cache);
|
||||
EMPTY_CACHE_(&data[1].cache);
|
||||
|
||||
keybd_event(VK_SHIFT, 0,0,0);
|
||||
keybd_event(VK_SHIFT, 0,KEYEVENTF_KEYUP,0);
|
||||
Sleep(100);
|
||||
FlushMessages();
|
||||
|
||||
COMPARE_CACHE_(&data[0].cache, Thread0_chain);
|
||||
COMPARE_CACHE_(&data[1].cache, empty_chain);
|
||||
|
||||
ret = AttachThreadInput( data[0].tid, data[1].tid , FALSE);
|
||||
ok(ret==1, "expected AttachThreadInput to succeed\n");
|
||||
}
|
||||
}
|
||||
|
||||
START_TEST(AttachThreadInput)
|
||||
{
|
||||
if(!InitThreads())
|
||||
return;
|
||||
|
||||
Test_SimpleParameters();
|
||||
Test_Focus();
|
||||
Test_UnaffectedMessages();
|
||||
Test_SendInput();
|
||||
|
||||
if(hMouseHookLL)
|
||||
UnhookWindowsHookEx(hMouseHookLL);
|
||||
if(hKbdHookLL)
|
||||
UnhookWindowsHookEx(hKbdHookLL);
|
||||
|
||||
/* Stop all threads and exit gratefully */
|
||||
PostThreadMessage(data[1].tid, WM_QUIT,0,0);
|
||||
PostThreadMessage(data[2].tid, WM_QUIT,0,0);
|
||||
PostThreadMessage(data[3].tid, WM_QUIT,0,0);
|
||||
PostThreadMessage(data[4].tid, WM_QUIT,0,0);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
list(APPEND SOURCE
|
||||
AttachThreadInput.c
|
||||
helper.c
|
||||
DeferWindowPos.c
|
||||
desktop.c
|
||||
|
|
|
@ -42,11 +42,11 @@ LRESULT CALLBACK DWPTestProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lPar
|
|||
{
|
||||
WINDOWPOS* pwp = (WINDOWPOS*)lParam;
|
||||
ok(wParam==0,"expected wParam=0\n");
|
||||
record_message(iwnd, message, SENT, get_iwnd(pwp->hwndInsertAfter), pwp->flags);
|
||||
RECOND_MESSAGE(iwnd, message, SENT, get_iwnd(pwp->hwndInsertAfter), pwp->flags);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
record_message(iwnd, message, SENT, 0,0);
|
||||
RECOND_MESSAGE(iwnd, message, SENT, 0,0);
|
||||
}
|
||||
return DefWindowProc(hWnd, message, wParam, lParam);
|
||||
}
|
||||
|
@ -59,7 +59,7 @@ static void FlushMessages()
|
|||
{
|
||||
int iwnd = get_iwnd(msg.hwnd);
|
||||
if(!(msg.message > WM_USER || !iwnd || IsDWmMsg(msg.message) || IseKeyMsg(msg.message)))
|
||||
record_message(iwnd, msg.message, POST,0,0);
|
||||
RECOND_MESSAGE(iwnd, msg.message, POST,0,0);
|
||||
DispatchMessageA( &msg );
|
||||
}
|
||||
}
|
||||
|
@ -105,7 +105,7 @@ static void set_default_pos()
|
|||
SetWindowPos(hWnd4, 0, 250,250,200,200, SWP_NOOWNERZORDER|SWP_SHOWWINDOW|SWP_NOACTIVATE);
|
||||
SetActiveWindow(hWnd4);
|
||||
FlushMessages();
|
||||
empty_message_cache();
|
||||
EMPTY_CACHE();
|
||||
}
|
||||
|
||||
static void Test_DWP_Error(HWND hWnd, HWND hWnd2)
|
||||
|
@ -248,7 +248,7 @@ static void Test_DWP_Error(HWND hWnd, HWND hWnd2)
|
|||
ok_windowpos(hWnd2, 70, 80, 250, 260, "Window 2");
|
||||
|
||||
FlushMessages();
|
||||
empty_message_cache();
|
||||
EMPTY_CACHE();
|
||||
}
|
||||
|
||||
MSG_ENTRY move1_chain[]={
|
||||
|
@ -299,7 +299,7 @@ static void Test_DWP_SimpleMsg(HWND hWnd1, HWND hWnd2)
|
|||
SetWindowPos(hWnd1, 0, 10,20,100,100,0);
|
||||
SetWindowPos(hWnd2, 0, 10,20,100,100,0);
|
||||
FlushMessages();
|
||||
empty_message_cache();
|
||||
EMPTY_CACHE();
|
||||
|
||||
/* move hWnd1 */
|
||||
hdwp = BeginDeferWindowPos(1);
|
||||
|
|
|
@ -48,11 +48,11 @@ LRESULT CALLBACK OwnerTestProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lP
|
|||
{
|
||||
WINDOWPOS* pwp = (WINDOWPOS*)lParam;
|
||||
ok(wParam==0,"expected wParam=0\n");
|
||||
record_message(iwnd, message, SENT, get_iwnd(pwp->hwndInsertAfter), pwp->flags);
|
||||
RECOND_MESSAGE(iwnd, message, SENT, get_iwnd(pwp->hwndInsertAfter), pwp->flags);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
record_message(iwnd, message, SENT, 0,0);
|
||||
RECOND_MESSAGE(iwnd, message, SENT, 0,0);
|
||||
}
|
||||
return DefWindowProc(hWnd, message, wParam, lParam);
|
||||
}
|
||||
|
@ -65,7 +65,7 @@ static void FlushMessages()
|
|||
{
|
||||
int iwnd = get_iwnd(msg.hwnd);
|
||||
if(!(msg.message > WM_USER || !iwnd || IsDWmMsg(msg.message) || IseKeyMsg(msg.message)))
|
||||
record_message(iwnd, msg.message, POST,0,0);
|
||||
RECOND_MESSAGE(iwnd, msg.message, POST,0,0);
|
||||
DispatchMessageA( &msg );
|
||||
}
|
||||
}
|
||||
|
@ -86,7 +86,7 @@ static void set_default_zorder()
|
|||
SetWindowPos(hWnd2, 0, 0,0,0,0, SWP_NOMOVE|SWP_NOREPOSITION|SWP_NOSIZE|SWP_SHOWWINDOW);
|
||||
|
||||
FlushMessages();
|
||||
empty_message_cache();
|
||||
EMPTY_CACHE();
|
||||
}
|
||||
|
||||
static void destroy_test_window()
|
||||
|
|
|
@ -38,11 +38,11 @@ LRESULT CALLBACK SysParamsTestProc(HWND hWnd, UINT message, WPARAM wParam, LPARA
|
|||
{
|
||||
WINDOWPOS* pwp = (WINDOWPOS*)lParam;
|
||||
ok(wParam==0,"expected wParam=0\n");
|
||||
record_message(iwnd, message, SENT, get_iwnd(pwp->hwndInsertAfter), pwp->flags);
|
||||
RECOND_MESSAGE(iwnd, message, SENT, get_iwnd(pwp->hwndInsertAfter), pwp->flags);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
record_message(iwnd, message, SENT, 0,0);
|
||||
RECOND_MESSAGE(iwnd, message, SENT, 0,0);
|
||||
}
|
||||
return DefWindowProc(hWnd, message, wParam, lParam);
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ static void FlushMessages()
|
|||
{
|
||||
int iwnd = get_iwnd(msg.hwnd);
|
||||
if(!(msg.message > WM_USER || !iwnd || IsDWmMsg(msg.message) || IseKeyMsg(msg.message)))
|
||||
record_message(iwnd, msg.message, POST,0,0);
|
||||
RECOND_MESSAGE(iwnd, msg.message, POST,0,0);
|
||||
DispatchMessageA( &msg );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ LRESULT CALLBACK TmeTestProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lPar
|
|||
ok(0, "Got unexpected WM_SYSTIMER in the winproc. wParam=%d\n", wParam);
|
||||
break;
|
||||
default:
|
||||
record_message(iwnd, message, SENT, 0,0);
|
||||
RECOND_MESSAGE(iwnd, message, SENT, 0,0);
|
||||
}
|
||||
return DefWindowProc(hWnd, message, wParam, lParam);
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ LRESULT CALLBACK TmeTestProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lPar
|
|||
static LRESULT CALLBACK MouseLLHookProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
LRESULT ret;
|
||||
record_message(0, WH_MOUSE_LL, HOOK, wParam, 0);
|
||||
RECOND_MESSAGE(0, WH_MOUSE_LL, HOOK, wParam, 0);
|
||||
ret = CallNextHookEx(hMouseHookLL, nCode, wParam, lParam);
|
||||
if(ignore_mousell)
|
||||
return TRUE;
|
||||
|
@ -68,7 +68,7 @@ static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam)
|
|||
{
|
||||
MOUSEHOOKSTRUCT *hs = (MOUSEHOOKSTRUCT*) lParam;
|
||||
LRESULT ret;
|
||||
record_message(get_iwnd(hs->hwnd), WH_MOUSE, HOOK, wParam, hs->wHitTestCode);
|
||||
RECOND_MESSAGE(get_iwnd(hs->hwnd), WH_MOUSE, HOOK, wParam, hs->wHitTestCode);
|
||||
ret = CallNextHookEx(hMouseHook, nCode, wParam, lParam);
|
||||
if(ignore_mouse)
|
||||
return TRUE;
|
||||
|
@ -86,12 +86,12 @@ static void FlushMessages()
|
|||
{
|
||||
if(msg.message == WM_SYSTIMER)
|
||||
{
|
||||
record_message(iwnd, msg.message, POST,msg.wParam,0);
|
||||
RECOND_MESSAGE(iwnd, msg.message, POST,msg.wParam,0);
|
||||
if(ignore_timer)
|
||||
continue;
|
||||
}
|
||||
else if(!(msg.message > WM_USER || !iwnd || IsDWmMsg(msg.message) || IseKeyMsg(msg.message)))
|
||||
record_message(iwnd, msg.message, POST,0,0);
|
||||
RECOND_MESSAGE(iwnd, msg.message, POST,0,0);
|
||||
}
|
||||
DispatchMessageA( &msg );
|
||||
}
|
||||
|
@ -265,7 +265,7 @@ void Test_TrackMouseEvent()
|
|||
MOVE_CURSOR(0,0);
|
||||
create_test_windows();
|
||||
FlushMessages();
|
||||
empty_message_cache();
|
||||
EMPTY_CACHE();
|
||||
|
||||
/* the mouse moves over hwnd2 */
|
||||
MOVE_CURSOR(220,220);
|
||||
|
|
|
@ -11,10 +11,11 @@
|
|||
#include "helper.h"
|
||||
#include <undocuser.h>
|
||||
|
||||
MSG_ENTRY last_post_message;
|
||||
MSG_ENTRY message_cache[100];
|
||||
static int message_cache_size = 0;
|
||||
|
||||
MSG_CACHE default_cache = {
|
||||
#ifdef _MSC_VER
|
||||
0
|
||||
#endif
|
||||
};
|
||||
MSG_ENTRY empty_chain[]= {{0,0}};
|
||||
|
||||
static char* get_msg_name(UINT msg)
|
||||
|
@ -48,6 +49,8 @@ static char* get_msg_name(UINT msg)
|
|||
case WM_SETTINGCHANGE: return "WM_SETTINGCHANGE";
|
||||
case WM_GETICON: return "WM_GETICON";
|
||||
case WM_SETICON: return "WM_SETICON";
|
||||
case WM_KEYDOWN: return "WM_KEYDOWN";
|
||||
case WM_KEYUP: return "WM_KEYUP";
|
||||
default: return NULL;
|
||||
}
|
||||
}
|
||||
|
@ -64,11 +67,9 @@ static char* get_hook_name(UINT id)
|
|||
}
|
||||
}
|
||||
|
||||
void empty_message_cache()
|
||||
void empty_message_cache(MSG_CACHE* cache)
|
||||
{
|
||||
memset(&last_post_message, 0, sizeof(last_post_message));
|
||||
memset(message_cache, 0, sizeof(message_cache));
|
||||
message_cache_size = 0;
|
||||
memset(cache, 0, sizeof(MSG_CACHE));
|
||||
}
|
||||
|
||||
void sprintf_msg_entry(char* buffer, MSG_ENTRY* msg)
|
||||
|
@ -111,20 +112,20 @@ void sprintf_msg_entry(char* buffer, MSG_ENTRY* msg)
|
|||
}
|
||||
}
|
||||
|
||||
void trace_cache(const char* file, int line)
|
||||
void trace_cache(MSG_CACHE* cache, const char* file, int line)
|
||||
{
|
||||
int i;
|
||||
char buff[100];
|
||||
|
||||
for (i=0; i < message_cache_size; i++)
|
||||
for (i=0; i < cache->count; i++)
|
||||
{
|
||||
sprintf_msg_entry(buff, &message_cache[i]);
|
||||
sprintf_msg_entry(buff, &cache->message_cache[i]);
|
||||
trace_(file,line)("%d: %s\n", i, buff);
|
||||
}
|
||||
trace_(file,line)("\n");
|
||||
}
|
||||
|
||||
void compare_cache(const char* file, int line, MSG_ENTRY *msg_chain)
|
||||
void compare_cache(MSG_CACHE* cache, const char* file, int line, MSG_ENTRY *msg_chain)
|
||||
{
|
||||
int i = 0;
|
||||
char buffGot[100], buffExp[100];
|
||||
|
@ -132,9 +133,9 @@ void compare_cache(const char* file, int line, MSG_ENTRY *msg_chain)
|
|||
|
||||
while(1)
|
||||
{
|
||||
BOOL same = !memcmp(&message_cache[i],msg_chain, sizeof(MSG_ENTRY));
|
||||
BOOL same = !memcmp(&cache->message_cache[i],msg_chain, sizeof(MSG_ENTRY));
|
||||
|
||||
sprintf_msg_entry(buffGot, &message_cache[i]);
|
||||
sprintf_msg_entry(buffGot, &cache->message_cache[i]);
|
||||
sprintf_msg_entry(buffExp, msg_chain);
|
||||
ok_(file,line)(same,"%d: got %s, expected %s\n",i, buffGot, buffExp);
|
||||
|
||||
|
@ -145,7 +146,7 @@ void compare_cache(const char* file, int line, MSG_ENTRY *msg_chain)
|
|||
msg_chain++;
|
||||
else
|
||||
{
|
||||
if(i>message_cache_size)
|
||||
if(i > cache->count)
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
|
@ -154,46 +155,46 @@ void compare_cache(const char* file, int line, MSG_ENTRY *msg_chain)
|
|||
if(got_error )
|
||||
{
|
||||
trace_(file,line)("The complete list of messages got is:\n");
|
||||
trace_cache(file,line);
|
||||
trace_cache(cache, file,line);
|
||||
}
|
||||
|
||||
empty_message_cache();
|
||||
empty_message_cache(cache);
|
||||
}
|
||||
|
||||
void record_message(int iwnd, UINT message, MSG_TYPE type, int param1,int param2)
|
||||
void record_message(MSG_CACHE* cache, int iwnd, UINT message, MSG_TYPE type, int param1,int param2)
|
||||
{
|
||||
if(message_cache_size>=100)
|
||||
if(cache->count >= 100)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
/* do not report a post message a second time */
|
||||
if(type == SENT &&
|
||||
last_post_message.iwnd == iwnd &&
|
||||
last_post_message.msg == message &&
|
||||
last_post_message.param1 == param1 &&
|
||||
last_post_message.param2 == param2)
|
||||
cache->last_post_message.iwnd == iwnd &&
|
||||
cache->last_post_message.msg == message &&
|
||||
cache->last_post_message.param1 == param1 &&
|
||||
cache->last_post_message.param2 == param2)
|
||||
{
|
||||
memset(&last_post_message, 0, sizeof(last_post_message));
|
||||
memset(&cache->last_post_message, 0, sizeof(MSG_ENTRY));
|
||||
return;
|
||||
}
|
||||
|
||||
message_cache[message_cache_size].iwnd = iwnd;
|
||||
message_cache[message_cache_size].msg = message;
|
||||
message_cache[message_cache_size].type = type;
|
||||
message_cache[message_cache_size].param1 = param1;
|
||||
message_cache[message_cache_size].param2 = param2;
|
||||
cache->message_cache[cache->count].iwnd = iwnd;
|
||||
cache->message_cache[cache->count].msg = message;
|
||||
cache->message_cache[cache->count].type = type;
|
||||
cache->message_cache[cache->count].param1 = param1;
|
||||
cache->message_cache[cache->count].param2 = param2;
|
||||
|
||||
if(message_cache[message_cache_size].type == POST)
|
||||
if(cache->message_cache[cache->count].type == POST)
|
||||
{
|
||||
last_post_message = message_cache[message_cache_size];
|
||||
cache->last_post_message = cache->message_cache[cache->count];
|
||||
}
|
||||
else
|
||||
{
|
||||
memset(&last_post_message, 0, sizeof(last_post_message));
|
||||
memset(&cache->last_post_message, 0, sizeof(MSG_ENTRY));
|
||||
}
|
||||
|
||||
message_cache_size++;
|
||||
cache->count++;
|
||||
}
|
||||
|
||||
ATOM RegisterSimpleClass(WNDPROC lpfnWndProc, LPCWSTR lpszClassName)
|
||||
|
|
|
@ -16,13 +16,21 @@ typedef struct _MSG_ENTRY
|
|||
int param2;
|
||||
} MSG_ENTRY;
|
||||
|
||||
typedef struct _MSG_CACHE
|
||||
{
|
||||
MSG_ENTRY last_post_message;
|
||||
MSG_ENTRY message_cache[100];
|
||||
int count;
|
||||
} MSG_CACHE;
|
||||
|
||||
extern MSG_ENTRY empty_chain[];
|
||||
extern MSG_CACHE default_cache;
|
||||
|
||||
void record_message(MSG_CACHE* cache, int iwnd, UINT message, MSG_TYPE type, int param1,int param2);
|
||||
void compare_cache(MSG_CACHE* cache, const char* file, int line, MSG_ENTRY *msg_chain);
|
||||
void trace_cache(MSG_CACHE* cache, const char* file, int line);
|
||||
void empty_message_cache(MSG_CACHE* cache);
|
||||
|
||||
void record_message(int iwnd, UINT message, MSG_TYPE type, int param1,int param2);
|
||||
void compare_cache(const char* file, int line, MSG_ENTRY *msg_chain);
|
||||
void trace_cache(const char* file, int line);
|
||||
void empty_message_cache();
|
||||
ATOM RegisterSimpleClass(WNDPROC lpfnWndProc, LPCWSTR lpszClassName);
|
||||
|
||||
/* filter messages that are affected by dwm */
|
||||
|
@ -44,10 +52,14 @@ static inline BOOL IseKeyMsg(UINT msg)
|
|||
return (msg == WM_KEYUP || msg == WM_KEYDOWN);
|
||||
}
|
||||
|
||||
#define COMPARE_CACHE(...) compare_cache(__FILE__, __LINE__, ##__VA_ARGS__)
|
||||
#define TRACE_CACHE() trace_cache(__FILE__, __LINE__)
|
||||
#define COMPARE_CACHE(msg_chain) compare_cache(&default_cache, __FILE__, __LINE__, msg_chain)
|
||||
#define TRACE_CACHE() trace_cache(&default_cache, __FILE__, __LINE__)
|
||||
#define EMPTY_CACHE() empty_message_cache(&default_cache);
|
||||
#define RECOND_MESSAGE(...) record_message(&default_cache, ##__VA_ARGS__);
|
||||
|
||||
#define EXPECT_ACTIVE(hwnd) ok(GetActiveWindow() == hwnd, "Expected %p to be the active window, not %p\n",hwnd,GetActiveWindow())
|
||||
#define COMPARE_CACHE_(cache, msg_chain) compare_cache(cache, __FILE__, __LINE__, msg_chain)
|
||||
#define TRACE_CACHE_(cache) trace_cache(cache, __FILE__, __LINE__)
|
||||
#define EMPTY_CACHE_(cache) empty_message_cache(cache);
|
||||
|
||||
#define EXPECT_QUEUE_STATUS(expected, notexpected) \
|
||||
{ \
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
#define STANDALONE
|
||||
#include "wine/test.h"
|
||||
|
||||
extern void func_AttachThreadInput(void);
|
||||
extern void func_DeferWindowPos(void);
|
||||
extern void func_desktop(void);
|
||||
extern void func_GetIconInfo(void);
|
||||
|
@ -24,6 +25,7 @@ extern void func_wsprintf(void);
|
|||
|
||||
const struct test winetest_testlist[] =
|
||||
{
|
||||
{ "AttachThreadInput", func_AttachThreadInput },
|
||||
{ "desktop", func_desktop },
|
||||
{ "DeferWindowPos", func_DeferWindowPos },
|
||||
{ "GetIconInfo", func_GetIconInfo },
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue