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

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

Done some improvements on the dashboard:

  • Remove hr/speed/cad info from the workouts list (too much info)
  • Added workout sport to the workouts list
  • Replaced full notes for each workout with a shortened/trimmed version (done this in the workouts list in the profile page too)
  • Property mode set to 100644
File size: 24.8 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_trimmed_notes(self):
147        workout = Workout()
148        assert workout.notes == ''
149        assert workout.trimmed_notes == ''
150        workout.notes = 'very short notes'
151        assert workout.notes == 'very short notes'
152        assert workout.trimmed_notes == 'very short notes'
153        workout.notes = 'long notes now, repeated' * 1000
154        assert len(workout.notes) == 24000
155        assert len(workout.trimmed_notes) == 224
156        assert workout.trimmed_notes.endswith(' ...')
157
158    def test_has_hr(self):
159        workout = Workout()
160        assert not workout.has_hr
161        workout.hr_min = 90
162        assert not workout.has_hr
163        workout.hr_max = 180
164        assert not workout.has_hr
165        workout.hr_avg = 120
166        assert workout.has_hr
167
168    def test_hr(self):
169        workout = Workout()
170        assert workout.hr is None
171        workout.hr_min = 90
172        assert workout.hr is None
173        workout.hr_max = 180
174        assert workout.hr is None
175        workout.hr_avg = 120
176        assert workout.hr['min'] == 90
177        assert workout.hr['max'] == 180
178        assert workout.hr['avg'] == 120
179
180    def test_has_cad(self):
181        workout = Workout()
182        assert not workout.has_cad
183        workout.cad_min = 0
184        assert not workout.has_cad
185        workout.cad_max = 110
186        assert not workout.has_cad
187        workout.cad_avg = 50
188        assert workout.has_cad
189
190    def test_cad(self):
191        workout = Workout()
192        assert workout.cad is None
193        workout.cad_min = 0
194        assert workout.cad is None
195        workout.cad_max = 110
196        assert workout.cad is None
197        workout.cad_avg = 50
198        assert workout.cad['min'] == 0
199        assert workout.cad['max'] == 110
200        assert workout.cad['avg'] == 50
201
202    def test_has_atemp(self):
203        workout = Workout()
204        assert not workout.has_atemp
205        workout.atemp_min = 0
206        assert not workout.has_atemp
207        workout.atemp_max = 12
208        assert not workout.has_atemp
209        workout.atemp_avg = 5
210        assert workout.has_atemp
211
212    def test_atemp(self):
213        workout = Workout()
214        assert workout.atemp is None
215        workout.atemp_min = 0
216        assert workout.atemp is None
217        workout.atemp_max = 12
218        assert workout.atemp is None
219        workout.atemp_avg = 5
220        assert workout.atemp['min'] == 0
221        assert workout.atemp['max'] == 12
222        assert workout.atemp['avg'] == 5
223
224    def test_tracking_file_path(self):
225        workout = Workout()
226        # no tracking file, path is None
227        assert workout.tracking_file_path is None
228        # workout still not saved to the db
229        workout.tracking_file = Mock()
230        workout.tracking_file._uncommitted.return_value = '/tmp/blobtempfile'
231        workout.tracking_file.committed.return_value = None
232        assert workout.tracking_file_path == '/tmp/blobtempfile'
233        workout.tracking_file._uncommitted.return_value = None
234        workout.tracking_file.committed.return_value = '/var/db/blobs/blobfile'
235        assert workout.tracking_file_path == '/var/db/blobs/blobfile'
236
237    def test_fit_file_path(self):
238        workout = Workout()
239        # no tracking file, path is None
240        assert workout.fit_file_path is None
241        # workout still not saved to the db
242        workout.fit_file = Mock()
243        workout.fit_file._uncommitted.return_value = '/tmp/blobtempfile'
244        workout.fit_file.committed.return_value = None
245        assert workout.fit_file_path == '/tmp/blobtempfile'
246        workout.fit_file._uncommitted.return_value = None
247        workout.fit_file.committed.return_value = '/var/db/blobs/blobfile'
248        assert workout.fit_file_path == '/var/db/blobs/blobfile'
249
250    def test_load_from_file_invalid(self):
251        workout = Workout()
252        workout.tracking_filetype = 'alf'
253        with patch.object(workout, 'load_from_gpx') as lfg:
254            workout.load_from_file()
255            assert not lfg.called
256
257    def test_load_from_file_gpx(self):
258        workout = Workout()
259        workout.tracking_filetype = 'gpx'
260        with patch.object(workout, 'load_from_gpx') as lfg:
261            workout.load_from_file()
262            assert lfg.called
263
264    def test_load_from_file_fit(self):
265        workout = Workout()
266        workout.tracking_filetype = 'fit'
267        with patch.object(workout, 'load_from_fit') as lff:
268            workout.load_from_file()
269            assert lff.called
270
271    gpx_params = (
272        # GPX 1.0 file, no extensions
273        ('fixtures/20131013.gpx', {
274            'start': datetime(2013, 10, 13, 5, 28, 26, tzinfo=timezone.utc),
275            'duration': timedelta(seconds=27652),
276            'distance': Decimal(98.12598431852807),
277            'title': 'A ride I will never forget',
278            'blob': 'path',
279            'hr': {'min': None, 'max': None, 'avg': None},
280            'cad': {'min': None, 'max': None, 'avg': None},
281            'atemp': {'min': None, 'max': None, 'avg': None}}),
282        # GPX 1.0 file, no extensions, missing elevation
283        ('fixtures/20131013-without-elevation.gpx', {
284            'start': datetime(2013, 10, 13, 5, 28, 26, tzinfo=timezone.utc),
285            'duration': timedelta(seconds=27652),
286            'distance': Decimal(98.12598431852807),
287            'title': 'A ride I will never forget',
288            'blob': None,
289            'hr': {'min': None, 'max': None, 'avg': None},
290            'cad': {'min': None, 'max': None, 'avg': None},
291            'atemp': {'min': None, 'max': None, 'avg': None}}),
292        # GPX 1.1 file with extensions
293        ('fixtures/20160129-with-extensions.gpx', {
294            'start': datetime(2016, 1, 29, 8, 12, 9, tzinfo=timezone.utc),
295            'duration': timedelta(seconds=7028),
296            'distance': Decimal(48.37448557752049237024039030),
297            'title': 'Cota counterclockwise + end bonus',
298            'blob': 'path',
299            'hr': {'min': Decimal(100), 'max': Decimal(175),
300                   'avg': Decimal(148.365864144454008055618032813072)},
301            'cad': {'min': Decimal(0), 'max': Decimal(110),
302                    'avg': Decimal(67.41745485812553740326741187)},
303            'atemp': {'min': Decimal(-4), 'max': Decimal(14),
304                      'avg': Decimal(-0.3869303525365434221840068788)}}),
305        )
306
307    @pytest.mark.parametrize(('filename', 'expected'), gpx_params)
308    def test_load_from_gpx(self, filename, expected):
309        """
310        Load a gpx file located in tests/fixtures using the load_from_gpx()
311        method of the Workout model, then check that certain attrs on the
312        workout are updated correctly
313        """
314        # expected values
315        start = expected['start']
316        duration = expected['duration']
317        distance = expected['distance']
318        title = expected['title']
319        blob = expected['blob']
320        hr = expected['hr']
321        cad = expected['cad']
322        atemp = expected['atemp']
323
324        workout = Workout()
325
326        # Check the values are different by default
327        assert workout.start != start
328        assert workout.duration != duration
329        assert workout.distance != distance
330
331        gpx_file_path = os.path.join(
332            os.path.dirname(os.path.dirname(__file__)), filename)
333        with patch.object(workout, 'tracking_file') as tf:
334            with open(gpx_file_path, 'r') as gpx_file:
335                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
336                # Set the path to the blob object containing the gpx file.
337                # more info in models.workout.Workout.parse_gpx()
338                tf._p_blob_uncommitted = gpx_file_path
339                if blob is None:
340                    # set the uncommited blob to None, mimicing what happens
341                    # with a workout saved into the db (transaction.commit())
342                    tf._p_blob_uncommitted = None
343                    tf._p_blob_committed = gpx_file_path
344                # Without this, has_gpx() will return False
345                workout.tracking_filetype = 'gpx'
346                res = workout.load_from_gpx()
347                assert res is True
348                assert workout.start == start
349                assert workout.duration == duration
350                assert isinstance(workout.distance, Decimal)
351                assert round(workout.distance) == round(distance)
352                # The title of the workout is taken from the gpx file
353                assert workout.title == title
354                for k in hr.keys():
355                    # We use 'fail' as the fallback in the getattr call because
356                    # None is one of the posible values, and we want to be sure
357                    # those attrs are there
358                    #
359                    # The keys are the same for the hr, cad and atemp dicts, so
360                    # we can do all tests in one loop
361                    #
362                    # If the expected value is not None, use round() to avoid
363                    # problems when comparing long Decimal objects
364                    value = getattr(workout, 'hr_'+k, 'fail')
365                    if hr[k] is None:
366                        assert hr[k] == value
367                    else:
368                        assert round(hr[k]) == round(value)
369
370                    value = getattr(workout, 'cad_'+k, 'fail')
371                    if cad[k] is None:
372                        assert cad[k] == value
373                    else:
374                        assert round(cad[k]) == round(value)
375
376                    value = getattr(workout, 'atemp_'+k, 'fail')
377                    if atemp[k] is None:
378                        assert atemp[k] == value
379                    else:
380                        assert round(atemp[k]) == round(value)
381
382    def test_load_from_gpx_no_tracks(self):
383        """
384        If we load an empty (but valid) gpx file (i.e., no tracks information)
385        the attrs on the workout are not updated, the call to load_from_gpx()
386        returns False
387        """
388        workout = Workout()
389
390        # We do not check the start time, it would need some mocking on the
391        # datetime module and there is no need really
392        assert workout.duration is None
393        assert workout.distance is None
394
395        gpx_file_path = os.path.join(
396            os.path.dirname(os.path.dirname(__file__)),
397            'fixtures/empty.gpx')
398        with patch.object(workout, 'tracking_file') as tf:
399            with open(gpx_file_path, 'r') as gpx_file:
400                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
401                res = workout.load_from_gpx()
402                assert res is False
403                assert workout.duration is None
404                assert workout.distance is None
405                assert workout.title == ''
406                for k in ['max', 'min', 'avg']:
407                    for a in ['hr_', 'cad_', 'atemp_']:
408                        assert getattr(workout, a+k, 'fail') is None
409
410    def test_parse_gpx_no_gpx_file(self):
411        """
412        Test the behaviour of parse_gpx() when we call it on a workout without
413        a gpx tracking file. The behaviour of such method when the workout has
414        a gpx tracking file is covered by the test_load_from_gpx() test above
415        """
416        workout = Workout()
417        res = workout.parse_gpx()
418        assert res == {}
419
420    fit_params = (
421        # complete fit file from a garmin 520 device
422        ('fixtures/20181230_101115.fit', {
423            'start': datetime(2018, 12, 30, 9, 11, 15, tzinfo=timezone.utc),
424            'duration': timedelta(0, 13872, 45000),
425            'distance': Decimal('103.4981999999999970896169543'),
426            'title': 'Synapse cycling',
427            'hr': {'min': 93, 'max': 170, 'avg': 144},
428            'cad': {'min': 0, 'max': 121, 'avg': 87},
429            'atemp': {'min': -4, 'max': 15, 'avg': 2},
430            'gpx_file': 'fixtures/20181230_101115.gpx'}),
431        # fit file from a garmin 520 without heart rate or cadence data
432        ('fixtures/20181231_110728.fit', {
433            'start': datetime(2018, 12, 31, 10, 7, 28, tzinfo=timezone.utc),
434            'duration': timedelta(0, 2373, 142000),
435            'distance': Decimal('6.094909999999999854480847716'),
436            'title': 'Synapse cycling',
437            'hr': None,
438            'cad': None,
439            'atemp': {'min': -1, 'max': 11, 'avg': 1},
440            'gpx_file': 'fixtures/20181231_110728.gpx'}),
441    )
442
443    @pytest.mark.parametrize(('filename', 'expected'), fit_params)
444    def test_load_from_fit(self, filename, expected):
445        """
446        Load a fit file located in tests/fixtures using the load_from_fit()
447        method of the Workout model, then check that certain attrs on the
448        workout are updated correctly.
449
450        Ensure also that the proper gpx file is created automatically from
451        the fit file, with the proper contents (we have a matching gpx file
452        in tests/fixtures for each fit file)
453        """
454        # expected values
455        start = expected['start']
456        duration = expected['duration']
457        distance = expected['distance']
458        title = expected['title']
459        hr = expected['hr']
460        cad = expected['cad']
461        atemp = expected['atemp']
462        # gpx_file = expected['gpx_file']
463
464        workout = Workout()
465
466        # Check the values are different by default
467        assert workout.start != start
468        assert workout.duration != duration
469        assert workout.distance != distance
470
471        # by default no tracking file and no fit file are associated with this
472        # workout.
473        assert workout.tracking_file is None
474        assert workout.fit_file is None
475        assert not workout.has_tracking_file
476        assert not workout.has_gpx
477        assert not workout.has_fit
478
479        fit_file_path = os.path.join(
480            os.path.dirname(os.path.dirname(__file__)), filename)
481
482        # gpx_file_path = os.path.join(
483        #     os.path.dirname(os.path.dirname(__file__)), gpx_file)
484
485        # add the fit file as a blob to tracking_file
486        with open(fit_file_path, 'rb') as fit_file:
487            fit_blob = create_blob(fit_file.read(), file_extension='fit',
488                                   binary=True)
489        workout.tracking_file = fit_blob
490        workout.tracking_filetype = 'fit'
491
492        res = workout.load_from_fit()
493
494        assert res is True
495        assert workout.start == start
496        assert workout.duration == duration
497        assert isinstance(workout.distance, Decimal)
498        assert round(workout.distance) == round(distance)
499        # The title of the workout is taken from the gpx file
500        assert workout.title == title
501
502        if hr is not None:
503            for k in hr.keys():
504                # We use 'fail' as the fallback in the getattr call
505                # because None is one of the posible values, and we
506                # want to be sure those attrs are there
507                value = getattr(workout, 'hr_' + k, 'fail')
508                # Use round() to avoid problems when comparing long
509                # Decimal objects
510                assert round(hr[k]) == round(value)
511
512        if cad is not None:
513            for k in cad.keys():
514                value = getattr(workout, 'cad_' + k, 'fail')
515                assert round(cad[k]) == round(value)
516
517        if atemp is not None:
518            for k in atemp.keys():
519                value = getattr(workout, 'atemp_' + k, 'fail')
520                assert round(atemp[k]) == round(value)
521
522        assert workout.tracking_file is not None
523        # the tracking file type is set back to gpx, as we have
524        # automatically generated the gpx version
525        assert workout.tracking_filetype == 'gpx'
526        assert workout.fit_file is not None
527        assert workout.has_tracking_file
528        assert workout.has_gpx
529        assert workout.has_fit
530
531    def test_has_tracking_file(self, root):
532        workout = root['john']['1']
533        # without tracking file
534        assert not workout.has_tracking_file
535        # with tracking file
536        workout.tracking_file = 'faked tracking file'
537        assert workout.has_tracking_file
538
539    def test_has_gpx(self, root):
540        workout = root['john']['1']
541        # without tracking file
542        assert not workout.has_gpx
543        workout.tracking_filetype = 'fit'
544        assert not workout.has_gpx
545        # with non-gpx tracking file
546        workout.tracking_file = 'faked tracking file'
547        workout.tracking_filetype = 'fit'
548        assert not workout.has_gpx
549        # with gpx tracking file
550        workout.tracking_file = 'faked tracking file'
551        workout.tracking_filetype = 'gpx'
552        assert workout.has_gpx
553
554    def test_has_fit(self, root):
555        workout = root['john']['1']
556        # without tracking file
557        assert not workout.has_fit
558        # tracking_file is a fit, this should not happen, as uploading a fit
559        # puts the fit file into .fit_file and generates a gpx for
560        # .tracking_file
561        workout.tracking_file = 'faked tracking file'
562        workout.tracking_filetype = 'fit'
563        assert not workout.has_fit
564        # now, having a fit file returns true
565        workout.fit_file = 'faked fit file'
566        assert workout.has_fit
567        # no matter what we have in tracking_file
568        workout.tracking_filetype = 'gpx'
569        assert workout.has_fit
570        workout.tracking_file = None
571        workout.tracking_filetype = None
572        assert workout.has_fit
573
574    @patch('ow.models.workout.os')
575    @patch('ow.models.workout.save_map_screenshot')
576    def test_map_screenshot_no_gpx(self, sms, os, root):
577        workout = root['john']['1']
578        assert workout.map_screenshot is None
579        assert not os.path.abspath.called
580        assert not os.path.dirname.called
581        assert not os.path.join.called
582        assert not os.path.exists.called
583        assert not sms.called
584
585    @patch('ow.models.workout.os')
586    @patch('ow.models.workout.save_map_screenshot')
587    def test_map_screenshot_save(self, sms, os, root):
588        """
589        A workout with a tracking file has no map screenshot, one is
590        saved to the filesystem.
591        This test simply asserts the calls to the separate methods that
592        look for existing screenshots and save a new one
593        """
594        os.path.abspath.return_value = 'current_dir'
595        os.path.join.side_effect = join
596        # This forces the "save screenshot" code to be run
597        os.path.exists.return_value = False
598
599        workout = root['john']['1']
600        workout.tracking_file = 'faked gpx file'
601        workout.tracking_filetype = 'gpx'
602
603        uid = str(root['john'].uid)
604        assert workout.map_screenshot == 'ow:/static/maps/' + uid + '/1.png'
605        assert os.path.abspath.called
606        assert os.path.dirname.called
607        assert os.path.join.call_count == 2
608        assert os.path.exists.called
609        sms.assert_called_once_with(workout)
610
611    @patch('ow.models.workout.os')
612    @patch('ow.models.workout.save_map_screenshot')
613    def test_map_screenshot_do_not_save(self, sms, os, root):
614        """
615        A workout with a tracking file has a map screenshot, the path to that
616        is returned without doing anything else
617        """
618        os.path.abspath.return_value = 'current_dir'
619        os.path.join.side_effect = join
620        # This forces the "save screenshot" code NOT to be run
621        os.path.exists.return_value = True
622
623        workout = root['john']['1']
624        workout.tracking_file = 'faked gpx file'
625        workout.tracking_filetype = 'gpx'
626
627        uid = str(root['john'].uid)
628        assert workout.map_screenshot == 'ow:/static/maps/' + uid + '/1.png'
629        assert os.path.abspath.called
630        assert os.path.dirname.called
631        assert os.path.join.call_count == 2
632        assert os.path.exists.called
633        assert not sms.called
Note: See TracBrowser for help on using the repository browser.