UIAutomationでマウスのドラッグ&ドロップの自動制御を試みるまで

本日のテーマ

昨日の続き。

  • UIテストでマウスのドラッグ&ドロップを自動化したい

方針

結局、最後はWin32APIに頼ることになりそう。

ちなみにこのWin32APIはなかなか癖者で、『ゲームプログラマになる前に覚えておきたい技術』§27.2.2ではこんなふうにdisられています。

(中略)これが絶望的に面倒くさい。win32APIで検索すると入門サイトが山と見つかるが、どこを見てもやる気が失せる。ボタンも何もないウィンドウを一つ出すだけで100行からのプログラムを書かねばならないし、何もかもがクラスでなく関数になっているので、かなり使いにくい。建て増しの歴史がそのまま透けて見えるような仕様の数々が、多少のやる気など粉砕してしまう。

今日やること

  • Windowsに標準でインストールされているペイントを自動起動する
  • ペイントを最大化する(モニタとウィンドウの左上端を合わせるため)
  • 座標(200, 300)から画面中央に向かってマウスを自動でドラッグ&ドロップする

成果物はこんな感じになる予定です。

f:id:tercel_s:20150430115009p:plain

なお、本記事の執筆にあたり使用した環境は、Surface Pro 3 (Windows 8.1 Pro 64ビット版) + Visual Studio Express 2013 for Windows Desktop です。マルチモニタ環境は想定していません。ごめんなさい。

じゅんび

「コンソールアプリケーション」のC#プロジェクトを新規作成し、ライブラリを参照に追加します。

今回は、

  • UIAutomationClient
  • UIAutomationTypes

だけでなく、さらに以下についても追加する必要があります。

  • System.Drawing
  • System.Windows.Forms

コーディング

コードはこちらの記事を参考に(というかコピペ)しました。

ペイントを起動して適当に線を引く

namespace UIAutomationSample
{
    class Program
    {
        [DllImport("user32.dll")]
        extern static uint SendInput(
            uint nInputs,       // INPUT 構造体の数(イベント数)
            INPUT[] pInputs,    // INPUT 構造体
            int cbSize          // INPUT 構造体のサイズ
            );

        [StructLayout(LayoutKind.Sequential)]
        struct INPUT
        {
            public int type;        // 0 = INPUT_MOUSE(デフォルト), 1 = INPUT_KEYBOARD
            public MOUSEINPUT mi;
        }

        [StructLayout(LayoutKind.Sequential)]
        struct MOUSEINPUT
        {
            public int dx;
            public int dy;
            public int mouseData;   // amount of wheel movement
            public int dwFlags;
            public int time;        // time stamp for the event
            public IntPtr dwExtraInfo;
        }

        // dwFlags
        const int MOUSEEVENTF_MOVED = 0x0001;
        const int MOUSEEVENTF_LEFTDOWN = 0x0002;    // 左ボタン Down
        const int MOUSEEVENTF_LEFTUP = 0x0004;      // 左ボタン Up
        const int MOUSEEVENTF_RIGHTDOWN = 0x0008;   // 右ボタン Down
        const int MOUSEEVENTF_RIGHTUP = 0x0010;     // 右ボタン Up
        const int MOUSEEVENTF_MIDDLEDOWN = 0x0020;  // 中ボタン Down
        const int MOUSEEVENTF_MIDDLEUP = 0x0040;    // 中ボタン Up
        const int MOUSEEVENTF_WHEEL = 0x0080;
        const int MOUSEEVENTF_XDOWN = 0x0100;
        const int MOUSEEVENTF_XUP = 0x0200;
        const int MOUSEEVENTF_ABSOLUTE = 0x8000;

        const int SCREEN_LENGTH = 0x10000;          // for MOUSEEVENTF_ABSOLUTE

        private static readonly string PROCESS_NAME = @"mspaint";
        private static readonly int DEFAULT_WAIT_TIME = 1000;

        private static AutomationElement mainForm;
        static void Main(string[] args)
        {
            Process process = Process.Start(PROCESS_NAME);
            try
            {
                // ペイントを起動します
                Thread.Sleep(DEFAULT_WAIT_TIME);
                mainForm = AutomationElement.FromHandle(process.MainWindowHandle);

                // 最大化します
                foreach (var key in new string[] { "% ", "X" })
                {
                    SendKeys.SendWait(key);
                }
                Thread.Sleep(DEFAULT_WAIT_TIME);

                // マウスカーソルの移動 (絶対座標 200, 300 へ移動)
                Cursor.Position = new Point(200, 300);

                // ドラッグ操作の準備 (struct 配列の宣言)
                INPUT[] input = new INPUT[3];  // 計3イベントを格納

                // ドラッグ操作の準備 (第1イベントの定義 = 左ボタン Down)
                input[0].mi.dwFlags = MOUSEEVENTF_LEFTDOWN;

                // ドラッグ操作の準備 (第2イベントの定義 = 絶対座標へ移動)
                input[1].mi.dx = SCREEN_LENGTH / 2;  // X 座標 = 画面 1/2 (中央)
                input[1].mi.dy = SCREEN_LENGTH / 2;  // Y 座標 = 画面 1/2 (中央)
                input[1].mi.dwFlags = MOUSEEVENTF_MOVED | MOUSEEVENTF_ABSOLUTE;

                // ドラッグ操作の準備 (第3イベントの定義 = 左ボタン Up)
                input[2].mi.dwFlags = MOUSEEVENTF_LEFTUP;

                // ドラッグ操作の実行 (計3イベントの一括生成)
                SendInput((uint)input.Length, input, Marshal.SizeOf(input[0]));
            }
            finally 
            {
                // process.CloseMainWindow();
            }
        }

        // 指定したID属性に一致するAutomationElementを返します
        private static AutomationElement FindElementById(AutomationElement rootElement, string automationId)
        {
            return rootElement.FindFirst(
                TreeScope.Element | TreeScope.Descendants,
                new PropertyCondition(AutomationElement.AutomationIdProperty, automationId));
        }

        // 指定したName属性に一致するAutomationElementをすべて返します
        private static IEnumerable<AutomationElement> FindElementsByName(AutomationElement rootElement, string name)
        {
            return rootElement.FindAll(
                TreeScope.Element | TreeScope.Descendants,
                new PropertyCondition(AutomationElement.NameProperty, name))
                .Cast<AutomationElement>();
        }

        // 指定したName属性に一致するボタン要素をすべて返します
        private static IEnumerable<AutomationElement> FindButtonsByName(AutomationElement rootElement, string name)
        {
            const string BUTTON_CLASS_NAME = "Button";
            return from x in FindElementsByName(rootElement, name) 
                   where x.Current.ClassName == BUTTON_CLASS_NAME 
                   select x;
        }
    }
}

注意:マウスの移動先の座標指定について

ちなみにSendInput()でマウスの移動先の座標を指定する際には、ちょっとした注意が必要です。

input[1].mi.dwFlags = MOUSEEVENTF_MOVED | MOUSEEVENTF_ABSOLUTE;

上記のようにdwFlagsMOUSEEVENTF_ABSOLUTEを設定しているときは、移動先のマウス座標 (x,\, y)を、さらに下記の式で変換する必要があります。

 (x',\, y') = \left(\dfrac{16^{3}x}{\mathrm{width}},\, \dfrac{16^{3}y}{\mathrm{height}}\right)

ここで \mathrm{width},\,\mathrm{height}は、それぞれモニタの縦横の解像度を指す変数です。

したがって、640×480(px) のモニタの (100,100) にマウスを移動させたい場合は、以下のように計算します。
 \begin{align}(x',\, y') &= \left(\dfrac{16^{3}\times 100}{640},\, \dfrac{16^{3}\times 100}{480}\right)\\
&= (10240,\,13653.\dot{3}) \\
&\fallingdotseq (10240,\,13653)
\end{align}
結果として、設定される値はdx = 10240;,dy = 13653;となります。

ドラッグ&ドロップをメソッド化する

WindowsAPIの泥臭いコードをMouseDrag()に追い出してみます。

本来は、構造体の宣言も含めてひとつユーティリティクラスに包んでやるのがよいのでしょうね。

        private static void MouseDrag(Point from, Point to)
        {
            // モニタの解像度を取得
            int primaryScreenWidth = Screen.PrimaryScreen.Bounds.Width;
            int primaryScreenHeight = Screen.PrimaryScreen.Bounds.Height;

            Cursor.Position = from;

            // ドラッグ操作の準備 (struct 配列の宣言)
            const int NUM_INPUT = 3;
            INPUT[] input = new INPUT[NUM_INPUT];  // 計3イベントを格納

            // ドラッグ操作の準備 (第1イベントの定義 = 左ボタン Down)
            input[0].mi.dwFlags = MOUSEEVENTF_LEFTDOWN;

            // ドラッグ操作の準備 (第2イベントの定義 = 絶対座標へ移動)
            input[1].mi.dx = (to.X * SCREEN_LENGTH) / primaryScreenWidth;
            input[1].mi.dy = (to.Y * SCREEN_LENGTH) / primaryScreenHeight;
            input[1].mi.dwFlags = MOUSEEVENTF_MOVED | MOUSEEVENTF_ABSOLUTE;

            // ドラッグ操作の準備 (第3イベントの定義 = 左ボタン Up)
            input[2].mi.dwFlags = MOUSEEVENTF_LEFTUP;

            // ドラッグ操作の実行 (計3イベントの一括生成)
            SendInput((uint)input.Length, input, Marshal.SizeOf(input[0]));
        }

補足:ウィンドウの位置(とサイズ)の取得

ここまでは簡単のため、マウスの基準座標の原点がモニタの左上端になっていました。

しかし、実際にはアプリケーションウィンドウの左上端を基準に考えた方が都合がよい場合もあります。

そこで、下記の2つの異なる座標空間を相互に変換する方法を考えたいと思います。

  • モニタの左上端から見たマウス位置 (^mx_{\textrm{mouse}},\, ^my_{\textrm{mouse}})
  • ウィンドウの左上端から見たマウス位置 (^wx_{\textrm{mouse}},\, ^wy_{\textrm{mouse}})

この話はそこまで面倒なことではなく、モニタの左上端から見たウィンドウの位置 (^mx_{\textrm{window}},\, ^my_{\textrm{window}})が判れば下記のように簡単に求められます。

 (^mx_{\textrm{mouse}},\, ^my_{\textrm{mouse}}) = (^mx_{\textrm{window}},\, ^my_{\textrm{window}}) + (^wx_{\textrm{mouse}},\, ^wy_{\textrm{mouse}})

モニタの左上端から見たメインウィンドウの位置は、AutomationElement.AutomationElementInformation.BoundingRectangle プロパティを使うことで取得できます(厳密には、取得される情報は当該要素を囲う矩形の座標です)。

まず、プロジェクトの参照設定で以下を追加します。

  • WindowsBase

次に、こういうメソッドを適当に書いてやります。

static Point GetPosition(AutomationElement element)
{
    var rect = mainForm.Current.BoundingRectangle;
    return new Point((int)rect.Left, (int)rect.Top);
}

あとはMainメソッドの中でこう書いてやると、ウィンドウ基準のマウス座標とモニタ基準のずれ(オフセット値)が簡単に取得できます。やったー。しにたい。

mainForm = AutomationElement.FromHandle(process.MainWindowHandle);

var point = GetPosition(mainForm);

int offsetX = point.X;
int offsetY = point.Y;

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.