diff --git a/web/source/dom/input.ts b/web/source/dom/input.ts index aad31884fd..46cab77506 100644 --- a/web/source/dom/input.ts +++ b/web/source/dom/input.ts @@ -68,9 +68,16 @@ namespace com.keyman.dom { } setCaret(caret: number) { - let domCaret = this.root.value._kmwCodePointToCodeUnit(caret); - this.root.setSelectionRange(domCaret, domCaret); - this.processedSelectionEnd = caret; + this.setSelection(caret, caret); + } + + setSelection(start: number, end: number) { + let domStart = this.root.value._kmwCodePointToCodeUnit(start); + let domEnd = this.root.value._kmwCodePointToCodeUnit(end); + this.root.setSelectionRange(domStart, domEnd); + + this.processedSelectionStart = start; + this.processedSelectionEnd = end; } getTextBeforeCaret(): string { diff --git a/web/source/dom/textarea.ts b/web/source/dom/textarea.ts index 2674e3fefe..aa9e539c31 100644 --- a/web/source/dom/textarea.ts +++ b/web/source/dom/textarea.ts @@ -75,9 +75,16 @@ namespace com.keyman.dom { } setCaret(caret: number) { - let domCaret = this.root.value._kmwCodePointToCodeUnit(caret); - this.root.setSelectionRange(domCaret, domCaret); - this.processedSelectionEnd = caret; + this.setSelection(caret, caret); + } + + setSelection(start: number, end: number) { + let domStart = this.root.value._kmwCodePointToCodeUnit(start); + let domEnd = this.root.value._kmwCodePointToCodeUnit(end); + this.root.setSelectionRange(domStart, domEnd); + + this.processedSelectionStart = start; + this.processedSelectionEnd = end; } getTextBeforeCaret(): string { diff --git a/web/source/text/deadkeys.ts b/web/source/text/deadkeys.ts index 68616955e6..d0f775ae04 100644 --- a/web/source/text/deadkeys.ts +++ b/web/source/text/deadkeys.ts @@ -33,6 +33,13 @@ namespace com.keyman.text { return this.o < other.o; } + clone(): Deadkey { + let dk = new Deadkey(this.p, this.d); + dk.o = this.o; + + return dk; + } + /** * Sorts the deadkeys in reverse order. */ @@ -57,7 +64,13 @@ namespace com.keyman.text { clone(): DeadkeyTracker { let dkt = new DeadkeyTracker(); - dkt.dks = this.toSortedArray(); + let dks = this.toSortedArray(); + + // Make sure to clone the deadkeys themselves - the Deadkey object is mutable. + dkt.dks = []; + dks.forEach(function(value: Deadkey) { + dkt.dks.push(value.clone()); + }); return dkt; } diff --git a/web/unit_tests/cases/target_mocks.js b/web/unit_tests/cases/target_mocks.js new file mode 100644 index 0000000000..e1c05ba465 --- /dev/null +++ b/web/unit_tests/cases/target_mocks.js @@ -0,0 +1,156 @@ +var assert = chai.assert; + +var MockTests; + +// Define common interface testing functions that can be run upon the OutputTarget interface. +if(typeof MockTests == 'undefined') { + MockTests = {}; + + (function(){ + // Makes a nice Unicode shortcut. + var u = toSupplementaryPairString; + + MockTests.Apple = {}; + MockTests.Apple.normal = 'apple'; + // Built in-line via function. Looks functionally equivalent to "apple", but with SMP characters. + MockTests.Apple.smp = u(0x1d5ba)+u(0x1d5c9)+u(0x1d5c9)+u(0x1d5c5)+u(0x1d5be); + MockTests.Apple.mixed = 'a'+u(0x1d5c9)+'p'+'l'+u(0x1d5be); + + MockTests.Deadkeys = {}; + MockTests.Deadkeys.initial = [ + {d: 1, p: 2}, // After the 'p'-ish SMP character of Apple.mixed. + {d: 0, p: 3} // After the normal 'p' character of Apple.mixed. + ]; + + //#region Defines helpers related to HTMLInputElement / Input test setup. + MockTests.initBase = function() { + var id = DynamicElements.addInput(); + var elem = document.getElementById(id); + var wrapper = new com.keyman.dom.Input(elem); + + return wrapper; + } + + MockTests.applyDeadkeys = function(base, dks) { + dks.forEach(function(val) { + base.setCaret(val.p); + base.insertDeadkeyBeforeCaret(val.d); + }); + } + + MockTests.setupBase = function(selStart, selEnd) { + // Defines initial selection range. + if(!selStart) { + selStart = 0; + } + + if(!selEnd) { + selEnd = selStart; + } + + let base = this.initBase(); + base.root.value = MockTests.Apple.mixed; + this.applyDeadkeys(base, this.Deadkeys.initial); + + // Set the requested selection range. + base.setSelection(selStart, selEnd); + + return base; + } + //#endregion + })(); +} + +describe('OutputTarget Mocking', function() { + this.timeout(kmwconfig.timeouts.standard); + + before(function() { + fixture.setBase('unit_tests/fixtures'); + + // Make sure the basic SMP extension hooks exist to prevent errors later. + String.kmwEnableSupplementaryPlane(true); + }); + + beforeEach(function() { + fixture.load("robustAttachment.html"); + }) + + afterEach(function() { + fixture.cleanup(); + }); + + after(function() { + String.kmwEnableSupplementaryPlane(false); + }) + + describe('The "Mock" output target', function() { + // Unique to the Mock type - element interface cloning tests. Is element state properly copied? + // TODO: This. + + // Test with Input / TextArea source + // - with/without pre-existing deadkeys + // - with/without existing selected text in the source element + // - text operations on the Mock are not aliased / do not affect the source element. + + describe('Initialization', function() { + it('copies an existing OutputTarget without a text selection', function() { + var base = MockTests.setupBase(4); + + var mock = com.keyman.text.Mock.from(base); + assert.equal(mock.getText(), MockTests.Apple.mixed); + assert.deepEqual(mock.deadkeys(), base.deadkeys()); + }); + + it('copies an existing OutputTarget with a text selection', function() { + var base = MockTests.setupBase(4, 5); + + var mock = com.keyman.text.Mock.from(base); + // The selection should appear to be automatically deleted, as any text mutation + // by KMW would automatically erase the text anyway. + assert.equal(mock.getText(), MockTests.Apple.mixed.substr(0, 5)); + assert.deepEqual(mock.deadkeys(), base.deadkeys()); + }); + }); + + describe('Aliasing', function() { + // is properly independent of the source element with no cross-effects + // with/without deadkeys + it('is not affected by mutation of the source element', function() { + // Already-verified code + var base = MockTests.setupBase(4); + var mock = com.keyman.text.Mock.from(base); + var baseInitDks = base.deadkeys().clone(); + + // Now for the actual test. + var dk = MockTests.Deadkeys.initial; + // Note - we selectively match and remove only ONE of the deadkeys. (Naturally, the one closer to the caret.) + base.hasDeadkeyMatch(4-dk[1].p, dk[1].d); + base.deadkeys().deleteMatched(); + base.deleteCharsBeforeCaret(2); + + assert.notDeepEqual(base.deadkeys(), baseInitDks, 'OutputTarget deadkey return is not a proper deep-copy'); + + assert.equal(mock.getText(), MockTests.Apple.mixed); + assert.deepEqual(mock.deadkeys(), baseInitDks); + }); + + it('does not affect the source element when mutated', function() { + // Already-verified code + var base = MockTests.setupBase(4); + var mock = com.keyman.text.Mock.from(base); + var baseInitDks = base.deadkeys().clone(); + + // Now for the actual test. + var dk = MockTests.Deadkeys.initial; + // Note - we selectively match and remove only ONE of the deadkeys. (Naturally, the one closer to the caret.) + mock.hasDeadkeyMatch(4-dk[1].p, dk[1].d); + mock.deadkeys().deleteMatched(); + mock.deleteCharsBeforeCaret(2); + + assert.notDeepEqual(mock.deadkeys(), baseInitDks); + assert.equal(base.getText(), MockTests.Apple.mixed); + assert.deepEqual(base.deadkeys(), baseInitDks); + }); + }); + }); +}); \ No newline at end of file