From 9e3a3051e3924cf1ae91b14c794eb6e1785484d8 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Wed, 20 Sep 2023 09:45:16 +0700 Subject: [PATCH 1/7] feat(web): integrates GestureSequence with GestureRecognizer --- .../src/engine/gestureRecognizer.ts | 9 +- .../engine/headless/touchpointCoordinator.ts | 68 +- .../gestures/touchpointCoordinator.spec.ts | 697 ++++++++++++++++++ .../src/hostFixtureLayoutController.ts | 2 +- 4 files changed, 759 insertions(+), 17 deletions(-) create mode 100644 common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts diff --git a/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts b/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts index 614c4dd136a..d7dce1d9cb9 100644 --- a/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts +++ b/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts @@ -11,10 +11,15 @@ export class GestureRecognizer extends TouchpointCoordinator; private readonly touchEngine: TouchEventEngine; - public constructor(config: GestureRecognizerConfiguration) { + public constructor(gestureModelDefinitions: GestureModelDefs, config: GestureRecognizerConfiguration) { const preprocessedConfig = preprocessRecognizerConfig(config); - super(); + // Possibly just a stop-gap measure... but this provides an empty gesture-spec set definition + // that allows testing the path-constrainment functionality without invoking gesture-processing + // overhead. + gestureModelDefinitions = gestureModelDefinitions || EMPTY_GESTURE_DEFS; + + super(gestureModelDefinitions); this.config = preprocessedConfig; this.mouseEngine = new MouseEventEngine(this.config); diff --git a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts index 64768ff794c..8ace8060114 100644 --- a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts +++ b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts @@ -2,6 +2,8 @@ import EventEmitter from "eventemitter3"; import { InputEngineBase } from "./inputEngineBase.js"; import { GestureSource, GestureSourceSubview } from "./gestureSource.js"; import { MatcherSelector } from "./gestures/matchers/matcherSelector.js"; +import { GestureSequence } from "./gestures/matchers/gestureSequence.js"; +import { GestureModelDefs, getGestureModelSet } from "./gestures/specs/gestureModelDefs.js"; interface EventMap { /** @@ -10,6 +12,8 @@ interface EventMap { * @returns */ 'inputstart': (input: GestureSource) => void; + + 'recognizedgesture': (sequence: GestureSequence) => void; } /** @@ -24,11 +28,21 @@ export class TouchpointCoordinator extends EventEmitter[]; private selectorStack: MatcherSelector[] = [new MatcherSelector()]; + private gestureModelDefinitions: GestureModelDefs; + private _activeSources: GestureSource[] = []; + private _activeGestures: GestureSequence[] = []; - public constructor() { + public constructor(gestureModelDefinitions: GestureModelDefs, inputEngines?: InputEngineBase[]) { super(); + + this.gestureModelDefinitions = gestureModelDefinitions; this.inputEngines = []; + if(inputEngines) { + for(let engine of inputEngines) { + this.addEngine(engine); + } + } } public pushSelector(selector: MatcherSelector) { @@ -58,26 +72,52 @@ export class TouchpointCoordinator extends EventEmitter) => { this.addSimpleSourceHooks(touchpoint); - - // ... stuff + const modelDefs = this.gestureModelDefinitions; + const selector = this.currentSelector; + + const firstSelectionPromise = selector.matchGesture(touchpoint, getGestureModelSet(modelDefs, selector.baseGestureSetId)); + firstSelectionPromise.then((selection) => { + if(selection.result.matched == false) { + return; + } + + // For multitouch gestures, only report the gesture **once**. + const sourceIDs = selection.matcher.allSourceIds; + for(let sequence of this._activeGestures) { + if(!!sequence.allSourceIds.find((id1) => !!sourceIDs.find((id2) => id1 == id2))) { + // We've already established (and thus, already reported) a GestureSequence for this selection. + return; + } + } + + const gestureSequence = new GestureSequence(selection, modelDefs, this.currentSelector, this); + this._activeGestures.push(gestureSequence); + gestureSequence.on('complete', () => { + // When the GestureSequence is fully complete and all related `firstSelectionPromise`s have + // had the chance to resolve, drop the reference; prevent memory leakage. + const index = this._activeGestures.indexOf(gestureSequence); + if(index != -1) { + this._activeGestures.splice(index, 1); + } + }); + + // Could track sequences easily enough; the question is how to tell when to 'let go'. + + this.emit('recognizedgesture', gestureSequence); + + // TODO: handle any related 'push' mechanics that may still be lingering. + // TODO: may be wise for the sequence object to support a 'finalized' promise... or maybe just a closure?... + // just to ensure that push states get reversed when needed? + }); this.emit('inputstart', touchpoint); } - private doGestureUpdate(source: GestureSource) { - // Should probably ensure data-updates for multi-contact gestures are synchronized - // before proceeding. Single-contact cases are inherently synchronized, of course. - // - // Should a gesture type have geometric requirements on the current location of active - // touchpaths, having a desync during a quick movement could cause the calculated - // distance between the locations to be markedly different than expected. - - // TODO: stuff, including synchronization. Probably do that on the caller, - // rather than here? + public get activeGestures(): GestureSequence[] { + return [].concat(this._activeGestures); } private addSimpleSourceHooks(touchpoint: GestureSource) { - touchpoint.path.on('step', () => this.doGestureUpdate(touchpoint)); touchpoint.path.on('invalidated', () => { // TODO: on cancellation, is there any other cleanup to be done? diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts new file mode 100644 index 00000000000..4dd59d765d0 --- /dev/null +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts @@ -0,0 +1,697 @@ +import { assert } from 'chai' +import sinon from 'sinon'; + +import { GestureModelDefs, GestureSource, gestures, TouchpointCoordinator } from '@keymanapp/gesture-recognizer'; +const { matchers } = gestures; + +// Huh... gotta do BOTH here? One for constructor use, the other for generic-parameter use? +const { GestureSequence, GestureStageReport, MatcherSelector } = matchers; +type GestureSequence = gestures.matchers.GestureSequence; +type MatcherSelector = gestures.matchers.MatcherSelector; +type MatcherSelection = gestures.matchers.MatcherSelection; +type GestureStageReport = gestures.matchers.GestureStageReport; + +import { HeadlessInputEngine, TouchpathTurtle } from '#tools'; +import { ManagedPromise, timedPromise } from '@keymanapp/web-utils'; + +import { assertGestureSequence, SequenceAssertion } from "../../../resources/sequenceAssertions.js"; + +import { + LongpressModel, + MultitapModel, + SimpleTapModel, + SubkeySelectModel +} from './isolatedGestureSpecs.js'; + +const TestGestureModelDefinitions: GestureModelDefs = { + gestures: [ + LongpressModel, + MultitapModel, + SimpleTapModel, + SubkeySelectModel, + // TODO: add something for a starting modipress. + ], + sets: { + default: [LongpressModel.id, SimpleTapModel.id, /* TODO: add a 'starting modipress' model */], + // TODO: modipress: [LongpressModel.id, SimpleTapModel.id], // no nested modipressing + } +} + +let fakeClock: ReturnType; + +describe("TouchpointCoordinator", () => { + beforeEach(function() { + fakeClock = sinon.useFakeTimers(); + }); + + afterEach(function() { + fakeClock.restore(); + }); + + it('longpress -> subkey select', async () => { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.wait(1000, 50); + turtle.move(0, 10, 100, 5); + turtle.hoveredItem = 'à'; + turtle.move(90, 10, 100, 5); + turtle.hoveredItem = 'â'; + turtle.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle.path, + }, + isFromTouch: true + }], + config: null + }); + + const sequenceAssertion: SequenceAssertion = [ + { + matchedId: 'longpress', + item: null, + linkType: 'chain', + sources: (sources) => { + // Assert single-source + assert.equal(sources.length, 1); + + // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. + const pathStats = sources[0].path.stats; + assert.isAtLeast(pathStats.duration, LongpressModel.contacts[0].model.timer.duration - 1); + assert.isAtMost(pathStats.rawDistance, 0.1); + return; + } + }, + { + matchedId: 'subkey-select', + item: 'â', + linkType: 'complete', + sources: (sources) => { + const pathStats = sources[0].path.stats; + assert.isAtLeast(pathStats.rawDistance, 19.9); + assert.isAtLeast(pathStats.duration, 1200 - LongpressModel.contacts[0].model.timer.duration - 2); + } + } + ]; + + const sequencePromise = new ManagedPromise(); + const sequenceAssertionPromise = new ManagedPromise(); + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + try { + sequencePromise.resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertion); + sequenceAssertionPromise.resolve(); + } catch(err) { + sequenceAssertionPromise.reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromise.corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await runnerPromise; + + await sequenceAssertionPromise.corePromise; + assert.isEmpty(touchpointCoordinator.activeGestures); + }); + + it('a single, standalone simple tap', async () => { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.wait(40, 2); + turtle.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle.path, + }, + isFromTouch: true + }], + config: null + }).then(async () => { + // Ride out the multitap timer so we can achieve full completion. + let promise = timedPromise(MultitapModel.sustainTimer.duration+1).then(() => {}); + await fakeClock.runToLastAsync(); + await promise; + }); + + const sequenceAssertion: SequenceAssertion = [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + + // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. + const pathStats = sources[0].path.stats; + assert.isAtLeast(pathStats.duration, 40); + return; + } + } + ]; + + const sequencePromise = new ManagedPromise(); + const sequenceAssertionPromise = new ManagedPromise(); + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + try { + sequencePromise.resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertion); + sequenceAssertionPromise.resolve(); + } catch(err) { + sequenceAssertionPromise.reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromise.corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + await runnerPromise; + + await sequenceAssertionPromise.corePromise; + assert.isEmpty(touchpointCoordinator.activeGestures); + }); + + it('two overlapping simple taps', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle0.wait(40, 2); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 101, + targetY: 101, + t: 120, + item: 'b' + }); + turtle1.wait(40, 2); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }).then(async () => { + // Ride out the multitap timer so we can achieve full completion. + let promise = timedPromise(MultitapModel.sustainTimer.duration+1).then(() => {}); + await fakeClock.runToLastAsync(); + await promise; + }); + + /* The fact that the two simple-taps are treated as part of the same sequence, rather than distinct ones, + * is a consequence of the existing gesture-modeling infrastructure. The second tap is what triggers + * "early completion" of the first tap, thus it's considered part of the same sequence at present. + * + * Rough notes toward potential mitigation / fix in the future: + * // - could be mitigated with a special 'flag' on the gesture-model, perhaps? + * // - something to indicate "early-termination second-touchpoint should mark a sequence split-point" + * // - the GestureSequence class/instance does have a reference to TouchpointCoordinator; it should + * // be able to use that reference to facilitate a split if/when appropriate, like here. + * + * Obviously having a separate, second sequence would be 'nice', conceptually, for consumers... + * but I don't think it's worth prioritizing at the moment; got enough else to deal with for now. + */ + const sequenceAssertion: SequenceAssertion = [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + // Assert dual-source; the first tap was early-triggered because of the concurrent second tap. + assert.equal(sources.length, 2); + assert.isTrue(sources[0].isPathComplete); + assert.isFalse(sources[1].isPathComplete); + + // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. + const pathStats = sources[0].path.stats; + assert.isAtMost(pathStats.duration, 21); + return; + } + }, + { + matchedId: 'simple-tap', + item: 'b', + linkType: 'optional-chain', + sources: (sources) => { + // Assert single-source; the first tap is not under consideration for this stage. + assert.equal(sources.length, 1); + const pathStats = sources[0].path.stats; + assert.isAtMost(pathStats.duration, 40); + } + } + ]; + + const sequencePromise = new ManagedPromise(); + const sequenceAssertionPromise = new ManagedPromise(); + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + try { + sequencePromise.resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertion); + sequenceAssertionPromise.resolve(); + } catch(err) { + sequenceAssertionPromise.reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromise.corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await sequenceAssertionPromise.corePromise; + await runnerPromise; + + assert.isEmpty(touchpointCoordinator.activeGestures); + }); + + it('2 consecutive simple taps', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle0.wait(40, 2); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 11, + targetY: 11, + t: 200, + item: 'b' + }); + turtle1.wait(40, 2); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }).then(async () => { + // Ride out the multitap timer so we can achieve full completion. + let promise = timedPromise(MultitapModel.sustainTimer.duration+1).then(() => {}); + await fakeClock.runToLastAsync(); + await promise; + }); + + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + // The second one is a separate sequence; no data for it should show up here. + ], + // The 'separate sequence'. + [ + { + matchedId: 'simple-tap', + item: 'b', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + ] + ]; + + const sequencePromises = [new ManagedPromise(), new ManagedPromise()]; + const sequenceAssertionPromises = [new ManagedPromise(), new ManagedPromise()]; + let index = 0; + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + const i = index++; + + try { + sequencePromises[i].resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertions[i]); + // but this waits on the assertions, which means we don't get the intermediate state stuff this way. + sequenceAssertionPromises[i].resolve(); + } catch(err) { + sequenceAssertionPromises[i].reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromises[0].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await sequencePromises[1].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await runnerPromise; + + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await completionPromise; + await Promise.all(sequenceAssertionPromises); + + assert.isEmpty(touchpointCoordinator.activeGestures); + }); + + it('2 consecutive simple taps (long intermediate delay)', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle0.wait(40, 2); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 1100, + item: 'a' + }); + turtle1.wait(40, 2); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }).then(async () => { + // Ride out the multitap timer so we can achieve full completion. + let promise = timedPromise(MultitapModel.sustainTimer.duration+1).then(() => {}); + await fakeClock.runToLastAsync(); + await promise; + }); + + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + // The second one is a separate sequence; no data for it should show up here. + ], + // The 'separate sequence'. + [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + ] + ]; + + const sequencePromises = [new ManagedPromise(), new ManagedPromise()]; + const sequenceAssertionPromises = [new ManagedPromise(), new ManagedPromise()]; + let index = 0; + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + const i = index++; + + // FIXME: is occurring twice! + console.log(sequence); + try { + sequencePromises[i].resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertions[i]); + sequenceAssertionPromises[i].resolve(); + } catch(err) { + sequenceAssertionPromises[i].reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromises[0].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await sequencePromises[1].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await runnerPromise; + + await completionPromise; + await sequenceAssertionPromises[1].corePromise; + + assert.isEmpty(touchpointCoordinator.activeGestures); + }); + + it('simple tap followed by longpress', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle0.wait(40, 2); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 11, + targetY: 11, + t: 200, + item: 'b' + }); + turtle1.wait(600, 30); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }); + + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + // The second one is a separate sequence; no data for it should show up here. + ], + // The 'separate sequence'. + [ + { + matchedId: 'longpress', + item: null, + linkType: 'chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isFalse(sources[0].isPathComplete); + return; + } + }, + { + matchedId: 'subkey-select', + item: 'b', + linkType: 'complete', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + ] + ]; + + const sequencePromises = [new ManagedPromise(), new ManagedPromise()]; + const sequenceAssertionPromises = [new ManagedPromise(), new ManagedPromise()]; + let index = 0; + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + const i = index++; + try { + sequencePromises[i].resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertions[i]); + sequenceAssertionPromises[i].resolve(); + } catch(err) { + sequenceAssertionPromises[i].reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromises[0].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await sequencePromises[1].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await runnerPromise; + await sequenceAssertionPromises[1].corePromise; + + assert.isEmpty(touchpointCoordinator.activeGestures); + }); + + it('basic multitap - 2 taps total', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle0.wait(40, 2); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 200, + item: 'a' + }); + turtle1.wait(40, 2); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }).then(async () => { + // Ride out the multitap timer so we can achieve full completion. + let promise = timedPromise(MultitapModel.sustainTimer.duration+1).then(() => {}); + await fakeClock.runToLastAsync(); + await promise; + }); + + const sequenceAssertion: SequenceAssertion = [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + }, + { + matchedId: 'multitap', + item: 'a', + linkType: 'optional-chain', + sources: (sources) => { + // Assert single-source; the first tap is not under consideration for this stage. + assert.equal(sources.length, 1); + } + } + ]; + + const sequencePromise = new ManagedPromise(); + const sequenceAssertionPromise = new ManagedPromise(); + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + // FIXME: is occurring twice! + console.log(sequence); + try { + sequencePromise.resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertion); + sequenceAssertionPromise.resolve(); + } catch(err) { + sequenceAssertionPromise.reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromise.corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await runnerPromise; + await sequenceAssertionPromise.corePromise; + assert.isEmpty(touchpointCoordinator.activeGestures); + }); +}); \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/tools/unit-test-resources/src/hostFixtureLayoutController.ts b/common/web/gesture-recognizer/src/tools/unit-test-resources/src/hostFixtureLayoutController.ts index 684e6f0d883..ff83a514a72 100644 --- a/common/web/gesture-recognizer/src/tools/unit-test-resources/src/hostFixtureLayoutController.ts +++ b/common/web/gesture-recognizer/src/tools/unit-test-resources/src/hostFixtureLayoutController.ts @@ -53,7 +53,7 @@ export class HostFixtureLayoutController extends EventEmitter { private _setup: () => boolean = function(this: HostFixtureLayoutController) { let config = this.buildBaseFixtureConfig(); - this._recognizer = new GestureRecognizer(config); + this._recognizer = new GestureRecognizer(null /* TODO */, config); this._hostFixture = document.getElementById('host-fixture'); }.bind(this); From 0d60e1454caccdbb63cfc24f3d53484cac3124b9 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 21 Sep 2023 11:40:09 +0700 Subject: [PATCH 2/7] feat(web): test for blocking of new gestures while selecting subkeys --- .../headless/gestures/gestureMatcher.spec.ts | 2 +- .../gestures/gestureModelDefs.spec.ts | 4 +- .../headless/gestures/isolatedGestureSpecs.ts | 1 + .../headless/gestures/matcherSelector.spec.ts | 8 +- .../gestures/touchpointCoordinator.spec.ts | 168 ++++++++++++++---- 5 files changed, 143 insertions(+), 40 deletions(-) diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts index 461e138fb8b..3e14bb1c4aa 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts @@ -335,7 +335,7 @@ describe("GestureMatcher", function() { assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED); assert.equal(await promiseStatus(completion), PromiseStatusModule.PROMISE_PENDING); - assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: null, next: 'subkey-select'}}); + assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: null, selectionMode: 'none', next: 'subkey-select'}}); assert.isFalse(sources[0].path.isComplete); // Did we resolve at the expected point in the path - once the timer duration had passed? diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts index bd7839c82f6..6ffdf77dcf5 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts @@ -23,7 +23,9 @@ const TestGestureModelDefinitions: GestureModelDefs = { sets: { default: [LongpressModel.id, SimpleTapModel.id, /* TODO: add a 'starting modipress' model */], // TODO: modipress: [LongpressModel.id, SimpleTapModel.id], // no nested modipressing - malformed: [LongpressModel.id, 'unavailable-model'] + malformed: [LongpressModel.id, 'unavailable-model'], + // For subkey-select mode - no new gestures should be allowed to start. + none: [] } } diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts index a842d4dabb0..c8b0a958e48 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts @@ -25,6 +25,7 @@ export const LongpressModel: GestureModel = { resolutionAction: { type: 'chain', next: 'subkey-select', + selectionMode: 'none', item: 'none' }, /* diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/matcherSelector.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/matcherSelector.spec.ts index 4383e53a11e..bdcf6057ffb 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/matcherSelector.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/matcherSelector.spec.ts @@ -67,7 +67,7 @@ describe("MatcherSelector", function () { const selection = await selectionPromises[0]; - assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: null, next: 'subkey-select'}}); + assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: null, selectionMode: 'none', next: 'subkey-select'}}); assert.deepEqual(selection.matcher.model, LongpressModel); assert.isFalse(sources[0].path.isComplete); @@ -104,7 +104,7 @@ describe("MatcherSelector", function () { const selection = await selectionPromises[0]; - assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: null, next: 'subkey-select'}}); + assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: null, selectionMode: 'none', next: 'subkey-select'}}); assert.deepEqual(selection.matcher.model, LongpressModel); assert.isFalse(sources[0].path.isComplete); @@ -199,7 +199,7 @@ describe("MatcherSelector", function () { assert.equal(await promiseStatus(completion), PromiseStatusModule.PROMISE_PENDING); const selection = await selectionPromises[0]; - assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: null, next: 'subkey-select'}}); + assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: null, selectionMode: 'none', next: 'subkey-select'}}); assert.deepEqual(selection.matcher.model, LongpressModel); // Original base item was 'a'; 'b' proves that a reset occurred by the point of the 'item' change. @@ -257,7 +257,7 @@ describe("MatcherSelector", function () { assert.equal(await promiseStatus(completion), PromiseStatusModule.PROMISE_PENDING); const selection = await selectionPromises[0]; - assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: null, next: 'subkey-select'}}); + assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: null, selectionMode: 'none', next: 'subkey-select'}}); assert.deepEqual(selection.matcher.model, LongpressModel); // Original base item was 'a'; 'b' proves that a reset occurred by the point of the 'item' change. diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts index 4dd59d765d0..b08bd9ce4ac 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts @@ -34,6 +34,7 @@ const TestGestureModelDefinitions: GestureModelDefs = { sets: { default: [LongpressModel.id, SimpleTapModel.id, /* TODO: add a 'starting modipress' model */], // TODO: modipress: [LongpressModel.id, SimpleTapModel.id], // no nested modipressing + none: [] } } @@ -125,6 +126,101 @@ describe("TouchpointCoordinator", () => { assert.isEmpty(touchpointCoordinator.activeGestures); }); + it('longpress -> attempted simple-tap during subkey select', async () => { + const turtle1 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle1.wait(1000, 50); + turtle1.move(0, 10, 100, 5); + turtle1.hoveredItem = 'à'; + turtle1.move(90, 10, 100, 5); + turtle1.hoveredItem = 'â'; + turtle1.commitPending(); + + const turtle2 = new TouchpathTurtle({ + targetX: 101, + targetY: 101, + t: 700, + item: 'b' + }); + turtle2.wait(40, 2); + turtle2.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle2.path, + }, + isFromTouch: true + }], + config: null + }); + + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'longpress', + item: null, + linkType: 'chain', + sources: (sources) => { + // Assert single-source + assert.equal(sources.length, 1); + + // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. + const pathStats = sources[0].path.stats; + assert.isAtLeast(pathStats.duration, LongpressModel.contacts[0].model.timer.duration - 1); + assert.isAtMost(pathStats.rawDistance, 0.1); + return; + } + }, + { + matchedId: 'subkey-select', + item: 'â', + linkType: 'complete', + sources: (sources) => { + const pathStats = sources[0].path.stats; + assert.isAtLeast(pathStats.rawDistance, 19.9); + assert.isAtLeast(pathStats.duration, 1200 - LongpressModel.contacts[0].model.timer.duration - 2); + } + } + ], + [ /* there should be no simple-tap here - it should be blocked. */] + ]; + + const sequencePromise = new ManagedPromise(); + const sequenceAssertionPromise = new ManagedPromise(); + let sequenceIndex = 0; + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + try { + sequencePromise.resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertions[sequenceIndex++]); + sequenceAssertionPromise.resolve(); + } catch(err) { + sequenceAssertionPromise.reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromise.corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await runnerPromise; + + await sequenceAssertionPromise.corePromise; + assert.isEmpty(touchpointCoordinator.activeGestures); + }); + it('a single, standalone simple tap', async () => { const turtle = new TouchpathTurtle({ targetX: 1, @@ -156,7 +252,7 @@ describe("TouchpointCoordinator", () => { { matchedId: 'simple-tap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -245,42 +341,46 @@ describe("TouchpointCoordinator", () => { * Obviously having a separate, second sequence would be 'nice', conceptually, for consumers... * but I don't think it's worth prioritizing at the moment; got enough else to deal with for now. */ - const sequenceAssertion: SequenceAssertion = [ - { - matchedId: 'simple-tap', - item: 'a', - linkType: 'optional-chain', - sources: (sources) => { - // Assert dual-source; the first tap was early-triggered because of the concurrent second tap. - assert.equal(sources.length, 2); - assert.isTrue(sources[0].isPathComplete); - assert.isFalse(sources[1].isPathComplete); + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'chain', + sources: (sources) => { + // Assert dual-source; the first tap was early-triggered because of the concurrent second tap. + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); - // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. - const pathStats = sources[0].path.stats; - assert.isAtMost(pathStats.duration, 21); - return; + // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. + const pathStats = sources[0].path.stats; + assert.isAtMost(pathStats.duration, 21); + return; + } } - }, - { - matchedId: 'simple-tap', - item: 'b', - linkType: 'optional-chain', - sources: (sources) => { - // Assert single-source; the first tap is not under consideration for this stage. - assert.equal(sources.length, 1); - const pathStats = sources[0].path.stats; - assert.isAtMost(pathStats.duration, 40); + ], [ + { + matchedId: 'simple-tap', + item: 'b', + linkType: 'chain', + sources: (sources) => { + // Assert single-source; the first tap is not under consideration for this stage. + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + const pathStats = sources[0].path.stats; + assert.isAtMost(pathStats.duration, 40); + } } - } + ] ]; const sequencePromise = new ManagedPromise(); const sequenceAssertionPromise = new ManagedPromise(); + let sequenceIndex = 0; touchpointCoordinator.on('recognizedgesture', async (sequence) => { try { sequencePromise.resolve(); - await assertGestureSequence(sequence, completionPromise, sequenceAssertion); + await assertGestureSequence(sequence, completionPromise, sequenceAssertions[sequenceIndex++]); sequenceAssertionPromise.resolve(); } catch(err) { sequenceAssertionPromise.reject(err); @@ -344,7 +444,7 @@ describe("TouchpointCoordinator", () => { { matchedId: 'simple-tap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -358,7 +458,7 @@ describe("TouchpointCoordinator", () => { { matchedId: 'simple-tap', item: 'b', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -448,7 +548,7 @@ describe("TouchpointCoordinator", () => { { matchedId: 'simple-tap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -462,7 +562,7 @@ describe("TouchpointCoordinator", () => { { matchedId: 'simple-tap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -546,7 +646,7 @@ describe("TouchpointCoordinator", () => { { matchedId: 'simple-tap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -653,7 +753,7 @@ describe("TouchpointCoordinator", () => { { matchedId: 'simple-tap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { assert.equal(sources.length, 1); assert.isTrue(sources[0].isPathComplete); @@ -663,7 +763,7 @@ describe("TouchpointCoordinator", () => { { matchedId: 'multitap', item: 'a', - linkType: 'optional-chain', + linkType: 'chain', sources: (sources) => { // Assert single-source; the first tap is not under consideration for this stage. assert.equal(sources.length, 1); From b8e758ba7e26d13ab0a7b9bda2448d6d8ff26d57 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Thu, 21 Sep 2023 11:41:20 +0700 Subject: [PATCH 3/7] chore(web): removes debug console-logging --- .../auto/headless/gestures/touchpointCoordinator.spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts index b08bd9ce4ac..6e6acd54e4a 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts @@ -577,9 +577,6 @@ describe("TouchpointCoordinator", () => { let index = 0; touchpointCoordinator.on('recognizedgesture', async (sequence) => { const i = index++; - - // FIXME: is occurring twice! - console.log(sequence); try { sequencePromises[i].resolve(); await assertGestureSequence(sequence, completionPromise, sequenceAssertions[i]); @@ -774,8 +771,6 @@ describe("TouchpointCoordinator", () => { const sequencePromise = new ManagedPromise(); const sequenceAssertionPromise = new ManagedPromise(); touchpointCoordinator.on('recognizedgesture', async (sequence) => { - // FIXME: is occurring twice! - console.log(sequence); try { sequencePromise.resolve(); await assertGestureSequence(sequence, completionPromise, sequenceAssertion); From cfd58a0a07b70f3e79e6f18d9f50ba42e9071c4e Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 22 Sep 2023 08:51:17 +0700 Subject: [PATCH 4/7] chore(web): TODO cleanup, modipress test prep --- .../gestures/matchers/gestureSequence.ts | 6 +++ .../engine/headless/touchpointCoordinator.ts | 12 ++++-- .../gestures/gestureModelDefs.spec.ts | 6 ++- .../headless/gestures/gestureSequence.spec.ts | 6 +-- .../headless/gestures/isolatedGestureSpecs.ts | 42 +++++++++++++++++++ .../gestures/touchpointCoordinator.spec.ts | 7 ++-- 6 files changed, 68 insertions(+), 11 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts index 133c0106918..7615a642c3d 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureSequence.ts @@ -117,6 +117,12 @@ export class GestureSequence extends EventEmitter> { }) ?? []; if(selection.result.action.type == 'complete' || selection.result.action.type == 'none') { + if(this.pushedSelector) { + // TODO: may need extra handling for 'sustain' states - like if a modipress's nested + // longpress is in subkey-select mode, to preserve that state instead of interrupting it. + this.touchpointCoordinator.popSelector(this.pushedSelector); + } + sources.forEach((source) => { if(!source.isPathComplete) { source.terminate(selection.result.action.type == 'none'); diff --git a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts index 8ace8060114..e302ce3d948 100644 --- a/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts +++ b/common/web/gesture-recognizer/src/engine/headless/touchpointCoordinator.ts @@ -105,9 +105,8 @@ export class TouchpointCoordinator extends EventEmitter extends EventEmitter) { touchpoint.path.on('invalidated', () => { - // TODO: on cancellation, is there any other cleanup to be done? + // GestureSequence _should_ handle any other cleanup internally as fallout + // from the path being cancelled. + + // To consider: should it specially mark if it 'completed' due to cancellation, + // or is that safe to infer from the tracked GestureSource(s)? + // Currently, we're going with the latter. // Also mark the touchpoint as no longer active. let i = this._activeSources.indexOf(touchpoint); diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts index 6ffdf77dcf5..7349877a760 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureModelDefs.spec.ts @@ -36,7 +36,11 @@ describe("Gesture model definitions", () => { }); it('getGestureModelSet', () => { - assert.sameMembers([LongpressModel, SimpleTapModel], getGestureModelSet(TestGestureModelDefinitions, 'default')); + assert.sameMembers(getGestureModelSet(TestGestureModelDefinitions, 'default'), [LongpressModel, SimpleTapModel]); + // An empty set is fine; this can disable detection of new gestures in certain conditions. + assert.sameMembers(getGestureModelSet(TestGestureModelDefinitions, 'none'), []); + + // These two, on the other hand, should always be considered errors. assert.throws(() => getGestureModelSet(TestGestureModelDefinitions, 'malformed')); assert.throws(() => getGestureModelSet(TestGestureModelDefinitions, 'unavailable-set')); }); diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts index 5004352f42c..1669194946f 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureSequence.spec.ts @@ -34,11 +34,11 @@ const TestGestureModelDefinitions: GestureModelDefs = { MultitapModel, SimpleTapModel, SubkeySelectModel, - // TODO: add something for a starting modipress. + // While modipress should be in the final set, it's not particularly testable without + // TouchpointCoordinator integration. ], sets: { - default: [LongpressModel.id, SimpleTapModel.id, /* TODO: add a 'starting modipress' model */], - // TODO: modipress: [LongpressModel.id, SimpleTapModel.id], // no nested modipressing + default: [LongpressModel.id, SimpleTapModel.id], } } diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts index c8b0a958e48..4be58954b0c 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts @@ -131,4 +131,46 @@ export const SubkeySelectModel: GestureModel = { type: 'complete', item: 'current' } +} + +export const ModipressStartModel: GestureModel = { + id: 'modipress-start', + resolutionPriority: 5, + itemPriority: 0, + contacts: [ + { + model: { + ...specs.ModipressStartModel, + allowsInitialState(incomingSample, comparisonSample, baseItem) { + const modifierKeyIds = ['shift', 'alt', 'ctrl']; + return modifierKeyIds.indexOf(baseItem) != -1; + }, + itemChangeAction: 'reject', + itemPriority: 1 + } + } + ], + resolutionAction: { + type: 'chain', + next: 'modipress-end', + selectionMode: 'modipress', + item: 'current' // return the modifier key ID so that we know to shift to it! + } +} + +export const ModipressEndModel: GestureModel = { + id: 'modipress-end', + resolutionPriority: 5, + itemPriority: 0, + contacts: [ + { + model: { + ...specs.ModipressEndModel, + itemChangeAction: 'reject' + } + } + ], + resolutionAction: { + type: 'complete' + } } \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts index 6e6acd54e4a..7b52d59ad8e 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts @@ -18,6 +18,7 @@ import { assertGestureSequence, SequenceAssertion } from "../../../resources/seq import { LongpressModel, + ModipressStartModel, MultitapModel, SimpleTapModel, SubkeySelectModel @@ -29,11 +30,11 @@ const TestGestureModelDefinitions: GestureModelDefs = { MultitapModel, SimpleTapModel, SubkeySelectModel, - // TODO: add something for a starting modipress. + ModipressStartModel ], sets: { - default: [LongpressModel.id, SimpleTapModel.id, /* TODO: add a 'starting modipress' model */], - // TODO: modipress: [LongpressModel.id, SimpleTapModel.id], // no nested modipressing + default: [LongpressModel.id, SimpleTapModel.id, ModipressStartModel.id], + modipress: [LongpressModel.id, SimpleTapModel.id], // no nested modipressing none: [] } } From 5a607a01ea86e505f1f350dff1794ee4e202f86f Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Fri, 22 Sep 2023 09:42:08 +0700 Subject: [PATCH 5/7] fix(web): base item for first source matching a gesture-model --- .../headless/gestures/matchers/gestureMatcher.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts index b32df366bc5..619036e8fa6 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts @@ -299,11 +299,13 @@ export class GestureMatcher implements PredecessorMatch { const contactSpec = this.model.contacts[existingContacts]; const contactModel = new PathMatcher(contactSpec.model, simpleSource); + // Add it early, as we need it to be accessible for reference via .primaryPath stuff below. + this.pathMatchers.push(contactModel); + let baseItem: Type = null; - if(existingContacts) { - // just use the highest-priority item source's base item and call it a day. - baseItem = this.primaryPath.baseItem; - } else if(this.predecessor && this.model.sustainTimer) { + // If there were no existing contacts but a predecessor exists and a sustain timer + // has been specified, it needs special base-item handling. + if(!existingContacts && this.predecessor && this.model.sustainTimer) { const baseItemMode = this.model.sustainTimer.baseItem ?? 'result'; switch(baseItemMode) { @@ -317,6 +319,10 @@ export class GestureMatcher implements PredecessorMatch { baseItem = this.predecessor.result.action.item; break; } + } else { + // just use the highest-priority item source's base item and call it a day. + // There's no need to refer to some previously-existing source for comparison. + baseItem = this.primaryPath.baseItem; } if(contactSpec.model.allowsInitialState) { @@ -330,8 +336,6 @@ export class GestureMatcher implements PredecessorMatch { contactModel.promise.then((resolution) => { this.finalize(resolution.type == 'resolve', resolution.cause); }); - - this.pathMatchers.push(contactModel); } update() { From b3925aefd886f63eac246083adfbb1bd17f5d9e0 Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 25 Sep 2023 09:42:47 +0700 Subject: [PATCH 6/7] fix(web): bug fixes - separate matcher-Promise handling issue, instant path resolutions --- .../gestures/matchers/gestureMatcher.ts | 63 +++++++++++++------ .../gestures/matchers/matcherSelector.ts | 62 +++++++++++++----- .../headless/gestures/gestureMatcher.spec.ts | 3 + 3 files changed, 94 insertions(+), 34 deletions(-) diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts index 619036e8fa6..020e82ac53d 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/gestureMatcher.ts @@ -40,6 +40,8 @@ export class GestureMatcher implements PredecessorMatch { }).filter((entry) => !!entry); } + private _isCancelled: boolean = false; + private readonly predecessor?: PredecessorMatch; private readonly publishedPromise: ManagedPromise>; // unsure on the actual typing at the moment. @@ -134,6 +136,14 @@ export class GestureMatcher implements PredecessorMatch { } } + public cancel() { + this._isCancelled = true; + } + + public get isCancelled(): boolean { + return this._isCancelled; + } + private finalize(matched: boolean, cause: FulfillmentCause) { if(this.publishedPromise.isFulfilled) { return this._result; @@ -161,24 +171,6 @@ export class GestureMatcher implements PredecessorMatch { }; } - for(let i = 0; i < this.pathMatchers.length; i++) { - const matcher = this.pathMatchers[i]; - const contactSpec = this.model.contacts[i]; - - // If the path already terminated, no need to evaluate further for this contact point. - if(matcher.source.isPathComplete) { - continue; - } - - if(matched && contactSpec.endOnResolve) { - matcher.source.terminate(false); - } else if(!matched && contactSpec.endOnReject) { - // Ending due to gesture-rejection effectively means to cancel the path, - // so signal exactly that. - matcher.source.terminate(true); - } - } - // Determine the item source for the item to be reported for this gesture, if any. let resolutionItem: Type; const itemSource = action.item ?? 'current'; @@ -213,6 +205,38 @@ export class GestureMatcher implements PredecessorMatch { } } + /** + * Applies any source-finalization specified by the model based on whether or not it was matched. + * It is invalid to call this method before model evaluation is complete. + * + * Additionally, this should only be applied for "selected" gesture models - those that "win" + * and are accepted as part of a GestureSequence. + */ + public finalizeSources() { + if(!this._result) { + throw Error("Invalid state for source-finalization - the matcher's evaluation of the gesture model is not yet complete"); + } + + const matched = this._result.matched; + for(let i = 0; i < this.pathMatchers.length; i++) { + const matcher = this.pathMatchers[i]; + const contactSpec = this.model.contacts[i]; + + // If the path already terminated, no need to evaluate further for this contact point. + if(matcher.source.isPathComplete) { + continue; + } + + if(matched && contactSpec.endOnResolve) { + matcher.source.terminate(false); + } else if(!matched && contactSpec.endOnReject) { + // Ending due to gesture-rejection effectively means to cancel the path, + // so signal exactly that. + matcher.source.terminate(true); + } + } + } + /** * Determines the active path-matcher best suited to serve as the "primary" path for the gesture. * @@ -333,6 +357,9 @@ export class GestureMatcher implements PredecessorMatch { } } + // Now that we've done the initial-state check, we can check for instantly-matching path models. + contactModel.update(); + contactModel.promise.then((resolution) => { this.finalize(resolution.type == 'resolve', resolution.cause); }); diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts index 4606087297c..75b45a4c814 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/matcherSelector.ts @@ -233,6 +233,23 @@ export class MatcherSelector extends EventEmitter> { } } + /* If cancellation was requested but not pre-filtered by the synchronizer setup, replace + * the result object. The matcher's Promise may have resolved simultaneously with the + * winner but 'lost', a scenario that may require careful handling to clean up. + */ + if(matcher.isCancelled) { + result = { + matched: false, + action: { + type: 'none', + item: null + } + }; + } else { + // Since we've selected this matcher, it should apply any model-specified finalization necessary. + matcher.finalizeSources(); + } + // Find ALL associated match-promises for sources matched by the matcher. const matchedContactIds = matcher.allSourceIds; @@ -249,6 +266,12 @@ export class MatcherSelector extends EventEmitter> { // It's already been handled; do not re-attempt. return; } + + if(matcher.isCancelled) { + // Fortunately, the rest of the code will help us recover from the state. + console.warn("Unexpected state: a cancelled GestureMatcher was still listed as a possibility"); + } + this.potentialMatchers.splice(matcherIndex, 1); /* @@ -331,29 +354,36 @@ export class MatcherSelector extends EventEmitter> { return !losingMatchers.find((matcher2) => matcher == matcher2); }); - // Drop all trackers for the matched sources. + /* + * While the 'synchronizer' setup will perfectly handle most cases, we need this block to catch + * a somewhat niche case: if a second source was added to the matcher at a later point in time, + * there are two separate Promise handlers - with separate synchronization sets. We use the + * `cancel` method to ensure that cancellation from one set propagates to the other handler. + * (It seems the simplest & most straightforward approach to do ensure localized, per-matcher + * consistency without mangling any matchers that shouldn't be affected.) + * + * This can arise if a modipress is triggered at the same time a new touchpoint begins, which + * could trigger a simple-tap. + */ + losingMatchers.forEach((matcher) => { + matcher.cancel(); + }); + + // Drop the newly-cancelled trackers. this._sourceSelector = this._sourceSelector.filter((a) => !sourceMetadata.find((b) => a == b)); // And now for one line with some "heavy lifting": /* - * Does two things: - * 1. Fulfills the contract set by `matchGesture`. + * Fulfills the contract set by `matchGesture`. * - * 2. Fulfilling the ManagedPromise acts as a synchronizer, facilitating the guarantee at - * the start of this closure. It's set synchronously, so other gesture-matchers that - * call into this method will know that a match has already fulfilled for the matched + * Also, fulfilling the ManagedPromise acts as a synchronizer, partially facilitating the + * guarantee at the start of this closure. It's set synchronously, so other gesture-matchers + * that call into this method will know that a match has already fulfilled for the matched * source(s). Any further matchers will be silently ignored, effectively cancelling them. - * - * If we're within this closure, the closure's synchronizer-promise matches the instance - * currently set on its `tracker` - as are any others affected by the resolving matcher. - * - * It _is_ possible that we may need to resolve a Promise not included in the synchronizer - * set - if a second contact / source was added at a later point in time to something that - * started single-contact. Two separate 'raise' attempts would occur, since the links to - * this method were set for each source independently. The most consistent way to ensure - * synchronization is thus to rely on the instance annotated on the tracker itself for - * each matched source. + * However, this fails to handle the case where two separate calls to matcherSelectionFilter + * occur for the same matcher due to one source being added at a later point in time; + * this is what the `cancel` */ tracker.matchPromise.resolve({matcher, result}); } diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts index 3e14bb1c4aa..5d60364ddf3 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/gestureMatcher.spec.ts @@ -643,6 +643,7 @@ describe("GestureMatcher", function() { assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED); assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap'}}); + modelMatcher.finalizeSources(); assert.isTrue(sources[1].path.isComplete); // Design note: as this one is _not_ complete, when gesture chaining tries to do a followup multitap match, @@ -719,6 +720,7 @@ describe("GestureMatcher", function() { assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED); assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap'}}); + modelMatcher.finalizeSources(); assert.isTrue(sources[0].path.isComplete); assert.isFalse(sources[1].path.isComplete); }); @@ -888,6 +890,7 @@ describe("GestureMatcher", function() { assert.equal(await promiseStatus(secondMatcher.promise), PromiseStatuses.PROMISE_RESOLVED); assert.deepEqual(await secondMatcher.promise, { matched: false, action: { type: 'none', item: null } }); + secondMatcher.finalizeSources(); assert.isTrue(sources[0].path.isComplete); assert.isTrue(sources[0].path.wasCancelled); }); From ca4ce8c091988cd198879c825fa5198e39b41c8b Mon Sep 17 00:00:00 2001 From: "Joshua A. Horton" Date: Mon, 25 Sep 2023 09:43:17 +0700 Subject: [PATCH 7/7] feat(web): adds initial modipress automated tests --- .../headless/gestures/isolatedGestureSpecs.ts | 3 +- .../gestures/touchpointCoordinator.spec.ts | 350 +++++++++++++++++- 2 files changed, 351 insertions(+), 2 deletions(-) diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts index 4be58954b0c..6677bf87767 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/isolatedGestureSpecs.ts @@ -171,6 +171,7 @@ export const ModipressEndModel: GestureModel = { } ], resolutionAction: { - type: 'complete' + type: 'complete', + item: 'none' } } \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts index 7b52d59ad8e..d8bc4099970 100644 --- a/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/touchpointCoordinator.spec.ts @@ -18,6 +18,7 @@ import { assertGestureSequence, SequenceAssertion } from "../../../resources/seq import { LongpressModel, + ModipressEndModel, ModipressStartModel, MultitapModel, SimpleTapModel, @@ -30,7 +31,8 @@ const TestGestureModelDefinitions: GestureModelDefs = { MultitapModel, SimpleTapModel, SubkeySelectModel, - ModipressStartModel + ModipressStartModel, + ModipressEndModel ], sets: { default: [LongpressModel.id, SimpleTapModel.id, ModipressStartModel.id], @@ -790,4 +792,350 @@ describe("TouchpointCoordinator", () => { await sequenceAssertionPromise.corePromise; assert.isEmpty(touchpointCoordinator.activeGestures); }); + + it('modipress: one simple tap before its end', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'shift' + }); + turtle0.wait(120, 6); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 11, + targetY: 11, + t: 200, + item: 'a' + }); + turtle1.wait(40, 2); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }).then(async () => { + // Ride out the multitap timer so we can achieve full completion. + + // TODO: We should not need this timer when all is said and done; the modipress's end + // should auto-terminate any further gesture processing on the simple-tap sequence. + + // Please request changes if this TODO remains. + + let promise = timedPromise(MultitapModel.sustainTimer.duration+1).then(() => {}); + await fakeClock.runToLastAsync(); + await promise; + }); + + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'modipress-start', + item: 'shift', + linkType: 'chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isFalse(sources[0].isPathComplete); + return; + } + }, { + matchedId: 'modipress-end', + item: null, + linkType: 'complete', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + ], [ + { + matchedId: 'simple-tap', + item: 'a', + linkType: 'chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + ] + ]; + + const sequencePromises = [new ManagedPromise(), new ManagedPromise()]; + const sequenceAssertionPromises = [new ManagedPromise(), new ManagedPromise()]; + let sequenceIndex = 0; + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + const index = sequenceIndex++; + try { + sequencePromises[index].resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertions[index]); + sequenceAssertionPromises[index].resolve(); + } catch(err) { + sequenceAssertionPromises[index].reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromises[0].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await sequencePromises[1].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await runnerPromise; + + // index 0 should complete before index 1... when the earlier TODO is fixed. + await sequenceAssertionPromises[1].corePromise; + assert.isEmpty(touchpointCoordinator.activeGestures); + }); + + it('modipress: disallows nested modipress attempts', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'shift' + }); + turtle0.wait(120, 6); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 11, + targetY: 11, + t: 200, + item: 'alt' + }); + turtle1.wait(40, 2); + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }).then(async () => { + // Ride out the multitap timer so we can achieve full completion. + + // TODO: We should not need this timer when all is said and done; the modipress's end + // should auto-terminate any further gesture processing on the simple-tap sequence. + + // Please request changes if this TODO remains. + + let promise = timedPromise(MultitapModel.sustainTimer.duration+1).then(() => {}); + await fakeClock.runToLastAsync(); + await promise; + }); + + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'modipress-start', + item: 'shift', + linkType: 'chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isFalse(sources[0].isPathComplete); + return; + } + }, { + matchedId: 'modipress-end', + item: null, + linkType: 'complete', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + ], [ + /* In the test config, there's nothing preventing 'alt' from being + * considered a legal key for simple taps. Because of the ongoing + * shift-modipress, it can't itself be modipressed... and so it + * shows up like this. + */ + { + matchedId: 'simple-tap', + item: 'alt', + linkType: 'chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + ] + ]; + + const sequencePromises = [new ManagedPromise(), new ManagedPromise()]; + const sequenceAssertionPromises = [new ManagedPromise(), new ManagedPromise()]; + let sequenceIndex = 0; + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + const index = sequenceIndex++; + try { + sequencePromises[index].resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertions[index]); + sequenceAssertionPromises[index].resolve(); + } catch(err) { + sequenceAssertionPromises[index].reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromises[0].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await sequencePromises[1].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await runnerPromise; + + // index 0 should complete before index 1... when the earlier TODO is fixed. + await sequenceAssertionPromises[1].corePromise; + assert.isEmpty(touchpointCoordinator.activeGestures); + }); + + it('modipress: simple longpress within its duration', async () => { + const turtle0 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'shift' + }); + turtle0.wait(1000, 50); + turtle0.commitPending(); + + const turtle1 = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 120, + item: 'a' + }); + turtle1.wait(600, 50); + turtle1.move(0, 10, 100, 5); + turtle1.hoveredItem = 'à'; + turtle1.move(90, 10, 100, 5); + turtle1.hoveredItem = 'â'; + turtle1.commitPending(); + + const emulationEngine = new HeadlessInputEngine(); + const touchpointCoordinator = new TouchpointCoordinator(TestGestureModelDefinitions, [emulationEngine]); + const completionPromise = emulationEngine.playbackRecording({ + inputs: [ { + path: { + coords: turtle0.path, + }, + isFromTouch: true + }, { + path: { + coords: turtle1.path, + }, + isFromTouch: true + }], + config: null + }); + + const sequenceAssertions: SequenceAssertion[] = [ + [ + { + matchedId: 'modipress-start', + item: 'shift', + linkType: 'chain', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isFalse(sources[0].isPathComplete); + return; + } + }, { + matchedId: 'modipress-end', + item: null, + linkType: 'complete', + sources: (sources) => { + assert.equal(sources.length, 1); + assert.isTrue(sources[0].isPathComplete); + return; + } + } + ], [ + { + matchedId: 'longpress', + item: null, + linkType: 'chain', + sources: (sources) => { + // Assert single-source + assert.equal(sources.length, 1); + + // Assert wait appropriate to the longpress threshold. Likely won't be the full 1000 ms. + const pathStats = sources[0].path.stats; + assert.isAtLeast(pathStats.duration, LongpressModel.contacts[0].model.timer.duration - 1); + assert.isAtMost(pathStats.rawDistance, 0.1); + return; + } + }, + { + matchedId: 'subkey-select', + item: 'â', + linkType: 'complete', + sources: (sources) => { + const pathStats = sources[0].path.stats; + assert.isAtLeast(pathStats.rawDistance, 19.9); + assert.isAtLeast(pathStats.duration, 1200 - LongpressModel.contacts[0].model.timer.duration - 2); + } + } + ] + ]; + + const sequencePromises = [new ManagedPromise(), new ManagedPromise()]; + const sequenceAssertionPromises = [new ManagedPromise(), new ManagedPromise()]; + let sequenceIndex = 0; + touchpointCoordinator.on('recognizedgesture', async (sequence) => { + const index = sequenceIndex++; + try { + sequencePromises[index].resolve(); + await assertGestureSequence(sequence, completionPromise, sequenceAssertions[index]); + sequenceAssertionPromises[index].resolve(); + } catch(err) { + sequenceAssertionPromises[index].reject(err); + } + }); + + const runnerPromise = fakeClock.runToLastAsync(); + + await sequencePromises[0].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await sequencePromises[1].corePromise; + assert.isNotEmpty(touchpointCoordinator.activeGestures); + + await runnerPromise; + + // index 0 completes before index 1! + await sequenceAssertionPromises[0].corePromise; + assert.isEmpty(touchpointCoordinator.activeGestures); + }); }); \ No newline at end of file