feat(web): further gesture-selection work - single source / first stage now vetted

This commit is contained in:
Joshua A. Horton 2023-08-08 13:29:45 +07:00
parent d03cd7f155
commit 978965fd5e
7 changed files with 518 additions and 7 deletions

View file

@ -82,8 +82,8 @@ export class GestureSource<HoveredItemType> {
}
public update(sample: InputSample<HoveredItemType>) {
this.path.extend(sample);
this._baseItem ||= sample.item;
this.path.extend(sample);
}
/**
@ -111,7 +111,7 @@ export class GestureSource<HoveredItemType> {
* from the most recently-observed path coordinate.
* @returns
*/
public constructSubview(startAtEnd: boolean, preserveBaseItem: boolean) {
public constructSubview(startAtEnd: boolean, preserveBaseItem: boolean): GestureSourceSubview<HoveredItemType> {
return new GestureSourceSubview(this, startAtEnd, preserveBaseItem);
}

View file

@ -1,2 +1,3 @@
export { GestureMatcher } from './gestureMatcher.js';
export { PathMatcher } from './pathMatcher.js';
export { PathMatcher } from './pathMatcher.js';
export { MatcherSelection, MatcherSelector } from './matcherSelector.js';

View file

@ -106,7 +106,9 @@ export class PathMatcher<Type> {
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');

View file

@ -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<string>(0, true);
let subview = source.constructSubview(true, true);
assert.equal(subview.path.coords.length, 0);
let sample: InputSample<string> = {
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<string>(0, true);
let subview = source.constructSubview(true, true);
let subview2 = source.constructSubview(true, true);
assert.equal(subview.path.coords.length, 0);
let sample: InputSample<string> = {
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);
});
});
});

View file

@ -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'
}
}
}

View file

@ -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<Type> = gestures.matchers.MatcherSelection<Type>;
type MatcherSelector<Type> = gestures.matchers.MatcherSelector<Type>;
type GestureModel<Type> = gestures.specs.GestureModel<Type>;
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<string>, (model: GestureModel<string>) => 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;
});
});
});
});

View file

@ -3,6 +3,8 @@ import { ManagedPromise, timedPromise } from '@keymanapp/web-utils';
import { GestureSourceSubview } from '../../../build/obj/headless/gestureSource.js';
type GestureMatcher<Type> = gestures.matchers.GestureMatcher<Type>;
type MatcherSelection<Type> = gestures.matchers.MatcherSelection<Type>;
type MatcherSelector<Type> = gestures.matchers.MatcherSelector<Type>;
type GestureModel<Type> = gestures.specs.GestureModel<Type>;
interface SimSpecSequence<Type> {
@ -328,4 +330,71 @@ export function simulateMultiSourceMatcherInput<Type>(
modelMatcherPromise: testObjPromise,
executor
};
}
type MatcherSelectorInput<Type> = {
sequence: (SimSpecTimer<Type> | SimSpecSequence<Type>),
specSet: GestureModel<Type>[]
}[];
export function simulateMultiSourceSelectorInput<Type>(
input: MatcherSelectorInput<Type>,
fakeClock: sinon.SinonFakeTimers
): {
sources: GestureSource<Type>[],
selectionPromises: Promise<MatcherSelection<Type>>[],
selectorPromise: Promise<MatcherSelector<Type>>,
executor: () => Promise<void>
} {
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<MatcherSelection<Type>>[] = [];
const config: SimulationConfig<MatcherSelector<Type>, Type> = {
construction: (source) => {
const selector = new gestures.matchers.MatcherSelector<Type>();
// 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
};
}