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

currentfeature/docs
Last change on this file since d6f8304 was d6f8304, checked in by Borja Lopez <borja@…>, 5 years ago

(#52) - screenshots of the workouts maps were corrupted randomly.

Replaced our screenshot_map shell script that was calling chrome headless
directly with some python code running splinter + selenium webdriver.

Using chromedriver in such environment we can first visit the map site
for the given workout, then wait a bit for it to completely load, then
take the screenshot.

I've also removed all traces of screenshot_map from the code.

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