signalk-race-control 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +34 -0
- package/package.json +12 -2
- package/public/app.js +6 -0
- package/test/geometry.test.js +110 -0
- package/test/parsing.test.js +246 -0
- package/test/plugin.test.js +328 -0
- package/test/race-shape.test.js +159 -0
package/index.js
CHANGED
|
@@ -3224,3 +3224,37 @@ module.exports = function (app) {
|
|
|
3224
3224
|
|
|
3225
3225
|
return plugin;
|
|
3226
3226
|
};
|
|
3227
|
+
|
|
3228
|
+
// Exposes the pure, top-level helper functions above for unit testing —
|
|
3229
|
+
// none of this runs, or is even looked at, when SignalK loads the plugin
|
|
3230
|
+
// via `require('signalk-race-control')(app)`; it's a plain extra property
|
|
3231
|
+
// on the exported factory function, purely for `test/*.test.js` to import
|
|
3232
|
+
// without instantiating a whole plugin + mock app for logic that doesn't
|
|
3233
|
+
// need one.
|
|
3234
|
+
module.exports.internal = {
|
|
3235
|
+
parseCsvText,
|
|
3236
|
+
parseNorwegianNumber,
|
|
3237
|
+
parseHandicapSheet,
|
|
3238
|
+
stripHtmlToText,
|
|
3239
|
+
parseKtkHtml,
|
|
3240
|
+
extractGoogleSheetId,
|
|
3241
|
+
parseManage2SailEventPage,
|
|
3242
|
+
parseManage2SailEntries,
|
|
3243
|
+
findHandicapSystem,
|
|
3244
|
+
resolveHandicapSystem,
|
|
3245
|
+
makeRaceId,
|
|
3246
|
+
makeBoatId,
|
|
3247
|
+
makeClassId,
|
|
3248
|
+
raceSummary,
|
|
3249
|
+
emptyCourse,
|
|
3250
|
+
ensureRaceShape,
|
|
3251
|
+
findClass,
|
|
3252
|
+
effectiveStartTime,
|
|
3253
|
+
distanceNm,
|
|
3254
|
+
midpoint,
|
|
3255
|
+
segmentsIntersect,
|
|
3256
|
+
validateCoordPoint,
|
|
3257
|
+
validateLine,
|
|
3258
|
+
formatLocalDateTime,
|
|
3259
|
+
buildOfflineTimerHtml
|
|
3260
|
+
};
|
package/package.json
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"description": "SignalK plugin and webapp for tracking elapsed and handicap-corrected (Time-on-Time) race time, with a per-boat editable TCF and boat names pulled from AIS when available.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Joachim Bakke <github@heiamoss.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/joabakk/signalk-race-control.git"
|
|
10
|
+
},
|
|
7
11
|
"keywords": [
|
|
8
12
|
"signalk-node-server-plugin",
|
|
9
13
|
"signalk-webapp",
|
|
10
14
|
"signalk-category-race"
|
|
11
15
|
],
|
|
12
16
|
"engines": {
|
|
13
|
-
"node": ">=
|
|
17
|
+
"node": ">=18"
|
|
14
18
|
},
|
|
15
19
|
"license": "MIT",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test"
|
|
22
|
+
},
|
|
16
23
|
"dependencies": {
|
|
17
24
|
"exceljs": "^4.4.0"
|
|
25
|
+
},
|
|
26
|
+
"overrides": {
|
|
27
|
+
"uuid": "^11.1.1"
|
|
18
28
|
}
|
|
19
29
|
}
|
package/public/app.js
CHANGED
|
@@ -2999,8 +2999,14 @@
|
|
|
2999
2999
|
: tsToTimeInputValue(raceState.startTime);
|
|
3000
3000
|
}
|
|
3001
3001
|
|
|
3002
|
+
if (document.activeElement !== scheduleInput) {
|
|
3003
|
+
scheduleInput.value = tsToDateTimeInputValue(raceState.scheduledStart);
|
|
3004
|
+
}
|
|
3002
3005
|
scheduleInput.disabled = !!raceState.startTime;
|
|
3003
3006
|
scheduleBtn.disabled = !!raceState.startTime;
|
|
3007
|
+
scheduleBtn.textContent = raceState.scheduledStart
|
|
3008
|
+
? 'Scheduled: ' + new Date(raceState.scheduledStart).toLocaleString()
|
|
3009
|
+
: 'Schedule Start';
|
|
3004
3010
|
cancelScheduleBtn.hidden = !raceState.scheduledStart || !!raceState.startTime;
|
|
3005
3011
|
|
|
3006
3012
|
callOffInput.type = raceState.multiDay ? 'datetime-local' : 'time';
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const { test, describe } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { distanceNm, midpoint, segmentsIntersect, validateCoordPoint, validateLine } = require('../index.js').internal;
|
|
4
|
+
|
|
5
|
+
describe('distanceNm', () => {
|
|
6
|
+
test('is zero for the same point', () => {
|
|
7
|
+
assert.equal(distanceNm({ lat: 59.7, lon: 10.5 }, { lat: 59.7, lon: 10.5 }), 0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('one degree of latitude is ~60nm', () => {
|
|
11
|
+
const nm = distanceNm({ lat: 59, lon: 10 }, { lat: 60, lon: 10 });
|
|
12
|
+
assert.ok(Math.abs(nm - 60) < 1, `expected ~60nm, got ${nm}`);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('is symmetric', () => {
|
|
16
|
+
const a = { lat: 59.72, lon: 10.54 };
|
|
17
|
+
const b = { lat: 59.68, lon: 10.56 };
|
|
18
|
+
assert.equal(distanceNm(a, b), distanceNm(b, a));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('midpoint', () => {
|
|
23
|
+
test('averages lat/lon', () => {
|
|
24
|
+
const m = midpoint({ lat: 59.7, lon: 10.5 }, { lat: 59.8, lon: 10.7 });
|
|
25
|
+
assert.equal(m.lat, 59.75);
|
|
26
|
+
assert.equal(m.lon, 10.6);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('segmentsIntersect', () => {
|
|
31
|
+
// A start line running roughly east-west, matching the shape of a real
|
|
32
|
+
// one in this app (pin + committee boat).
|
|
33
|
+
const startLineA = { lat: 59.723876, lon: 10.543537 };
|
|
34
|
+
const startLineB = { lat: 59.7257498, lon: 10.5352725 };
|
|
35
|
+
|
|
36
|
+
test('detects a genuine crossing from one side to the other', () => {
|
|
37
|
+
const before = { lat: 59.7242279, lon: 10.5392721 };
|
|
38
|
+
const after = { lat: 59.7253979, lon: 10.5395375 };
|
|
39
|
+
assert.equal(segmentsIntersect(before, after, startLineA, startLineB), true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('does not fire for a track that stays on one side', () => {
|
|
43
|
+
const p1 = { lat: 59.72, lon: 10.53 };
|
|
44
|
+
const p2 = { lat: 59.721, lon: 10.531 };
|
|
45
|
+
assert.equal(segmentsIntersect(p1, p2, startLineA, startLineB), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('does not fire for a track that passes near but short of the line', () => {
|
|
49
|
+
// Heads toward the line's midpoint but stops well before reaching it.
|
|
50
|
+
const near = midpoint(startLineA, startLineB);
|
|
51
|
+
const short = { lat: (startLineA.lat + near.lat) / 2 - 0.01, lon: (startLineA.lon + near.lon) / 2 };
|
|
52
|
+
const shorter = { lat: short.lat + 0.0001, lon: short.lon + 0.0001 };
|
|
53
|
+
assert.equal(segmentsIntersect(short, shorter, startLineA, startLineB), false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('is symmetric in which segment is "the line" vs "the track"', () => {
|
|
57
|
+
const before = { lat: 59.7242279, lon: 10.5392721 };
|
|
58
|
+
const after = { lat: 59.7253979, lon: 10.5395375 };
|
|
59
|
+
assert.equal(
|
|
60
|
+
segmentsIntersect(before, after, startLineA, startLineB),
|
|
61
|
+
segmentsIntersect(startLineA, startLineB, before, after)
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('validateCoordPoint', () => {
|
|
67
|
+
test('accepts a plain lat/lon', () => {
|
|
68
|
+
assert.deepEqual(validateCoordPoint({ lat: 59.7, lon: 10.5 }), { lat: 59.7, lon: 10.5 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('keeps a trimmed name when present', () => {
|
|
72
|
+
assert.deepEqual(validateCoordPoint({ lat: 1, lon: 2, name: ' Mark 1 ' }), { lat: 1, lon: 2, name: 'Mark 1' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('omits an empty/whitespace-only name rather than keeping ""', () => {
|
|
76
|
+
const out = validateCoordPoint({ lat: 1, lon: 2, name: ' ' });
|
|
77
|
+
assert.ok(!('name' in out));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const badPoints = [null, undefined, {}, { lat: 'x', lon: 2 }, { lat: 1, lon: NaN }, { lat: 91, lon: 0 }, { lat: -91, lon: 0 }, { lat: 0, lon: 181 }, { lat: 0, lon: -181 }];
|
|
81
|
+
for (const bad of badPoints) {
|
|
82
|
+
test(`rejects ${JSON.stringify(bad)}`, () => {
|
|
83
|
+
assert.equal(validateCoordPoint(bad), null);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('validateLine', () => {
|
|
89
|
+
test('null passes through as null (an explicitly cleared line)', () => {
|
|
90
|
+
assert.equal(validateLine(null), null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('validates both ends of a proper pair', () => {
|
|
94
|
+
const out = validateLine([{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }]);
|
|
95
|
+
assert.deepEqual(out, [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('undefined (invalid) for the wrong array length', () => {
|
|
99
|
+
assert.equal(validateLine([{ lat: 1, lon: 2 }]), undefined);
|
|
100
|
+
assert.equal(validateLine([{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 6 }]), undefined);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('undefined (invalid) when either end fails validation', () => {
|
|
104
|
+
assert.equal(validateLine([{ lat: 1, lon: 2 }, { lat: 'x', lon: 4 }]), undefined);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('undefined (invalid) for a non-array', () => {
|
|
108
|
+
assert.equal(validateLine('not an array'), undefined);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
const { test, describe } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const {
|
|
4
|
+
parseCsvText,
|
|
5
|
+
parseNorwegianNumber,
|
|
6
|
+
parseHandicapSheet,
|
|
7
|
+
stripHtmlToText,
|
|
8
|
+
parseKtkHtml,
|
|
9
|
+
extractGoogleSheetId,
|
|
10
|
+
parseManage2SailEventPage,
|
|
11
|
+
parseManage2SailEntries,
|
|
12
|
+
findHandicapSystem,
|
|
13
|
+
resolveHandicapSystem
|
|
14
|
+
} = require('../index.js').internal;
|
|
15
|
+
|
|
16
|
+
describe('parseCsvText', () => {
|
|
17
|
+
test('splits plain comma-separated rows', () => {
|
|
18
|
+
assert.deepEqual(parseCsvText('a,b,c\n1,2,3'), [
|
|
19
|
+
['a', 'b', 'c'],
|
|
20
|
+
['1', '2', '3']
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('keeps a comma inside a quoted field as part of that field', () => {
|
|
25
|
+
assert.deepEqual(parseCsvText('name,value\nSolli,"1,050"'), [
|
|
26
|
+
['name', 'value'],
|
|
27
|
+
['Solli', '1,050']
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('un-escapes a doubled quote inside a quoted field', () => {
|
|
32
|
+
assert.deepEqual(parseCsvText('a\n"say ""hi"""'), [['a'], ['say "hi"']]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('drops \\r before \\n but keeps rows intact', () => {
|
|
36
|
+
assert.deepEqual(parseCsvText('a,b\r\n1,2\r\n'), [
|
|
37
|
+
['a', 'b'],
|
|
38
|
+
['1', '2']
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('parseNorwegianNumber', () => {
|
|
44
|
+
test('treats a comma as the decimal separator', () => {
|
|
45
|
+
assert.equal(parseNorwegianNumber('1,05'), 1.05);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('still accepts a plain dot', () => {
|
|
49
|
+
assert.equal(parseNorwegianNumber('1.05'), 1.05);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('null for null/undefined/blank input', () => {
|
|
53
|
+
assert.equal(parseNorwegianNumber(null), null);
|
|
54
|
+
assert.equal(parseNorwegianNumber(undefined), null);
|
|
55
|
+
assert.equal(parseNorwegianNumber(' '), null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('null for non-numeric text', () => {
|
|
59
|
+
assert.equal(parseNorwegianNumber('n/a'), null);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('parseHandicapSheet', () => {
|
|
64
|
+
const csv = [
|
|
65
|
+
'SSCA VET-tall 2026',
|
|
66
|
+
',,,,,',
|
|
67
|
+
'Båt,Gyldig,Seilføring,VET 1,Klasse,Eier',
|
|
68
|
+
'RS 21 Solli,OK,Uten spinnaker,"1,050",IRC,Bakke',
|
|
69
|
+
',,,,,', // blank name — must be skipped
|
|
70
|
+
'No Vets,OK,,,IRC,Nobody' // no parseable VET value — must be skipped
|
|
71
|
+
].join('\n');
|
|
72
|
+
|
|
73
|
+
test('finds the header row and reads boats after it', () => {
|
|
74
|
+
const boats = parseHandicapSheet(csv);
|
|
75
|
+
assert.equal(boats.length, 1);
|
|
76
|
+
assert.equal(boats[0].name, 'RS 21 Solli');
|
|
77
|
+
assert.equal(boats[0].validity, 'OK');
|
|
78
|
+
assert.equal(boats[0].class, 'IRC');
|
|
79
|
+
assert.equal(boats[0].owner, 'Bakke');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('reads the VET value and its label from the column before it', () => {
|
|
83
|
+
const boats = parseHandicapSheet(csv);
|
|
84
|
+
assert.deepEqual(boats[0].vets, [{ label: 'Uten spinnaker', value: 1.05 }]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('throws when the header row cannot be found', () => {
|
|
88
|
+
assert.throws(() => parseHandicapSheet('just,some,junk\n1,2,3'), /header row/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('falls back to a generic "VET n" label when the label column is blank', () => {
|
|
92
|
+
const noLabelCsv = ['Båt,Gyldig,,VET 1,Klasse', 'Solveig,OK,,"1,20",IRC'].join('\n');
|
|
93
|
+
const boats = parseHandicapSheet(noLabelCsv);
|
|
94
|
+
assert.equal(boats[0].vets[0].label, 'VET 1');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('stripHtmlToText', () => {
|
|
99
|
+
test('removes tags and decodes the entities this app actually emits', () => {
|
|
100
|
+
assert.equal(stripHtmlToText('<td>Solveig II & co</td>'), 'Solveig II & co');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('trims surrounding whitespace', () => {
|
|
104
|
+
assert.equal(stripHtmlToText(' <b>hi</b> '), 'hi');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('parseKtkHtml', () => {
|
|
109
|
+
test('throws when there is no table on the page', () => {
|
|
110
|
+
assert.throws(() => parseKtkHtml('<html><body>no table here</body></html>'), /KLR table/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('parses one boat, one KLR value, plain "KLR" label', () => {
|
|
114
|
+
const html = '<table><tr><td>Boat</td><td>KLR</td></tr><tr><td>Njord</td><td>166</td></tr></table>';
|
|
115
|
+
const boats = parseKtkHtml(html);
|
|
116
|
+
assert.equal(boats.length, 1);
|
|
117
|
+
assert.equal(boats[0].name, 'Njord');
|
|
118
|
+
assert.deepEqual(boats[0].vets, [{ label: 'KLR', value: 1.66, raw: 166 }]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('groups multiple rows for the same boat (case-insensitively) and numbers the labels', () => {
|
|
122
|
+
const html =
|
|
123
|
+
'<table>' +
|
|
124
|
+
'<tr><td>Boat</td><td>KLR</td></tr>' +
|
|
125
|
+
'<tr><td>Solveig</td><td>166</td></tr>' +
|
|
126
|
+
'<tr><td>solveig</td><td>170</td></tr>' +
|
|
127
|
+
'</table>';
|
|
128
|
+
const boats = parseKtkHtml(html);
|
|
129
|
+
assert.equal(boats.length, 1);
|
|
130
|
+
assert.deepEqual(boats[0].vets, [
|
|
131
|
+
{ label: 'KLR 1', value: 1.66, raw: 166 },
|
|
132
|
+
{ label: 'KLR 2', value: 1.7, raw: 170 }
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('skips a row whose second cell is not a usable positive number', () => {
|
|
137
|
+
const html = '<table><tr><td>Boat</td><td>KLR</td></tr><tr><td>Njord</td><td>n/a</td></tr></table>';
|
|
138
|
+
assert.deepEqual(parseKtkHtml(html), []);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('extractGoogleSheetId', () => {
|
|
143
|
+
test('pulls the id out of a full sheets URL', () => {
|
|
144
|
+
assert.equal(
|
|
145
|
+
extractGoogleSheetId('https://docs.google.com/spreadsheets/d/1AbC-xyz_123/edit#gid=0'),
|
|
146
|
+
'1AbC-xyz_123'
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('null when no sheets link is present', () => {
|
|
151
|
+
assert.equal(extractGoogleSheetId('nothing relevant here'), null);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('parseManage2SailEventPage', () => {
|
|
156
|
+
const eventId = '12345678-1234-1234-1234-123456789012';
|
|
157
|
+
const html = `
|
|
158
|
+
<a href="/en-US/support/EventIssue?eventId=${eventId}">Report an issue</a>
|
|
159
|
+
<script>window.boostrapedResourceData = {"Regatta":[{"Id":"r1","Name":"ORC A"},{"Id":"r2","Name":"ORC B"}]};</script>
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
test('extracts the event id and class list', () => {
|
|
163
|
+
const result = parseManage2SailEventPage(html);
|
|
164
|
+
assert.equal(result.eventId, eventId);
|
|
165
|
+
assert.deepEqual(result.classes, [
|
|
166
|
+
{ id: 'r1', name: 'ORC A' },
|
|
167
|
+
{ id: 'r2', name: 'ORC B' }
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('throws when the event id is missing', () => {
|
|
172
|
+
assert.throws(() => parseManage2SailEventPage('<html>nothing here</html>'), /event's id/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('throws when the resource data script is missing', () => {
|
|
176
|
+
assert.throws(
|
|
177
|
+
() => parseManage2SailEventPage(`<a href="/EventIssue?eventId=${eventId}"></a>`),
|
|
178
|
+
/class data/
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('parseManage2SailEntries', () => {
|
|
184
|
+
test('picks a display name from the first available field, in priority order', () => {
|
|
185
|
+
const json = {
|
|
186
|
+
HcpName: 'YS',
|
|
187
|
+
Entries: [
|
|
188
|
+
{ BoatName: 'Njord', SailNumber: 'NOR 1', Hcp: '95' },
|
|
189
|
+
{ SailNumber: 'NOR 2', TeamName: 'Team Two', Hcp: '100' },
|
|
190
|
+
{ TeamName: 'Team Three', SkipperName: 'Skipper Three', Hcp: '105' },
|
|
191
|
+
{ SkipperName: 'Skipper Four', Hcp: '110' }
|
|
192
|
+
]
|
|
193
|
+
};
|
|
194
|
+
const result = parseManage2SailEntries(json);
|
|
195
|
+
assert.deepEqual(result.entries.map((e) => e.name), ['Njord', 'NOR 2', 'Team Three', 'Skipper Four']);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('accepts a comma decimal in Hcp', () => {
|
|
199
|
+
const result = parseManage2SailEntries({ Entries: [{ BoatName: 'Njord', Hcp: '95,5' }] });
|
|
200
|
+
assert.equal(result.entries[0].hcp, 95.5);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('skips entries with no usable name or a non-positive/invalid Hcp', () => {
|
|
204
|
+
const json = {
|
|
205
|
+
Entries: [
|
|
206
|
+
{ Hcp: '95' }, // no name at all
|
|
207
|
+
{ BoatName: 'Njord', Hcp: '0' }, // zero
|
|
208
|
+
{ BoatName: 'Njord', Hcp: 'n/a' } // unparseable
|
|
209
|
+
]
|
|
210
|
+
};
|
|
211
|
+
const result = parseManage2SailEntries(json);
|
|
212
|
+
assert.equal(result.entries.length, 0);
|
|
213
|
+
assert.equal(result.skipped, 3);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('findHandicapSystem / resolveHandicapSystem', () => {
|
|
218
|
+
test('findHandicapSystem looks up by key', () => {
|
|
219
|
+
assert.equal(findHandicapSystem('py').key, 'py');
|
|
220
|
+
assert.equal(findHandicapSystem('nope'), null);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('resolves YS on DSV-scale values (40-250) to "ys"', () => {
|
|
224
|
+
assert.deepEqual(resolveHandicapSystem('YS', [95, 100, 110]), { resolved: 'ys' });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('resolves YS on RYA-scale values (400-3000) to "py"', () => {
|
|
228
|
+
assert.deepEqual(resolveHandicapSystem('YS', [950, 1000, 1100]), { resolved: 'py' });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('reports candidates when YS values fit neither scale unambiguously', () => {
|
|
232
|
+
const result = resolveHandicapSystem('YS', [300]);
|
|
233
|
+
assert.equal(result.resolved, null);
|
|
234
|
+
assert.deepEqual(result.candidates, ['ys', 'py', 'tcf']);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('PY/PN always resolves to "py" regardless of values', () => {
|
|
238
|
+
assert.deepEqual(resolveHandicapSystem('PY', []), { resolved: 'py' });
|
|
239
|
+
assert.deepEqual(resolveHandicapSystem('PN', [1]), { resolved: 'py' });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('an unrecognized/blank name defaults to "tcf"', () => {
|
|
243
|
+
assert.deepEqual(resolveHandicapSystem('', []), { resolved: 'tcf' });
|
|
244
|
+
assert.deepEqual(resolveHandicapSystem('ORC', [1.05]), { resolved: 'tcf' });
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
const { test, describe, before, after } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const pluginFactory = require('../index.js');
|
|
8
|
+
|
|
9
|
+
// ---- Minimal SignalK app + Express-router mocks --------------------------
|
|
10
|
+
// Just enough surface for the plugin to run its full HTTP-facing behavior
|
|
11
|
+
// without a real SignalK server: a scratch data directory for race-state.json,
|
|
12
|
+
// and no-op stubs for everything optional (resourcesApi, activateRoute,
|
|
13
|
+
// getSelfPath/getPath) so those best-effort code paths cleanly no-op, the
|
|
14
|
+
// same as they do on a real server with no resources provider configured.
|
|
15
|
+
|
|
16
|
+
function makeApp(dataDir) {
|
|
17
|
+
return {
|
|
18
|
+
getDataDirPath: () => dataDir,
|
|
19
|
+
setPluginStatus: () => {},
|
|
20
|
+
debug: () => {},
|
|
21
|
+
error: () => {}
|
|
22
|
+
// getSelfPath / getPath / resourcesApi / activateRoute deliberately
|
|
23
|
+
// absent — every call site already guards for that.
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeRouter() {
|
|
28
|
+
const routes = [];
|
|
29
|
+
const register = (method) => (routePath, handler) => {
|
|
30
|
+
const keys = [];
|
|
31
|
+
const patternSrc = routePath.replace(/:[A-Za-z]+/g, (m) => {
|
|
32
|
+
keys.push(m.slice(1));
|
|
33
|
+
return '([^/]+)';
|
|
34
|
+
});
|
|
35
|
+
routes.push({ method, pattern: new RegExp(`^${patternSrc}$`), keys, handler });
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
get: register('GET'),
|
|
39
|
+
post: register('POST'),
|
|
40
|
+
put: register('PUT'),
|
|
41
|
+
delete: register('DELETE'),
|
|
42
|
+
_routes: routes
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Invokes a registered route handler as Express would, minus anything this
|
|
47
|
+
// plugin's handlers never rely on (headers, streaming, middleware order).
|
|
48
|
+
function call(router, method, routePath, { body, query } = {}) {
|
|
49
|
+
const [pathname, qs] = routePath.split('?');
|
|
50
|
+
const route = router._routes.find((r) => r.method === method && r.pattern.test(pathname));
|
|
51
|
+
if (!route) throw new Error(`No route registered for ${method} ${routePath}`);
|
|
52
|
+
const match = route.pattern.exec(pathname);
|
|
53
|
+
const params = {};
|
|
54
|
+
route.keys.forEach((k, i) => {
|
|
55
|
+
params[k] = decodeURIComponent(match[i + 1]);
|
|
56
|
+
});
|
|
57
|
+
const parsedQuery = query || Object.fromEntries(new URLSearchParams(qs || ''));
|
|
58
|
+
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let statusCode = 200;
|
|
61
|
+
let settled = false;
|
|
62
|
+
const settle = (payload) => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
settled = true;
|
|
65
|
+
resolve({ status: statusCode, body: payload });
|
|
66
|
+
};
|
|
67
|
+
const res = {
|
|
68
|
+
status(code) {
|
|
69
|
+
statusCode = code;
|
|
70
|
+
return this;
|
|
71
|
+
},
|
|
72
|
+
json: settle,
|
|
73
|
+
send: settle
|
|
74
|
+
};
|
|
75
|
+
const req = { params, query: parsedQuery, body: body === undefined ? {} : body };
|
|
76
|
+
try {
|
|
77
|
+
const result = route.handler(req, res);
|
|
78
|
+
if (result && typeof result.catch === 'function') {
|
|
79
|
+
result.catch((e) => settle({ error: e.message }));
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
settle({ error: e.message });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('plugin shape', () => {
|
|
88
|
+
test('exposes the standard SignalK plugin interface', () => {
|
|
89
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'race-control-test-'));
|
|
90
|
+
const plugin = pluginFactory(makeApp(dataDir));
|
|
91
|
+
assert.equal(plugin.id, 'race-control');
|
|
92
|
+
assert.equal(plugin.name, 'Race Control');
|
|
93
|
+
assert.equal(typeof plugin.schema, 'object');
|
|
94
|
+
assert.equal(typeof plugin.start, 'function');
|
|
95
|
+
assert.equal(typeof plugin.stop, 'function');
|
|
96
|
+
assert.equal(typeof plugin.registerWithRouter, 'function');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('REST API — full race lifecycle', () => {
|
|
101
|
+
let plugin;
|
|
102
|
+
let router;
|
|
103
|
+
let raceId;
|
|
104
|
+
|
|
105
|
+
before(() => {
|
|
106
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'race-control-test-'));
|
|
107
|
+
plugin = pluginFactory(makeApp(dataDir));
|
|
108
|
+
plugin.start({});
|
|
109
|
+
router = makeRouter();
|
|
110
|
+
plugin.registerWithRouter(router);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
after(() => {
|
|
114
|
+
plugin.stop();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('POST /races rejects a blank name', async () => {
|
|
118
|
+
const res = await call(router, 'POST', '/races', { body: { name: ' ' } });
|
|
119
|
+
assert.equal(res.status, 400);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('POST /races creates a race and makes it current', async () => {
|
|
123
|
+
const res = await call(router, 'POST', '/races', { body: { name: 'Onsdagsseilas' } });
|
|
124
|
+
assert.equal(res.status, 200);
|
|
125
|
+
assert.equal(res.body.race.name, 'Onsdagsseilas');
|
|
126
|
+
assert.equal(res.body.currentRaceId, res.body.race.id);
|
|
127
|
+
assert.deepEqual(res.body.race.course, { startLine: null, marks: [], finishLine: null });
|
|
128
|
+
raceId = res.body.race.id;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('GET /races lists the created race', async () => {
|
|
132
|
+
const res = await call(router, 'GET', '/races');
|
|
133
|
+
assert.equal(res.status, 200);
|
|
134
|
+
assert.ok(res.body.races.some((r) => r.id === raceId));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('GET /races/:id 404s for an unknown id', async () => {
|
|
138
|
+
const res = await call(router, 'GET', '/races/does-not-exist');
|
|
139
|
+
assert.equal(res.status, 404);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('GET /races/:id returns the full race', async () => {
|
|
143
|
+
const res = await call(router, 'GET', `/races/${raceId}`);
|
|
144
|
+
assert.equal(res.status, 200);
|
|
145
|
+
assert.equal(res.body.id, raceId);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('PUT /races/:id/course rejects an invalid mark', async () => {
|
|
149
|
+
const res = await call(router, 'PUT', `/races/${raceId}/course`, {
|
|
150
|
+
body: { marks: [{ lat: 'not a number', lon: 10.5 }] }
|
|
151
|
+
});
|
|
152
|
+
assert.equal(res.status, 400);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('PUT /races/:id/course accepts a valid start/finish/marks', async () => {
|
|
156
|
+
const res = await call(router, 'PUT', `/races/${raceId}/course`, {
|
|
157
|
+
body: {
|
|
158
|
+
startLine: [{ lat: 59.723876, lon: 10.543537, name: 'Pin' }, { lat: 59.7257498, lon: 10.5352725, name: 'Committee' }],
|
|
159
|
+
finishLine: [{ lat: 59.681879, lon: 10.5640363 }, { lat: 59.6769532, lon: 10.5623646 }],
|
|
160
|
+
marks: [{ id: 'm1', lat: 59.7, lon: 10.55, name: 'Mark 1' }]
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
assert.equal(res.status, 200);
|
|
164
|
+
assert.equal(res.body.course.marks.length, 1);
|
|
165
|
+
assert.equal(res.body.course.startLine[0].name, 'Pin');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
let boatId;
|
|
169
|
+
let classId;
|
|
170
|
+
|
|
171
|
+
test('POST /races/:id/boats rejects a blank name', async () => {
|
|
172
|
+
const res = await call(router, 'POST', `/races/${raceId}/boats`, { body: { name: '' } });
|
|
173
|
+
assert.equal(res.status, 400);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('POST /races/:id/boats adds a boat', async () => {
|
|
177
|
+
const res = await call(router, 'POST', `/races/${raceId}/boats`, { body: { name: 'RS 21 Solli' } });
|
|
178
|
+
assert.equal(res.status, 200);
|
|
179
|
+
assert.equal(res.body.name, 'RS 21 Solli');
|
|
180
|
+
assert.equal(res.body.finishTime, null);
|
|
181
|
+
assert.equal(res.body.classId, null);
|
|
182
|
+
boatId = res.body.id;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('POST /races/:id/classes adds a class', async () => {
|
|
186
|
+
const res = await call(router, 'POST', `/races/${raceId}/classes`, { body: { name: 'Cruisers' } });
|
|
187
|
+
assert.equal(res.status, 200);
|
|
188
|
+
assert.equal(res.body.classes.length, 1);
|
|
189
|
+
classId = res.body.classes[0].id;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('PUT boat class rejects an unknown class id', async () => {
|
|
193
|
+
const res = await call(router, 'PUT', `/races/${raceId}/boats/${boatId}/class`, { body: { classId: 'nope' } });
|
|
194
|
+
assert.equal(res.status, 400);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('PUT boat class assigns a real class', async () => {
|
|
198
|
+
const res = await call(router, 'PUT', `/races/${raceId}/boats/${boatId}/class`, { body: { classId } });
|
|
199
|
+
assert.equal(res.status, 200);
|
|
200
|
+
assert.equal(res.body.classId, classId);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("PUT class startTime sets the class's staggered start", async () => {
|
|
204
|
+
const res = await call(router, 'PUT', `/races/${raceId}/classes/${classId}/startTime`, { body: { startTime: 5000 } });
|
|
205
|
+
assert.equal(res.status, 200);
|
|
206
|
+
assert.equal(res.body.classes[0].startTime, 5000);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('POST /races/:id/start sets startTime and resets courseActivated', async () => {
|
|
210
|
+
const res = await call(router, 'POST', `/races/${raceId}/start`);
|
|
211
|
+
assert.equal(res.status, 200);
|
|
212
|
+
assert.ok(res.body.startTime > 0);
|
|
213
|
+
assert.equal(res.body.courseActivated, false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('PUT boat finishTime rejects a non-numeric/zero value', async () => {
|
|
217
|
+
const res = await call(router, 'PUT', `/races/${raceId}/boats/${boatId}/finishTime`, { body: { finishTime: 0 } });
|
|
218
|
+
assert.equal(res.status, 400);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('PUT boat finishTime records a finish and clears dnf/dns', async () => {
|
|
222
|
+
const res = await call(router, 'PUT', `/races/${raceId}/boats/${boatId}/finishTime`, { body: { finishTime: Date.now() } });
|
|
223
|
+
assert.equal(res.status, 200);
|
|
224
|
+
assert.ok(res.body.finishTime > 0);
|
|
225
|
+
assert.equal(res.body.dnf, false);
|
|
226
|
+
assert.equal(res.body.dns, false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('PUT boat dnf clears the finish time it just set', async () => {
|
|
230
|
+
const res = await call(router, 'PUT', `/races/${raceId}/boats/${boatId}/dnf`, { body: { dnf: true } });
|
|
231
|
+
assert.equal(res.status, 200);
|
|
232
|
+
assert.equal(res.body.dnf, true);
|
|
233
|
+
assert.equal(res.body.finishTime, null);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('PUT boat dns clears dnf (mutually exclusive states)', async () => {
|
|
237
|
+
const res = await call(router, 'PUT', `/races/${raceId}/boats/${boatId}/dns`, { body: { dns: true } });
|
|
238
|
+
assert.equal(res.status, 200);
|
|
239
|
+
assert.equal(res.body.dns, true);
|
|
240
|
+
assert.equal(res.body.dnf, false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('POST /races/:id/reset clears start time, class start times, and boat race state', async () => {
|
|
244
|
+
const res = await call(router, 'POST', `/races/${raceId}/reset`);
|
|
245
|
+
assert.equal(res.status, 200);
|
|
246
|
+
assert.equal(res.body.startTime, null);
|
|
247
|
+
assert.equal(res.body.classes[0].startTime, null);
|
|
248
|
+
const boat = Object.values(res.body.boats)[0];
|
|
249
|
+
assert.equal(boat.dns, false);
|
|
250
|
+
assert.equal(boat.dnf, false);
|
|
251
|
+
assert.equal(boat.finishTime, null);
|
|
252
|
+
// Boats and their class assignment survive a reset — only race/start
|
|
253
|
+
// state is cleared.
|
|
254
|
+
assert.equal(boat.classId, classId);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('DELETE boat removes it', async () => {
|
|
258
|
+
const res = await call(router, 'DELETE', `/races/${raceId}/boats/${boatId}`);
|
|
259
|
+
assert.equal(res.status, 200);
|
|
260
|
+
const check = await call(router, 'GET', `/races/${raceId}`);
|
|
261
|
+
assert.equal(Object.keys(check.body.boats).length, 0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('DELETE class 404s for an unknown class', async () => {
|
|
265
|
+
const res = await call(router, 'DELETE', `/races/${raceId}/classes/nope`);
|
|
266
|
+
assert.equal(res.status, 404);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('DELETE class removes it', async () => {
|
|
270
|
+
const res = await call(router, 'DELETE', `/races/${raceId}/classes/${classId}`);
|
|
271
|
+
assert.equal(res.status, 200);
|
|
272
|
+
assert.equal(res.body.classes.length, 0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('DELETE /races/:id removes the race entirely', async () => {
|
|
276
|
+
const res = await call(router, 'DELETE', `/races/${raceId}`);
|
|
277
|
+
assert.equal(res.status, 200);
|
|
278
|
+
const check = await call(router, 'GET', `/races/${raceId}`);
|
|
279
|
+
assert.equal(check.status, 404);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('/overpass proxy — falls back across mirrors', () => {
|
|
284
|
+
let plugin;
|
|
285
|
+
let router;
|
|
286
|
+
let originalFetch;
|
|
287
|
+
|
|
288
|
+
before(() => {
|
|
289
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'race-control-test-'));
|
|
290
|
+
plugin = pluginFactory(makeApp(dataDir));
|
|
291
|
+
plugin.start({});
|
|
292
|
+
router = makeRouter();
|
|
293
|
+
plugin.registerWithRouter(router);
|
|
294
|
+
originalFetch = global.fetch;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
after(() => {
|
|
298
|
+
plugin.stop();
|
|
299
|
+
global.fetch = originalFetch;
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('400s when the query is missing', async () => {
|
|
303
|
+
const res = await call(router, 'GET', '/overpass');
|
|
304
|
+
assert.equal(res.status, 400);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('falls through a failing mirror to the next and returns its data', async () => {
|
|
308
|
+
let calls = 0;
|
|
309
|
+
global.fetch = async () => {
|
|
310
|
+
calls++;
|
|
311
|
+
if (calls === 1) throw new Error('mirror unreachable');
|
|
312
|
+
return { ok: true, json: async () => ({ elements: [{ id: 1, tags: { 'seamark:name': 'Test Mark' } }] }) };
|
|
313
|
+
};
|
|
314
|
+
const res = await call(router, 'GET', '/overpass?data=%5Bout%3Ajson%5D');
|
|
315
|
+
assert.equal(res.status, 200);
|
|
316
|
+
assert.equal(res.body.elements[0].tags['seamark:name'], 'Test Mark');
|
|
317
|
+
assert.ok(calls >= 2, 'expected at least 2 mirror attempts after the first failure');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('returns an empty element list when every mirror fails', async () => {
|
|
321
|
+
global.fetch = async () => {
|
|
322
|
+
throw new Error('unreachable');
|
|
323
|
+
};
|
|
324
|
+
const res = await call(router, 'GET', '/overpass?data=%5Bout%3Ajson%5D');
|
|
325
|
+
assert.equal(res.status, 200);
|
|
326
|
+
assert.deepEqual(res.body.elements, []);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const { test, describe } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { ensureRaceShape, effectiveStartTime, findClass, raceSummary, emptyCourse } = require('../index.js').internal;
|
|
4
|
+
|
|
5
|
+
function bareRace(overrides) {
|
|
6
|
+
return Object.assign(
|
|
7
|
+
{
|
|
8
|
+
id: 'r1',
|
|
9
|
+
name: 'Test Race',
|
|
10
|
+
boats: {}
|
|
11
|
+
},
|
|
12
|
+
overrides
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('emptyCourse', () => {
|
|
17
|
+
test('has null lines and no marks', () => {
|
|
18
|
+
assert.deepEqual(emptyCourse(), { startLine: null, marks: [], finishLine: null });
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('ensureRaceShape', () => {
|
|
23
|
+
test('backfills every optional field on a bare/legacy race', () => {
|
|
24
|
+
const race = bareRace();
|
|
25
|
+
ensureRaceShape(race);
|
|
26
|
+
assert.deepEqual(race.course, { startLine: null, marks: [], finishLine: null });
|
|
27
|
+
assert.equal(race.selfBoatId, null);
|
|
28
|
+
assert.equal(race.stopTime, null);
|
|
29
|
+
assert.equal(race.scheduledCallOff, null);
|
|
30
|
+
assert.equal(race.multiDay, false);
|
|
31
|
+
assert.equal(race.courseActivated, false);
|
|
32
|
+
assert.deepEqual(race.classes, []);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('leaves already-present fields alone', () => {
|
|
36
|
+
const race = bareRace({ multiDay: true, courseActivated: true, selfBoatId: 'b1' });
|
|
37
|
+
ensureRaceShape(race);
|
|
38
|
+
assert.equal(race.multiDay, true);
|
|
39
|
+
assert.equal(race.courseActivated, true);
|
|
40
|
+
assert.equal(race.selfBoatId, 'b1');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('backfills every optional boat field', () => {
|
|
44
|
+
const race = bareRace({ boats: { b1: { id: 'b1', name: 'Solli' } } });
|
|
45
|
+
ensureRaceShape(race);
|
|
46
|
+
const b = race.boats.b1;
|
|
47
|
+
assert.deepEqual(b.track, []);
|
|
48
|
+
assert.equal(b.dnf, false);
|
|
49
|
+
assert.equal(b.dns, false);
|
|
50
|
+
assert.equal(b.dnfPosition, null);
|
|
51
|
+
assert.equal(b.startTime, null);
|
|
52
|
+
assert.equal(b.sailNumber, null);
|
|
53
|
+
assert.equal(b.classId, null);
|
|
54
|
+
assert.deepEqual(b.markTimes, {});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('backfills a startTime on every class', () => {
|
|
58
|
+
const race = bareRace({ classes: [{ id: 'c1', name: 'Cruisers' }] });
|
|
59
|
+
ensureRaceShape(race);
|
|
60
|
+
assert.equal(race.classes[0].startTime, null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('clears a boat classId that no longer matches any class (deleted class)', () => {
|
|
64
|
+
const race = bareRace({
|
|
65
|
+
classes: [{ id: 'c1', name: 'Cruisers', startTime: null }],
|
|
66
|
+
boats: {
|
|
67
|
+
b1: { id: 'b1', name: 'Stays', classId: 'c1' },
|
|
68
|
+
b2: { id: 'b2', name: 'Stale', classId: 'c-deleted' }
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
ensureRaceShape(race);
|
|
72
|
+
assert.equal(race.boats.b1.classId, 'c1');
|
|
73
|
+
assert.equal(race.boats.b2.classId, null);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('is idempotent — running it twice changes nothing further', () => {
|
|
77
|
+
const race = bareRace({ boats: { b1: { id: 'b1', name: 'Solli' } } });
|
|
78
|
+
ensureRaceShape(race);
|
|
79
|
+
const once = JSON.stringify(race);
|
|
80
|
+
ensureRaceShape(race);
|
|
81
|
+
assert.equal(JSON.stringify(race), once);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('findClass', () => {
|
|
86
|
+
test('finds a class by id', () => {
|
|
87
|
+
const race = bareRace({ classes: [{ id: 'c1', name: 'Cruisers' }, { id: 'c2', name: 'Racers' }] });
|
|
88
|
+
assert.equal(findClass(race, 'c2').name, 'Racers');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('returns null for an unknown id', () => {
|
|
92
|
+
const race = bareRace({ classes: [{ id: 'c1', name: 'Cruisers' }] });
|
|
93
|
+
assert.equal(findClass(race, 'nope'), null);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('returns null when the race has no classes array at all', () => {
|
|
97
|
+
assert.equal(findClass(bareRace(), 'c1'), null);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('effectiveStartTime — priority chain: boat > class > race', () => {
|
|
102
|
+
test("falls back to the race's own start time with no boat or class override", () => {
|
|
103
|
+
const race = bareRace({ startTime: 1000, classes: [] });
|
|
104
|
+
const boat = { classId: null, startTime: null };
|
|
105
|
+
assert.equal(effectiveStartTime(race, boat), 1000);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("falls back to the boat's class start time over the race's", () => {
|
|
109
|
+
const race = bareRace({ startTime: 1000, classes: [{ id: 'c1', name: 'Cruisers', startTime: 2000 }] });
|
|
110
|
+
const boat = { classId: 'c1', startTime: null };
|
|
111
|
+
assert.equal(effectiveStartTime(race, boat), 2000);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a boat's own start time wins over its class's", () => {
|
|
115
|
+
const race = bareRace({ startTime: 1000, classes: [{ id: 'c1', name: 'Cruisers', startTime: 2000 }] });
|
|
116
|
+
const boat = { classId: 'c1', startTime: 3000 };
|
|
117
|
+
assert.equal(effectiveStartTime(race, boat), 3000);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a boat's own start time wins even with no class at all", () => {
|
|
121
|
+
const race = bareRace({ startTime: 1000, classes: [] });
|
|
122
|
+
const boat = { classId: null, startTime: 3000 };
|
|
123
|
+
assert.equal(effectiveStartTime(race, boat), 3000);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('falls through to the race start when the boat is in a class with no start time of its own', () => {
|
|
127
|
+
const race = bareRace({ startTime: 1000, classes: [{ id: 'c1', name: 'Cruisers', startTime: null }] });
|
|
128
|
+
const boat = { classId: 'c1', startTime: null };
|
|
129
|
+
assert.equal(effectiveStartTime(race, boat), 1000);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('raceSummary', () => {
|
|
134
|
+
test('counts boats, finished, and DNF correctly', () => {
|
|
135
|
+
const race = bareRace({
|
|
136
|
+
createdAt: 42,
|
|
137
|
+
scheduledStart: null,
|
|
138
|
+
startTime: null,
|
|
139
|
+
boats: {
|
|
140
|
+
b1: { finishTime: 100, dnf: false },
|
|
141
|
+
b2: { finishTime: null, dnf: true },
|
|
142
|
+
b3: { finishTime: null, dnf: false }
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
const summary = raceSummary(race);
|
|
146
|
+
assert.equal(summary.boatCount, 3);
|
|
147
|
+
assert.equal(summary.finishedCount, 1);
|
|
148
|
+
assert.equal(summary.dnfCount, 1);
|
|
149
|
+
assert.equal(summary.id, 'r1');
|
|
150
|
+
assert.equal(summary.name, 'Test Race');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('handles a race with no boats', () => {
|
|
154
|
+
const summary = raceSummary(bareRace());
|
|
155
|
+
assert.equal(summary.boatCount, 0);
|
|
156
|
+
assert.equal(summary.finishedCount, 0);
|
|
157
|
+
assert.equal(summary.dnfCount, 0);
|
|
158
|
+
});
|
|
159
|
+
});
|