From e72e9af5d7e83a7dbcc24ccad379b5df0ffd2a32 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 10 Mar 2026 10:36:23 -0500 Subject: [PATCH 1/8] refactor(web): reorganize worker-thread test helpers and test fixtures Build-bot: skip build:web Test-bot: skip --- .../buildAlphabeticClusterFixture.tests.ts | 21 ++ .../buildAlphabeticClusteredFixture.ts | 200 ++++++++++++++++++ .../helpers/buildCantLinearFixture.tests.ts | 27 +++ .../helpers/buildCantLinearFixture.ts | 41 ++++ .../helpers/constituentPaths.tests.ts | 45 ++++ .../helpers/constituentPaths.ts | 43 ++++ .../helpers/quotientPathHasInputs.tests.ts | 95 +++++++++ .../helpers/quotientPathHasInputs.ts} | 33 +-- .../context/context-token.tests.ts | 2 +- .../search-quotient-cluster.tests.ts | 190 ++--------------- .../search-quotient-node.tests.ts | 10 +- .../search-quotient-spur.tests.ts | 81 ++----- 12 files changed, 509 insertions(+), 279 deletions(-) create mode 100644 web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusterFixture.tests.ts create mode 100644 web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts create mode 100644 web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts create mode 100644 web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts create mode 100644 web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts create mode 100644 web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts create mode 100644 web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.tests.ts rename web/src/test/auto/{resources/searchQuotientUtils.ts => headless/engine/predictive-text/helpers/quotientPathHasInputs.ts} (71%) diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusterFixture.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusterFixture.tests.ts new file mode 100644 index 00000000000..9a595df4416 --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusterFixture.tests.ts @@ -0,0 +1,21 @@ +import { assert } from "chai"; + +import { SearchQuotientNode } from "@keymanapp/lm-worker/test-index"; + +import { constituentPaths } from "./constituentPaths.js"; +import { quotientPathHasInputs } from "./quotientPathHasInputs.js"; +import { buildAlphabeticClusterFixtures } from "./buildAlphabeticClusteredFixture.js"; + +describe('buildAlphabeticClusteredFixture() fixture', () => { + it('constructs paths properly', () => { + const { clusters, paths, distributions } = buildAlphabeticClusterFixtures(); + assert.equal(clusters.cluster_k5c6.inputCount, 5); + + const allPaths = Object.values(paths).map(set => Object.values(set)).flat() as SearchQuotientNode[]; + const allDists = Object.values(distributions).map(set => Object.values(set)).flat(); + const finalClusterPaths = constituentPaths(clusters.cluster_k5c6) as SearchQuotientNode[][]; + + allPaths.forEach((spur) => assert.isTrue(finalClusterPaths.find(seq => seq.indexOf(spur) > -1))); + allDists.forEach((dist) => allPaths.find(path => quotientPathHasInputs(path, [dist]))); + }); +}); \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts new file mode 100644 index 00000000000..a6493368d7a --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts @@ -0,0 +1,200 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-03-09 + * + * This file defines a unit-text fixture designed for testing + * behaviors related to the clustering of search quotient spurs + * based on just substitution/match edges. + */ + +import { LexicalModelTypes } from '@keymanapp/common-types'; +import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; + +import { + models, + LegacyQuotientSpur, + SearchQuotientCluster, + LegacyQuotientRoot +} from '@keymanapp/lm-worker/test-index'; + +import Distribution = LexicalModelTypes.Distribution; +import Transform = LexicalModelTypes.Transform; +import TrieModel = models.TrieModel; + +const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); + +export const buildAlphabeticClusterFixtures = () => { + const rootPath = new LegacyQuotientRoot(testModel); + + // consonant-cluster 1, insert 1, delete 0 + const distrib_c1_i1d0: Distribution = [ + { sample: { insert: 'b', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.3 }, // most likely for id 11 + { sample: { insert: 'c', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.2 }, + { sample: { insert: 'd', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.1 }, + ]; + + // consonant-cluster 1, insert 2, delete 0 + const distrib_c1_i2d0: Distribution = [ + { sample: { insert: 'fg', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.16 }, + { sample: { insert: 'hj', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.14 }, + { sample: { insert: 'kl', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.1 }, + ]; + + // keystrokes 1, codepoints 1, total inserts 1, delete 0 + const path_k1c1_i1d0 = new LegacyQuotientSpur(rootPath, distrib_c1_i1d0, distrib_c1_i1d0[0]); + // keystrokes 1, codepoints 2, total inserts 2, delete 0 + const path_k1c2_i2d0 = new LegacyQuotientSpur(rootPath, distrib_c1_i2d0, distrib_c1_i1d0[0]); + + // Second input + + const distrib_v1_i1d0: Distribution = [ + { sample: { insert: 'e', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.4 }, // most likely for id 12 + { sample: { insert: 'a', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.3 }, + { sample: { insert: 'i', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 }, + { sample: { insert: 'o', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 }, + { sample: { insert: 'u', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 }, + ]; + + const path_k2c2_i2d0 = new LegacyQuotientSpur(path_k1c1_i1d0, distrib_v1_i1d0, distrib_v1_i1d0[0]); + const path_k2c3_i3d0 = new LegacyQuotientSpur(path_k1c2_i2d0, distrib_v1_i1d0, distrib_v1_i1d0[0]); + + // Third input + const distrib_v2_i1d0: Distribution = [ + { sample: { insert: 'e', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.15 }, // most likely for id 13 + { sample: { insert: 'a', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.13 }, + { sample: { insert: 'i', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.12 }, + { sample: { insert: 'o', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.11 }, + { sample: { insert: 'u', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.09 }, + ]; // 0.60 total + + const distrib_v2_i1d1: Distribution = [ + { sample: { insert: 'á', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 }, + { sample: { insert: 'é', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 }, + { sample: { insert: 'í', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.04 }, + { sample: { insert: 'ó', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.03 }, + { sample: { insert: 'ú', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.02 }, + ]; // 0.2 total + + const distrib_v2_i2d1: Distribution = [ + { sample: { insert: 'áá', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 }, + { sample: { insert: 'éé', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 }, + { sample: { insert: 'íí', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.04 }, + { sample: { insert: 'óó', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.03 }, + { sample: { insert: 'úú', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.02 }, + ]; // 0.2 total + + const path_k3c2_i3d1 = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i1d1, distrib_v2_i1d0[0]); + + const path_k3c3_i3d0 = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i1d0, distrib_v2_i1d0[0]); + const path_k3c3_i4d1a = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i2d1, distrib_v2_i1d0[0]); + const path_k3c3_i4d1b = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i1d1, distrib_v2_i1d0[0]); + + // both are built on path k1c2 (splits at index 1) + const path_k3c4_i4d0 = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i1d0, distrib_v2_i1d0[0]); + const path_k3c4_i5d1 = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i2d1, distrib_v2_i1d0[0]); + + const cluster_k3c3 = new SearchQuotientCluster([path_k3c3_i3d0, path_k3c3_i4d1a, path_k3c3_i4d1b]); + // both are built on path k1c2. + const cluster_k3c4 = new SearchQuotientCluster([path_k3c4_i4d0, path_k3c4_i5d1]); + + // Input 4 + const distrib_c2_i1d0: Distribution = [ + { sample: { insert: 'n', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.12 }, + { sample: { insert: 'p', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.08 }, + ]; + + const distrib_c2_i2d0: Distribution = [ + { sample: { insert: 'qr', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.3 }, // most likely for id 14 + { sample: { insert: 'st', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.2 }, + { sample: { insert: 'vw', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.1 } + ]; + + const path_k4c4_i2 = new LegacyQuotientSpur(path_k3c2_i3d1, distrib_c2_i2d0, distrib_c2_i2d0[0]); + const path_k4c4_i1 = new LegacyQuotientSpur(cluster_k3c3, distrib_c2_i1d0, distrib_c2_i2d0[0]); + + const path_k4c5_i2 = new LegacyQuotientSpur(cluster_k3c3, distrib_c2_i2d0, distrib_c2_i2d0[0]); + const path_k4c5_i1 = new LegacyQuotientSpur(cluster_k3c4, distrib_c2_i1d0, distrib_c2_i2d0[0]); + + const path_k4c6 = new LegacyQuotientSpur(cluster_k3c4, distrib_c2_i2d0, distrib_c2_i2d0[0]); + + const cluster_k4c4 = new SearchQuotientCluster([path_k4c4_i2, path_k4c4_i1]); + const cluster_k4c5 = new SearchQuotientCluster([path_k4c5_i2, path_k4c5_i1]); + + // Input 5 (currently used only for merge tests) + const distrib_c3_i2d0: Distribution = [ + { sample: { insert: 'xy', deleteLeft: 0, deleteRight: 0, id: 15 }, p: 0.6 } // most likely for id 15 + ]; + const distrib_c3_i1d0: Distribution = [ + { sample: { insert: 'z', deleteLeft: 0, deleteRight: 0, id: 15 }, p: 0.4 } + ]; + + const path_k5c6_a = new LegacyQuotientSpur(cluster_k4c4, distrib_c3_i2d0, distrib_c3_i2d0[0]); + const path_k5c6_b = new LegacyQuotientSpur(cluster_k4c5, distrib_c3_i1d0, distrib_c3_i2d0[0]); + + const cluster_k5c6 = new SearchQuotientCluster([path_k5c6_a, path_k5c6_b]); + + return { + distributions: { + 1: { + distrib_c1_i1d0, + distrib_c1_i2d0 + }, + 2: { + distrib_v1_i1d0 + }, + 3: { + distrib_v2_i1d0, + distrib_v2_i1d1, + distrib_v2_i2d1 + }, + 4: { + distrib_c2_i1d0, + distrib_c2_i2d0 + }, + 5: { + distrib_c3_i1d0, + distrib_c3_i2d0 + } + }, + paths: { + 0: { + rootPath + }, + 1: { + path_k1c1_i1d0, + path_k1c2_i2d0, + }, + 2: { + path_k2c2_i2d0, + path_k2c3_i3d0, + }, + 3: { + path_k3c2_i3d1, + path_k3c3_i3d0, + path_k3c3_i4d1a, + path_k3c3_i4d1b, + path_k3c4_i4d0, + path_k3c4_i5d1, + }, + 4: { + path_k4c4_i2, + path_k4c4_i1, + path_k4c5_i2, + path_k4c5_i1, + path_k4c6 + }, + 5: { + path_k5c6_a, + path_k5c6_b + } + }, + clusters: { + cluster_k3c3, + cluster_k3c4, + cluster_k4c4, + cluster_k4c5, + cluster_k5c6 + } + } +} \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts new file mode 100644 index 00000000000..cc95a8563e5 --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts @@ -0,0 +1,27 @@ +import { assert } from "chai"; + +import { constituentPaths } from "./constituentPaths.js"; +import { quotientPathHasInputs } from "./quotientPathHasInputs.js"; +import { buildCantLinearFixture } from "./buildCantLinearFixture.js"; + +describe('buildSimplePathSplitFixture() fixture', () => { + it('constructs paths properly', () => { + const { paths, distributions } = buildCantLinearFixture(); + const pathToSplit = paths[4]; + + assert.equal(pathToSplit.inputCount, 4); + assert.equal(distributions.length, pathToSplit.inputCount); + assert.equal(pathToSplit.codepointLength, 4); // one char per input, no deletions anywhere + // Per assertions documented in the setup above. + assert.deepEqual(pathToSplit.bestExample, distributions.reduce( + (constructing, current) => ({text: constructing.text + current[0].sample.insert, p: constructing.p * current[0].p}), + {text: '', p: 1}) + ); + assert.deepEqual(pathToSplit.parents[0].bestExample, distributions.slice(0, pathToSplit.inputCount-1).reduce( + (constructing, current) => ({text: constructing.text + current[0].sample.insert, p: constructing.p * current[0].p}), + {text: '', p: 1}) + ); + assert.isTrue(quotientPathHasInputs(pathToSplit, distributions)); + assert.equal(constituentPaths(pathToSplit).length, 1); + }); +}); \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts new file mode 100644 index 00000000000..c7c9d68bccc --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts @@ -0,0 +1,41 @@ +import { LegacyQuotientRoot, LegacyQuotientSpur, models } from "@keymanapp/lm-worker/test-index"; +import { jsonFixture } from "@keymanapp/common-test-resources/model-helpers.mjs"; + +import TrieModel = models.TrieModel; + +const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); + +// Needs legacy vs 'new' style implementation! + +export function buildCantLinearFixture() { + const rootPath = new LegacyQuotientRoot(testModel); + + const distrib1 = [ + { sample: {insert: 'c', deleteLeft: 0, id: 11}, p: 0.5 }, + { sample: {insert: 'r', deleteLeft: 0, id: 11}, p: 0.4 }, + { sample: {insert: 't', deleteLeft: 0, id: 11}, p: 0.1 } + ]; + const path1 = new LegacyQuotientSpur(rootPath, distrib1, distrib1[0]); + + const distrib2 = [ + { sample: {insert: 'a', deleteLeft: 0, id: 12}, p: 0.7 }, + { sample: {insert: 'e', deleteLeft: 0, id: 12}, p: 0.3 } + ]; + const path2 = new LegacyQuotientSpur(path1, distrib2, distrib2[0]); + + const distrib3 = [ + { sample: {insert: 'n', deleteLeft: 0, id: 13}, p: 0.8 }, + { sample: {insert: 'r', deleteLeft: 0, id: 13}, p: 0.2 } + ]; + const path3 = new LegacyQuotientSpur(path2, distrib3, distrib3[0]); + + const distrib4 = [ + { sample: {insert: 't', deleteLeft: 0, id: 14}, p: 1 } + ]; + const path4 = new LegacyQuotientSpur(path3, distrib4, distrib4[0]); + + return { + paths: [null, path1, path2, path3, path4], + distributions: [distrib1, distrib2, distrib3, distrib4] + }; +} \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts new file mode 100644 index 00000000000..8ea47e9029f --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts @@ -0,0 +1,45 @@ +/** + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-03-10 + * + * This file adds unit tests for unit-test helper functions validating + * the correction-search modules of the Keyman predictive-text engine. + */ + +import { assert } from 'chai'; + +import { constituentPaths } from "./constituentPaths.js"; +import { buildCantLinearFixture } from './buildCantLinearFixture.js'; +import { buildAlphabeticClusterFixtures } from './buildAlphabeticClusteredFixture.js'; + +describe('constituentPaths', () => { + it('includes a single entry array when all parents are SearchQuotientSpurs', () => { + const { paths } = buildCantLinearFixture(); + const finalPath = paths[4]; + + assert.equal(constituentPaths(finalPath).length, 1); + + const pathSequence = constituentPaths(finalPath)[0]; + assert.equal(pathSequence.length, 4); // 4 inputs; does not include root node + + assert.sameOrderedMembers(pathSequence, paths.slice(1)); + }); + + it('properly enumerates child paths when encountering SearchCluster ancestors', () => { + const fixture = buildAlphabeticClusterFixtures(); + const finalPath = fixture.paths[4].path_k4c6; + + // The longest SearchPath at the end of that fixture's set is based on a + // lead-in cluster; all variants of that should be included. + assert.equal(constituentPaths(finalPath).length, constituentPaths(fixture.clusters.cluster_k3c4).length); + + // That cluster holds the different potential penultimate paths; + // finalPath's inputs are added directly after any variation that may be + // output from the cluster. + assert.sameDeepMembers(constituentPaths(finalPath), constituentPaths(fixture.clusters.cluster_k3c4).map((p) => { + p.push(finalPath); + return p; + })); + }); +}); \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts new file mode 100644 index 00000000000..9c01297f0ce --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts @@ -0,0 +1,43 @@ +/** + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-03-10 + * + * This file adds helper functions useful for developing test assertions against + * different types of SearchQuotientNodes and their properties. + */ + +import { + SearchQuotientCluster, + SearchQuotientNode, + SearchQuotientRoot, + SearchQuotientSpur +} from "@keymanapp/lm-worker/test-index"; + +/** + * Enumerates the different potential SearchQuotientSpur sequences that lead + * to a SearchQuotientNode. + * + * Intended only for use during unit testing. Does not include the root node. + */ +export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[][] { + if(node instanceof SearchQuotientRoot) { + return []; + } else if(node instanceof SearchQuotientCluster) { + return node.parents.flatMap((p) => constituentPaths(p)); + } else if(node instanceof SearchQuotientSpur) { + const parentPaths = constituentPaths(node.parents[0]); + let pathsToExtend = parentPaths; + + if(parentPaths.length > 0) { + return pathsToExtend.map(p => { + p.push(node); + return p; + }); + } else { + return [[node]]; + } + } else { + throw new Error("constituentPaths is unable to handle a new, unexpected SearchQuotientNode type"); + } +} \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.tests.ts new file mode 100644 index 00000000000..952699ef16a --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.tests.ts @@ -0,0 +1,95 @@ +/** + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-03-10 + * + * This file adds unit tests for unit-test helper functions validating + * the correction-search modules of the Keyman predictive-text engine. + */ + +import { assert } from 'chai'; + +import { models, SearchQuotientRoot } from '@keymanapp/lm-worker/test-index'; + +import { quotientPathHasInputs } from "./quotientPathHasInputs.js"; +import { buildCantLinearFixture } from './buildCantLinearFixture.js'; +import { buildAlphabeticClusterFixtures } from './buildAlphabeticClusteredFixture.js'; +import { jsonFixture } from '../templates/helpers.js'; + +import TrieModel = models.TrieModel; + +const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); + +describe('quotientNodeHasParents()', () => { + it('matches an empty array on root SearchPaths', () => { + assert.isTrue(quotientPathHasInputs(new SearchQuotientRoot(testModel), [])); + }); + + it('matches all path inputs when provided in proper order', () => { + const { paths, distributions } = buildCantLinearFixture(); + assert.isTrue(quotientPathHasInputs(paths[4], distributions)); + }); + + it('does not match when any path input component is missing', () => { + const { paths, distributions } = buildCantLinearFixture(); + assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(1))); + assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(2))); + assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(3))); + assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(0, 3))); + assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(0, 1).concat(distributions.slice(2)))); + }); + + it('does not match when path inputs are not in proper order', () => { + const { paths, distributions } = buildCantLinearFixture(); + assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice().reverse())); + + // Random shuffle. + let shuffled: typeof distributions; + let isShuffled: boolean; + do { + shuffled = distributions.slice().sort(() => Math.random() * 2 - 1); + // Validate that we actually shuffled - that we didn't land on the original order! + isShuffled = false; + for(let i = 0; i < distributions.length; i++) { + if(distributions[i] != shuffled[i]) { + isShuffled = true; + break; + } + } + } while(!isShuffled); + assert.isFalse(quotientPathHasInputs(paths[4], shuffled)); + }); + + it('is able to match inputs against SearchQuotientCluster constituent input paths', () => { + const { distributions, clusters } = buildAlphabeticClusterFixtures(); + + const fourCharCluster = clusters.cluster_k4c4; + const fiveCharCluster = clusters.cluster_k4c5; + + assert.isTrue(quotientPathHasInputs(fourCharCluster, [ + distributions[1].distrib_c1_i1d0, + distributions[2].distrib_v1_i1d0, + distributions[3].distrib_v2_i1d0, + distributions[4].distrib_c2_i1d0 + ])); + assert.isFalse(quotientPathHasInputs(fiveCharCluster,[ + distributions[1].distrib_c1_i1d0, + distributions[2].distrib_v1_i1d0, + distributions[3].distrib_v2_i1d0, + distributions[4].distrib_c2_i1d0 + ])); + + assert.isFalse(quotientPathHasInputs(fourCharCluster, [ + distributions[1].distrib_c1_i1d0, + distributions[2].distrib_v1_i1d0, + distributions[3].distrib_v2_i1d0, + distributions[4].distrib_c2_i2d0 + ])); + assert.isTrue(quotientPathHasInputs(fiveCharCluster, [ + distributions[1].distrib_c1_i1d0, + distributions[2].distrib_v1_i1d0, + distributions[3].distrib_v2_i1d0, + distributions[4].distrib_c2_i2d0 + ])); + }); +}); \ No newline at end of file diff --git a/web/src/test/auto/resources/searchQuotientUtils.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.ts similarity index 71% rename from web/src/test/auto/resources/searchQuotientUtils.ts rename to web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.ts index 22f64a32031..778d06e4800 100644 --- a/web/src/test/auto/resources/searchQuotientUtils.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.ts @@ -1,7 +1,7 @@ /** * Keyman is copyright (C) SIL Global. MIT License. * - * Created by jahorton on 2026-02-05 + * Created by jahorton on 2026-03-10 * * This file adds helper functions useful for developing test assertions against * different types of SearchQuotientNodes and their properties. @@ -9,7 +9,10 @@ import { LexicalModelTypes } from "@keymanapp/common-types"; -import { SearchQuotientCluster, SearchQuotientNode, SearchQuotientRoot, SearchQuotientSpur } from "@keymanapp/lm-worker/test-index"; +import { + SearchQuotientNode, + SearchQuotientSpur +} from "@keymanapp/lm-worker/test-index"; import Distribution = LexicalModelTypes.Distribution; import Transform = LexicalModelTypes.Transform; @@ -82,30 +85,4 @@ export function quotientPathHasInputs(node: SearchQuotientNode, keystrokeDistrib return parentHasInput(); } -} - -/** - * Enumerates the different potential SearchQuotientSpur sequences that lead - * to a SearchQuotientNode. - * - * Intended only for use during unit testing. Does not include the root node. - */ -export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[][] { - if(node instanceof SearchQuotientRoot) { - return []; - } else if(node instanceof SearchQuotientCluster) { - return node.parents.flatMap((p) => constituentPaths(p)); - } else if(node instanceof SearchQuotientSpur) { - const parentPaths = constituentPaths(node.parents[0]); - if(parentPaths.length > 0) { - return parentPaths.map(p => { - p.push(node); - return p; - }); - } else { - return [[node]]; - } - } else { - throw new Error("constituentPaths is unable to handle a new, unexpected SearchQuotientNode type"); - } } \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts index d9ac75f0734..791c594957c 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts @@ -17,7 +17,7 @@ import { KMWString } from '@keymanapp/web-utils'; import { ContextToken, correction, generateSubsetId, getBestMatches, InputSegment, models, SearchQuotientSpur } from '@keymanapp/lm-worker/test-index'; -import { quotientPathHasInputs } from "#test-resources/searchQuotientUtils.js"; +import { quotientPathHasInputs } from "../../helpers/quotientPathHasInputs.js"; import Distribution = LexicalModelTypes.Distribution; import ExecutionTimer = correction.ExecutionTimer; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-cluster.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-cluster.tests.ts index eb4f5f8f81e..7b12d8a8577 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-cluster.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-cluster.tests.ts @@ -12,191 +12,27 @@ import { assert } from 'chai'; import { LexicalModelTypes } from '@keymanapp/common-types'; import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; import { KMWString } from '@keymanapp/web-utils'; -import { generateSubsetId, LegacyQuotientRoot, LegacyQuotientSpur, models, SearchQuotientCluster, SearchQuotientNode, SearchQuotientRoot, SearchQuotientSpur } from '@keymanapp/lm-worker/test-index'; +import { + generateSubsetId, + LegacyQuotientRoot, + LegacyQuotientSpur, + models, + SearchQuotientCluster, + SearchQuotientNode, + SearchQuotientRoot, + SearchQuotientSpur +} from '@keymanapp/lm-worker/test-index'; import Distribution = LexicalModelTypes.Distribution; import Transform = LexicalModelTypes.Transform; import TrieModel = models.TrieModel; -import { constituentPaths, quotientPathHasInputs } from '#test-resources/searchQuotientUtils.js'; +import { constituentPaths } from '../../helpers/constituentPaths.js'; +import { quotientPathHasInputs } from '../../helpers/quotientPathHasInputs.js'; +import { buildAlphabeticClusterFixtures } from '../../helpers/buildAlphabeticClusteredFixture.js'; const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); -export const buildAlphabeticClusterFixtures = () => { - const rootPath = new LegacyQuotientRoot(testModel); - - // consonant-cluster 1, insert 1, delete 0 - const distrib_c1_i1d0: Distribution = [ - { sample: { insert: 'b', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.3 }, // most likely for id 11 - { sample: { insert: 'c', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.2 }, - { sample: { insert: 'd', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.1 }, - ]; - - // consonant-cluster 1, insert 2, delete 0 - const distrib_c1_i2d0: Distribution = [ - { sample: { insert: 'fg', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.16 }, - { sample: { insert: 'hj', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.14 }, - { sample: { insert: 'kl', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.1 }, - ]; - - // keystrokes 1, codepoints 1, total inserts 1, delete 0 - const path_k1c1_i1d0 = new LegacyQuotientSpur(rootPath, distrib_c1_i1d0, distrib_c1_i1d0[0]); - // keystrokes 1, codepoints 2, total inserts 2, delete 0 - const path_k1c2_i2d0 = new LegacyQuotientSpur(rootPath, distrib_c1_i2d0, distrib_c1_i1d0[0]); - - // Second input - - const distrib_v1_i1d0: Distribution = [ - { sample: { insert: 'e', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.4 }, // most likely for id 12 - { sample: { insert: 'a', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.3 }, - { sample: { insert: 'i', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 }, - { sample: { insert: 'o', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 }, - { sample: { insert: 'u', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 }, - ]; - - const path_k2c2_i2d0 = new LegacyQuotientSpur(path_k1c1_i1d0, distrib_v1_i1d0, distrib_v1_i1d0[0]); - const path_k2c3_i3d0 = new LegacyQuotientSpur(path_k1c2_i2d0, distrib_v1_i1d0, distrib_v1_i1d0[0]); - - // Third input - const distrib_v2_i1d0: Distribution = [ - { sample: { insert: 'e', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.15 }, // most likely for id 13 - { sample: { insert: 'a', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.13 }, - { sample: { insert: 'i', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.12 }, - { sample: { insert: 'o', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.11 }, - { sample: { insert: 'u', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.09 }, - ]; // 0.60 total - - const distrib_v2_i1d1: Distribution = [ - { sample: { insert: 'á', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 }, - { sample: { insert: 'é', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 }, - { sample: { insert: 'í', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.04 }, - { sample: { insert: 'ó', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.03 }, - { sample: { insert: 'ú', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.02 }, - ]; // 0.2 total - - const distrib_v2_i2d1: Distribution = [ - { sample: { insert: 'áá', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 }, - { sample: { insert: 'éé', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 }, - { sample: { insert: 'íí', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.04 }, - { sample: { insert: 'óó', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.03 }, - { sample: { insert: 'úú', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.02 }, - ]; // 0.2 total - - const path_k3c2_i3d1 = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i1d1, distrib_v2_i1d0[0]); - - const path_k3c3_i3d0 = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i1d0, distrib_v2_i1d0[0]); - const path_k3c3_i4d1a = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i2d1, distrib_v2_i1d0[0]); - const path_k3c3_i4d1b = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i1d1, distrib_v2_i1d0[0]); - - // both are built on path k1c2 (splits at index 1) - const path_k3c4_i4d0 = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i1d0, distrib_v2_i1d0[0]); - const path_k3c4_i5d1 = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i2d1, distrib_v2_i1d0[0]); - - const cluster_k3c3 = new SearchQuotientCluster([path_k3c3_i3d0, path_k3c3_i4d1a, path_k3c3_i4d1b]); - // both are built on path k1c2. - const cluster_k3c4 = new SearchQuotientCluster([path_k3c4_i4d0, path_k3c4_i5d1]); - - // Input 4 - const distrib_c2_i1d0: Distribution = [ - { sample: { insert: 'n', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.12 }, - { sample: { insert: 'p', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.08 }, - ]; - - const distrib_c2_i2d0: Distribution = [ - { sample: { insert: 'qr', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.3 }, // most likely for id 14 - { sample: { insert: 'st', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.2 }, - { sample: { insert: 'vw', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.1 } - ]; - - const path_k4c4_i2 = new LegacyQuotientSpur(path_k3c2_i3d1, distrib_c2_i2d0, distrib_c2_i2d0[0]); - const path_k4c4_i1 = new LegacyQuotientSpur(cluster_k3c3, distrib_c2_i1d0, distrib_c2_i2d0[0]); - - const path_k4c5_i2 = new LegacyQuotientSpur(cluster_k3c3, distrib_c2_i2d0, distrib_c2_i2d0[0]); - const path_k4c5_i1 = new LegacyQuotientSpur(cluster_k3c4, distrib_c2_i1d0, distrib_c2_i2d0[0]); - - const path_k4c6 = new LegacyQuotientSpur(cluster_k3c4, distrib_c2_i2d0, distrib_c2_i2d0[0]); - - const cluster_k4c4 = new SearchQuotientCluster([path_k4c4_i2, path_k4c4_i1]); - const cluster_k4c5 = new SearchQuotientCluster([path_k4c5_i2, path_k4c5_i1]); - - // Input 5 (currently used only for merge tests) - const distrib_c3_i2d0: Distribution = [ - { sample: { insert: 'xy', deleteLeft: 0, deleteRight: 0, id: 15 }, p: 0.6 } // most likely for id 15 - ]; - const distrib_c3_i1d0: Distribution = [ - { sample: { insert: 'z', deleteLeft: 0, deleteRight: 0, id: 15 }, p: 0.4 } - ]; - - const path_k5c6_a = new LegacyQuotientSpur(cluster_k4c4, distrib_c3_i2d0, distrib_c3_i2d0[0]); - const path_k5c6_b = new LegacyQuotientSpur(cluster_k4c5, distrib_c3_i1d0, distrib_c3_i2d0[0]); - - const cluster_k5c6 = new SearchQuotientCluster([path_k5c6_a, path_k5c6_b]); - - return { - distributions: { - 1: { - distrib_c1_i1d0, - distrib_c1_i2d0 - }, - 2: { - distrib_v1_i1d0 - }, - 3: { - distrib_v2_i1d0, - distrib_v2_i1d1, - distrib_v2_i2d1 - }, - 4: { - distrib_c2_i1d0, - distrib_c2_i2d0 - }, - 5: { - distrib_c3_i1d0, - distrib_c3_i2d0 - } - }, - paths: { - 0: { - rootPath - }, - 1: { - path_k1c1_i1d0, - path_k1c2_i2d0, - }, - 2: { - path_k2c2_i2d0, - path_k2c3_i3d0, - }, - 3: { - path_k3c2_i3d1, - path_k3c3_i3d0, - path_k3c3_i4d1a, - path_k3c3_i4d1b, - path_k3c4_i4d0, - path_k3c4_i5d1, - }, - 4: { - path_k4c4_i2, - path_k4c4_i1, - path_k4c5_i2, - path_k4c5_i1, - path_k4c6 - }, - 5: { - path_k5c6_a, - path_k5c6_b - } - }, - clusters: { - cluster_k3c3, - cluster_k3c4, - cluster_k4c4, - cluster_k4c5, - cluster_k5c6 - } - } -} - const splitDistribution = (dist: Distribution, index: number) => { const splitEntries = dist.map((entry) => ([{ p: entry.p, diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts index d02e614151a..82212cdfd91 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts @@ -3,12 +3,12 @@ import { assert } from 'chai'; import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; import { LegacyQuotientRoot, models } from '@keymanapp/lm-worker/test-index'; -import { buildSimplePathSplitFixture } from './search-quotient-spur.tests.js'; +import { buildCantLinearFixture } from '../../helpers/buildCantLinearFixture.js'; +import { buildAlphabeticClusterFixtures } from '../../helpers/buildAlphabeticClusteredFixture.js'; import { quotientPathHasInputs } from '#test-resources/searchQuotientUtils.js'; import TrieModel = models.TrieModel; -import { buildAlphabeticClusterFixtures } from './search-quotient-cluster.tests.js'; const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); @@ -18,12 +18,12 @@ describe('quotientNodeHasParents()', () => { }); it('matches all path inputs when provided in proper order', () => { - const { paths, distributions } = buildSimplePathSplitFixture(); + const { paths, distributions } = buildCantLinearFixture(); assert.isTrue(quotientPathHasInputs(paths[4], distributions)); }); it('does not match when any path input component is missing', () => { - const { paths, distributions } = buildSimplePathSplitFixture(); + const { paths, distributions } = buildCantLinearFixture(); assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(1))); assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(2))); assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(3))); @@ -32,7 +32,7 @@ describe('quotientNodeHasParents()', () => { }); it('does not match when path inputs are not in proper order', () => { - const { paths, distributions } = buildSimplePathSplitFixture(); + const { paths, distributions } = buildCantLinearFixture(); assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice().reverse())); // Random shuffle. diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts index f3a39a93c5a..fedd55c78ac 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts @@ -23,17 +23,17 @@ import { SearchQuotientSpur } from '@keymanapp/lm-worker/test-index'; -import { buildAlphabeticClusterFixtures } from './search-quotient-cluster.tests.js'; +import { constituentPaths } from '../../helpers/constituentPaths.js'; +import { quotientPathHasInputs } from '../../helpers/quotientPathHasInputs.js'; +import { buildCantLinearFixture } from '../../helpers/buildCantLinearFixture.js'; +import { buildAlphabeticClusterFixtures } from '../../helpers/buildAlphabeticClusteredFixture.js'; import Distribution = LexicalModelTypes.Distribution; import Transform = LexicalModelTypes.Transform; import TrieModel = models.TrieModel; -import { constituentPaths, quotientPathHasInputs } from '#test-resources/searchQuotientUtils.js'; - const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); - // https://www.compart.com/en/unicode/block/U+1D400 const mathBoldUpperA = 0x1D400; // Mathematical Bold Capital A const mathBoldLowerA = 0x1D41A; // Small A @@ -63,39 +63,6 @@ function toMathematicalSMP(text: string) { return asSMP.join(''); } -export function buildSimplePathSplitFixture() { - const rootPath = new LegacyQuotientRoot(testModel); - - const distrib1 = [ - { sample: {insert: 'c', deleteLeft: 0, id: 11}, p: 0.5 }, - { sample: {insert: 'r', deleteLeft: 0, id: 11}, p: 0.4 }, - { sample: {insert: 't', deleteLeft: 0, id: 11}, p: 0.1 } - ]; - const path1 = new LegacyQuotientSpur(rootPath, distrib1, distrib1[0]); - - const distrib2 = [ - { sample: {insert: 'a', deleteLeft: 0, id: 12}, p: 0.7 }, - { sample: {insert: 'e', deleteLeft: 0, id: 12}, p: 0.3 } - ]; - const path2 = new LegacyQuotientSpur(path1, distrib2, distrib2[0]); - - const distrib3 = [ - { sample: {insert: 'n', deleteLeft: 0, id: 13}, p: 0.8 }, - { sample: {insert: 'r', deleteLeft: 0, id: 13}, p: 0.2 } - ]; - const path3 = new LegacyQuotientSpur(path2, distrib3, distrib3[0]); - - const distrib4 = [ - { sample: {insert: 't', deleteLeft: 0, id: 14}, p: 1 } - ]; - const path4 = new LegacyQuotientSpur(path3, distrib4, distrib4[0]); - - return { - paths: [null, path1, path2, path3, path4], - distributions: [distrib1, distrib2, distrib3, distrib4] - }; -} - describe('SearchQuotientSpur', () => { describe('constructor', () => { it('initializes from a lexical model', () => { @@ -269,31 +236,9 @@ describe('SearchQuotientSpur', () => { }); }); - describe('fixture construction', () => { - it('setup: buildSimplePathSplitFixture() constructs paths properly', () => { - const { paths, distributions } = buildSimplePathSplitFixture(); - const pathToSplit = paths[4]; - - assert.equal(pathToSplit.inputCount, 4); - assert.equal(distributions.length, pathToSplit.inputCount); - assert.equal(pathToSplit.codepointLength, 4); // one char per input, no deletions anywhere - // Per assertions documented in the setup above. - assert.deepEqual(pathToSplit.bestExample, distributions.reduce( - (constructing, current) => ({text: constructing.text + current[0].sample.insert, p: constructing.p * current[0].p}), - {text: '', p: 1}) - ); - assert.deepEqual(pathToSplit.parents[0].bestExample, distributions.slice(0, pathToSplit.inputCount-1).reduce( - (constructing, current) => ({text: constructing.text + current[0].sample.insert, p: constructing.p * current[0].p}), - {text: '', p: 1}) - ); - assert.isTrue(quotientPathHasInputs(pathToSplit, distributions)); - assert.equal(constituentPaths(pathToSplit).length, 1); - }); - }); - describe('constituentPaths', () => { it('includes a single entry array when all parents are SearchQuotientSpurs', () => { - const { paths } = buildSimplePathSplitFixture(); + const { paths } = buildCantLinearFixture(); const finalPath = paths[4]; assert.equal(constituentPaths(finalPath).length, 1); @@ -326,7 +271,7 @@ describe('SearchQuotientSpur', () => { it('changes when input source subset IDs differ', () => { const root = new LegacyQuotientRoot(testModel); - const {distributions} = buildSimplePathSplitFixture(); + const {distributions} = buildCantLinearFixture(); const inputSrc = { segment: { transitionId: distributions[0][0].sample.id, @@ -351,7 +296,7 @@ describe('SearchQuotientSpur', () => { it('changes when different parts of the same input source are used', () => { const root = new LegacyQuotientRoot(testModel); - const {distributions} = buildSimplePathSplitFixture(); + const {distributions} = buildCantLinearFixture(); const inputSrc = { segment: { transitionId: distributions[0][0].sample.id, @@ -386,7 +331,7 @@ describe('SearchQuotientSpur', () => { describe('split()', () => { describe(`on token comprised of single-char transforms: [crt][ae][nr][t]`, () => { const runSplit = (splitIndex: number) => { - const { paths, distributions } = buildSimplePathSplitFixture(); + const { paths, distributions } = buildCantLinearFixture(); const pathToSplit = paths[4]; const splitResults = pathToSplit.split(splitIndex); @@ -419,7 +364,7 @@ describe('SearchQuotientSpur', () => { it('splits properly at index 0', () => { runSplit(0); - const { paths, distributions } = buildSimplePathSplitFixture(); + const { paths, distributions } = buildCantLinearFixture(); const pathToSplit = paths[4]; const [head, tail] = pathToSplit.split(0)[0]; @@ -436,7 +381,7 @@ describe('SearchQuotientSpur', () => { it('splits properly at index 1', () => { runSplit(1); - const { paths } = buildSimplePathSplitFixture(); + const { paths } = buildCantLinearFixture(); const pathToSplit = paths[4]; const [head] = pathToSplit.split(1)[0]; @@ -446,7 +391,7 @@ describe('SearchQuotientSpur', () => { it('splits properly at index 2', () => { runSplit(2); - const { paths } = buildSimplePathSplitFixture(); + const { paths } = buildCantLinearFixture(); const pathToSplit = paths[4]; const [head] = pathToSplit.split(2)[0]; @@ -456,7 +401,7 @@ describe('SearchQuotientSpur', () => { it('splits properly at index 3', () => { runSplit(3); - const { paths } = buildSimplePathSplitFixture(); + const { paths } = buildCantLinearFixture(); const pathToSplit = paths[4]; const [head] = pathToSplit.split(3)[0]; @@ -466,7 +411,7 @@ describe('SearchQuotientSpur', () => { it('splits properly at index 4', () => { runSplit(4); - const { paths } = buildSimplePathSplitFixture(); + const { paths } = buildCantLinearFixture(); const pathToSplit = paths[4]; const [head] = pathToSplit.split(4)[0]; From f78a7bae426948b1489e4cbdd1a9459c7877663d Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 10 Mar 2026 10:43:41 -0500 Subject: [PATCH 2/8] change(web): remove old copy of relocated helper test --- .../search-quotient-spur.tests.ts | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts index fedd55c78ac..474ea09c76b 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts @@ -26,7 +26,6 @@ import { import { constituentPaths } from '../../helpers/constituentPaths.js'; import { quotientPathHasInputs } from '../../helpers/quotientPathHasInputs.js'; import { buildCantLinearFixture } from '../../helpers/buildCantLinearFixture.js'; -import { buildAlphabeticClusterFixtures } from '../../helpers/buildAlphabeticClusteredFixture.js'; import Distribution = LexicalModelTypes.Distribution; import Transform = LexicalModelTypes.Transform; @@ -236,37 +235,6 @@ describe('SearchQuotientSpur', () => { }); }); - describe('constituentPaths', () => { - it('includes a single entry array when all parents are SearchQuotientSpurs', () => { - const { paths } = buildCantLinearFixture(); - const finalPath = paths[4]; - - assert.equal(constituentPaths(finalPath).length, 1); - - const pathSequence = constituentPaths(finalPath)[0]; - assert.equal(pathSequence.length, 4); // 4 inputs; does not include root node - - assert.sameOrderedMembers(pathSequence, paths.slice(1)); - }); - - it('properly enumerates child paths when encountering SearchCluster ancestors', () => { - const fixture = buildAlphabeticClusterFixtures(); - const finalPath = fixture.paths[4].path_k4c6; - - // The longest SearchPath at the end of that fixture's set is based on a - // lead-in cluster; all variants of that should be included. - assert.equal(constituentPaths(finalPath).length, constituentPaths(fixture.clusters.cluster_k3c4).length); - - // That cluster holds the different potential penultimate paths; - // finalPath's inputs are added directly after any variation that may be - // output from the cluster. - assert.sameDeepMembers(constituentPaths(finalPath), constituentPaths(fixture.clusters.cluster_k3c4).map((p) => { - p.push(finalPath); - return p; - })); - }); - }); - describe('.edgeKey', () => { it('changes when input source subset IDs differ', () => { const root = new LegacyQuotientRoot(testModel); From 7469f685eb51f0c98c5759a0561dcd1e9feb6c60 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 10 Mar 2026 10:59:06 -0500 Subject: [PATCH 3/8] change(web): remove test file that only tested a test-helper --- .../search-quotient-node.tests.ts | 88 ------------------- 1 file changed, 88 deletions(-) delete mode 100644 web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts deleted file mode 100644 index 82212cdfd91..00000000000 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { assert } from 'chai'; - -import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; -import { LegacyQuotientRoot, models } from '@keymanapp/lm-worker/test-index'; - -import { buildCantLinearFixture } from '../../helpers/buildCantLinearFixture.js'; -import { buildAlphabeticClusterFixtures } from '../../helpers/buildAlphabeticClusteredFixture.js'; - -import { quotientPathHasInputs } from '#test-resources/searchQuotientUtils.js'; - -import TrieModel = models.TrieModel; - -const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); - -describe('quotientNodeHasParents()', () => { - it('matches an empty array on root SearchPaths', () => { - assert.isTrue(quotientPathHasInputs(new LegacyQuotientRoot(testModel), [])); - }); - - it('matches all path inputs when provided in proper order', () => { - const { paths, distributions } = buildCantLinearFixture(); - assert.isTrue(quotientPathHasInputs(paths[4], distributions)); - }); - - it('does not match when any path input component is missing', () => { - const { paths, distributions } = buildCantLinearFixture(); - assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(1))); - assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(2))); - assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(3))); - assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(0, 3))); - assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(0, 1).concat(distributions.slice(2)))); - }); - - it('does not match when path inputs are not in proper order', () => { - const { paths, distributions } = buildCantLinearFixture(); - assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice().reverse())); - - // Random shuffle. - let shuffled: typeof distributions; - let isShuffled: boolean; - do { - shuffled = distributions.slice().sort(() => Math.random() * 2 - 1); - // Validate that we actually shuffled - that we didn't land on the original order! - isShuffled = false; - for(let i = 0; i < distributions.length; i++) { - if(distributions[i] != shuffled[i]) { - isShuffled = true; - break; - } - } - } while(!isShuffled); - assert.isFalse(quotientPathHasInputs(paths[4], shuffled)); - }); - - it('is able to match inputs against SearchQuotientCluster constituent input paths', () => { - const { distributions, clusters } = buildAlphabeticClusterFixtures(); - - const fourCharCluster = clusters.cluster_k4c4; - const fiveCharCluster = clusters.cluster_k4c5; - - assert.isTrue(quotientPathHasInputs(fourCharCluster, [ - distributions[1].distrib_c1_i1d0, - distributions[2].distrib_v1_i1d0, - distributions[3].distrib_v2_i1d0, - distributions[4].distrib_c2_i1d0 - ])); - assert.isFalse(quotientPathHasInputs(fiveCharCluster,[ - distributions[1].distrib_c1_i1d0, - distributions[2].distrib_v1_i1d0, - distributions[3].distrib_v2_i1d0, - distributions[4].distrib_c2_i1d0 - ])); - - assert.isFalse(quotientPathHasInputs(fourCharCluster, [ - distributions[1].distrib_c1_i1d0, - distributions[2].distrib_v1_i1d0, - distributions[3].distrib_v2_i1d0, - distributions[4].distrib_c2_i2d0 - ])); - assert.isTrue(quotientPathHasInputs(fiveCharCluster, [ - distributions[1].distrib_c1_i1d0, - distributions[2].distrib_v1_i1d0, - distributions[3].distrib_v2_i1d0, - distributions[4].distrib_c2_i2d0 - ])); - }); -}); - From 44fdb6ed477dc1490772767e388f1e5fb34883d5 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 10 Mar 2026 11:54:29 -0500 Subject: [PATCH 4/8] change(web): correct name for test-helper unit test suite --- .../predictive-text/helpers/buildCantLinearFixture.tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts index cc95a8563e5..41acda38254 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts @@ -4,7 +4,7 @@ import { constituentPaths } from "./constituentPaths.js"; import { quotientPathHasInputs } from "./quotientPathHasInputs.js"; import { buildCantLinearFixture } from "./buildCantLinearFixture.js"; -describe('buildSimplePathSplitFixture() fixture', () => { +describe('buildCantLinearFixture() fixture', () => { it('constructs paths properly', () => { const { paths, distributions } = buildCantLinearFixture(); const pathToSplit = paths[4]; From 79fd689d1307d6d8f8d69ef9be233fdfa9193ad3 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 12 Mar 2026 12:30:36 -0500 Subject: [PATCH 5/8] change(web): address many PR review concerns --- .../helpers/buildAlphabeticClusterFixture.tests.ts | 13 +++++++++++-- .../helpers/buildAlphabeticClusteredFixture.ts | 4 ++-- .../helpers/buildCantLinearFixture.tests.ts | 10 ++++++++++ .../helpers/buildCantLinearFixture.ts | 10 ++++++++++ .../helpers/quotientPathHasInputs.tests.ts | 2 +- 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusterFixture.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusterFixture.tests.ts index 9a595df4416..b680159c044 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusterFixture.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusterFixture.tests.ts @@ -1,3 +1,12 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-03-09 + * + * This file tests the construction of the helper test-fixture function in + * buildAlphabeticClusteredFixture.ts. + */ + import { assert } from "chai"; import { SearchQuotientNode } from "@keymanapp/lm-worker/test-index"; @@ -15,7 +24,7 @@ describe('buildAlphabeticClusteredFixture() fixture', () => { const allDists = Object.values(distributions).map(set => Object.values(set)).flat(); const finalClusterPaths = constituentPaths(clusters.cluster_k5c6) as SearchQuotientNode[][]; - allPaths.forEach((spur) => assert.isTrue(finalClusterPaths.find(seq => seq.indexOf(spur) > -1))); - allDists.forEach((dist) => allPaths.find(path => quotientPathHasInputs(path, [dist]))); + allPaths.forEach((spur) => assert.isOk(finalClusterPaths.find(seq => seq.indexOf(spur) > -1))); + allDists.forEach((dist) => assert.isOk(allPaths.find(path => quotientPathHasInputs(path, [dist])))); }); }); \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts index a6493368d7a..5da1bb59dd6 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts @@ -69,16 +69,16 @@ export const buildAlphabeticClusterFixtures = () => { ]; // 0.60 total const distrib_v2_i1d1: Distribution = [ - { sample: { insert: 'á', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 }, { sample: { insert: 'é', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 }, + { sample: { insert: 'á', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 }, { sample: { insert: 'í', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.04 }, { sample: { insert: 'ó', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.03 }, { sample: { insert: 'ú', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.02 }, ]; // 0.2 total const distrib_v2_i2d1: Distribution = [ - { sample: { insert: 'áá', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 }, { sample: { insert: 'éé', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 }, + { sample: { insert: 'áá', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 }, { sample: { insert: 'íí', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.04 }, { sample: { insert: 'óó', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.03 }, { sample: { insert: 'úú', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.02 }, diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts index 41acda38254..de3620a202b 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.tests.ts @@ -1,3 +1,13 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-03-09 + * + * This file defines unit tests for validating a unit-text fixture for behaviors + * related to a simple linear sequence of SearchQuotientSpurs, with no + * convergence or divergence occurring within the fixture. + */ + import { assert } from "chai"; import { constituentPaths } from "./constituentPaths.js"; diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts index c7c9d68bccc..9226ff95827 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts @@ -1,3 +1,13 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-03-09 + * + * This file defines a unit-text fixture for testing behaviors related to a + * simple linear sequence of SearchQuotientSpurs, with no convergence or + * divergence occurring within the fixture. + */ + import { LegacyQuotientRoot, LegacyQuotientSpur, models } from "@keymanapp/lm-worker/test-index"; import { jsonFixture } from "@keymanapp/common-test-resources/model-helpers.mjs"; diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.tests.ts index 952699ef16a..b0f4ee34d2c 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/quotientPathHasInputs.tests.ts @@ -10,11 +10,11 @@ import { assert } from 'chai'; import { models, SearchQuotientRoot } from '@keymanapp/lm-worker/test-index'; +import { jsonFixture } from "@keymanapp/common-test-resources/model-helpers.mjs"; import { quotientPathHasInputs } from "./quotientPathHasInputs.js"; import { buildCantLinearFixture } from './buildCantLinearFixture.js'; import { buildAlphabeticClusterFixtures } from './buildAlphabeticClusteredFixture.js'; -import { jsonFixture } from '../templates/helpers.js'; import TrieModel = models.TrieModel; From 2ff1691636b9b35c9cac9c45e513689d0de30a7f Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 13 Mar 2026 07:54:34 -0500 Subject: [PATCH 6/8] change(web): fix fixture test file name --- ...rFixture.tests.ts => buildAlphabeticClusteredFixture.tests.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename web/src/test/auto/headless/engine/predictive-text/helpers/{buildAlphabeticClusterFixture.tests.ts => buildAlphabeticClusteredFixture.tests.ts} (100%) diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusterFixture.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.tests.ts similarity index 100% rename from web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusterFixture.tests.ts rename to web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.tests.ts From 7aa4cc480b44578f958435589b7882fa85379b8b Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 13 Mar 2026 07:54:44 -0500 Subject: [PATCH 7/8] docs(web): add doc-comment to fixture construction method --- .../helpers/buildAlphabeticClusteredFixture.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts index 5da1bb59dd6..086193a387a 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildAlphabeticClusteredFixture.ts @@ -24,6 +24,12 @@ import TrieModel = models.TrieModel; const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); +/** + * Builds a fixture for use in unit-testing against diverging and re-converging + * routes through the modeled correction-search graph and its SearchQuotientNode + * representation. + * @returns + */ export const buildAlphabeticClusterFixtures = () => { const rootPath = new LegacyQuotientRoot(testModel); From a09dc04bdede04a787368fef3b06878e36a3b8c5 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 13 Mar 2026 07:56:00 -0500 Subject: [PATCH 8/8] docs(web): add doc comment to buildCantLinearFixture --- .../engine/predictive-text/helpers/buildCantLinearFixture.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts index 9226ff95827..3de7986562c 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildCantLinearFixture.ts @@ -15,8 +15,9 @@ import TrieModel = models.TrieModel; const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); -// Needs legacy vs 'new' style implementation! - +/** + * Build a linear fixture that models the word 'cant' and words close to that. + */ export function buildCantLinearFixture() { const rootPath = new LegacyQuotientRoot(testModel);