"""Helper module for gui test suite
"""
import os
import sys
import hashlib
import logging
from PyQt4 import QtGui, QtCore
from qgis.core import (QgsApplication,
QgsVectorLayer,
QgsRasterLayer,
QgsRectangle,
QgsCoordinateReferenceSystem)
from qgis.gui import QgsMapCanvas
from qgis_interface import QgisInterface
# For testing and demoing
from safe.common.testing import TESTDATA
from safe_qgis.safe_interface import (readKeywordsFromFile,
temp_dir,
unique_filename)
LOGGER = logging.getLogger('InaSAFE')
QGISAPP = None # Static vainasafele used to hold hand to running QGis app
CANVAS = None
PARENT = None
IFACE = None
GEOCRS = 4326 # constant for EPSG:GEOCRS Geographic CRS id
GOOGLECRS = 900913 # constant for EPSG:GOOGLECRS Google Mercator id
DEVNULL = open(os.devnull, 'w')
CONTROL_IMAGE_DIR = os.path.join(os.path.dirname(__file__),
'test_data/test_images')
[docs]def assertHashesForFile(theHashes, theFilename):
"""Assert that a files has matches one of a list of expected hashes"""
myHash = hashForFile(theFilename)
myMessage = ('Unexpected hash'
'\nGot: %s'
'\nExpected: %s'
'\nPlease check graphics %s visually '
'and add to list of expected hashes '
'if it is OK on this platform.'
% (myHash, theHashes, theFilename))
assert myHash in theHashes, myMessage
[docs]def assertHashForFile(theHash, theFilename):
"""Assert that a files has matches its expected hash"""
myHash = hashForFile(theFilename)
myMessage = ('Unexpected hash'
'\nGot: %s'
'\nExpected: %s' % (myHash, theHash))
assert myHash == theHash, myMessage
[docs]def hashForFile(theFilename):
"""Return an md5 checksum for a file"""
myPath = theFilename
myData = file(myPath, 'rb').read()
myHash = hashlib.md5()
myHash.update(myData)
myHash = myHash.hexdigest()
return myHash
[docs]def getQgisTestApp():
""" Start one QGis application to test agaist
Input
NIL
Output
handle to qgis app
If QGis is already running the handle to that app will be returned
"""
global QGISAPP # pylint: disable=W0603
if QGISAPP is None:
myGuiFlag = True # All test will run qgis in safe_qgis mode
QGISAPP = QgsApplication(sys.argv, myGuiFlag)
# Note: This block is not needed for QGIS > 1.8 which will
# automatically check the QGIS_PREFIX_PATH var so it is here
# for backwards compatibility only
if 'QGIS_PREFIX_PATH' in os.environ:
myPath = os.environ['QGIS_PREFIX_PATH']
myUseDefaultPathFlag = True
QGISAPP.setPrefixPath(myPath, myUseDefaultPathFlag)
QGISAPP.initQgis()
s = QGISAPP.showSettings()
LOGGER.debug(s)
global PARENT # pylint: disable=W0603
if PARENT is None:
PARENT = QtGui.QWidget()
global CANVAS # pylint: disable=W0603
if CANVAS is None:
CANVAS = QgsMapCanvas(PARENT)
CANVAS.resize(QtCore.QSize(400, 400))
global IFACE # pylint: disable=W0603
if IFACE is None:
# QgisInterface is a stub implementation of the QGIS plugin interface
IFACE = QgisInterface(CANVAS)
return QGISAPP, CANVAS, IFACE, PARENT
[docs]def unitTestDataPath(theSubdir=None):
"""Return the absolute path to the InaSAFE unit test data dir.
.. note:: This is not the same thing as the SVN inasafe_data dir. Rather
this is a new dataset where the test datasets are all tiny for fast
testing and the datasets live in the same repo as the code.
Args:
* theSubdir: (Optional) Additional subdir to add to the path - typically
'hazard' or 'exposure'.
"""
from safe.common.testing import UNITDATA
myPath = UNITDATA
if theSubdir is not None:
myPath = os.path.abspath(os.path.join(myPath,
theSubdir))
return myPath
[docs]def loadLayer(theLayerFile, theDirectory=TESTDATA):
"""Helper to load and return a single QGIS layer
Args:
theLayerFile: Pathname to raster or vector file
DIR: Optional parameter stating the parent dir. If None,
pathname is assumed to be absolute
Returns: QgsMapLayer, str (for layer type)
"""
# Extract basename and absolute path
myFilename = os.path.split(theLayerFile)[-1] # In case path was absolute
myBaseName, myExt = os.path.splitext(myFilename)
if theDirectory is None:
myPath = theLayerFile
else:
myPath = os.path.join(theDirectory, theLayerFile)
myKeywordPath = myPath[:-4] + '.keywords'
# Determine if layer is hazard or exposure
myKeywords = readKeywordsFromFile(myKeywordPath)
myType = 'undefined'
if 'category' in myKeywords:
myType = myKeywords['category']
myMessage = 'Could not read %s' % myKeywordPath
assert myKeywords is not None, myMessage
# Create QGis Layer Instance
if myExt in ['.asc', '.tif']:
myLayer = QgsRasterLayer(myPath, myBaseName)
elif myExt in ['.shp']:
myLayer = QgsVectorLayer(myPath, myBaseName, 'ogr')
else:
myMessage = 'File %s had illegal extension' % myPath
raise Exception(myMessage)
myMessage = 'Layer "%s" is not valid' % str(myLayer.source())
assert myLayer.isValid(), myMessage
return myLayer, myType
[docs]def setCanvasCrs(theEpsgId, theOtfpFlag=False):
"""Helper to set the crs for the CANVAS before a test is run.
Args:
* theEpsgId - Valid EPSG identifier (int)
* theOtfpFlag - whether on the fly projections should be enabled
on the CANVAS. Default to False.
"""
# Enable on-the-fly reprojection
CANVAS.mapRenderer().setProjectionsEnabled(theOtfpFlag)
# Create CRS Instance
myCrs = QgsCoordinateReferenceSystem()
myCrs.createFromId(theEpsgId, QgsCoordinateReferenceSystem.EpsgCrsId)
# Reproject all layers to WGS84 geographic CRS
CANVAS.mapRenderer().setDestinationCrs(myCrs)
[docs]def setPadangGeoExtent():
"""Zoom to an area occupied by both both Padang layers"""
myRect = QgsRectangle(100.21, -1.05, 100.63, -0.84)
CANVAS.setExtent(myRect)
[docs]def setJakartaGeoExtent():
"""Zoom to an area occupied by both Jakarta layers in Geo"""
myRect = QgsRectangle(106.52, -6.38, 107.14, -6.07)
CANVAS.setExtent(myRect)
[docs]def setJakartaGoogleExtent():
"""Zoom to an area occupied by both Jakarta layers in 900913 crs
"""
myRect = QgsRectangle(11873524, -695798, 11913804, -675295)
CANVAS.setExtent(myRect)
[docs]def setBatemansBayGeoExtent():
"""Zoom to an area occupied by both Batemans Bay
layers in geo crs"""
myRect = QgsRectangle(150.152, -35.710, 150.187, -35.7013)
CANVAS.setExtent(myRect)
[docs]def setYogyaGeoExtent():
"""Zoom to an area occupied by both Jakarta layers in Geo"""
myRect = QgsRectangle(110.348, -7.732, 110.368, -7.716)
CANVAS.setExtent(myRect)
[docs]def setGeoExtent(theBoundingBox):
"""Zoom to an area specified given bounding box (list)"""
myRect = QgsRectangle(*theBoundingBox)
CANVAS.setExtent(myRect)
[docs]def checkImages(theControlImages, theTestImagePath, theTolerance=1000):
"""Compare a test image against a collection of known good images.
Args:
* theControlImagePath: list of file names. Give only the basename +ext
as the test image path (CONTROL_IMAGE_DIR) will be prepended.
* theTestImagePath: The Image being checked (must have same dimensions
as the control image). Must be full path to image.
* theTolerance: How many pixels may be different between the
two images.
Returns:
(bool, str, str) where:
* bool is success or failure indicator
* str is the file path of the resulting difference image
* str is a message providing analysis comparison notes
Raises:
None
"""
myMessages = ''
for myControlImage in theControlImages:
myFullPath = os.path.join(CONTROL_IMAGE_DIR,
myControlImage)
myFlag, myMessage = checkImage(myFullPath,
theTestImagePath,
theTolerance)
myMessages += myMessage
# As soon as one passes we are done!
if myFlag:
break
return myFlag, myMessages
[docs]def checkImage(theControlImagePath, theTestImagePath, theTolerance=1000):
"""Compare a test image against a known good image.
Args:
* theControlImagePath: The image representing expected output
* theTestImagePath: The Image being checked (must have same dimensions
as the control image).
* theTolerance: How many pixels may be different between the
two images.
Returns:
(bool, str, str) where:
* bool is success or failure indicator
* str is a message providing analysis comparison notes
Raises:
None
"""
try:
if not os.path.exists(theTestImagePath):
raise OSError
myTestImage = QtGui.QImage(theTestImagePath)
except OSError:
myMessage = 'Test image:\n%s\ncould not be loaded' % theTestImagePath
return False, myMessage
try:
if not os.path.exists(theControlImagePath):
raise OSError
myControlImage = QtGui.QImage(theControlImagePath)
except OSError:
myMessage = ('Control image:\n%s\ncould not be loaded.\n'
'Test image is:\n%s\n' % (
theControlImagePath,
theTestImagePath))
return False, myMessage
if (myControlImage.width() != myTestImage.width()
or myControlImage.height() != myTestImage.height()):
myMessage = ('Control and test images are different sizes.\n'
'Control image : %s\n'
'Test image : %s\n'
'Difference image: %s\n'
'If this test has failed look at the above images '
'to try to determine what may have change or '
'adjust the tolerance if needed.' %
(theTolerance,
theControlImagePath,
theTestImagePath))
return False, myMessage
myImageWidth = myControlImage.width()
myImageHeight = myControlImage.height()
myMismatchCount = 0
myDifferenceImage = QtGui.QImage(myImageWidth,
myImageHeight,
QtGui.QImage.Format_ARGB32_Premultiplied)
myDifferenceImage.fill(QtGui.qRgb(152, 219, 249))
myControlPixel = QtGui.QColor().rgb()
myTestPixel = QtGui.QColor().rgb()
for myY in range(myImageHeight):
for myX in range(myImageWidth):
myControlPixel = myControlImage.pixel(myX, myY)
myTestPixel = myTestImage.pixel(myX, myY)
if (myControlPixel != myTestPixel):
myMismatchCount = myMismatchCount + 1
myDifferenceImage.setPixel(myX, myY, QtGui.qRgb(255, 0, 0))
myDifferenceFilePath = unique_filename(prefix='difference',
suffix='.png',
dir=temp_dir('test'))
myDifferenceImage.save(myDifferenceFilePath, "PNG")
#allow pixel deviation of 1 percent
myPixelCount = myImageWidth * myImageHeight
# FIXME (Ole): Use relative error i.e. mismatchcount/total pixels
if myMismatchCount > theTolerance:
mySuccessFlag = False
else:
mySuccessFlag = True
myMessage = ('%i of %i pixels are mismatched. Tolerance is %i.\n'
'Control image : %s\n'
'Test image : %s\n'
'Difference image: %s\n'
'If this test has failed look at the above images '
'to try to determine what may have change or '
'adjust the tolerance if needed.' %
(myMismatchCount,
myPixelCount,
theTolerance,
theControlImagePath,
theTestImagePath,
myDifferenceFilePath))
return mySuccessFlag, myMessage
[docs]class RedirectStdStreams(object):
"""Context manager for redirection of stdout and stderr
This is from
http://stackoverflow.com/questions/6796492/
python-temporarily-redirect-stdout-stderr
In this context, the class is used to get rid of QGIS
output in the test suite - BUT IT DOESN'T WORK (Maybe
because QGIS starts its providers in a different process?)
Usage:
devnull = open(os.devnull, 'w')
print('Fubar')
with RedirectStdStreams(stdout=devnull, stderr=devnull):
print("You'll never see me")
print("I'm back!")
"""
def __init__(self, stdout=None, stderr=None):
self._stdout = stdout or sys.stdout
self._stderr = stderr or sys.stderr
def __enter__(self):
self.old_stdout, self.old_stderr = sys.stdout, sys.stderr
self.old_stdout.flush()
self.old_stderr.flush()
sys.stdout, sys.stderr = self._stdout, self._stderr
def __exit__(self, exc_type, exc_value, traceback):
self._stdout.flush()
self._stderr.flush()
sys.stdout = self.old_stdout
sys.stderr = self.old_stderr