source: OpenWorkouts-current/ow/tests/models/test_workout.py @ 0dedfbe

current
Last change on this file since 0dedfbe was 0dedfbe, checked in by Borja Lopez <borja@…>, 5 years ago

(#39) Duplicated workouts, fixed broken tests, added more tests coverage

  • Property mode set to 100644
File size: 25.4 KB
Line 
1import os
2from io import BytesIO
3from datetime import datetime, timedelta, timezone
4from decimal import Decimal
5from unittest.mock import patch, Mock
6
7import pytest
8from pyramid.security import Allow, Everyone, Deny, ALL_PERMISSIONS
9
10from ow.models.workout import Workout
11from ow.models.user import User
12from ow.models.root import OpenWorkouts
13from ow.utilities import create_blob
14
15from ow.tests.helpers import join
16
17
18class TestWorkoutModels(object):
19
20    @pytest.fixture
21    def root(self):
22        root = OpenWorkouts()
23        root['john'] = User(firstname='John', lastname='Doe',
24                            email='john.doe@example.net')
25        root['john'].password = 's3cr3t'
26        root['john']['1'] = Workout(
27            start=datetime(2015, 6, 28, 12, 55, tzinfo=timezone.utc),
28            duration=timedelta(minutes=60),
29            distance=30
30        )
31        return root
32
33    def test__acl__(self, root):
34        # First check permissions for a workout without parent
35        workout = Workout()
36        with pytest.raises(AttributeError):
37            workout.__acl__()
38        # Now permissions on a workout that has been added to a user
39        uid = str(root['john'].uid)
40        workout = root['john']['1']
41        permissions = [
42            (Allow, uid, 'view'),
43            (Allow, uid, 'edit'),
44            (Allow, uid, 'delete'),
45            (Deny, Everyone, ALL_PERMISSIONS)
46        ]
47        assert workout.__acl__() == permissions
48
49    def test_runthrough(self, root):
50        """
51        Just a simple run through to see if those objects click
52        """
53        root['joe'] = User(firstname='Joe', lastname='Di Maggio')
54        assert(u'Joe Di Maggio' == root['joe'].fullname)
55        joe = root['joe']
56        start = datetime(2015, 6, 5, 19, 1, tzinfo=timezone.utc)
57        duration = timedelta(minutes=20)
58        distance = Decimal('0.25')  # 250m
59        w = Workout(start=start, duration=duration, sport='swimming',
60                    notes=u'Yay, I swam!', distance=distance)
61        joe['1'] = w
62        expected = datetime(2015, 6, 5, 19, 21, tzinfo=timezone.utc)
63        assert expected == joe['1'].end
64        assert 250 == joe['1'].distance * 1000
65
66    def test_workout_id(self, root):
67        assert root['john']['1'].workout_id == '1'
68
69    def test_owner(self, root):
70        # workout with owner
71        assert root['john']['1'].owner == root['john']
72        # workout without owner
73        w = Workout()
74        assert w.owner is None
75
76    def test_end(self, root):
77        # workout without duration, it has no end
78        workout = Workout()
79        assert workout.end is None
80        # workout with duration, end is start_time + duration
81        expected = datetime(2015, 6, 28, 13, 55, tzinfo=timezone.utc)
82        assert root['john']['1'].end == expected
83
84    def test_start_date(self):
85        start_date = datetime.now()
86        workout = Workout(start=start_date)
87        assert workout.start_date == start_date.strftime('%d/%m/%Y')
88
89    def test_start_time(self):
90        start_date = datetime.now()
91        workout = Workout(start=start_date)
92        assert workout.start_time == start_date.strftime('%H:%M')
93
94    def test_start_in_timezone(self):
95        start_date = datetime.now(tz=timezone.utc)
96        str_start_date = start_date.strftime('%d/%m/%Y %H:%M (%Z)')
97        workout = Workout(start=start_date)
98        assert workout.start_in_timezone('UTC') == str_start_date
99        assert workout.start_in_timezone('Europe/Madrid') != str_start_date
100        assert workout.start_in_timezone('America/Vancouver') != str_start_date
101
102    def test_end_in_timezone(self):
103        start_date = datetime.now(tz=timezone.utc)
104        end_date = start_date + timedelta(minutes=60)
105        str_end_date = end_date.strftime('%d/%m/%Y %H:%M (%Z)')
106        workout = Workout(start=start_date, duration=timedelta(minutes=60))
107        assert workout.end_in_timezone('UTC') == str_end_date
108        assert workout.end_in_timezone('Europe/Madrid') != str_end_date
109        assert workout.end_in_timezone('America/Vancouver') != str_end_date
110
111    def test_split_duration(self):
112        # return the same hours, minutes, seconds we provided when creating
113        # the workout instance
114        duration = timedelta(hours=1, minutes=30, seconds=15)
115        workout = Workout(duration=duration)
116        assert workout.split_duration() == (1, 30, 15)
117        # If the duration is longer than a day, return the calculation of
118        # in hours, minutes, seconds too
119        duration = timedelta(days=1, hours=1, minutes=30, seconds=15)
120        workout = Workout(duration=duration)
121        assert workout.split_duration() == (25, 30, 15)
122
123    def test_duration_hours_minutes_seconds(self):
124        duration = timedelta(hours=1, minutes=30, seconds=15)
125        workout = Workout(duration=duration)
126        assert workout.duration_hours == '01'
127        assert workout.duration_minutes == '30'
128        assert workout.duration_seconds == '15'
129
130    def test__duration(self):
131        # covering the property that shows the duration of a workout properly
132        # formatted in hours:minutes:seconds
133        duration = timedelta(hours=1, minutes=30, seconds=15)
134        workout = Workout(duration=duration)
135        assert workout._duration == '01:30:15'
136
137    def test_rounded_distance_no_value(self):
138        workout = Workout()
139        assert workout.rounded_distance == '-'
140
141    def test_rounded_distance(self):
142        workout = Workout()
143        workout.distance = 44.44444444
144        assert workout.rounded_distance == 44.44
145
146    def test_hashed(self, root):
147        # first test a workout that is attached to a user
148        workout = root['john']['1']
149        assert workout.hashed == (
150            str(workout.owner.uid) +
151            workout.start.strftime('%Y%m%d%H%M%S') +
152            str(workout.duration.seconds) +
153            str(workout.distance)
154        )
155        # now a workout that is not (no owner info)
156        workout = Workout(
157            start_time=datetime.now(timezone.utc),
158            duration=timedelta(seconds=3600),
159            distance=Decimal(30)
160        )
161        assert workout.hashed == (
162            workout.start.strftime('%Y%m%d%H%M%S') +
163            str(workout.duration.seconds) +
164            str(workout.distance)
165        )
166        # now an empty workout...
167        workout = Workout()
168        with pytest.raises(AttributeError):
169            assert workout.hashed == (
170                workout.start.strftime('%Y%m%d%H%M%S') +
171                str(workout.duration.seconds) +
172                str(workout.distance)
173            )
174
175    def test_trimmed_notes(self):
176        workout = Workout()
177        assert workout.notes == ''
178        assert workout.trimmed_notes == ''
179        workout.notes = 'very short notes'
180        assert workout.notes == 'very short notes'
181        assert workout.trimmed_notes == 'very short notes'
182        workout.notes = 'long notes now, repeated' * 1000
183        assert len(workout.notes) == 24000
184        assert len(workout.trimmed_notes) == 224
185        assert workout.trimmed_notes.endswith(' ...')
186
187    def test_has_hr(self):
188        workout = Workout()
189        assert not workout.has_hr
190        workout.hr_min = 90
191        assert not workout.has_hr
192        workout.hr_max = 180
193        assert not workout.has_hr
194        workout.hr_avg = 120
195        assert workout.has_hr
196
197    def test_hr(self):
198        workout = Workout()
199        assert workout.hr is None
200        workout.hr_min = 90
201        assert workout.hr is None
202        workout.hr_max = 180
203        assert workout.hr is None
204        workout.hr_avg = 120
205        assert workout.hr['min'] == 90
206        assert workout.hr['max'] == 180
207        assert workout.hr['avg'] == 120
208
209    def test_has_cad(self):
210        workout = Workout()
211        assert not workout.has_cad
212        workout.cad_min = 0
213        assert not workout.has_cad
214        workout.cad_max = 110
215        assert not workout.has_cad
216        workout.cad_avg = 50
217        assert workout.has_cad
218
219    def test_cad(self):
220        workout = Workout()
221        assert workout.cad is None
222        workout.cad_min = 0
223        assert workout.cad is None
224        workout.cad_max = 110
225        assert workout.cad is None
226        workout.cad_avg = 50
227        assert workout.cad['min'] == 0
228        assert workout.cad['max'] == 110
229        assert workout.cad['avg'] == 50
230
231    def test_has_atemp(self):
232        workout = Workout()
233        assert not workout.has_atemp
234        workout.atemp_min = 0
235        assert not workout.has_atemp
236        workout.atemp_max = 12
237        assert not workout.has_atemp
238        workout.atemp_avg = 5
239        assert workout.has_atemp
240
241    def test_atemp(self):
242        workout = Workout()
243        assert workout.atemp is None
244        workout.atemp_min = 0
245        assert workout.atemp is None
246        workout.atemp_max = 12
247        assert workout.atemp is None
248        workout.atemp_avg = 5
249        assert workout.atemp['min'] == 0
250        assert workout.atemp['max'] == 12
251        assert workout.atemp['avg'] == 5
252
253    def test_tracking_file_path(self):
254        workout = Workout()
255        # no tracking file, path is None
256        assert workout.tracking_file_path is None
257        # workout still not saved to the db
258        workout.tracking_file = Mock()
259        workout.tracking_file._uncommitted.return_value = '/tmp/blobtempfile'
260        workout.tracking_file.committed.return_value = None
261        assert workout.tracking_file_path == '/tmp/blobtempfile'
262        workout.tracking_file._uncommitted.return_value = None
263        workout.tracking_file.committed.return_value = '/var/db/blobs/blobfile'
264        assert workout.tracking_file_path == '/var/db/blobs/blobfile'
265
266    def test_fit_file_path(self):
267        workout = Workout()
268        # no tracking file, path is None
269        assert workout.fit_file_path is None
270        # workout still not saved to the db
271        workout.fit_file = Mock()
272        workout.fit_file._uncommitted.return_value = '/tmp/blobtempfile'
273        workout.fit_file.committed.return_value = None
274        assert workout.fit_file_path == '/tmp/blobtempfile'
275        workout.fit_file._uncommitted.return_value = None
276        workout.fit_file.committed.return_value = '/var/db/blobs/blobfile'
277        assert workout.fit_file_path == '/var/db/blobs/blobfile'
278
279    def test_load_from_file_invalid(self):
280        workout = Workout()
281        workout.tracking_filetype = 'alf'
282        with patch.object(workout, 'load_from_gpx') as lfg:
283            workout.load_from_file()
284            assert not lfg.called
285
286    def test_load_from_file_gpx(self):
287        workout = Workout()
288        workout.tracking_filetype = 'gpx'
289        with patch.object(workout, 'load_from_gpx') as lfg:
290            workout.load_from_file()
291            assert lfg.called
292
293    def test_load_from_file_fit(self):
294        workout = Workout()
295        workout.tracking_filetype = 'fit'
296        with patch.object(workout, 'load_from_fit') as lff:
297            workout.load_from_file()
298            assert lff.called
299
300    gpx_params = (
301        # GPX 1.0 file, no extensions
302        ('fixtures/20131013.gpx', {
303            'start': datetime(2013, 10, 13, 5, 28, 26, tzinfo=timezone.utc),
304            'duration': timedelta(seconds=27652),
305            'distance': Decimal(98.12598431852807),
306            'title': 'A ride I will never forget',
307            'blob': 'path',
308            'hr': {'min': None, 'max': None, 'avg': None},
309            'cad': {'min': None, 'max': None, 'avg': None},
310            'atemp': {'min': None, 'max': None, 'avg': None}}),
311        # GPX 1.0 file, no extensions, missing elevation
312        ('fixtures/20131013-without-elevation.gpx', {
313            'start': datetime(2013, 10, 13, 5, 28, 26, tzinfo=timezone.utc),
314            'duration': timedelta(seconds=27652),
315            'distance': Decimal(98.12598431852807),
316            'title': 'A ride I will never forget',
317            'blob': None,
318            'hr': {'min': None, 'max': None, 'avg': None},
319            'cad': {'min': None, 'max': None, 'avg': None},
320            'atemp': {'min': None, 'max': None, 'avg': None}}),
321        # GPX 1.1 file with extensions
322        ('fixtures/20160129-with-extensions.gpx', {
323            'start': datetime(2016, 1, 29, 8, 12, 9, tzinfo=timezone.utc),
324            'duration': timedelta(seconds=7028),
325            'distance': Decimal(48.37448557752049237024039030),
326            'title': 'Cota counterclockwise + end bonus',
327            'blob': 'path',
328            'hr': {'min': Decimal(100), 'max': Decimal(175),
329                   'avg': Decimal(148.365864144454008055618032813072)},
330            'cad': {'min': Decimal(0), 'max': Decimal(110),
331                    'avg': Decimal(67.41745485812553740326741187)},
332            'atemp': {'min': Decimal(-4), 'max': Decimal(14),
333                      'avg': Decimal(-0.3869303525365434221840068788)}}),
334        )
335
336    @pytest.mark.parametrize(('filename', 'expected'), gpx_params)
337    def test_load_from_gpx(self, filename, expected):
338        """
339        Load a gpx file located in tests/fixtures using the load_from_gpx()
340        method of the Workout model, then check that certain attrs on the
341        workout are updated correctly
342        """
343        # expected values
344        start = expected['start']
345        duration = expected['duration']
346        distance = expected['distance']
347        title = expected['title']
348        blob = expected['blob']
349        hr = expected['hr']
350        cad = expected['cad']
351        atemp = expected['atemp']
352
353        workout = Workout()
354
355        # Check the values are different by default
356        assert workout.start != start
357        assert workout.duration != duration
358        assert workout.distance != distance
359
360        gpx_file_path = os.path.join(
361            os.path.dirname(os.path.dirname(__file__)), filename)
362        with patch.object(workout, 'tracking_file') as tf:
363            with open(gpx_file_path, 'r') as gpx_file:
364                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
365                # Set the path to the blob object containing the gpx file.
366                # more info in models.workout.Workout.parse_gpx()
367                tf._p_blob_uncommitted = gpx_file_path
368                if blob is None:
369                    # set the uncommited blob to None, mimicing what happens
370                    # with a workout saved into the db (transaction.commit())
371                    tf._p_blob_uncommitted = None
372                    tf._p_blob_committed = gpx_file_path
373                # Without this, has_gpx() will return False
374                workout.tracking_filetype = 'gpx'
375                res = workout.load_from_gpx()
376                assert res is True
377                assert workout.start == start
378                assert workout.duration == duration
379                assert isinstance(workout.distance, Decimal)
380                assert round(workout.distance) == round(distance)
381                # The title of the workout is taken from the gpx file
382                assert workout.title == title
383                for k in hr.keys():
384                    # We use 'fail' as the fallback in the getattr call because
385                    # None is one of the posible values, and we want to be sure
386                    # those attrs are there
387                    #
388                    # The keys are the same for the hr, cad and atemp dicts, so
389                    # we can do all tests in one loop
390                    #
391                    # If the expected value is not None, use round() to avoid
392                    # problems when comparing long Decimal objects
393                    value = getattr(workout, 'hr_'+k, 'fail')
394                    if hr[k] is None:
395                        assert hr[k] == value
396                    else:
397                        assert round(hr[k]) == round(value)
398
399                    value = getattr(workout, 'cad_'+k, 'fail')
400                    if cad[k] is None:
401                        assert cad[k] == value
402                    else:
403                        assert round(cad[k]) == round(value)
404
405                    value = getattr(workout, 'atemp_'+k, 'fail')
406                    if atemp[k] is None:
407                        assert atemp[k] == value
408                    else:
409                        assert round(atemp[k]) == round(value)
410
411    def test_load_from_gpx_no_tracks(self):
412        """
413        If we load an empty (but valid) gpx file (i.e., no tracks information)
414        the attrs on the workout are not updated, the call to load_from_gpx()
415        returns False
416        """
417        workout = Workout()
418
419        # We do not check the start time, it would need some mocking on the
420        # datetime module and there is no need really
421        assert workout.duration is None
422        assert workout.distance is None
423
424        gpx_file_path = os.path.join(
425            os.path.dirname(os.path.dirname(__file__)),
426            'fixtures/empty.gpx')
427        with patch.object(workout, 'tracking_file') as tf:
428            with open(gpx_file_path, 'r') as gpx_file:
429                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
430                res = workout.load_from_gpx()
431                assert res is False
432                assert workout.duration is None
433                assert workout.distance is None
434                assert workout.title == ''
435                for k in ['max', 'min', 'avg']:
436                    for a in ['hr_', 'cad_', 'atemp_']:
437                        assert getattr(workout, a+k, 'fail') is None
438
439    def test_parse_gpx_no_gpx_file(self):
440        """
441        Test the behaviour of parse_gpx() when we call it on a workout without
442        a gpx tracking file. The behaviour of such method when the workout has
443        a gpx tracking file is covered by the test_load_from_gpx() test above
444        """
445        workout = Workout()
446        res = workout.parse_gpx()
447        assert res == {}
448
449    fit_params = (
450        # complete fit file from a garmin 520 device
451        ('fixtures/20181230_101115.fit', {
452            'start': datetime(2018, 12, 30, 9, 11, 15, tzinfo=timezone.utc),
453            'duration': timedelta(0, 13872, 45000),
454            'distance': Decimal('103.4981999999999970896169543'),
455            'title': 'Synapse cycling',
456            'hr': {'min': 93, 'max': 170, 'avg': 144},
457            'cad': {'min': 0, 'max': 121, 'avg': 87},
458            'atemp': {'min': -4, 'max': 15, 'avg': 2},
459            'gpx_file': 'fixtures/20181230_101115.gpx'}),
460        # fit file from a garmin 520 without heart rate or cadence data
461        ('fixtures/20181231_110728.fit', {
462            'start': datetime(2018, 12, 31, 10, 7, 28, tzinfo=timezone.utc),
463            'duration': timedelta(0, 2373, 142000),
464            'distance': Decimal('6.094909999999999854480847716'),
465            'title': 'Synapse cycling',
466            'hr': None,
467            'cad': None,
468            'atemp': {'min': -1, 'max': 11, 'avg': 1},
469            'gpx_file': 'fixtures/20181231_110728.gpx'}),
470    )
471
472    @pytest.mark.parametrize(('filename', 'expected'), fit_params)
473    def test_load_from_fit(self, filename, expected):
474        """
475        Load a fit file located in tests/fixtures using the load_from_fit()
476        method of the Workout model, then check that certain attrs on the
477        workout are updated correctly.
478
479        Ensure also that the proper gpx file is created automatically from
480        the fit file, with the proper contents (we have a matching gpx file
481        in tests/fixtures for each fit file)
482        """
483        # expected values
484        start = expected['start']
485        duration = expected['duration']
486        distance = expected['distance']
487        title = expected['title']
488        hr = expected['hr']
489        cad = expected['cad']
490        atemp = expected['atemp']
491        # gpx_file = expected['gpx_file']
492
493        workout = Workout()
494
495        # Check the values are different by default
496        assert workout.start != start
497        assert workout.duration != duration
498        assert workout.distance != distance
499
500        # by default no tracking file and no fit file are associated with this
501        # workout.
502        assert workout.tracking_file is None
503        assert workout.fit_file is None
504        assert not workout.has_tracking_file
505        assert not workout.has_gpx
506        assert not workout.has_fit
507
508        fit_file_path = os.path.join(
509            os.path.dirname(os.path.dirname(__file__)), filename)
510
511        # gpx_file_path = os.path.join(
512        #     os.path.dirname(os.path.dirname(__file__)), gpx_file)
513
514        # add the fit file as a blob to tracking_file
515        with open(fit_file_path, 'rb') as fit_file:
516            fit_blob = create_blob(fit_file.read(), file_extension='fit',
517                                   binary=True)
518        workout.tracking_file = fit_blob
519        workout.tracking_filetype = 'fit'
520
521        res = workout.load_from_fit()
522
523        assert res is True
524        assert workout.start == start
525        assert workout.duration == duration
526        assert isinstance(workout.distance, Decimal)
527        assert round(workout.distance) == round(distance)
528        # The title of the workout is taken from the gpx file
529        assert workout.title == title
530
531        if hr is not None:
532            for k in hr.keys():
533                # We use 'fail' as the fallback in the getattr call
534                # because None is one of the posible values, and we
535                # want to be sure those attrs are there
536                value = getattr(workout, 'hr_' + k, 'fail')
537                # Use round() to avoid problems when comparing long
538                # Decimal objects
539                assert round(hr[k]) == round(value)
540
541        if cad is not None:
542            for k in cad.keys():
543                value = getattr(workout, 'cad_' + k, 'fail')
544                assert round(cad[k]) == round(value)
545
546        if atemp is not None:
547            for k in atemp.keys():
548                value = getattr(workout, 'atemp_' + k, 'fail')
549                assert round(atemp[k]) == round(value)
550
551        assert workout.tracking_file is not None
552        # the tracking file type is set back to gpx, as we have
553        # automatically generated the gpx version
554        assert workout.tracking_filetype == 'gpx'
555        assert workout.fit_file is not None
556        assert workout.has_tracking_file
557        assert workout.has_gpx
558        assert workout.has_fit
559
560    def test_has_tracking_file(self, root):
561        workout = root['john']['1']
562        # without tracking file
563        assert not workout.has_tracking_file
564        # with tracking file
565        workout.tracking_file = 'faked tracking file'
566        assert workout.has_tracking_file
567
568    def test_has_gpx(self, root):
569        workout = root['john']['1']
570        # without tracking file
571        assert not workout.has_gpx
572        workout.tracking_filetype = 'fit'
573        assert not workout.has_gpx
574        # with non-gpx tracking file
575        workout.tracking_file = 'faked tracking file'
576        workout.tracking_filetype = 'fit'
577        assert not workout.has_gpx
578        # with gpx tracking file
579        workout.tracking_file = 'faked tracking file'
580        workout.tracking_filetype = 'gpx'
581        assert workout.has_gpx
582
583    def test_has_fit(self, root):
584        workout = root['john']['1']
585        # without tracking file
586        assert not workout.has_fit
587        # tracking_file is a fit, this should not happen, as uploading a fit
588        # puts the fit file into .fit_file and generates a gpx for
589        # .tracking_file
590        workout.tracking_file = 'faked tracking file'
591        workout.tracking_filetype = 'fit'
592        assert not workout.has_fit
593        # now, having a fit file returns true
594        workout.fit_file = 'faked fit file'
595        assert workout.has_fit
596        # no matter what we have in tracking_file
597        workout.tracking_filetype = 'gpx'
598        assert workout.has_fit
599        workout.tracking_file = None
600        workout.tracking_filetype = None
601        assert workout.has_fit
602
603    def test_map_screenshot_name(self, root):
604        workout = root['john']['1']
605        assert workout.map_screenshot_name == (
606            str(root['john'].uid) + '/' + str(workout.workout_id) + '.png')
607
608    def test_map_screenshot_path(self, root):
609        workout = root['john']['1']
610        assert workout.map_screenshot_path.endswith(
611            'static/maps/' + workout.map_screenshot_name)
612
613    @patch('ow.models.workout.os')
614    def test_map_screenshot_no_gpx(self, os, root):
615        workout = root['john']['1']
616        assert workout.map_screenshot is None
617        assert not os.path.join.called
618        assert not os.path.exists.called
619
620    @patch('ow.models.workout.os')
621    def test_map_screenshot_no_shot(self, os, root):
622        """
623        A workout with a tracking file has no map screenshot
624        """
625        # This says "no screenshot found"
626        os.path.exists.return_value = False
627
628        workout = root['john']['1']
629        workout.tracking_file = 'faked gpx file'
630        workout.tracking_filetype = 'gpx'
631
632        assert workout.map_screenshot is None
633        assert os.path.join.called
634        os.path.exists.assert_called_once_with(workout.map_screenshot_path)
635
636    @patch('ow.models.workout.os')
637    def test_map_screenshot_has_shot(self, os, root):
638        """
639        A workout with a tracking file has a map screenshot, the path to that
640        is returned without doing anything else
641        """
642        # This says "yeah, we have a screenshot"
643        os.path.exists.return_value = True
644        os.path.abspath.return_value = '/'
645        os.path.join.side_effect = join
646
647        workout = root['john']['1']
648        workout.tracking_file = 'faked gpx file'
649        workout.tracking_filetype = 'gpx'
650        uid = str(root['john'].uid)
651        assert workout.map_screenshot == 'ow:/static/maps/' + uid + '/1.png'
652        os.path.exists.assert_called_once_with(workout.map_screenshot_path)
653        assert os.path.join.called
Note: See TracBrowser for help on using the repository browser.