├── .github └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── _config.yml ├── doc └── ImageHorizonLibrary.html ├── setup.cfg ├── setup.py ├── src └── ImageHorizonLibrary │ ├── __init__.py │ ├── errors.py │ ├── interaction │ ├── __init__.py │ ├── _keyboard.py │ ├── _mouse.py │ └── _operating_system.py │ ├── recognition │ ├── __init__.py │ ├── _recognize_images.py │ └── _screenshot.py │ ├── utils.py │ └── version.py └── tests ├── atest ├── calculator.robot ├── calculator │ ├── calculator.py │ └── web │ │ ├── Roboto-Black.ttf │ │ └── main.html ├── reference_images │ └── calculator │ │ ├── close_button.png │ │ ├── inputs_folder │ │ ├── doge.png │ │ ├── inputs_local.png │ │ ├── inputs_ubuntu.png │ │ └── inputs_windows.png │ │ └── or_button.png └── run_tests.py └── utest ├── reference_images └── my_picture.png ├── run_tests.py ├── rëförence_imägës └── mÿ_päksör.png ├── symbolic_link └── my_picture.png ├── test_keyboard.py ├── test_main_class.py ├── test_mouse.py ├── test_operating_system.py ├── test_recognize_images.py └── test_screenshot.py /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests (Python 3.x) 2 | 3 | on: 4 | push: 5 | paths: 6 | - '.github/workflows/**' 7 | - 'src/**' 8 | - 'tests/**' 9 | - 'setup.py' 10 | pull_request: 11 | paths: 12 | - '.github/workflows/**' 13 | - 'src/**' 14 | - 'tests/**' 15 | - 'setup.py' 16 | jobs: 17 | test_using_builtin_python: 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [ 'ubuntu-latest', 'windows-latest' ] 23 | python-version: [ '3.6', '3.7', '3.8' ] 24 | include: 25 | - os: ubuntu-latest 26 | set_display: export DISPLAY=:99; Xvfb :99 -screen 0 1024x768x24 -ac -noreset & sleep 3 27 | #- os: macos-latest 28 | #set_tmpdir: export TMPDIR=/tmp 29 | - os: windows-latest 30 | set_codepage: chcp 850 31 | 32 | runs-on: ${{ matrix.os }} 33 | 34 | name: Python ${{ matrix.python-version }} on ${{ matrix.os }} 35 | steps: 36 | - uses: actions/checkout@v2 37 | 38 | - name: Setup python ${{ matrix.python-version }} for running the tests 39 | uses: actions/setup-python@v1 40 | with: 41 | python-version: ${{ matrix.python-version }} 42 | architecture: 'x64' 43 | 44 | # pyautogui does not work by default (https://github.com/asweigart/pyautogui/issues/247) 45 | # need to disable security feature (https://apple.stackexchange.com/questions/178313/change-accessibility-setting-on-mac-using-terminal) 46 | # and database is readonly (https://github.com/jacobsalmela/tccutil/issues/18) 47 | # you need to disable SIP, but for that go into recovery mode and disable it - not on CI system 48 | #- name: Install test tools to Mac 49 | #run: | 50 | #sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" 'UPDATE access SET allowed = "1";' 51 | #if: runner.os == 'macOS' 52 | 53 | - name: Install test tools to Linux 54 | run: | 55 | sudo apt-get update 56 | sudo apt-get -y -q install xvfb scrot 57 | touch ~/.Xauthority 58 | if: contains(matrix.os, 'ubuntu') 59 | 60 | - name: Install python test dependencies 61 | run: | 62 | python --version 63 | python -m pip install mock robotframework opencv-python eel . 64 | 65 | - name: Run tests 66 | run: | 67 | ${{ matrix.set_codepage }} 68 | ${{ matrix.set_display }} 69 | python tests/utest/run_tests.py 70 | python tests/atest/run_tests.py 71 | 72 | - name: Archive acceptances test results 73 | uses: actions/upload-artifact@v2.3.0 74 | with: 75 | name: output-${{ matrix.python-version }}-${{ matrix.os }} 76 | path: | 77 | log.html 78 | ./*.png 79 | if: always() && job.status == 'failure' 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | output.xml 3 | report.html 4 | log.html 5 | *.pyc 6 | *-screenshot-*.png 7 | *\$py.class 8 | *.swp 9 | *__pycache__ 10 | /bin 11 | /include 12 | /lib 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Eficode Oy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | recursive-include src *.py 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | ImageHorizonLibrary 3 | =================== 4 | 5 | This Robot Framework library provides the facilities to automate GUIs based on 6 | image recognition similar to Sikuli. This library wraps pyautogui_ to achieve 7 | this. 8 | 9 | For non pixel perfect matches, there is a feature called `confidence level` 10 | that comes with a dependency OpenCV (python package: `opencv-python`). 11 | This functionality is optional - you are not required to 12 | install `opencv-python` package if you do not use confidence level. 13 | 14 | Keyword documentation 15 | --------------------- 16 | 17 | `Keyword Documentation`__ 18 | 19 | __ http://eficode.github.io/robotframework-imagehorizonlibrary/doc/ImageHorizonLibrary.html 20 | 21 | Travis CI 22 | --------- 23 | 24 | `Travis CI`__ 25 | 26 | __ https://travis-ci.org/Eficode/robotframework-imagehorizonlibrary/ 27 | 28 | 29 | .. image:: https://travis-ci.org/Eficode/robotframework-imagehorizonlibrary.svg?branch=master 30 | :target: https://travis-ci.org/Eficode/robotframework-imagehorizonlibrary 31 | 32 | 33 | Prerequisites 34 | ------------- 35 | 36 | - `Python 3.x` 37 | - pip_ for easy installation 38 | - pyautogui_ and `it's prerequisites`_ 39 | - `Robot Framework`_ 40 | 41 | On Ubuntu, you need to take `special measures`_ to make the screenshot 42 | functionality to work correctly. The keyboard functions might not work on 43 | Ubuntu when run in VirtualBox on Windows. 44 | 45 | Development 46 | ''''''''''' 47 | 48 | - mock__ 49 | 50 | __ http://www.voidspace.org.uk/python/mock/ 51 | 52 | Installation 53 | ------------ 54 | 55 | If you have pip_, installation is straightforward: 56 | 57 | :: 58 | 59 | $ pip install robotframework-imagehorizonlibrary 60 | 61 | This will automatically install dependencies as well as their dependencies. 62 | 63 | 64 | Windows 65 | ''''''' 66 | 67 | ImageHorizonLibrary should work on Windows "out-of-the-box". Just run the 68 | commands above to install it. 69 | 70 | OSX 71 | ''' 72 | 73 | *NOTICE* 74 | ImageHorizonLibrary does not currently work with XCode v.8. Please use a previous version. 75 | 76 | You additionally need to install these for pyautogui_: 77 | 78 | :: 79 | 80 | $ pip install pyobjc-core pyobjc 81 | 82 | 83 | For these, you need to install XCode_ 84 | 85 | Linux 86 | ''''' 87 | 88 | You additionally need to install these for pyautogui_: 89 | 90 | :: 91 | 92 | $ sudo apt-get install python-dev python-xlib 93 | 94 | 95 | You might also need, depending on your Python distribution, to install: 96 | 97 | :: 98 | 99 | $ sudo apt-get install python-tk 100 | 101 | If you are using virtualenv, you must install python-xlib_ manually to the 102 | virtual environment for pyautogui_: 103 | 104 | - `Fetch the source distribution`_ 105 | - Install with: 106 | 107 | :: 108 | 109 | $ pip install python-xlib-.tar.gz 110 | 111 | Running unit tests 112 | ------------------ 113 | 114 | :: 115 | 116 | $ python tests/utest/run_tests.py [verbosity=2] 117 | 118 | 119 | Running acceptance tests 120 | ------------------------ 121 | 122 | Additionally to unit test dependencies, you also need OpenCV, Eel, scrot and Chrome/Chromium browser. 123 | OpenCV is used because this tests are testing also confidence level. 124 | Browser is used by Eel for cross-platform GUI demo application. 125 | scrot is used for capturing screenshots. 126 | 127 | :: 128 | 129 | $ pip install opencv-python eel 130 | 131 | 132 | To run tests, run this command: 133 | 134 | :: 135 | 136 | $ python tests/atest/run_tests.py 137 | 138 | 139 | Updating Docs 140 | ------------- 141 | 142 | To regenerate documentation (`doc/ImageHorizonLibrary.html`), use this command: 143 | 144 | :: 145 | 146 | $ python -m robot.libdoc -P ./src ImageHorizonLibrary doc/ImageHorizonLibrary.html 147 | 148 | 149 | .. _Python 3.x: http://python.org 150 | .. _pip: https://pypi.python.org/pypi/pip 151 | .. _pyautogui: https://github.com/asweigart/pyautogui 152 | .. _it's prerequisites: https://pyautogui.readthedocs.org/en/latest/install.html 153 | .. _Robot Framework: http://robotframework.org 154 | .. _double all coordinates: https://github.com/asweigart/pyautogui/issues/33 155 | .. _special measures: https://pyautogui.readthedocs.org/en/latest/screenshot.html#special-notes-about-ubuntu 156 | .. _XCode: https://developer.apple.com/xcode/downloads/ 157 | .. _Fetch the source distribution: 158 | .. _python-xlib: http://sourceforge.net/projects/python-xlib/files/ 159 | 160 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_files = LICENSE 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from os.path import abspath, dirname, join as path_join 3 | from setuptools import setup 4 | 5 | CURDIR = abspath(dirname(__file__)) 6 | 7 | # get VERSION number 8 | exec(compile(open('src/ImageHorizonLibrary/version.py', "rb").read(), 'src/ImageHorizonLibrary/version.py', 'exec')) 9 | 10 | KEYWORDS = ('imagerecognition gui robotframework testing testautomation ' 11 | 'acceptancetesting atdd bdd') 12 | 13 | SHORT_DESC = ('Cross-platform Robot Framework library for GUI automation ' 14 | 'based on image recognition') 15 | 16 | with open(path_join(CURDIR, 'README.rst'), 'r') as readme: 17 | LONG_DESCRIPTION = readme.read() 18 | 19 | CLASSIFIERS = ''' 20 | Development Status :: 5 - Production/Stable 21 | Programming Language :: Python :: 3 :: Only 22 | Operating System :: OS Independent 23 | Topic :: Software Development :: Testing 24 | License :: OSI Approved :: MIT License 25 | '''.strip().splitlines() 26 | 27 | setup(name='robotframework-imagehorizonlibrary', 28 | author='Eficode Oy', 29 | author_email='info@eficode.com', 30 | url='https://github.com/Eficode/robotframework-imagehorizonlibrary', 31 | license='MIT', 32 | install_requires=[ 33 | 'robotframework>=2.8', 34 | 'pyautogui>=0.9.30' 35 | ], 36 | packages=[ 37 | 'ImageHorizonLibrary', 38 | 'ImageHorizonLibrary.interaction', 39 | 'ImageHorizonLibrary.recognition', 40 | ], 41 | package_dir={'': 'src'}, 42 | keywords=KEYWORDS, 43 | classifiers=CLASSIFIERS, 44 | version=VERSION, 45 | description=SHORT_DESC, 46 | long_description=LONG_DESCRIPTION) 47 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import OrderedDict 3 | from contextlib import contextmanager 4 | 5 | from .errors import * # import errors before checking dependencies! 6 | 7 | try: 8 | import pyautogui as ag 9 | except ImportError: 10 | raise ImageHorizonLibraryError('There is something wrong pyautogui or ' 11 | 'it is not installed.') 12 | 13 | try: 14 | from robot.api import logger as LOGGER 15 | from robot.libraries.BuiltIn import BuiltIn 16 | except ImportError: 17 | raise ImageHorizonLibraryError('There is something wrong with ' 18 | 'Robot Framework or it is not installed.') 19 | 20 | try: 21 | from tkinter import Tk as TK 22 | except ImportError: 23 | raise ImageHorizonLibraryError('There is either something wrong with ' 24 | 'Tkinter or you are running this on Java, ' 25 | 'which is not a supported platform. Please ' 26 | 'use Python and verify that Tkinter works.') 27 | 28 | from . import utils 29 | from .interaction import * 30 | from .recognition import * 31 | from .version import VERSION 32 | 33 | __version__ = VERSION 34 | 35 | 36 | class ImageHorizonLibrary(_Keyboard, 37 | _Mouse, 38 | _OperatingSystem, 39 | _RecognizeImages, 40 | _Screenshot): 41 | '''A cross-platform Robot Framework library for GUI automation. 42 | 43 | ImageHorizonLibrary provides keyboard and mouse actions as well as 44 | facilities to recognize images on screen. It can also take screenshots in 45 | case of failure or otherwise. 46 | 47 | 48 | This library is built on top of 49 | [https://pyautogui.readthedocs.org|pyautogui]. 50 | 51 | == Confidence Level == 52 | By default, image recognition searches images with pixel-perfect matching. 53 | This is in many scenarios too precise, as changing desktop background, 54 | transpareny in the reference images, slightly changing resolutions, and 55 | myriad of factors might throw the algorithm off. In these cases, it is 56 | advised to adjust the precision manually. 57 | 58 | This ability to adjust can be enabled by installing 59 | [https://pypi.org/project/opencv-python|opencv-python] Python package 60 | separately: 61 | 62 | | $ pip install opencv-python 63 | 64 | After installation, the library will use OpenCV, which enables setting the 65 | precision during `library importing` and during the test case with keyword 66 | `Set Confidence`. 67 | 68 | 69 | = Reference image names = 70 | ``reference_image`` parameter can be either a single file, or a folder. 71 | If ``reference_image`` is a folder, image recognition is tried separately 72 | for each image in that folder, in alphabetical order until a match is found. 73 | 74 | For ease of use, reference image names are automatically normalized 75 | according to the following rules: 76 | 77 | - The name is lower cased: ``MYPICTURE`` and ``mYPiCtUrE`` become 78 | ``mypicture`` 79 | 80 | - All spaces are converted to underscore ``_``: ``my picture`` becomes 81 | ``my_picture`` 82 | 83 | - If the image name does not end in ``.png``, it will be added: 84 | ``mypicture`` becomes ``mypicture.png`` 85 | 86 | - Path to _reference folder_ is prepended. This option must be given when 87 | `importing` the library. 88 | 89 | Using good names for reference images is evident from easy-to-read test 90 | data: 91 | 92 | | `Import Library` | ImageHorizonLibrary | reference_folder=images | | 93 | | `Click Image` | popup Window title | | # Path is images/popup_window_title.png | 94 | | `Click Image` | button Login Without User Credentials | | # Path is images/button_login_without_user_credentials.png | 95 | 96 | = Performance = 97 | 98 | Locating images on screen, especially if screen resolution is large and 99 | reference image is also large, might take considerable time. It is 100 | therefore advisable to save the returned coordinates if you are 101 | manipulating the same context many times in the row: 102 | 103 | | `Wait For` | label Name | | 104 | | `Click To The Left Of Image` | label Name | 200 | 105 | 106 | In the above example, same image is located twice. Below is an example how 107 | we can leverage the returned location: 108 | 109 | | ${location}= | `Wait For` | label Name | 110 | | `Click To The Left Of` | ${location} | 200 | 111 | ''' 112 | 113 | ROBOT_LIBRARY_SCOPE = 'TEST SUITE' 114 | ROBOT_LIBRARY_VERSION = VERSION 115 | 116 | def __init__(self, reference_folder=None, screenshot_folder=None, 117 | keyword_on_failure='ImageHorizonLibrary.Take A Screenshot', 118 | confidence=None): 119 | '''ImageHorizonLibrary can be imported with several options. 120 | 121 | ``reference_folder`` is path to the folder where all reference images 122 | are stored. It must be a _valid absolute path_. As the library 123 | is suite-specific (ie. new instance is created for every suite), 124 | different suites can have different folders for it's reference images. 125 | 126 | ``screenshot_folder`` is path to the folder where screenshots are 127 | saved. If not given, screenshots are saved to the current working 128 | directory. 129 | 130 | ``keyword_on_failure`` is the keyword to be run, when location-related 131 | keywords fail. If you wish to not take screenshots, use for example 132 | `BuiltIn.No Operation`. Keyword must however be a valid keyword. 133 | 134 | ``confidence`` provides a tolerance for the ``reference_image``. 135 | It can be used if python-opencv is installed and 136 | is given as number between 0 and 1. Not used 137 | by default. 138 | ''' 139 | 140 | self.reference_folder = reference_folder 141 | self.screenshot_folder = screenshot_folder 142 | self.keyword_on_failure = keyword_on_failure 143 | self.open_applications = OrderedDict() 144 | self.screenshot_counter = 1 145 | self.is_windows = utils.is_windows() 146 | self.is_mac = utils.is_mac() 147 | self.is_linux = utils.is_linux() 148 | self.has_retina = utils.has_retina() 149 | self.has_cv = utils.has_cv() 150 | self.confidence = confidence 151 | 152 | def _get_location(self, direction, location, offset): 153 | x, y = location 154 | offset = int(offset) 155 | if direction == 'left': 156 | x = x - offset 157 | if direction == 'up': 158 | y = y - offset 159 | if direction == 'right': 160 | x = x + offset 161 | if direction == 'down': 162 | y = y + offset 163 | return x, y 164 | 165 | def _click_to_the_direction_of(self, direction, location, offset, 166 | clicks, button, interval): 167 | x, y = self._get_location(direction, location, offset) 168 | try: 169 | clicks = int(clicks) 170 | except ValueError: 171 | raise MouseException('Invalid argument "%s" for `clicks`') 172 | if button not in ['left', 'middle', 'right']: 173 | raise MouseException('Invalid button "%s" for `button`') 174 | try: 175 | interval = float(interval) 176 | except ValueError: 177 | raise MouseException('Invalid argument "%s" for `interval`') 178 | 179 | LOGGER.info('Clicking %d time(s) at (%d, %d) with ' 180 | '%s mouse button at interval %f' % (clicks, x, y, 181 | button, interval)) 182 | ag.click(x, y, clicks=clicks, button=button, interval=interval) 183 | 184 | def _convert_to_valid_special_key(self, key): 185 | key = str(key).lower() 186 | if key.startswith('key.'): 187 | key = key.split('key.', 1)[1] 188 | elif len(key) > 1: 189 | return None 190 | if key in ag.KEYBOARD_KEYS: 191 | return key 192 | return None 193 | 194 | def _validate_keys(self, keys): 195 | valid_keys = [] 196 | for key in keys: 197 | valid_key = self._convert_to_valid_special_key(key) 198 | if not valid_key: 199 | raise KeyboardException('Invalid keyboard key "%s", valid ' 200 | 'keyboard keys are:\n%r' % 201 | (key, ', '.join(ag.KEYBOARD_KEYS))) 202 | valid_keys.append(valid_key) 203 | return valid_keys 204 | 205 | def _press(self, *keys, **options): 206 | keys = self._validate_keys(keys) 207 | ag.hotkey(*keys, **options) 208 | 209 | @contextmanager 210 | def _tk(self): 211 | tk = TK() 212 | yield tk.clipboard_get() 213 | tk.destroy() 214 | 215 | def copy(self): 216 | '''Executes ``Ctrl+C`` on Windows and Linux, ``⌘+C`` on OS X and 217 | returns the content of the clipboard.''' 218 | key = 'Key.command' if self.is_mac else 'Key.ctrl' 219 | self._press(key, 'c') 220 | return self.get_clipboard_content() 221 | 222 | def get_clipboard_content(self): 223 | '''Returns what is currently copied in the system clipboard.''' 224 | with self._tk() as clipboard_content: 225 | return clipboard_content 226 | 227 | def pause(self): 228 | '''Shows a dialog that must be dismissed with manually clicking. 229 | 230 | This is mainly for when you are developing the test case and want to 231 | stop the test execution. 232 | 233 | It should probably not be used otherwise. 234 | ''' 235 | ag.alert(text='Test execution paused.', title='Pause', 236 | button='Continue') 237 | 238 | def _run_on_failure(self): 239 | if not self.keyword_on_failure: 240 | return 241 | try: 242 | BuiltIn().run_keyword(self.keyword_on_failure) 243 | except Exception as e: 244 | LOGGER.debug(e) 245 | LOGGER.warn('Failed to take a screenshot. ' 246 | 'Is Robot Framework running?') 247 | 248 | def set_reference_folder(self, reference_folder_path): 249 | '''Sets where all reference images are stored. 250 | 251 | See `library importing` for format of the reference folder path. 252 | ''' 253 | self.reference_folder = reference_folder_path 254 | 255 | def set_screenshot_folder(self, screenshot_folder_path): 256 | '''Sets the folder where screenshots are saved to. 257 | 258 | See `library importing` for more specific information. 259 | ''' 260 | self.screenshot_folder = screenshot_folder_path 261 | 262 | def set_confidence(self, new_confidence): 263 | '''Sets the accuracy when finding images. 264 | 265 | ``new_confidence`` is a decimal number between 0 and 1 inclusive. 266 | 267 | See `Confidence level` about additional dependencies that needs to be 268 | installed before this keyword has any effect. 269 | ''' 270 | if new_confidence is not None: 271 | try: 272 | new_confidence = float(new_confidence) 273 | if not 1 >= new_confidence >= 0: 274 | LOGGER.warn('Unable to set confidence to {}. Value ' 275 | 'must be between 0 and 1, inclusive.' 276 | .format(new_confidence)) 277 | else: 278 | self.confidence = new_confidence 279 | except TypeError as err: 280 | LOGGER.warn("Can't set confidence to {}".format(new_confidence)) 281 | else: 282 | self.confidence = None 283 | 284 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | class ImageHorizonLibraryError(ImportError): 3 | pass 4 | 5 | 6 | class ImageNotFoundException(Exception): 7 | def __init__(self, image_name): 8 | self.image_name = image_name 9 | 10 | def __str__(self): 11 | return 'Reference image "%s" was not found on screen' % self.image_name 12 | 13 | 14 | class InvalidImageException(Exception): 15 | pass 16 | 17 | 18 | class KeyboardException(Exception): 19 | pass 20 | 21 | 22 | class MouseException(Exception): 23 | pass 24 | 25 | 26 | class OSException(Exception): 27 | pass 28 | 29 | 30 | class ReferenceFolderException(Exception): 31 | pass 32 | 33 | 34 | class ScreenshotFolderException(Exception): 35 | pass 36 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/interaction/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from ._keyboard import _Keyboard 3 | from ._mouse import _Mouse 4 | from ._operating_system import _OperatingSystem 5 | 6 | __all__ = [ 7 | '_Keyboard', 8 | '_Mouse', 9 | '_OperatingSystem' 10 | ] 11 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/interaction/_keyboard.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pyautogui as ag 3 | 4 | 5 | class _Keyboard(object): 6 | def press_combination(self, *keys): 7 | '''Press given keyboard keys. 8 | 9 | All keyboard keys must be prefixed with ``Key.``. 10 | 11 | Keyboard keys are case-insensitive: 12 | 13 | | Press Combination | KEY.ALT | key.f4 |  14 | | Press Combination | kEy.EnD | | 15 | 16 | [https://pyautogui.readthedocs.org/en/latest/keyboard.html#keyboard-keys| 17 | See valid keyboard keys here]. 18 | ''' 19 | self._press(*keys) 20 | 21 | def type(self, *keys_or_text): 22 | '''Type text and keyboard keys. 23 | 24 | See valid keyboard keys in `Press Combination`. 25 | 26 | Examples: 27 | 28 | | Type | separated | Key.ENTER | by linebreak | 29 | | Type | Submit this with enter | Key.enter | | 30 | | Type | key.windows | notepad | Key.enter | 31 | ''' 32 | for key_or_text in keys_or_text: 33 | key = self._convert_to_valid_special_key(key_or_text) 34 | if key: 35 | ag.press(key) 36 | else: 37 | ag.typewrite(key_or_text) 38 | 39 | 40 | def type_with_keys_down(self, text, *keys): 41 | '''Press keyboard keys down, then write given text, then release the 42 | keyboard keys. 43 | 44 | See valid keyboard keys in `Press Combination`. 45 | 46 | Examples: 47 | 48 | | Type with keys down | write this in caps | Key.Shift | 49 | ''' 50 | valid_keys = self._validate_keys(keys) 51 | for key in valid_keys: 52 | ag.keyDown(key) 53 | ag.typewrite(text) 54 | for key in valid_keys: 55 | ag.keyUp(key) 56 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/interaction/_mouse.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pyautogui as ag 3 | 4 | from ..errors import MouseException 5 | 6 | 7 | class _Mouse(object): 8 | 9 | def _click_to_the_direction_of(self, direction, location, offset, 10 | clicks, button, interval): 11 | raise NotImplementedError('This is defined in the main class.') 12 | 13 | def click_to_the_above_of(self, location, offset, clicks=1, 14 | button='left', interval=0.0): 15 | '''Clicks above of given location by given offset. 16 | 17 | ``location`` can be any Python sequence type (tuple, list, etc.) that 18 | represents coordinates on the screen ie. have an x-value and y-value. 19 | Locating-related keywords return location you can use with this 20 | keyword. 21 | 22 | ``offset`` is the number of pixels from the specified ``location``. 23 | 24 | ``clicks`` is how many times the mouse button is clicked. 25 | 26 | See `Click` for documentation for valid buttons. 27 | 28 | Example: 29 | 30 | | ${image location}= | Locate | my image | | 31 | | Click To The Above Of | ${image location} | 50 | | 32 | | @{coordinates}= | Create List | ${600} | ${500} | 33 | | Click To The Above Of | ${coordinates} | 100 | | 34 | ''' 35 | self._click_to_the_direction_of('up', location, offset, 36 | clicks, button, interval) 37 | 38 | def click_to_the_below_of(self, location, offset, clicks=1, 39 | button='left', interval=0.0): 40 | '''Clicks below of given location by given offset. 41 | 42 | See argument documentation in `Click To The Above Of`. 43 | ''' 44 | self._click_to_the_direction_of('down', location, offset, 45 | clicks, button, interval) 46 | 47 | def click_to_the_left_of(self, location, offset, clicks=1, 48 | button='left', interval=0.0): 49 | '''Clicks left of given location by given offset. 50 | 51 | See argument documentation in `Click To The Above Of`. 52 | ''' 53 | self._click_to_the_direction_of('left', location, offset, 54 | clicks, button, interval) 55 | 56 | def click_to_the_right_of(self, location, offset, clicks=1, 57 | button='left', interval=0.0): 58 | '''Clicks right of given location by given offset. 59 | 60 | See argument documentation in `Click To The Above Of`. 61 | ''' 62 | self._click_to_the_direction_of('right', location, offset, 63 | clicks, button, interval) 64 | 65 | def move_to(self, *coordinates): 66 | '''Moves the mouse pointer to an absolute coordinates. 67 | 68 | ``coordinates`` can either be a Python sequence type with two values 69 | (eg. ``(x, y)``) or separate values ``x`` and ``y``: 70 | 71 | | Move To | 25 | 150 | | 72 | | @{coordinates}= | Create List | 25 | 150 | 73 | | Move To | ${coordinates} | | | 74 | | ${coords}= | Evaluate | (25, 150) | | 75 | | Move To | ${coords} | | | 76 | 77 | 78 | X grows from left to right and Y grows from top to bottom, which means 79 | that top left corner of the screen is (0, 0) 80 | ''' 81 | if len(coordinates) > 2 or (len(coordinates) == 1 and 82 | type(coordinates[0]) not in (list, tuple)): 83 | raise MouseException('Invalid number of coordinates. Please give ' 84 | 'either (x, y) or x, y.') 85 | if len(coordinates) == 2: 86 | coordinates = (coordinates[0], coordinates[1]) 87 | else: 88 | coordinates = coordinates[0] 89 | try: 90 | coordinates = [int(coord) for coord in coordinates] 91 | except ValueError: 92 | raise MouseException('Coordinates %s are not integers' % 93 | (coordinates,)) 94 | ag.moveTo(*coordinates) 95 | 96 | def mouse_down(self, button='left'): 97 | '''Presses specidied mouse button down''' 98 | ag.mouseDown(button=button) 99 | 100 | def mouse_up(self, button='left'): 101 | '''Releases specified mouse button''' 102 | ag.mouseUp(button=button) 103 | 104 | def click(self, button='left'): 105 | '''Clicks with the specified mouse button. 106 | 107 | Valid buttons are ``left``, ``right`` or ``middle``. 108 | ''' 109 | ag.click(button=button) 110 | 111 | def double_click(self, button='left', interval=0.0): 112 | '''Double clicks with the specified mouse button. 113 | 114 | See documentation of ``button`` in `Click`. 115 | 116 | ``interval`` specifies the time between clicks and should be 117 | floating point number. 118 | ''' 119 | ag.doubleClick(button=button, interval=float(interval)) 120 | 121 | def triple_click(self, button='left', interval=0.0): 122 | '''Triple clicks with the specified mouse button. 123 | 124 | See documentation of ``button`` in `Click`. 125 | 126 | See documentation of ``interval`` in `Double Click`. 127 | ''' 128 | ag.tripleClick(button=button, interval=float(interval)) 129 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/interaction/_operating_system.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import shlex 3 | import subprocess 4 | 5 | from ..errors import OSException 6 | 7 | 8 | class _OperatingSystem(object): 9 | 10 | def launch_application(self, app, alias=None): 11 | '''Launches an application. 12 | 13 | Executes the string argument ``app`` as a separate process with 14 | Python's 15 | ``[https://docs.python.org/2/library/subprocess.html|subprocess]`` 16 | module. It should therefore be the exact command you would use to 17 | launch the application from command line. 18 | 19 | On Windows, if you are using relative or absolute paths in ``app``, 20 | enclose the command with double quotes: 21 | 22 | | Launch Application | "C:\\my folder\\myprogram.exe" | # Needs quotes | 23 | | Launch Application | myprogram.exe | # No need for quotes | 24 | 25 | Returns automatically generated alias which can be used with `Terminate 26 | Application`. 27 | 28 | Automatically generated alias can be overridden by providing ``alias`` 29 | yourself. 30 | ''' 31 | if not alias: 32 | alias = str(len(self.open_applications)) 33 | process = subprocess.Popen(shlex.split(app)) 34 | self.open_applications[alias] = process 35 | return alias 36 | 37 | def terminate_application(self, alias=None): 38 | '''Terminates the process launched with `Launch Application` with 39 | given ``alias``. 40 | 41 | If no ``alias`` is given, terminates the last process that was 42 | launched. 43 | ''' 44 | if alias and alias not in self.open_applications: 45 | raise OSException('Invalid alias "%s".' % alias) 46 | process = self.open_applications.pop(alias, None) 47 | if not process: 48 | try: 49 | _, process = self.open_applications.popitem() 50 | except KeyError: 51 | raise OSException('`Terminate Application` called without ' 52 | '`Launch Application` called first.') 53 | process.terminate() 54 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/recognition/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from ._recognize_images import _RecognizeImages 3 | from ._screenshot import _Screenshot 4 | 5 | __all__ = [ 6 | '_RecognizeImages', 7 | '_Screenshot' 8 | ] 9 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/recognition/_recognize_images.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from os import listdir 3 | from os.path import abspath, isdir, isfile, join as path_join 4 | from time import time 5 | from contextlib import contextmanager 6 | 7 | import pyautogui as ag 8 | from robot.api import logger as LOGGER 9 | 10 | from ..errors import ImageNotFoundException, InvalidImageException 11 | from ..errors import ReferenceFolderException 12 | 13 | class _RecognizeImages(object): 14 | 15 | def __normalize(self, path): 16 | if (not self.reference_folder or 17 | not isinstance(self.reference_folder, str) or 18 | not isdir(self.reference_folder)): 19 | raise ReferenceFolderException('Reference folder is invalid: ' 20 | '"%s"' % self.reference_folder) 21 | if (not path or not isinstance(path, str)): 22 | raise InvalidImageException('"%s" is invalid image name.' % path) 23 | path = str(path.lower().replace(' ', '_')) 24 | path = abspath(path_join(self.reference_folder, path)) 25 | if not path.endswith('.png') and not isdir(path): 26 | path += '.png' 27 | if not isfile(path) and not isdir(path): 28 | raise InvalidImageException('Image path not found: "%s".' % path) 29 | return path 30 | 31 | def click_image(self, reference_image): 32 | '''Finds the reference image on screen and clicks it once. 33 | 34 | ``reference_image`` is automatically normalized as described in the 35 | `Reference image names`. 36 | ''' 37 | center_location = self.locate(reference_image) 38 | LOGGER.info('Clicking image "%s" in position %s' % (reference_image, 39 | center_location)) 40 | ag.click(center_location) 41 | return center_location 42 | 43 | def _click_to_the_direction_of(self, direction, location, offset, 44 | clicks, button, interval): 45 | raise NotImplementedError('This is defined in the main class.') 46 | 47 | def _locate_and_click_direction(self, direction, reference_image, offset, 48 | clicks, button, interval): 49 | location = self.locate(reference_image) 50 | self._click_to_the_direction_of(direction, location, offset, clicks, 51 | button, interval) 52 | 53 | def click_to_the_above_of_image(self, reference_image, offset, clicks=1, 54 | button='left', interval=0.0): 55 | '''Clicks above of reference image by given offset. 56 | 57 | See `Reference image names` for documentation for ``reference_image``. 58 | 59 | ``offset`` is the number of pixels from the center of the reference 60 | image. 61 | 62 | ``clicks`` and ``button`` are documented in `Click To The Above Of`. 63 | ''' 64 | self._locate_and_click_direction('up', reference_image, offset, 65 | clicks, button, interval) 66 | 67 | def click_to_the_below_of_image(self, reference_image, offset, clicks=1, 68 | button='left', interval=0.0): 69 | '''Clicks below of reference image by given offset. 70 | 71 | See argument documentation in `Click To The Above Of Image`. 72 | ''' 73 | self._locate_and_click_direction('down', reference_image, offset, 74 | clicks, button, interval) 75 | 76 | def click_to_the_left_of_image(self, reference_image, offset, clicks=1, 77 | button='left', interval=0.0): 78 | '''Clicks left of reference image by given offset. 79 | 80 | See argument documentation in `Click To The Above Of Image`. 81 | ''' 82 | self._locate_and_click_direction('left', reference_image, offset, 83 | clicks, button, interval) 84 | 85 | def click_to_the_right_of_image(self, reference_image, offset, clicks=1, 86 | button='left', interval=0.0): 87 | '''Clicks right of reference image by given offset. 88 | 89 | See argument documentation in `Click To The Above Of Image`. 90 | ''' 91 | self._locate_and_click_direction('right', reference_image, offset, 92 | clicks, button, interval) 93 | 94 | def copy_from_the_above_of(self, reference_image, offset): 95 | '''Clicks three times above of reference image by given offset and 96 | copies. 97 | 98 | See `Reference image names` for documentation for ``reference_image``. 99 | 100 | See `Click To The Above Of Image` for documentation for ``offset``. 101 | 102 | Copy is done by pressing ``Ctrl+C`` on Windows and Linux and ``⌘+C`` 103 | on OS X. 104 | ''' 105 | self._locate_and_click_direction('up', reference_image, offset, 106 | clicks=3, button='left', interval=0.0) 107 | return self.copy() 108 | 109 | def copy_from_the_below_of(self, reference_image, offset): 110 | '''Clicks three times below of reference image by given offset and 111 | copies. 112 | 113 | See argument documentation in `Copy From The Above Of`. 114 | ''' 115 | self._locate_and_click_direction('down', reference_image, offset, 116 | clicks=3, button='left', interval=0.0) 117 | return self.copy() 118 | 119 | def copy_from_the_left_of(self, reference_image, offset): 120 | '''Clicks three times left of reference image by given offset and 121 | copies. 122 | 123 | See argument documentation in `Copy From The Above Of`. 124 | ''' 125 | self._locate_and_click_direction('left', reference_image, offset, 126 | clicks=3, button='left', interval=0.0) 127 | return self.copy() 128 | 129 | def copy_from_the_right_of(self, reference_image, offset): 130 | '''Clicks three times right of reference image by given offset and 131 | copies. 132 | 133 | See argument documentation in `Copy From The Above Of`. 134 | ''' 135 | self._locate_and_click_direction('right', reference_image, offset, 136 | clicks=3, button='left', interval=0.0) 137 | return self.copy() 138 | 139 | @contextmanager 140 | def _suppress_keyword_on_failure(self): 141 | keyword = self.keyword_on_failure 142 | self.keyword_on_failure = None 143 | yield None 144 | self.keyword_on_failure = keyword 145 | 146 | def _locate(self, reference_image, log_it=True): 147 | is_dir = False 148 | try: 149 | if isdir(self.__normalize(reference_image)): 150 | is_dir = True 151 | except InvalidImageException: 152 | pass 153 | is_file = False 154 | try: 155 | if isfile(self.__normalize(reference_image)): 156 | is_file = True 157 | except InvalidImageException: 158 | pass 159 | reference_image = self.__normalize(reference_image) 160 | 161 | reference_images = [] 162 | if is_file: 163 | reference_images = [reference_image] 164 | elif is_dir: 165 | for f in listdir(self.__normalize(reference_image)): 166 | if not isfile(self.__normalize(path_join(reference_image, f))): 167 | raise InvalidImageException( 168 | self.__normalize(reference_image)) 169 | reference_images.append(path_join(reference_image, f)) 170 | 171 | def try_locate(ref_image): 172 | location = None 173 | with self._suppress_keyword_on_failure(): 174 | try: 175 | if self.has_cv and self.confidence: 176 | location = ag.locateOnScreen(ref_image, 177 | confidence=self.confidence) 178 | else: 179 | if self.confidence: 180 | LOGGER.warn("Can't set confidence because you don't " 181 | "have OpenCV (python-opencv) installed " 182 | "or a confidence level was not given.") 183 | location = ag.locateOnScreen(ref_image) 184 | except ImageNotFoundException as ex: 185 | LOGGER.info(ex) 186 | pass 187 | return location 188 | 189 | location = None 190 | for ref_image in reference_images: 191 | location = try_locate(ref_image) 192 | if location != None: 193 | break 194 | 195 | if location is None: 196 | if log_it: 197 | LOGGER.info('Image "%s" was not found ' 198 | 'on screen.' % reference_image) 199 | self._run_on_failure() 200 | raise ImageNotFoundException(reference_image) 201 | if log_it: 202 | LOGGER.info('Image "%s" found at %r' % (reference_image, location)) 203 | center_point = ag.center(location) 204 | x = center_point.x 205 | y = center_point.y 206 | if self.has_retina: 207 | x = x / 2 208 | y = y / 2 209 | return (x, y) 210 | 211 | def does_exist(self, reference_image): 212 | '''Returns ``True`` if reference image was found on screen or 213 | ``False`` otherwise. Never fails. 214 | 215 | See `Reference image names` for documentation for ``reference_image``. 216 | ''' 217 | with self._suppress_keyword_on_failure(): 218 | try: 219 | return bool(self._locate(reference_image, log_it=False)) 220 | except ImageNotFoundException: 221 | return False 222 | 223 | def locate(self, reference_image): 224 | '''Locate image on screen. 225 | 226 | Fails if image is not found on screen. 227 | 228 | Returns Python tuple ``(x, y)`` of the coordinates. 229 | ''' 230 | return self._locate(reference_image) 231 | 232 | def wait_for(self, reference_image, timeout=10): 233 | '''Tries to locate given image from the screen for given time. 234 | 235 | Fail if the image is not found on the screen after ``timeout`` has 236 | expired. 237 | 238 | See `Reference image names` for documentation for ``reference_image``. 239 | 240 | ``timeout`` is given in seconds. 241 | 242 | Returns Python tuple ``(x, y)`` of the coordinates. 243 | ''' 244 | stop_time = time() + int(timeout) 245 | location = None 246 | with self._suppress_keyword_on_failure(): 247 | while time() < stop_time: 248 | try: 249 | location = self._locate(reference_image, log_it=False) 250 | break 251 | except ImageNotFoundException: 252 | pass 253 | if location is None: 254 | self._run_on_failure() 255 | raise ImageNotFoundException(self.__normalize(reference_image)) 256 | LOGGER.info('Image "%s" found at %r' % (reference_image, location)) 257 | return location 258 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/recognition/_screenshot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from os.path import abspath, join as path_join 3 | from random import choice 4 | from string import ascii_lowercase 5 | 6 | import pyautogui as ag 7 | from robot.libraries.BuiltIn import BuiltIn, RobotNotRunningError 8 | from robot.api import logger as LOGGER 9 | 10 | from ..errors import ScreenshotFolderException 11 | 12 | 13 | class _Screenshot(object): 14 | def _make_up_filename(self): 15 | try: 16 | path = BuiltIn().get_variable_value('${SUITE NAME}') 17 | path = '%s-screenshot' % path.replace(' ', '') 18 | except RobotNotRunningError: 19 | LOGGER.info('Could not get suite name, using ' 20 | 'default naming scheme') 21 | path = 'ImageHorizon-screenshot' 22 | path = '%s-%d.png' % (path, self.screenshot_counter) 23 | self.screenshot_counter += 1 24 | return path 25 | 26 | def take_a_screenshot(self): 27 | '''Takes a screenshot of the screen. 28 | 29 | This keyword is run on failure if it is not overwritten when 30 | `importing` the library. 31 | 32 | Screenshots are saved to the current working directory or in the 33 | ``screenshot_folder`` if such is defined during `importing`. 34 | 35 | The file name for the screenshot is the current suite name with a 36 | running integer appended. If this keyword is used outside of Robot 37 | Framework execution, file name is this library's name with running 38 | integer appended. 39 | ''' 40 | target_dir = self.screenshot_folder if self.screenshot_folder else '' 41 | if not isinstance(target_dir, str): 42 | raise ScreenshotFolderException('Screenshot folder is invalid: ' 43 | '"%s"' % target_dir) 44 | path = self._make_up_filename() 45 | path = abspath(path_join(target_dir, path)) 46 | LOGGER.info('Screenshot taken: {0}
'.format(path), html=True) 48 | ag.screenshot(path) 49 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from platform import platform, architecture 3 | from subprocess import call 4 | 5 | 6 | PLATFORM = platform() 7 | ARCHITECTURE = architecture() 8 | 9 | 10 | def is_windows(): 11 | return PLATFORM.lower().startswith('windows') 12 | 13 | 14 | def is_mac(): 15 | return PLATFORM.lower().startswith('darwin') 16 | 17 | 18 | def is_linux(): 19 | return PLATFORM.lower().startswith('linux') 20 | 21 | 22 | def is_java(): 23 | return PLATFORM.lower().startswith('java') 24 | 25 | def has_retina(): 26 | if is_mac(): 27 | # Will return 0 if there is a retina display 28 | return call("system_profiler SPDisplaysDataType | grep 'Retina'", shell=True) == 0 29 | return False 30 | 31 | def has_cv(): 32 | has_cv = True 33 | try: 34 | import cv2 35 | except ModuleNotFoundError as err: 36 | has_cv = False 37 | return has_cv 38 | -------------------------------------------------------------------------------- /src/ImageHorizonLibrary/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | VERSION = '1.1-devel' 3 | -------------------------------------------------------------------------------- /tests/atest/calculator.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library ImageHorizonLibrary ${CURDIR}${/}reference_images${/}calculator screenshot_folder=${OUTPUT_DIR} 3 | 4 | *** Test cases *** 5 | 6 | Calculator 7 | Set Confidence 0.9 8 | Launch application python tests/atest/calculator/calculator.py 9 | ${location1}= Wait for inputs_folder timeout=30 10 | Click to the above of ${location1} 20 11 | Type 1010 12 | Click to the below of ${location1} 20 13 | Type 1001 14 | ${location2}= Locate or_button.png 15 | Click to the below of ${location2} 0 16 | Click to the below of ${location2} 50 17 | ${result}= Copy 18 | Should be equal as integers ${result} 1011 19 | Click Image close_button.png 20 | [Teardown] Terminate application 21 | -------------------------------------------------------------------------------- /tests/atest/calculator/calculator.py: -------------------------------------------------------------------------------- 1 | import eel 2 | import os 3 | import sys 4 | 5 | 6 | os.chdir(os.path.dirname(__file__)) 7 | 8 | eel.init('web') 9 | 10 | 11 | @eel.expose 12 | def close(): 13 | sys.exit(0) 14 | 15 | 16 | mode = 'edge' if os.name == 'nt' else 'chrome' 17 | 18 | eel.start('main.html', size=(300, 380), mode=mode) 19 | -------------------------------------------------------------------------------- /tests/atest/calculator/web/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-imagehorizonlibrary/0778575e51a6e3b04c8cddbdd99204a988327572/tests/atest/calculator/web/Roboto-Black.ttf -------------------------------------------------------------------------------- /tests/atest/calculator/web/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Bitwise Calculator 5 | 6 | 62 | 63 | 64 | 167 | 168 | 169 | 170 | 171 |
172 |
173 | 174 | 175 |
176 |
177 | 178 | 179 |
180 | 181 |
182 | 183 | 184 |
185 |
186 |
187 | N/A 188 |
189 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /tests/atest/reference_images/calculator/close_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-imagehorizonlibrary/0778575e51a6e3b04c8cddbdd99204a988327572/tests/atest/reference_images/calculator/close_button.png -------------------------------------------------------------------------------- /tests/atest/reference_images/calculator/inputs_folder/doge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-imagehorizonlibrary/0778575e51a6e3b04c8cddbdd99204a988327572/tests/atest/reference_images/calculator/inputs_folder/doge.png -------------------------------------------------------------------------------- /tests/atest/reference_images/calculator/inputs_folder/inputs_local.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-imagehorizonlibrary/0778575e51a6e3b04c8cddbdd99204a988327572/tests/atest/reference_images/calculator/inputs_folder/inputs_local.png -------------------------------------------------------------------------------- /tests/atest/reference_images/calculator/inputs_folder/inputs_ubuntu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-imagehorizonlibrary/0778575e51a6e3b04c8cddbdd99204a988327572/tests/atest/reference_images/calculator/inputs_folder/inputs_ubuntu.png -------------------------------------------------------------------------------- /tests/atest/reference_images/calculator/inputs_folder/inputs_windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-imagehorizonlibrary/0778575e51a6e3b04c8cddbdd99204a988327572/tests/atest/reference_images/calculator/inputs_folder/inputs_windows.png -------------------------------------------------------------------------------- /tests/atest/reference_images/calculator/or_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-imagehorizonlibrary/0778575e51a6e3b04c8cddbdd99204a988327572/tests/atest/reference_images/calculator/or_button.png -------------------------------------------------------------------------------- /tests/atest/run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import os 6 | 7 | from pathlib import Path 8 | 9 | from robot import run_cli 10 | 11 | 12 | if __name__ == '__main__': 13 | curdir = Path(__file__).parent 14 | srcdir = curdir / '..' / '..' / 'src' 15 | run_cli(sys.argv[1:] + ['-P', srcdir.resolve(), curdir]) 16 | -------------------------------------------------------------------------------- /tests/utest/reference_images/my_picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-imagehorizonlibrary/0778575e51a6e3b04c8cddbdd99204a988327572/tests/utest/reference_images/my_picture.png -------------------------------------------------------------------------------- /tests/utest/run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | 5 | from os.path import abspath, dirname, join as path_join 6 | from unittest import TestLoader, TextTestRunner 7 | 8 | directory = dirname(__file__) 9 | path = path_join(abspath(path_join(directory, '..', '..', 'src'))) 10 | sys.path.insert(1, path) 11 | 12 | try: 13 | import mock 14 | except ImportError: 15 | raise ImportError('Please install mock') 16 | 17 | if len(sys.argv) > 1 and 'verbosity=' in sys.argv[1]: 18 | verbosity = int(sys.argv[1].split('=')[1]) 19 | else: 20 | verbosity = 1 21 | 22 | sys.exit(not TextTestRunner(verbosity=verbosity).run(TestLoader().discover(directory)).wasSuccessful()) 23 | -------------------------------------------------------------------------------- /tests/utest/rëförence_imägës/mÿ_päksör.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-imagehorizonlibrary/0778575e51a6e3b04c8cddbdd99204a988327572/tests/utest/rëförence_imägës/mÿ_päksör.png -------------------------------------------------------------------------------- /tests/utest/symbolic_link/my_picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-imagehorizonlibrary/0778575e51a6e3b04c8cddbdd99204a988327572/tests/utest/symbolic_link/my_picture.png -------------------------------------------------------------------------------- /tests/utest/test_keyboard.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest import TestCase 3 | from mock import patch, MagicMock 4 | 5 | 6 | KEYBOARD_KEYS = [ 7 | '\\t', '\\n', '\\r', ' ', '!', '"', '#', '$', '%', '&', "'", '(', 8 | ')', '*', '+', ',', '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '7', 9 | '8', '9', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '^', '_', '`', 10 | 'a', 'b', 'c', 'd', 'e','f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 11 | 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~', 12 | 'accept', 'add', 'alt', 'altleft', 'altright', 'apps', 'backspace', 13 | 'browserback', 'browserfavorites', 'browserforward', 'browserhome', 14 | 'browserrefresh', 'browsersearch', 'browserstop', 'capslock', 'clear', 15 | 'convert', 'ctrl', 'ctrlleft', 'ctrlright', 'decimal', 'del', 'delete', 16 | 'divide', 'down', 'end', 'enter', 'esc', 'escape', 'execute', 'f1', 'f10', 17 | 'f11', 'f12', 'f13', 'f14', 'f15', 'f16', 'f17', 'f18', 'f19', 'f2', 'f20', 18 | 'f21', 'f22', 'f23', 'f24', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 19 | 'final', 'fn', 'hanguel', 'hangul', 'hanja', 'help', 'home', 'insert', 20 | 'junja', 'kana', 'kanji', 'launchapp1', 'launchapp2', 'launchmail', 21 | 'launchmediaselect', 'left', 'modechange', 'multiply', 'nexttrack', 22 | 'nonconvert', 'num0', 'num1', 'num2', 'num3', 'num4', 'num5', 'num6', 23 | 'num7', 'num8', 'num9', 'numlock', 'pagedown', 'pageup', 'pause', 'pgdn', 24 | 'pgup', 'playpause', 'prevtrack', 'print', 'printscreen', 'prntscrn', 25 | 'prtsc', 'prtscr', 'return', 'right', 'scrolllock', 'select', 'separator', 26 | 'shift', 'shiftleft', 'shiftright', 'sleep', 'stop', 'subtract', 'tab', 27 | 'up', 'volumedown', 'volumemute', 'volumeup', 'win', 'winleft', 'winright', 28 | 'yen', 'command', 'option', 'optionleft' 29 | ] 30 | 31 | class TestKeyboard(TestCase): 32 | def setUp(self): 33 | self.mock = MagicMock() 34 | self.mock.KEYBOARD_KEYS = KEYBOARD_KEYS 35 | self.patcher = patch.dict('sys.modules', {'pyautogui': self.mock}) 36 | self.patcher.start() 37 | from ImageHorizonLibrary import ImageHorizonLibrary 38 | self.lib = ImageHorizonLibrary() 39 | 40 | def tearDown(self): 41 | self.mock.reset_mock() 42 | self.patcher.stop() 43 | 44 | def test_type_with_text(self): 45 | self.lib.type('hey you fool') 46 | self.mock.typewrite.assert_called_once_with('hey you fool') 47 | self.mock.reset_mock() 48 | 49 | self.lib.type('.') 50 | self.mock.press.assert_called_once_with('.') 51 | 52 | def test_type_with_umlauts(self): 53 | self.lib.type('öäöäü') 54 | self.mock.typewrite.assert_called_once_with('öäöäü') 55 | 56 | def test_type_with_text_and_keys(self): 57 | self.lib.type('I love you', 'Key.ENTER') 58 | self.mock.typewrite.assert_called_once_with('I love you') 59 | self.mock.press.assert_called_once_with('enter') 60 | 61 | def test_type_with_utf8_keys(self): 62 | self.lib.type('key.Tab') 63 | self.assertEqual(self.mock.typewrite.call_count, 0) 64 | self.mock.press.assert_called_once_with('tab') 65 | self.assertEqual(type(self.mock.press.call_args[0][0]), 66 | type(str())) 67 | 68 | def test_type_with_keys_down(self): 69 | self.lib.type_with_keys_down('hello', 'key.shift') 70 | self.mock.keyDown.assert_called_once_with('shift') 71 | self.mock.typewrite.assert_called_once_with('hello') 72 | self.mock.keyUp.assert_called_once_with('shift') 73 | 74 | def test_type_with_keys_down_with_invalid_keys(self): 75 | from ImageHorizonLibrary import KeyboardException 76 | 77 | expected_msg = ('Invalid keyboard key "enter", valid keyboard keys ' 78 | 'are:\n%r' % ', '.join(self.mock.KEYBOARD_KEYS)) 79 | with self.assertRaises(KeyboardException) as cm: 80 | self.lib.type_with_keys_down('sometext', 'enter') 81 | self.assertEqual(str(cm.exception), expected_msg) 82 | 83 | def test_press_combination(self): 84 | self.lib.press_combination('Key.ctrl', 'A') 85 | self.mock.hotkey.assert_called_once_with('ctrl', 'a') 86 | self.mock.reset_mock() 87 | 88 | for key in self.mock.KEYBOARD_KEYS: 89 | self.lib.press_combination('Key.%s' % key) 90 | self.mock.hotkey.assert_called_once_with(key.lower()) 91 | self.mock.reset_mock() 92 | -------------------------------------------------------------------------------- /tests/utest/test_main_class.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import shlex 4 | 5 | from os.path import abspath, dirname, join as path_join 6 | from subprocess import PIPE, Popen 7 | from unittest import SkipTest, TestCase 8 | from warnings import warn 9 | 10 | from mock import MagicMock, patch 11 | 12 | 13 | SRCDIR = path_join(abspath(dirname(__file__)), '..', '..', 'src') 14 | 15 | 16 | class TestMainClass(TestCase): 17 | def setUp(self): 18 | self.pyautogui_mock = MagicMock() 19 | self.Tk_mock = MagicMock() 20 | self.clipboard_mock = MagicMock() 21 | self.clipboard_mock.clipboard_get.return_value = 'copied text' 22 | self.Tk_mock.Tk.return_value = self.clipboard_mock 23 | self.patcher = patch.dict('sys.modules', 24 | {'pyautogui': self.pyautogui_mock, 25 | 'tkinter': self.Tk_mock}) 26 | self.patcher.start() 27 | from ImageHorizonLibrary import ImageHorizonLibrary 28 | self.lib = ImageHorizonLibrary() 29 | 30 | def tearDown(self): 31 | for mock in (self.Tk_mock, self.clipboard_mock, self.pyautogui_mock): 32 | mock.reset_mock() 33 | self.patcher.stop() 34 | 35 | def test_copy(self): 36 | from ImageHorizonLibrary import ImageHorizonLibrary 37 | 38 | with patch.object(ImageHorizonLibrary, '_press') as press_mock: 39 | retval = self.lib.copy() 40 | self.assertEqual(retval, 'copied text') 41 | self.clipboard_mock.clipboard_get.assert_called_once_with() 42 | if self.lib.is_mac: 43 | press_mock.assert_called_once_with('Key.command', 'c') 44 | else: 45 | press_mock.assert_called_once_with('Key.ctrl', 'c') 46 | 47 | def test_clipboard_content(self): 48 | retval = self.lib.get_clipboard_content() 49 | self.assertEqual(retval, 'copied text') 50 | self.clipboard_mock.clipboard_get.assert_called_once_with() 51 | 52 | def test_alert(self): 53 | self.lib.pause() 54 | self.pyautogui_mock.alert.assert_called_once_with( 55 | button='Continue', text='Test execution paused.', title='Pause') 56 | 57 | def _get_cmd(self, jython, path): 58 | cmd = ('JYTHONPATH={path} {jython} -c ' 59 | '"from ImageHorizonLibrary import ImageHorizonLibrary"') 60 | return cmd.format(jython=jython, path=path) 61 | 62 | def test_importing_fails_on_java(self): 63 | # This test checks that importing fails when any of the dependencies 64 | # are not installed or, at least, importing Tkinter fails because 65 | # it's not supported on Jython. 66 | if 'JYTHON_HOME' not in os.environ: 67 | self.skipTest('%s() was not run because JYTHON_HOME ' 68 | 'was not set.' % self._testMethodName) 69 | jython_cmd = path_join(os.environ['JYTHON_HOME'], 'bin', 'jython') 70 | cmd = self._get_cmd(jython_cmd, SRCDIR) 71 | p = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE) 72 | _, stderr = p.communicate() 73 | self.assertNotEqual(stderr, '') 74 | 75 | def test_set_reference_folder(self): 76 | self.assertEqual(self.lib.reference_folder, None) 77 | self.lib.set_reference_folder('/test/path') 78 | self.assertEqual(self.lib.reference_folder, '/test/path') 79 | 80 | def test_set_screenshot_folder(self): 81 | self.assertEqual(self.lib.screenshot_folder, None) 82 | self.lib.set_screenshot_folder('/test/path') 83 | self.assertEqual(self.lib.screenshot_folder, '/test/path') 84 | 85 | def test_set_confidence(self): 86 | self.assertEqual(self.lib.confidence, None) 87 | 88 | self.lib.set_confidence(0) 89 | self.assertEqual(self.lib.confidence, 0) 90 | 91 | self.lib.set_confidence(0.5) 92 | self.assertEqual(self.lib.confidence, 0.5) 93 | 94 | self.lib.set_confidence(-1) 95 | self.assertEqual(self.lib.confidence, 0.5) 96 | 97 | self.lib.set_confidence(2) 98 | self.assertEqual(self.lib.confidence, 0.5) 99 | 100 | self.lib.set_confidence(1) 101 | self.assertEqual(self.lib.confidence, 1) 102 | 103 | self.lib.set_confidence(None) 104 | self.assertEqual(self.lib.confidence, None) 105 | -------------------------------------------------------------------------------- /tests/utest/test_mouse.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest import TestCase 3 | from mock import call, MagicMock, patch 4 | 5 | 6 | class TestMouse(TestCase): 7 | 8 | def setUp(self): 9 | self.mock = MagicMock() 10 | self.patcher = patch.dict('sys.modules', {'pyautogui': self.mock}) 11 | self.patcher.start() 12 | from ImageHorizonLibrary import ImageHorizonLibrary, MouseException 13 | self.lib = ImageHorizonLibrary() 14 | 15 | def tearDown(self): 16 | self.mock.reset_mock() 17 | self.patcher.stop() 18 | 19 | def test_all_directional_clicks(self): 20 | for direction in ['above', 'below', 'left', 'right']: 21 | fn = getattr(self.lib, 'click_to_the_%s_of' % direction) 22 | fn((0, 0), '10') 23 | self.assertEqual(self.mock.click.mock_calls, 24 | [call(0, -10, button='left', interval=0.0, clicks=1), 25 | call(0, 10, button='left', interval=0.0, clicks=1), 26 | call(-10, 0, button='left', interval=0.0, clicks=1), 27 | call(10, 0, button='left', interval=0.0, clicks=1)]) 28 | 29 | def _verify_directional_clicks_fail(self, direction, kwargs): 30 | from ImageHorizonLibrary import MouseException 31 | 32 | fn = getattr(self.lib, 'click_to_the_%s_of' % direction) 33 | with self.assertRaises(MouseException): 34 | fn((0, 0), 10, **kwargs) 35 | self.assertEqual(self.mock.click.mock_calls, []) 36 | 37 | def test_arguments_in_directional_clicks(self): 38 | self.lib.click_to_the_above_of((0, 0), 10, clicks='2', 39 | button='middle', interval='1.2') 40 | self.assertEqual(self.mock.click.mock_calls, [call(0, -10, 41 | button='middle', 42 | interval=1.2, 43 | clicks=2)]) 44 | self.mock.reset_mock() 45 | for args in (('below', {'clicks': 'notvalid'}), 46 | ('right', {'button': 'notvalid'}), 47 | ('left', {'interval': 'notvalid'})): 48 | self._verify_directional_clicks_fail(*args) 49 | 50 | def _verify_move_to_fails(self, *args): 51 | from ImageHorizonLibrary import MouseException 52 | with self.assertRaises(MouseException): 53 | self.lib.move_to(*args) 54 | 55 | def test_move_to(self): 56 | for args in [(1, 2), ((1, 2),), ('1', '2'), (('1', '2'),)]: 57 | self.lib.move_to(*args) 58 | self.assertEqual(self.mock.moveTo.mock_calls, [call(1, 2)]) 59 | self.mock.reset_mock() 60 | 61 | for args in [(1,), 62 | (1, 2, 3), 63 | ('1', 'lollerskates'), 64 | (('1', 'lollerskates'),)]: 65 | self._verify_move_to_fails(*args) 66 | 67 | def test_mouse_down(self): 68 | for args in [tuple(), ('right',)]: 69 | self.lib.mouse_down(*args) 70 | self.assertEqual(self.mock.mouseDown.mock_calls, [call(button='left'), call(button='right')]) 71 | 72 | def test_mouse_up(self): 73 | for args in [tuple(), ('right',)]: 74 | self.lib.mouse_up(*args) 75 | self.assertEqual(self.mock.mouseUp.mock_calls, [call(button='left'), call(button='right')]) 76 | -------------------------------------------------------------------------------- /tests/utest/test_operating_system.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest import TestCase 3 | 4 | from mock import MagicMock, patch 5 | 6 | 7 | class TestOperatingSystem(TestCase): 8 | def setUp(self): 9 | self.patcher = patch.dict('sys.modules', {'pyautogui': MagicMock()}) 10 | self.patcher.start() 11 | from ImageHorizonLibrary import ImageHorizonLibrary 12 | self.lib = ImageHorizonLibrary() 13 | 14 | def tearDown(self): 15 | self.patcher.stop() 16 | 17 | def test_launch_application(self): 18 | mock = MagicMock() 19 | with patch('subprocess.Popen', 20 | autospec=True, 21 | return_value=mock) as mock_popen: 22 | self.lib.launch_application('application -argument') 23 | mock_popen.assert_called_once_with(['application', '-argument']) 24 | self.assertDictEqual(self.lib.open_applications, {'0': mock}) 25 | mock_popen.reset_mock() 26 | 27 | self.lib.launch_application('application -a -r --gu ment', 28 | 'MY ALIAS') 29 | mock_popen.assert_called_once_with(['application', '-a', 30 | '-r', '--gu', 'ment']) 31 | self.assertDictEqual(self.lib.open_applications, 32 | {'0': mock, 'MY ALIAS': mock}) 33 | mock_popen.reset_mock() 34 | 35 | self.lib.launch_application('application', 'ÛMLÄYT ÖLIAS') 36 | mock_popen.assert_called_once_with(['application']) 37 | self.assertDictEqual(self.lib.open_applications, 38 | {'0': mock, 'MY ALIAS': mock, 'ÛMLÄYT ÖLIAS': mock}) 39 | 40 | 41 | def test_terminate_application_when_application_was_not_launched(self): 42 | from ImageHorizonLibrary import OSException 43 | with self.assertRaises(OSException): 44 | self.lib.terminate_application() 45 | 46 | def test_terminate_application(self): 47 | from ImageHorizonLibrary import OSException 48 | mock = MagicMock() 49 | with patch('subprocess.Popen', 50 | autospec=True, 51 | return_value=mock) as mock_popen: 52 | for args in (('app1',), ('app2', 'my alias'), 53 | ('app3', 'youalias'), ('app4', 'shelias')): 54 | self.lib.launch_application(*args) 55 | self.assertDictEqual(self.lib.open_applications, {'0': mock, 56 | 'my alias': mock, 57 | 'youalias': mock, 58 | 'shelias': mock}) 59 | 60 | self.lib.terminate_application() 61 | self.assertDictEqual(self.lib.open_applications, 62 | {'0': mock, 63 | 'my alias': mock, 64 | 'youalias': mock}) 65 | self.lib.terminate_application('my alias') 66 | self.assertDictEqual(self.lib.open_applications, 67 | {'0': mock, 'youalias': mock}) 68 | self.lib.terminate_application('0') 69 | self.assertDictEqual(self.lib.open_applications, 70 | {'youalias': mock}) 71 | 72 | self.lib.terminate_application() 73 | self.assertDictEqual(self.lib.open_applications, {}) 74 | 75 | with self.assertRaises(OSException): 76 | self.lib.terminate_application('nonexistent alias') 77 | -------------------------------------------------------------------------------- /tests/utest/test_recognize_images.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import time 3 | 4 | from unittest import TestCase 5 | from os.path import abspath, dirname, join as path_join 6 | from mock import call, MagicMock, patch 7 | 8 | CURDIR = abspath(dirname(__file__)) 9 | TESTIMG_DIR = path_join(CURDIR, 'reference_images') 10 | 11 | class TestRecognizeImages(TestCase): 12 | def setUp(self): 13 | self.mock = MagicMock() 14 | self.patcher = patch.dict('sys.modules', {'pyautogui': self.mock}) 15 | self.patcher.start() 16 | from ImageHorizonLibrary import ImageHorizonLibrary 17 | self.lib = ImageHorizonLibrary(reference_folder=TESTIMG_DIR) 18 | self.locate = 'ImageHorizonLibrary.ImageHorizonLibrary.locate' 19 | self._locate = 'ImageHorizonLibrary.ImageHorizonLibrary._locate' 20 | 21 | def tearDown(self): 22 | self.mock.reset_mock() 23 | self.patcher.stop() 24 | 25 | def test_find_with_confidence(self): 26 | self.lib.reference_folder = path_join(CURDIR, 'symbolic_link') 27 | self.lib.set_confidence(0.5) 28 | self.lib.has_cv = True 29 | self.lib.locate('mY_PiCtURe') 30 | expected_path = path_join(CURDIR, 'symbolic_link', 'my_picture.png') 31 | self.mock.locateOnScreen.assert_called_once_with(expected_path, confidence=0.5) 32 | self.mock.reset_mock() 33 | 34 | def test_find_with_confidence_no_opencv(self): 35 | self.lib.reference_folder = path_join(CURDIR, 'symbolic_link') 36 | self.lib.set_confidence(0.5) 37 | self.lib.has_cv = False 38 | self.lib.locate('mY_PiCtURe') 39 | expected_path = path_join(CURDIR, 'symbolic_link', 'my_picture.png') 40 | self.mock.locateOnScreen.assert_called_once_with(expected_path) 41 | self.mock.reset_mock() 42 | 43 | def test_click_image(self): 44 | with patch(self.locate, return_value=(0, 0)): 45 | self.lib.click_image('my_picture') 46 | self.mock.click.assert_called_once_with((0, 0)) 47 | 48 | def _call_all_directional_functions(self, fn_name): 49 | from ImageHorizonLibrary import ImageHorizonLibrary 50 | retvals = [] 51 | for direction in ['above', 'below', 'left', 'right']: 52 | fn = getattr(self.lib, fn_name % direction) 53 | with patch(self.locate, return_value=(0, 0)): 54 | retvals.append(fn('my_picture', 10)) 55 | return retvals 56 | 57 | def _verify_calls_to_pyautogui(self, mock_calls, clicks=1): 58 | self.assertEqual( 59 | mock_calls, 60 | [call(0, -10, button='left', interval=0.0, clicks=clicks), 61 | call(0, 10, button='left', interval=0.0, clicks=clicks), 62 | call(-10, 0, button='left', interval=0.0, clicks=clicks), 63 | call(10, 0, button='left', interval=0.0, clicks=clicks)]) 64 | 65 | def test_directional_clicks(self): 66 | self._call_all_directional_functions('click_to_the_%s_of_image') 67 | self._verify_calls_to_pyautogui(self.mock.click.mock_calls) 68 | 69 | def test_directional_copies(self): 70 | copy = 'ImageHorizonLibrary.ImageHorizonLibrary.copy' 71 | with patch(copy, return_value='Some Text'): 72 | ret = self._call_all_directional_functions('copy_from_the_%s_of') 73 | self._verify_calls_to_pyautogui(self.mock.click.mock_calls, clicks=3) 74 | for retval in ret: 75 | self.assertEqual(retval, 'Some Text') 76 | 77 | def test_does_exist(self): 78 | from ImageHorizonLibrary import ImageNotFoundException 79 | 80 | with patch(self._locate, return_value=(0, 0)): 81 | self.assertTrue(self.lib.does_exist('my_picture')) 82 | 83 | run_on_failure = MagicMock() 84 | with patch(self._locate, side_effect=ImageNotFoundException('')), \ 85 | patch.object(self.lib, '_run_on_failure', run_on_failure): 86 | self.assertFalse(self.lib.does_exist('my_picture')) 87 | self.assertEqual(len(run_on_failure.mock_calls), 0) 88 | 89 | def test_wait_for_happy_path(self): 90 | from ImageHorizonLibrary import InvalidImageException 91 | run_on_failure = MagicMock() 92 | 93 | with patch(self._locate, return_value=(0, 0)), \ 94 | patch.object(self.lib, '_run_on_failure', run_on_failure): 95 | self.lib.wait_for('my_picture', timeout=1) 96 | self.assertEqual(len(run_on_failure.mock_calls), 0) 97 | 98 | def test_wait_for_negative_path(self): 99 | from ImageHorizonLibrary import InvalidImageException 100 | run_on_failure = MagicMock() 101 | 102 | with self.assertRaises(InvalidImageException), \ 103 | patch(self.locate, side_effect=InvalidImageException('')), \ 104 | patch.object(self.lib, '_run_on_failure', run_on_failure): 105 | 106 | start = time.time() 107 | self.lib.wait_for('notfound', timeout='1') 108 | stop = time.time() 109 | 110 | run_on_failure.assert_called_once_with() 111 | # check that timeout given as string works and it does not use 112 | # default timeout 113 | self.assertLess(stop-start, 10) 114 | 115 | def _verify_path_works(self, image_name, expected): 116 | self.lib.locate(image_name) 117 | expected_path = path_join(TESTIMG_DIR, expected) 118 | self.mock.locateOnScreen.assert_called_once_with(expected_path) 119 | self.mock.reset_mock() 120 | 121 | def test_locate(self): 122 | from ImageHorizonLibrary import InvalidImageException 123 | 124 | for image_name in ('my_picture.png', 'my picture.png', 'MY PICTURE', 125 | 'mY_PiCtURe'): 126 | self._verify_path_works(image_name, 'my_picture.png') 127 | 128 | self.mock.locateOnScreen.return_value = None 129 | run_on_failure = MagicMock() 130 | with self.assertRaises(InvalidImageException), \ 131 | patch.object(self.lib, '_run_on_failure', run_on_failure): 132 | self.lib.locate('nonexistent') 133 | run_on_failure.assert_called_once_with() 134 | 135 | def test_locate_with_valid_reference_folder(self): 136 | for ref, img in (('reference_images', 'my_picture.png'), 137 | ('./reference_images', 'my picture.png'), 138 | ('../../tests/utest/reference_images', 'MY PICTURE')): 139 | 140 | self.lib.set_reference_folder(path_join(CURDIR, ref)) 141 | self._verify_path_works(img, 'my_picture.png') 142 | 143 | self.lib.reference_folder = path_join(CURDIR, 'symbolic_link') 144 | self.lib.locate('mY_PiCtURe') 145 | expected_path = path_join(CURDIR, 'symbolic_link', 'my_picture.png') 146 | self.mock.locateOnScreen.assert_called_once_with(expected_path) 147 | self.mock.reset_mock() 148 | 149 | self.lib.reference_folder = path_join(CURDIR, 'rëförence_imägës') 150 | self.lib.locate('mŸ PäKSÖR') 151 | expected_path = path_join(CURDIR, 'rëförence_imägës', 152 | 'mÿ_päksör.png') 153 | self.mock.locateOnScreen.assert_called_once_with(expected_path) 154 | self.mock.reset_mock() 155 | 156 | def test_locate_with_invalid_reference_folder(self): 157 | from ImageHorizonLibrary import ReferenceFolderException 158 | 159 | for invalid_folder in (None, 123, 'nonexistent', 'nönëxistänt'): 160 | self.lib.reference_folder = invalid_folder 161 | with self.assertRaises(ReferenceFolderException): 162 | self.lib.locate('my_picture') 163 | 164 | if not self.lib.is_windows: 165 | self.lib.reference_folder = TESTIMG_DIR.replace('/', '\\') 166 | with self.assertRaises(ReferenceFolderException): 167 | self.lib.locate('my_picture') 168 | 169 | def test_locate_with_invalid_image_name(self): 170 | from ImageHorizonLibrary import InvalidImageException 171 | 172 | for invalid_image_name in (None, 123, 1.2, True, self.lib.__class__()): 173 | with self.assertRaises(InvalidImageException): 174 | self.lib.locate(invalid_image_name) 175 | -------------------------------------------------------------------------------- /tests/utest/test_screenshot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from os import getcwd, listdir 3 | from os.path import abspath, dirname, isdir, join as path_join 4 | from shutil import rmtree 5 | from sys import exc_info 6 | from tempfile import mkdtemp 7 | from unittest import TestCase 8 | 9 | from mock import patch, MagicMock 10 | from robot.libraries.BuiltIn import BuiltIn 11 | 12 | CURDIR = abspath(dirname(__file__)) 13 | 14 | 15 | class TestScreenshot(TestCase): 16 | def setUp(self): 17 | self.mock = MagicMock() 18 | self.patcher = patch.dict('sys.modules', {'pyautogui': self.mock}) 19 | self.patcher.start() 20 | from ImageHorizonLibrary import ImageHorizonLibrary 21 | self.lib = ImageHorizonLibrary() 22 | 23 | def tearDown(self): 24 | self.mock.reset_mock() 25 | self.patcher.stop() 26 | 27 | def _take_screenshot_many_times(self, expected_filename): 28 | folder = path_join(CURDIR, 'reference_folder') 29 | self.lib.set_screenshot_folder(folder) 30 | for i in range(1, 15): 31 | self.lib.take_a_screenshot() 32 | self.mock.screenshot.assert_called_once_with( 33 | path_join(folder, expected_filename % i)) 34 | self.mock.reset_mock() 35 | 36 | def test_take_a_screenshot(self): 37 | self._take_screenshot_many_times('ImageHorizon-screenshot-%d.png') 38 | 39 | def test_take_a_screenshot_inside_robot(self): 40 | with patch.object(BuiltIn, 'get_variable_value', 41 | return_value='Suite Name'): 42 | self._take_screenshot_many_times('SuiteName-screenshot-%d.png') 43 | 44 | def test_take_a_screenshot_with_invalid_folder(self): 45 | from ImageHorizonLibrary import ScreenshotFolderException 46 | 47 | for index, invalid_folder in enumerate((None, 0, False), 1): 48 | self.lib.screenshot_folder = invalid_folder 49 | expected = path_join(getcwd(), 50 | 'ImageHorizon-screenshot-%d.png' % index) 51 | self.lib.take_a_screenshot() 52 | self.mock.screenshot.assert_called_once_with(expected) 53 | self.mock.reset_mock() 54 | 55 | for invalid_folder in (123, object()): 56 | self.lib.screenshot_folder = invalid_folder 57 | with self.assertRaises(ScreenshotFolderException): 58 | self.lib.take_a_screenshot() 59 | --------------------------------------------------------------------------------