diff --git a/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts b/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts index d229a7c9a0..66ce86df7d 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts @@ -82,8 +82,8 @@ export class GestureSource { } public update(sample: InputSample) { - this.path.extend(sample); this._baseItem ||= sample.item; + this.path.extend(sample); } /** @@ -111,7 +111,7 @@ export class GestureSource { * from the most recently-observed path coordinate. * @returns */ - public constructSubview(startAtEnd: boolean, preserveBaseItem: boolean) { + public constructSubview(startAtEnd: boolean, preserveBaseItem: boolean): GestureSourceSubview { return new GestureSourceSubview(this, startAtEnd, preserveBaseItem); } diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/index.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/index.ts index b337d4379e..1e7cb5b123 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/index.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/index.ts @@ -1,2 +1,3 @@ export { GestureMatcher } from './gestureMatcher.js'; -export { PathMatcher } from './pathMatcher.js'; \ No newline at end of file +export { PathMatcher } from './pathMatcher.js'; +export { MatcherSelection, MatcherSelector } from './matcherSelector.js'; \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/pathMatcher.ts b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/pathMatcher.ts index 50c51faffb..ec9245fc26 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/pathMatcher.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestures/matchers/pathMatcher.ts @@ -106,7 +106,9 @@ export class PathMatcher { return this.finalize(false, 'path'); } - if(model.itemChangeAction && source.currentSample.item != source.baseItem) { + // For certain unit-test setups, we may have a zero-length path when this is called during test init. + // It's best to have that path-coord-length check in place, just in case. + if(model.itemChangeAction && source.path.coords.length > 0 && source.currentSample.item != source.baseItem) { const result = model.itemChangeAction == 'resolve'; return this.finalize(result, 'item'); diff --git a/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts b/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts new file mode 100644 index 0000000000..42228eb51c --- /dev/null +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestureSource.spec.ts @@ -0,0 +1,51 @@ +import { assert } from 'chai' +import { GestureSource, InputSample } from '@keymanapp/gesture-recognizer'; + +// Should probably be a bit more thorough, but it's a start. +describe("GestureSource", function() { + describe(".constructSubview", function() { + it("properly propagates updates", () => { + let source = new GestureSource(0, true); + let subview = source.constructSubview(true, true); + + assert.equal(subview.path.coords.length, 0); + + let sample: InputSample = { + targetX: 1, + targetY: 2, + item: 'hello', + t: 101 + }; + + source.update(sample); + + assert.equal(subview.path.coords.length, 1); + assert.equal(subview.currentSample, sample); + }); + + it("propagates path termination (complete)", () => { + let source = new GestureSource(0, true); + let subview = source.constructSubview(true, true); + let subview2 = source.constructSubview(true, true); + + assert.equal(subview.path.coords.length, 0); + + let sample: InputSample = { + targetX: 1, + targetY: 2, + item: 'hello', + t: 101 + }; + + source.update(sample); + subview.terminate(false); + + assert.equal(subview.path.coords.length, 1); + assert.equal(subview.currentSample, sample); + assert.equal(subview.isPathComplete, true); + assert.equal(subview.path.wasCancelled, false); + assert.equal(subview2.isPathComplete, true); + assert.equal(subview2.path.wasCancelled, false); + }); + }); +}); \ No newline at end of file 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 d17dc311cd..65951eecd0 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 @@ -45,7 +45,7 @@ export const LongpressModel: GestureModel = { export const MultitapModel: GestureModel = { id: 'multitap', - resolutionPriority: 1, + resolutionPriority: 2, itemPriority: 1, contacts: [ { @@ -75,7 +75,7 @@ export const MultitapModel: GestureModel = { export const SimpleTapModel: GestureModel = { id: 'simple-tap', - resolutionPriority: 0, + resolutionPriority: 1, itemPriority: 0, contacts: [ { @@ -91,7 +91,14 @@ export const SimpleTapModel: GestureModel = { ], resolutionAction: { type: 'optional-chain', - allowNext: 'multitap' + allowNext: 'multitap', + item: 'current' + }, + rejectionActions: { + item: { + type: 'optional-chain', + allowNext: 'simple-tap' + } } } 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 new file mode 100644 index 0000000000..40ad193621 --- /dev/null +++ b/common/web/gesture-recognizer/src/test/auto/headless/gestures/matcherSelector.spec.ts @@ -0,0 +1,381 @@ +import { assert } from 'chai' +import sinon from 'sinon'; + +import * as PromiseStatusModule from 'promise-status-async'; +const PromiseStatuses = PromiseStatusModule.PromiseStatuses; +import { assertingPromiseStatus as promiseStatus } from '../../../resources/assertingPromiseStatus.js'; + +import { simulateMultiSourceSelectorInput } from "../../../resources/simulateMultiSourceInput.js"; + +import { GestureSource, gestures } from '@keymanapp/gesture-recognizer'; + +import { TouchpathTurtle } from '#tools'; + +type MatcherSelection = gestures.matchers.MatcherSelection; +type MatcherSelector = gestures.matchers.MatcherSelector; +type GestureModel = gestures.specs.GestureModel; + +import { + LongpressModel, + MultitapModel, + SimpleTapModel, + SubkeySelectModel +} from './isolatedGestureSpecs.js'; + +import { + LongpressDistanceThreshold, + MainLongpressSourceModel +} from './isolatedPathSpecs.js'; + +describe("MatcherSelector", function () { + beforeEach(function() { + this.fakeClock = sinon.useFakeTimers(); + }); + + afterEach(function() { + this.fakeClock.restore(); + }); + + describe("Single-source", function() { + describe("First stage", function() { + it("Longpress (in isolation)", async function() { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.wait(1000, 50); + turtle.commitPending(); + + const { + sources, + selectionPromises, + selectorPromise, + executor + } = simulateMultiSourceSelectorInput([ + { + sequence: { type: 'sequence', samples: turtle.path, terminate: false }, + specSet: [LongpressModel] + } + ], this.fakeClock); + + let completion = executor(); + const selector = await selectorPromise; + await Promise.race([completion, selectionPromises[0]]); + + assert.equal(await promiseStatus(selectionPromises[0]), PromiseStatuses.PROMISE_RESOLVED); + 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: 'subkeyselect'}}); + assert.deepEqual(selection.matcher.model, LongpressModel); + assert.isFalse(sources[0].path.isComplete); + + // Allow the rest of the simulation to play out; it's easy cleanup that way. + await completion; + }); + + it("Longpress (with other possibilities)", async function() { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.wait(1000, 50); + turtle.commitPending(); + + const { + sources, + selectionPromises, + selectorPromise, + executor + } = simulateMultiSourceSelectorInput([ + { + sequence: { type: 'sequence', samples: turtle.path, terminate: false }, + specSet: [LongpressModel, MultitapModel, SimpleTapModel] + } + ], this.fakeClock); + + let completion = executor(); + const selector = await selectorPromise; + await Promise.race([completion, selectionPromises[0]]); + + assert.equal(await promiseStatus(selectionPromises[0]), PromiseStatuses.PROMISE_RESOLVED); + 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: 'subkeyselect'}}); + assert.deepEqual(selection.matcher.model, LongpressModel); + assert.isFalse(sources[0].path.isComplete); + + // Allow the rest of the simulation to play out; it's easy cleanup that way. + await completion; + }); + + it("Longpress reject (due to path) -> reset request (in isolation)", async function() { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.move(90, LongpressDistanceThreshold + 2, LongpressModel.contacts[0].model.timer.duration, 20); + turtle.wait(1000, 50); + turtle.commitPending(); + + const { + sources, + selectionPromises, + selectorPromise, + executor + } = simulateMultiSourceSelectorInput([ + { + sequence: { type: 'sequence', samples: turtle.path, terminate: false }, + specSet: [LongpressModel] + } + ], this.fakeClock); + + + let completion = executor(); + const selector = await selectorPromise; + const rejectionStub = sinon.fake(); + selector.on('rejectionwithaction', rejectionStub); + + await Promise.race([completion, selectionPromises[0]]); + + assert.equal(await promiseStatus(selectionPromises[0]), PromiseStatuses.PROMISE_PENDING); + // It finished simulating without a match. (Note: we don't terminate the simulated + // touchpath - `terminate: false`.) + assert.equal(await promiseStatus(completion), PromiseStatusModule.PROMISE_RESOLVED); + assert.isTrue(rejectionStub.calledOnce); + + const rejectionData = rejectionStub.firstCall.args as [ MatcherSelection, (model: GestureModel) => void ]; + assert.equal(rejectionData[0].matcher.model.id, 'longpress'); + assert.equal(rejectionData[0].result.matched, false); + assert.deepEqual(rejectionData[0].result.action, { type: 'optional-chain', allowNext: 'longpress', item: null}); + + // ... we technically already have it, but this _is_ a convenient pattern to maintain for + // consistency among all this suite's tests. + await completion; + }); + + it("Longpress rejection replacement (in isolation)", async function() { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.move(90, LongpressDistanceThreshold + 2, LongpressModel.contacts[0].model.timer.duration - 2, 20); + turtle.hoveredItem = 'b'; + turtle.wait(1000, 50); + turtle.commitPending(); + + const { + sources, + selectionPromises, + selectorPromise, + executor + } = simulateMultiSourceSelectorInput([ + { + sequence: { type: 'sequence', samples: turtle.path, terminate: false }, + specSet: [LongpressModel] + } + ], this.fakeClock); + + + let completion = executor(); + const selector = await selectorPromise; + let rejectionCounter = 0; + selector.on('rejectionwithaction', (selection, replaceModelWith) => { + assert.equal(selection.matcher.model.id, 'longpress'); + + rejectionCounter++; + // Just... restart the model. + replaceModelWith(LongpressModel); + }); + + await Promise.race([completion, selectionPromises[0]]); + + assert.equal(await promiseStatus(selectionPromises[0]), PromiseStatuses.PROMISE_RESOLVED); + // It finished simulating without a match. (Note: we don't terminate the simulated + // touchpath - `terminate: false`.) + 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: 'subkeyselect'}}); + assert.deepEqual(selection.matcher.model, LongpressModel); + + // Original base item was 'a'; 'b' proves that a reset occurred by the point of the 'item' change. + assert.equal(selection.matcher.baseItem, 'b'); + assert.isFalse(sources[0].path.isComplete); + + // One for path distance before the longpress timer completed, then one for change of current path 'item'. + // (from 'a' to 'b') + assert.equal(rejectionCounter, 2); + + await completion; + }); + + it("Longpress rejection replacement (with other possibilities)", async function() { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.move(90, LongpressDistanceThreshold + 2, LongpressModel.contacts[0].model.timer.duration - 2, 20); + turtle.hoveredItem = 'b'; + turtle.wait(1000, 50); + turtle.commitPending(); + + const { + sources, + selectionPromises, + selectorPromise, + executor + } = simulateMultiSourceSelectorInput([ + { + sequence: { type: 'sequence', samples: turtle.path, terminate: false }, + // Current problem: adding the extra models leads to rejection of all? + specSet: [LongpressModel, MultitapModel, SimpleTapModel] + } + ], this.fakeClock); + + + let completion = executor(); + const selector = await selectorPromise; + selector.on('rejectionwithaction', (selection, replaceModelWith) => { + if(selection.matcher.model.id == 'longpress') { + replaceModelWith(LongpressModel); + } else if(selection.matcher.model.id == 'simple-tap') { + replaceModelWith(SimpleTapModel); + } else { + assert.fail(); + } + }); + + await Promise.race([completion, selectionPromises[0]]); + + assert.equal(await promiseStatus(selectionPromises[0]), PromiseStatuses.PROMISE_RESOLVED); + // It finished simulating without a match. (Note: we don't terminate the simulated + // touchpath - `terminate: false`.) + 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: 'subkeyselect'}}); + assert.deepEqual(selection.matcher.model, LongpressModel); + + // Original base item was 'a'; 'b' proves that a reset occurred by the point of the 'item' change. + assert.equal(selection.matcher.baseItem, 'b'); + assert.isFalse(sources[0].path.isComplete); + + await completion; + }); + + // roaming touch: relatively long + slow move; expect longpress reset w/ matched: false + // (not in isolation) + + it("Simple Tap (single source)", async function() { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.wait(100, 5); + turtle.commitPending(); + + const { + sources, + selectionPromises, + selectorPromise, + executor + } = simulateMultiSourceSelectorInput([ + { + sequence: { type: 'sequence', samples: turtle.path, terminate: true }, + specSet: [LongpressModel, MultitapModel, SimpleTapModel] + } + ], this.fakeClock); + + let completion = executor(); + const selector = await selectorPromise; + await Promise.race([completion, selectionPromises[0]]); + + // So, the terminate signal didn't complete the selection? + assert.equal(await promiseStatus(selectionPromises[0]), PromiseStatuses.PROMISE_RESOLVED); + assert.equal(await promiseStatus(completion), PromiseStatusModule.PROMISE_PENDING); + + const selection = await selectionPromises[0]; + + assert.deepEqual(selection.result, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }}); + assert.deepEqual(selection.matcher.model, SimpleTapModel); + assert.isTrue(sources[0].path.isComplete); + + // Allow the rest of the simulation to play out; it's easy cleanup that way. + await completion; + }); + + it("Simple Tap (single source) with reset", async function() { + const turtle = new TouchpathTurtle({ + targetX: 1, + targetY: 1, + t: 100, + item: 'a' + }); + turtle.wait(40, 5); + turtle.move(90, 2, 20, 1); + turtle.hoveredItem = 'b'; + turtle.wait(40, 5); + + turtle.commitPending(); + + const { + sources, + selectionPromises, + selectorPromise, + executor + } = simulateMultiSourceSelectorInput([ + { + sequence: { type: 'sequence', samples: turtle.path, terminate: true }, + specSet: [LongpressModel, MultitapModel, SimpleTapModel] + } + ], this.fakeClock); + + let completion = executor(); + let resets = 0; + const selector = await selectorPromise; + selector.on('rejectionwithaction', (selection, replaceModelWith) => { + if(selection.matcher.model.id == 'longpress') { + replaceModelWith(LongpressModel); + } else if(selection.matcher.model.id == 'simple-tap') { + resets++; + replaceModelWith(SimpleTapModel); + } else { + assert.fail(); + } + }); + + await Promise.race([completion, selectionPromises[0]]); + + // So, the terminate signal didn't complete the selection? + assert.equal(await promiseStatus(selectionPromises[0]), PromiseStatuses.PROMISE_RESOLVED); + assert.equal(await promiseStatus(completion), PromiseStatusModule.PROMISE_PENDING); + + const selection = await selectionPromises[0]; + + assert.deepEqual(selection.result, {matched: true, action: { type: 'optional-chain', item: 'b', allowNext: 'multitap' }}); + assert.deepEqual(selection.matcher.model, SimpleTapModel); + assert.isTrue(sources[0].path.isComplete); + assert.isAtLeast(resets, 1); + + // Allow the rest of the simulation to play out; it's easy cleanup that way. + await completion; + }); + }); + }); +}); \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts b/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts index d9f2e247c2..43116f25af 100644 --- a/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts +++ b/common/web/gesture-recognizer/src/test/resources/simulateMultiSourceInput.ts @@ -3,6 +3,8 @@ import { ManagedPromise, timedPromise } from '@keymanapp/web-utils'; import { GestureSourceSubview } from '../../../build/obj/headless/gestureSource.js'; type GestureMatcher = gestures.matchers.GestureMatcher; +type MatcherSelection = gestures.matchers.MatcherSelection; +type MatcherSelector = gestures.matchers.MatcherSelector; type GestureModel = gestures.specs.GestureModel; interface SimSpecSequence { @@ -328,4 +330,71 @@ export function simulateMultiSourceMatcherInput( modelMatcherPromise: testObjPromise, executor }; +} + +type MatcherSelectorInput = { + sequence: (SimSpecTimer | SimSpecSequence), + specSet: GestureModel[] +}[]; + +export function simulateMultiSourceSelectorInput( + input: MatcherSelectorInput, + fakeClock: sinon.SinonFakeTimers +): { + sources: GestureSource[], + selectionPromises: Promise>[], + selectorPromise: Promise>, + executor: () => Promise +} { + let inputClone = [].concat(input); + + // We NEED the sequences specified to be in chronological order of their start. + // We'll just check if it's done properly out-of-the-gate - by sorting a clone, then comparing. + // We shouldn't actually mutate / correct this b/c of the returned `selectorPromises` field. + inputClone.sort((a, b) => { + if(a.sequence.type == "timer") { + return -1; + } else if(b.sequence.type == "timer") { + return 1; + } else { + return a.sequence.samples[0].t - b.sequence.samples[0].t + } + }); + + for(let i=0; i < input.length; i++) { + if(input[i] != inputClone[i]) { + throw new Error("The specified input data should be in chronological order based on its start position."); + } + } + + let indexSeed = 0; + const selectionPromises: Promise>[] = []; + + const config: SimulationConfig, Type> = { + construction: (source) => { + const selector = new gestures.matchers.MatcherSelector(); + + // TS can't resolve the two-typed parameter to two separate overloads of the same method, it seems. + selectionPromises.push(selector.matchGesture(source as any, input[indexSeed++].specSet)); + + return selector; + }, + addSource: (obj, source) => { + obj.matchGesture(source, input[indexSeed++].specSet); + }, + update: () => {} + } + + const { + sources, + testObjPromise, + executor + } = simulateMultiSourceInput(config, input.map((entry) => entry.sequence), fakeClock); + + return { + sources, + selectionPromises, + selectorPromise: testObjPromise, + executor + }; } \ No newline at end of file