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

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

Workout.rounded_distance() returns distance rounded with 2 decimals now

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