diff --git a/rowers/dataflow.py b/rowers/dataflow.py index 0f28e85d..542c543e 100644 --- a/rowers/dataflow.py +++ b/rowers/dataflow.py @@ -58,7 +58,7 @@ from uuid import uuid4 def getrower(user): try: - if user is None or user.is_anonymous: + if user is None or user.is_anonymous: # pragma: no cover return None except AttributeError: # pragma: no cover if User.objects.get(id=user).is_anonymous: @@ -77,12 +77,12 @@ def generate_job_id(): def valid_uploadoptions(uploadoptions): fstr = uploadoptions.get('file', None) - if fstr is None: + if fstr is None: # pragma: no cover return False, "Missing file in upload options." # check if file can be found if isinstance(fstr, str): - if not os.path.isfile(fstr): + if not os.path.isfile(fstr): # pragma: no cover return False, f"File not found: {fstr}" @@ -108,19 +108,19 @@ def is_invalid_file(file_path): return False, "Image files are not supported for upload." if fileformat == "json": return False, "JSON files are not supported for upload." - if fileformat == "c2log": + if fileformat == "c2log": # pragma: no cover return False, "Concept2 log files are not supported for upload." - if fileformat == "nostrokes": + if fileformat == "nostrokes": # pragma: no cover return False, "No stroke data found in the file." if fileformat == "kml": return False, "KML files are not supported for upload." - if fileformat == "notgzip": + if fileformat == "notgzip": # pragma: no cover return False, "The gzip file appears to be corrupted." - if fileformat == "rowprolog": + if fileformat == "rowprolog": # pragma: no cover return False, "RowPro logbook summary files are not supported for upload." if fileformat == "gpx": return False, "GPX files are not supported for upload." - if fileformat == "unknown": + if fileformat == "unknown": # pragma: no cover extension = os.path.splitext(f2)[1] filename = os.path.splitext(f2)[0] if extension == '.gz': @@ -139,14 +139,14 @@ def is_invalid_file(file_path): def upload_handler(uploadoptions, filename): valid, message = valid_uploadoptions(uploadoptions) - if not valid: + if not valid: # pragma: no cover return { "status": "error", "job_id": None, "message": message } is_valid, message = is_invalid_file(filename) - if not is_valid: + if not is_valid: # pragma: no cover os.remove(filename) return { "status": "error", @@ -203,7 +203,7 @@ def unzip_and_process(zip_filepath, uploadoptions, parent_job_id, debug=False, * def get_rower_from_uploadoptions(uploadoptions): rowerform = TeamInviteForm(uploadoptions) - if not rowerform.is_valid(): + if not rowerform.is_valid(): # pragma: no cover return None try: u = rowerform.cleaned_data['user'] @@ -214,7 +214,7 @@ def get_rower_from_uploadoptions(uploadoptions): if len(us): u = us[0] r = getrower(u) - else: + else: # pragma: no cover r = None for rwr in Rower.objects.all(): if rwr.emailalternatives is not None: @@ -322,7 +322,7 @@ def update_workout_attributes(w, row, file_path, uploadoptions, empowerside = 'starboard' stravaid = uploadoptions.get('stravaid',0) - if stravaid != 0: + if stravaid != 0: # pragma: no cover workoutsource = 'strava' w.uploadedtostrava = stravaid @@ -347,10 +347,10 @@ def update_workout_attributes(w, row, file_path, uploadoptions, totaltime = row.df['TimeStamp (sec)'].max() - row.df['TimeStamp (sec)'].min() try: totaltime = totaltime + row.df.loc[:, ' ElapsedTime (sec)'].iloc[0] - except KeyError: + except KeyError: # pragma: no cover pass - if np.isnan(totaltime): + if np.isnan(totaltime): # pragma: no cover totaltime = 0 if uploadoptions.get('summary', '') == '': @@ -358,11 +358,11 @@ def update_workout_attributes(w, row, file_path, uploadoptions, else: summary = uploadoptions.get('summary', '') - if uploadoptions.get('makeprivate', False): + if uploadoptions.get('makeprivate', False): # pragma: no cover privacy = 'hidden' elif workoutsource != 'strava': privacy = 'visible' - else: + else: # pragma: no cover privacy = 'hidden' # checking for in values @@ -378,7 +378,7 @@ def update_workout_attributes(w, row, file_path, uploadoptions, try: workoutenddatetime = startdatetime+delta - except AttributeError as e: + except AttributeError as e: # pragma: no cover workoutstartdatetime = pendulum.parse(str(startdatetime)) workoutenddatetime = startdatetime+delta @@ -386,7 +386,7 @@ def update_workout_attributes(w, row, file_path, uploadoptions, # check for duplicate start times and duration duplicate = checkduplicates( w.user, startdate, startdatetime, workoutenddatetime) - if duplicate: + if duplicate: # pragma: no cover rankingpiece = False # test title length @@ -431,7 +431,7 @@ def update_workout_attributes(w, row, file_path, uploadoptions, w.save() # check for registrationid - if registrationid != 0: + if registrationid != 0: # pragma: no cover races = VirtualRace.objects.filter( registration_closure__gt=tz.now(), id=raceid, @@ -495,11 +495,11 @@ def update_running_wps(r, w, row): duplicate=False).count() new_value = (cntr*r.running_wps_erg + row.df['driveenergy'].mean())/(cntr+1.0) # if new_value is not zero or infinite or -inf, r.running_wps can be set to value - if not (math.isnan(new_value) or math.isinf(new_value) or new_value == 0): + if not (math.isnan(new_value) or math.isinf(new_value) or new_value == 0): # pragma: no cover r.running_wps_erg = new_value elif not (math.isnan(r.running_wps_erg) or math.isinf(r.running_wps_erg) or r.running_wps_erg == 0): pass - else: + else: # pragma: no cover r.running_wps_erg = 600. r.save() @@ -509,13 +509,13 @@ def update_running_wps(r, w, row): duplicate=False).count() try: new_value = (cntr*r.running_wps_erg + row.df['driveenergy'].mean())/(cntr+1.0) - except TypeError: + except TypeError: # pragma: no cover new_value = r.running_wps if not (math.isnan(new_value) or math.isinf(new_value) or new_value == 0): r.running_wps = new_value elif not (math.isnan(r.running_wps) or math.isinf(r.running_wps) or r.running_wps == 0): pass - else: + else: # pragma: no cover r.running_wps = 400. r.save() @@ -531,7 +531,7 @@ def process_single_file(file_path, uploadoptions, job_id, debug=False, **kwargs) f1 = uuid4().hex[:10]+'-'+strftime('%Y%m%d-%H%M%S')+ext f2 = 'media/'+f1 copyfile(file_path, f2) - except FileNotFoundError: + except FileNotFoundError: # pragma: no cover return { "status": "error", "job_id": job_id, @@ -540,7 +540,7 @@ def process_single_file(file_path, uploadoptions, job_id, debug=False, **kwargs) # determine the user r = get_rower_from_uploadoptions(uploadoptions) - if r is None: + if r is None: # pragma: no cover os.remove(f2) return { "status": "error", @@ -550,7 +550,7 @@ def process_single_file(file_path, uploadoptions, job_id, debug=False, **kwargs) try: fileformat = get_file_type(f2) - except Exception as e: + except Exception as e: # pragma: no cover os.remove(f2) return { "status": "error", @@ -559,16 +559,17 @@ def process_single_file(file_path, uploadoptions, job_id, debug=False, **kwargs) } # Get fileformat from fit & tcx - if fileformat == 'fit': + if "fit" in fileformat: workouttype = get_workouttype_from_fit(f2) uploadoptions['workouttype'] = workouttype new_title = get_title_from_fit(f2) - if new_title: + if new_title: # pragma: no cover uploadoptions['title'] = new_title new_notes = get_notes_from_fit(f2) - if new_notes: + if new_notes: # pragma: no cover uploadoptions['notes'] = new_notes + # handle non-Painsled if fileformat != 'csv': f2, summary, oarlength, inboard, fileformat, impeller = handle_nonpainsled( @@ -581,7 +582,7 @@ def process_single_file(file_path, uploadoptions, job_id, debug=False, **kwargs) uploadoptions['useImpeller'] = impeller if uploadoptions['workouttype'] != 'strave': uploadoptions['workoutsource'] = fileformat - if not f2: + if not f2: # pragma: no cover return { "status": "error", "job_id": job_id, @@ -600,7 +601,7 @@ def process_single_file(file_path, uploadoptions, job_id, debug=False, **kwargs) powerperc=powerperc, powerzones=r.powerzones) row = rdata(f2, rower=rr) - if row.df.empty: + if row.df.empty: # pragma: no cover os.remove(f2) return { "status": "error", @@ -608,7 +609,7 @@ def process_single_file(file_path, uploadoptions, job_id, debug=False, **kwargs) "message": "No valid data found in the uploaded file." } - if row == 0: + if row == 0: # pragma: no cover os.remove(f2) return { "status": "error", @@ -639,7 +640,7 @@ def process_single_file(file_path, uploadoptions, job_id, debug=False, **kwargs) pass workoutid = uploadoptions.get('id', None) - if workoutid is not None: + if workoutid is not None: # pragma: no cover try: w = Workout.objects.get(id=workoutid) except Workout.DoesNotExist: @@ -657,7 +658,7 @@ def process_single_file(file_path, uploadoptions, job_id, debug=False, **kwargs) if w.privacy == 'visible': ts = Team.objects.filter(rower=r ) - for t in ts: + for t in ts: # pragma: no cover w.team.add(t) # put stroke data in file store through "dataplep" @@ -686,14 +687,14 @@ def process_single_file(file_path, uploadoptions, job_id, debug=False, **kwargs) wps_avg = r.median_wps elif w.workouttype in otetypes: wps_avg = r.median_wps_erg - else: + else: # pragma: no cover wps_avg = 0 _ = myqueue(queuehigh, handle_calctrimp, w.id, f2, r.ftp, r.sex, r.hrftp, r.max, r.rest, wps_avg) # make plots - if uploadoptions.get('makeplot', False): + if uploadoptions.get('makeplot', False): # pragma: no cover plottype = uploadoptions.get('plottype', 'timeplot') res, jobid = uploads.make_plot(r, w, f1, f2, plottype, w.name) elif r.staticchartonupload != 'None': # pragma: no cover diff --git a/rowers/tests/test_uploads2.py b/rowers/tests/test_uploads2.py index 0365bf66..c261c396 100644 --- a/rowers/tests/test_uploads2.py +++ b/rowers/tests/test_uploads2.py @@ -7,11 +7,47 @@ from __future__ import unicode_literals from .statements import * nu = datetime.datetime.now() from django.db import transaction +import shutil from rowers.views import add_defaultfavorites from rowers.dataflow import process_single_file, upload_handler, unzip_and_process from django.core.files.uploadedfile import SimpleUploadedFile from django.conf import settings +from rowingdata import get_file_type + +file_list = [ + 'rowers/tests/testdata/testdata.csv', + 'rowers/tests/testdata/testdata.csv.gz', + 'rowers/tests/testdata/tim.csv', + 'rowers/tests/testdata/crewnerddata.tcx', + 'rowers/tests/testdata/Speedcoach2example.csv', + 'rowers/tests/testdata/Impeller.csv', + 'rowers/tests/testdata/speedcoach3test3.csv', + 'rowers/tests/testdata/SpeedCoach2Linkv1.27.csv', + 'rowers/tests/testdata/SpeedCoach2Link_interval.csv', + 'rowers/tests/testdata/NoHR.tcx', + 'rowers/tests/testdata/rowinginmotionexample.tcx', + 'rowers/tests/testdata/RP_testdata.csv', + 'rowers/tests/testdata/mystery.csv', + 'rowers/tests/testdata/RP_interval.csv', + 'rowers/tests/testdata/3x250m.fit', + 'rowers/tests/testdata/painsled_desktop_example.csv', + 'rowers/tests/testdata/ergdata_example.csv', + 'rowers/tests/testdata/boatcoach_2021-09-09__18-15-53.csv', + 'rowers/tests/testdata/colinforce.csv', + 'rowers/tests/testdata/PainsledForce.csv', + 'rowers/tests/testdata/EmpowerSpeedCoachForce.csv', + 'rowers/tests/testdata/boatcoach.csv', + 'rowers/tests/testdata/ergstick.csv', +] + +fail_list = [ + 'rowers/tests/testdata/lofoten.jpg', + 'rowers/tests/testdata/c2records.json', + 'rowers/tests/testdata/alphen.kml', + 'rowers/tests/testdata/testdata.gpx' +] + #@pytest.mark.django_db @override_settings(TESTING=True) @@ -31,28 +67,29 @@ class ViewTest(TestCase): self.nu = datetime.datetime.now() - file_list = ['rowers/tests/testdata/testdata.csv', - 'rowers/tests/testdata/tim.csv', - 'rowers/tests/testdata/crewnerddata.tcx', - 'rowers/tests/testdata/Speedcoach2example.csv', - 'rowers/tests/testdata/Impeller.csv', - 'rowers/tests/testdata/speedcoach3test3.csv', - 'rowers/tests/testdata/SpeedCoach2Linkv1.27.csv', - 'rowers/tests/testdata/SpeedCoach2Link_interval.csv', - 'rowers/tests/testdata/NoHR.tcx', - 'rowers/tests/testdata/rowinginmotionexample.tcx', - 'rowers/tests/testdata/RP_testdata.csv', - 'rowers/tests/testdata/mystery.csv', - 'rowers/tests/testdata/RP_interval.csv', - 'rowers/tests/testdata/painsled_desktop_example.csv', - 'rowers/tests/testdata/ergdata_example.csv', - 'rowers/tests/testdata/boatcoach_2021-09-09__18-15-53.csv', - 'rowers/tests/testdata/colinforce.csv', - 'rowers/tests/testdata/PainsledForce.csv', - 'rowers/tests/testdata/EmpowerSpeedCoachForce.csv', - 'rowers/tests/testdata/boatcoach.csv', - 'rowers/tests/testdata/ergstick.csv', - ] + # copy every file in fail_list to rowers/tests/testdata/backup folder + # Zorg ervoor dat de backup-map bestaat + backup_dir = 'rowers/tests/testdata/backup' + os.makedirs(backup_dir, exist_ok=True) + + # Kopieer elk bestand in fail_list naar de backup-map + for file_path in fail_list: + if os.path.exists(file_path): + shutil.copy(file_path, backup_dir) + else: + print(f"Bestand niet gevonden: {file_path}") + + def tearDown(self): + backup_dir = 'rowers/tests/testdata/backup' + for file_path in fail_list: + backup_file = os.path.join(backup_dir, os.path.basename(file_path)) + if os.path.exists(backup_file): + shutil.copy(backup_file, os.path.dirname(file_path)) + else: + print(f"Backup-bestand niet gevonden: {backup_file}") + + + @parameterized.expand(file_list) @patch('rowers.dataflow.myqueue') def test_upload_view(self, filename, mocked_myqueue): @@ -93,12 +130,62 @@ class ViewTest(TestCase): response = self.c.post('/rowers/workout/upload/', data = form_data, files = {'file': uploaded_file}, follow=True) + self.assertEqual(response.status_code, 200) + uploadoptions = form.cleaned_data.copy() uploadoptions.update(optionsform.cleaned_data) result = upload_handler(uploadoptions, filename) self.assertEqual(result["status"], "processing") + @parameterized.expand(fail_list) + @patch('rowers.dataflow.myqueue') + def test_upload_view(self, filename, mocked_myqueue): + # simple test to see if upload view works. Submits a DocumentsForm to /rowers/workout/upload/ + login = self.c.login(username='john',password='koeinsloot') + self.assertTrue(login) + + with open(filename, 'rb') as f: + file_content = f.read() + uploaded_file = SimpleUploadedFile( + "testdata.csv", + file_content, + content_type="text/csv" + ) + form_data = { + 'title':'test', + 'workouttype':'rower', + 'boattype':'1x', + 'notes':'aap noot mies', + 'make_plot':False, + 'rpe':6, + 'upload_to_c2':False, + 'plottype':'timeplot', + 'landingpage':'workout_edit_view', + 'raceid':0, + 'file': filename, + } + + + request = RequestFactory() + request.user = self.u + form = DocumentsForm(data = form_data,files={'file': uploaded_file}) + self.assertTrue(form.is_valid()) + + optionsform = UploadOptionsForm(form_data,request=request) + self.assertTrue(optionsform.is_valid()) + + response = self.c.post('/rowers/workout/upload/', data = form_data, + files = {'file': uploaded_file}, follow=True) + + self.assertEqual(response.status_code, 200) + + uploadoptions = form.cleaned_data.copy() + uploadoptions.update(optionsform.cleaned_data) + result = upload_handler(uploadoptions, filename) + + self.assertEqual(result["status"], "error") + @parameterized.expand(file_list) @patch('rowers.dataflow.myqueue') def test_process_single_file(self, filename, mocked_myqueue): @@ -120,6 +207,75 @@ class ViewTest(TestCase): self.assertEqual(result, True) os.remove(f2+'.gz') + # process a single file without 'user' + @patch('rowers.dataflow.myqueue') + def test_process_single_file_nouser(self, mocked_myqueue): + filename = 'rowers/tests/testdata/testdata.csv' + uploadoptions = { + 'title':'test', + 'workouttype':'rower', + 'boattype':'1x', + 'notes':'aap noot mies', + 'make_plot':False, + 'rpe':6, + 'upload_to_c2':False, + 'plottype':'timeplot', + 'landingpage':'workout_edit_view', + 'raceid':0, + 'useremail': self.u.email, + 'file': filename, + } + result, f2 = process_single_file(filename, uploadoptions, 1) + self.assertEqual(result, True) + os.remove(f2+'.gz') + + # process a zip file + @patch('rowers.dataflow.myqueue') + def test_process_single_zipfile(self, mocked_myqueue): + filename = 'rowers/tests/testdata/zipfile.zip' + uploadoptions = { + 'title':'test', + 'workouttype':'rower', + 'boattype':'1x', + 'notes':'aap noot mies', + 'make_plot':False, + 'rpe':6, + 'upload_to_c2':False, + 'plottype':'timeplot', + 'landingpage':'workout_edit_view', + 'raceid':0, + 'user': self.u, + 'file': filename, + } + result = process_single_file(filename, uploadoptions, 1) + + self.assertEqual(result["status"], "error") + + result = upload_handler(uploadoptions, filename) + + self.assertEqual(result["status"], "processing") + + # process a single file without 'title' + @patch('rowers.dataflow.myqueue') + def test_process_single_file_nouser(self, mocked_myqueue): + filename = 'rowers/tests/testdata/testdata.csv' + uploadoptions = { + 'workouttype':'rower', + 'boattype':'1x', + 'notes':'aap noot mies', + 'make_plot':False, + 'rpe':6, + 'upload_to_c2':False, + 'plottype':'timeplot', + 'landingpage':'workout_edit_view', + 'raceid':0, + 'user': self.u, + 'file': filename, + } + result, f2 = process_single_file(filename, uploadoptions, 1) + self.assertEqual(result, True) + os.remove(f2+'.gz') + @patch('rowers.dataflow.myqueue') def test_process_zip_file(self, mocked_myqueue): filename = 'rowers/tests/testdata/zipfile.zip' diff --git a/rowers/tests/testdata/backup/alphen.kml b/rowers/tests/testdata/backup/alphen.kml new file mode 100644 index 00000000..9ed981cb --- /dev/null +++ b/rowers/tests/testdata/backup/alphen.kml @@ -0,0 +1,178 @@ + + + + Courses.kml + + Courses + + Alphen - Alphen aan den Rijn + 1 + + Start + + 1 + + + 4.704149601313898,52.14611068342334,0 4.704648516706039,52.14606840788696,0 4.704642182077736,52.14626893773362,0 4.704151599747837,52.14628828501986,0 4.704149601313898,52.14611068342334,0 4.704149601313898,52.14611068342334,0 + + + + + + Gate 1 + + 1 + + + 4.704040567073562,52.14772365703576,0 4.704544185247905,52.14767250842382,0 4.704570221164488,52.14791407188889,0 4.704130359234369,52.14797079566858,0 4.704040567073562,52.14772365703576,0 4.704040567073562,52.14772365703576,0 + + + + + + Gate 2 + + 1 + + + 4.707120374629225,52.15459940303027,0 4.707573702026327,52.15460568431943,0 4.70761596147063,52.15486728249238,0 4.707159504658982,52.15489881627455,0 4.707120374629225,52.15459940303027,0 4.707120374629225,52.15459940303027,0 + + + + + + Gate 3 + + 1 + + + 4.709028668490356,52.1646474322453,0 4.70984931790314,52.16449178436365,0 4.709978566943311,52.16488586779201,0 4.709244456319242,52.16499245615274,0 4.709028668490356,52.1646474322453,0 4.709028668490356,52.1646474322453,0 + + + + + + Gate 4 + + 1 + + + 4.718138359290078,52.17865355742074,0 4.718653235056161,52.17830639665007,0 4.719134204848634,52.17862031168055,0 4.71867160984541,52.17894003397144,0 4.718138359290078,52.17865355742074,0 4.718138359290078,52.17865355742074,0 + + + + + + Gate 5 + + 1 + + + 4.727641648412835,52.18284846695732,0 4.728273789904367,52.18251973845241,0 4.728577606945771,52.1827641768111,0 4.7279847617705,52.1830837392454,0 4.727641648412835,52.18284846695732,0 4.727641648412835,52.18284846695732,0 + + + + + + Gate 6 + + 1 + + + 4.738716857017891,52.19396028458393,0 4.739294818571407,52.19389560588872,0 4.739411118817641,52.19428660874426,0 4.738864571028594,52.19431307372239,0 4.738716857017891,52.19396028458393,0 4.738716857017891,52.19396028458393,0 + + + + + + Gate 7 + + 1 + + + 4.734183821236371,52.20620514880871,0 4.734924962205387,52.20637199686158,0 4.734802543714663,52.20688025274802,0 4.733601274999542,52.20663721340052,0 4.734183821236371,52.20620514880871,0 4.734183821236371,52.20620514880871,0 + + + + + + Gate 8 + + 1 + + + 4.738785303605908,52.19457123452171,0 4.739333350356509,52.19459196501802,0 4.739304304831564,52.19482691469288,0 4.73885420703549,52.19479878738656,0 4.738785303605908,52.19457123452171,0 4.738785303605908,52.19457123452171,0 + + + + + + Gate 9 + + 1 + + + 4.728292586661338,52.18327969510192,0 4.728884338045631,52.18302182842039,0 4.729083849790216,52.1833152834237,0 4.728606271720666,52.18355598784883,0 4.728292586661338,52.18327969510192,0 4.728292586661338,52.18327969510192,0 + + + + + + Gate 10 + + 1 + + + 4.717008631662971,52.17788756203277,0 4.717714777374475,52.17758571819474,0 4.718168595226933,52.17803093936305,0 4.717634575621297,52.17832999894938,0 4.717008631662971,52.17788756203277,0 4.717008631662971,52.17788756203277,0 + + + + + + Gate 11 + + 1 + + + 4.708580146922809,52.16405851453961,0 4.709467162927956,52.16392338577828,0 4.709761923185198,52.16427786809471,0 4.708922971852094,52.16448915385681,0 4.708580146922809,52.16405851453961,0 4.708580146922809,52.16405851453961,0 + + + + + + Gate 12 + + 1 + + + 4.70716800510311,52.15500418035832,0 4.707671825192278,52.15498496004398,0 4.707743878685751,52.15525628533189,0 4.707149393888881,52.1553218720998,0 4.70716800510311,52.15500418035832,0 4.70716800510311,52.15500418035832,0 + + + + + + Gate 13 + + 1 + + + 4.704140681716737,52.14813498986593,0 4.704864196194787,52.1479883822655,0 4.705153909432487,52.14838874308533,0 4.704223464041033,52.14854260247372,0 4.704140681716737,52.14813498986593,0 4.704140681716737,52.14813498986593,0 + + + + + + Finish + + 1 + + + 4.70414987291546,52.1461319705247,0 4.704561170436561,52.14607111930849,0 4.704642182077736,52.14626893773362,0 4.70415735390207,52.14628831020436,0 4.70414987291546,52.1461319705247,0 4.70414987291546,52.1461319705247,0 + + + + + + + + diff --git a/rowers/tests/testdata/backup/lofoten.jpg b/rowers/tests/testdata/backup/lofoten.jpg new file mode 100644 index 00000000..474999ca Binary files /dev/null and b/rowers/tests/testdata/backup/lofoten.jpg differ diff --git a/rowers/tests/testdata/backup/testdata.gpx b/rowers/tests/testdata/backup/testdata.gpx new file mode 100644 index 00000000..462f2508 --- /dev/null +++ b/rowers/tests/testdata/backup/testdata.gpx @@ -0,0 +1,574 @@ +Garmin InternationalExport by rowingdata + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/rowers/tests/testdata/testdata.tcx.gz b/rowers/tests/testdata/testdata.tcx.gz index 529a7af8..4dbdb99b 100644 Binary files a/rowers/tests/testdata/testdata.tcx.gz and b/rowers/tests/testdata/testdata.tcx.gz differ