mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-06 00:45:32 +00:00
305 lines
No EOL
12 KiB
JavaScript
305 lines
No EOL
12 KiB
JavaScript
import { assert } from 'chai';
|
|
import sinon from 'sinon';
|
|
|
|
import { LanguageProcessor, PredictionContext, TranscriptionCache } from '@keymanapp/input-processor';
|
|
import { Worker as LMWorker } from "@keymanapp/lexical-model-layer/node";
|
|
import { DeviceSpec, KeyboardProcessor, Mock } from '@keymanapp/keyboard-processor';
|
|
|
|
function compileDummyModel(suggestionSets) {
|
|
return `
|
|
LMLayerWorker.loadModel(new models.DummyModel({
|
|
futureSuggestions: ${JSON.stringify(suggestionSets, null, 2)},
|
|
}));
|
|
`;
|
|
}
|
|
|
|
// Common spec used for each test's setup. It's actually irrelevant for the tests,
|
|
// but KeyboardProcessor needs an instance.
|
|
const deviceSpec = new DeviceSpec(
|
|
DeviceSpec.Browser.Chrome,
|
|
DeviceSpec.FormFactor.Desktop,
|
|
DeviceSpec.OperatingSystem.Windows
|
|
);
|
|
|
|
const appleDummySuggestionSets = [[
|
|
// Set 1:
|
|
{
|
|
transform: { insert: 'e', deleteLeft: 0},
|
|
displayAs: 'apple',
|
|
}, {
|
|
transform: { insert: 'y', deleteLeft: 0},
|
|
displayAs: 'apply'
|
|
}, {
|
|
transform: { insert: 'es', deleteLeft: 0},
|
|
displayAs: 'apples'
|
|
}
|
|
], [
|
|
// Set 2:
|
|
{
|
|
transform: { insert: '', deleteLeft: 0},
|
|
displayAs: 'apple',
|
|
tag: 'keep'
|
|
}, {
|
|
transform: { insert: 'y', deleteLeft: 0},
|
|
displayAs: 'apply'
|
|
}, {
|
|
transform: { insert: 's', deleteLeft: 1},
|
|
displayAs: 'apps'
|
|
}
|
|
], [
|
|
// Set 3:
|
|
{
|
|
transform: { insert: 'ied', deleteLeft: 2},
|
|
displayAs: 'applied'
|
|
}
|
|
], [
|
|
{
|
|
transform: { insert: ' reverted', deleteLeft: 5},
|
|
displayAs: 'reverted'
|
|
}
|
|
]];
|
|
|
|
const appleDummyModel = {
|
|
id: 'dummy',
|
|
languages: ['en'],
|
|
code: compileDummyModel(appleDummySuggestionSets)
|
|
};
|
|
|
|
describe("PredictionContext", () => {
|
|
let worker;
|
|
|
|
beforeEach(function() {
|
|
worker = LMWorker.constructInstance();
|
|
});
|
|
|
|
afterEach(function() {
|
|
worker.terminate();
|
|
});
|
|
|
|
it('receives predictions as they are generated', async function () {
|
|
const langProcessor = new LanguageProcessor(worker, new TranscriptionCache());
|
|
await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker.
|
|
|
|
const kbdProcessor = new KeyboardProcessor(deviceSpec);
|
|
const predictiveContext = new PredictionContext(langProcessor, kbdProcessor);
|
|
|
|
let updateFake = sinon.fake();
|
|
predictiveContext.on('update', updateFake);
|
|
|
|
let mock = new Mock("appl", 4); // "appl|", with '|' as the caret position.
|
|
const initialMock = Mock.from(mock);
|
|
const promise = predictiveContext.setCurrentTarget(mock);
|
|
|
|
// Initial predictive state: no suggestions. context.initializeState() has not yet been called.
|
|
assert.equal(updateFake.callCount, 1);
|
|
assert.isEmpty(updateFake.firstCall.args[0]); // should have no suggestions. (if convenient for testing)
|
|
|
|
await promise;
|
|
let suggestions;
|
|
|
|
// Initialization results: our first set of dummy suggestions.
|
|
assert.equal(updateFake.callCount, 2);
|
|
suggestions = updateFake.secondCall.args[0];
|
|
assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['apple', 'apply', 'apples']);
|
|
assert.isNotOk(suggestions.find((obj) => obj.tag == 'keep'));
|
|
assert.isNotOk(suggestions.find((obj) => obj.transform.deleteLeft != 0));
|
|
|
|
mock.insertTextBeforeCaret('e'); // appl| + e = apple
|
|
let transcription = mock.buildTranscriptionFrom(initialMock, null, true);
|
|
await langProcessor.predict(transcription, kbdProcessor.layerId);
|
|
|
|
// First predict call results: our second set of dummy suggestions, the first of which includes
|
|
// a 'keep' of the original text.
|
|
assert.equal(updateFake.callCount, 3);
|
|
suggestions = updateFake.thirdCall.args[0];
|
|
assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['apple', 'apply', 'apps']);
|
|
assert.equal(suggestions.find((obj) => obj.tag == 'keep').displayAs, 'apple');
|
|
assert.equal(suggestions.find((obj) => obj.transform.deleteLeft != 0).displayAs, 'apps');
|
|
});
|
|
|
|
it('sendUpdateState retrieves the most recent suggestion set', async function() {
|
|
const langProcessor = new LanguageProcessor(worker, new TranscriptionCache());
|
|
await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker.
|
|
|
|
const kbdProcessor = new KeyboardProcessor(deviceSpec);
|
|
const predictiveContext = new PredictionContext(langProcessor, kbdProcessor);
|
|
|
|
let mock = new Mock("appl", 4); // "appl|", with '|' as the caret position.
|
|
const initialSuggestions = await predictiveContext.setCurrentTarget(mock);
|
|
|
|
let updateFake = sinon.fake();
|
|
predictiveContext.on('update', updateFake);
|
|
predictiveContext.sendUpdateEvent(); // Allows external code to request a re-retrieval of current suggestion state.
|
|
|
|
// Now, let's initialize the predictive state - this will load the initial suggestions.
|
|
let suggestions;
|
|
|
|
// Initialization results: our first set of dummy suggestions.
|
|
assert.isTrue(updateFake.calledOnce);
|
|
suggestions = updateFake.firstCall.args[0];
|
|
assert.deepEqual(suggestions, initialSuggestions);
|
|
|
|
// The array instances may be different, but their contents should be the same instances and in the same order.
|
|
assert.sameOrderedMembers(suggestions, initialSuggestions);
|
|
});
|
|
|
|
it('suggestion application logic & triggered effects', async function () {
|
|
const langProcessor = new LanguageProcessor(worker, new TranscriptionCache());
|
|
await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker.
|
|
|
|
const kbdProcessor = new KeyboardProcessor(deviceSpec);
|
|
const predictiveContext = new PredictionContext(langProcessor, kbdProcessor);
|
|
|
|
let textState = new Mock("appl", 4); // "appl|", with '|' as the caret position.
|
|
|
|
await predictiveContext.setCurrentTarget(textState);
|
|
|
|
let updateFake = sinon.fake();
|
|
predictiveContext.on('update', updateFake);
|
|
|
|
let suggestions;
|
|
|
|
let previousTextState = Mock.from(textState);
|
|
textState.insertTextBeforeCaret('e'); // appl| + e = apple
|
|
let transcription = textState.buildTranscriptionFrom(previousTextState, null, true);
|
|
await langProcessor.predict(transcription, kbdProcessor.layerId);
|
|
|
|
// Verify setup.
|
|
assert.equal(updateFake.callCount, 1);
|
|
suggestions = updateFake.firstCall.args[0];
|
|
assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['apple', 'apply', 'apps']);
|
|
assert.equal(suggestions.find((obj) => obj.tag == 'keep').displayAs, 'apple');
|
|
assert.equal(suggestions.find((obj) => obj.transform.deleteLeft != 0).displayAs, 'apps');
|
|
|
|
// Now for the real test.
|
|
previousTextState = Mock.from(textState); // snapshot it!
|
|
|
|
const suggestionApply = suggestions.find((obj) => obj.displayAs == 'apply');
|
|
assert.isOk(suggestionApply);
|
|
|
|
// For awaiting the suggestions generated upon applying our desired suggestion.
|
|
// We aren't given a direct Promise for that, but we can construct one this way.
|
|
let postApplySuggestions = new Promise((resolve) => {
|
|
predictiveContext.once('update', resolve);
|
|
});
|
|
|
|
// Apply the desired suggestion. Also passively generates new, post-acceptance
|
|
// suggestions, but this function itself don't provide a Promise for that...
|
|
// hence the previous block.
|
|
let promiseForApplyReversion = predictiveContext.accept(suggestionApply);
|
|
|
|
assert.equal(updateFake.callCount, 1); // No new 'update' has been raised yet.
|
|
|
|
// Check 1: did our active text context get changed? We DID just ask to apply
|
|
// a suggestion...
|
|
assert.notEqual(textState.getText(), previousTextState.getText());
|
|
assert.equal(textState.getText(), 'apply ');
|
|
|
|
let reversion = await promiseForApplyReversion;
|
|
await postApplySuggestions;
|
|
|
|
// Check 2: a second 'update' - post-application predictions!
|
|
assert.equal(updateFake.callCount, 2);
|
|
suggestions = updateFake.secondCall.args[0];
|
|
assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['applied']);
|
|
assert.isNotOk(suggestions.find((obj) => obj.tag == 'keep'));
|
|
assert.equal(suggestions[0].transform.deleteLeft, 2);
|
|
|
|
// Check 4+: for the returned reversion object.
|
|
assert.isOk(reversion);
|
|
|
|
// All other reversion details are tested in the 'reversion application logic...' section defined below.
|
|
});
|
|
|
|
it('reversion application logic & triggered effects', async function () {
|
|
const langProcessor = new LanguageProcessor(worker, new TranscriptionCache());
|
|
await langProcessor.loadModel(appleDummyModel); // await: must fully 'configure', load script into worker.
|
|
|
|
const kbdProcessor = new KeyboardProcessor(deviceSpec);
|
|
const predictiveContext = new PredictionContext(langProcessor, kbdProcessor);
|
|
|
|
let textState = new Mock("appl", 4); // "appl|", with '|' as the caret position.
|
|
|
|
// Test setup - return to the state at the end of the prior-defined unit test ('suggestion application...')
|
|
|
|
await predictiveContext.setCurrentTarget(textState);
|
|
|
|
// This is the point in time that a reversion operation will rewind the context to.
|
|
const revertBaseTextState = Mock.from(textState);
|
|
textState.insertTextBeforeCaret('e'); // appl| + e = apple
|
|
let transcription = textState.buildTranscriptionFrom(revertBaseTextState, null, true);
|
|
|
|
let suggestionCaptureFake = sinon.fake();
|
|
predictiveContext.once('update', suggestionCaptureFake);
|
|
await langProcessor.predict(transcription, kbdProcessor.layerId);
|
|
|
|
// We need to capture the suggestion we wish to apply. We could hardcode a forced
|
|
// value, but that might become brittle in the long-term.
|
|
const originalSuggestionSet = suggestionCaptureFake.firstCall.args[0];
|
|
const suggestionApply = originalSuggestionSet.find((obj) => obj.displayAs == 'apply');
|
|
assert.isOk(suggestionApply);
|
|
|
|
let previousTextState = Mock.from(textState);
|
|
|
|
// For awaiting the suggestions generated upon applying our desired suggestion.
|
|
// We aren't given a direct Promise for that, but we can construct one this way.
|
|
let postApplySuggestions = new Promise((resolve) => {
|
|
predictiveContext.once('update', resolve);
|
|
});
|
|
|
|
let reversion = await predictiveContext.accept(suggestionApply);
|
|
await postApplySuggestions;
|
|
|
|
// Test setup complete.
|
|
|
|
// Check the assertion object (from end of 'suggestion application' checks) - verify setup.
|
|
assert.isOk(reversion);
|
|
|
|
// Reversion IDs are inverses of the applied suggestion.
|
|
// This is the important link used to rewind the context when reverting suggestions.
|
|
assert.equal(reversion.id, -suggestionApply.id);
|
|
assert.equal(reversion.transformId, -suggestionApply.transformId);
|
|
|
|
// Revert display strings include quotes.
|
|
//
|
|
// We could check this more rigorously by importing from @keymanapp/models-wordbreakers for
|
|
// the default quotes.
|
|
assert.isTrue(reversion.displayAs.includes(previousTextState.getText()));
|
|
assert.equal(reversion.displayAs.length, previousTextState.getText().length + 2); // +2: opening + closing quotes.
|
|
|
|
// Fire away! Time to apply the reversion.
|
|
previousTextState = Mock.from(textState);
|
|
|
|
// Since the test uses a separate thread via Worker, make sure to set up any important event handlers
|
|
// before we request the reversion.
|
|
let updateFake = sinon.fake();
|
|
predictiveContext.on('update', updateFake);
|
|
|
|
// Now, in order to synchronize... we rely on a Promise. The callback is indeed
|
|
// called synchronously.
|
|
let postRevertSuggestions = new Promise((resolve) => {
|
|
predictiveContext.once('update', resolve);
|
|
});
|
|
|
|
// And now, apply the reversion itself.
|
|
let returnValue = await predictiveContext.accept(reversion);
|
|
|
|
// 'accepting' a reversion performs a rewind; there's no need for async ops here.
|
|
assert.isNull(returnValue); // as per the method's spec.
|
|
|
|
// Verify that the rewind + application of reversion worked!
|
|
let rewoundTextStateWithInput = Mock.from(revertBaseTextState); // appl
|
|
rewoundTextStateWithInput.apply(reversion.transform); // + e
|
|
assert.equal(rewoundTextStateWithInput.getText(), 'apple'); // For visual clarity.
|
|
|
|
// Note: no space appended.
|
|
assert.equal(textState.getText(), rewoundTextStateWithInput.getText());
|
|
|
|
// Re-synchronize once we've received word of the new post-reversion predictions.
|
|
await postRevertSuggestions;
|
|
|
|
assert.equal(updateFake.callCount, 1);
|
|
const suggestionsPostReversion = updateFake.firstCall.args[0];
|
|
assert.deepEqual(suggestionsPostReversion.map((obj) => obj.displayAs), ['reverted']);
|
|
});
|
|
}); |