mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-05 00:15:32 +00:00
Merge pull request #16273 from keymanapp/fix/web/empty-prediction-cases
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled
fix(web): improve prediction whitespace-input, backspace-input edge case handling 🚂
This commit is contained in:
commit
67b26cb643
11 changed files with 681 additions and 33 deletions
|
|
@ -673,9 +673,6 @@ export class ContextTokenization {
|
||||||
tailTokenization.splice(tokenIndex, 1, affectedToken);
|
tailTokenization.splice(tokenIndex, 1, affectedToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
affectedToken.isPartial = true;
|
|
||||||
delete affectedToken.appliedTransitionId;
|
|
||||||
|
|
||||||
// If we are completely replacing a token via delete left, erase the deleteLeft;
|
// If we are completely replacing a token via delete left, erase the deleteLeft;
|
||||||
// that part applied to a _previous_ token that no longer exists.
|
// that part applied to a _previous_ token that no longer exists.
|
||||||
// We start at index 0 in the insert string for the "new" token.
|
// We start at index 0 in the insert string for the "new" token.
|
||||||
|
|
@ -699,6 +696,11 @@ export class ContextTokenization {
|
||||||
affectedToken = new ContextToken(affectedToken);
|
affectedToken = new ContextToken(affectedToken);
|
||||||
affectedToken.addInput(inputSource, distribution);
|
affectedToken.addInput(inputSource, distribution);
|
||||||
|
|
||||||
|
// Do not adjust the original token, as it may be used by other transitions.
|
||||||
|
// Only adjust the new, extended token.
|
||||||
|
affectedToken.isPartial = true;
|
||||||
|
delete affectedToken.appliedTransitionId;
|
||||||
|
|
||||||
const tokenize = determineModelTokenizer(lexicalModel);
|
const tokenize = determineModelTokenizer(lexicalModel);
|
||||||
affectedToken.isWhitespace = tokenize({left: affectedToken.exampleInput, startOfBuffer: false, endOfBuffer: false}).left[0]?.isWhitespace ?? false;
|
affectedToken.isWhitespace = tokenize({left: affectedToken.exampleInput, startOfBuffer: false, endOfBuffer: false}).left[0]?.isWhitespace ?? false;
|
||||||
// Do not re-use the previous token; the mutation may have unexpected
|
// Do not re-use the previous token; the mutation may have unexpected
|
||||||
|
|
@ -708,8 +710,19 @@ export class ContextTokenization {
|
||||||
affectedToken = null;
|
affectedToken = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backspace handling - emptying context via backspace or erasing _part_ of
|
||||||
|
// a whitespace token can erase the tokenization-final empty token usually
|
||||||
|
// used for word-initial suggestions.
|
||||||
|
//
|
||||||
|
// We re-add it here so that suggestions can be presented to the user as
|
||||||
|
// normal.
|
||||||
|
const tokenSequence = this.tokens.slice(0, sliceIndex).concat(tailTokenization);
|
||||||
|
if(tokenSequence.length == 0 || tokenSequence[tokenSequence.length - 1]?.isWhitespace) {
|
||||||
|
tokenSequence.push(new ContextToken(new LegacyQuotientRoot(lexicalModel)));
|
||||||
|
}
|
||||||
|
|
||||||
return new ContextTokenization(
|
return new ContextTokenization(
|
||||||
this.tokens.slice(0, sliceIndex).concat(tailTokenization),
|
tokenSequence,
|
||||||
null,
|
null,
|
||||||
determineTaillessTrueKeystroke(transitionEdge)
|
determineTaillessTrueKeystroke(transitionEdge)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@ import Reversion = LexicalModelTypes.Reversion;
|
||||||
import Suggestion = LexicalModelTypes.Suggestion;
|
import Suggestion = LexicalModelTypes.Suggestion;
|
||||||
import Transform = LexicalModelTypes.Transform;
|
import Transform = LexicalModelTypes.Transform;
|
||||||
|
|
||||||
|
export interface TransitionReversionView extends Pick<ContextTransition, 'reversion'> {
|
||||||
|
/**
|
||||||
|
* Gets the context state resulting from the context transition event,
|
||||||
|
* including any generated suggestions and data regarding potential
|
||||||
|
* application thereof.
|
||||||
|
*/
|
||||||
|
final: Pick<ContextState, 'suggestions'>
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the transition between two context states as triggered
|
* Represents the transition between two context states as triggered
|
||||||
* by input keystrokes or applied suggestions.
|
* by input keystrokes or applied suggestions.
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,8 @@ export function legacySubsetKeyer(tokenizationEdits: TokenizationTransitionEdits
|
||||||
// Now, based on the transform tokenization. We want to force uniqueness for
|
// Now, based on the transform tokenization. We want to force uniqueness for
|
||||||
// all variations of result length on each tokenized transform resulting from
|
// all variations of result length on each tokenized transform resulting from
|
||||||
// the precomputation's represented keystroke.
|
// the precomputation's represented keystroke.
|
||||||
for(const {0: relativeIndex} of tokenizedTransform.entries()) {
|
for(const {0: relativeIndex, 1: transform} of tokenizedTransform.entries()) {
|
||||||
|
const insertLen = KMWString.length(transform.insert);
|
||||||
if(relativeIndex > 0) {
|
if(relativeIndex > 0) {
|
||||||
// The true boundary lie before the insert if the value is non-zero;
|
// The true boundary lie before the insert if the value is non-zero;
|
||||||
// don't differentiate here!
|
// don't differentiate here!
|
||||||
|
|
@ -148,10 +149,10 @@ export function legacySubsetKeyer(tokenizationEdits: TokenizationTransitionEdits
|
||||||
//
|
//
|
||||||
// IMPORTANT: update unit tests manually if the BI marker here changes
|
// IMPORTANT: update unit tests manually if the BI marker here changes
|
||||||
// or the use of SENTINEL_CODE_UNIT as a key component separator changes.
|
// or the use of SENTINEL_CODE_UNIT as a key component separator changes.
|
||||||
components.push(`BI@${relativeIndex}`);
|
components.push(`BI@${relativeIndex}-${boundaryTextLen + insertLen}`);
|
||||||
boundaryTextLen = 0;
|
boundaryTextLen = 0;
|
||||||
} else {
|
} else {
|
||||||
components.push(`I@${relativeIndex}`);
|
components.push(`I@${relativeIndex}-${boundaryTextLen + insertLen}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import * as models from '@keymanapp/models-templates';
|
||||||
import { LexicalModelTypes } from '@keymanapp/common-types';
|
import { LexicalModelTypes } from '@keymanapp/common-types';
|
||||||
|
|
||||||
import { TransformUtils } from './transformUtils.js';
|
import { TransformUtils } from './transformUtils.js';
|
||||||
import { applySuggestionCasing, correctAndEnumerate, createDefaultKeep, dedupeSuggestions, finalizeSuggestions, predictionAutoSelect, processSimilarity, toAnnotatedSuggestion, tupleDisplayOrderSort } from './predict-helpers.js';
|
import { applySuggestionCasing, correctAndEnumerate, createDefaultKeep, dedupeSuggestions, finalizeSuggestions, predictionAutoSelect, prependReversion, processSimilarity, toAnnotatedSuggestion, tupleDisplayOrderSort } from './predict-helpers.js';
|
||||||
import { detectCurrentCasing, determineModelTokenizer, determineModelWordbreaker, determinePunctuationFromModel } from './model-helpers.js';
|
import { detectCurrentCasing, determineModelTokenizer, determineModelWordbreaker, determinePunctuationFromModel } from './model-helpers.js';
|
||||||
|
|
||||||
import { ContextTracker } from './correction/context-tracker.js';
|
import { ContextTracker } from './correction/context-tracker.js';
|
||||||
|
|
@ -206,18 +206,8 @@ export class ModelCompositor {
|
||||||
this.SUGGESTION_ID_SEED++;
|
this.SUGGESTION_ID_SEED++;
|
||||||
});
|
});
|
||||||
|
|
||||||
if(revertableTransitionId) {
|
const transitionToRevert = this.contextTracker?.peek(revertableTransitionId);
|
||||||
const reversion = this.contextTracker.peek(revertableTransitionId)?.reversion;
|
prependReversion(suggestions, transitionToRevert);
|
||||||
if(reversion) {
|
|
||||||
if(suggestions[0]?.tag == 'keep') {
|
|
||||||
const keep = suggestions.shift();
|
|
||||||
suggestions.unshift(reversion);
|
|
||||||
suggestions.unshift(keep);
|
|
||||||
} else {
|
|
||||||
suggestions.unshift(reversion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the suggestions on the final token of the current context state (if it exists).
|
// Store the suggestions on the final token of the current context state (if it exists).
|
||||||
// Or, once phrase-level suggestions are possible, on whichever token serves as each prediction's root.
|
// Or, once phrase-level suggestions are possible, on whichever token serves as each prediction's root.
|
||||||
|
|
@ -239,8 +229,8 @@ export class ModelCompositor {
|
||||||
// Step 1: re-use the original input Transform as the reversion's Transform.
|
// Step 1: re-use the original input Transform as the reversion's Transform.
|
||||||
// The Web engine will restore the original state of the context before accepting
|
// The Web engine will restore the original state of the context before accepting
|
||||||
// and before reverting; all we need to do is put the original keystroke back in place.
|
// and before reverting; all we need to do is put the original keystroke back in place.
|
||||||
let reversionTransform: Transform = originalInput
|
let reversionTransform: Transform = originalInput
|
||||||
? { ...originalInput }
|
? { ...originalInput }
|
||||||
: { insert: '', deleteLeft: 0, id: suggestion.transform.id };
|
: { insert: '', deleteLeft: 0, id: suggestion.transform.id };
|
||||||
|
|
||||||
// Step 2: building the proper 'displayAs' string for the Reversion
|
// Step 2: building the proper 'displayAs' string for the Reversion
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,10 @@ import { ContextTokenization } from './correction/context-tokenization.js';
|
||||||
import { ContextTracker } from './correction/context-tracker.js';
|
import { ContextTracker } from './correction/context-tracker.js';
|
||||||
import { ContextToken } from './correction/context-token.js';
|
import { ContextToken } from './correction/context-token.js';
|
||||||
import { ContextState, determineContextSlideTransform } from './correction/context-state.js';
|
import { ContextState, determineContextSlideTransform } from './correction/context-state.js';
|
||||||
import { ContextTransition } from './correction/context-transition.js';
|
import { ContextTransition, TransitionReversionView } from './correction/context-transition.js';
|
||||||
import { ExecutionTimer } from './correction/execution-timer.js';
|
import { ExecutionTimer } from './correction/execution-timer.js';
|
||||||
import { ModelCompositor } from './model-compositor.js';
|
import { ModelCompositor } from './model-compositor.js';
|
||||||
import { getBestTokenMatches } from './correction/distance-modeler.js';
|
import { getBestTokenMatches } from './correction/distance-modeler.js';
|
||||||
import { TokenResultMapping } from './correction/token-result-mapping.js';
|
|
||||||
|
|
||||||
import CasingForm = LexicalModelTypes.CasingForm;
|
import CasingForm = LexicalModelTypes.CasingForm;
|
||||||
import Context = LexicalModelTypes.Context;
|
import Context = LexicalModelTypes.Context;
|
||||||
|
|
@ -459,7 +458,8 @@ export function determineSuggestionRange(
|
||||||
export function buildAndMapPredictions(
|
export function buildAndMapPredictions(
|
||||||
transition: ContextTransition,
|
transition: ContextTransition,
|
||||||
tokenization: ContextTokenization,
|
tokenization: ContextTokenization,
|
||||||
match: Readonly<TokenResultMapping>,
|
// Originally, Readonly<TokenResultMapping> - but we only need these two components here.
|
||||||
|
match: Readonly<{matchString: string, totalCost: number}>,
|
||||||
costFactor: number
|
costFactor: number
|
||||||
): CorrectionPredictionTuple[] {
|
): CorrectionPredictionTuple[] {
|
||||||
const model = transition.final.model;
|
const model = transition.final.model;
|
||||||
|
|
@ -493,6 +493,13 @@ export function buildAndMapPredictions(
|
||||||
// entry.baseTokenization = transition.final.tokenizationSourceMap.get(tokenization);
|
// entry.baseTokenization = transition.final.tokenizationSourceMap.get(tokenization);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Backspaces that shorten a multi-codepoint whitespace token are not handled well by default.
|
||||||
|
// As a new empty token is placed at the end for such cases, we can detect and handle such cases.
|
||||||
|
const inputTransform = transition.inputDistribution?.[0].sample ?? { insert: '', deleteLeft: 0 };
|
||||||
|
if(tokenization.tokens.length > 1 && tokenization.tail.searchModule.codepointLength == 0 && inputTransform.deleteLeft > 0) {
|
||||||
|
predictions.forEach((p) => p.prediction.sample.transform.deleteLeft += inputTransform.deleteLeft);
|
||||||
|
}
|
||||||
|
|
||||||
return predictions;
|
return predictions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -578,11 +585,6 @@ export async function correctAndEnumerate(
|
||||||
// Corrections obtained: now to predict from them!
|
// Corrections obtained: now to predict from them!
|
||||||
const tokenization = tokenizations.find(t => t.spaceId == match.spaceId);
|
const tokenization = tokenizations.find(t => t.spaceId == match.spaceId);
|
||||||
|
|
||||||
// If our 'match' results in fully deleting the new token, reject it and try again.
|
|
||||||
if(match.matchSequence.length == 0 && match.inputSequence.length != 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If our 'match' fully replaces the token, reject it and try again.
|
// If our 'match' fully replaces the token, reject it and try again.
|
||||||
if(match.matchSequence.length != 0 && match.matchSequence.length == match.knownCost) {
|
if(match.matchSequence.length != 0 && match.matchSequence.length == match.knownCost) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1077,6 +1079,9 @@ export function finalizeSuggestions(
|
||||||
if(presDL > 0) {
|
if(presDL > 0) {
|
||||||
mergedTransform.deleteLeft -= presDL;
|
mergedTransform.deleteLeft -= presDL;
|
||||||
}
|
}
|
||||||
|
if(prediction.sample.transform.id !== undefined) {
|
||||||
|
mergedTransform.id = prediction.sample.transform.id;
|
||||||
|
}
|
||||||
|
|
||||||
// Temporarily and locally drops 'readonly' semantics so that we can reassign the transform.
|
// Temporarily and locally drops 'readonly' semantics so that we can reassign the transform.
|
||||||
// See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#improved-control-over-mapped-type-modifiers
|
// See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#improved-control-over-mapped-type-modifiers
|
||||||
|
|
@ -1191,4 +1196,35 @@ export function toAnnotatedSuggestion(
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For applicable scenarios, this mutates the passed-in suggestion array by
|
||||||
|
* prepending a predictive-text reversion that restores the context to a prior
|
||||||
|
* state. Otherwise, it leaves the suggestion array unaltered.
|
||||||
|
* @param suggestions
|
||||||
|
* @param transitionToRevert
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function prependReversion(suggestions: Suggestion[], transitionToRevert: TransitionReversionView) {
|
||||||
|
if(transitionToRevert) {
|
||||||
|
const reversion = transitionToRevert.reversion;
|
||||||
|
if(reversion) {
|
||||||
|
if(suggestions[0]?.tag == 'keep') {
|
||||||
|
const appliedId = -reversion.id;
|
||||||
|
const appliedSuggestion = transitionToRevert.final.suggestions.find((s) => s.id == appliedId);
|
||||||
|
// If the selected suggestion was itself a `keep`, we don't need a
|
||||||
|
// reversion. They'd do the same thing.
|
||||||
|
if(appliedSuggestion.tag != 'keep') {
|
||||||
|
const keep = suggestions.shift();
|
||||||
|
suggestions.unshift(reversion);
|
||||||
|
suggestions.unshift(keep);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
suggestions.unshift(reversion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
}
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ export * from './correction/context-state.js';
|
||||||
export * from './correction/context-token.js';
|
export * from './correction/context-token.js';
|
||||||
export * from './correction/context-tokenization.js';
|
export * from './correction/context-tokenization.js';
|
||||||
export { ContextTracker } from './correction/context-tracker.js';
|
export { ContextTracker } from './correction/context-tracker.js';
|
||||||
export { ContextTransition } from './correction/context-transition.js';
|
export * from './correction/context-transition.js';
|
||||||
export * from './correction/correction-searchable.js';
|
export * from './correction/correction-searchable.js';
|
||||||
export * from './correction/correction-result-mapping.js';
|
export * from './correction/correction-result-mapping.js';
|
||||||
export * from './correction/distance-modeler.js';
|
export * from './correction/distance-modeler.js';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Transcription } from "keyman/engine/keyboard";
|
import { Transcription } from "keyman/engine/keyboard";
|
||||||
import { RewindableCache } from "keyman/common/web-utils";
|
import { RewindableCache } from "keyman/common/web-utils";
|
||||||
|
|
||||||
const TRANSCRIPTION_BUFFER_SIZE = 10;
|
const TRANSCRIPTION_BUFFER_SIZE = 20;
|
||||||
|
|
||||||
export class TranscriptionCache extends RewindableCache<Transcription> {
|
export class TranscriptionCache extends RewindableCache<Transcription> {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,43 @@ describe('ContextTokenization', function() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles simple case - deletion of final context content via backspace', () => {
|
||||||
|
const baseTokens = ['a'];
|
||||||
|
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
|
||||||
|
|
||||||
|
const targetTokens = [''].map((t) => ({text: t, isWhitespace: t == ' '}));
|
||||||
|
const inputTransform = { insert: '', deleteLeft: 1, deleteRight: 0, id: 42 };
|
||||||
|
const inputTransformMap: Map<number, Transform> = new Map();
|
||||||
|
inputTransformMap.set(0, { insert: '', deleteLeft: 1, id: 42 });
|
||||||
|
|
||||||
|
const edgeWindow = buildEdgeWindow(baseTokenization.tokens, inputTransform, false, testEdgeWindowSpec);
|
||||||
|
const tokenization = baseTokenization.evaluateTransition({
|
||||||
|
alignment: {
|
||||||
|
merges: [],
|
||||||
|
splits: [],
|
||||||
|
unmappedEdits: [],
|
||||||
|
edgeWindow: {
|
||||||
|
...edgeWindow,
|
||||||
|
// The range within the window constructed by the prior call for its parameterization.
|
||||||
|
// Any adjustments on the boundary token itself are included here.
|
||||||
|
retokenization: [...targetTokens.slice(edgeWindow.sliceIndex).map(t => t.text)]
|
||||||
|
},
|
||||||
|
removedTokenCount: 1
|
||||||
|
},
|
||||||
|
inputs: [{ sample: inputTransformMap, p: 1 }],
|
||||||
|
inputSubsetId: generateSubsetId()
|
||||||
|
},
|
||||||
|
inputTransform.id,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.isOk(tokenization);
|
||||||
|
assert.equal(tokenization.tokens.length, targetTokens.length);
|
||||||
|
assert.deepEqual(tokenization.tokens.map((t) => ({text: t.exampleInput, isWhitespace: t.isWhitespace})),
|
||||||
|
targetTokens
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('handles simple case - new character added to last token', () => {
|
it('handles simple case - new character added to last token', () => {
|
||||||
const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'da'];
|
const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'da'];
|
||||||
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
|
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
|
||||||
|
|
@ -823,6 +860,198 @@ describe('ContextTokenization', function() {
|
||||||
assert.equal(preTail.exampleInput, '\'');
|
assert.equal(preTail.exampleInput, '\'');
|
||||||
assert.equal(tail.exampleInput, '.');
|
assert.equal(tail.exampleInput, '.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('properly handles previously-applied transition IDs', () => {
|
||||||
|
it('does not preserve applied transition IDs on edited tokens', () => {
|
||||||
|
const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', 'can'];
|
||||||
|
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
|
||||||
|
|
||||||
|
const REVERTABLE_TRANSITION_ID = 31415;
|
||||||
|
baseTokenization.tail.appliedTransitionId = REVERTABLE_TRANSITION_ID;
|
||||||
|
const NEW_TRANSITION_ID = REVERTABLE_TRANSITION_ID + 1;
|
||||||
|
|
||||||
|
const dist = [
|
||||||
|
{
|
||||||
|
sample: { insert: 't', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID },
|
||||||
|
p: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const resultTokenization = baseTokenization.evaluateTransition(
|
||||||
|
{
|
||||||
|
alignment: {
|
||||||
|
merges: [],
|
||||||
|
splits: [],
|
||||||
|
unmappedEdits: [],
|
||||||
|
edgeWindow: {
|
||||||
|
...buildEdgeWindow(baseTokenization.tokens, dist[0].sample, false),
|
||||||
|
retokenization: baseTokenization.tokens.map((t) => t.exampleInput),
|
||||||
|
retokenizationText: baseTokenization.tokens.map((t) => t.exampleInput).reduce((accum, curr) => accum + curr, '')
|
||||||
|
},
|
||||||
|
removedTokenCount: 0
|
||||||
|
},
|
||||||
|
inputs: (() => {
|
||||||
|
const map: Map<number, Transform> = new Map();
|
||||||
|
map.set(0, dist[0].sample);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{sample: map, p: 1}
|
||||||
|
];
|
||||||
|
})(),
|
||||||
|
inputSubsetId: 0
|
||||||
|
},
|
||||||
|
NEW_TRANSITION_ID,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
|
||||||
|
resultTokenization.tokens.forEach((t) => {
|
||||||
|
assert.isUndefined(t.appliedTransitionId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves applied transition IDs on applicable tokens', () => {
|
||||||
|
const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', 'can'];
|
||||||
|
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
|
||||||
|
|
||||||
|
const REVERTABLE_TRANSITION_ID = 31415;
|
||||||
|
baseTokenization.tail.appliedTransitionId = REVERTABLE_TRANSITION_ID;
|
||||||
|
const NEW_TRANSITION_ID = REVERTABLE_TRANSITION_ID + 1;
|
||||||
|
|
||||||
|
|
||||||
|
const dist = [
|
||||||
|
{
|
||||||
|
sample: { insert: ' ', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID },
|
||||||
|
p: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const resultTokenization = baseTokenization.evaluateTransition(
|
||||||
|
{
|
||||||
|
alignment: {
|
||||||
|
merges: [],
|
||||||
|
splits: [],
|
||||||
|
unmappedEdits: [],
|
||||||
|
edgeWindow: {
|
||||||
|
...buildEdgeWindow(baseTokenization.tokens, dist[0].sample, false),
|
||||||
|
retokenization: baseTokenization.tokens.map((t) => t.exampleInput),
|
||||||
|
retokenizationText: baseTokenization.tokens.map((t) => t.exampleInput).reduce((accum, curr) => accum + curr, '')
|
||||||
|
},
|
||||||
|
removedTokenCount: 0
|
||||||
|
},
|
||||||
|
inputs: (() => {
|
||||||
|
const map: Map<number, Transform> = new Map();
|
||||||
|
map.set(1, dist[0].sample);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{sample: map, p: 1}
|
||||||
|
];
|
||||||
|
})(),
|
||||||
|
inputSubsetId: 0
|
||||||
|
},
|
||||||
|
NEW_TRANSITION_ID,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
|
||||||
|
const resultTokenLength = resultTokenization.tokens.length;
|
||||||
|
|
||||||
|
resultTokenization.tokens.forEach((t, i) => {
|
||||||
|
// The space will add TWO tokens.
|
||||||
|
if(i == resultTokenLength - 3) {
|
||||||
|
assert.equal(t.appliedTransitionId, REVERTABLE_TRANSITION_ID);
|
||||||
|
} else {
|
||||||
|
assert.isUndefined(t.appliedTransitionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Performs the two above in sequence in a manner that could cause cross-effects
|
||||||
|
// if implemented incorrectly.
|
||||||
|
it('does not conflate effects between different tokenization transitions', () => {
|
||||||
|
const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', 'can'];
|
||||||
|
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
|
||||||
|
|
||||||
|
const REVERTABLE_TRANSITION_ID = 31415;
|
||||||
|
baseTokenization.tail.appliedTransitionId = REVERTABLE_TRANSITION_ID;
|
||||||
|
const NEW_TRANSITION_ID = REVERTABLE_TRANSITION_ID + 1;
|
||||||
|
|
||||||
|
const dist = [
|
||||||
|
{
|
||||||
|
sample: { insert: ' ', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID },
|
||||||
|
p: .8
|
||||||
|
}, {
|
||||||
|
sample: { insert: ' ', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID },
|
||||||
|
p: .2
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const baseTransitionEdge: TransitionEdge = {
|
||||||
|
alignment: {
|
||||||
|
merges: [],
|
||||||
|
splits: [],
|
||||||
|
unmappedEdits: [],
|
||||||
|
edgeWindow: {
|
||||||
|
...buildEdgeWindow(baseTokenization.tokens, dist[0].sample, false),
|
||||||
|
retokenization: baseTokenization.tokens.map((t) => t.exampleInput),
|
||||||
|
retokenizationText: baseTokenization.tokens.map((t) => t.exampleInput).reduce((accum, curr) => accum + curr, '')
|
||||||
|
},
|
||||||
|
removedTokenCount: 0
|
||||||
|
},
|
||||||
|
inputs: (() => {
|
||||||
|
const map: Map<number, Transform> = new Map();
|
||||||
|
map.set(1, dist[0].sample);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{sample: map, p: dist[0].p}
|
||||||
|
];
|
||||||
|
})(),
|
||||||
|
inputSubsetId: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// We don't care about the results here. What we care about is that
|
||||||
|
// this call doesn't remove the appliedTransitionId from the source
|
||||||
|
// token, preventing it from being marked on later tokenization
|
||||||
|
// transitions.
|
||||||
|
baseTokenization.evaluateTransition(
|
||||||
|
{
|
||||||
|
alignment: {
|
||||||
|
...baseTransitionEdge.alignment,
|
||||||
|
edgeWindow: {
|
||||||
|
...baseTransitionEdge.alignment.edgeWindow,
|
||||||
|
...buildEdgeWindow(baseTokenization.tokens, dist[1].sample, false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inputs: (() => {
|
||||||
|
const map: Map<number, Transform> = new Map();
|
||||||
|
map.set(0, dist[1].sample);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{sample: map, p: dist[1].p}
|
||||||
|
];
|
||||||
|
})(),
|
||||||
|
inputSubsetId: 0
|
||||||
|
},
|
||||||
|
NEW_TRANSITION_ID,
|
||||||
|
dist[1].p
|
||||||
|
)
|
||||||
|
|
||||||
|
const resultTokenization = baseTokenization.evaluateTransition(
|
||||||
|
baseTransitionEdge,
|
||||||
|
NEW_TRANSITION_ID,
|
||||||
|
dist[0].p
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultTokenLength = resultTokenization.tokens.length;
|
||||||
|
|
||||||
|
resultTokenization.tokens.forEach((t, i) => {
|
||||||
|
// The space will add TWO tokens.
|
||||||
|
if(i == resultTokenLength - 3) {
|
||||||
|
assert.equal(t.appliedTransitionId, REVERTABLE_TRANSITION_ID);
|
||||||
|
} else {
|
||||||
|
assert.isUndefined(t.appliedTransitionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildEdgeWindow', () => {
|
describe('buildEdgeWindow', () => {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
ContextToken,
|
ContextToken,
|
||||||
ContextTokenization,
|
ContextTokenization,
|
||||||
generateSubsetId,
|
generateSubsetId,
|
||||||
|
legacySubsetKeyer,
|
||||||
models,
|
models,
|
||||||
precomputationSubsetKeyer,
|
precomputationSubsetKeyer,
|
||||||
TokenizationTransitionEdits,
|
TokenizationTransitionEdits,
|
||||||
|
|
@ -31,7 +32,7 @@ import Distribution = LexicalModelTypes.Distribution;
|
||||||
import Transform = LexicalModelTypes.Transform;
|
import Transform = LexicalModelTypes.Transform;
|
||||||
import TrieModel = models.TrieModel;
|
import TrieModel = models.TrieModel;
|
||||||
|
|
||||||
var plainModel = new TrieModel(jsonFixture('models/tries/english-1000'),
|
const plainModel = new TrieModel(jsonFixture('models/tries/english-1000'),
|
||||||
{wordBreaker: defaultBreaker});
|
{wordBreaker: defaultBreaker});
|
||||||
|
|
||||||
function toToken(text: string) {
|
function toToken(text: string) {
|
||||||
|
|
@ -41,6 +42,54 @@ function toToken(text: string) {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('legacySubsetKeyer', () => {
|
||||||
|
it('does not map backspace inputs to same result as standard key inputs', () => {
|
||||||
|
const appleToken = ContextToken.fromRawText(plainModel, 'apple', true);
|
||||||
|
|
||||||
|
const bksp = { insert: '', deleteLeft: 1, deleteRight: 0, id: 3 };
|
||||||
|
const bkspKey = legacySubsetKeyer({
|
||||||
|
alignment: {
|
||||||
|
merges: [],
|
||||||
|
splits: [],
|
||||||
|
unmappedEdits: [],
|
||||||
|
edgeWindow: {
|
||||||
|
...buildEdgeWindow([appleToken], bksp, false),
|
||||||
|
retokenization: ['appl'],
|
||||||
|
retokenizationText: 'appl'
|
||||||
|
},
|
||||||
|
removedTokenCount: 0
|
||||||
|
},
|
||||||
|
tokenizedTransform: (() => {
|
||||||
|
const map: Map<number, Transform> = new Map();
|
||||||
|
map.set(0, bksp);
|
||||||
|
return map;
|
||||||
|
})()
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = { insert: 's', deleteLeft: 0, deleteRight: 0, id: bksp.id };
|
||||||
|
const inputKey = legacySubsetKeyer({
|
||||||
|
alignment: {
|
||||||
|
merges: [],
|
||||||
|
splits: [],
|
||||||
|
unmappedEdits: [],
|
||||||
|
edgeWindow: {
|
||||||
|
...buildEdgeWindow([appleToken], input, false),
|
||||||
|
retokenization: ['apples'],
|
||||||
|
retokenizationText: 'apples'
|
||||||
|
},
|
||||||
|
removedTokenCount: 0
|
||||||
|
},
|
||||||
|
tokenizedTransform: (() => {
|
||||||
|
const map: Map<number, Transform> = new Map();
|
||||||
|
map.set(0, input);
|
||||||
|
return map;
|
||||||
|
})()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.notEqual(bkspKey, inputKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('precomputationSubsetKeyer', function() {
|
describe('precomputationSubsetKeyer', function() {
|
||||||
it("safely generates keys for empty transition + empty contexts", () => {
|
it("safely generates keys for empty transition + empty contexts", () => {
|
||||||
const rawTextTokens = [''];
|
const rawTextTokens = [''];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Keyman is copyright (C) SIL Global. MIT License.
|
||||||
|
*
|
||||||
|
* Created by jahorton on 2026-07-23
|
||||||
|
*
|
||||||
|
* This file contains tests designed to validate the behavior of the
|
||||||
|
* buildAndMapPredictions helper function class and its integration with the
|
||||||
|
* lower-level predictive-text helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { default as defaultBreaker } from '@keymanapp/models-wordbreakers';
|
||||||
|
import { LexicalModelTypes } from '@keymanapp/common-types';
|
||||||
|
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';
|
||||||
|
import { TrieModel } from '@keymanapp/models-templates';
|
||||||
|
|
||||||
|
import { buildAndMapPredictions, buildEdgeWindow, ContextState, ContextToken, ContextTokenization, ContextTransition, generateSubsetId, LegacyQuotientRoot, LegacyQuotientSpur, models, predictFromCorrections } from "@keymanapp/lm-worker/test-index";
|
||||||
|
|
||||||
|
import Context = LexicalModelTypes.Context;
|
||||||
|
import Distribution = LexicalModelTypes.Distribution;
|
||||||
|
import ProbabilityMass = LexicalModelTypes.ProbabilityMass;
|
||||||
|
import Transform = LexicalModelTypes.Transform;
|
||||||
|
|
||||||
|
const plainModel = new TrieModel(jsonFixture('models/tries/english-1000'),
|
||||||
|
{wordBreaker: defaultBreaker});
|
||||||
|
|
||||||
|
describe('buildAndMapPredictions', () => {
|
||||||
|
it('adds the preservation transform to all generated predictions', () => {
|
||||||
|
const context: Context = {
|
||||||
|
left: 'th',
|
||||||
|
right: '',
|
||||||
|
startOfBuffer: true,
|
||||||
|
endOfBuffer: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const correctionDistribution: Distribution<Transform> = [{
|
||||||
|
sample: {
|
||||||
|
insert: 'e',
|
||||||
|
deleteLeft: 0
|
||||||
|
},
|
||||||
|
p: 0.6
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const basePredictions = predictFromCorrections(plainModel, correctionDistribution, context);
|
||||||
|
basePredictions.forEach((entry) => assert.isNotOk(entry.preservationTransform));
|
||||||
|
|
||||||
|
// must construct the taillessTrueKeystroke appropriately.
|
||||||
|
const tailless = { insert: 'TEST', deleteLeft: 0 };
|
||||||
|
const tokenization = new ContextTokenization([ContextToken.fromRawText(plainModel, 'th', true)], null, tailless);
|
||||||
|
const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0);
|
||||||
|
|
||||||
|
const targetTokenization = new ContextTokenization([new ContextToken(new LegacyQuotientSpur(tokenization.tail.searchModule, correctionDistribution, correctionDistribution[0]))]);
|
||||||
|
transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution);
|
||||||
|
|
||||||
|
const mappedPredictions = buildAndMapPredictions(
|
||||||
|
transition,
|
||||||
|
transition.base.displayTokenization,
|
||||||
|
{matchString: 'the', totalCost: 0},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(mappedPredictions.map((tuple) => tuple.prediction), basePredictions.map((tuple) => tuple.prediction));
|
||||||
|
mappedPredictions.forEach((tuple) => assert.isOk(tuple.preservationTransform));
|
||||||
|
mappedPredictions.forEach((tuple) => tuple.preservationTransform == tailless);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('properly handles empty prediction roots from deleted same-token codepoints', () => {
|
||||||
|
const context: Context = {
|
||||||
|
left: 'the a',
|
||||||
|
right: '',
|
||||||
|
startOfBuffer: true,
|
||||||
|
endOfBuffer: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const correctionDistribution: Distribution<Transform> = [{
|
||||||
|
sample: {
|
||||||
|
insert: '',
|
||||||
|
deleteLeft: 1
|
||||||
|
},
|
||||||
|
p: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const basePredictions = predictFromCorrections(plainModel, correctionDistribution, context);
|
||||||
|
|
||||||
|
// must construct the taillessTrueKeystroke appropriately.
|
||||||
|
const tokenization = new ContextTokenization([
|
||||||
|
ContextToken.fromRawText(plainModel, 'the', false),
|
||||||
|
ContextToken.fromRawText(plainModel, ' ', false),
|
||||||
|
ContextToken.fromRawText(plainModel, 'a', true)
|
||||||
|
]);
|
||||||
|
const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0);
|
||||||
|
|
||||||
|
const targetTokenization = new ContextTokenization([
|
||||||
|
tokenization.tokens[0],
|
||||||
|
tokenization.tokens[1],
|
||||||
|
new ContextToken(new LegacyQuotientRoot(plainModel))
|
||||||
|
]);
|
||||||
|
transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution);
|
||||||
|
|
||||||
|
const mappedPredictions = buildAndMapPredictions(
|
||||||
|
transition,
|
||||||
|
transition.base.displayTokenization,
|
||||||
|
{matchString: '', totalCost: 0},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(mappedPredictions.map((tuple) => tuple.prediction), basePredictions.map((tuple) => tuple.prediction));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('properly handles empty prediction roots caused by backspacing one of multiple spaces', () => {
|
||||||
|
const context: Context = {
|
||||||
|
left: 'the ',
|
||||||
|
right: '',
|
||||||
|
startOfBuffer: true,
|
||||||
|
endOfBuffer: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const correctionDistribution: Distribution<Transform> = [{
|
||||||
|
sample: {
|
||||||
|
insert: '',
|
||||||
|
deleteLeft: 1
|
||||||
|
},
|
||||||
|
p: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const basePredictions = predictFromCorrections(plainModel, correctionDistribution, context);
|
||||||
|
|
||||||
|
// must construct the taillessTrueKeystroke appropriately.
|
||||||
|
const tokenization = new ContextTokenization([
|
||||||
|
ContextToken.fromRawText(plainModel, 'the', false),
|
||||||
|
ContextToken.fromRawText(plainModel, ' ', false),
|
||||||
|
ContextToken.fromRawText(plainModel, '', true)
|
||||||
|
]);
|
||||||
|
const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0);
|
||||||
|
|
||||||
|
const targetTokenization = new ContextTokenization([
|
||||||
|
tokenization.tokens[0],
|
||||||
|
new ContextToken(new LegacyQuotientSpur(tokenization.tokens[1].searchModule, correctionDistribution, correctionDistribution[0])),
|
||||||
|
new ContextToken(new LegacyQuotientRoot(plainModel))
|
||||||
|
]);
|
||||||
|
transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution);
|
||||||
|
|
||||||
|
const mappedPredictions = buildAndMapPredictions(
|
||||||
|
transition,
|
||||||
|
transition.base.displayTokenization,
|
||||||
|
{matchString: '', totalCost: 0},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(mappedPredictions.map((tuple) => tuple.prediction), basePredictions.map((tuple) => tuple.prediction));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('properly handles contexts made empty by input backspace', () => {
|
||||||
|
const context: Context = {
|
||||||
|
left: 't',
|
||||||
|
right: '',
|
||||||
|
startOfBuffer: true,
|
||||||
|
endOfBuffer: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const correctionDistribution = [{
|
||||||
|
sample: {
|
||||||
|
insert: '',
|
||||||
|
deleteLeft: 1,
|
||||||
|
deleteRight: 0
|
||||||
|
},
|
||||||
|
p: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// must construct the taillessTrueKeystroke appropriately.
|
||||||
|
const tokenization = new ContextTokenization([
|
||||||
|
ContextToken.fromRawText(plainModel, 't', true)
|
||||||
|
]);
|
||||||
|
const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0);
|
||||||
|
|
||||||
|
const targetTokenization = new ContextTokenization([
|
||||||
|
new ContextToken(new LegacyQuotientRoot(plainModel))
|
||||||
|
], {
|
||||||
|
alignment: {
|
||||||
|
merges: [],
|
||||||
|
splits: [],
|
||||||
|
unmappedEdits: [],
|
||||||
|
edgeWindow: {
|
||||||
|
...buildEdgeWindow(tokenization.tokens, correctionDistribution[0].sample, false),
|
||||||
|
retokenization: [''],
|
||||||
|
retokenizationText: ''
|
||||||
|
},
|
||||||
|
removedTokenCount: 0
|
||||||
|
},
|
||||||
|
inputs: (() => {
|
||||||
|
const val: ProbabilityMass<Map<number, Transform>>[] = [{
|
||||||
|
sample: new Map(),
|
||||||
|
p: correctionDistribution[0].p
|
||||||
|
}];
|
||||||
|
|
||||||
|
val[0].sample.set(0, correctionDistribution[0].sample);
|
||||||
|
|
||||||
|
return val;
|
||||||
|
})(),
|
||||||
|
inputSubsetId: generateSubsetId()
|
||||||
|
}, null);
|
||||||
|
transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution);
|
||||||
|
|
||||||
|
const mappedPredictions = buildAndMapPredictions(
|
||||||
|
transition,
|
||||||
|
transition.final.displayTokenization,
|
||||||
|
{matchString: '', totalCost: 0},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
mappedPredictions.forEach((tuple) => assert.equal(tuple.prediction.sample.transform.deleteLeft, 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Keyman is copyright (C) SIL Global. MIT License.
|
||||||
|
*
|
||||||
|
* Created by jahorton on 2026-07-27
|
||||||
|
*
|
||||||
|
* This file contains tests designed to ensure predictive text does not
|
||||||
|
* provide matching 'keep' and 'revert' suggestions in any context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { LexicalModelTypes } from '@keymanapp/common-types';
|
||||||
|
import { prependReversion, type TransitionReversionView } from "@keymanapp/lm-worker/test-index";
|
||||||
|
|
||||||
|
import Suggestion = LexicalModelTypes.Suggestion;
|
||||||
|
|
||||||
|
describe('prependReversion', () => {
|
||||||
|
it(`prepends reversions when reverting a non-'keep' suggestion`, () => {
|
||||||
|
// context: Original was appl+u, corrected to apply. Reached via bksp.
|
||||||
|
const suggestions: Suggestion[] = [{
|
||||||
|
tag: 'keep',
|
||||||
|
transform: { insert: 'apply', deleteLeft: 6, id: 3 },
|
||||||
|
displayAs: '"apply"',
|
||||||
|
id: 5,
|
||||||
|
matchesModel: false
|
||||||
|
} as Suggestion];
|
||||||
|
|
||||||
|
const revertable: TransitionReversionView = {
|
||||||
|
reversion: {
|
||||||
|
tag: 'revert',
|
||||||
|
transform: { insert: 'u', deleteLeft: 0, id: 3 },
|
||||||
|
id: -3,
|
||||||
|
displayAs: '"applu"'
|
||||||
|
},
|
||||||
|
final: {
|
||||||
|
suggestions: [{
|
||||||
|
tag: 'keep',
|
||||||
|
transform: { insert: 'applu', deleteLeft: 4, id: 3 },
|
||||||
|
displayAs: '"applu"',
|
||||||
|
id: 2,
|
||||||
|
matchesModel: false
|
||||||
|
} as Suggestion, {
|
||||||
|
transform: { insert: 'apply', deleteLeft: 4, id: 3 },
|
||||||
|
displayAs: 'apply',
|
||||||
|
id: 3
|
||||||
|
}, {
|
||||||
|
transform: { insert: 'applied', deleteLeft: 4, id: 3 },
|
||||||
|
displayAs: 'applied',
|
||||||
|
id: 4
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
prependReversion(suggestions, revertable);
|
||||||
|
|
||||||
|
assert.includeMembers(suggestions, [revertable.reversion]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`does not prepend reversions when reverting a 'keep' suggestion`, () => {
|
||||||
|
// context: Original was appl+u, corrected to apply
|
||||||
|
const suggestions: Suggestion[] = [{
|
||||||
|
tag: 'keep',
|
||||||
|
transform: { insert: 'applu', deleteLeft: 5, id: 3 },
|
||||||
|
displayAs: '"applu"',
|
||||||
|
id: 5,
|
||||||
|
matchesModel: false
|
||||||
|
} as Suggestion];
|
||||||
|
|
||||||
|
const revertable: TransitionReversionView = {
|
||||||
|
reversion: {
|
||||||
|
tag: 'revert',
|
||||||
|
transform: { insert: 'u', deleteLeft: 0, id: 3 },
|
||||||
|
id: -2,
|
||||||
|
displayAs: '"applu"'
|
||||||
|
},
|
||||||
|
final: {
|
||||||
|
suggestions: [{
|
||||||
|
tag: 'keep',
|
||||||
|
transform: { insert: 'applu', deleteLeft: 4, id: 3 },
|
||||||
|
displayAs: '"applu"',
|
||||||
|
id: 2,
|
||||||
|
matchesModel: false
|
||||||
|
} as Suggestion, {
|
||||||
|
transform: { insert: 'apply', deleteLeft: 4, id: 3 },
|
||||||
|
displayAs: 'apply',
|
||||||
|
id: 3
|
||||||
|
}, {
|
||||||
|
transform: { insert: 'applied', deleteLeft: 4, id: 3 },
|
||||||
|
displayAs: 'applied',
|
||||||
|
id: 4
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
prependReversion(suggestions, revertable);
|
||||||
|
|
||||||
|
assert.notIncludeMembers(suggestions, [revertable.reversion]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue