diff --git a/web/src/engine/src/js-processor/jsKeyboardInterface.ts b/web/src/engine/src/js-processor/jsKeyboardInterface.ts index bd545322b0..71b988373e 100644 --- a/web/src/engine/src/js-processor/jsKeyboardInterface.ts +++ b/web/src/engine/src/js-processor/jsKeyboardInterface.ts @@ -627,6 +627,8 @@ export class JSKeyboardInterface extends KeyboardHarness { * @param {number} d deadkey * @return {boolean} True if deadkey found selected context matches val * Description Match deadkey at current cursor position + * + * Deprecated: use `fullContextMatch` instead. */ deadkeyMatch(n: number, textStore: TextStore, d: number): boolean { return textStore.hasDeadkeyMatch(n, d); diff --git a/web/src/engine/src/keyboard/deadkeys.ts b/web/src/engine/src/keyboard/deadkeys.ts index 53cf0d5995..8c3effc7cb 100644 --- a/web/src/engine/src/keyboard/deadkeys.ts +++ b/web/src/engine/src/keyboard/deadkeys.ts @@ -11,10 +11,11 @@ export class Deadkey { this.p = pos; this.d = id; this.o = Deadkey.ordinalSeed++; + this.matched = 0; } - match(p: number, d: number): boolean { - const result:boolean = (this.p == p && this.d == d); + match(pos: number, deadkey: number): boolean { + const result:boolean = (this.p == pos && this.d == deadkey); return result; } @@ -39,7 +40,7 @@ export class Deadkey { } equal(other: Deadkey) { - return this.d == other.d && this.p == other.d && this.o == other.o; + return this.d == other.d && this.p == other.p && this.o == other.o; } /** @@ -82,11 +83,11 @@ export class DeadkeyTracker { * Scope Public * @param {number} caretPos current cursor position * @param {number} n expected offset of deadkey from cursor - * @param {number} d deadkey + * @param {number} deadkey deadkey * @return {boolean} True if deadkey found selected context matches val * Description Match deadkey at current cursor position */ - isMatch(caretPos: number, n: number, d: number): boolean { + isMatch(caretPos: number, n: number, deadkey: number): boolean { if(this.dks.length == 0) { return false; // I3318 } @@ -96,7 +97,7 @@ export class DeadkeyTracker { for(let i = 0; i < this.dks.length; i++) { // Don't re-match an already-matched deadkey. It's possible to have two identical // entries, and they should be kept separately. - if(this.dks[i].match(n, d) && !this.dks[i].matched) { + if(this.dks[i].match(n, deadkey) && !this.dks[i].matched) { this.dks[i].set(); // Assumption: since we match the first possible entry in the array, we // match the entry with the lower ordinal - the 'first' deadkey in the position. @@ -115,7 +116,9 @@ export class DeadkeyTracker { remove(dk: Deadkey) { const index = this.dks.indexOf(dk); - this.dks.splice(index, 1); + if (index > -1) { + this.dks.splice(index, 1); + } } clear() { @@ -161,7 +164,6 @@ export class DeadkeyTracker { } const otherDks = other.dks; - const matchedDks: Deadkey[] = []; for(const dk of this.dks) { const match = otherDks.find((otherDk) => dk.equal(otherDk)); @@ -170,7 +172,7 @@ export class DeadkeyTracker { } } - return matchedDks.length == otherDks.length; + return true; } count(): number { diff --git a/web/src/engine/src/keyboard/index.ts b/web/src/engine/src/keyboard/index.ts index 90e1489e1f..ba3250d411 100644 --- a/web/src/engine/src/keyboard/index.ts +++ b/web/src/engine/src/keyboard/index.ts @@ -38,6 +38,15 @@ export { TextStoreLanguageProcessorInterface } from "./textStoreLanguageProcesso export { findCommonSubstringEndIndex } from "./stringDivergence.js"; export { Deadkey } from "./deadkeys.js"; +import { DeadkeyTracker } from './deadkeys.js'; + +/** + * these are exported only for unit tests, do not use + */ +export const unitTestEndpoints = { + DeadkeyTracker, +}; + // TODO-web-core: why do we export these here? export * from "keyman/common/web-utils"; diff --git a/web/src/test/auto/headless/engine/keyboard/deadkeyTracker.tests.ts b/web/src/test/auto/headless/engine/keyboard/deadkeyTracker.tests.ts new file mode 100644 index 0000000000..fe749074f8 --- /dev/null +++ b/web/src/test/auto/headless/engine/keyboard/deadkeyTracker.tests.ts @@ -0,0 +1,283 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ + +import { assert } from 'chai'; +import { Deadkey, unitTestEndpoints } from 'keyman/engine/keyboard'; + +const DeadkeyTracker = unitTestEndpoints.DeadkeyTracker; + +describe('DeadkeyTracker', function() { + it('can add deadkeys and return count', function() { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(3, 1)); + tracker.add(new Deadkey(5, 2)); + tracker.add(new Deadkey(10, 3)); + + assert.equal(tracker.dks.length, 3); + assert.equal(tracker.count(), 3); + }); + + it('sorts deadkeys by position, latest first', function() { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(5, 2)); + tracker.add(new Deadkey(3, 1)); + tracker.add(new Deadkey(10, 3)); + + const sortedDeadkeys = tracker.toSortedArray(); + + assert.equal(sortedDeadkeys[0].p, 10); + assert.equal(sortedDeadkeys[1].p, 5); + assert.equal(sortedDeadkeys[2].p, 3); + }); + + it('can adjust positions', function () { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(3, 1)); + tracker.add(new Deadkey(5, 2)); + tracker.add(new Deadkey(10, 3)); + + tracker.adjustPositions(4, 2); // adjust deadkeys after position 4 by +2 + + const sortedDeadkeys = tracker.toSortedArray(); + assert.equal(sortedDeadkeys[0].p, 12); // 10 + 2 + assert.equal(sortedDeadkeys[1].p, 7); // 5 + 2 + assert.equal(sortedDeadkeys[2].p, 3); // unchanged + }); + + describe('equal', function () { + it('treats cloned tracker as equal', function () { + const tracker1 = new DeadkeyTracker(); + tracker1.add(new Deadkey(3, 1)); + tracker1.add(new Deadkey(5, 2)); + tracker1.add(new Deadkey(10, 3)); + + const tracker2 = tracker1.clone(); + + assert.isTrue(tracker1.equal(tracker2)); + }); + + it('treats two trackers with same deadkeys create separately not as equal', function () { + const tracker1 = new DeadkeyTracker(); + tracker1.add(new Deadkey(3, 1)); + tracker1.add(new Deadkey(5, 2)); + tracker1.add(new Deadkey(10, 3)); + + const tracker2 = new DeadkeyTracker(); + tracker2.add(new Deadkey(3, 1)); + tracker2.add(new Deadkey(5, 2)); + tracker2.add(new Deadkey(10, 3)); + + assert.isFalse(tracker1.equal(tracker2)); + }); + + it('treats two trackers with same deadkeys in different order not as equal', function () { + const tracker1 = new DeadkeyTracker(); + tracker1.add(new Deadkey(5, 2)); + tracker1.add(new Deadkey(3, 1)); + tracker1.add(new Deadkey(10, 3)); + + const tracker2 = new DeadkeyTracker(); + tracker2.add(new Deadkey(10, 3)); + tracker2.add(new Deadkey(5, 2)); + tracker2.add(new Deadkey(3, 1)); + + assert.isFalse(tracker1.equal(tracker2)); + }); + + it('treats two trackers with same deadkeys in different places not as equal', function () { + const tracker1 = new DeadkeyTracker(); + tracker1.add(new Deadkey(7, 2)); + tracker1.add(new Deadkey(3, 1)); + tracker1.add(new Deadkey(12, 3)); + + const tracker2 = new DeadkeyTracker(); + tracker2.add(new Deadkey(10, 3)); + tracker2.add(new Deadkey(5, 2)); + tracker2.add(new Deadkey(3, 1)); + + assert.isFalse(tracker1.equal(tracker2)); + }); + + it('treats a tracker with additional deadkeys not as equal', function () { + const tracker1 = new DeadkeyTracker(); + tracker1.add(new Deadkey(3, 1)); + tracker1.add(new Deadkey(5, 2)); + tracker1.add(new Deadkey(10, 3)); + + const tracker2 = tracker1.clone(); + tracker2.add(new Deadkey(15, 4)); + + assert.isFalse(tracker1.equal(tracker2)); + }); + }); + + it('can clear deadkeys', function() { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(3, 1)); + tracker.add(new Deadkey(5, 2)); + tracker.add(new Deadkey(10, 3)); + + tracker.clear(); + + assert.equal(tracker.count(), 0); + }); + + describe('remove', function () { + it('can remove existing deadkey', function () { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(3, 1)); + const dk = new Deadkey(5, 2); + tracker.add(dk); + tracker.add(new Deadkey(10, 3)); + + tracker.remove(dk); + + assert.equal(tracker.count(), 2); + assert.equal(tracker.dks[0].p, 3); + assert.equal(tracker.dks[1].p, 10); + }); + + it('does not remove similar deadkey', function () { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(3, 1)); + tracker.add(new Deadkey(5, 2)); + tracker.add(new Deadkey(10, 3)); + + tracker.remove(new Deadkey(5, 2)); + + assert.equal(tracker.count(), 3); + assert.equal(tracker.dks[0].p, 3); + assert.equal(tracker.dks[1].p, 5); + assert.equal(tracker.dks[2].p, 10); + }); + }); + + it('sorts deadkeys in cloned tracker', function () { + const tracker1 = new DeadkeyTracker(); + tracker1.add(new Deadkey(5, 1)); + tracker1.add(new Deadkey(3, 2)); + tracker1.add(new Deadkey(10, 3)); + + const tracker2 = tracker1.clone(); + + assert.equal(tracker2.count(), 3); + assert.equal(tracker2.dks[0].p, 10); + assert.equal(tracker2.dks[1].p, 5); + assert.equal(tracker2.dks[2].p, 3); + }); + + it('can delete matched deadkeys', function () { + const dk1 = new Deadkey(5, 1); + const dk2 = new Deadkey(3, 2); + const dk3 = new Deadkey(10, 3); + const tracker = new DeadkeyTracker(); + tracker.add(dk1); + tracker.add(dk2); + tracker.add(dk3); + dk2.set(); + dk3.set(); + + tracker.deleteMatched(); + + assert.equal(tracker.count(), 1); + assert.equal(tracker.dks[0].p, 5); + }); + + it('can reset matched deadkeys', function () { + const dk1 = new Deadkey(5, 1); + const dk2 = new Deadkey(3, 2); + const dk3 = new Deadkey(10, 3); + const tracker = new DeadkeyTracker(); + tracker.add(dk1); + tracker.add(dk2); + tracker.add(dk3); + dk2.set(); + dk3.set(); + + tracker.resetMatched(); + + assert.equal(tracker.count(), 3); + assert.equal(tracker.dks[0].matched, 0); + assert.equal(tracker.dks[1].matched, 0); + assert.equal(tracker.dks[2].matched, 0); + }); + + describe('isMatch', function () { + it('returns false without having any deadkeys', function () { + const tracker = new DeadkeyTracker(); + + assert.isFalse(tracker.isMatch(0, 1, 2)); + }); + + it('returns false for non-matching deadkey', function () { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(3, 1)); + tracker.add(new Deadkey(5, 2)); + tracker.add(new Deadkey(10, 3)); + + assert.isFalse(tracker.isMatch(0, 88, 99)); + + assert.equal(tracker.dks[0].matched, 0); + assert.equal(tracker.dks[1].matched, 0); + assert.equal(tracker.dks[2].matched, 0); + }); + + it('returns false if offset is wrong', function () { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(3, 1)); + tracker.add(new Deadkey(5, 2)); + tracker.add(new Deadkey(10, 3)); + + // REVIEW: this behavior seems odd + assert.isFalse(tracker.isMatch(0, 3, 1)); + + assert.equal(tracker.dks[0].matched, 0); + assert.equal(tracker.dks[1].matched, 0); + assert.equal(tracker.dks[2].matched, 0); + }); + + it('returns true for matching deadkey and sets matched flag on deadkey', function () { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(3, 1)); + tracker.add(new Deadkey(5, 2)); + tracker.add(new Deadkey(10, 3)); + + // REVIEW: this behavior seems odd + assert.isTrue(tracker.isMatch(0, -3, 1)); + + assert.equal(tracker.dks[0].matched, 1); + assert.equal(tracker.dks[1].matched, 0); + assert.equal(tracker.dks[2].matched, 0); + }); + + it('returns true for matching deadkey if caret on dk', function () { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(3, 1)); + tracker.add(new Deadkey(5, 2)); + tracker.add(new Deadkey(10, 3)); + + assert.isTrue(tracker.isMatch(3, 0, 1)); + + assert.equal(tracker.dks[0].matched, 1); + assert.equal(tracker.dks[1].matched, 0); + assert.equal(tracker.dks[2].matched, 0); + }); + + it('returns false for matching deadkey if already matched and resets matched flag', function () { + const tracker = new DeadkeyTracker(); + tracker.add(new Deadkey(3, 1)); + tracker.add(new Deadkey(5, 2)); + tracker.add(new Deadkey(10, 3)); + tracker.dks[0].matched = 1; + tracker.dks[1].matched = 1; + tracker.dks[2].matched = 1; + + assert.isFalse(tracker.isMatch(0, -3, 1)); + + assert.equal(tracker.dks[0].matched, 0); + assert.equal(tracker.dks[1].matched, 0); + assert.equal(tracker.dks[2].matched, 0); + }); + }); +});