1# -*- coding: utf-8 -*-
2"""QGIS Unit tests for QgsProject bad layers handling.
3
4.. note:: This program is free software; you can redistribute it and/or modify
5it under the terms of the GNU General Public License as published by
6the Free Software Foundation; either version 2 of the License, or
7(at your option) any later version.
8"""
9from builtins import chr
10from builtins import range
11__author__ = 'Alessandro Pasotti'
12__date__ = '20/10/2018'
13__copyright__ = 'Copyright 2018, The QGIS Project'
14
15import os
16import filecmp
17
18import qgis  # NOQA
19
20from qgis.core import (QgsProject,
21                       QgsVectorLayer,
22                       QgsCoordinateTransform,
23                       QgsMapSettings,
24                       QgsRasterLayer,
25                       QgsMapLayer,
26                       QgsRectangle,
27                       QgsDataProvider,
28                       QgsReadWriteContext,
29                       QgsCoordinateReferenceSystem,
30                       )
31from qgis.gui import (QgsLayerTreeMapCanvasBridge,
32                      QgsMapCanvas)
33
34from qgis.PyQt.QtGui import QFont, QColor
35from qgis.PyQt.QtTest import QSignalSpy
36from qgis.PyQt.QtCore import QT_VERSION_STR, QTemporaryDir, QSize
37from qgis.PyQt.QtXml import QDomDocument, QDomNode
38
39from qgis.testing import start_app, unittest
40from utilities import (unitTestDataPath, renderMapToImage)
41from shutil import copyfile
42
43app = start_app()
44TEST_DATA_DIR = unitTestDataPath()
45
46
47class TestQgsProjectBadLayers(unittest.TestCase):
48
49    def setUp(self):
50        p = QgsProject.instance()
51        p.removeAllMapLayers()
52
53    @classmethod
54    def getBaseMapSettings(cls):
55        """
56        :rtype: QgsMapSettings
57        """
58        ms = QgsMapSettings()
59        crs = QgsCoordinateReferenceSystem('epsg:4326')
60        ms.setBackgroundColor(QColor(152, 219, 249))
61        ms.setOutputSize(QSize(420, 280))
62        ms.setOutputDpi(72)
63        ms.setFlag(QgsMapSettings.Antialiasing, True)
64        ms.setFlag(QgsMapSettings.UseAdvancedEffects, False)
65        ms.setFlag(QgsMapSettings.ForceVectorOutput, False)  # no caching?
66        ms.setDestinationCrs(crs)
67        return ms
68
69    def _change_data_source(self, layer, datasource, provider_key):
70        """Due to the fact that a project r/w context is not available inside
71        the map layers classes, the original style and subset string restore
72        happens in app, this function replicates app behavior"""
73
74        options = QgsDataProvider.ProviderOptions()
75
76        subset_string = ''
77        if not layer.isValid():
78            try:
79                subset_string = layer.dataProvider().subsetString()
80            except:
81                pass
82
83        layer.setDataSource(datasource, layer.name(), provider_key, options)
84
85        if subset_string:
86            layer.setSubsetString(subset_string)
87
88        self.assertTrue(layer.originalXmlProperties(), layer.name())
89        context = QgsReadWriteContext()
90        context.setPathResolver(QgsProject.instance().pathResolver())
91        errorMsg = ''
92        doc = QDomDocument()
93        self.assertTrue(doc.setContent(layer.originalXmlProperties()))
94        layer_node = QDomNode(doc.firstChild())
95        self.assertTrue(layer.readSymbology(layer_node, errorMsg, context))
96
97    def test_project_roundtrip(self):
98        """Tests that a project with bad layers can be saved and restored"""
99
100        p = QgsProject.instance()
101        temp_dir = QTemporaryDir()
102        for ext in ('shp', 'dbf', 'shx', 'prj'):
103            copyfile(os.path.join(TEST_DATA_DIR, 'lines.%s' % ext), os.path.join(temp_dir.path(), 'lines.%s' % ext))
104        copyfile(os.path.join(TEST_DATA_DIR, 'raster', 'band1_byte_ct_epsg4326.tif'), os.path.join(temp_dir.path(), 'band1_byte_ct_epsg4326.tif'))
105        copyfile(os.path.join(TEST_DATA_DIR, 'raster', 'band1_byte_ct_epsg4326.tif'), os.path.join(temp_dir.path(), 'band1_byte_ct_epsg4326_copy.tif'))
106        l = QgsVectorLayer(os.path.join(temp_dir.path(), 'lines.shp'), 'lines', 'ogr')
107        self.assertTrue(l.isValid())
108
109        rl = QgsRasterLayer(os.path.join(temp_dir.path(), 'band1_byte_ct_epsg4326.tif'), 'raster', 'gdal')
110        self.assertTrue(rl.isValid())
111        rl_copy = QgsRasterLayer(os.path.join(temp_dir.path(), 'band1_byte_ct_epsg4326_copy.tif'), 'raster_copy', 'gdal')
112        self.assertTrue(rl_copy.isValid())
113        self.assertTrue(p.addMapLayers([l, rl, rl_copy]))
114
115        # Save project
116        project_path = os.path.join(temp_dir.path(), 'project.qgs')
117        self.assertTrue(p.write(project_path))
118
119        # Re-load the project, checking for the XML properties
120        p.removeAllMapLayers()
121        self.assertTrue(p.read(project_path))
122        vector = list(p.mapLayersByName('lines'))[0]
123        raster = list(p.mapLayersByName('raster'))[0]
124        raster_copy = list(p.mapLayersByName('raster_copy'))[0]
125        self.assertTrue(vector.originalXmlProperties() != '')
126        self.assertTrue(raster.originalXmlProperties() != '')
127        self.assertTrue(raster_copy.originalXmlProperties() != '')
128        # Test setter
129        raster.setOriginalXmlProperties('pippo')
130        self.assertEqual(raster.originalXmlProperties(), 'pippo')
131
132        # Now create an invalid project:
133        bad_project_path = os.path.join(temp_dir.path(), 'project_bad.qgs')
134        with open(project_path, 'r') as infile:
135            with open(bad_project_path, 'w+') as outfile:
136                outfile.write(infile.read().replace('./lines.shp', './lines-BAD_SOURCE.shp').replace('band1_byte_ct_epsg4326_copy.tif', 'band1_byte_ct_epsg4326_copy-BAD_SOURCE.tif'))
137
138        # Load the bad project
139        p.removeAllMapLayers()
140        self.assertTrue(p.read(bad_project_path))
141        # Check layer is invalid
142        vector = list(p.mapLayersByName('lines'))[0]
143        raster = list(p.mapLayersByName('raster'))[0]
144        raster_copy = list(p.mapLayersByName('raster_copy'))[0]
145        self.assertIsNotNone(vector.dataProvider())
146        self.assertIsNotNone(raster.dataProvider())
147        self.assertIsNotNone(raster_copy.dataProvider())
148        self.assertFalse(vector.isValid())
149        self.assertFalse(raster_copy.isValid())
150        # Try a getFeatures
151        self.assertEqual([f for f in vector.getFeatures()], [])
152        self.assertTrue(raster.isValid())
153        self.assertEqual(vector.providerType(), 'ogr')
154
155        # Save the project
156        bad_project_path2 = os.path.join(temp_dir.path(), 'project_bad2.qgs')
157        p.write(bad_project_path2)
158        # Re-save the project, with fixed paths
159        good_project_path = os.path.join(temp_dir.path(), 'project_good.qgs')
160        with open(bad_project_path2, 'r') as infile:
161            with open(good_project_path, 'w+') as outfile:
162                outfile.write(infile.read().replace('./lines-BAD_SOURCE.shp', './lines.shp').replace('band1_byte_ct_epsg4326_copy-BAD_SOURCE.tif', 'band1_byte_ct_epsg4326_copy.tif'))
163
164        # Load the good project
165        p.removeAllMapLayers()
166        self.assertTrue(p.read(good_project_path))
167        # Check layer is valid
168        vector = list(p.mapLayersByName('lines'))[0]
169        raster = list(p.mapLayersByName('raster'))[0]
170        raster_copy = list(p.mapLayersByName('raster_copy'))[0]
171        self.assertTrue(vector.isValid())
172        self.assertTrue(raster.isValid())
173        self.assertTrue(raster_copy.isValid())
174
175    def test_project_relations(self):
176        """Tests that a project with bad layers and relations can be saved with relations"""
177
178        temp_dir = QTemporaryDir()
179        p = QgsProject.instance()
180        for ext in ('qgs', 'gpkg'):
181            copyfile(os.path.join(TEST_DATA_DIR, 'projects', 'relation_reference_test.%s' % ext), os.path.join(temp_dir.path(), 'relation_reference_test.%s' % ext))
182
183        # Load the good project
184        project_path = os.path.join(temp_dir.path(), 'relation_reference_test.qgs')
185        p.removeAllMapLayers()
186        self.assertTrue(p.read(project_path))
187        point_a = list(p.mapLayersByName('point_a'))[0]
188        point_b = list(p.mapLayersByName('point_b'))[0]
189        point_a_source = point_a.publicSource()
190        point_b_source = point_b.publicSource()
191        self.assertTrue(point_a.isValid())
192        self.assertTrue(point_b.isValid())
193
194        # Check relations
195        def _check_relations():
196            relation = list(p.relationManager().relations().values())[0]
197            self.assertTrue(relation.isValid())
198            self.assertEqual(relation.referencedLayer().id(), point_b.id())
199            self.assertEqual(relation.referencingLayer().id(), point_a.id())
200
201        _check_relations()
202
203        # Now build a bad project
204        bad_project_path = os.path.join(temp_dir.path(), 'relation_reference_test_bad.qgs')
205        with open(project_path, 'r') as infile:
206            with open(bad_project_path, 'w+') as outfile:
207                outfile.write(infile.read().replace('./relation_reference_test.gpkg', './relation_reference_test-BAD_SOURCE.gpkg'))
208
209        # Load the bad project
210        p.removeAllMapLayers()
211        self.assertTrue(p.read(bad_project_path))
212        point_a = list(p.mapLayersByName('point_a'))[0]
213        point_b = list(p.mapLayersByName('point_b'))[0]
214        self.assertFalse(point_a.isValid())
215        self.assertFalse(point_b.isValid())
216
217        # This fails because relations are not valid anymore
218        with self.assertRaises(AssertionError):
219            _check_relations()
220
221        # Changing data source, relations should be restored:
222        options = QgsDataProvider.ProviderOptions()
223        point_a.setDataSource(point_a_source, 'point_a', 'ogr', options)
224        point_b.setDataSource(point_b_source, 'point_b', 'ogr', options)
225        self.assertTrue(point_a.isValid())
226        self.assertTrue(point_b.isValid())
227
228        # Check if relations were restored
229        _check_relations()
230
231        # Reload the bad project
232        p.removeAllMapLayers()
233        self.assertTrue(p.read(bad_project_path))
234        point_a = list(p.mapLayersByName('point_a'))[0]
235        point_b = list(p.mapLayersByName('point_b'))[0]
236        self.assertFalse(point_a.isValid())
237        self.assertFalse(point_b.isValid())
238
239        # This fails because relations are not valid anymore
240        with self.assertRaises(AssertionError):
241            _check_relations()
242
243        # Save the bad project
244        bad_project_path2 = os.path.join(temp_dir.path(), 'relation_reference_test_bad2.qgs')
245        p.write(bad_project_path2)
246
247        # Now fix the bad project
248        bad_project_path_fixed = os.path.join(temp_dir.path(), 'relation_reference_test_bad_fixed.qgs')
249        with open(bad_project_path2, 'r') as infile:
250            with open(bad_project_path_fixed, 'w+') as outfile:
251                outfile.write(infile.read().replace('./relation_reference_test-BAD_SOURCE.gpkg', './relation_reference_test.gpkg'))
252
253        # Load the fixed project
254        p.removeAllMapLayers()
255        self.assertTrue(p.read(bad_project_path_fixed))
256        point_a = list(p.mapLayersByName('point_a'))[0]
257        point_b = list(p.mapLayersByName('point_b'))[0]
258        point_a_source = point_a.publicSource()
259        point_b_source = point_b.publicSource()
260        self.assertTrue(point_a.isValid())
261        self.assertTrue(point_b.isValid())
262        _check_relations()
263
264    def testStyles(self):
265        """Test that styles for rasters and vectors are kept when setDataSource is called"""
266
267        temp_dir = QTemporaryDir()
268        p = QgsProject.instance()
269        for f in (
270                'bad_layer_raster_test.tfw',
271                'bad_layer_raster_test.tiff',
272                'bad_layer_raster_test.tiff.aux.xml',
273                'bad_layers_test.gpkg',
274                'good_layers_test.qgs'):
275            copyfile(os.path.join(TEST_DATA_DIR, 'projects', f), os.path.join(temp_dir.path(), f))
276
277        project_path = os.path.join(temp_dir.path(), 'good_layers_test.qgs')
278        p = QgsProject().instance()
279        p.removeAllMapLayers()
280        self.assertTrue(p.read(project_path))
281        self.assertEqual(p.count(), 4)
282
283        ms = self.getBaseMapSettings()
284        point_a_copy = list(p.mapLayersByName('point_a copy'))[0]
285        point_a = list(p.mapLayersByName('point_a'))[0]
286        point_b = list(p.mapLayersByName('point_b'))[0]
287        raster = list(p.mapLayersByName('bad_layer_raster_test'))[0]
288        self.assertTrue(point_a_copy.isValid())
289        self.assertTrue(point_a.isValid())
290        self.assertTrue(point_b.isValid())
291        self.assertTrue(raster.isValid())
292        ms.setExtent(QgsRectangle(2.81861, 41.98138, 2.81952, 41.9816))
293        ms.setLayers([point_a_copy, point_a, point_b, raster])
294        image = renderMapToImage(ms)
295        self.assertTrue(image.save(os.path.join(temp_dir.path(), 'expected.png'), 'PNG'))
296
297        point_a_source = point_a.publicSource()
298        point_b_source = point_b.publicSource()
299        raster_source = raster.publicSource()
300        self._change_data_source(point_a, point_a_source, 'ogr')
301        # Attention: we are not passing the subset string here:
302        self._change_data_source(point_a_copy, point_a_source, 'ogr')
303        self._change_data_source(point_b, point_b_source, 'ogr')
304        self._change_data_source(raster, raster_source, 'gdal')
305        self.assertTrue(image.save(os.path.join(temp_dir.path(), 'actual.png'), 'PNG'))
306
307        self.assertTrue(filecmp.cmp(os.path.join(temp_dir.path(), 'actual.png'), os.path.join(temp_dir.path(), 'expected.png')), False)
308
309        # Now build a bad project
310        p.removeAllMapLayers()
311        bad_project_path = os.path.join(temp_dir.path(), 'bad_layers_test.qgs')
312        with open(project_path, 'r') as infile:
313            with open(bad_project_path, 'w+') as outfile:
314                outfile.write(infile.read().replace('./bad_layers_test.', './bad_layers_test-BAD_SOURCE.').replace('bad_layer_raster_test.tiff', 'bad_layer_raster_test-BAD_SOURCE.tiff'))
315
316        p.removeAllMapLayers()
317        self.assertTrue(p.read(bad_project_path))
318        self.assertEqual(p.count(), 4)
319        point_a_copy = list(p.mapLayersByName('point_a copy'))[0]
320        point_a = list(p.mapLayersByName('point_a'))[0]
321        point_b = list(p.mapLayersByName('point_b'))[0]
322        raster = list(p.mapLayersByName('bad_layer_raster_test'))[0]
323        self.assertFalse(point_a.isValid())
324        self.assertFalse(point_a_copy.isValid())
325        self.assertFalse(point_b.isValid())
326        self.assertFalse(raster.isValid())
327        ms.setLayers([point_a_copy, point_a, point_b, raster])
328        image = renderMapToImage(ms)
329        self.assertTrue(image.save(os.path.join(temp_dir.path(), 'bad.png'), 'PNG'))
330        self.assertFalse(filecmp.cmp(os.path.join(temp_dir.path(), 'bad.png'), os.path.join(temp_dir.path(), 'expected.png')), False)
331
332        self._change_data_source(point_a, point_a_source, 'ogr')
333        # We are not passing the subset string!!
334        self._change_data_source(point_a_copy, point_a_source, 'ogr')
335        self._change_data_source(point_b, point_b_source, 'ogr')
336        self._change_data_source(raster, raster_source, 'gdal')
337        self.assertTrue(point_a.isValid())
338        self.assertTrue(point_a_copy.isValid())
339        self.assertTrue(point_b.isValid())
340        self.assertTrue(raster.isValid())
341
342        ms.setLayers([point_a_copy, point_a, point_b, raster])
343        image = renderMapToImage(ms)
344        self.assertTrue(image.save(os.path.join(temp_dir.path(), 'actual_fixed.png'), 'PNG'))
345
346        self.assertTrue(filecmp.cmp(os.path.join(temp_dir.path(), 'actual_fixed.png'), os.path.join(temp_dir.path(), 'expected.png')), False)
347
348
349if __name__ == '__main__':
350    unittest.main()
351