diff --git a/package-lock.json b/package-lock.json index c6c5209..6a3afe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -959,6 +959,51 @@ "to-fast-properties": "^2.0.0" } }, + "@sinonjs/commons": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.1.tgz", + "integrity": "sha512-892K+kWUUi3cl+LlqEWIDrhvLgdL79tECi8JZUyq6IviKy/DNhuzCRlbHUjxK89f4ypPMMaFnFuR9Ie6DoIMsw==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", + "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/formatio": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-5.0.1.tgz", + "integrity": "sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1", + "@sinonjs/samsam": "^5.0.2" + } + }, + "@sinonjs/samsam": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.2.0.tgz", + "integrity": "sha512-CaIcyX5cDsjcW/ab7HposFWzV1kC++4HNsfnEdFJa7cP1QIuILAKV+BgfeqRXhcnSAc76r/Rh/O5C+300BwUIw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", @@ -1832,6 +1877,12 @@ "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "optional": true }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3756,6 +3807,12 @@ "verror": "1.10.0" } }, + "just-extend": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.1.1.tgz", + "integrity": "sha512-aWgeGFW67BP3e5181Ep1Fv2v8z//iBJfrvyTnq8wG86vEESwmonn1zPBJ0VfmT9CJq2FIT0VsETtrNFm2a+SHA==", + "dev": true + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -3804,6 +3861,12 @@ "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4040,6 +4103,19 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "nise": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/nise/-/nise-4.0.4.tgz", + "integrity": "sha512-bTTRUNlemx6deJa+ZyoCUTRvH3liK5+N6VQZ4NIw90AgDXY6iPnsqplNFf6STcj+ePk0H/xqxnP75Lr0J0Fq3A==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0", + "@sinonjs/fake-timers": "^6.0.0", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, "node-abi": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.15.0.tgz", @@ -4415,6 +4491,23 @@ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", "dev": true }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -4890,6 +4983,38 @@ "simple-concat": "^1.0.0" } }, + "sinon": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.0.tgz", + "integrity": "sha512-eSNXz1XMcGEMHw08NJXSyTHIu6qTCOiN8x9ODACmZpNQpr0aXTBXBnI4xTzQzR+TEpOmLiKowGf9flCuKIzsbw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/formatio": "^5.0.1", + "@sinonjs/samsam": "^5.2.0", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "slash": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", @@ -5483,6 +5608,12 @@ "prelude-ls": "~1.1.2" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", diff --git a/package.json b/package.json index a9c87fc..41d75a5 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@babel/plugin-transform-parameters": "^7.9.5", "@babel/preset-env": "^7.9.5", "eslint": "^6.8.0", + "sinon": "^9.2.0", "tape": "^5.0.0" } } diff --git a/src/app/simulation.js b/src/app/simulation.js index 25697e4..843acdd 100644 --- a/src/app/simulation.js +++ b/src/app/simulation.js @@ -59,7 +59,7 @@ export class Simulation extends EventEmitter { let now = Date.now(); let timeSinceLast = now - this._lastPedalTime; let timeUntilNext = Math.max(0, this._interval - timeSinceLast); - let nextPedalTime = now - timeSinceLast + this._interval; + let nextPedalTime = now + timeUntilNext; this._timeoutId = setTimeout(() => { this.onPedal(nextPedalTime); this.schedulePedal(); diff --git a/src/test/app/simulation.js b/src/test/app/simulation.js new file mode 100644 index 0000000..33c244e --- /dev/null +++ b/src/test/app/simulation.js @@ -0,0 +1,143 @@ +import {test} from 'tape'; +import sinon from 'sinon'; +import {Simulation} from '../../app/simulation'; + +test('constant cadence', t => { + const timeline = [ + cadenceChange(0, 60), + pedalEvent(0), + pedalEvent(1000), + pedalEvent(2000), + pedalEvent(3000), + ]; + + testTimeline(timeline, t); +}); + +test('start/stop/start', t => { + const timeline = [ + cadenceChange(0, 60), + pedalEvent(0), + pedalEvent(1000), + pedalEvent(2000), + pedalEvent(3000), + + cadenceChange(3001, 0), + + cadenceChange(100000, 1000), + pedalEvent(100000), + pedalEvent(100060), + ] + + testTimeline(timeline, t); +}); + +test('inconsequential cadence changes', t => { + const timeline = [ + cadenceChange(0, 10), + pedalEvent(0), + pedalEvent(6000), + pedalEvent(12000), + cadenceChange(12001, 20), + cadenceChange(12002, 30), + cadenceChange(12020, 40), + cadenceChange(12100, 50), + cadenceChange(12150, 120), + cadenceChange(12499, 60), + cadenceChange(12999, 30), + pedalEvent(14000), + ] + + testTimeline(timeline, t); +}); + +test('increase/decrease cadence', t => { + const timeline = [ + cadenceChange(0, 10), + pedalEvent(0), + pedalEvent(6000), + + cadenceChange(6001, 1000), + pedalEvent(6060), + pedalEvent(6120), + pedalEvent(6180), + + cadenceChange(6181, 60), + pedalEvent(7180), + pedalEvent(8180), + ] + + testTimeline(timeline, t); +}); + +test('varying cadence', t => { + const timeline = [ + cadenceChange(0, 60), + pedalEvent(0), + pedalEvent(1000), + pedalEvent(2000), + + cadenceChange(2001, 120), + pedalEvent(2500), + pedalEvent(3000), + + cadenceChange(3100, 30), + pedalEvent(5000), + pedalEvent(7000), + + cadenceChange(8999, 10), + pedalEvent(13000), + pedalEvent(19000), + + cadenceChange(24999, 1000), + pedalEvent(24999), + pedalEvent(25059), + pedalEvent(25119), + + cadenceChange(25178, 60), + pedalEvent(26119), + ] + + testTimeline(timeline, t); +}); + + +const C = 'CADENCE_CHANGE'; +const P = 'PEDAL_EMIT'; +const cadenceChange = (timestamp, cadence) => ({timestamp, type: C, cadence}) +const pedalEvent = (timestamp) => ({timestamp, type: P}) +const isPedalEvent = (evt) => evt.type === P +const isCadenceChange = (evt) => evt.type === C + +/** + * Test that pedal events are emitted with the expected timestamps. + * @param {object[]} timeline - timestamped events (pedal or cadence change) + * @param {string} timeline[].type - event type P|C (pedal or cadence change) + * @param {number} timeline[].timestamp - millisecond timestamp + * @param {number} [timeline[].cadence] - cadence in rpm (only for cadence change event) + * @param {Test} t - tape test object + */ +function testTimeline(timeline, t) { + const timestamps = timeline.filter(isPedalEvent).map(e => e.timestamp); + const cadenceChanges = timeline.filter(isCadenceChange); + const duration = Math.max(...timestamps); + + const clock = sinon.useFakeTimers(); + const sim = new Simulation(); + + // change sim.cadence at the appropriate times + for (let {timestamp, cadence} of cadenceChanges) { + setTimeout(() => { sim.cadence = cadence; }, timestamp); + } + + t.plan(timestamps.length); + + let i = 0; + sim.on('pedal', (timestamp) => { + t.equal(timestamp, timestamps[i], `pedal event ${timestamp}`); + i++; + }); + + clock.tick(duration); + clock.restore(); +}