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

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

Fixed asserts on some workout models tests. Added missing tests covering has_fit

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