Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(web): gesture-engine integration - automated test patchup 🐵 #9719

Merged
merged 10 commits into from
Oct 20, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
this.selector = selector;
this.selector.on('rejectionwithaction', this.modelResetHandler);
this.once('complete', () => {
this.selector.off('rejectionwithaction', this.modelResetHandler)
this.selector.off('rejectionwithaction', this.modelResetHandler);
this.selector.dropSourcesWithIds(this.allSourceIds);

// 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.
Expand All @@ -130,7 +131,9 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
}

public get allSourceIds(): string[] {
return this.stageReports[this.stageReports.length - 1]?.allSourceIds;
// Note: there is a brief window of time - between construction & the deferred first
// 'stage' event - during which this array may be of length 0.
return this.stageReports[this.stageReports.length - 1]?.allSourceIds ?? [];
}

private get baseGestureSetId(): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ManagedPromise } from "@keymanapp/web-utils";
import { GestureSource, GestureSourceSubview } from "../../gestureSource.js";
import { GestureMatcher, MatchResult, PredecessorMatch } from "./gestureMatcher.js";
import { GestureModel } from "../specs/gestureModel.js";
import { GestureSequence } from "./index.js";

interface GestureSourceTracker<Type> {
/**
Expand Down Expand Up @@ -149,6 +150,14 @@ export class MatcherSelector<Type> extends EventEmitter<EventMap<Type>> {
? [source instanceof GestureSourceSubview ? source.baseSource : source]
: (source.sources as GestureSourceSubview<Type>[]).map((source) => source.baseSource);

if(sourceNotYetStaged) {
// Cancellation before a first stage is possible; in this case, there's no sequence
// to trigger cleanup. We can do that here.
source.path.on('invalidated', () => {
this.dropSourcesWithIds([source.identifier]);
})
}

const matchPromise = new ManagedPromise<MatcherSelection<Type>>();

/*
Expand Down Expand Up @@ -221,7 +230,10 @@ export class MatcherSelector<Type> extends EventEmitter<EventMap<Type>> {
matcher: null,
result: {
matched: false,
action: null
action: {
type: 'complete',
item: null
}
}
});
}
Expand Down Expand Up @@ -280,6 +292,27 @@ export class MatcherSelector<Type> extends EventEmitter<EventMap<Type>> {
this._sourceSelector.forEach((entry) => resetHooks(entry.source));
}

public dropSourcesWithIds(idsToClean: string[]) {
for(const id of idsToClean) {
const index = this._sourceSelector.findIndex((entry) => entry.source.identifier);
if(index > -1) {
// Ensure that any pending MatcherSelector and/or GestureSequence promises dependent
// on the source fully resolve (with cancellation).
const droppedSelector = this._sourceSelector.splice(index, 1)[0];
droppedSelector.matchPromise.resolve({
matcher: null,
result: {
matched: false,
action: {
type: 'none',
item: null
}
}
});
}
}
}

private matchersForSource(source: GestureSource<Type>) {
return this.potentialMatchers.filter((matcher) => {
return !!matcher.sources.find((src) => src.identifier == source.identifier)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,14 @@ describe("TouchpointCoordinator", () => {
});

const runnerPromise = fakeClock.runToLastAsync();
const sequence = await sequencePromise;
const completeStub = sinon.fake();
sequence.on('complete', completeStub); // was not called! Confirms a suspicion.

await Promise.all([runnerPromise, completionPromise1, completionPromise2]);

assert.isTrue(completeStub.calledOnce);

// Verify that all sources and sequences are cleared.
assert.sameOrderedMembers(touchpointCoordinator.activeSources, []);
assert.sameOrderedMembers(touchpointCoordinator.activeGestures, []);
Expand Down
4 changes: 2 additions & 2 deletions common/web/keyboard-processor/tests/node/basic-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ describe('Engine - Basic Simulation', function() {
if(!proctor.compatibleWithSuite(testSuite)) {
it.skip(set.toTestName() + " - Cannot run this test suite on Node.");
} else {
it(set.toTestName(), function() {
it(set.toTestName(), async function() {
// Refresh the proctor instance at runtime.
let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal);
set.test(proctor);
await set.test(proctor);
});
}
}
Expand Down
4 changes: 2 additions & 2 deletions common/web/keyboard-processor/tests/node/chirality.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ describe('Engine - Chirality', function() {
if(!proctor.compatibleWithSuite(testSuite)) {
it.skip(set.toTestName() + " - Cannot run this test suite on Node.");
} else if(set.constraint.target == 'hardware') {
it(set.toTestName(), function() {
it(set.toTestName(), async function() {
// Refresh the proctor instance at runtime.
let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal);
set.test(proctor);
await set.test(proctor);
});
} else {
it.skip(set.toTestName() + " - modifier state simulation for OSK not yet supported in headless KeyboardProcessor");
Expand Down
4 changes: 2 additions & 2 deletions common/web/keyboard-processor/tests/node/deadkeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ describe('Engine - Deadkeys', function() {
if(!proctor.compatibleWithSuite(testSuite)) {
it.skip(set.toTestName() + " - Cannot run this test suite on Node.");
} else {
it(set.toTestName(), function() {
it(set.toTestName(), async function() {
// Refresh the proctor instance at runtime.
let proctor = new NodeProctor(keyboardWithHarness, device, assert.equal);
set.test(proctor);
await set.test(proctor);
});
}
}
Expand Down
20 changes: 10 additions & 10 deletions common/web/recorder/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,15 +212,15 @@ export abstract class TestSequence<KeyRecord extends RecordedKeystroke | InputEv

abstract hasOSKInteraction(): boolean;

test(proctor: Proctor, target?: OutputTarget): {success: boolean, result: string} {
async test(proctor: Proctor, target?: OutputTarget): Promise<{success: boolean, result: string}> {
// Start with an empty OutputTarget and a fresh KeyboardProcessor.
if(!target) {
target = new Mock();
}

proctor.before();

let result = proctor.simulateSequence(this, target);
let result = await proctor.simulateSequence(this, target);
proctor.assertEquals(result, this.output, this.msg);

return {success: (result == this.output), result: result};
Expand Down Expand Up @@ -527,7 +527,7 @@ export interface TestSet<Sequence extends TestSequence<any>> {

addTest(seq: Sequence): void;
isValidForDevice(device: utils.DeviceSpec, usingOSK?: boolean): boolean;
test(proctor: Proctor): TestFailure[];
test(proctor: Proctor): Promise<TestFailure[]>;
}

/**
Expand Down Expand Up @@ -563,13 +563,13 @@ export class EventSpecTestSet implements TestSet<InputEventSpecSequence> {
}

// Validity should be checked before calling this method.
test(proctor: Proctor): TestFailure[] {
async test(proctor: Proctor): Promise<TestFailure[]> {
var failures: TestFailure[] = [];
let testSet = this.testSet;

for(var i=0; i < testSet.length; i++) {
var testSeq = this[i];
var simResult = testSet[i].test(proctor);
var simResult = await testSet[i].test(proctor);
if(!simResult.success) {
// Failed test!
failures.push(new TestFailure(this.constraint, testSeq, simResult.result));
Expand Down Expand Up @@ -613,13 +613,13 @@ export class RecordedSequenceTestSet implements TestSet<RecordedKeystrokeSequenc
}

// Validity should be checked before calling this method.
test(proctor: Proctor): TestFailure[] {
async test(proctor: Proctor): Promise<TestFailure[]> {
var failures: TestFailure[] = [];
let testSet = this.testSet;

for(var i=0; i < testSet.length; i++) {
var testSeq = this[i];
var simResult = testSet[i].test(proctor);
var simResult = await testSet[i].test(proctor);
if(!simResult.success) {
// Failed test!
failures.push(new TestFailure(this.constraint, testSeq, simResult.result));
Expand Down Expand Up @@ -729,11 +729,11 @@ export class KeyboardTest {
newSet.addTest(seq);
}

test(proctor: Proctor) {
async test(proctor: Proctor) {
var setHasRun = false;
var failures: TestFailure[] = [];

proctor.beforeAll();
await proctor.beforeAll();

// The original test spec requires a browser environment and thus requires its own `.run` implementation.
if(!(proctor.compatibleWithSuite(this))) {
Expand All @@ -745,7 +745,7 @@ export class KeyboardTest {
var testSet = this.inputTestSets[i];

if(proctor.matchesTestSet(testSet)) {
var testFailures = testSet.test(proctor);
var testFailures = await testSet.test(proctor);
if(testFailures) {
failures = failures.concat(testFailures);
}
Expand Down
4 changes: 2 additions & 2 deletions common/web/recorder/src/nodeProctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default class NodeProctor extends Proctor {
this.keyboardWithHarness = kbdHarness;
}

beforeAll() {
async beforeAll() {
//
}

Expand All @@ -47,7 +47,7 @@ export default class NodeProctor extends Proctor {
return true;
}

simulateSequence(sequence: TestSequence<any>, target?: OutputTarget): string {
async simulateSequence(sequence: TestSequence<any>, target?: OutputTarget): Promise<string> {
// Start with an empty OutputTarget and a fresh KeyboardProcessor.
if(!target) {
target = new Mock();
Expand Down
4 changes: 2 additions & 2 deletions common/web/recorder/src/proctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default abstract class Proctor {
}

// Performs global test prep.
abstract beforeAll();
abstract beforeAll(): Promise<void>;

// Performs per-test setup
abstract before();
Expand All @@ -49,5 +49,5 @@ export default abstract class Proctor {
* Simulates the specified test sequence for use in testing.
* @param sequence The recorded sequence, generally provided by a test set.
*/
abstract simulateSequence(sequence: TestSequence<any>, target?: OutputTarget);
abstract simulateSequence(sequence: TestSequence<any>, target?: OutputTarget): Promise<string>;
}
21 changes: 19 additions & 2 deletions web/src/app/browser/src/contextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,17 @@ export default class ContextManager extends ContextManagerBase<BrowserConfigurat

public deactivateCurrentTarget() {
const priorTarget = this.activeTarget || this.lastActiveTarget;
if(priorTarget) {

/* During integrated tests, it was possible in the past for a `beforeAll`
* -initialized KMW to reach this state between tests. The target fixture
* got cleared, but the `mostRecentTarget` / `lastActiveTarget` was not
* - just the `currentTarget` / `activeTarget`. See #9718.
*
* Newly-added code in `forgetActiveTarget` seeks to prevent this scenario,
* but as there's no consistent repro to prove it sufficient, an appropriate
* guard-condition has been added here too.
*/
if(priorTarget && this.page.isAttached(priorTarget.getElement())) {
this._BlurKeyboardSettings(priorTarget.getElement());
}

Expand All @@ -198,12 +208,19 @@ export default class ContextManager extends ContextManagerBase<BrowserConfigurat
this.focusAssistant.maintainingFocus = false;
this.focusAssistant.restoringFocus = false;

const priorTarget = this.activeTarget || this.lastActiveTarget;
const priorTarget = this.activeTarget || this.mostRecentTarget;
if(priorTarget) {
this._BlurKeyboardSettings(priorTarget.getElement());
}

// Will ensure that the element is no longer active. Does not erase
// it from being the `lastActiveTarget`, though.
this.setActiveTarget(null, true);

// So we erase it here.
if(priorTarget == this.lastActiveTarget) {
this.mostRecentTarget = null;
}
}

public setActiveTarget(target: OutputTarget<any>, sendEvents: boolean) {
Expand Down
25 changes: 24 additions & 1 deletion web/src/engine/dom-utils/src/stylesheets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DeviceSpec } from '@keymanapp/web-utils';
import { DeviceSpec, ManagedPromise } from '@keymanapp/web-utils';
import { type InternalKeyboardFont as KeyboardFont } from '@keymanapp/keyboard-processor';

type FontFamilyStyleMap = {[family: string]: HTMLStyleElement};
Expand Down Expand Up @@ -28,6 +28,27 @@ export class StylesheetManager {
this.linkNode.appendChild(sheet);
}

/**
* Provides a `Promise` that resolves when all currently-linked stylesheets have loaded.
* Any change to the set of linked sheets after the initial call will be ignored.
*/
async allLoadedPromise() {
const promises: Promise<void>[] = [];

for(const sheetElem of this.linkedSheets) {
// Based on https://stackoverflow.com/a/21147238
if(sheetElem.sheet?.cssRules) {
promises.push(Promise.resolve());
} else {
const promise = new ManagedPromise<void>();
sheetElem.addEventListener('load', () => promise.resolve());
promises.push(promise.corePromise);
}
}

await Promise.all(promises);
}

/**
* Build a stylesheet with a font-face CSS descriptor for the embedded font appropriate
* for the browser being used
Expand Down Expand Up @@ -212,6 +233,8 @@ export class StylesheetManager {
sheet.parentNode.removeChild(sheet);
}
}

this.linkedSheets.splice(0, this.linkedSheets.length);
}
}

Expand Down
23 changes: 6 additions & 17 deletions web/src/engine/osk/src/keyboard-layout/oskLayerGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,28 +104,17 @@ export default class OSKLayerGroup {
private nearestKey(coord: Omit<InputSample<KeyElement>, 'item'>, layer: OSKLayer): KeyElement {
const baseRect = this.element.getBoundingClientRect();

/**
* Transforms the client rect of child elements to use a coordinate system where the top-left
* of the layer group's bounding rectangle serves as the origin - the same coordinate
* system output by the gesture engine.
* @param childRect
* @returns
*/
const translation = (childRect: DOMRect) => {
return new DOMRect(childRect.x - baseRect.x, childRect.y - baseRect.y, childRect.width, childRect.height);
}

let row: OSKRow = null;
let bestMatchDistance = Number.MAX_VALUE;

// Find the row that the touch-coordinate lies within.
for(const r of layer.rows) {
const rowRect = translation(r.element.getBoundingClientRect());
if(rowRect.top <= coord.targetY && coord.targetY < rowRect.bottom) {
const rowRect = r.element.getBoundingClientRect();
if(rowRect.top <= coord.clientY && coord.clientY < rowRect.bottom) {
row = r;
break;
} else {
const distance = rowRect.top > coord.targetY ? rowRect.top - coord.targetY : coord.targetY - rowRect.bottom;
const distance = rowRect.top > coord.clientY ? rowRect.top - coord.clientY : coord.clientY - rowRect.bottom;

if(distance < bestMatchDistance) {
bestMatchDistance = distance;
Expand All @@ -144,12 +133,12 @@ export default class OSKLayerGroup {
let dxMax = 24;
let dxMin = 100000;

const x = coord.targetX;
const x = coord.clientX;

for (let k = 0; k < row.keys.length; k++) {
// Second-biggest, though documentation suggests this is probably right.
const keySquare = row.keys[k].square as HTMLElement; // gets the .kmw-key-square containing a key
const squareRect = translation(keySquare.getBoundingClientRect());
const squareRect = keySquare.getBoundingClientRect();

// Find the actual key element.
let childNode = keySquare.firstChild ? keySquare.firstChild as HTMLElement : keySquare;
Expand Down Expand Up @@ -179,7 +168,7 @@ export default class OSKLayerGroup {

if (dxMin < 100000) {
const t = <HTMLElement>row.keys[closestKeyIndex].square;
const squareRect = translation(t.getBoundingClientRect());
const squareRect = t.getBoundingClientRect();

const x1 = squareRect.left;
const x2 = squareRect.right;
Expand Down
Loading