diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/alignment-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/alignment-helpers.ts index 5707434572..ed785ee28a 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/alignment-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/alignment-helpers.ts @@ -121,7 +121,7 @@ export function isSubstitutionAlignable( // Use max length in case the word is actually already partly out of // the sliding context window. Math.max(incomingToken.length, matchingToken.length) - ).editPath(); + ).editPath()[0].map(t => t.op); const firstInsert = subEditPath.indexOf('insert'); const firstDelete = subEditPath.indexOf('delete'); @@ -236,7 +236,7 @@ export function computeAlignment( 3 ); - let editPath = mapping.editPath(); + let editPath = mapping.editPath()[0].map(t => t.op); const failure: ContextStateAlignment = { canAlign: false, diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts index 0848d9590c..1a5d2cde18 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts @@ -1,7 +1,23 @@ import { SENTINEL_CODE_UNIT } from '@keymanapp/models-templates'; +/** + * The human-readable names for legal edit-operation edges on the edit path. + */ export type EditOperation = 'insert' | 'delete' | 'match' | 'substitute' | 'transpose-start' | 'transpose-end' | 'transpose-insert' | 'transpose-delete'; +/** + * Represents individual nodes on calculated edit paths and the relevant + * edited value(s) at each step. + */ +export interface EditTuple { + /** The edit operation taking place at this position in the edit path */ + op: EditOperation, + /** The value for the `input` source at this position in the edit path */ + input?: TUnit, + /** The value for the `match` source at this position in the edit path */ + match?: TUnit +} + /** * Represents the lowest-level unit for comparison during edit-distance calculations. */ @@ -11,8 +27,41 @@ export interface EditToken { // Implemented externally from the class so that it may be more easily // disconnected from the class; it's not the smallest method. -export function visualizeCalculation(calc: ClassicalDistanceCalculation) { - const path = calc.editPath(); +/** + * Produces a visualization of a ClassicalDistanceCalculation instance, + * displaying its calculated values, how they align to the input and match + * sources, and what steps were taken on the 'best' edit path to align the input + * and match sources. + * + * Example case: + * + * - Input sequence: ['quick', 'brown', 'fox', 'jumped', 'ove'] + * - Match sequence: ['uick', 'brown', 'fox', 'jumped', 'over', 't'] + * + * ``` + * j + * b u + * ur mo + * iofpv + * cwoee + * knxdrt + * + * 0|123 + * -------- + * quick 1|1234 substitute('quick' => 'uick') + * brown 2|21234 match('brown') + * fox 3|321234 match('fox') + * jumped |432123 match('jumped') + * ove | 43223 substitute('ove' => 'over'), insert('t') + * ``` + * + * @param calc The distance calculation object to visualize + * @param path If set, uses this path instead of the 'best' edit path in the + * visualization + * @returns + */ +export function visualizeCalculation(calc: ClassicalDistanceCalculation, path?: EditTuple[]) { + path = (path ?? calc.editPath()[0]).slice(); const inputs = calc.inputSequence.map(i => '' + i.key); const matches = calc.matchSequence.map(i => '' + i.key); @@ -39,11 +88,6 @@ export function visualizeCalculation(calc: ClassicalDistanceCalculation(calc: ClassicalDistanceCalculation 0 ? sparseCount : 1); let edits: string[] = []; - const printEdit = (edit: EditOperation) => { + const printEdit = (edit: EditTuple) => { let tokenText: string; - switch(edit) { + switch(edit.op) { case 'delete': case 'transpose-delete': - tokenText = `'${inputs[inputIndex]}'`; - inputIndex++; + tokenText = `'${edit.input}'`; break; case 'insert': case 'transpose-insert': - tokenText = `'${matches[matchIndex]}'`; - matchIndex++; + tokenText = `'${edit.match}'`; break; case 'substitute': - tokenText = `'${inputs[inputIndex]}' => '${matches[matchIndex]}'`; - // fallthrough case 'match': - tokenText = tokenText || `'${inputs[inputIndex]}'`; - inputIndex++; - matchIndex++; + const op = edit.op == 'substitute' ? '=>' : '=='; + tokenText = tokenText || `'${edit.input}' ${op} '${edit.match}'`; break; - // transpose-start, transpose-end: may not actually be correct, though. + // transpose-start, transpose-end default: - tokenText = `'${inputs[inputIndex]}'`; - inputIndex++; - matchIndex++; + tokenText = `'${edit.input}' vs '${edit.match}'`; } - return `${edit}(${tokenText})`; + return `${edit.op}(${tokenText})`; } do { edits.push(printEdit(path.shift())); @@ -116,20 +153,28 @@ export function visualizeCalculation(calc: ClassicalDistanceCalculation = EditToken, TMatch extends EditToken = EditToken> { /** * Stores ONLY the computed diagonal elements, nothing else. @@ -167,7 +212,14 @@ export class ClassicalDistanceCalculation); constructor(other?: ClassicalDistanceCalculation) { if(other) { @@ -275,75 +327,207 @@ export class ClassicalDistanceCalculation[][] { + const results = this._editPath(); - let insertParentCost = this.getCostAt(row, col-1); - let deleteParentCost = this.getCostAt(row-1, col); - let substitutionParentCost = this.getCostAt(row-1, col-1); - let [lastInputIndex, lastMatchIndex] = ClassicalDistanceCalculation.getTransposeParent(this, row, col); + if(results.length <= 1) { + return results; + } + + const properties = results.map((result) => { + let maxM = 0; // includes matches + let maxMS = 0; // includes above + substitutions + let maxMST = 0; // includes above + transposes + + let m = 0; // same as above, but running counter + let ms = 0; + let mst = 0; + + for(let edit of result) { + if(edit.op == 'match') { + m++; + ms++; + mst++; + continue; + } + + maxM = Math.max(maxM, m); + m = 0; + + if(edit.op == 'substitute') { + ms++; + mst++; + continue; + } + + maxMS = Math.max(maxMS, ms); + ms = 0; + + if(edit.op.indexOf('transpose') > -1) { + mst++; + continue; + } + + maxMST = Math.max(maxMST, mst); + mst = 0; + } + + maxM = Math.max(maxM, m); + maxMS = Math.max(maxMS, ms); + + return { + result, + maxM, + maxMS, + maxMST + } + }); + + properties.sort((a, b) => { + const tier0 = b.maxM - a.maxM; + if(tier0 != 0) { + return tier0; + } + const tier1 = b.maxMS - a.maxMS; + if(tier1 != 0) { + return tier1; + } + return b.maxMST - a.maxMST; + }); + + return properties.map((p) => p.result); + }; + + /** + * Determines the edit path used to obtain the optimal cost, distinguishing between zero-cost + * substitutions ('match' operations) and actual substitutions. + * @param row + * @param col + */ + private _editPath( + row: number = this.inputSequence.length - 1, + col: number = this.matchSequence.length - 1 + ): EditTuple[][] { + const currentCost = this.getCostAt(row, col); + const validPaths: EditTuple[][] = []; + + const tryPath = (row: number, col: number, ops: EditTuple[]) => { + // Recursively build the edit path. + let results: EditTuple[][]; + if(row >= 0 && col >= 0) { + results = this._editPath(row, col); + } else { + results = [[]]; + const result = results[0]; + for(let r = 0; r <= row; r++) { + // There are initial deletions. + result.push({ + op: 'delete', + input: this.inputSequence[r].key + }); + } + for(let c = 0; c <= col; c++) { + // There are initial insertions. + result.push({ + op: 'insert', + match: this.matchSequence[c].key + }); + } + } + + // If null, there must not be any valid results + if(results) { + // ... also, if the array's empty. + results.forEach(r => validPaths.push(r.concat(ops))); + } + } + + const [lastInputIndex, lastMatchIndex] = ClassicalDistanceCalculation.getTransposeParent(this, row, col); if(lastInputIndex >= 0 && lastMatchIndex >= 0) { // OK, a transposition source is quite possible. Still need to do more vetting, to be sure. let expectedCost = 1; // This transposition includes either 'transpose-insert' or 'transpose-delete' operations. - ops = ['transpose-start']; // always needs a 'start'. + let i = row; + let m = col; + let ops: EditTuple[] = []; + if(lastInputIndex != row-1) { - let count = row - lastInputIndex - 1; - ops = ops.concat( Array(count).fill('transpose-delete') ); - expectedCost += count; + let count = row - lastInputIndex; + ops.push({ + op: 'transpose-start', + input: this.inputSequence[i-count]?.key, + match: this.matchSequence[lastMatchIndex]?.key + }); + for(let x=count-1; x > 0; x--) { + ops.push({ + op: 'transpose-delete', + input: this.inputSequence[i-x]?.key + }); + } + expectedCost += count-1; } else { - let count = col - lastMatchIndex - 1; - ops = ops.concat( Array(count).fill('transpose-insert') ); - expectedCost += count; + let count = col - lastMatchIndex; + ops.push({ + op: 'transpose-start', + input: this.inputSequence[lastInputIndex]?.key, + match: this.matchSequence[m-count]?.key + }); + for(let y=count-1; y > 0; y--) { + ops.push({ + op: 'transpose-insert', + match: this.matchSequence[m-y]?.key + }); + } + expectedCost += count - 1; } - ops.push('transpose-end'); + + ops.push({ + op: 'transpose-end', + input: this.inputSequence[i]?.key, + match: this.matchSequence[m]?.key + }); // Double-check our expectations. - if(this.getCostAt(lastInputIndex-1, lastMatchIndex-1) != currentCost - expectedCost) { - ops = null; - } - parent = [lastInputIndex-1, lastMatchIndex-1]; - } - - if(ops) { - // bypass the ladder. - } else if(insertParentCost == currentCost - 1) { - // check 'insert' BEFORE 'substitute' - we categorize the path in reverse, - // and we want 'insert's to appear after 'substitute's at the path's tail, - // the part that matters most. - ops = ['insert']; - parent = [row, col-1]; - } else if(substitutionParentCost == currentCost - 1) { - ops = ['substitute']; - parent = [row-1, col-1]; - } else if(deleteParentCost == currentCost - 1) { - ops = ['delete']; - parent = [row-1, col]; - } else { //if(substitutionParentCost == currentCost) { - ops = ['match']; - parent = [row-1, col-1]; - } - - // Recursively build the edit path. - if(parent[0] >= 0 && parent[1] >= 0) { - return this.editPath(parent[0], parent[1]).concat(ops); - } else { - if(parent[0] > -1) { - // There are initial deletions. - return Array(parent[0]+1).fill('delete').concat(ops); - } else if(parent[1] > -1) { - // There are initial insertions. - return Array(parent[1]+1).fill('insert').concat(ops); - } else { - return ops; + if(this.getCostAt(lastInputIndex-1, lastMatchIndex-1) == currentCost - expectedCost) { + tryPath(lastInputIndex - 1, lastMatchIndex -1, ops); } } + + // Could rework to evaluate whether the selected path actually resolves... + // But there would likely be edge cases that still wouldn't be handled + // properly. + const input = this.inputSequence[row]?.key; + const match = this.matchSequence[col]?.key; + + const insertParentCost = this.getCostAt(row, col-1); + if(insertParentCost == currentCost - 1) { + tryPath(row, col-1, [{op: 'insert', match}]); + } + + const deleteParentCost = this.getCostAt(row-1, col); + if(deleteParentCost == currentCost - 1) { + tryPath(row-1, col, [{op: 'delete', input}]); + } + + const substitutionParentCost = this.getCostAt(row-1, col-1); + if(substitutionParentCost == currentCost - 1) { + tryPath(row-1, col-1, [{op: 'substitute', input, match}]); + // VERY IMPORTANT: validate the match. The path can go "off the rails" if + // we don't validate this! + } else if(substitutionParentCost == currentCost && input == match) { + tryPath(row-1, col-1, [{op: 'match', input, match}]); + } + + return validPaths; } private static getTransposeParent, TMatch extends EditToken>( @@ -588,11 +772,12 @@ export class ClassicalDistanceCalculation, TMatch extends EditToken>( - buffer: ClassicalDistanceCalculation, - r: number, - c: number, - value: number, - diagonalIndex: number) { + buffer: ClassicalDistanceCalculation, + r: number, + c: number, + value: number, + diagonalIndex: number + ) { // Note: this function does not actually need the `c` parameter! // That said, it's very useful when tracing stack traces & debugging. if(value < buffer.resolvedDistances[r][diagonalIndex]) { @@ -680,7 +865,18 @@ export class ClassicalDistanceCalculation[]) { + // return visualizeCalculation(this, path); // } } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts index f000bc8783..88dd0ccfe2 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts @@ -212,6 +212,9 @@ export class ContextState { ); if(!resultTokenization) { + if(console && console.error) { + console.error(`Transition to alignable tokenization failed: alignment properties ${JSON.stringify(alignmentResults)}`); + } return null; } diff --git a/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts b/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts index 3a4dd6737a..6ea989d4fc 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts @@ -1,10 +1,9 @@ -export { ClassicalDistanceCalculation } from './correction/classical-calculation.js'; +export { ClassicalDistanceCalculation, EditOperation, EditTuple } from './correction/classical-calculation.js'; export { ContextState } from './correction/context-state.js'; export { ContextToken } from './correction/context-token.js'; export { ContextTokenization } from './correction/context-tokenization.js'; export { ContextTracker } from './correction/context-tracker.js'; export { ContextTransition } from './correction/context-transition.js'; -export { EditOperation } from './correction/classical-calculation.js'; export * from './correction/alignment-helpers.js'; export * as correction from './correction/index.js'; export * from './model-helpers.js'; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/classical-calculation.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/classical-calculation.tests.ts index 998a8b2ec7..5c6881e291 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/classical-calculation.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/classical-calculation.tests.ts @@ -1,5 +1,5 @@ import { assert } from 'chai'; -import { ClassicalDistanceCalculation } from '@keymanapp/lm-worker/test-index'; +import { ClassicalDistanceCalculation, EditTuple } from '@keymanapp/lm-worker/test-index'; // Very useful for diagnosing unit test issues when path inspection is needed. // Not currently used ('cause that's noisy during test runs). @@ -459,86 +459,199 @@ describe('Classical Damerau-Levenshtein edit-distance calculation', function() { }); describe("edit path construction", function() { - let op = { - s: 'substitute', - m: 'match', - i: 'insert', - d: 'delete', - ts: 'transpose-start', - te: 'transpose-end', - ti: 'transpose-insert', - td: 'transpose-delete' - } - it("'accomodate' -> 'accommodate'", function() { let buffer = compute("accomodate", "accommodate", "InputThenMatch"); - let editSequence = [ - // a c c o m - op.m, op.m, op.m, op.m, op.m, - // +m o d a t e - op.i, op.m, op.m, op.m, op.m, op.m - ]; + // While the two 'm's are interchangable, the path will favor + // a result with the longest contiguous set of matches. + let editSequences: EditTuple[][] = [[ + { op: 'match', input: 'a', match: 'a'}, + { op: 'match', input: 'c', match: 'c'}, + { op: 'match', input: 'c', match: 'c'}, + { op: 'match', input: 'o', match: 'o'}, + { op: 'insert', match: 'm'}, // insert is earlier + { op: 'match', input: 'm', match: 'm'}, // longer contiguous match sequence + { op: 'match', input: 'o', match: 'o'}, + { op: 'match', input: 'd', match: 'd'}, + { op: 'match', input: 'a', match: 'a'}, + { op: 'match', input: 't', match: 't'}, + { op: 'match', input: 'e', match: 'e'}, + ], [ + { op: 'match', input: 'a', match: 'a'}, + { op: 'match', input: 'c', match: 'c'}, + { op: 'match', input: 'c', match: 'c'}, + { op: 'match', input: 'o', match: 'o'}, + { op: 'match', input: 'm', match: 'm'}, + { op: 'insert', match: 'm'}, // now inserting later. + { op: 'match', input: 'o', match: 'o'}, + { op: 'match', input: 'd', match: 'd'}, + { op: 'match', input: 'a', match: 'a'}, + { op: 'match', input: 't', match: 't'}, + { op: 'match', input: 'e', match: 'e'}, + ]]; - assert.deepEqual(buffer.editPath(), editSequence); + assert.sameDeepOrderedMembers(buffer.editPath(), editSequences); }); it("'harras' -> 'harass'", function() { let buffer = compute("harras", "harass", "InputThenMatch"); - let editSequence = [ - // h a r -r a s +s - op.m, op.m, op.m, op.d, op.m, op.m, op.i + let bestEditSequence: EditTuple[] = [ + { op: 'match', input: 'h', match: 'h'}, + { op: 'match', input: 'a', match: 'a'}, + { op: 'match', input: 'r', match: 'r'}, + { op: 'substitute', input: 'r', match: 'a'}, + { op: 'substitute', input: 'a', match: 's'}, + { op: 'match', input: 's', match: 's'} ]; - assert.deepEqual(buffer.editPath(), editSequence); + let altSequence: EditTuple[] = [ + { op: 'match', input: 'h', match: 'h'}, + { op: 'match', input: 'a', match: 'a'}, + { op: 'match', input: 'r', match: 'r'}, + { op: 'delete', input: 'r'}, + { op: 'match', input: 'a', match: 'a'}, + { op: 'match', input: 's', match: 's'}, + { op: 'insert', match: 's'} + ]; + + const viablePaths = buffer.editPath(); + // Note that the position of the 'delete' and the 'insert' in + // `altSequence` may be repositioned +1 way each, making 2x2 = 4 ways in + // total... then +1 for the substitute approach. + assert.equal(viablePaths.length, 5); + assert.deepEqual(viablePaths[0], bestEditSequence); + assert.includeDeepMembers(viablePaths, [altSequence]); }); it("'access' -> 'assess'", function() { let buffer = compute("access", "assess", "InputThenMatch"); - let editSequence = [ - // a c/s c/s e s s - op.m, op.s, op.s, op.m, op.m, op.m + let editSequence: EditTuple[] = [ + { op: 'match', input: 'a', match: 'a'}, + { op: 'substitute', input: 'c', match: 's'}, + { op: 'substitute', input: 'c', match: 's'}, + { op: 'match', input: 'e', match: 'e'}, + { op: 'match', input: 's', match: 's'}, + { op: 'match', input: 's', match: 's'} ]; - assert.deepEqual(buffer.editPath(), editSequence); + const viablePaths = buffer.editPath(); + assert.equal(viablePaths.length, 1); + assert.deepEqual(viablePaths[0], editSequence); }); it("'ifhs' -> 'fish'", function() { let buffer = compute("ifhs", "fish"); - let editSequence = [ - op.ts, op.te, op.ts, op.te + let editSequence: EditTuple[] = [ + { op: 'transpose-start', input: 'i', match: 'f'}, + { op: 'transpose-end', input: 'f', match: 'i'}, + { op: 'transpose-start', input: 'h', match: 's'}, + { op: 'transpose-end', input: 's', match: 'h'} ]; - assert.deepEqual(buffer.editPath(), editSequence); + const viablePaths = buffer.editPath(); + assert.equal(viablePaths.length, 1); + assert.deepEqual(viablePaths[0], editSequence); }); it("'then' -> 'their'", function() { let buffer = compute("then", "their"); - let editSequence = [ - op.m, op.m, op.m, op.s, op.i + let editSequence: EditTuple[] = [ + { op: 'match', input: 't', match: 't'}, + { op: 'match', input: 'h', match: 'h'}, // best sequence: continguous matches + { op: 'match', input: 'e', match: 'e'}, // + adjacent substitutes + { op: 'substitute', input: 'n', match: 'i'}, + { op: 'insert', match: 'r'}, ]; - assert.deepEqual(buffer.editPath(), editSequence); + let altSequence: EditTuple[] = [ + { op: 'match', input: 't', match: 't'}, + { op: 'match', input: 'h', match: 'h'}, + { op: 'match', input: 'e', match: 'e'}, + { op: 'insert', match: 'i'}, + { op: 'substitute', input: 'n', match: 'r'}, + ]; + + const viablePaths = buffer.editPath(); + assert.equal(viablePaths.length, 2); + assert.deepEqual(viablePaths[0], editSequence); + assert.deepEqual(viablePaths[1], altSequence); }); // Two transpositions: abc -> ca, ig <- ghi. Also, one deletion: 'd'. it("'abczdefig' -> 'cazefghi'", function() { let buffer = compute("abczdefig", "cazefghi", "InputThenMatch", 2); - let editSequence = [ - // --- abc -> ca ---- - // a/c b/_ c/a z -d e f - op.ts, op.td, op.te, op.m, op.d, op.m, op.m, - // --- ig -> ghi ---- - // i/g _/h g/i - op.ts, op.ti, op.te + let editSequence: EditTuple[] = [ + { op: 'transpose-start', input: 'a', match: 'c'}, + { op: 'transpose-delete', input: 'b'}, + { op: 'transpose-end', input: 'c', match: 'a'}, + { op: 'match', input: 'z', match: 'z'}, + { op: 'delete', input: 'd'}, + { op: 'match', input: 'e', match: 'e'}, + { op: 'match', input: 'f', match: 'f'}, + { op: 'transpose-start', input: 'i', match: 'g'}, + { op: 'transpose-insert', match: 'h'}, + { op: 'transpose-end', input: 'g', match: 'i'} ]; - assert.deepEqual(buffer.editPath(), editSequence); + const viablePaths = buffer.editPath(); + assert.equal(viablePaths.length, 1); + assert.deepEqual(viablePaths[0], editSequence); + }); + + it('works well for whitespaced-context analogues', () => { + // Corresponds closely to an actual sliding-context window scenario that + // once occurred. I did tweak the end ('r' was actually a 'p') for a bit + // more rigor. + const src = "a b c d?;e f g, h i j k l m n o p" + const dst = "q c d?;e f g, h i j k l m n o r" + const buffer = compute(src, dst, "InputThenMatch", 4); + + let editSequence: EditTuple[] = [ + { op: 'delete', input: 'a'}, + { op: 'delete', input: ' '}, + { op: 'substitute', input: 'b', match: 'q'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'c', match: 'c'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'd', match: 'd'}, + { op: 'match', input: '?', match: '?'}, + { op: 'match', input: ';', match: ';'}, + { op: 'match', input: 'e', match: 'e'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'f', match: 'f'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'g', match: 'g'}, + { op: 'match', input: ',', match: ','}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'h', match: 'h'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'i', match: 'i'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'j', match: 'j'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'k', match: 'k'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'l', match: 'l'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'm', match: 'm'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'n', match: 'n'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'match', input: 'o', match: 'o'}, + { op: 'match', input: ' ', match: ' '}, + { op: 'substitute', input: 'p', match: 'r'}, + ]; + + const viablePaths = buffer.editPath(); + // There are alternate variations for the first four tokens, but only one + // puts the substitute next to the long stretch of matches. + assert.equal(viablePaths.length, 4); + assert.deepEqual(viablePaths[0], editSequence); }); }) });