diff --git a/src/presence/__tests__/heartbeat-test.js b/src/presence/__tests__/heartbeat-test.js index 98ca77a41ff..50ea220467e 100644 --- a/src/presence/__tests__/heartbeat-test.js +++ b/src/presence/__tests__/heartbeat-test.js @@ -63,11 +63,107 @@ describe('Heartbeat', () => { // arbitrarily, one full hour between heartbeats const HEARTBEAT_TIME = 60 * 60 * 1000; + /** + * "The course of false time never did run smooth." -- Shakespeare, probably + * + * Since we're using fake timers, two sequential events can appear to occur + * "at the same time". This can confuse simple comparisons terribly. + * + * We therefore represent an event time as a dual number (a + bε). The real + * part (a) is the readout of Date.now(), while the infinitesimal part (b) is + * an integer which differentiates the two. + * + * The dual number itself we represent as a 2-tuple [a, b]. (We could just say + * "we represent times as 2-tuples with the lexicographic ordering", but the + * "real"/"infinitesimal" terminology is useful.) + */ + type TimeVector = [number, number]; + + type HeartbeatEventType = 'activate' | 'deactivate' | 'callback'; + type HeartbeatEventRaw = { + type: HeartbeatEventType, + timeVector: TimeVector, + }; type HeartbeatEvent = { - type: 'activate' | 'deactivate' | 'callback', + type: HeartbeatEventType, time: number, }; + /** + * Create an (order-preserving) homomorphism M: V → ℝ, for some finite set V + * of TimeVectors. + */ + const createTimeMapping = (times: TimeVector[]): (TimeVector => number) => { + /* + The reals ℝ and the dual numbers ⅅ are of different order types: in + particular, there is no injective homomorphism ⅅ ↣ ℝ. However, as we only + need to worry about a known, finite domain V ⊊ ⅅ, we have our choice of + uncountably many homomorphisms. We (ab)use this freedom to encode + additional useful properties in our chosen map. + + Below, we denote arbitrary TimeVectors as v, v1, v2, ... and their images + as t = M(v), t1 = M(v1), t2 = M(v2)...; the real and infinitesimal parts + of a TimeVector are denoted Re(v) and In(v). + + We ensure that the mapping we create has the following qualities: + + (1) v1 < v2 ⇒ t1 < t2. + + This is the whole point, of course. + + (2) |t1 - t2| ≤ 1 ⟺ Re(v1) = Re(v2). + + This allows us to trivially test, without keeping around additional + metadata, whether two times differ from each other only infinitesimally. + + This is useful for testing predicates which should approximately hold: + we formalize that as meaning the predicate holds everywhere except + possibly over finitely many intervals of strictly infinitesimal measure. + + (3) ∀k ∈ ℝ: t1 + k = M(v1 + k). + + This ensures that it makes sense to add a number representing a + sufficiently-large time interval (like HEARTBEAT_TIME) to values in the + image of M. The result will be a plausible, correctly-ordered distance. + + (This property can't actually be satisfied for all possible inputs -- at + least, not while also satisfying (1) and (2). We _could_ substitute one + of + + (2′) ∃ℓ ∈ ℝ: |t1 - t2| ≤ ℓ ⟺ Re(v1) = Re(v2) + (3′) ∃ℎ ∈ ℝ: t1 + ℎ⋅k = Re(v1 + k) + + to satisfy instead, but then we'd have to pass around and use at least + one scaling factor in various places. It's simpler just to fail if we + can't use ℓ = ℎ = 1.) + + (4) The values of Re(v) and In(v) are human-readable in t. + + This is just for convenience when looking at logs. We don't rely on it + for correctness. + */ + + // minimum difference between real parts of consecutive timeVector groups + const min_delta_a: number = Math.min( + ...slidingWindow(times.map(([a]) => a), 2).map(([l, r]) => r - l || Infinity), + ); + + // This ensures that (2) and (3) can hold simultaneously. (The unusual + // condition ensures that we also throw on NaN.) + if (!(min_delta_a >= 2)) { + throw new Error(`minimum real Δt ${min_delta_a} is too low!`); + } + + // maximum value of infinitesimal part of timeVector + const max_b: number = Math.max(...times.map(([, b]) => b)); + + // Any value greater than max_b will satisfy (2); we choose a power of ten + // to also satisfy (4). + const adjustment = 10 ** (1 + Math.floor(Math.log10(max_b + 1))); + + return ([a, b]) => a + b / adjustment; + }; + /** * Wrapper class for Heartbeat. * @@ -80,16 +176,32 @@ describe('Heartbeat', () => { * by test cases. */ class JestHeartbeatHelper { - callback: CallbackType; - heartbeat: Heartbeat; - _events: HeartbeatEvent[] = []; - + /** List of heartbeats used in the current test. */ static _currentHeartbeats: Array = []; + // ============================================================== + // Event tracking + + _last_event_time: TimeVector = [-Infinity, 0]; + _events_raw: HeartbeatEventRaw[] = []; + _events: HeartbeatEvent[] | null = null; // for memoization + _recordEvent(type: 'activate' | 'deactivate' | 'callback') { - this._events.push({ type, time: Date.now() }); + const now = Date.now(); + const [lastA, lastB] = this._last_event_time; + const timeVector: TimeVector = lastA === now ? [now, lastB + 1] : [now, 0]; + + this._events_raw.push({ type, timeVector }); + this._last_event_time = timeVector; + this._events = null; // clear cache } + // ============================================================== + // Public interface + + callback: CallbackType; + heartbeat: Heartbeat; + constructor() { this.callback = jest.fn().mockImplementation(() => this._recordEvent('callback')); this.heartbeat = new Heartbeat(this.callback, HEARTBEAT_TIME); @@ -110,6 +222,17 @@ describe('Heartbeat', () => { } getEvents(): $ReadOnlyArray { + if (this._events === null) { + const mapping: TimeVector => number = createTimeMapping( + this._events_raw.map(({ timeVector }) => timeVector), + ); + + this._events = this._events_raw.map(({ type, timeVector }) => ({ + type, + time: mapping(timeVector), + })); + } + return this._events; }