@torrent-tv/proxy 2.20.0 → 2.21.0
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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/server.js +3 -6
- package/services/encoder-readings.js +45 -0
- package/services/hls-session-manager.js +96 -15
- package/services/torrent-worker/client.js +510 -497
- package/services/torrent-worker/pool-adapter.js +204 -195
- package/services/torrent-worker/protocol.js +2 -0
- package/services/torrent-worker/worker.js +16 -0
- package/test/encoder-readings.test.js +46 -0
|
@@ -1,195 +1,204 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file `TorrentPool`'s interface, served from the worker thread.
|
|
3
|
-
*
|
|
4
|
-
* The routes, the planner, the health report and the session manager all reach
|
|
5
|
-
* for a torrent pool and use it the same handful of ways. Rather than rewrite
|
|
6
|
-
* every one of them to thread a `sourceKey` through and await what used to be
|
|
7
|
-
* immediate, this presents the shape they already expect and does the thread
|
|
8
|
-
* hop behind it. Swapping the implementation is then a one-line change at
|
|
9
|
-
* construction, and the call sites are untouched — which is what keeps a change
|
|
10
|
-
* of this size reviewable.
|
|
11
|
-
*
|
|
12
|
-
* Two accommodations are needed, and both are deliberate:
|
|
13
|
-
*
|
|
14
|
-
* - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
|
|
15
|
-
* nothing the caller inspects, so the command is dispatched and not awaited.
|
|
16
|
-
* `acquireFile` hands back a release function exactly as before, which sends
|
|
17
|
-
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
-
* site for no observable gain.
|
|
19
|
-
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
|
-
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
|
-
* get one derived from the source itself, so the identity stays stable
|
|
22
|
-
* across calls for the same torrent.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import crypto from "node:crypto";
|
|
26
|
-
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
-
*
|
|
31
|
-
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
-
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
-
*
|
|
34
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
-
* @param {string} source
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
function deriveSourceKey(sourceType, source) {
|
|
39
|
-
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A torrent pool whose work happens on another thread.
|
|
44
|
-
*
|
|
45
|
-
* See `protocol.js` for why: the torrent was taking ~85% of the main thread and
|
|
46
|
-
* everything owed to a viewer queued behind it.
|
|
47
|
-
*/
|
|
48
|
-
export class WorkerTorrentPool {
|
|
49
|
-
#client;
|
|
50
|
-
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
51
|
-
#torrents = new Map();
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
|
|
55
|
-
*/
|
|
56
|
-
constructor(options = {}) {
|
|
57
|
-
this.#client = new TorrentWorkerClient(options);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Load (or join) a torrent and return a stand-in for it.
|
|
62
|
-
*
|
|
63
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
64
|
-
* @param {string} source
|
|
65
|
-
* @returns {Promise<object>}
|
|
66
|
-
*/
|
|
67
|
-
async getTorrent(sourceType, source) {
|
|
68
|
-
const sourceKey = deriveSourceKey(sourceType, source);
|
|
69
|
-
const existing = this.#torrents.get(sourceKey);
|
|
70
|
-
if (existing) {
|
|
71
|
-
return existing;
|
|
72
|
-
}
|
|
73
|
-
const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
|
|
74
|
-
this.#torrents.set(sourceKey, torrent);
|
|
75
|
-
return torrent;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Claim a file for reading; the returned function releases it.
|
|
80
|
-
*
|
|
81
|
-
* Synchronous by design — see the file header.
|
|
82
|
-
*
|
|
83
|
-
* @param {object} torrent - A stand-in from {@link getTorrent}.
|
|
84
|
-
* @param {number} fileIndex
|
|
85
|
-
* @returns {() => void}
|
|
86
|
-
*/
|
|
87
|
-
acquireFile(torrent, fileIndex) {
|
|
88
|
-
const sourceKey = torrent?.sourceKey;
|
|
89
|
-
if (!sourceKey) {
|
|
90
|
-
return () => undefined;
|
|
91
|
-
}
|
|
92
|
-
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
93
|
-
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
94
|
-
// to the worker, and if release arrives first the reader count drops to zero
|
|
95
|
-
// while a read is still running. The idle sweep then removes the torrent AND
|
|
96
|
-
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
97
|
-
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
98
|
-
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
99
|
-
// Chaining the release onto the acquire keeps them in order.
|
|
100
|
-
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => null);
|
|
101
|
-
let released = false;
|
|
102
|
-
return () => {
|
|
103
|
-
if (released) {
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
released = true;
|
|
107
|
-
// Release the claim this call opened, not "the file" — waiting for the
|
|
108
|
-
// acquire is also what tells us which claim that is.
|
|
109
|
-
void acquired
|
|
110
|
-
.then((claimId) => (claimId ? this.#client.releaseFile(claimId) : undefined))
|
|
111
|
-
.catch(() => undefined);
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Live download figures for the progress display.
|
|
117
|
-
*
|
|
118
|
-
* @param {object} torrent
|
|
119
|
-
* @param {number | null} [fileIndex]
|
|
120
|
-
* @param {{ resumeAnchorByteStart?: number | null }} [options]
|
|
121
|
-
* @returns {Promise<object | null>}
|
|
122
|
-
*/
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
this.#
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file `TorrentPool`'s interface, served from the worker thread.
|
|
3
|
+
*
|
|
4
|
+
* The routes, the planner, the health report and the session manager all reach
|
|
5
|
+
* for a torrent pool and use it the same handful of ways. Rather than rewrite
|
|
6
|
+
* every one of them to thread a `sourceKey` through and await what used to be
|
|
7
|
+
* immediate, this presents the shape they already expect and does the thread
|
|
8
|
+
* hop behind it. Swapping the implementation is then a one-line change at
|
|
9
|
+
* construction, and the call sites are untouched — which is what keeps a change
|
|
10
|
+
* of this size reviewable.
|
|
11
|
+
*
|
|
12
|
+
* Two accommodations are needed, and both are deliberate:
|
|
13
|
+
*
|
|
14
|
+
* - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
|
|
15
|
+
* nothing the caller inspects, so the command is dispatched and not awaited.
|
|
16
|
+
* `acquireFile` hands back a release function exactly as before, which sends
|
|
17
|
+
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
+
* site for no observable gain.
|
|
19
|
+
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
|
+
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
|
+
* get one derived from the source itself, so the identity stays stable
|
|
22
|
+
* across calls for the same torrent.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
+
*
|
|
31
|
+
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
+
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
+
*
|
|
34
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
+
* @param {string} source
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function deriveSourceKey(sourceType, source) {
|
|
39
|
+
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A torrent pool whose work happens on another thread.
|
|
44
|
+
*
|
|
45
|
+
* See `protocol.js` for why: the torrent was taking ~85% of the main thread and
|
|
46
|
+
* everything owed to a viewer queued behind it.
|
|
47
|
+
*/
|
|
48
|
+
export class WorkerTorrentPool {
|
|
49
|
+
#client;
|
|
50
|
+
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
51
|
+
#torrents = new Map();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
|
|
55
|
+
*/
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
this.#client = new TorrentWorkerClient(options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load (or join) a torrent and return a stand-in for it.
|
|
62
|
+
*
|
|
63
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
64
|
+
* @param {string} source
|
|
65
|
+
* @returns {Promise<object>}
|
|
66
|
+
*/
|
|
67
|
+
async getTorrent(sourceType, source) {
|
|
68
|
+
const sourceKey = deriveSourceKey(sourceType, source);
|
|
69
|
+
const existing = this.#torrents.get(sourceKey);
|
|
70
|
+
if (existing) {
|
|
71
|
+
return existing;
|
|
72
|
+
}
|
|
73
|
+
const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
|
|
74
|
+
this.#torrents.set(sourceKey, torrent);
|
|
75
|
+
return torrent;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Claim a file for reading; the returned function releases it.
|
|
80
|
+
*
|
|
81
|
+
* Synchronous by design — see the file header.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} torrent - A stand-in from {@link getTorrent}.
|
|
84
|
+
* @param {number} fileIndex
|
|
85
|
+
* @returns {() => void}
|
|
86
|
+
*/
|
|
87
|
+
acquireFile(torrent, fileIndex) {
|
|
88
|
+
const sourceKey = torrent?.sourceKey;
|
|
89
|
+
if (!sourceKey) {
|
|
90
|
+
return () => undefined;
|
|
91
|
+
}
|
|
92
|
+
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
93
|
+
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
94
|
+
// to the worker, and if release arrives first the reader count drops to zero
|
|
95
|
+
// while a read is still running. The idle sweep then removes the torrent AND
|
|
96
|
+
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
97
|
+
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
98
|
+
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
99
|
+
// Chaining the release onto the acquire keeps them in order.
|
|
100
|
+
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => null);
|
|
101
|
+
let released = false;
|
|
102
|
+
return () => {
|
|
103
|
+
if (released) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
released = true;
|
|
107
|
+
// Release the claim this call opened, not "the file" — waiting for the
|
|
108
|
+
// acquire is also what tells us which claim that is.
|
|
109
|
+
void acquired
|
|
110
|
+
.then((claimId) => (claimId ? this.#client.releaseFile(claimId) : undefined))
|
|
111
|
+
.catch(() => undefined);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Live download figures for the progress display.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} torrent
|
|
119
|
+
* @param {number | null} [fileIndex]
|
|
120
|
+
* @param {{ resumeAnchorByteStart?: number | null }} [options]
|
|
121
|
+
* @returns {Promise<object | null>}
|
|
122
|
+
*/
|
|
123
|
+
/**
|
|
124
|
+
* Bytes every torrent here has moved.
|
|
125
|
+
*
|
|
126
|
+
* @returns {Promise<{ downloaded: number, uploaded: number }>}
|
|
127
|
+
*/
|
|
128
|
+
async getTorrentTotals() {
|
|
129
|
+
return this.#client.getTorrentTotals();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async getFileStats(torrent, fileIndex = null, options = {}) {
|
|
133
|
+
const sourceKey = torrent?.sourceKey;
|
|
134
|
+
if (!sourceKey) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return this.#client.getFileStats({
|
|
138
|
+
sourceKey,
|
|
139
|
+
fileIndex,
|
|
140
|
+
resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Reorder piece selection around a read position.
|
|
146
|
+
*
|
|
147
|
+
* Synchronous by design — see the file header.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} torrent
|
|
150
|
+
* @param {number} fileIndex
|
|
151
|
+
* @param {number} byteStart
|
|
152
|
+
* @param {number} [windowBytes]
|
|
153
|
+
* @param {{ wholeFileRead?: boolean }} [options]
|
|
154
|
+
* @returns {void}
|
|
155
|
+
*/
|
|
156
|
+
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes, options) {
|
|
157
|
+
const sourceKey = torrent?.sourceKey;
|
|
158
|
+
if (!sourceKey) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
void this.#client
|
|
162
|
+
.prioritizeByteRange({
|
|
163
|
+
sourceKey,
|
|
164
|
+
fileIndex,
|
|
165
|
+
byteStart,
|
|
166
|
+
windowBytes,
|
|
167
|
+
wholeFileRead: options?.wholeFileRead === true
|
|
168
|
+
})
|
|
169
|
+
.catch(() => undefined);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Pre-fetch the head and tail the codec probe needs.
|
|
174
|
+
*
|
|
175
|
+
* Takes an options object, matching `TorrentPool.prefetchFileEdges` — this
|
|
176
|
+
* adapter exists to present that same interface. It previously declared
|
|
177
|
+
* positional parameters instead, so the planner's options object arrived as
|
|
178
|
+
* `headBytes` and only worked because it was passed along far enough to be
|
|
179
|
+
* destructured at the far end. Anyone calling it as documented got the
|
|
180
|
+
* defaults instead of the sizes they asked for.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} torrent
|
|
183
|
+
* @param {number} fileIndex
|
|
184
|
+
* @param {{ headBytes?: number, tailBytes?: number, timeoutMs?: number }} [options]
|
|
185
|
+
* @returns {Promise<unknown>}
|
|
186
|
+
*/
|
|
187
|
+
async prefetchFileEdges(torrent, fileIndex, options = {}) {
|
|
188
|
+
const sourceKey = torrent?.sourceKey;
|
|
189
|
+
if (!sourceKey) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return this.#client.prefetchFileEdges({ sourceKey, fileIndex, options });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Shut the torrent client down and stop the thread.
|
|
197
|
+
*
|
|
198
|
+
* @returns {Promise<void>}
|
|
199
|
+
*/
|
|
200
|
+
async destroyAll() {
|
|
201
|
+
this.#torrents.clear();
|
|
202
|
+
await this.#client.destroyAll();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -55,6 +55,8 @@ export const Command = {
|
|
|
55
55
|
LIST_FILES: "list-files",
|
|
56
56
|
/** Live download figures for the progress display. */
|
|
57
57
|
FILE_STATS: "file-stats",
|
|
58
|
+
/** Bytes every torrent here has moved, for pricing the torrent's own cost. */
|
|
59
|
+
TORRENT_TOTALS: "torrent-totals",
|
|
58
60
|
/** Reorder piece selection around a read position (seek prioritisation). */
|
|
59
61
|
PRIORITIZE: "prioritize",
|
|
60
62
|
/** Read a byte range; the body arrives as CHUNK messages. */
|
|
@@ -340,6 +340,22 @@ async function runCommand(command, params, id) {
|
|
|
340
340
|
return released;
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
case Command.TORRENT_TOTALS: {
|
|
344
|
+
// Downloaded and uploaded are counted apart: hashing every downloaded
|
|
345
|
+
// byte is work of a different order from sending one back to the swarm,
|
|
346
|
+
// and adding them would price both at whatever the mixture happened to
|
|
347
|
+
// be.
|
|
348
|
+
let downloaded = 0;
|
|
349
|
+
let uploaded = 0;
|
|
350
|
+
for (const torrent of pool.client?.torrents ?? []) {
|
|
351
|
+
const gotBytes = Number(torrent?.downloaded);
|
|
352
|
+
const sentBytes = Number(torrent?.uploaded);
|
|
353
|
+
downloaded += Number.isFinite(gotBytes) ? gotBytes : 0;
|
|
354
|
+
uploaded += Number.isFinite(sentBytes) ? sentBytes : 0;
|
|
355
|
+
}
|
|
356
|
+
return { downloaded, uploaded };
|
|
357
|
+
}
|
|
358
|
+
|
|
343
359
|
case Command.FILE_STATS: {
|
|
344
360
|
const torrent = await requireTorrent(params.sourceKey);
|
|
345
361
|
return pool.getFileStats(torrent, params.fileIndex, {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What two readings of a running encoder say about its speed.
|
|
3
|
+
*
|
|
4
|
+
* The case this exists for is a COPY. ffmpeg's own cumulative `speed=` counts
|
|
5
|
+
* the seconds the look-ahead cap keeps the encoder stopped, and a copy is
|
|
6
|
+
* stopped for most of its life — reaching the cap in about fifteen seconds and
|
|
7
|
+
* then waiting a minute. Read cumulatively, a copy running at 8x reports 1.6x,
|
|
8
|
+
* which filed as the price of copying would refuse rungs on a measurement of a
|
|
9
|
+
* pause.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
|
|
15
|
+
import { speedFromReadings } from "../services/encoder-readings.js";
|
|
16
|
+
|
|
17
|
+
const at = (seconds, processedSeconds) => ({ takenAt: seconds * 1000, processedSeconds });
|
|
18
|
+
|
|
19
|
+
test("a copy producing eight seconds of video per second reads as eight", () => {
|
|
20
|
+
assert.equal(speedFromReadings(at(10, 100), at(15, 140), 3), 8);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a stretch too short to divide by says nothing", () => {
|
|
24
|
+
assert.equal(speedFromReadings(at(10, 100), at(11, 108), 3), null);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a run that produced nothing between the readings says nothing", () => {
|
|
28
|
+
// What a repositioned run looks like before it reaches its new start.
|
|
29
|
+
assert.equal(speedFromReadings(at(10, 100), at(20, 100), 3), null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a run that went BACKWARDS says nothing rather than a negative speed", () => {
|
|
33
|
+
assert.equal(speedFromReadings(at(10, 200), at(20, 100), 3), null);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a missing reading is not an answer", () => {
|
|
37
|
+
assert.equal(speedFromReadings(null, at(20, 100), 3), null);
|
|
38
|
+
assert.equal(speedFromReadings(at(10, 100), null, 3), null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the pair is judged on wall clock, so a pause between them is the caller's problem", () => {
|
|
42
|
+
// Deliberate: this function cannot see a pause. The session drops its
|
|
43
|
+
// previous reading whenever the encoder is stopped or restarted, which is
|
|
44
|
+
// what makes every surviving pair an uninterrupted stretch.
|
|
45
|
+
assert.equal(speedFromReadings(at(0, 0), at(60, 60), 3), 1);
|
|
46
|
+
});
|