change(web): spin off method, add unit tests to verify handling of keep vs revert suggestions
Some checks are pending
Keyman Build Summary / Summarize build status checks (push) Waiting to run

This commit is contained in:
Joshua Horton 2026-07-27 12:11:50 -05:00
parent e225b26f10
commit f864bf293a
5 changed files with 148 additions and 17 deletions

View file

@ -19,6 +19,15 @@ import Reversion = LexicalModelTypes.Reversion;
import Suggestion = LexicalModelTypes.Suggestion;
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
* by input keystrokes or applied suggestions.

View file

@ -2,7 +2,7 @@ import * as models from '@keymanapp/models-templates';
import { LexicalModelTypes } from '@keymanapp/common-types';
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 { ContextTracker } from './correction/context-tracker.js';
@ -206,18 +206,8 @@ export class ModelCompositor {
this.SUGGESTION_ID_SEED++;
});
if(revertableTransitionId) {
const reversion = this.contextTracker.peek(revertableTransitionId)?.reversion;
if(reversion) {
if(suggestions[0]?.tag == 'keep') {
const keep = suggestions.shift();
suggestions.unshift(reversion);
suggestions.unshift(keep);
} else {
suggestions.unshift(reversion)
}
}
}
const transitionToRevert = this.contextTracker?.peek(revertableTransitionId);
prependReversion(suggestions, transitionToRevert);
// 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.
@ -239,8 +229,8 @@ export class ModelCompositor {
// 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
// and before reverting; all we need to do is put the original keystroke back in place.
let reversionTransform: Transform = originalInput
? { ...originalInput }
let reversionTransform: Transform = originalInput
? { ...originalInput }
: { insert: '', deleteLeft: 0, id: suggestion.transform.id };
// Step 2: building the proper 'displayAs' string for the Reversion

View file

@ -9,7 +9,7 @@ import { ContextTokenization } from './correction/context-tokenization.js';
import { ContextTracker } from './correction/context-tracker.js';
import { ContextToken } from './correction/context-token.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 { ModelCompositor } from './model-compositor.js';
import { getBestTokenMatches } from './correction/distance-modeler.js';
@ -1196,4 +1196,35 @@ export function toAnnotatedSuggestion(
}
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;
}

View file

@ -3,7 +3,7 @@ export * from './correction/context-state.js';
export * from './correction/context-token.js';
export * from './correction/context-tokenization.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-result-mapping.js';
export * from './correction/distance-modeler.js';

View file

@ -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]);
});
});