QGIS/tests/src/python/test_qgspallabeling_base.py

487 lines
17 KiB
Python

# -*- coding: utf-8 -*-
"""QGIS Unit tests for QgsPalLabeling: base suite setup
From build dir, run: ctest -R PyQgsPalLabelingBase -V
See <qgis-src-dir>/tests/testdata/labeling/README.rst for description.
.. note:: This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
"""
from future import standard_library
import collections
standard_library.install_aliases()
__author__ = 'Larry Shaffer'
__date__ = '07/09/2013'
__copyright__ = 'Copyright 2013, The QGIS Project'
# This will get replaced with a git SHA1 when you do a git archive
__revision__ = '$Format:%H$'
import qgis # NOQA
import os
import sys
import datetime
import glob
import shutil
from qgis.PyQt.QtCore import QSize, qDebug, Qt
from qgis.PyQt.QtGui import QFont, QColor
from qgis.core import (
QgsCoordinateReferenceSystem,
QgsCoordinateTransform,
QgsDataSourceUri,
QgsGeometry,
QgsLabelingEngineSettings,
QgsProject,
QgsMapSettings,
QgsPalLabeling,
QgsPalLayerSettings,
QgsProviderRegistry,
QgsStringReplacementCollection,
QgsVectorLayer,
QgsVectorLayerSimpleLabeling,
QgsMultiRenderChecker,
QgsUnitTypes
)
from qgis.testing import start_app, unittest
from qgis.testing.mocked import get_iface
from utilities import (
unitTestDataPath,
getTempfilePath,
renderMapToImage,
loadTestFonts,
getTestFont,
openInBrowserTab
)
start_app(sys.platform != 'darwin') # No cleanup on mac os x, it crashes the pallabelingcanvas test on exit
FONTSLOADED = loadTestFonts()
PALREPORT = 'PAL_REPORT' in os.environ
PALREPORTS = {}
# noinspection PyPep8Naming,PyShadowingNames
class TestQgsPalLabeling(unittest.TestCase):
_TestDataDir = unitTestDataPath()
_PalDataDir = os.path.join(_TestDataDir, 'labeling')
_PalFeaturesDb = os.path.join(_PalDataDir, 'pal_features_v3.sqlite')
_TestFont = getTestFont() # Roman at 12 pt
""":type: QFont"""
_MapRegistry = None
""":type: QgsProject"""
_MapSettings = None
""":type: QgsMapSettings"""
_Canvas = None
""":type: QgsMapCanvas"""
_BaseSetup = False
@classmethod
def setUpClass(cls):
"""Run before all tests"""
# qgis iface
cls._Iface = get_iface()
cls._Canvas = cls._Iface.mapCanvas()
# verify that spatialite provider is available
msg = '\nSpatialite provider not found, SKIPPING TEST SUITE'
# noinspection PyArgumentList
res = 'spatialite' in QgsProviderRegistry.instance().providerList()
assert res, msg
cls._TestFunction = ''
cls._TestGroup = ''
cls._TestGroupPrefix = ''
cls._TestGroupAbbr = ''
cls._TestGroupCanvasAbbr = ''
cls._TestImage = ''
cls._TestMapSettings = None
cls._Mismatch = 0
cls._Mismatches = dict()
cls._ColorTol = 0
cls._ColorTols = dict()
# initialize class MapRegistry, Canvas, MapRenderer, Map and PAL
# noinspection PyArgumentList
cls._MapRegistry = QgsProject.instance()
cls._MapSettings = cls.getBaseMapSettings()
osize = cls._MapSettings.outputSize()
cls._Canvas.resize(QSize(osize.width(), osize.height())) # necessary?
# set color to match render test comparisons background
cls._Canvas.setCanvasColor(cls._MapSettings.backgroundColor())
cls.setDefaultEngineSettings()
cls._BaseSetup = True
@classmethod
def tearDownClass(cls):
"""Run after all tests"""
def setUp(self):
"""Run before each test."""
TestQgsPalLabeling.setDefaultEngineSettings()
self.lyr = self.defaultLayerSettings()
@classmethod
def setDefaultEngineSettings(cls):
"""Restore default settings for pal labeling"""
cls._MapSettings.setLabelingEngineSettings(QgsLabelingEngineSettings())
@classmethod
def removeAllLayers(cls):
cls._MapSettings.setLayers([])
cls._MapRegistry.removeAllMapLayers()
@classmethod
def removeMapLayer(cls, layer):
if layer is None:
return
lyr_id = layer.id()
cls._MapRegistry.removeMapLayer(lyr_id)
ms_layers = cls._MapSettings.layers()
if layer in ms_layers:
ms_layers.remove(layer)
cls._MapSettings.setLayers(ms_layers)
@classmethod
def getTestFont(cls):
return QFont(cls._TestFont)
@classmethod
def loadFeatureLayer(cls, table, chk=False):
if chk and cls._MapRegistry.mapLayersByName(table):
return
uri = QgsDataSourceUri()
uri.setDatabase(cls._PalFeaturesDb)
uri.setDataSource('', table, 'geometry')
vlayer = QgsVectorLayer(uri.uri(), table, 'spatialite')
# .qml should contain only style for symbology
vlayer.loadNamedStyle(os.path.join(cls._PalDataDir,
'{0}.qml'.format(table)))
# qDebug('render_lyr = {0}'.format(repr(vlayer)))
cls._MapRegistry.addMapLayer(vlayer)
# place new layer on top of render stack
render_lyrs = [vlayer]
render_lyrs.extend(cls._MapSettings.layers())
# qDebug('render_lyrs = {0}'.format(repr(render_lyrs)))
cls._MapSettings.setLayers(render_lyrs)
# zoom to aoi
cls._MapSettings.setExtent(cls.aoiExtent())
cls._Canvas.zoomToFullExtent()
return vlayer
@classmethod
def aoiExtent(cls):
"""Area of interest extent, which matches output aspect ratio"""
uri = QgsDataSourceUri()
uri.setDatabase(cls._PalFeaturesDb)
uri.setDataSource('', 'aoi', 'geometry')
aoilayer = QgsVectorLayer(uri.uri(), 'aoi', 'spatialite')
return aoilayer.extent()
@classmethod
def getBaseMapSettings(cls):
"""
:rtype: QgsMapSettings
"""
ms = QgsMapSettings()
crs = QgsCoordinateReferenceSystem()
""":type: QgsCoordinateReferenceSystem"""
# default for labeling test data: WGS 84 / UTM zone 13N
crs.createFromSrid(32613)
ms.setBackgroundColor(QColor(152, 219, 249))
ms.setOutputSize(QSize(420, 280))
ms.setOutputDpi(72)
ms.setFlag(QgsMapSettings.Antialiasing, True)
ms.setFlag(QgsMapSettings.UseAdvancedEffects, False)
ms.setFlag(QgsMapSettings.ForceVectorOutput, False) # no caching?
ms.setDestinationCrs(crs)
ms.setExtent(cls.aoiExtent())
return ms
def cloneMapSettings(self, oms):
"""
:param QgsMapSettings oms: Other QgsMapSettings
:rtype: QgsMapSettings
"""
ms = QgsMapSettings()
ms.setBackgroundColor(oms.backgroundColor())
ms.setOutputSize(oms.outputSize())
ms.setOutputDpi(oms.outputDpi())
ms.setFlags(oms.flags())
ms.setDestinationCrs(oms.destinationCrs())
ms.setExtent(oms.extent())
ms.setOutputImageFormat(oms.outputImageFormat())
ms.setLabelingEngineSettings(oms.labelingEngineSettings())
ms.setLayers(oms.layers())
return ms
def configTest(self, prefix, abbr):
"""Call in setUp() function of test subclass"""
self._TestGroupPrefix = prefix
self._TestGroupAbbr = abbr
# insert test's Class.function marker into debug output stream
# this helps visually track down the start of a test's debug output
testid = self.id().split('.')
self._TestGroup = testid[1]
self._TestFunction = testid[2]
testheader = '\n#####_____ {0}.{1} _____#####\n'.\
format(self._TestGroup, self._TestFunction)
qDebug(testheader)
# define the shorthand name of the test (to minimize file name length)
self._Test = '{0}_{1}'.format(self._TestGroupAbbr,
self._TestFunction.replace('test_', ''))
def defaultLayerSettings(self):
lyr = QgsPalLayerSettings()
lyr.fieldName = 'text' # default in test data sources
font = self.getTestFont()
font.setPointSize(32)
format = lyr.format()
format.setFont(font)
format.setNamedStyle('Roman')
format.setSize(32)
format.setSizeUnit(QgsUnitTypes.RenderPoints)
format.buffer().setJoinStyle(Qt.BevelJoin)
lyr.setFormat(format)
return lyr
@staticmethod
def settingsDict(lyr):
"""Return a dict of layer-level labeling settings
.. note:: QgsPalLayerSettings is not a QObject, so we can not collect
current object properties, and the public properties of the C++ obj
can't be listed with __dict__ or vars(). So, we sniff them out relative
to their naming convention (camelCase), as reported by dir().
"""
res = {}
for attr in dir(lyr):
if attr[0].islower() and not attr.startswith("__"):
value = getattr(lyr, attr)
if isinstance(value, (QgsGeometry, QgsStringReplacementCollection, QgsCoordinateTransform)):
continue # ignore these objects
if not isinstance(value, collections.Callable):
res[attr] = value
return res
def controlImagePath(self, grpprefix=''):
if not grpprefix:
grpprefix = self._TestGroupPrefix
return os.path.join(self._TestDataDir, 'control_images',
'expected_' + grpprefix,
self._Test, self._Test + '.png')
def saveControlImage(self, tmpimg=''):
# don't save control images for RenderVsOtherOutput (Vs) tests, since
# those control images belong to a different test result
if ('PAL_CONTROL_IMAGE' not in os.environ or
'Vs' in self._TestGroup):
return
imgpath = self.controlImagePath()
testdir = os.path.dirname(imgpath)
if not os.path.exists(testdir):
os.makedirs(testdir)
imgbasepath = \
os.path.join(testdir,
os.path.splitext(os.path.basename(imgpath))[0])
# remove any existing control images
for f in glob.glob(imgbasepath + '.*'):
if os.path.exists(f):
os.remove(f)
qDebug('Control image for {0}.{1}'.format(self._TestGroup,
self._TestFunction))
if not tmpimg:
# TODO: this can be deprecated, when per-base-test-class rendering
# in checkTest() is verified OK for all classes
qDebug('Rendering control to: {0}'.format(imgpath))
ms = self._MapSettings # class settings
""":type: QgsMapSettings"""
settings_type = 'Class'
if self._TestMapSettings is not None:
ms = self._TestMapSettings # per test settings
settings_type = 'Test'
qDebug('MapSettings type: {0}'.format(settings_type))
img = renderMapToImage(ms, parallel=False)
""":type: QImage"""
tmpimg = getTempfilePath('png')
if not img.save(tmpimg, 'png'):
os.unlink(tmpimg)
raise OSError('Control not created for: {0}'.format(imgpath))
if tmpimg and os.path.exists(tmpimg):
qDebug('Copying control to: {0}'.format(imgpath))
shutil.copyfile(tmpimg, imgpath)
else:
raise OSError('Control not copied to: {0}'.format(imgpath))
def renderCheck(self, mismatch=0, colortol=0, imgpath='', grpprefix=''):
"""Check rendered map canvas or existing image against control image
:mismatch: number of pixels different from control, and still valid
:colortol: maximum difference for each color component including alpha
:imgpath: existing image; if present, skips rendering canvas
:grpprefix: compare test image/rendering against different test group
"""
if not grpprefix:
grpprefix = self._TestGroupPrefix
chk = QgsMultiRenderChecker()
chk.setControlPathPrefix('expected_' + grpprefix)
chk.setControlName(self._Test)
if imgpath:
chk.setRenderedImage(imgpath)
ms = self._MapSettings # class settings
if self._TestMapSettings is not None:
ms = self._TestMapSettings # per test settings
chk.setMapSettings(ms)
chk.setColorTolerance(colortol)
# noinspection PyUnusedLocal
res = chk.runTest(self._Test, mismatch)
if PALREPORT and not res: # don't report ok checks
testname = self._TestGroup + ' . ' + self._Test
PALREPORTS[testname] = chk.report()
msg = '\nRender check failed for "{0}"'.format(self._Test)
return res, msg
def checkTest(self, **kwargs):
"""Intended to be overridden in subclasses"""
pass
class TestPALConfig(TestQgsPalLabeling):
@classmethod
def setUpClass(cls):
TestQgsPalLabeling.setUpClass()
cls.layer = TestQgsPalLabeling.loadFeatureLayer('point')
@classmethod
def tearDownClass(cls):
cls.removeMapLayer(cls.layer)
def setUp(self):
"""Run before each test."""
self.configTest('pal_base', 'base')
def tearDown(self):
"""Run after each test."""
pass
def test_default_pal_disabled(self):
# Verify PAL labeling is disabled for layer by default
palset = self.layer.customProperty('labeling', '')
msg = '\nExpected: Empty string\nGot: {0}'.format(palset)
self.assertEqual(palset, '', msg)
def test_settings_no_labeling(self):
self.layer.setLabeling(None)
self.assertEqual(None, self.layer.labeling())
def test_layer_pal_activated(self):
# Verify, via engine, that PAL labeling can be activated for layer
lyr = self.defaultLayerSettings()
self.layer.setLabeling(QgsVectorLayerSimpleLabeling(lyr))
msg = '\nLayer labeling not activated, as reported by labelingEngine'
self.assertTrue(QgsPalLabeling.staticWillUseLayer(self.layer), msg)
def test_write_read_settings(self):
# Verify written PAL settings are same when read from layer
# load and write default test settings
lyr1 = self.defaultLayerSettings()
lyr1dict = self.settingsDict(lyr1)
# print(lyr1dict)
self.layer.setLabeling(QgsVectorLayerSimpleLabeling(lyr1))
# read settings
lyr2 = self.layer.labeling().settings()
lyr2dict = self.settingsDict(lyr2)
# print(lyr2dict)
msg = '\nLayer settings read not same as settings written'
self.assertDictEqual(lyr1dict, lyr2dict, msg)
def test_default_partials_labels_enabled(self):
# Verify ShowingPartialsLabels is enabled for PAL by default
engine_settings = QgsLabelingEngineSettings()
self.assertTrue(engine_settings.testFlag(QgsLabelingEngineSettings.UsePartialCandidates))
def test_partials_labels_activate(self):
engine_settings = QgsLabelingEngineSettings()
# Enable partials labels
engine_settings.setFlag(QgsLabelingEngineSettings.UsePartialCandidates)
self.assertTrue(engine_settings.testFlag(QgsLabelingEngineSettings.UsePartialCandidates))
def test_partials_labels_deactivate(self):
engine_settings = QgsLabelingEngineSettings()
# Disable partials labels
engine_settings.setFlag(QgsLabelingEngineSettings.UsePartialCandidates, False)
self.assertFalse(engine_settings.testFlag(QgsLabelingEngineSettings.UsePartialCandidates))
# noinspection PyPep8Naming,PyShadowingNames
def runSuite(module, tests):
"""This allows for a list of test names to be selectively run.
Also, ensures unittest verbose output comes at end, after debug output"""
loader = unittest.defaultTestLoader
if 'PAL_SUITE' in os.environ:
if tests:
suite = loader.loadTestsFromNames(tests, module)
else:
raise Exception(
"\n\n####__ 'PAL_SUITE' set, but no tests specified __####\n")
else:
suite = loader.loadTestsFromModule(module)
verb = 2 if 'PAL_VERBOSE' in os.environ else 0
res = unittest.TextTestRunner(verbosity=verb).run(suite)
if PALREPORTS:
teststamp = 'PAL Test Report: ' + \
datetime.datetime.now().strftime('%Y-%m-%d %X')
report = '<html><head><title>{0}</title></head><body>'.format(teststamp)
report += '\n<h2>Failed Tests: {0}</h2>'.format(len(PALREPORTS))
for k, v in list(PALREPORTS.items()):
report += '\n<h3>{0}</h3>\n{1}'.format(k, v)
report += '</body></html>'
tmp_name = getTempfilePath('html')
with open(tmp_name, 'wt') as report_file:
report_file.write(report)
openInBrowserTab('file://' + tmp_name)
return res
if __name__ == '__main__':
# NOTE: unless PAL_SUITE env var is set all test class methods will be run
# ex: 'TestGroup(Point|Line|Curved|Polygon|Feature).test_method'
suite = [
'TestPALConfig.test_write_read_settings'
]
res = runSuite(sys.modules[__name__], suite)
sys.exit(not res.wasSuccessful())