Skip to content

Commit bde82ad

Browse files
committed
feat(web): add unit-tests that verify result propagation mechanisms
1 parent a9fe2f6 commit bde82ad

3 files changed

Lines changed: 177 additions & 9 deletions

File tree

web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-spur.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -319,14 +319,25 @@ export abstract class SearchQuotientSpur extends SearchQuotientNode {
319319
return this.parentNode?.correctionsEnabled || this.inputs?.length > 1;
320320
}
321321

322-
public get currentCost(): number {
322+
// Exposed for use with mock-implementations used in unit-testing; is also
323+
// used by this class internally at run-time.
324+
/**
325+
* This method builds search results from edges represented by this
326+
* quotient-spur and its inputs that extend from any search results newly
327+
* processed by this spur's parent.
328+
*/
329+
protected processPendingRoots() {
323330
if(this.incomingNodes.length > 0) {
324331
this.queueNodes(this.buildEdgesFromResults(this.incomingNodes));
325332

326333
// Preserve the array instance, but trash all entries.
327334
// The array is registered with the parent; do not replace!
328335
this.incomingNodes.splice(0, this.incomingNodes.length);
329336
}
337+
}
338+
339+
public get currentCost(): number {
340+
this.processPendingRoots();
330341

331342
const parentCost = this.parentNode?.currentCost ?? Number.POSITIVE_INFINITY;
332343
const localCost = this.selectionQueue.peek()?.currentCost ?? Number.POSITIVE_INFINITY;
@@ -347,13 +358,7 @@ export abstract class SearchQuotientSpur extends SearchQuotientNode {
347358
* @returns
348359
*/
349360
public handleNextNode(): PathResult<TokenResultMapping> {
350-
if(this.incomingNodes.length > 0) {
351-
this.queueNodes(this.buildEdgesFromResults(this.incomingNodes));
352-
353-
// Preserve the array instance, but trash all entries.
354-
// The array is registered with the parent; do not replace!
355-
this.incomingNodes.splice(0, this.incomingNodes.length);
356-
}
361+
this.processPendingRoots();
357362

358363
const parentCost = this.parentNode?.currentCost ?? Number.POSITIVE_INFINITY;
359364
const localCost = this.selectionQueue.peek()?.currentCost ?? Number.POSITIVE_INFINITY;
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Keyman is copyright (C) SIL Global. MIT License.
3+
*
4+
* Created by jahorton on 2026-03-09
5+
*
6+
* This file adds unit tests for verifying core mechanics of the
7+
* SearchQuotientNode set of types.
8+
*/
9+
10+
import { assert } from 'chai';
11+
12+
import { LexicalModelTypes } from '@keymanapp/common-types';
13+
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';
14+
import { generateSpaceSeed, InputSegment, models, PathResult, SearchNode, SearchQuotientNode, SearchQuotientRoot, TokenResultMapping } from '@keymanapp/lm-worker/test-index';
15+
16+
import LexicalModel = LexicalModelTypes.LexicalModel;
17+
import TrieModel = models.TrieModel;
18+
19+
const testModel = new TrieModel(jsonFixture('models/tries/english-1000'));
20+
21+
export class MockQuotientNode extends SearchQuotientNode {
22+
/* This section is the part relevant for SearchQuotientNode-specific testing. */
23+
public readonly receivedResults: TokenResultMapping[] = [];
24+
private _spaceId: number;
25+
26+
constructor(parent: SearchQuotientNode) {
27+
super();
28+
if(parent) {
29+
this.linkAndQueueFromParent(parent, this.receivedResults);
30+
}
31+
}
32+
33+
mockResult(resultNode: SearchNode) {
34+
// Temporarily set ._spaceId so that TokenResultMapping construction may proceed.
35+
this._spaceId = resultNode.spaceId;
36+
this.saveResult(new TokenResultMapping(this, resultNode));
37+
this._spaceId = undefined;
38+
}
39+
40+
get spaceId(): number {
41+
return this._spaceId;
42+
}
43+
44+
/* End relevant section */
45+
46+
// The rest of this is simply just... implementing the abstract methods so
47+
// that the type signature is satisfied.
48+
get model(): LexicalModel {
49+
throw new Error('Method not implemented.');
50+
}
51+
get parents(): SearchQuotientNode[] {
52+
throw new Error('Method not implemented.');
53+
}
54+
handleNextNode(): PathResult<TokenResultMapping> {
55+
throw new Error('Method not implemented.');
56+
}
57+
increaseMaxEditDistance(): void {
58+
throw new Error('Method not implemented.');
59+
}
60+
get currentCost(): number {
61+
throw new Error('Method not implemented.');
62+
}
63+
lowestPossibleSingleCost: number;
64+
correctionsEnabled: boolean;
65+
inputCount: number;
66+
codepointLength: number;
67+
bestExample: { text: string; p: number; };
68+
inputSegments: InputSegment[];
69+
get sourceRangeKey(): string {
70+
throw new Error('Method not implemented.');
71+
}
72+
merge(space: SearchQuotientNode): SearchQuotientNode {
73+
throw new Error('Method not implemented.');
74+
}
75+
split(charIndex: number): [SearchQuotientNode, SearchQuotientNode][] {
76+
throw new Error('Method not implemented.');
77+
}
78+
isSameNode(node: SearchQuotientNode): boolean {
79+
throw new Error('Method not implemented.');
80+
}
81+
}
82+
83+
describe('SearchQuotientNode', () => {
84+
it('propagates node search results to linked descendants', () => {
85+
const root = new SearchQuotientRoot(testModel);
86+
87+
const baseNode = new MockQuotientNode(root);
88+
const descendants = [
89+
new MockQuotientNode(baseNode),
90+
new MockQuotientNode(baseNode),
91+
new MockQuotientNode(baseNode)
92+
];
93+
94+
const rootPath = new SearchNode(testModel.traverseFromRoot(), generateSpaceSeed());
95+
const path1 = rootPath.buildSubstitutionEdges(
96+
[{
97+
sample: {
98+
insert: 't',
99+
deleteLeft: 0,
100+
deleteRight: 0
101+
},
102+
p: 1.0
103+
}],
104+
13
105+
).flatMap((n) => n.processSubsetEdge())[0];
106+
const path2 = rootPath.buildSubstitutionEdges(
107+
[{
108+
sample: {
109+
insert: 'a',
110+
deleteLeft: 0,
111+
deleteRight: 0
112+
},
113+
p: 1.0
114+
}],
115+
13
116+
).flatMap((n) => n.processSubsetEdge())[0];
117+
baseNode.mockResult(path1);
118+
baseNode.mockResult(path2);
119+
120+
// Check that each descendant received the node.
121+
descendants.forEach((qn) => assert.sameMembers(qn.receivedResults.map((r) => r.spaceId), [path1.spaceId, path2.spaceId]));
122+
});
123+
});

web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ import {
1818
LegacyQuotientSpur,
1919
models,
2020
PathInputProperties,
21+
SearchNode,
2122
SearchQuotientNode,
2223
SearchQuotientRoot,
23-
SearchQuotientSpur
24+
SearchQuotientSpur,
25+
TokenResultMapping
2426
} from '@keymanapp/lm-worker/test-index';
2527

2628
import { constituentPaths } from '../../helpers/constituentPaths.js';
@@ -62,7 +64,45 @@ function toMathematicalSMP(text: string) {
6264
return asSMP.join('');
6365
}
6466

67+
class MockQuotientSpur extends SearchQuotientSpur {
68+
insertLength: number;
69+
leftDeleteLength: number;
70+
71+
readonly _receivedResults: TokenResultMapping[] = [];
72+
73+
public get receivedResults(): TokenResultMapping[] {
74+
// This triggers .buildEdgesForNodes() for any new results from the parent.
75+
//
76+
// .handleNextNode() and .currentCost also trigger this, but this mock
77+
// implementation bypasses both.
78+
this.processPendingRoots();
79+
return this._receivedResults;
80+
}
81+
82+
constructor(parentNode: SearchQuotientNode, inputs: Distribution<Transform>, inputSource: PathInputProperties) {
83+
super(parentNode, inputs, inputSource, 0);
84+
}
85+
86+
construct(parentNode: SearchQuotientNode, inputs: Distribution<Transform>, inputSource: PathInputProperties): this {
87+
return new MockQuotientSpur(parentNode, inputs, inputSource) as this;
88+
}
89+
90+
protected buildEdgesFromResults(baseResults: ReadonlyArray<TokenResultMapping>): SearchNode[] {
91+
baseResults.forEach((n) => this._receivedResults.push(n));
92+
return [];
93+
}
94+
}
95+
6596
describe('SearchQuotientSpur', () => {
97+
it('constructor links to parent node and receives queued results from it', () => {
98+
const baseNode = new SearchQuotientRoot(testModel);
99+
const descendantSpur = new MockQuotientSpur(baseNode, /* ? */ null, /* ? */ null);
100+
101+
assert.isEmpty(descendantSpur.receivedResults);
102+
baseNode.handleNextNode();
103+
assert.isNotEmpty(descendantSpur.receivedResults);
104+
});
105+
66106
describe('split()', () => {
67107
describe(`on token comprised of single-char transforms: [crt][ae][nr][t]`, () => {
68108
const runSplit = (splitIndex: number) => {

0 commit comments

Comments
 (0)