diff --git a/common/web/gesture-recognizer/src/inputEventEngine.ts b/common/web/gesture-recognizer/src/inputEventEngine.ts index d52985fae7..f62b7bc176 100644 --- a/common/web/gesture-recognizer/src/inputEventEngine.ts +++ b/common/web/gesture-recognizer/src/inputEventEngine.ts @@ -77,23 +77,27 @@ namespace com.keyman.osk { sequenceWrapper.signalUpdate(sample); } - onInputMoveCancel(identifier: number) { + onInputMoveCancel(identifier: number, sample: InputSample) { let sequenceWrapper = this.getSequenceWrapperWithId(identifier); if(!sequenceWrapper) { return; } + sequenceWrapper.item.addSample(sample); + sequenceWrapper.signalUpdate(sample); sequenceWrapper.cancel(); } - onInputEnd(identifier: number, sample: InputSample) { + onInputEnd(identifier: number) { let sequenceWrapper = this.getSequenceWrapperWithId(identifier); if(!sequenceWrapper) { return; } + // We do not add a sample here because any 'end' event immediately follows a + // 'move' if it occurred simultaneously. sequenceWrapper.end(); } } diff --git a/common/web/gesture-recognizer/src/mouseEventEngine.ts b/common/web/gesture-recognizer/src/mouseEventEngine.ts index c0450cd287..cbbc8af4a8 100644 --- a/common/web/gesture-recognizer/src/mouseEventEngine.ts +++ b/common/web/gesture-recognizer/src/mouseEventEngine.ts @@ -118,7 +118,7 @@ namespace com.keyman.osk { if(!event.buttons) { if(this.hasActiveClick) { this.hasActiveClick = false; - this.onInputMoveCancel(this.activeIdentifier); + this.onInputMoveCancel(this.activeIdentifier, sample); } return; } @@ -128,7 +128,7 @@ namespace com.keyman.osk { if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, this.config, this.disabledSafeBounds)) { this.onInputMove(this.activeIdentifier, sample); } else { - this.onInputMoveCancel(this.activeIdentifier); + this.onInputMoveCancel(this.activeIdentifier, sample); } } @@ -141,8 +141,7 @@ namespace com.keyman.osk { this.hasActiveClick = false; } - const sample = this.buildSampleFromEvent(event); - this.onInputEnd(this.activeIdentifier, sample); + this.onInputEnd(this.activeIdentifier); } } } \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/test/auto/browser/cases/layerOne.js b/common/web/gesture-recognizer/src/test/auto/browser/cases/layerOne.js deleted file mode 100644 index 0e9f8c7105..0000000000 --- a/common/web/gesture-recognizer/src/test/auto/browser/cases/layerOne.js +++ /dev/null @@ -1,46 +0,0 @@ -var assert = chai.assert; -var expect = chai.expect; - -describe("Layer one - DOM -> InputSequence", function() { - this.timeout(testconfig.timeouts.standard); - - before(function() { - fixture.setBase(''); - }); - - beforeEach(function(done) { - fixture.load('host-fixture.html'); - this.controller = new Testing.HostFixtureLayoutController(); - this.controller.connect().then(() => done()); - }); - - afterEach(function() { - this.controller.destroy(); - fixture.cleanup(); - }); - - it('First true unit test', function() { - let playbackEngine = new Testing.InputSequenceSimulator(this.controller); - let testObj = __json__.canaryRecording; // See src/test/resources/json/canaryRecording.json - - let result = playbackEngine.replay(testObj); - assert.equal(testObj.set.length, result.set.length); - - // Removes the timestamp element; we know that this component won't match, as the playback engine - // doesn't care about it. - let sampleCleaner = (sample) => { return {targetX: sample.targetX, targetY: sample.targetY} }; - - // Returns just the observed, cleaned samples for a sequence object. The recorded coordinates - // should match perfectly. - let seqCleaner = (seq) => { return seq.sequence.samples.map(sampleCleaner) }; - let cleanOriginalSet = testObj.set.map(seqCleaner); - let cleanResultSet = result .set.map(seqCleaner); - - expect(cleanResultSet).to.deep.equal(cleanOriginalSet); - - // The 'terminationEvent' property should match. Any sequence that was "canceled" should still - // cancel; that's a pretty critical detail! - let terminationEventMapper = (seq) => seq.terminationEvent; - expect(result.set.map(terminationEventMapper)).to.deep.equal(testObj.set.map(terminationEventMapper)); - }); -}); \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/test/auto/browser/cases/recordedCoordSequences.js b/common/web/gesture-recognizer/src/test/auto/browser/cases/recordedCoordSequences.js new file mode 100644 index 0000000000..cd1413e91a --- /dev/null +++ b/common/web/gesture-recognizer/src/test/auto/browser/cases/recordedCoordSequences.js @@ -0,0 +1,73 @@ +var assert = chai.assert; +var expect = chai.expect; + +describe("Layer one - DOM -> InputSequence", function() { + this.timeout(testconfig.timeouts.standard); + + before(function() { + fixture.setBase(''); + }); + + beforeEach(function(done) { + fixture.load('host-fixture.html'); + this.controller = new Testing.HostFixtureLayoutController(); + this.controller.connect().then(() => done()); + }); + + afterEach(function() { + this.controller.destroy(); + fixture.cleanup(); + fixture.cleanup(); + }); + + describe('recorded input sequences', function() { + // We rely on this function to have the same context as `it` - the test-definition function. + let replayAndCompare = function(testObj) { + // Everything else can be extracted to a common test function... probably. + let playbackEngine = new Testing.InputSequenceSimulator(this.controller); + let result = playbackEngine.replay(testObj); + + assert.equal(result.set.length, testObj.set.length); + + // Removes the timestamp element; we know that this component won't match, as the playback engine + // doesn't care about it. + let sampleCleaner = (sample) => { return {targetX: sample.targetX, targetY: sample.targetY} }; + + // Returns just the observed, cleaned samples for a sequence object. The recorded coordinates + // should match perfectly. + let seqCleaner = (seq) => { return seq.sequence.samples.map(sampleCleaner) }; + let cleanOriginalSet = testObj.set.map(seqCleaner); + let cleanResultSet = result .set.map(seqCleaner); + + expect(cleanResultSet).to.deep.equal(cleanOriginalSet); + + // The 'terminationEvent' property should match. Any sequence that was "canceled" should still + // cancel; that's a pretty critical detail! + let terminationEventMapper = (seq) => seq.terminationEvent; + expect(result.set.map(terminationEventMapper)).to.deep.equal(testObj.set.map(terminationEventMapper)); + } + + // List all relevant fixtures in src/test/resources/json. + let testRecordings = [ + 'canaryRecording', + 'desktopRoamAndReturn', + 'mobileSafeZoneCancel', + 'mobileProximityApproach', + 'embeddedBorderCancel', + 'hardBorderCancel', + 'popupLongRoamingEnd', + 'popupShimCancel' + ]; + + for(let recordingID of testRecordings) { + it(`${recordingID}.json`, function() { + let testObj = __json__[recordingID]; + + console.log(`expected terminator: ${testObj.set[0].terminationEvent}`) + + // 'describe' has a notably different `this` reference than `it`, `before`, etc. + replayAndCompare.call(this, testObj); + }); + } + }); +}); \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/test/resources/json/desktopRoamAndReturn.json b/common/web/gesture-recognizer/src/test/resources/json/desktopRoamAndReturn.json new file mode 100644 index 0000000000..336a13c1d1 --- /dev/null +++ b/common/web/gesture-recognizer/src/test/resources/json/desktopRoamAndReturn.json @@ -0,0 +1,288 @@ +{ + "set": [ + { + "sequence": { + "samples": [ + { + "targetX": 119, + "targetY": 27, + "t": 670652.6999999881 + }, + { + "targetX": 119, + "targetY": 26, + "t": 670770.8999999762 + }, + { + "targetX": 119, + "targetY": 25, + "t": 670783.5 + }, + { + "targetX": 119, + "targetY": 24, + "t": 670792.5 + }, + { + "targetX": 119, + "targetY": 24, + "t": 670815.2999999523 + }, + { + "targetX": 119, + "targetY": 23, + "t": 670825.8999999762 + }, + { + "targetX": 119, + "targetY": 22, + "t": 670848.1999999881 + }, + { + "targetX": 119, + "targetY": 21, + "t": 670859.0999999642 + }, + { + "targetX": 119, + "targetY": 20, + "t": 670875.8999999762 + }, + { + "targetX": 119, + "targetY": 20, + "t": 670892.6999999881 + }, + { + "targetX": 119, + "targetY": 19, + "t": 670909.1999999881 + }, + { + "targetX": 119, + "targetY": 18, + "t": 670926.5 + }, + { + "targetX": 119, + "targetY": 16, + "t": 670942.5 + }, + { + "targetX": 118, + "targetY": 15, + "t": 670959.1999999881 + }, + { + "targetX": 118, + "targetY": 12, + "t": 670975.7999999523 + }, + { + "targetX": 118, + "targetY": 12, + "t": 670993.1999999881 + }, + { + "targetX": 118, + "targetY": 10, + "t": 671009.1999999881 + }, + { + "targetX": 118, + "targetY": 8, + "t": 671025.8999999762 + }, + { + "targetX": 118, + "targetY": 6, + "t": 671042.5999999642 + }, + { + "targetX": 118, + "targetY": 5, + "t": 671059.2999999523 + }, + { + "targetX": 118, + "targetY": 4, + "t": 671075.8999999762 + }, + { + "targetX": 118, + "targetY": 2, + "t": 671092.5999999642 + }, + { + "targetX": 118, + "targetY": 0, + "t": 671109.1999999881 + }, + { + "targetX": 118, + "targetY": 0, + "t": 671125.8999999762 + }, + { + "targetX": 118, + "targetY": -2, + "t": 671142.5999999642 + }, + { + "targetX": 118, + "targetY": -3, + "t": 671159.0999999642 + }, + { + "targetX": 118, + "targetY": -4, + "t": 671175.7999999523 + }, + { + "targetX": 118, + "targetY": -4, + "t": 671192.5 + }, + { + "targetX": 118, + "targetY": -5, + "t": 671244.3999999762 + }, + { + "targetX": 118, + "targetY": -6, + "t": 671260 + }, + { + "targetX": 119, + "targetY": -7, + "t": 671275.8999999762 + }, + { + "targetX": 120, + "targetY": -7, + "t": 671326.1999999881 + }, + { + "targetX": 120, + "targetY": -8, + "t": 671342.5 + }, + { + "targetX": 120, + "targetY": -8, + "t": 671359.8999999762 + }, + { + "targetX": 120, + "targetY": -8, + "t": 671376 + }, + { + "targetX": 121, + "targetY": -8, + "t": 671395.1999999881 + }, + { + "targetX": 121, + "targetY": -9, + "t": 671420.5999999642 + }, + { + "targetX": 122, + "targetY": -9, + "t": 671460 + }, + { + "targetX": 123, + "targetY": -9, + "t": 671475.8999999762 + }, + { + "targetX": 124, + "targetY": -9, + "t": 671525.8999999762 + }, + { + "targetX": 125, + "targetY": -8, + "t": 671542.5999999642 + }, + { + "targetX": 126, + "targetY": -8, + "t": 671559.2999999523 + }, + { + "targetX": 127, + "targetY": -6, + "t": 671575.7999999523 + }, + { + "targetX": 128, + "targetY": -4, + "t": 671592.5999999642 + }, + { + "targetX": 128, + "targetY": -3, + "t": 671609.2999999523 + }, + { + "targetX": 129, + "targetY": -1, + "t": 671625.8999999762 + }, + { + "targetX": 129, + "targetY": 0, + "t": 671642.5999999642 + }, + { + "targetX": 129, + "targetY": 0, + "t": 671659.1999999881 + }, + { + "targetX": 129, + "targetY": 1, + "t": 671675.8999999762 + }, + { + "targetX": 130, + "targetY": 1, + "t": 671692.8999999762 + }, + { + "targetX": 130, + "targetY": 3, + "t": 671709.2999999523 + }, + { + "targetX": 130, + "targetY": 4, + "t": 671734.8999999762 + }, + { + "targetX": 130, + "targetY": 4, + "t": 671749.5 + }, + { + "targetX": 130, + "targetY": 5, + "t": 671793 + } + ], + "isFromTouch": false + }, + "terminationEvent": "end" + } + ], + "config": { + "deviceStyle": "screen3", + "roamingStyle": "bounds1", + "receiverStyle": "full", + "safeZoneStyle": "safe-loose" + } +} \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/test/resources/json/mobileSafeZoneCancel.json b/common/web/gesture-recognizer/src/test/resources/json/mobileSafeZoneCancel.json new file mode 100644 index 0000000000..508b28be0c --- /dev/null +++ b/common/web/gesture-recognizer/src/test/resources/json/mobileSafeZoneCancel.json @@ -0,0 +1,38 @@ +{ + "set": [ + { + "sequence": { + "samples": [ + { + "targetX": 226, + "targetY": 38, + "t": 61290 + }, + { + "targetX": 234, + "targetY": 38, + "t": 61676 + }, + { + "targetX": 235, + "targetY": 37, + "t": 61729 + }, + { + "targetX": 237, + "targetY": 37, + "t": 61829 + } + ], + "isFromTouch": true + }, + "terminationEvent": "cancel" + } + ], + "config": { + "deviceStyle": "screen2", + "roamingStyle": "bounds1", + "receiverStyle": "full", + "safeZoneStyle": "safe-loose" + } +} \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/tools/recorder/src/index.html b/common/web/gesture-recognizer/src/tools/recorder/src/index.html index 9438072dee..4584984ec0 100644 --- a/common/web/gesture-recognizer/src/tools/recorder/src/index.html +++ b/common/web/gesture-recognizer/src/tools/recorder/src/index.html @@ -25,6 +25,12 @@

The area below represents a simulated page with its corresponding screen, page, and gesture-reception area.

+

+ NOTE: For setting up touch-based sequences from a normal computer, use of Firefox's touch-emulation mode is + advised. Chrome's version tends to produce touch events with subpixel coordinates, whereas Firefox's doesn't. + + You may also need to manually tweak the final coordinate a little bit for cross-browser consistency. +

diff --git a/common/web/gesture-recognizer/src/tools/unit-test-resources/src/inputSequenceSimulator.ts b/common/web/gesture-recognizer/src/tools/unit-test-resources/src/inputSequenceSimulator.ts index 088926b957..901b8e3469 100644 --- a/common/web/gesture-recognizer/src/tools/unit-test-resources/src/inputSequenceSimulator.ts +++ b/common/web/gesture-recognizer/src/tools/unit-test-resources/src/inputSequenceSimulator.ts @@ -20,31 +20,60 @@ namespace Testing { } } + private buildSyntheticTouchEvent(name: string, dict: TouchEventInit): TouchEvent { + let config = this.controller.recognizer.config; + + let fullDict = { + srcElement: config.touchEventRoot, + view: window, + ...dict + } as any; + + return new Event('touchstart', fullDict) as TouchEvent; + } + replayTouchSample(sample: JSONObject, state: string, identifier: number, otherTouches: Touch[]): Touch { let config = this.controller.recognizer.config; let event: TouchEvent; const mappedSample = this.getSampleClientPos(sample); - let touch = new Touch({identifier: identifier, - target: config.targetRoot, - ...mappedSample}); + let touch: Touch; + let touchDict = {identifier: identifier, + target: config.targetRoot, + ...mappedSample}; + if(window['Touch'] !== undefined) { + touch = new Touch(touchDict); + } else { + // When not performing touch-emulation, some desktop browsers will leave `Touch` undefined. + touch = touchDict as any; + } + + otherTouches = otherTouches || []; let touchEventDict: TouchEventInit = { bubbles: true, - touches: [touch], - changedTouches: [touch] + touches: [touch, ...otherTouches], + changedTouches: [touch], + } + + let buildEvent = (type: string, dict: TouchEventInit) => { + if(window['TouchEvent'] !== undefined) { + return new TouchEvent(type, dict); + } else { + return this.buildSyntheticTouchEvent(type, dict); + } } switch(state) { case 'start': - event = new TouchEvent('touchstart', touchEventDict); + event = buildEvent('touchstart', touchEventDict); break; case 'move': - event = new TouchEvent('touchmove', touchEventDict); + event = buildEvent('touchmove', touchEventDict); break; case 'end': - event = new TouchEvent('touchend', touchEventDict); + event = buildEvent('touchend', touchEventDict); break; } @@ -169,7 +198,9 @@ namespace Testing { // We technically don't have access to 'samples' for the actual recorded object. // Obtaining a 'deep copy' (though methodless) works around this nicely and // matches the original `sequenceTestSpec`'s format to boot. - return JSON.parse(recorder.recordingsToJSON()) as RecordedCoordSequenceSet; + let recording = JSON.parse(recorder.recordingsToJSON()) as RecordedCoordSequenceSet; + recorder.clear(); + return recording; } } } \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/touchEventEngine.ts b/common/web/gesture-recognizer/src/touchEventEngine.ts index a67f292d5b..a05b07ce1d 100644 --- a/common/web/gesture-recognizer/src/touchEventEngine.ts +++ b/common/web/gesture-recognizer/src/touchEventEngine.ts @@ -119,7 +119,7 @@ namespace com.keyman.osk { if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, this.config, this.safeBoundMaskMap[touch.identifier])) { this.onInputMove(touch.identifier, sample); } else { - this.onInputMoveCancel(touch.identifier); + this.onInputMoveCancel(touch.identifier, sample); } } } @@ -134,9 +134,7 @@ namespace com.keyman.osk { this.preventPropagation(event); - const sample = this.buildSampleFromTouch(touch); - - this.onInputEnd(touch.identifier, sample); + this.onInputEnd(touch.identifier); } } }