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

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

Put duration/distance on the workout hash only if those values are there

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