Merge pull request #9455 from keymanapp/feat/web/gesture-staging

feat(web): gesture stage sequencing 🐵
This commit is contained in:
Joshua Horton 2023-09-29 15:43:23 +07:00 committed by GitHub
commit de4054a703
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1117 additions and 43 deletions

View file

@ -3,6 +3,7 @@ import { MouseEventEngine } from "./mouseEventEngine.js";
import { Nonoptional } from "./nonoptional.js";
import { TouchEventEngine } from "./touchEventEngine.js";
import { TouchpointCoordinator } from "./headless/touchpointCoordinator.js";
import { EMPTY_GESTURE_DEFS, GestureModelDefs } from "./headless/gestures/specs/index.js";
export class GestureRecognizer<HoveredItemType> extends TouchpointCoordinator<HoveredItemType> {
public readonly config: Nonoptional<GestureRecognizerConfiguration<HoveredItemType>>;

View file

@ -1,6 +1,6 @@
import { GestureSource, GestureSourceSubview } from "../../gestureSource.js";
import { GestureModel, GestureResolution, GestureResolutionSpec, RejectionDefault, ResolutionItemSpec } from "../specs/gestureModel.js";
import { GestureModel, GestureResolution, GestureResolutionSpec, RejectionDefault, RejectionReplace, ResolutionItemSpec } from "../specs/gestureModel.js";
import { ManagedPromise, TimeoutPromise } from "@keymanapp/web-utils";
import { FulfillmentCause, PathMatcher } from "./pathMatcher.js";
@ -44,7 +44,13 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
private readonly pathMatchers: PathMatcher<Type>[];
public get sources(): GestureSource<Type>[] {
return this.pathMatchers.map((pathMatch) => pathMatch.source);
return this.pathMatchers.map((pathMatch, index) => {
if(this.model.contacts[index].resetOnResolve) {
return undefined;
} else {
return pathMatch.source;
}
}).filter((entry) => !!entry);
}
private readonly predecessor?: PredecessorMatch<Type>;
@ -148,7 +154,7 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
try {
// Determine the correct action-spec that should result from the finalization.
let action: GestureResolutionSpec | (RejectionDefault & ResolutionItemSpec);
let action: GestureResolutionSpec | ((RejectionDefault | RejectionReplace) & ResolutionItemSpec);
if(matched) {
// Easy peasy - resolutions only need & have the one defined action type.
action = this.model.resolutionAction;
@ -271,7 +277,8 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
* 'all'... but that'd take a little extra work.
*/
public get allSourceIds(): string[] {
let currentIds = this.pathMatchers.map((entry) => entry.source.identifier);
// Do not include any to-be-reset (thus, excluded) sources here.
let currentIds = this.sources.map((entry) => entry.identifier);
const predecessorIds = this.predecessor ? this.predecessor.allSourceIds : [];
// Each ID should only be listed once, regardless of source.

View file

@ -0,0 +1,226 @@
import EventEmitter from "eventemitter3";
import { GestureModelDefs, getGestureModel, getGestureModelSet } from "../specs/gestureModelDefs.js";
import { GestureSource, GestureSourceSubview } from "../../gestureSource.js";
import { GestureMatcher, MatchResult, PredecessorMatch } from "./gestureMatcher.js";
import { GestureModel, GestureResolution } from "../specs/gestureModel.js";
import { MatcherSelection, MatcherSelector } from "./matcherSelector.js";
import { GestureRecognizerConfiguration, TouchpointCoordinator } from "../../../index.js";
export class GestureStageReport<Type> {
public readonly matchedId: string;
public readonly linkType: MatchResult<Type>['action']['type'];
public readonly item: Type;
public readonly sources: GestureSourceSubview<Type>[];
public readonly allSourceIds: string[];
constructor(selection: MatcherSelection<Type>) {
const { matcher, result } = selection;
this.matchedId = matcher?.model.id;
this.linkType = result.action.type;
this.item = result.action.item;
// Assumption: GestureMatcher always builds the Subview type when constructing each PathMatcher.
// This assumption currently holds, though we could always do a quick instanceof-check to build a
// subview if it isn't already one.
//
// Each entry has a .baseSource property that may be used to refer to the non-snapshotted version
// of the source by consumers of this object.
this.sources = matcher?.sources as GestureSourceSubview<Type>[];
// Just to be extra-sure they don't continue to update.
// Alternatively, we could just make an extra copy and then instantly "disconnect" the new instance.
this.sources?.forEach((source) => source.disconnect());
this.allSourceIds = matcher?.allSourceIds || [];
}
}
interface PushConfig<Type> {
type: 'push',
config: GestureRecognizerConfiguration<Type>
}
// I don't think we currently need this option, but it fits as part of the overall conceptual
// model and is good for generality.
interface PopConfig {
type: 'pop',
count: number
}
interface EventMap<Type> {
stage: (
stageReport: GestureStageReport<Type>,
changeConfiguration: (configStackCommand: PushConfig<Type> | PopConfig) => void
) => void;
complete: () => void;
}
export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
public stageReports: GestureStageReport<Type>[];
// It's not specific to just this sequence... but it does have access to
// the potential next stages.
private selector: MatcherSelector<Type>;
// We need this reference in order to properly handle 'setchange' resolution actions when staging.
private touchpointCoordinator: TouchpointCoordinator<Type>;
// Selectors have locked-in 'base gesture sets'; this is only non-null if
// in a 'setchange' action.
private pushedSelector?: MatcherSelector<Type>;
private gestureConfig: GestureModelDefs<Type>;
// Note: the first stage will be available under `stageReports` after awaiting a simple Promise.resolve().
constructor(
firstSelectionMatch: MatcherSelection<Type>,
gestureModelDefinitions: GestureModelDefs<Type>,
selector: MatcherSelector<Type>,
touchpointCoordinator: TouchpointCoordinator<Type>
) {
super();
this.stageReports = [];
this.selector = selector;
this.selector.on('rejectionwithaction', (this.modelResetHandler));
this.gestureConfig = gestureModelDefinitions;
// So that we can...
// 1. push a different selector as active (and restore it later) - say, for modipress
// - 'push' & corresponding pop-like resolution behaviors
// 2. push a different default gesture set ID (and restore it later)
this.touchpointCoordinator = touchpointCoordinator;
// Adds a slight delay; a constructed Sequence will provide a brief window of time -
// until the event queue next 'ticks' - to receive data about the base stage via the
// same 'stage' event raised for all subsequent stages.
Promise.resolve().then(() => this.selectionHandler(firstSelectionMatch));
}
public get allSourceIds(): string[] {
return this.stageReports[this.stageReports.length - 1]?.allSourceIds;
}
private get baseGestureSetId(): string {
return this.selector?.baseGestureSetId ?? null;
}
private readonly selectionHandler = (selection: MatcherSelection<Type>) => {
const matchReport = new GestureStageReport<Type>(selection);
if(selection.matcher) {
this.stageReports.push(matchReport);
}
const sourceTracker = selection.matcher ?? this.stageReports[this.stageReports.length-1];
const sources = sourceTracker?.sources.map((matchSource) => {
return matchSource instanceof GestureSourceSubview ? matchSource.baseSource : matchSource;
}) ?? [];
if(selection.result.action.type == 'complete' || selection.result.action.type == 'none') {
sources.forEach((source) => {
if(!source.isPathComplete) {
source.terminate(selection.result.action.type == 'none');
}
});
if(!selection.result.matched) {
this.emit('complete');
return;
}
}
// Raise the event, providing a functor that allows the listener to specify an alt config for the next stage.
// Example case: longpress => subkey selection - the subkey menu has different boundary conditions.
this.emit('stage', matchReport, (command) => {
// Assertion: each Source may only be part of one GestureSequence.
// As such, pushed and popped configs may only come from one influence - the GestureSequence's
// staging transitions.
if(command.type == 'pop') {
sources.forEach((source) => source.popRecognizerConfig());
} else /* if(command.type == 'push') */ {
sources.forEach((source) => source.pushRecognizerConfig(command.config));
}
});
// ... right, the gesture-definitions.
const nextModels = modelSetForAction(selection.result.action, this.gestureConfig, this.baseGestureSetId);
if(nextModels.length > 0) {
// Note: if a 'push', that should be handled by an event listener from the main engine driver (or similar)
const promise = this.selector.matchGesture(selection.matcher, nextModels);
promise.then(this.selectionHandler);
// Handling 'setchange' resolution actions (where one gesture enables a different gesture set for others
// while active. Example case: modipress.)
if(selection.result.action.type == 'chain' && selection.result.action.selectionMode == this.pushedSelector?.baseGestureSetId) {
// do nothing; maintain the existing 'selectionMode' behavior
} else {
// pop the old one, if it exists - if it matches our expectations for a current one.
if(this.pushedSelector) {
this.touchpointCoordinator.popSelector(this.pushedSelector);
this.pushedSelector = null;
}
/* Note: we do not change the instance held by this class - it gets to maintain access
* to its original selector regardless.
*
* Example use-case: during subkey selection, which is the intended followup for a longpress,
* either...
*
* 1. No other gestures (new touch contact points) should be allowed and/or trigger interactions
* 2. OR such attempts should automatically cancel the subkey-selection process.
*
* For approach 1, we 'allow' an empty set of gestures, disabling all of them.
*
* For approach 2, we permit a single type of new gesture; when triggered, the gesture consumer
* can then use that to trigger cancellation of the subkey-selection mode.
*/
if(selection.result.action.type == 'chain') {
const targetSet = selection.result.action.selectionMode;
// push the new one.
const changedSetSelector = new MatcherSelector<Type>(targetSet);
this.touchpointCoordinator.pushSelector(changedSetSelector);
}
}
} else {
if(this.pushedSelector) {
this.touchpointCoordinator.popSelector(this.pushedSelector);
this.pushedSelector = null;
}
// Dropping the reference here gives us two benefits:
// 1. Allows garbage collection to do its thing; this might be the last reference left to the selector instance.
// 2. Acts as an obvious flag / indicator of sequence completion.
this.selector = null;
// Any extra finalization stuff should go here, before the event, if needed.
this.emit('complete');
}
}
private readonly modelResetHandler = (selection: MatcherSelection<Type>, replaceModelWith: (model: GestureModel<Type>) => void) => {
if(selection.result.action.type == 'replace') {
replaceModelWith(getGestureModel(this.gestureConfig, selection.result.action.replace));
} else {
throw new Error("Missed a case in implementation!");
}
};
}
export function modelSetForAction<Type>(
action: GestureResolution<Type>,
gestureModelDefinitions: GestureModelDefs<Type>,
activeSetId: string
): GestureModel<Type>[] {
switch(action.type) {
case 'none':
case 'complete':
return [];
case 'replace':
return [getGestureModel(gestureModelDefinitions, action.replace)];
case 'chain':
return [getGestureModel(gestureModelDefinitions, action.next)];
default:
throw new Error("Unexpected case arose within `processGestureAction` method");
}
}

View file

@ -1,3 +1,4 @@
export { GestureMatcher } from './gestureMatcher.js';
export { GestureSequence, GestureStageReport, modelSetForAction } from './gestureSequence.js';
export { MatcherSelection, MatcherSelector } from './matcherSelector.js';
export { PathMatcher } from './pathMatcher.js';

View file

@ -245,6 +245,10 @@ export class MatcherSelector<Type> extends EventEmitter<EventMap<Type>> {
// We have a result for this matcher; go ahead and remove it from the 'potential' list.
const matcherIndex = this.potentialMatchers.indexOf(matcher);
if(matcherIndex == -1) {
// It's already been handled; do not re-attempt.
return;
}
this.potentialMatchers.splice(matcherIndex, 1);
/*

View file

@ -10,20 +10,42 @@ export interface ResolutionItem<Type> {
item: Type
}
export interface ResolutionPush {
type: 'push',
allowedGestures: string[]
}
/**
* Indicates that the matched gesture is but a component (or stage) of a
* multi-part gesture; there may be one or more follow-up components
* that will follow.
*/
export interface ResolutionChain {
type: 'chain',
next: string
}
// For consideration: string | string[]; // But we don't need the latter part for 17.0 gesture support.
/**
* The gesture ID for the next gesture component in sequence.
*
* E.g. longpress => subkey-select; that is, 'subkey-select' would be next after the 'longpress' model
* matches.
*/
next: string,
// is not "locked-in"
export interface OptionalChain {
type: 'optional-chain', // With spec-shift: 'reset'?
allowNext: string
/**
* When specified, gesture-component selection for new GestureSources will use the specified
* set of models instead of the current default set for new sources.
*
* Example 1: longpresses, when transitioning to subkey-select mode, do not allow new incoming
* gestures during their lifetime. They should either cancel or block new gestures until
* subkey-selection is complete.
*
* Example 2: modipress operations should prevent secondary modipresses from occurring during
* their lifetime.
*
* Followup gesture-models must also specify the alternate model set in order to maintain it during
* transition between components. Leaving it `undefined` in a followup will fully cancel the
* alternate gesture-component selection mode and any gestures activated during the alternate
* selection mode (unless `sustainIfNested` is `true` for the processing gesture model).
*
* Changing to a different ID will do the likewise, then reactivate the alternate gesture-selection
* mode with the newly-specified gesture-model set target.
*/
selectionMode?: string
}
export interface ResolutionComplete {
@ -34,16 +56,30 @@ export interface RejectionDefault {
type: 'none'
}
/**
* Only permitted when rejecting a gesture match; certain models may specify a replacement
* or reset under certain conditions.
*/
export interface RejectionReplace {
type: 'replace',
// For consideration: string | string[]; // But we don't need the latter part for 17.0 gesture support.
// Is trickier here for 'replace' than for 'chain'.
/**
* The ID of a gesture model to start matching as a replacement for the gesture-model that failed to match.
*/
replace: string
}
// If there is a 'gesture stack' associated with the gesture chain, it's auto-popped
// upon completion of the chain. So, either this resolution type or a final,
// non-chainable rejection will 'pop' to undo any existing prior 'push' resolutions
// in the chain. As such, there is no need for a {type: 'pop'} variant.
type ResolutionStruct = ResolutionPush | ResolutionChain | OptionalChain | ResolutionComplete;
type ResolutionStruct = ResolutionChain | ResolutionComplete;
export type GestureResolutionSpec = ResolutionStruct & ResolutionItemSpec;
export type GestureResolution<Type> = (ResolutionStruct | RejectionDefault) & ResolutionItem<Type>;
export type GestureResolution<Type> = (ResolutionStruct | RejectionDefault | RejectionReplace) & ResolutionItem<Type>;
export interface GestureModel<Type> {
// Gestures may want to say "build gesture of type `id`" for a followup-gesture.
@ -67,7 +103,21 @@ export interface GestureModel<Type> {
// ordinal position. (Same order as in the TrackedInput)
readonly contacts: {
model: ContactModel<Type>,
/**
* Indicates that the corresponding GestureSource should not be considered part of the
* Gesture sequence being matched, acting more as a separate gesture that 'triggers' a state
* change in the current gesture being processed.
*/
resetOnResolve?: boolean,
/**
* Indicates that the corresponding GestureSource should be terminated whenever this GestureModel
* is successfully matched.
*/
endOnResolve?: boolean,
/**
* Indicates that the corresponding GestureSource should be terminated whenever this GestureModel
* _fails_ to match.
*/
endOnReject?: boolean
}[];
@ -84,11 +134,18 @@ export interface GestureModel<Type> {
readonly resolutionAction: GestureResolutionSpec;
readonly rejectionActions?: Partial<Record<FulfillmentCause, Omit<OptionalChain, 'item'>>>;
readonly rejectionActions?: Partial<Record<FulfillmentCause, RejectionReplace>>;
// If there is a 'gesture stack' associated with the gesture chain, it's auto-popped
// upon completion of the chain. Optional-chaining can sustain the chain while the
// potential child gesture is still a possibility.
// If we're locked-in on the gesture being matched and its detection occurs under the influence
// of another gesture, should that "another gesture" complete, this flag specifies if the
// locked-in "subgesture" should be maintained or auto-cancelled as a consequence.
//
// Default: cancelled.
readonly sustainWhenNested?: boolean;
// TODO: allow function for correlating multitouch paths (like for caret-pannning)
// But that's something we'll likely defer past 17.0.
// Probably: takes both paths' stat-objects. (Fortunately, the stats object holds

View file

@ -0,0 +1,45 @@
import * as gestures from "../index.js";
// Prototype spec for the main gesture & gesture-set definitions.
// A work in-progress. Should probably land somewhere within headless/gestures/specs/.
// ... with the following two functions, as well.
export interface GestureModelDefs<Type> {
gestures: gestures.specs.GestureModel<Type>[],
sets: {
default: string[],
} & Record<string, string[]>;
}
export function getGestureModel<Type>(defs: GestureModelDefs<Type>, id: string): gestures.specs.GestureModel<Type> {
const result = defs.gestures.find((spec) => spec.id == id);
if(!result) {
throw new Error(`Could not find spec for gesture with id '${id}'`);
}
return result;
}
export function getGestureModelSet<Type>(defs: GestureModelDefs<Type>, id: string): gestures.specs.GestureModel<Type>[] {
let idSet = defs.sets[id];
if(!idSet) {
throw new Error(`Could not find a defined gesture-set with id '${id}'`);
}
const set = defs.gestures.filter((spec) => !!idSet.find((id) => spec.id == id));
const missing = idSet.filter((id) => !set.find((spec) => spec.id == id));
if(missing.length > 0) {
throw new Error(`Set '${id}' cannot find definitions for gestures with ids ${missing}`);
}
return set;
}
export const EMPTY_GESTURE_DEFS = {
gestures: [
],
sets: {
default: []
}
} as GestureModelDefs<any>

View file

@ -1,3 +1,4 @@
export * from './contactModel.js';
export * from './gestureModel.js';
export * from './gestureModelDefs.js';
export * from './pathModel.js';

View file

@ -1,6 +1,7 @@
import EventEmitter from "eventemitter3";
import { InputEngineBase } from "./inputEngineBase.js";
import { GestureSource, GestureSourceSubview } from "./gestureSource.js";
import { MatcherSelector } from "./gestures/matchers/matcherSelector.js";
interface EventMap<HoveredItemType> {
/**
@ -21,6 +22,7 @@ interface EventMap<HoveredItemType> {
*/
export class TouchpointCoordinator<HoveredItemType> extends EventEmitter<EventMap<HoveredItemType>> {
private inputEngines: InputEngineBase<HoveredItemType>[];
private selectorStack: MatcherSelector<HoveredItemType>[] = [new MatcherSelector()];
private _activeSources: GestureSource<HoveredItemType>[] = [];
@ -29,6 +31,26 @@ export class TouchpointCoordinator<HoveredItemType> extends EventEmitter<EventMa
this.inputEngines = [];
}
public pushSelector(selector: MatcherSelector<HoveredItemType>) {
this.selectorStack.push(selector);
}
public popSelector(selector: MatcherSelector<HoveredItemType>) {
if(this.selectorStack.length <= 1) {
throw new Error("May not pop the original, base gesture selector.");
}
const index = this.selectorStack.indexOf(selector);
if(index == -1) {
throw new Error("This selector has not been pushed onto the 'setChange' stack.");
}
this.selectorStack.splice(index, 1);
}
public get currentSelector() {
return this.selectorStack[this.selectorStack.length-1];
}
protected addEngine(engine: InputEngineBase<HoveredItemType>) {
engine.on('pointstart', this.onNewTrackedPath);
this.inputEngines.push(engine);

View file

@ -1,5 +1,6 @@
export { ConstructingSegment } from './headless/subsegmentation/constructingSegment.js';
export { CumulativePathStats } from './headless/cumulativePathStats.js';
export { GestureModelDefs } from './headless/gestures/specs/gestureModelDefs.js';
export { GestureRecognizer } from "./gestureRecognizer.js";
export { GestureRecognizerConfiguration } from "./configuration/gestureRecognizerConfiguration.js";
export { InputEngineBase } from "./headless/inputEngineBase.js";

View file

@ -374,7 +374,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: false, action: { type: 'optional-chain', item: null, allowNext: 'longpress'}});
assert.deepEqual(await modelMatcher.promise, {matched: false, action: { type: 'replace', item: null, replace: 'longpress'}});
assert.isFalse(sources[0].path.isComplete);
const dist = (sample1: InputSample<any>, sample2: InputSample<any>) => {
@ -420,7 +420,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: false, action: { type: 'optional-chain', item: null, allowNext: 'longpress'}});
assert.deepEqual(await modelMatcher.promise, {matched: false, action: { type: 'replace', item: null, replace: 'longpress'}});
assert.isFalse(sources[0].path.isComplete);
// The sample at which the item changed from 'a' to 'b'.
@ -487,7 +487,7 @@ describe("GestureMatcher", function() {
const modelMatcher = await modelMatcherPromise;
assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED);
assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }});
assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap' }});
// touchpoints[0] - a pre-completed path.
assert.isTrue(sources[1].path.isComplete);
});
@ -642,7 +642,7 @@ describe("GestureMatcher", function() {
}
assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED);
assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap'}});
assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap'}});
assert.isTrue(sources[1].path.isComplete);
// Design note: as this one is _not_ complete, when gesture chaining tries to do a followup multitap match,
@ -676,7 +676,7 @@ describe("GestureMatcher", function() {
await modelMatcher.promise;
}
assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED);
assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap'}});
assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap'}});
assert.isTrue(sources[0].path.isComplete);
const finalStats = modelMatcher.sources[0].path.stats;
@ -718,7 +718,7 @@ describe("GestureMatcher", function() {
assert.equal(await promiseStatus(modelMatcher.promise), PromiseStatuses.PROMISE_RESOLVED);
assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap'}});
assert.deepEqual(await modelMatcher.promise, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap'}});
assert.isTrue(sources[0].path.isComplete);
assert.isFalse(sources[1].path.isComplete);
});

View file

@ -0,0 +1,41 @@
import { assert } from 'chai';
import { GestureModelDefs, gestures } from '@keymanapp/gesture-recognizer';
const getGestureModel = gestures.specs.getGestureModel;
const getGestureModelSet = gestures.specs.getGestureModelSet;
import {
LongpressModel,
MultitapModel,
SimpleTapModel,
SubkeySelectModel
} from './isolatedGestureSpecs.js';
const TestGestureModelDefinitions: GestureModelDefs<string> = {
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
malformed: [LongpressModel.id, 'unavailable-model']
}
}
describe("Gesture model definitions", () => {
it('getGestureModel', () => {
assert.equal(LongpressModel, getGestureModel(TestGestureModelDefinitions, LongpressModel.id));
assert.throws(() => getGestureModel(TestGestureModelDefinitions, "unavailable-model"));
});
it('getGestureModelSet', () => {
assert.sameMembers([LongpressModel, SimpleTapModel], getGestureModelSet(TestGestureModelDefinitions, 'default'));
assert.throws(() => getGestureModelSet(TestGestureModelDefinitions, 'malformed'));
assert.throws(() => getGestureModelSet(TestGestureModelDefinitions, 'unavailable-set'));
});
});

View file

@ -0,0 +1,588 @@
import { assert } from 'chai'
import sinon from 'sinon';
import * as PromiseStatusModule from 'promise-status-async';
import { assertingPromiseStatus as promiseStatus } from '../../../resources/assertingPromiseStatus.js';
import { GestureModelDefs, GestureSource, gestures } 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<Type> = gestures.matchers.GestureSequence<Type>;
type MatcherSelector<Type> = gestures.matchers.MatcherSelector<Type>;
type MatcherSelection<Type> = gestures.matchers.MatcherSelection<Type>;
const getGestureModelSet = gestures.specs.getGestureModelSet;
const modelSetForAction = gestures.matchers.modelSetForAction;
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<string> = {
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
}
}
describe("modelSetForAction", function() {
it('successful longpress', () => {
const nextModels = modelSetForAction({
type: 'chain',
item: null,
next: 'subkey-select'
}, TestGestureModelDefinitions, 'default');
assert.sameMembers(nextModels, [SubkeySelectModel]);
});
// A cancelled longpress that is reset is handled by different mechanisms;
// no test is appropriate in this location.
it('successful simple-tap or multi-tap', () => {
const nextModels = modelSetForAction({
type: 'chain',
item: 'a',
next: 'multitap'
}, TestGestureModelDefinitions, 'default');
assert.sameMembers(nextModels, [MultitapModel]);
});
it('successful subkey-select', () => {
const nextModels = modelSetForAction({
type: 'complete',
item: 'b',
}, TestGestureModelDefinitions, 'default');
assert.sameMembers(nextModels, []);
});
});
let fakeClock: ReturnType<typeof sinon.useFakeTimers>;
async function sequenceEmulationAndAssertion(emulationEngine: HeadlessInputEngine, emulationCompletion: Promise<void>, sequenceAssertions: SequenceAssertion<string>[]) {
const selectionPromise = new ManagedPromise<MatcherSelection<string>>;
const testPromise = new ManagedPromise<void>();
// One selector for ALL sources, not per-source.
const selector = new MatcherSelector<string>('default');
// Track pre-built sequences; we need to double-check that new touchpoints don't correspond to existing sequences.
const sequences: GestureSequence<string>[] = [];
let indexSeed = 0;
// Note: errors from async handlers do not get caught by Mocha if unhandled.
// The workaround: we build Promises for would-be async handlers that can sync; we pass caught errors
// to them so that they're reported by the automated test.
emulationEngine.on('pointstart', (source) => {
try {
// These parts should be handled by TouchpointCoordinator. This is a simplified mocked version
// of what lies there.
const matchPromise = selector.matchGesture(source, getGestureModelSet(TestGestureModelDefinitions, 'default'));
matchPromise.then(async (selection) => {
if(!selectionPromise.isResolved) {
selectionPromise.resolve(selection);
}
// Ensure that existing sequences have a chance to include the new GestureSource before proceeding.
// (This handler is called synchronously, while the Sequence updates asynchronously.)
await Promise.resolve();
if(sequences.find((sequence) => sequence.allSourceIds.find((identifier) => identifier == source.identifier))) {
// This touchpoint has already been included within an existing GestureSequence.
return;
}
// And that should be enough to spin up a GestureSequence for continuation.
// The `null` bit is "cheating" a bit, but is "fine" for this test.
const sequence = new GestureSequence<string>(
selection,
TestGestureModelDefinitions,
selector,
null // We're 'mocking out' the TouchpointCoordinator.
);
sequences.push(sequence);
const sequenceIndex = indexSeed++;
const assertion = sequenceAssertions[sequenceIndex];
if(assertion) {
try {
await assertGestureSequence(sequence, emulationCompletion, assertion);
testPromise.resolve();
} catch(err) {
testPromise.reject(err);
}
} else {
testPromise.reject(new Error(`Missing assertion for sequence ${sequenceIndex} of test`));
}
});
selectionPromise.catch((err) => testPromise.reject(err));
} catch(err) {
testPromise.reject(err);
return;
}
});
//fakeClock.runToLastAsync();
fakeClock.runAllAsync();
// Assert that an initial 'stage' (component of the sequence) is available - it's needed to
// build the sequence object.
await Promise.race([selectionPromise, emulationCompletion]);
assert.equal(await promiseStatus(selectionPromise.corePromise), PromiseStatusModule.PROMISE_RESOLVED);
await selectionPromise;
// Other assertions are embedded in the simulation bit above.
await testPromise;
}
// TODO(?): right, simulation. Again. Yaaaaay.
// - Fortunately, the _start_ can just use selection-sim semantics; the GestureSequence
// constructor takes in an existing Selector & its selection, after all.
// - in fact... that should be 100% fine, right? There's only ever the one selector!
// - the issue: we do want to 'select' early, before later-stage timers are all run.
// Later, in a different file: testing TouchpointCoordinator's integration with this.
describe("GestureSequence", function() {
beforeEach(function() {
fakeClock = sinon.useFakeTimers();
});
afterEach(function() {
fakeClock.restore();
});
// TODO: author tests for (at least) the following
// Modipress - but it expects an actual TouchpointCoordinator instance. May not be testable on this level.
// Android longpress delegation? (Though... we _are_ killing this Android aspect, so maybe it's not worth explicitly testing anymore.)
//
// Defer: flick test - confirm => execute
//
// .on('complete') may be checked to validate if any further match attempts will be possible
// based on the condition; it may be worth 'extending' tests (via mocked timer) to
// double-check such scenarios.
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 completionPromise = emulationEngine.playbackRecording({
inputs: [ {
path: {
coords: turtle.path,
},
isFromTouch: true
}],
config: null
});
const sequenceAssertion: SequenceAssertion<string> = [
{
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);
}
}
];
await sequenceEmulationAndAssertion(emulationEngine, completionPromise, [sequenceAssertion]);
// simulateSelectorInput should be sufficient for sequence emulation; just capture
// the first selection once kick-started, then add the "fun" hooks for the rest of the test.
// We do have to build a selector and complete the first pass to create a Sequence object, after all.
//
// For equivalent TouchpointCoordinator auto-tests, the HeadlessInputEngine class should work well.
// ... it might even be possible here, since we'll always be starting from the 'head' of the
// GestureSequence for these tests. (Later starts fall within the domain of MatcherSelector.)
});
// Note: cannot do longpress-blocking of secondary gestures here, since that requires TouchpointCoordinator
// integration.
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 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<string> = [
{
matchedId: 'simple-tap',
item: 'a',
linkType: '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;
}
}
];
await sequenceEmulationAndAssertion(emulationEngine, completionPromise, [sequenceAssertion]);
});
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 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 two will be treated as separate sequences.
const sequenceAssertions: SequenceAssertion<string>[] = [
[
{
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.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;
}
}
], [
{
// OK... this one's not happening because it's not an allowed 'next' followup. Riiiight.
// Need a way for this to 'fall back' and not be included... might be best to move forward with
// that 'make it a separate sequence' idea given the spec shift.
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);
}
}
]
];
await sequenceEmulationAndAssertion(emulationEngine, completionPromise, sequenceAssertions);
});
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 completionPromise = emulationEngine.playbackRecording({
inputs: [ {
path: {
coords: turtle0.path,
},
isFromTouch: true
}, {
path: {
coords: turtle1.path,
},
isFromTouch: true
}],
config: null
});
const sequenceAssertions: SequenceAssertion<string>[] = [
[
{
matchedId: 'simple-tap',
item: 'a',
linkType: '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: 'chain',
sources: (sources) => {
assert.equal(sources.length, 1);
assert.isTrue(sources[0].isPathComplete);
return;
}
}
]
];
await sequenceEmulationAndAssertion(emulationEngine, completionPromise, sequenceAssertions);
});
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 completionPromise = emulationEngine.playbackRecording({
inputs: [ {
path: {
coords: turtle0.path,
},
isFromTouch: true
}, {
path: {
coords: turtle1.path,
},
isFromTouch: true
}],
config: null
});
const sequenceAssertions: SequenceAssertion<string>[] = [
[
{
matchedId: 'simple-tap',
item: 'a',
linkType: '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;
}
}
]
];
await sequenceEmulationAndAssertion(emulationEngine, completionPromise, sequenceAssertions);
});
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 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<string> = [
{
matchedId: 'simple-tap',
item: 'a',
linkType: 'chain',
sources: (sources) => {
assert.equal(sources.length, 1);
assert.isTrue(sources[0].isPathComplete);
return;
}
},
{
matchedId: 'multitap',
item: 'a',
linkType: 'chain',
sources: (sources) => {
// Assert single-source; the first tap is not under consideration for this stage.
assert.equal(sources.length, 1);
}
}
];
await sequenceEmulationAndAssertion(emulationEngine, completionPromise, [sequenceAssertion]);
});
});

View file

@ -33,12 +33,12 @@ export const LongpressModel: GestureModel = {
*/
rejectionActions: {
item: {
type: 'optional-chain',
allowNext: 'longpress'
type: 'replace',
replace: 'longpress'
},
path: {
type: 'optional-chain',
allowNext: 'longpress'
type: 'replace',
replace: 'longpress'
}
}
}
@ -68,8 +68,8 @@ export const MultitapModel: GestureModel = {
baseItem: 'base'
},
resolutionAction: {
type: 'optional-chain',
allowNext: 'multitap',
type: 'chain',
next: 'multitap',
item: 'current'
}
}
@ -87,18 +87,19 @@ export const SimpleTapModel: GestureModel = {
},
endOnResolve: true
}, {
model: specs.InstantResolutionModel
model: specs.InstantResolutionModel,
resetOnResolve: true
}
],
resolutionAction: {
type: 'optional-chain',
allowNext: 'multitap',
type: 'chain',
next: 'multitap',
item: 'current'
},
rejectionActions: {
item: {
type: 'optional-chain',
allowNext: 'simple-tap'
type: 'replace',
replace: 'simple-tap'
}
}
}

View file

@ -150,7 +150,7 @@ describe("MatcherSelector", function () {
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});
assert.deepEqual(rejectionData[0].result.action, { type: 'replace', replace: 'longpress', item: null});
// ... we technically already have it, but this _is_ a convenient pattern to maintain for
// consistency among all this suite's tests.
@ -300,7 +300,7 @@ describe("MatcherSelector", function () {
const selection = await selectionPromises[0];
assert.deepEqual(selection.result, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }});
assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap' }});
assert.deepEqual(selection.matcher.model, SimpleTapModel);
assert.isTrue(sources[0].path.isComplete);
@ -354,7 +354,7 @@ describe("MatcherSelector", function () {
const selection = await selectionPromises[0];
assert.deepEqual(selection.result, {matched: true, action: { type: 'optional-chain', item: 'b', allowNext: 'multitap' }});
assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: 'b', next: 'multitap' }});
assert.deepEqual(selection.matcher.model, SimpleTapModel);
assert.isTrue(sources[0].path.isComplete);
assert.isAtLeast(resets, 1);
@ -476,7 +476,7 @@ describe("MatcherSelector", function () {
const selection = await selectionPromises[0];
assert.deepEqual(selection.result, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }});
assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap' }});
assert.deepEqual(selection.matcher.model, SimpleTapModel);
assert.isTrue(sources[0].path.isComplete);
assert.isAtMost(sources[0].path.stats.duration, 101);
@ -549,7 +549,7 @@ describe("MatcherSelector", function () {
const selection = await selectionPromises[0];
assert.deepEqual(selection.result, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }});
assert.deepEqual(selection.result, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap' }});
assert.deepEqual(selection.matcher.model, MultitapModel);
assert.isTrue(sources[0].path.isComplete);
@ -628,7 +628,7 @@ describe("MatcherSelector", function () {
// Ignoring the multi-tap leadup and starting a new gesture-stage sequence instead...
const selection2 = await selectionPromises[1];
assert.deepEqual(selection2.result, {matched: true, action: { type: 'optional-chain', item: 'b', allowNext: 'multitap' }});
assert.deepEqual(selection2.result, {matched: true, action: { type: 'chain', item: 'b', next: 'multitap' }});
assert.deepEqual(selection2.matcher.model, SimpleTapModel);
assert.isTrue(sources[0].path.isComplete);
@ -712,7 +712,7 @@ describe("MatcherSelector", function () {
assert.equal(await promiseStatus(selectionPromises[1]), PromiseStatuses.PROMISE_RESOLVED);
const selection2 = await selectionPromises[1];
assert.deepEqual(selection2.result, {matched: true, action: { type: 'optional-chain', item: 'a', allowNext: 'multitap' }});
assert.deepEqual(selection2.result, {matched: true, action: { type: 'chain', item: 'a', next: 'multitap' }});
assert.deepEqual(selection2.matcher.model, SimpleTapModel);
assert.isTrue(sources[0].path.isComplete);

View file

@ -0,0 +1,79 @@
import { assert } from 'chai'
import sinon from 'sinon';
import * as PromiseStatusModule from 'promise-status-async';
const PromiseStatuses = PromiseStatusModule.PromiseStatuses;
import { assertingPromiseStatus as promiseStatus } from './assertingPromiseStatus.js';
import { GestureSource, gestures } 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<Type> = gestures.matchers.GestureSequence<Type>;
type GestureStageReport<Type> = gestures.matchers.GestureStageReport<Type>;
import { ManagedPromise, timedPromise } from '@keymanapp/web-utils';
export interface StageReportAssertion<Type> {
matchedId: string,
item?: Type,
linkType?: typeof GestureStageReport.prototype['linkType']
sources?: (sources: GestureSource<Type>[]) => void;
}
export type SequenceAssertion<Type> = StageReportAssertion<Type>[];
export async function assertGestureSequence<Type>(
sequence: GestureSequence<Type>,
emulationCompletion: Promise<void>,
reportAssertions: StageReportAssertion<Type>[]
) {
const completionCheck = sinon.fake();
const stagePromises: ManagedPromise<GestureStageReport<Type>>[] = [
new ManagedPromise()
];
sequence.on('stage', (report) => {
stagePromises[stagePromises.length - 1].resolve(report);
stagePromises.push(new ManagedPromise());
});
sequence.on('complete', completionCheck);
let index: number;
for(index = 0; index < reportAssertions.length; index++) {
// Assert that the expected stage actually occurs for the simulated sequence.
await Promise.race([stagePromises[index].corePromise, emulationCompletion]);
assert.equal(await promiseStatus(stagePromises[index].corePromise), PromiseStatuses.PROMISE_RESOLVED, `Expected gesture stage with index ${index} did not occur`);
// Assert that the detected stage has the expected properties for the simulated sequence.
const report = await stagePromises[index].corePromise;
const assertValue = reportAssertions[index];
const expectation = `Expected stage (index ${index}, id ${assertValue.matchedId})`;
assert.equal(report.matchedId, assertValue.matchedId, `${expectation} did not match expected type`);
if(assertValue.item !== undefined) {
if(assertValue.item) {
assert.equal(report.item, assertValue.item, `${expectation} did not result in expected item`);
} else {
assert.equal(report.item, assertValue.item, `${expectation} resulted in unexpected item`);
}
}
if(assertValue.linkType !== undefined) {
assert.equal(report.linkType, assertValue.linkType, `${expectation} specified an unexpected stage transition type`);
}
if(assertValue.sources) {
assertValue.sources(report.sources);
}
}
// There should be no unexpected stage in the sequence's analysis; we should reach completion
// with the last specified stage.
await Promise.race([stagePromises[index].corePromise, emulationCompletion]);
assert.equal(await promiseStatus(emulationCompletion), PromiseStatuses.PROMISE_RESOLVED, `Unexpected stage with index ${index}; sequence should have terminated`);
assert.equal(await promiseStatus(stagePromises[index].corePromise), PromiseStatuses.PROMISE_PENDING, `Unexpected stage with index ${index}; sequence should have terminated`);
await emulationCompletion;
await Promise.resolve();
assert.isTrue(completionCheck.called, `Sequence ${index} did not reach completion by the end of emulation`); // issue: simple-tap tests could, in theory, still go multi-tap!
}

View file

@ -75,7 +75,7 @@ export default class ManagedPromise<Type> {
return this._promise.then(onfulfilled, onrejected);
}
catch(onrejected?: (reason: any) => PromiseLike<never>): Promise<Type> {
catch<TResult1>(onrejected?: (reason: any) => TResult1 | PromiseLike<TResult1>): Promise<Type | TResult1> {
return this._promise.catch(onrejected);
}