From db46cb6234928fdc6bcc420a6d37f539165935fb Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 28 Apr 2026 21:00:42 +0100 Subject: [PATCH 1/2] Normalize formatting and add Gridset helpers Apply code-style and formatting changes across the codebase (quote normalization to double-quotes, consistent trailing commas/line breaks, spacing, and minor string/console formatting fixes). Update CLI and core modules for consistent formatting and improved readability. Add new Gridset helper files and utilities for the gridset processor (src/processors/gridset/cellHelpers.ts, gridCalculations.ts, xmlFormatter.ts) and adjust related gridset processor modules. Update docs (README, CHANGELOG) and numerous tests to reflect these changes. --- CHANGELOG.md | 17 +- README.md | 30 +- src/analytics.ts | 2 +- src/applePanels.ts | 2 +- src/astericsGrid.ts | 2 +- src/cli/index.ts | 390 +++-- src/cli/prettyPrint.ts | 12 +- src/core/analyze.ts | 70 +- src/core/baseProcessor.ts | 124 +- src/core/stringCasing.ts | 75 +- src/core/treeStructure.ts | 160 +- src/dot.ts | 2 +- src/excel.ts | 2 +- src/gridset.ts | 24 +- src/index.browser.ts | 83 +- src/index.node.ts | 120 +- src/index.ts | 2 +- src/metrics.ts | 28 +- src/obf.ts | 4 +- src/obfset.ts | 2 +- src/opml.ts | 2 +- src/processors/applePanelsProcessor.ts | 307 ++-- src/processors/astericsGridProcessor.ts | 1265 ++++++++------- src/processors/dotProcessor.ts | 111 +- src/processors/excelProcessor.ts | 191 ++- src/processors/gridset/cellHelpers.ts | 123 ++ src/processors/gridset/colorUtils.ts | 33 +- src/processors/gridset/commands.ts | 1104 ++++++------- src/processors/gridset/crypto.ts | 31 +- src/processors/gridset/gridCalculations.ts | 82 + src/processors/gridset/helpers.ts | 147 +- src/processors/gridset/imageDebug.ts | 117 +- src/processors/gridset/index.ts | 44 +- src/processors/gridset/password.ts | 43 +- src/processors/gridset/pluginTypes.ts | 222 +-- src/processors/gridset/resolver.ts | 33 +- src/processors/gridset/styleHelpers.ts | 208 +-- src/processors/gridset/symbolAlignment.ts | 54 +- src/processors/gridset/symbolExtractor.ts | 150 +- src/processors/gridset/symbolSearch.ts | 52 +- src/processors/gridset/symbols.ts | 201 ++- src/processors/gridset/wordlistHelpers.ts | 93 +- src/processors/gridset/xmlFormatter.ts | 108 ++ src/processors/gridsetProcessor.ts | 1426 +++++++++-------- src/processors/index.ts | 20 +- src/processors/obfProcessor.ts | 376 +++-- src/processors/obfsetProcessor.ts | 34 +- src/processors/opmlProcessor.ts | 185 ++- src/processors/snap/helpers.ts | 119 +- src/processors/snapProcessor.ts | 580 ++++--- src/processors/touchchat/helpers.ts | 12 +- src/processors/touchchatProcessor.ts | 407 +++-- src/snap.ts | 4 +- src/touchchat.ts | 4 +- src/translation.ts | 4 +- src/types/aac.ts | 41 +- src/utilities/analytics/history.ts | 61 +- src/utilities/analytics/index.ts | 40 +- src/utilities/analytics/metrics/comparison.ts | 121 +- src/utilities/analytics/metrics/core.ts | 442 +++-- src/utilities/analytics/metrics/effort.ts | 66 +- src/utilities/analytics/metrics/index.ts | 12 +- src/utilities/analytics/metrics/obl-types.ts | 14 +- src/utilities/analytics/metrics/obl.ts | 150 +- src/utilities/analytics/metrics/sentence.ts | 27 +- src/utilities/analytics/metrics/types.ts | 2 +- src/utilities/analytics/metrics/vocabulary.ts | 33 +- src/utilities/analytics/morphology/engine.ts | 1046 ++++++------ .../analytics/morphology/grid3VerbsParser.ts | 182 ++- src/utilities/analytics/morphology/index.ts | 8 +- .../analytics/morphology/wordFormGenerator.ts | 55 +- src/utilities/analytics/reference/browser.ts | 35 +- src/utilities/analytics/reference/index.ts | 43 +- src/utilities/analytics/utils/idGenerator.ts | 16 +- src/utilities/symbolTools.ts | 47 +- .../translation/translationProcessor.ts | 48 +- src/utils/io.ts | 120 +- src/utils/sqlite.ts | 57 +- src/utils/zip.ts | 18 +- src/validation.ts | 26 +- src/validation/applePanelsValidator.ts | 62 +- src/validation/astericsValidator.ts | 55 +- src/validation/baseValidator.ts | 35 +- src/validation/dotValidator.ts | 65 +- src/validation/excelValidator.ts | 62 +- src/validation/gridsetValidator.ts | 334 ++-- src/validation/index.ts | 135 +- src/validation/obfValidator.ts | 601 ++++--- src/validation/obfsetValidator.ts | 53 +- src/validation/opmlValidator.ts | 62 +- src/validation/snapValidator.ts | 319 ++-- src/validation/touchChatValidator.ts | 336 ++-- src/validation/validationTypes.ts | 18 +- test/advancedScenarios.test.ts | 330 ++-- test/aliasMethodsIntegration.test.ts | 190 +-- test/applePanelsProcessor.roundtrip.test.ts | 70 +- test/astericsColors.test.ts | 54 +- test/astericsGridProcessor.test.ts | 129 +- test/audit-images.test.ts | 72 +- test/browserBundle.output.test.ts | 24 +- test/browserCompatibility.test.ts | 88 +- test/cli.comprehensive.test.ts | 340 ++-- test/colorUtils.test.ts | 242 +-- test/concurrency.test.ts | 97 +- test/core/analyze.test.ts | 120 +- test/core/baseConfig.test.ts | 44 +- test/core/baseProcessor.generic.test.ts | 105 +- test/core/coverageBoost.test.ts | 181 ++- test/core/treeStructure.test.ts | 160 +- test/dotProcessor.roundtrip.test.ts | 18 +- test/dotProcessor.test.ts | 38 +- test/edgeCases.test.ts | 212 +-- test/errorHandling.test.ts | 147 +- test/grid3VerbsParser.test.ts | 153 +- test/gridsetHelpers.misc.test.ts | 38 +- test/gridsetHelpers.test.ts | 237 +-- test/gridsetImageDebug.test.ts | 75 +- test/gridsetPluginTypes.test.ts | 112 +- test/gridsetProcessor.coverage.test.ts | 382 ++--- .../gridsetProcessor.roundtrip.test.legacy.ts | 18 +- test/gridsetProcessor.roundtrip.test.ts | 67 +- test/gridsetProcessor.test.ts | 61 +- test/gridsetResolver.test.ts | 80 +- test/gridsetWordlistHelpers.test.ts | 253 +-- test/history.analytics.test.ts | 63 +- test/history.test.ts | 78 +- test/index.entrypoints.test.ts | 61 +- test/integration.test.ts | 252 +-- test/memoryLeaks.test.ts | 125 +- test/morphology.test.ts | 350 ++-- test/obfProcessor.roundtrip.test.ts | 86 +- test/obfProcessor.test.ts | 39 +- test/obfsetProcessor.test.ts | 64 +- test/obl.test.ts | 194 ++- test/opmlProcessor.roundtrip.test.ts | 28 +- test/opmlProcessor.test.ts | 14 +- test/performance.memory.test.ts | 253 +-- test/performance.test.ts | 92 +- test/platformPaths.test.ts | 349 ++-- test/processTexts.realworld.test.ts | 248 +-- test/processTexts.test.ts | 139 +- test/processors/excelProcessor.test.ts | 174 +- test/processors/gridset/symbols.test.ts | 59 +- test/propertyBased.test.ts | 322 ++-- test/scanningMetrics.test.ts | 105 +- .../snapProcessor.audio.comprehensive.test.ts | 216 +-- test/snapProcessor.audio.test.ts | 114 +- ...apProcessor.corruption.performance.test.ts | 137 +- test/snapProcessor.coverage.test.ts | 86 +- test/snapProcessor.roundtrip.test.ts | 26 +- test/snapProcessor.test.ts | 82 +- test/stringCasing.test.ts | 178 +- test/styling.test.ts | 147 +- test/suggestWordsEffort.test.ts | 112 +- test/symbolAlignment.test.ts | 423 ++--- test/touchchatHelpers.test.ts | 20 +- test/touchchatProcessor.comprehensive.test.ts | 218 +-- test/touchchatProcessor.coverage.test.ts | 65 +- test/touchchatProcessor.roundtrip.test.ts | 18 +- test/touchchatProcessor.test.ts | 14 +- test/utils/ioHelpers.test.ts | 48 +- test/utils/testFactories.ts | 171 +- test/utils/testHelpers.ts | 79 +- test/utils/zipAdapter.browser.test.ts | 22 +- test/utils/zipAdapter.test.ts | 36 +- test/validation.coverage.test.ts | 525 +++--- test/validation.newFormats.test.ts | 130 +- test/validation.test.ts | 204 ++- test/wordFormGenerator.test.ts | 226 +-- tsconfig.json | 3 +- 170 files changed, 13917 insertions(+), 10505 deletions(-) create mode 100644 src/processors/gridset/cellHelpers.ts create mode 100644 src/processors/gridset/gridCalculations.ts create mode 100644 src/processors/gridset/xmlFormatter.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f2b93..6f89018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,11 @@ processor.saveFromTree(tree, output); // After (v3.x) const tree: AACTree = await processor.loadIntoTree(file); const texts: string[] = await processor.extractTexts(file); -const result: Uint8Array = await processor.processTexts(file, translations, output); +const result: Uint8Array = await processor.processTexts( + file, + translations, + output, +); await processor.saveFromTree(tree, output); ``` @@ -39,7 +43,8 @@ const symbols: ButtonForTranslation[] = processor.extractSymbolsForLLM(file); processor.processLLMTranslations(file, translations, output); // After -const symbols: ButtonForTranslation[] = await processor.extractSymbolsForLLM(file); +const symbols: ButtonForTranslation[] = + await processor.extractSymbolsForLLM(file); await processor.processLLMTranslations(file, translations, output); ``` @@ -47,10 +52,10 @@ await processor.processLLMTranslations(file, translations, output); ```typescript // Before -const result = analyze(file, format); // Returns { tree } +const result = analyze(file, format); // Returns { tree } // After -const result = await analyze(file, format); // Returns Promise<{ tree }> +const result = await analyze(file, format); // Returns Promise<{ tree }> ``` ### Migration Guide @@ -103,7 +108,7 @@ async function processMultipleFiles(files: string[]) { const processor = getProcessor(file); const tree = await processor.loadIntoTree(file); return tree; - }) + }), ); return results; } @@ -154,6 +159,7 @@ async function processMultipleFiles(files: string[]) { ### Browser Compatibility Progress This change enables the following browser-compatible processors: + - ✅ DotProcessor - ✅ OpmlProcessor - ✅ ObfProcessor (JSZip migration complete!) @@ -164,6 +170,7 @@ This change enables the following browser-compatible processors: **Note:** Gridset `.gridsetx` encrypted files require Node.js for crypto operations. Regular `.gridset` files work in browser. Still Node-only (deferred): + - ❌ SnapProcessor (sqlite - needs wasm sqlite) - ❌ TouchChatProcessor (sqlite - needs wasm sqlite) - ❌ ExcelProcessor (fs dependencies - needs audit) diff --git a/README.md b/README.md index dafd579..7398016 100644 --- a/README.md +++ b/README.md @@ -13,20 +13,22 @@ npm install @willwade/aac-processors ## Dual Build Targets ### Node.js (default) + Full feature set, including filesystem access, SQLite-backed formats, and ZIP/encrypted formats. ```ts -import { getProcessor, SnapProcessor } from '@willwade/aac-processors'; +import { getProcessor, SnapProcessor } from "@willwade/aac-processors"; -const processor = getProcessor('board.sps'); -const tree = await processor.loadIntoTree('board.sps'); +const processor = getProcessor("board.sps"); +const tree = await processor.loadIntoTree("board.sps"); const snap = new SnapProcessor(); -const texts = await snap.extractTexts('board.sps'); +const texts = await snap.extractTexts("board.sps"); ``` ### Browser + Browser-safe entry that avoids Node-only dependencies. It expects `Buffer`, `Uint8Array`, or `ArrayBuffer` inputs rather than file paths. @@ -36,7 +38,10 @@ and either include `` in your HTML, or `window.initSqlJs = require('sql.js');` in your app. ```ts -import { configureSqlJs, SnapProcessor } from '@willwade/aac-processors/browser'; +import { + configureSqlJs, + SnapProcessor, +} from "@willwade/aac-processors/browser"; configureSqlJs({ locateFile: (file) => new URL(`./${file}`, import.meta.url).toString(), @@ -47,7 +52,7 @@ const tree = await snap.loadIntoTree(snapUint8Array); ``` ```ts -import { GridsetProcessor } from '@willwade/aac-processors/browser'; +import { GridsetProcessor } from "@willwade/aac-processors/browser"; const processor = new GridsetProcessor(); const tree = await processor.loadIntoTree(gridsetUint8Array); @@ -70,19 +75,20 @@ const tree = await processor.loadIntoTree(gridsetUint8Array); All processors implement `processTexts()` to get all strings eg ```ts -import { DotProcessor } from '@willwade/aac-processors'; +import { DotProcessor } from "@willwade/aac-processors"; const processor = new DotProcessor(); -const texts = await processor.extractTexts('board.dot'); +const texts = await processor.extractTexts("board.dot"); const translations = new Map([ - ['Hello', 'Hola'], - ['Food', 'Comida'], + ["Hello", "Hola"], + ["Food", "Comida"], ]); -await processor.processTexts('board.dot', translations, 'board-es.dot'); +await processor.processTexts("board.dot", translations, "board-es.dot"); ``` -NB: Please use [https://aactools.co.uk](https://aactools.co.uk) for a far more comphrensive translation logic - where we do far far more than this... + +NB: Please use [https://aactools.co.uk](https://aactools.co.uk) for a far more comphrensive translation logic - where we do far far more than this... ## Documentation diff --git a/src/analytics.ts b/src/analytics.ts index 39fffb8..8d4f1bc 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -5,4 +5,4 @@ * This is separate from pageset metrics. */ -export * from './utilities/analytics/history'; +export * from "./utilities/analytics/history"; diff --git a/src/applePanels.ts b/src/applePanels.ts index dac2070..5208841 100644 --- a/src/applePanels.ts +++ b/src/applePanels.ts @@ -5,7 +5,7 @@ */ // Processor class -export { ApplePanelsProcessor } from './processors/applePanelsProcessor'; +export { ApplePanelsProcessor } from "./processors/applePanelsProcessor"; // Note: Apple Panels doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/astericsGrid.ts b/src/astericsGrid.ts index 121beba..8a85fb8 100644 --- a/src/astericsGrid.ts +++ b/src/astericsGrid.ts @@ -5,7 +5,7 @@ */ // Processor class -export { AstericsGridProcessor } from './processors/astericsGridProcessor'; +export { AstericsGridProcessor } from "./processors/astericsGridProcessor"; // Note: Asterics Grid doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/cli/index.ts b/src/cli/index.ts index eb8452a..2909750 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,19 +1,20 @@ #!/usr/bin/env node -import { program } from 'commander'; -import { prettyPrintTree } from './prettyPrint'; -import { getProcessor } from '../core/analyze'; -import { ProcessorOptions } from '../core/baseProcessor'; +import { program } from "commander"; +import { prettyPrintTree } from "./prettyPrint"; +import { getProcessor } from "../core/analyze"; +import { ProcessorOptions } from "../core/baseProcessor"; import { exportHistoryToBaton, readGrid3History, readSnapUsage, -} from '../utilities/analytics/history'; -import { ComparisonAnalyzer, MetricsCalculator } from '../utilities/analytics'; -import { CellScanningOrder, ScanningSelectionMethod } from '../types/aac'; -import { defaultFileAdapter, extname } from '../utils/io'; -import { readFileSync } from 'node:fs'; +} from "../utilities/analytics/history"; +import { ComparisonAnalyzer, MetricsCalculator } from "../utilities/analytics"; +import { CellScanningOrder, ScanningSelectionMethod } from "../types/aac"; +import { defaultFileAdapter, extname } from "../utils/io"; +import { readFileSync } from "node:fs"; -const { pathExists, isDirectory, join, basename, writeTextToPath } = defaultFileAdapter; +const { pathExists, isDirectory, join, basename, writeTextToPath } = + defaultFileAdapter; // Helper function to detect format from file/folder path async function detectFormat(filePath: string): Promise { @@ -21,17 +22,17 @@ async function detectFormat(filePath: string): Promise { if ( (await pathExists(filePath)) && (await isDirectory(filePath)) && - filePath.endsWith('.ascconfig') + filePath.endsWith(".ascconfig") ) { - return 'ascconfig'; + return "ascconfig"; } // Map multi-file formats to their base processor - if (filePath.endsWith('.obfset')) { - return 'obf'; // Use ObfProcessor for .obfset files + if (filePath.endsWith(".obfset")) { + return "obf"; // Use ObfProcessor for .obfset files } - if (filePath.endsWith('.gridset')) { - return 'gridset'; + if (filePath.endsWith(".gridset")) { + return "gridset"; } // Otherwise use file extension @@ -69,17 +70,19 @@ function parseFilteringOptions(options: { // Handle custom button exclusion list if (options.excludeButtons) { const excludeList = options.excludeButtons - .split(',') + .split(",") .map((s) => s.trim().toLowerCase()) .filter((s) => s.length > 0); if (excludeList.length > 0) { processorOptions.customButtonFilter = (button) => { - const label = button.label?.toLowerCase() || ''; - const message = button.message?.toLowerCase() || ''; + const label = button.label?.toLowerCase() || ""; + const message = button.message?.toLowerCase() || ""; // Exclude if button label or message contains any of the excluded terms - return !excludeList.some((term) => label.includes(term) || message.includes(term)); + return !excludeList.some( + (term) => label.includes(term) || message.includes(term), + ); }; } } @@ -88,20 +91,37 @@ function parseFilteringOptions(options: { } // Set version from package.json -const packageJson = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')) as { +const packageJson = JSON.parse( + readFileSync(join(__dirname, "../../package.json"), "utf8"), +) as { version: string; }; program.version(packageJson.version); program - .command('analyze ') - .option('--format ', 'Format type (auto-detected if not specified)') - .option('--pretty', 'Pretty print output') - .option('--preserve-all-buttons', 'Preserve all buttons including navigation/system buttons') - .option('--no-exclude-navigation', "Don't exclude navigation buttons (Home, Back)") - .option('--no-exclude-system', "Don't exclude system buttons (Delete, Clear, etc.)") - .option('--exclude-buttons ', 'Comma-separated list of button labels/terms to exclude') - .option('--gridset-password ', 'Password for encrypted Grid3 archives (.gridsetx)') + .command("analyze ") + .option("--format ", "Format type (auto-detected if not specified)") + .option("--pretty", "Pretty print output") + .option( + "--preserve-all-buttons", + "Preserve all buttons including navigation/system buttons", + ) + .option( + "--no-exclude-navigation", + "Don't exclude navigation buttons (Home, Back)", + ) + .option( + "--no-exclude-system", + "Don't exclude system buttons (Delete, Clear, etc.)", + ) + .option( + "--exclude-buttons ", + "Comma-separated list of button labels/terms to exclude", + ) + .option( + "--gridset-password ", + "Password for encrypted Grid3 archives (.gridsetx)", + ) .action( async ( file: string, @@ -113,7 +133,7 @@ program excludeSystem?: boolean; excludeButtons?: string; gridsetPassword?: string; - } + }, ) => { try { // Parse filtering options @@ -137,24 +157,39 @@ program } } catch (error) { console.error( - 'Error analyzing file:', - error instanceof Error ? error.message : String(error) + "Error analyzing file:", + error instanceof Error ? error.message : String(error), ); process.exit(1); } - } + }, ); program - .command('extract ') - .option('--format ', 'Format type (auto-detected if not specified)') - .option('--verbose', 'Verbose output') - .option('--quiet', 'Quiet output') - .option('--preserve-all-buttons', 'Preserve all buttons including navigation/system buttons') - .option('--no-exclude-navigation', "Don't exclude navigation buttons (Home, Back)") - .option('--no-exclude-system', "Don't exclude system buttons (Delete, Clear, etc.)") - .option('--exclude-buttons ', 'Comma-separated list of button labels/terms to exclude') - .option('--gridset-password ', 'Password for encrypted Grid3 archives (.gridsetx)') + .command("extract ") + .option("--format ", "Format type (auto-detected if not specified)") + .option("--verbose", "Verbose output") + .option("--quiet", "Quiet output") + .option( + "--preserve-all-buttons", + "Preserve all buttons including navigation/system buttons", + ) + .option( + "--no-exclude-navigation", + "Don't exclude navigation buttons (Home, Back)", + ) + .option( + "--no-exclude-system", + "Don't exclude system buttons (Delete, Clear, etc.)", + ) + .option( + "--exclude-buttons ", + "Comma-separated list of button labels/terms to exclude", + ) + .option( + "--gridset-password ", + "Password for encrypted Grid3 archives (.gridsetx)", + ) .action( async ( file: string, @@ -167,7 +202,7 @@ program excludeSystem?: boolean; excludeButtons?: string; gridsetPassword?: string; - } + }, ) => { try { // Parse filtering options @@ -185,14 +220,18 @@ program // Show filtering info in verbose mode if (filteringOptions.preserveAllButtons) { - console.log('Filtering: All buttons preserved'); + console.log("Filtering: All buttons preserved"); } else { const filters = []; - if (filteringOptions.excludeNavigationButtons !== false) filters.push('navigation'); - if (filteringOptions.excludeSystemButtons !== false) filters.push('system'); - if (filteringOptions.customButtonFilter) filters.push('custom'); + if (filteringOptions.excludeNavigationButtons !== false) + filters.push("navigation"); + if (filteringOptions.excludeSystemButtons !== false) + filters.push("system"); + if (filteringOptions.customButtonFilter) filters.push("custom"); if (filters.length > 0) { - console.log(`Filtering: Excluding ${filters.join(', ')} buttons`); + console.log( + `Filtering: Excluding ${filters.join(", ")} buttons`, + ); } } } @@ -202,22 +241,37 @@ program texts.forEach((text) => console.log(text)); } catch (error) { console.error( - 'Error extracting texts:', - error instanceof Error ? error.message : String(error) + "Error extracting texts:", + error instanceof Error ? error.message : String(error), ); process.exit(1); } - } + }, ); program - .command('convert ') - .option('--format ', 'Output format (required)') - .option('--preserve-all-buttons', 'Preserve all buttons including navigation/system buttons') - .option('--no-exclude-navigation', "Don't exclude navigation buttons (Home, Back)") - .option('--no-exclude-system', "Don't exclude system buttons (Delete, Clear, etc.)") - .option('--exclude-buttons ', 'Comma-separated list of button labels/terms to exclude') - .option('--gridset-password ', 'Password for encrypted Grid3 archives (.gridsetx)') + .command("convert ") + .option("--format ", "Output format (required)") + .option( + "--preserve-all-buttons", + "Preserve all buttons including navigation/system buttons", + ) + .option( + "--no-exclude-navigation", + "Don't exclude navigation buttons (Home, Back)", + ) + .option( + "--no-exclude-system", + "Don't exclude system buttons (Delete, Clear, etc.)", + ) + .option( + "--exclude-buttons ", + "Comma-separated list of button labels/terms to exclude", + ) + .option( + "--gridset-password ", + "Password for encrypted Grid3 archives (.gridsetx)", + ) .action( async ( input: string, @@ -229,11 +283,13 @@ program excludeSystem?: boolean; excludeButtons?: string; gridsetPassword?: string; - } + }, ) => { try { if (!options.format) { - console.error('Error: --format option is required for convert command'); + console.error( + "Error: --format option is required for convert command", + ); process.exit(1); } @@ -252,39 +308,44 @@ program await outputProcessor.saveFromTree(tree, output); // Show filtering summary - let filteringSummary = ''; + let filteringSummary = ""; if (filteringOptions.preserveAllButtons) { - filteringSummary = ' (all buttons preserved)'; + filteringSummary = " (all buttons preserved)"; } else { const filters = []; - if (filteringOptions.excludeNavigationButtons !== false) filters.push('navigation'); - if (filteringOptions.excludeSystemButtons !== false) filters.push('system'); - if (filteringOptions.customButtonFilter) filters.push('custom'); + if (filteringOptions.excludeNavigationButtons !== false) + filters.push("navigation"); + if (filteringOptions.excludeSystemButtons !== false) + filters.push("system"); + if (filteringOptions.customButtonFilter) filters.push("custom"); if (filters.length > 0) { - filteringSummary = ` (filtered: ${filters.join(', ')} buttons)`; + filteringSummary = ` (filtered: ${filters.join(", ")} buttons)`; } } console.log( - `Successfully converted ${input} to ${output} (${options.format} format)${filteringSummary}` + `Successfully converted ${input} to ${output} (${options.format} format)${filteringSummary}`, ); } catch (error) { console.error( - 'Error converting file:', - error instanceof Error ? error.message : String(error) + "Error converting file:", + error instanceof Error ? error.message : String(error), ); process.exit(1); } - } + }, ); program - .command('validate ') - .description('Validate an AAC file format') - .option('--format ', 'Format type (auto-detected if not specified)') - .option('--json', 'Output results as JSON') - .option('--quiet', 'Only output validation result (valid/invalid)') - .option('--gridset-password ', 'Password for encrypted Grid3 archives (.gridsetx)') + .command("validate ") + .description("Validate an AAC file format") + .option("--format ", "Format type (auto-detected if not specified)") + .option("--json", "Output results as JSON") + .option("--quiet", "Only output validation result (valid/invalid)") + .option( + "--gridset-password ", + "Password for encrypted Grid3 archives (.gridsetx)", + ) .action( async ( file: string, @@ -293,7 +354,7 @@ program json?: boolean; quiet?: boolean; gridsetPassword?: string; - } + }, ) => { try { // Auto-detect format if not specified @@ -309,7 +370,9 @@ program // Check if processor supports validation if (!processor.validate) { - console.error(`Error: Validation not supported for format '${format}'`); + console.error( + `Error: Validation not supported for format '${format}'`, + ); process.exit(1); } @@ -318,7 +381,7 @@ program // Output results if (options.quiet) { - console.log(result.valid ? 'valid' : 'invalid'); + console.log(result.valid ? "valid" : "invalid"); } else if (options.json) { console.log(JSON.stringify(result, null, 2)); } else { @@ -326,13 +389,13 @@ program console.log(`\nValidation Results for: ${result.filename}`); console.log(`Format: ${result.format}`); console.log(`File size: ${result.filesize} bytes`); - console.log(`Status: ${result.valid ? '✓ VALID' : '✗ INVALID'}`); + console.log(`Status: ${result.valid ? "✓ VALID" : "✗ INVALID"}`); console.log(`Errors: ${result.errors}`); console.log(`Warnings: ${result.warnings}\n`); if (result.errors > 0 || result.warnings > 0) { if (result.errors > 0) { - console.log('Errors:'); + console.log("Errors:"); result.results .filter((r) => !r.valid) .forEach((check) => { @@ -344,7 +407,7 @@ program } if (result.warnings > 0) { - console.log('\nWarnings:'); + console.log("\nWarnings:"); result.results.forEach((check) => { if (check.warnings && check.warnings.length > 0) { console.log(` ⚠ ${check.description}`); @@ -358,39 +421,39 @@ program // Show sub-results if available if (result.sub_results && result.sub_results.length > 0) { - console.log('\nSub-results:'); + console.log("\nSub-results:"); result.sub_results.forEach((sub, idx) => { console.log(` [${idx + 1}] ${sub.filename}`); console.log( - ` Status: ${sub.valid ? '✓' : '✗'} (${sub.errors} errors, ${sub.warnings} warnings)` + ` Status: ${sub.valid ? "✓" : "✗"} (${sub.errors} errors, ${sub.warnings} warnings)`, ); }); } - console.log(''); + console.log(""); } // Exit with appropriate code process.exit(result.valid ? 0 : 1); } catch (error) { console.error( - 'Error validating file:', - error instanceof Error ? error.message : String(error) + "Error validating file:", + error instanceof Error ? error.message : String(error), ); process.exit(1); } - } + }, ); program - .command('history ') - .option('--format ', 'Output format: raw or baton', 'raw') - .option('--out ', 'Write output to a file instead of stdout') - .option('--source ', 'History source: auto, grid3, snap', 'auto') - .option('--anonymous-uuid ', 'Anonymous UUID for baton export') - .option('--export-date ', 'Export date for baton export (ISO string)') - .option('--encryption ', 'Encryption label for baton export', 'none') - .option('--version ', 'Baton export version', '1.0') + .command("history ") + .option("--format ", "Output format: raw or baton", "raw") + .option("--out ", "Write output to a file instead of stdout") + .option("--source ", "History source: auto, grid3, snap", "auto") + .option("--anonymous-uuid ", "Anonymous UUID for baton export") + .option("--export-date ", "Export date for baton export (ISO string)") + .option("--encryption ", "Encryption label for baton export", "none") + .option("--version ", "Baton export version", "1.0") .action( async ( input: string, @@ -402,38 +465,48 @@ program exportDate?: string; encryption?: string; version?: string; - } + }, ) => { try { if (!(await pathExists(input))) { throw new Error(`File not found: ${input}`); } - const normalizedSource = (options.source || 'auto').toLowerCase(); + const normalizedSource = (options.source || "auto").toLowerCase(); const ext = extname(input).toLowerCase(); - const isGrid3Db = ext === '.sqlite' || basename(input).toLowerCase() === 'history.sqlite'; - const isSnap = ext === '.sps' || ext === '.spb'; + const isGrid3Db = + ext === ".sqlite" || + basename(input).toLowerCase() === "history.sqlite"; + const isSnap = ext === ".sps" || ext === ".spb"; let entries; - if (normalizedSource === 'grid3' || (normalizedSource === 'auto' && isGrid3Db)) { + if ( + normalizedSource === "grid3" || + (normalizedSource === "auto" && isGrid3Db) + ) { entries = await readGrid3History(input); - } else if (normalizedSource === 'snap' || (normalizedSource === 'auto' && isSnap)) { + } else if ( + normalizedSource === "snap" || + (normalizedSource === "auto" && isSnap) + ) { entries = await readSnapUsage(input); } else { - throw new Error('Unable to detect history source. Use --source grid3 or --source snap.'); + throw new Error( + "Unable to detect history source. Use --source grid3 or --source snap.", + ); } - const format = (options.format || 'raw').toLowerCase(); + const format = (options.format || "raw").toLowerCase(); let payload: unknown = entries; - if (format === 'baton') { + if (format === "baton") { payload = exportHistoryToBaton(entries, { version: options.version, exportDate: options.exportDate, encryption: options.encryption, anonymousUUID: options.anonymousUuid, }); - } else if (format !== 'raw') { + } else if (format !== "raw") { throw new Error(`Unsupported format: ${format}`); } @@ -445,35 +518,54 @@ program } } catch (error) { console.error( - 'Error exporting history:', - error instanceof Error ? error.message : String(error) + "Error exporting history:", + error instanceof Error ? error.message : String(error), ); process.exit(1); } - } + }, ); program - .command('metrics ') - .option('--format ', 'Format type (auto-detected if not specified)') - .option('--pretty', 'Pretty print JSON output') - .option('--out ', 'Write output to a file instead of stdout') - .option('--preserve-all-buttons', 'Preserve all buttons including navigation/system buttons') - .option('--no-exclude-navigation', "Don't exclude navigation buttons (Home, Back)") - .option('--no-exclude-system', "Don't exclude system buttons (Delete, Clear, etc.)") - .option('--exclude-buttons ', 'Comma-separated list of button labels/terms to exclude') - .option('--gridset-password ', 'Password for encrypted Grid3 archives (.gridsetx)') - .option('--access-method ', 'direct or scanning', 'direct') - .option('--scanning-pattern ', 'linear, row-column, or block', 'row-column') + .command("metrics ") + .option("--format ", "Format type (auto-detected if not specified)") + .option("--pretty", "Pretty print JSON output") + .option("--out ", "Write output to a file instead of stdout") + .option( + "--preserve-all-buttons", + "Preserve all buttons including navigation/system buttons", + ) .option( - '--selection-method ', - 'auto-1-switch, step-1-switch, or step-2-switch', - 'auto-1-switch' + "--no-exclude-navigation", + "Don't exclude navigation buttons (Home, Back)", ) - .option('--error-correction', 'Enable scanning error correction', false) - .option('--use-prediction', 'Enable prediction in CARE scoring', false) - .option('--no-smart-grammar', 'Disable smart grammar word forms') - .option('--care', 'Include CARE comparison output', false) + .option( + "--no-exclude-system", + "Don't exclude system buttons (Delete, Clear, etc.)", + ) + .option( + "--exclude-buttons ", + "Comma-separated list of button labels/terms to exclude", + ) + .option( + "--gridset-password ", + "Password for encrypted Grid3 archives (.gridsetx)", + ) + .option("--access-method ", "direct or scanning", "direct") + .option( + "--scanning-pattern ", + "linear, row-column, or block", + "row-column", + ) + .option( + "--selection-method ", + "auto-1-switch, step-1-switch, or step-2-switch", + "auto-1-switch", + ) + .option("--error-correction", "Enable scanning error correction", false) + .option("--use-prediction", "Enable prediction in CARE scoring", false) + .option("--no-smart-grammar", "Disable smart grammar word forms") + .option("--care", "Include CARE comparison output", false) .action( async ( file: string, @@ -493,7 +585,7 @@ program usePrediction?: boolean; smartGrammar?: boolean; care?: boolean; - } + }, ) => { try { const filteringOptions = parseFilteringOptions(options); @@ -501,44 +593,52 @@ program const processor = getProcessor(format, filteringOptions); const tree = await processor.loadIntoTree(file); - const accessMethod = (options.accessMethod || 'direct').toLowerCase(); - const scanningPattern = (options.scanningPattern || 'row-column').toLowerCase(); - const selectionMethodParam = (options.selectionMethod || 'auto-1-switch').toLowerCase(); + const accessMethod = (options.accessMethod || "direct").toLowerCase(); + const scanningPattern = ( + options.scanningPattern || "row-column" + ).toLowerCase(); + const selectionMethodParam = ( + options.selectionMethod || "auto-1-switch" + ).toLowerCase(); const errorCorrection = !!options.errorCorrection; let scanningConfig = undefined; - if (accessMethod === 'scanning') { + if (accessMethod === "scanning") { let cellScanningOrder = CellScanningOrder.SimpleScan; let blockScanEnabled = false; switch (scanningPattern) { - case 'linear': + case "linear": cellScanningOrder = CellScanningOrder.SimpleScan; break; - case 'row-column': + case "row-column": cellScanningOrder = CellScanningOrder.RowColumnScan; break; - case 'block': + case "block": cellScanningOrder = CellScanningOrder.RowColumnScan; blockScanEnabled = true; break; default: - throw new Error(`Unsupported scanning pattern: ${scanningPattern}`); + throw new Error( + `Unsupported scanning pattern: ${scanningPattern}`, + ); } let selectionMethod = ScanningSelectionMethod.AutoScan; switch (selectionMethodParam) { - case 'auto-1-switch': + case "auto-1-switch": selectionMethod = ScanningSelectionMethod.AutoScan; break; - case 'step-1-switch': + case "step-1-switch": selectionMethod = ScanningSelectionMethod.StepScan1Switch; break; - case 'step-2-switch': + case "step-2-switch": selectionMethod = ScanningSelectionMethod.StepScan2Switch; break; default: - throw new Error(`Unsupported selection method: ${selectionMethodParam}`); + throw new Error( + `Unsupported selection method: ${selectionMethodParam}`, + ); } scanningConfig = { @@ -577,7 +677,9 @@ program care, }; - const output = options.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result); + const output = options.pretty + ? JSON.stringify(result, null, 2) + : JSON.stringify(result); if (options.out) { await writeTextToPath(options.out, output); } else { @@ -585,12 +687,12 @@ program } } catch (error) { console.error( - 'Error calculating metrics:', - error instanceof Error ? error.message : String(error) + "Error calculating metrics:", + error instanceof Error ? error.message : String(error), ); process.exit(1); } - } + }, ); // Show help if no command provided diff --git a/src/cli/prettyPrint.ts b/src/cli/prettyPrint.ts index df480dd..8463f89 100644 --- a/src/cli/prettyPrint.ts +++ b/src/cli/prettyPrint.ts @@ -1,23 +1,23 @@ -import { AACTree } from '../core/treeStructure'; +import { AACTree } from "../core/treeStructure"; export function prettyPrintTree(tree: AACTree): string { - let output = ''; + let output = ""; for (const pageId in tree.pages) { const page = tree.pages[pageId]; output += `Page: ${page.name} (ID: ${page.id})\n`; if (!page.buttons || page.buttons.length === 0) { - output += ' (no buttons)\n'; + output += " (no buttons)\n"; } else { for (const btn of page.buttons) { const intentStr = String(btn.semanticAction?.intent); - const isNavigate = intentStr === 'NAVIGATE_TO' || !!btn.targetPageId; - const buttonType = isNavigate ? 'NAVIGATE' : 'SPEAK'; + const isNavigate = intentStr === "NAVIGATE_TO" || !!btn.targetPageId; + const buttonType = isNavigate ? "NAVIGATE" : "SPEAK"; output += ` - Button: ${JSON.stringify(btn.label)} [${buttonType}`; if (isNavigate) { const target = btn.semanticAction?.targetId || btn.targetPageId; if (target) output += ` to page: ${target}`; } - output += ']\n'; + output += "]\n"; } } } diff --git a/src/core/analyze.ts b/src/core/analyze.ts index f9335ed..d9ae2b7 100644 --- a/src/core/analyze.ts +++ b/src/core/analyze.ts @@ -1,52 +1,55 @@ -import { OpmlProcessor } from '../processors/opmlProcessor'; -import { ObfProcessor } from '../processors/obfProcessor'; -import { TouchChatProcessor } from '../processors/touchchatProcessor'; -import { GridsetProcessor } from '../processors/gridsetProcessor'; -import { AstericsGridProcessor } from '../processors/astericsGridProcessor'; -import { SnapProcessor } from '../processors/snapProcessor'; -import { DotProcessor } from '../processors/dotProcessor'; -import { ExcelProcessor } from '../processors/excelProcessor'; -import { ApplePanelsProcessor } from '../processors/applePanelsProcessor'; -import { AACTree } from './treeStructure'; -import { BaseProcessor, ProcessorOptions } from './baseProcessor'; +import { OpmlProcessor } from "../processors/opmlProcessor"; +import { ObfProcessor } from "../processors/obfProcessor"; +import { TouchChatProcessor } from "../processors/touchchatProcessor"; +import { GridsetProcessor } from "../processors/gridsetProcessor"; +import { AstericsGridProcessor } from "../processors/astericsGridProcessor"; +import { SnapProcessor } from "../processors/snapProcessor"; +import { DotProcessor } from "../processors/dotProcessor"; +import { ExcelProcessor } from "../processors/excelProcessor"; +import { ApplePanelsProcessor } from "../processors/applePanelsProcessor"; +import { AACTree } from "./treeStructure"; +import { BaseProcessor, ProcessorOptions } from "./baseProcessor"; /** * Resolve a processor instance by friendly format name or common extension. * @param format Format key or extension (e.g., 'snap', 'obf', 'xlsx') * @param options Optional processor configuration */ -export function getProcessor(format: string, options?: ProcessorOptions): BaseProcessor { - const normalizedFormat = (format || '').toLowerCase(); +export function getProcessor( + format: string, + options?: ProcessorOptions, +): BaseProcessor { + const normalizedFormat = (format || "").toLowerCase(); switch (normalizedFormat) { - case 'opml': + case "opml": return new OpmlProcessor(options); - case 'obf': - case 'obfset': // Obfset files use ObfProcessor + case "obf": + case "obfset": // Obfset files use ObfProcessor return new ObfProcessor(options); - case 'touchchat': - case 'ce': // TouchChat file extension + case "touchchat": + case "ce": // TouchChat file extension return new TouchChatProcessor(options); - case 'gridset': - case 'gridsetx': + case "gridset": + case "gridsetx": return new GridsetProcessor(options); // Grid3 format - case 'grd': // Asterics Grid file extension + case "grd": // Asterics Grid file extension return new AstericsGridProcessor(options); - case 'snap': - case 'sps': // Snap file extension - case 'spb': // Snap backup file extension + case "snap": + case "sps": // Snap file extension + case "spb": // Snap backup file extension return new SnapProcessor(options); - case 'dot': + case "dot": return new DotProcessor(options); - case 'excel': - case 'xlsx': // Excel file extension + case "excel": + case "xlsx": // Excel file extension return new ExcelProcessor(options); - case 'applepanels': - case 'panels': // Apple Panels file extension - case 'ascconfig': // Apple Panels folder format + case "applepanels": + case "panels": // Apple Panels file extension + case "ascconfig": // Apple Panels folder format return new ApplePanelsProcessor(options); default: - throw new Error('Unknown format: ' + format); + throw new Error("Unknown format: " + format); } } @@ -55,7 +58,10 @@ export function getProcessor(format: string, options?: ProcessorOptions): BasePr * @param file Path to the source file * @param format Format key or extension (passed to getProcessor) */ -export async function analyze(file: string, format: string): Promise<{ tree: AACTree }> { +export async function analyze( + file: string, + format: string, +): Promise<{ tree: AACTree }> { const processor = getProcessor(format); const tree = await processor.loadIntoTree(file); return { tree }; diff --git a/src/core/baseProcessor.ts b/src/core/baseProcessor.ts index 0487184..fd4ac73 100644 --- a/src/core/baseProcessor.ts +++ b/src/core/baseProcessor.ts @@ -40,11 +40,16 @@ * See `src/utilities/translation/translationProcessor.ts` for shared utilities. */ -import { AACTree, AACButton, AACSemanticCategory } from './treeStructure'; -import { StringCasing, detectCasing, isNumericOrEmpty } from './stringCasing'; -import { ValidationResult } from '../validation/validationTypes'; -import { BinaryOutput, defaultFileAdapter, FileAdapter, ProcessorInput } from '../utils/io'; -import { getZipAdapter, ZipAdapter } from '../utils/zip'; +import { AACTree, AACButton, AACSemanticCategory } from "./treeStructure"; +import { StringCasing, detectCasing, isNumericOrEmpty } from "./stringCasing"; +import { ValidationResult } from "../validation/validationTypes"; +import { + BinaryOutput, + defaultFileAdapter, + FileAdapter, + ProcessorInput, +} from "../utils/io"; +import { getZipAdapter, ZipAdapter } from "../utils/zip"; // Configuration options for processors export interface ProcessorConfig { @@ -77,7 +82,10 @@ export interface ProcessorConfig { fileAdapter: FileAdapter; // Adapter for handling encoding/decoding zip files - zipAdapter: (input?: ProcessorInput, fileAdapter?: FileAdapter) => Promise; + zipAdapter: ( + input?: ProcessorInput, + fileAdapter?: FileAdapter, + ) => Promise; } export type ProcessorOptions = Partial; @@ -100,7 +108,7 @@ export interface VocabLocation { export interface ProcessingError { message: string; - step: 'EXTRACT' | 'PROCESS' | 'SAVE'; + step: "EXTRACT" | "PROCESS" | "SAVE"; } export interface ExtractStringsResult { @@ -145,7 +153,7 @@ abstract class BaseProcessor { abstract processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, ): Promise; // Save tree structure back to file/buffer @@ -174,7 +182,7 @@ abstract class BaseProcessor { generateTranslatedDownload?( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise; // Helper method to determine if a button should be filtered out @@ -196,13 +204,16 @@ abstract class BaseProcessor { // Filter specific navigation intents (toolbar navigation only) if (this.options.excludeNavigationButtons) { const i = String(intent); - if (i === 'GO_BACK' || i === 'GO_HOME') { + if (i === "GO_BACK" || i === "GO_HOME") { return true; } } // Filter system/text editing buttons by category - if (this.options.excludeSystemButtons && category === AACSemanticCategory.TEXT_EDITING) { + if ( + this.options.excludeSystemButtons && + category === AACSemanticCategory.TEXT_EDITING + ) { return true; } @@ -210,10 +221,10 @@ abstract class BaseProcessor { if (this.options.excludeSystemButtons) { const i = String(intent); if ( - i === 'DELETE_WORD' || - i === 'DELETE_CHARACTER' || - i === 'CLEAR_TEXT' || - i === 'COPY_TEXT' + i === "DELETE_WORD" || + i === "DELETE_CHARACTER" || + i === "CLEAR_TEXT" || + i === "COPY_TEXT" ) { return true; } @@ -224,25 +235,30 @@ abstract class BaseProcessor { // Only apply label-based filtering if button doesn't have semantic actions if ( !button.semanticAction && - (this.options.excludeNavigationButtons || this.options.excludeSystemButtons) + (this.options.excludeNavigationButtons || + this.options.excludeSystemButtons) ) { - const label = button.label?.toLowerCase() || ''; - const message = button.message?.toLowerCase() || ''; + const label = button.label?.toLowerCase() || ""; + const message = button.message?.toLowerCase() || ""; // More conservative navigation terms (exclude "more" since it's often used for legitimate page navigation) - const navigationTerms = ['back', 'home', 'menu', 'settings']; - const systemTerms = ['delete', 'clear', 'copy', 'paste', 'undo', 'redo']; + const navigationTerms = ["back", "home", "menu", "settings"]; + const systemTerms = ["delete", "clear", "copy", "paste", "undo", "redo"]; if ( this.options.excludeNavigationButtons && - navigationTerms.some((term) => label.includes(term) || message.includes(term)) + navigationTerms.some( + (term) => label.includes(term) || message.includes(term), + ) ) { return true; } if ( this.options.excludeSystemButtons && - systemTerms.some((term) => label.includes(term) || message.includes(term)) + systemTerms.some( + (term) => label.includes(term) || message.includes(term), + ) ) { return true; } @@ -263,7 +279,7 @@ abstract class BaseProcessor { * @returns Promise with extracted strings and metadata */ protected async extractStringsWithMetadataGeneric( - filePath: string + filePath: string, ): Promise { try { const tree = await this.loadIntoTree(filePath); @@ -272,30 +288,48 @@ abstract class BaseProcessor { // Process all pages and buttons Object.values(tree.pages).forEach((page) => { // Process page names - if (page.name && page.name.trim().length > 1 && !isNumericOrEmpty(page.name)) { + if ( + page.name && + page.name.trim().length > 1 && + !isNumericOrEmpty(page.name) + ) { const key = page.name.trim().toLowerCase(); const vocabLocation: VocabLocation = { - table: 'pages', + table: "pages", id: page.id, - column: 'NAME', + column: "NAME", casing: detectCasing(page.name), }; - this.addToExtractedMap(extractedMap, key, page.name.trim(), vocabLocation); + this.addToExtractedMap( + extractedMap, + key, + page.name.trim(), + vocabLocation, + ); } page.buttons.forEach((button) => { // Process button labels - if (button.label && button.label.trim().length > 1 && !isNumericOrEmpty(button.label)) { + if ( + button.label && + button.label.trim().length > 1 && + !isNumericOrEmpty(button.label) + ) { const key = button.label.trim().toLowerCase(); const vocabLocation: VocabLocation = { - table: 'buttons', + table: "buttons", id: button.id, - column: 'LABEL', + column: "LABEL", casing: detectCasing(button.label), }; - this.addToExtractedMap(extractedMap, key, button.label.trim(), vocabLocation); + this.addToExtractedMap( + extractedMap, + key, + button.label.trim(), + vocabLocation, + ); } // Process button messages (if different from label) @@ -307,13 +341,18 @@ abstract class BaseProcessor { ) { const key = button.message.trim().toLowerCase(); const vocabLocation: VocabLocation = { - table: 'buttons', + table: "buttons", id: button.id, - column: 'MESSAGE', + column: "MESSAGE", casing: detectCasing(button.message), }; - this.addToExtractedMap(extractedMap, key, button.message.trim(), vocabLocation); + this.addToExtractedMap( + extractedMap, + key, + button.message.trim(), + vocabLocation, + ); } }); }); @@ -324,8 +363,11 @@ abstract class BaseProcessor { return { errors: [ { - message: error instanceof Error ? error.message : 'Unknown extraction error', - step: 'EXTRACT' as const, + message: + error instanceof Error + ? error.message + : "Unknown extraction error", + step: "EXTRACT" as const, }, ], extractedStrings: [], @@ -344,14 +386,14 @@ abstract class BaseProcessor { protected async generateTranslatedDownloadGeneric( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { // Build translation map from the provided data const translations = new Map(); sourceStrings.forEach((sourceString) => { const translated = translatedStrings.find( - (ts) => ts.sourcestringid.toString() === sourceString.id.toString() + (ts) => ts.sourcestringid.toString() === sourceString.id.toString(), ); if (translated) { @@ -383,7 +425,7 @@ abstract class BaseProcessor { extractedMap: Map, key: string, originalString: string, - vocabLocation: VocabLocation + vocabLocation: VocabLocation, ): void { const existing = extractedMap.get(key); if (existing) { @@ -404,9 +446,9 @@ abstract class BaseProcessor { * @returns Path for the translated output file */ protected generateTranslatedOutputPath(filePath: string): string { - const lastDotIndex = filePath.lastIndexOf('.'); + const lastDotIndex = filePath.lastIndexOf("."); if (lastDotIndex === -1) { - return filePath + '_translated'; + return filePath + "_translated"; } const basePath = filePath.substring(0, lastDotIndex); diff --git a/src/core/stringCasing.ts b/src/core/stringCasing.ts index 41b68de..d07e8f1 100644 --- a/src/core/stringCasing.ts +++ b/src/core/stringCasing.ts @@ -4,17 +4,17 @@ */ export enum StringCasing { - LOWER = 'lower', - SNAKE = 'snake', - CONSTANT = 'constant', - CAMEL = 'camel', - UPPER = 'upper', - KEBAB = 'kebab', - CAPITAL = 'capital', - HEADER = 'header', - PASCAL = 'pascal', - TITLE = 'title', - SENTENCE = 'sentence', + LOWER = "lower", + SNAKE = "snake", + CONSTANT = "constant", + CAMEL = "camel", + UPPER = "upper", + KEBAB = "kebab", + CAPITAL = "capital", + HEADER = "header", + PASCAL = "pascal", + TITLE = "title", + SENTENCE = "sentence", } /** @@ -35,17 +35,17 @@ export function detectCasing(text: string): StringCasing { // Check for specific patterns // CONSTANT_CASE (ALL_CAPS_WITH_UNDERSCORES) - if (/^[A-Z][A-Z0-9_]*$/.test(trimmed) && trimmed.includes('_')) { + if (/^[A-Z][A-Z0-9_]*$/.test(trimmed) && trimmed.includes("_")) { return StringCasing.CONSTANT; } // snake_case (lowercase_with_underscores) - if (/^[a-z][a-z0-9_]*$/.test(trimmed) && trimmed.includes('_')) { + if (/^[a-z][a-z0-9_]*$/.test(trimmed) && trimmed.includes("_")) { return StringCasing.SNAKE; } // kebab-case (lowercase-with-hyphens) - if (/^[a-z][a-z0-9-]*$/.test(trimmed) && trimmed.includes('-')) { + if (/^[a-z][a-z0-9-]*$/.test(trimmed) && trimmed.includes("-")) { return StringCasing.KEBAB; } @@ -64,7 +64,11 @@ export function detectCasing(text: string): StringCasing { } // UPPER CASE (ALL UPPERCASE) - but only if more than one character - if (trimmed === trimmed.toUpperCase() && /[A-Z]/.test(trimmed) && trimmed.length > 1) { + if ( + trimmed === trimmed.toUpperCase() && + /[A-Z]/.test(trimmed) && + trimmed.length > 1 + ) { return StringCasing.UPPER; } @@ -81,22 +85,22 @@ export function detectCasing(text: string): StringCasing { (word) => word.length > 0 && word[0] === word[0].toUpperCase() && - (word.length === 1 || word.slice(1) === word.slice(1).toLowerCase()) + (word.length === 1 || word.slice(1) === word.slice(1).toLowerCase()), ) ) { return StringCasing.TITLE; } // Header-Case (First-Letter-Of-Each-Word-Capitalized-With-Hyphens) - if (trimmed.includes('-')) { - const hyphenWords = trimmed.split('-'); + if (trimmed.includes("-")) { + const hyphenWords = trimmed.split("-"); if ( hyphenWords.length > 1 && hyphenWords.every( (word) => word.length > 0 && word[0] === word[0].toUpperCase() && - (word.length === 1 || word.slice(1) === word.slice(1).toLowerCase()) + (word.length === 1 || word.slice(1) === word.slice(1).toLowerCase()), ) ) { return StringCasing.HEADER; @@ -132,7 +136,10 @@ export function detectCasing(text: string): StringCasing { * @param text Input string * @param targetCasing Desired casing variant */ -export function convertCasing(text: string, targetCasing: StringCasing): string { +export function convertCasing( + text: string, + targetCasing: StringCasing, +): string { if (!text || text.length === 0) return text; const trimmed = text.trim(); @@ -154,8 +161,10 @@ export function convertCasing(text: string, targetCasing: StringCasing): string case StringCasing.TITLE: return trimmed .split(/\s+/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(' '); + .map( + (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ) + .join(" "); case StringCasing.CAMEL: return trimmed @@ -163,39 +172,43 @@ export function convertCasing(text: string, targetCasing: StringCasing): string .map((word, index) => index === 0 ? word.toLowerCase() - : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), ) - .join(''); + .join(""); case StringCasing.PASCAL: return trimmed .split(/[\s_-]+/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(''); + .map( + (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ) + .join(""); case StringCasing.SNAKE: return trimmed .split(/[\s-]+/) .map((word) => word.toLowerCase()) - .join('_'); + .join("_"); case StringCasing.CONSTANT: return trimmed .split(/[\s-]+/) .map((word) => word.toUpperCase()) - .join('_'); + .join("_"); case StringCasing.KEBAB: return trimmed .split(/[\s_]+/) .map((word) => word.toLowerCase()) - .join('-'); + .join("-"); case StringCasing.HEADER: return trimmed .split(/[\s_]+/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join('-'); + .map( + (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ) + .join("-"); default: return trimmed; diff --git a/src/core/treeStructure.ts b/src/core/treeStructure.ts index a346ce5..29bba19 100644 --- a/src/core/treeStructure.ts +++ b/src/core/treeStructure.ts @@ -8,7 +8,7 @@ import type { TouchChatMetadata, CellScanningOrder, ScanningSelectionMethod, -} from '../types/aac'; +} from "../types/aac"; // Re-export for consumers export type { @@ -24,60 +24,60 @@ export type { // Semantic action categories for cross-platform compatibility export enum AACSemanticCategory { - COMMUNICATION = 'communication', // Speech, text output - NAVIGATION = 'navigation', // Page/grid navigation - TEXT_EDITING = 'text_editing', // Text manipulation - SYSTEM_CONTROL = 'system_control', // Device/app control - MEDIA = 'media', // Audio/video playback - ACCESSIBILITY = 'accessibility', // Switch scanning, etc. - CUSTOM = 'custom', // Platform-specific extensions + COMMUNICATION = "communication", // Speech, text output + NAVIGATION = "navigation", // Page/grid navigation + TEXT_EDITING = "text_editing", // Text manipulation + SYSTEM_CONTROL = "system_control", // Device/app control + MEDIA = "media", // Audio/video playback + ACCESSIBILITY = "accessibility", // Switch scanning, etc. + CUSTOM = "custom", // Platform-specific extensions } // Semantic intents within each category export enum AACSemanticIntent { // Communication - SPEAK_TEXT = 'SPEAK_TEXT', - SPEAK_IMMEDIATE = 'SPEAK_IMMEDIATE', - STOP_SPEECH = 'STOP_SPEECH', - INSERT_TEXT = 'INSERT_TEXT', + SPEAK_TEXT = "SPEAK_TEXT", + SPEAK_IMMEDIATE = "SPEAK_IMMEDIATE", + STOP_SPEECH = "STOP_SPEECH", + INSERT_TEXT = "INSERT_TEXT", // Navigation - NAVIGATE_TO = 'NAVIGATE_TO', - GO_BACK = 'GO_BACK', - GO_HOME = 'GO_HOME', + NAVIGATE_TO = "NAVIGATE_TO", + GO_BACK = "GO_BACK", + GO_HOME = "GO_HOME", // Text Editing - DELETE_WORD = 'DELETE_WORD', - DELETE_CHARACTER = 'DELETE_CHARACTER', - CLEAR_TEXT = 'CLEAR_TEXT', - COPY_TEXT = 'COPY_TEXT', - PASTE_TEXT = 'PASTE_TEXT', + DELETE_WORD = "DELETE_WORD", + DELETE_CHARACTER = "DELETE_CHARACTER", + CLEAR_TEXT = "CLEAR_TEXT", + COPY_TEXT = "COPY_TEXT", + PASTE_TEXT = "PASTE_TEXT", // System Control - SEND_KEYS = 'SEND_KEYS', - MOUSE_CLICK = 'MOUSE_CLICK', + SEND_KEYS = "SEND_KEYS", + MOUSE_CLICK = "MOUSE_CLICK", // Media - PLAY_SOUND = 'PLAY_SOUND', - PLAY_VIDEO = 'PLAY_VIDEO', + PLAY_SOUND = "PLAY_SOUND", + PLAY_VIDEO = "PLAY_VIDEO", // Accessibility - SCAN_NEXT = 'SCAN_NEXT', - SCAN_SELECT = 'SCAN_SELECT', + SCAN_NEXT = "SCAN_NEXT", + SCAN_SELECT = "SCAN_SELECT", // Custom - PLATFORM_SPECIFIC = 'PLATFORM_SPECIFIC', + PLATFORM_SPECIFIC = "PLATFORM_SPECIFIC", } /** * Scanning types for accessibility */ export enum AACScanType { - LINEAR = 'linear', // Left-to-right, top-to-bottom - ROW_COLUMN = 'row-column', // Scan rows, then columns - COLUMN_ROW = 'column-row', // Scan columns, then rows - BLOCK_ROW_COLUMN = 'block-row-column', // Scan blocks, then rows, then columns - BLOCK_COLUMN_ROW = 'block-column-row', // Scan blocks, then columns, then rows + LINEAR = "linear", // Left-to-right, top-to-bottom + ROW_COLUMN = "row-column", // Scan rows, then columns + COLUMN_ROW = "column-row", // Scan columns, then rows + BLOCK_ROW_COLUMN = "block-row-column", // Scan blocks, then rows, then columns + BLOCK_COLUMN_ROW = "block-column-row", // Scan blocks, then columns, then rows } /** @@ -143,7 +143,7 @@ export interface AACSemanticAction { // Fallback for unknown platforms fallback?: { - type: 'SPEAK' | 'NAVIGATE' | 'ACTION'; + type: "SPEAK" | "NAVIGATE" | "ACTION"; message?: string; targetPageId?: string; temporary_home?: boolean | string | null; @@ -169,7 +169,7 @@ export class AACButton { }; // Extended properties for advanced platforms - contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell'; + contentType?: "Normal" | "AutoContent" | "Workspace" | "LiveCell"; contentSubType?: string; image?: string; resolvedImageEntry?: string; // normalized zip path to resolved image, if present @@ -187,7 +187,12 @@ export class AACButton { * Scan block number (1-8) for block scanning */ scanBlock?: number; - visibility?: 'Visible' | 'Hidden' | 'Disabled' | 'PointerAndTouchOnly' | 'Empty'; + visibility?: + | "Visible" + | "Hidden" + | "Disabled" + | "PointerAndTouchOnly" + | "Empty"; directActivate?: boolean; audioDescription?: string; parameters?: { [key: string]: any }; @@ -207,8 +212,8 @@ export class AACButton { constructor({ id, - label = '', - message = '', + label = "", + message = "", targetPageId, semanticAction, audioRecording, @@ -249,7 +254,7 @@ export class AACButton { metadata?: string; }; style?: AACStyle; - contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell'; + contentType?: "Normal" | "AutoContent" | "Workspace" | "LiveCell"; contentSubType?: string; image?: string; resolvedImageEntry?: string; @@ -261,7 +266,12 @@ export class AACButton { rowSpan?: number; scanBlocks?: number[]; scanBlock?: number; - visibility?: 'Visible' | 'Hidden' | 'Disabled' | 'PointerAndTouchOnly' | 'Empty'; + visibility?: + | "Visible" + | "Hidden" + | "Disabled" + | "PointerAndTouchOnly" + | "Empty"; directActivate?: boolean; parameters?: { [key: string]: any }; predictions?: string[]; @@ -276,9 +286,9 @@ export class AACButton { semantic_id?: string; clone_id?: string; // Legacy constructor properties for backward compatibility - type?: 'SPEAK' | 'NAVIGATE' | 'ACTION'; + type?: "SPEAK" | "NAVIGATE" | "ACTION"; action?: { - type: 'SPEAK' | 'NAVIGATE' | 'ACTION'; + type: "SPEAK" | "NAVIGATE" | "ACTION"; targetPageId?: string; message?: string; } | null; @@ -313,80 +323,83 @@ export class AACButton { // Legacy mapping: if no semanticAction provided, derive from legacy `action` first if (!this.semanticAction && action) { - if (action.type === 'NAVIGATE' && (action.targetPageId || this.targetPageId)) { + if ( + action.type === "NAVIGATE" && + (action.targetPageId || this.targetPageId) + ) { if (!this.targetPageId) this.targetPageId = action.targetPageId; this.semanticAction = { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.NAVIGATE_TO, targetId: this.targetPageId, - fallback: { type: 'NAVIGATE', targetPageId: this.targetPageId }, + fallback: { type: "NAVIGATE", targetPageId: this.targetPageId }, }; - } else if (action.type === 'SPEAK') { - const text = action.message || this.message || this.label || ''; + } else if (action.type === "SPEAK") { + const text = action.message || this.message || this.label || ""; if (!this.message) this.message = text; this.semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, text, - fallback: { type: 'SPEAK', message: text }, + fallback: { type: "SPEAK", message: text }, }; } else { this.semanticAction = { category: AACSemanticCategory.SYSTEM_CONTROL, intent: AACSemanticIntent.PLATFORM_SPECIFIC, - fallback: { type: 'ACTION' }, + fallback: { type: "ACTION" }, }; } } // Legacy mapping: if still no semanticAction and `type` provided if (!this.semanticAction && type) { - if (type === 'NAVIGATE' && this.targetPageId) { + if (type === "NAVIGATE" && this.targetPageId) { this.semanticAction = { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.NAVIGATE_TO, targetId: this.targetPageId, - fallback: { type: 'NAVIGATE', targetPageId: this.targetPageId }, + fallback: { type: "NAVIGATE", targetPageId: this.targetPageId }, }; - } else if (type === 'SPEAK') { - const text = this.message || this.label || ''; + } else if (type === "SPEAK") { + const text = this.message || this.label || ""; this.semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, text, - fallback: { type: 'SPEAK', message: text }, + fallback: { type: "SPEAK", message: text }, }; } else { this.semanticAction = { category: AACSemanticCategory.SYSTEM_CONTROL, intent: AACSemanticIntent.PLATFORM_SPECIFIC, - fallback: { type: 'ACTION' }, + fallback: { type: "ACTION" }, }; } } } // Legacy compatibility properties - get type(): 'SPEAK' | 'NAVIGATE' | 'ACTION' | undefined { + get type(): "SPEAK" | "NAVIGATE" | "ACTION" | undefined { if (this.semanticAction) { const i = String(this.semanticAction.intent); - if (i === 'NAVIGATE_TO') return 'NAVIGATE'; - if (i === 'SPEAK_TEXT' || i === 'SPEAK_IMMEDIATE') return 'SPEAK'; - return 'ACTION'; + if (i === "NAVIGATE_TO") return "NAVIGATE"; + if (i === "SPEAK_TEXT" || i === "SPEAK_IMMEDIATE") return "SPEAK"; + return "ACTION"; } - if (this.targetPageId) return 'NAVIGATE'; - if (this.message) return 'SPEAK'; - return 'SPEAK'; + if (this.targetPageId) return "NAVIGATE"; + if (this.message) return "SPEAK"; + return "SPEAK"; } get action(): { - type: 'SPEAK' | 'NAVIGATE' | 'ACTION'; + type: "SPEAK" | "NAVIGATE" | "ACTION"; targetPageId?: string; message?: string; } | null { const t = this.type; if (!t) return null; - if (t === 'SPEAK' && !this.message && !this.label && !this.semanticAction) { + if (t === "SPEAK" && !this.message && !this.label && !this.semanticAction) { return null; } return { type: t, targetPageId: this.targetPageId, message: this.message }; @@ -408,7 +421,7 @@ export class AACPage { semantic_ids?: string[]; clone_ids?: string[]; // Scanning configuration for this page - scanningConfig?: import('../types/aac').ScanningConfig; + scanningConfig?: import("../types/aac").ScanningConfig; // Scanning support scanType?: AACScanType; @@ -416,7 +429,7 @@ export class AACPage { constructor({ id, - name = '', + name = "", grid = [], buttons = [], parentId = null, @@ -443,7 +456,7 @@ export class AACPage { sounds?: any[]; semantic_ids?: string[]; clone_ids?: string[]; - scanningConfig?: import('../types/aac').ScanningConfig; + scanningConfig?: import("../types/aac").ScanningConfig; scanBlocksConfig?: AACScanBlock[]; scanType?: AACScanType; }) { @@ -451,10 +464,17 @@ export class AACPage { this.name = name; if (Array.isArray(grid)) { this.grid = grid; - } else if (grid && typeof grid === 'object' && 'columns' in grid && 'rows' in grid) { + } else if ( + grid && + typeof grid === "object" && + "columns" in grid && + "rows" in grid + ) { const cols = (grid as any).columns as number; const rows = (grid as any).rows as number; - this.grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => null)); + this.grid = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => null), + ); } else { this.grid = []; } @@ -532,7 +552,11 @@ export class AACTree { page.buttons .filter((b) => { const i = String(b.semanticAction?.intent); - return i === 'NAVIGATE_TO' || !!b.semanticAction?.targetId || !!b.targetPageId; + return ( + i === "NAVIGATE_TO" || + !!b.semanticAction?.targetId || + !!b.targetPageId + ); }) .forEach((b) => { const target = b.semanticAction?.targetId || b.targetPageId; diff --git a/src/dot.ts b/src/dot.ts index 7d05161..80a94cf 100644 --- a/src/dot.ts +++ b/src/dot.ts @@ -5,7 +5,7 @@ */ // Processor class -export { DotProcessor } from './processors/dotProcessor'; +export { DotProcessor } from "./processors/dotProcessor"; // Note: DOT doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/excel.ts b/src/excel.ts index 13a8020..467388c 100644 --- a/src/excel.ts +++ b/src/excel.ts @@ -5,7 +5,7 @@ */ // Processor class -export { ExcelProcessor } from './processors/excelProcessor'; +export { ExcelProcessor } from "./processors/excelProcessor"; // Note: Excel doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/gridset.ts b/src/gridset.ts index 8d925a8..6a40825 100644 --- a/src/gridset.ts +++ b/src/gridset.ts @@ -6,7 +6,7 @@ */ // Processor class -export { GridsetProcessor } from './processors/gridsetProcessor'; +export { GridsetProcessor } from "./processors/gridsetProcessor"; // === User & File System Helpers === export { @@ -29,7 +29,7 @@ export { type Grid3UserPath, type Grid3VocabularyPath, type Grid3HistoryEntry, -} from './processors/gridset/helpers'; +} from "./processors/gridset/helpers"; // === Wordlist Management === export { @@ -39,7 +39,7 @@ export { wordlistToXml, type WordList, type WordListItem, -} from './processors/gridset/wordlistHelpers'; +} from "./processors/gridset/wordlistHelpers"; // === Color Utilities === export { @@ -52,7 +52,7 @@ export { darkenColor, normalizeColor, ensureAlphaChannel, -} from './processors/gridset/colorUtils'; +} from "./processors/gridset/colorUtils"; // === Style Helpers === export { @@ -63,7 +63,7 @@ export { CellBackgroundShape, SHAPE_NAMES, ensureAlphaChannel as ensureAlphaChannelFromStyles, -} from './processors/gridset/styleHelpers'; +} from "./processors/gridset/styleHelpers"; // === Plugin & Workspace Detection === export { @@ -78,7 +78,7 @@ export { WORKSPACE_TYPES, LIVECELL_TYPES, AUTOCONTENT_TYPES, -} from './processors/gridset/pluginTypes'; +} from "./processors/gridset/pluginTypes"; // === Command Detection === export { @@ -94,7 +94,7 @@ export { type CommandParameter, type ExtractedParameters, Grid3CommandCategory, -} from './processors/gridset/commands'; +} from "./processors/gridset/commands"; // === Symbol Libraries === export { @@ -121,7 +121,7 @@ export { // Backward compatibility getSymbolsDir, getSymbolSearchDir, -} from './processors/gridset/index'; +} from "./processors/gridset/index"; // === Symbol Extraction === export { @@ -132,7 +132,7 @@ export { suggestExtractionStrategy, exportSymbolReferencesToCsv, createSymbolManifest, -} from './processors/gridset/symbolExtractor'; +} from "./processors/gridset/symbolExtractor"; // === Symbol Search === export { @@ -146,13 +146,13 @@ export { getSearchSuggestions, countLibrarySymbols, getSymbolSearchStats, -} from './processors/gridset/symbolSearch'; +} from "./processors/gridset/symbolSearch"; // === Password Management === export { resolveGridsetPassword, resolveGridsetPasswordFromEnv, -} from './processors/gridset/password'; +} from "./processors/gridset/password"; // === Image Debugging === export { @@ -160,4 +160,4 @@ export { formatImageAuditSummary, type ImageAuditResult, type ImageIssue, -} from './processors/gridset/imageDebug'; +} from "./processors/gridset/imageDebug"; diff --git a/src/index.browser.ts b/src/index.browser.ts index b54b183..590ede7 100644 --- a/src/index.browser.ts +++ b/src/index.browser.ts @@ -15,39 +15,39 @@ // =================================================================== // CORE TYPES // =================================================================== -export * from './core/treeStructure'; -export * from './core/baseProcessor'; -export * from './core/stringCasing'; +export * from "./core/treeStructure"; +export * from "./core/baseProcessor"; +export * from "./core/stringCasing"; // =================================================================== // BROWSER-SAFE PROCESSORS // =================================================================== -export { DotProcessor } from './processors/dotProcessor'; -export { OpmlProcessor } from './processors/opmlProcessor'; -export { ObfProcessor } from './processors/obfProcessor'; -export { GridsetProcessor } from './processors/gridsetProcessor'; -export { SnapProcessor } from './processors/snapProcessor'; -export { TouchChatProcessor } from './processors/touchchatProcessor'; -export { ApplePanelsProcessor } from './processors/applePanelsProcessor'; -export { AstericsGridProcessor } from './processors/astericsGridProcessor'; +export { DotProcessor } from "./processors/dotProcessor"; +export { OpmlProcessor } from "./processors/opmlProcessor"; +export { ObfProcessor } from "./processors/obfProcessor"; +export { GridsetProcessor } from "./processors/gridsetProcessor"; +export { SnapProcessor } from "./processors/snapProcessor"; +export { TouchChatProcessor } from "./processors/touchchatProcessor"; +export { ApplePanelsProcessor } from "./processors/applePanelsProcessor"; +export { AstericsGridProcessor } from "./processors/astericsGridProcessor"; // =================================================================== // UTILITY FUNCTIONS // =================================================================== // Metrics namespace (pageset analytics) -export * as Metrics from './metrics'; +export * as Metrics from "./metrics"; -import { BaseProcessor, ProcessorOptions } from './core/baseProcessor'; -import { DotProcessor } from './processors/dotProcessor'; -import { OpmlProcessor } from './processors/opmlProcessor'; -import { ObfProcessor } from './processors/obfProcessor'; -import { GridsetProcessor } from './processors/gridsetProcessor'; -import { SnapProcessor } from './processors/snapProcessor'; -import { TouchChatProcessor } from './processors/touchchatProcessor'; -import { ApplePanelsProcessor } from './processors/applePanelsProcessor'; -import { AstericsGridProcessor } from './processors/astericsGridProcessor'; -export { configureSqlJs } from './utils/sqlite'; +import { BaseProcessor, ProcessorOptions } from "./core/baseProcessor"; +import { DotProcessor } from "./processors/dotProcessor"; +import { OpmlProcessor } from "./processors/opmlProcessor"; +import { ObfProcessor } from "./processors/obfProcessor"; +import { GridsetProcessor } from "./processors/gridsetProcessor"; +import { SnapProcessor } from "./processors/snapProcessor"; +import { TouchChatProcessor } from "./processors/touchchatProcessor"; +import { ApplePanelsProcessor } from "./processors/applePanelsProcessor"; +import { AstericsGridProcessor } from "./processors/astericsGridProcessor"; +export { configureSqlJs } from "./utils/sqlite"; /** * Factory function to get the appropriate processor for a file extension @@ -57,30 +57,30 @@ export { configureSqlJs } from './utils/sqlite'; */ export function getProcessor( filePathOrExtension: string, - options?: ProcessorOptions + options?: ProcessorOptions, ): BaseProcessor { - const extension = filePathOrExtension.includes('.') - ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf('.')) + const extension = filePathOrExtension.includes(".") + ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf(".")) : filePathOrExtension; switch (extension.toLowerCase()) { - case '.dot': + case ".dot": return new DotProcessor(options); - case '.opml': + case ".opml": return new OpmlProcessor(options); - case '.obf': - case '.obz': + case ".obf": + case ".obz": return new ObfProcessor(options); - case '.gridset': + case ".gridset": return new GridsetProcessor(options); - case '.spb': - case '.sps': + case ".spb": + case ".sps": return new SnapProcessor(options); - case '.ce': + case ".ce": return new TouchChatProcessor(options); - case '.plist': + case ".plist": return new ApplePanelsProcessor(options); - case '.grd': + case ".grd": return new AstericsGridProcessor(options); default: throw new Error(`Unsupported file extension: ${extension}`); @@ -92,7 +92,18 @@ export function getProcessor( * @returns Array of supported file extensions */ export function getSupportedExtensions(): string[] { - return ['.dot', '.opml', '.obf', '.obz', '.gridset', '.spb', '.sps', '.ce', '.plist', '.grd']; + return [ + ".dot", + ".opml", + ".obf", + ".obz", + ".gridset", + ".spb", + ".sps", + ".ce", + ".plist", + ".grd", + ]; } /** diff --git a/src/index.node.ts b/src/index.node.ts index 1c32b8f..59eae71 100644 --- a/src/index.node.ts +++ b/src/index.node.ts @@ -9,60 +9,60 @@ // =================================================================== // CORE TYPES (always needed) // =================================================================== -export * from './core/treeStructure'; -export * from './core/baseProcessor'; -export * from './core/stringCasing'; +export * from "./core/treeStructure"; +export * from "./core/baseProcessor"; +export * from "./core/stringCasing"; // =================================================================== // PROCESSORS (main functionality) // =================================================================== -export * from './processors'; +export * from "./processors"; // =================================================================== // NAMESPACES // =================================================================== // Analytics namespace (usage/history) -export * as Analytics from './analytics'; +export * as Analytics from "./analytics"; // Validation namespace -export * as Validation from './validation'; +export * as Validation from "./validation"; // Metrics namespace (pageset analytics) -export * as Metrics from './metrics'; +export * as Metrics from "./metrics"; // Node-only morphology utilities (Grid 3 verbs parser) -export { Grid3VerbsParser } from './utilities/analytics/morphology/grid3VerbsParser'; -export { WordFormGenerator } from './utilities/analytics/morphology/wordFormGenerator'; +export { Grid3VerbsParser } from "./utilities/analytics/morphology/grid3VerbsParser"; +export { WordFormGenerator } from "./utilities/analytics/morphology/wordFormGenerator"; // Processor namespaces (platform-specific utilities) -export * as Gridset from './gridset'; -export * as Snap from './snap'; -export * as OBF from './obf'; -export * as Obfset from './obfset'; -export * as TouchChat from './touchchat'; -export * as Dot from './dot'; -export * as Excel from './excel'; -export * as Opml from './opml'; -export * as ApplePanels from './applePanels'; -export * as AstericsGrid from './astericsGrid'; -export * as Translation from './translation'; +export * as Gridset from "./gridset"; +export * as Snap from "./snap"; +export * as OBF from "./obf"; +export * as Obfset from "./obfset"; +export * as TouchChat from "./touchchat"; +export * as Dot from "./dot"; +export * as Excel from "./excel"; +export * as Opml from "./opml"; +export * as ApplePanels from "./applePanels"; +export * as AstericsGrid from "./astericsGrid"; +export * as Translation from "./translation"; // =================================================================== // UTILITY FUNCTIONS // =================================================================== -import { BaseProcessor, ProcessorOptions } from './core/baseProcessor'; -import { DotProcessor } from './processors/dotProcessor'; -import { ExcelProcessor } from './processors/excelProcessor'; -import { OpmlProcessor } from './processors/opmlProcessor'; -import { ObfProcessor } from './processors/obfProcessor'; -import { GridsetProcessor } from './processors/gridsetProcessor'; -import { SnapProcessor } from './processors/snapProcessor'; -import { TouchChatProcessor } from './processors/touchchatProcessor'; -import { ApplePanelsProcessor } from './processors/applePanelsProcessor'; -import { AstericsGridProcessor } from './processors/astericsGridProcessor'; -import { ObfsetProcessor } from './processors/obfsetProcessor'; +import { BaseProcessor, ProcessorOptions } from "./core/baseProcessor"; +import { DotProcessor } from "./processors/dotProcessor"; +import { ExcelProcessor } from "./processors/excelProcessor"; +import { OpmlProcessor } from "./processors/opmlProcessor"; +import { ObfProcessor } from "./processors/obfProcessor"; +import { GridsetProcessor } from "./processors/gridsetProcessor"; +import { SnapProcessor } from "./processors/snapProcessor"; +import { TouchChatProcessor } from "./processors/touchchatProcessor"; +import { ApplePanelsProcessor } from "./processors/applePanelsProcessor"; +import { AstericsGridProcessor } from "./processors/astericsGridProcessor"; +import { ObfsetProcessor } from "./processors/obfsetProcessor"; /** * Factory function to get the appropriate processor for a file extension @@ -76,36 +76,36 @@ import { ObfsetProcessor } from './processors/obfsetProcessor'; */ export function getProcessor( filePathOrExtension: string, - options?: ProcessorOptions + options?: ProcessorOptions, ): BaseProcessor { // Extract extension from file path - const extension = filePathOrExtension.includes('.') - ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf('.')) + const extension = filePathOrExtension.includes(".") + ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf(".")) : filePathOrExtension; switch (extension.toLowerCase()) { - case '.dot': + case ".dot": return new DotProcessor(options); - case '.xlsx': + case ".xlsx": return new ExcelProcessor(options); - case '.opml': + case ".opml": return new OpmlProcessor(options); - case '.obf': - case '.obz': + case ".obf": + case ".obz": return new ObfProcessor(options); - case '.obfset': + case ".obfset": return new ObfsetProcessor(options); - case '.gridset': - case '.gridsetx': + case ".gridset": + case ".gridsetx": return new GridsetProcessor(options); - case '.spb': - case '.sps': + case ".spb": + case ".sps": return new SnapProcessor(options); - case '.ce': + case ".ce": return new TouchChatProcessor(options); - case '.plist': + case ".plist": return new ApplePanelsProcessor(options); - case '.grd': + case ".grd": return new AstericsGridProcessor(options); default: throw new Error(`Unsupported file extension: ${extension}`); @@ -118,19 +118,19 @@ export function getProcessor( */ export function getSupportedExtensions(): string[] { return [ - '.dot', - '.xlsx', - '.opml', - '.obf', - '.obz', - '.obfset', - '.gridset', - '.gridsetx', - '.spb', - '.sps', - '.ce', - '.plist', - '.grd', + ".dot", + ".xlsx", + ".opml", + ".obf", + ".obz", + ".obfset", + ".gridset", + ".gridsetx", + ".spb", + ".sps", + ".ce", + ".plist", + ".grd", ]; } diff --git a/src/index.ts b/src/index.ts index ada9ed9..1c03dce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1 @@ -export * from './index.node'; +export * from "./index.node"; diff --git a/src/metrics.ts b/src/metrics.ts index 5ae2bc5..eb46483 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -5,16 +5,16 @@ * Use this for analyzing AAC trees, not end-user usage logs. */ -export * from './utilities/analytics/metrics/types'; -export * from './utilities/analytics/metrics/effort'; -export * from './utilities/analytics/metrics/obl-types'; -export { OblUtil, OblAnonymizer } from './utilities/analytics/metrics/obl'; -export { MetricsCalculator } from './utilities/analytics/metrics/core'; -export { VocabularyAnalyzer } from './utilities/analytics/metrics/vocabulary'; -export { SentenceAnalyzer } from './utilities/analytics/metrics/sentence'; -export { ComparisonAnalyzer } from './utilities/analytics/metrics/comparison'; -export { MorphologyEngine } from './utilities/analytics/morphology'; -export { WordFormGenerator } from './utilities/analytics/morphology'; +export * from "./utilities/analytics/metrics/types"; +export * from "./utilities/analytics/metrics/effort"; +export * from "./utilities/analytics/metrics/obl-types"; +export { OblUtil, OblAnonymizer } from "./utilities/analytics/metrics/obl"; +export { MetricsCalculator } from "./utilities/analytics/metrics/core"; +export { VocabularyAnalyzer } from "./utilities/analytics/metrics/vocabulary"; +export { SentenceAnalyzer } from "./utilities/analytics/metrics/sentence"; +export { ComparisonAnalyzer } from "./utilities/analytics/metrics/comparison"; +export { MorphologyEngine } from "./utilities/analytics/morphology"; +export { WordFormGenerator } from "./utilities/analytics/morphology"; export type { MorphRuleSet, MorphRule, @@ -23,12 +23,12 @@ export type { AstericsWordForm, VerbFormWithConditions, Grid3VerbFormsDetailed, -} from './utilities/analytics/morphology'; -export { ReferenceLoader } from './utilities/analytics/reference'; +} from "./utilities/analytics/morphology"; +export { ReferenceLoader } from "./utilities/analytics/reference"; export { InMemoryReferenceLoader, createBrowserReferenceLoader, loadReferenceDataFromUrl, type ReferenceData, -} from './utilities/analytics/reference/browser'; -export * from './utilities/analytics/utils/idGenerator'; +} from "./utilities/analytics/reference/browser"; +export * from "./utilities/analytics/utils/idGenerator"; diff --git a/src/obf.ts b/src/obf.ts index 575b32c..dfedaaa 100644 --- a/src/obf.ts +++ b/src/obf.ts @@ -5,8 +5,8 @@ */ // Processor class -export { ObfProcessor } from './processors/obfProcessor'; -export { ObfsetProcessor } from './processors/obfsetProcessor'; +export { ObfProcessor } from "./processors/obfProcessor"; +export { ObfsetProcessor } from "./processors/obfsetProcessor"; // Note: OBF doesn't currently have platform-specific helpers like Gridset/Snap // Future helper functions can be added here diff --git a/src/obfset.ts b/src/obfset.ts index bbcb063..d4021d6 100644 --- a/src/obfset.ts +++ b/src/obfset.ts @@ -5,7 +5,7 @@ */ // Processor class -export { ObfsetProcessor } from './processors/obfsetProcessor'; +export { ObfsetProcessor } from "./processors/obfsetProcessor"; // Note: Obfset doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/opml.ts b/src/opml.ts index bd0ab3e..33be009 100644 --- a/src/opml.ts +++ b/src/opml.ts @@ -5,7 +5,7 @@ */ // Processor class -export { OpmlProcessor } from './processors/opmlProcessor'; +export { OpmlProcessor } from "./processors/opmlProcessor"; // Note: OPML doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/processors/applePanelsProcessor.ts b/src/processors/applePanelsProcessor.ts index 636be12..8d6e82c 100644 --- a/src/processors/applePanelsProcessor.ts +++ b/src/processors/applePanelsProcessor.ts @@ -4,7 +4,7 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from '../core/baseProcessor'; +} from "../core/baseProcessor"; import { AACTree, AACPage, @@ -12,13 +12,13 @@ import { AACSemanticAction, AACSemanticCategory, AACSemanticIntent, -} from '../core/treeStructure'; -import plist, { PlistValue } from 'plist'; +} from "../core/treeStructure"; +import plist, { PlistValue } from "plist"; import { ValidationFailureError, buildValidationResultFromMessage, -} from '../validation/validationTypes'; -import { ProcessorInput, getBasename } from '../utils/io'; +} from "../validation/validationTypes"; +import { ProcessorInput, getBasename } from "../utils/io"; interface ApplePanelsActionParameters { CharString?: string; @@ -89,7 +89,7 @@ interface ApplePanelsPanelObject { DisplayText: string; FontSize: number; ID: string; - PanelObjectType: 'Button'; + PanelObjectType: "Button"; Rect: string; DisplayColor?: string; DisplayImageWeight?: string; @@ -117,35 +117,42 @@ interface ApplePanelsPanelDefinition { } function isNormalizedPanel( - panel: ApplePanelsPanel | ApplePanelsRawPanel + panel: ApplePanelsPanel | ApplePanelsRawPanel, ): panel is ApplePanelsPanel { - return typeof (panel as ApplePanelsPanel).id === 'string'; + return typeof (panel as ApplePanelsPanel).id === "string"; } -function normalizePanel(panel: ApplePanelsRawPanel, fallbackId: string): ApplePanelsPanel { +function normalizePanel( + panel: ApplePanelsRawPanel, + fallbackId: string, +): ApplePanelsPanel { const rawId = panel.ID || fallbackId; const buttons = Array.isArray(panel.PanelObjects) ? panel.PanelObjects.filter( - (obj): obj is ApplePanelsRawButton => obj.PanelObjectType === 'Button' + (obj): obj is ApplePanelsRawButton => obj.PanelObjectType === "Button", ) : []; const normalizedButtons: ApplePanelsButton[] = buttons.map((btn) => { const firstAction: ApplePanelsRawAction | undefined = - Array.isArray(btn.Actions) && btn.Actions.length > 0 ? btn.Actions[0] : undefined; + Array.isArray(btn.Actions) && btn.Actions.length > 0 + ? btn.Actions[0] + : undefined; const isCharSequence = firstAction && - (firstAction.ActionType === 'ActionPressKeyCharSequence' || - firstAction.ActionType === 'ActionSendKeys'); - const charString = isCharSequence ? firstAction?.ActionParam?.CharString : undefined; + (firstAction.ActionType === "ActionPressKeyCharSequence" || + firstAction.ActionType === "ActionSendKeys"); + const charString = isCharSequence + ? firstAction?.ActionParam?.CharString + : undefined; const targetPanel = - firstAction && firstAction.ActionType === 'ActionOpenPanel' - ? firstAction.ActionParam?.PanelID?.replace(/^USER\./, '') + firstAction && firstAction.ActionType === "ActionOpenPanel" + ? firstAction.ActionParam?.PanelID?.replace(/^USER\./, "") : undefined; return { - label: btn.DisplayText || 'Button', - message: charString || btn.DisplayText || 'Button', + label: btn.DisplayText || "Button", + message: charString || btn.DisplayText || "Button", DisplayColor: btn.DisplayColor, DisplayImageWeight: btn.DisplayImageWeight, FontSize: btn.FontSize, @@ -155,14 +162,16 @@ function normalizePanel(panel: ApplePanelsRawPanel, fallbackId: string): ApplePa }); return { - id: rawId.replace(/^USER\./, ''), - name: panel.Name || 'Panel', + id: rawId.replace(/^USER\./, ""), + name: panel.Name || "Panel", buttons: normalizedButtons, }; } -function normalizeActionParameters(input: unknown): ApplePanelsActionParameters { - if (typeof input === 'object' && input !== null) { +function normalizeActionParameters( + input: unknown, +): ApplePanelsActionParameters { + if (typeof input === "object" && input !== null) { return { ...(input as Record) }; } return {}; @@ -174,12 +183,14 @@ class ApplePanelsProcessor extends BaseProcessor { } // Helper function to parse Apple Panels Rect format "{{x, y}, {width, height}}" private parseRect( - rectString: string + rectString: string, ): { x: number; y: number; width: number; height: number } | null { if (!rectString) return null; // Parse format like "{{0, 0}, {100, 25}}" - const match = rectString.match(/\{\{(\d+),\s*(\d+)\},\s*\{(\d+),\s*(\d+)\}\}/); + const match = rectString.match( + /\{\{(\d+),\s*(\d+)\},\s*\{(\d+),\s*(\d+)\}\}/, + ); if (!match) return null; return { @@ -194,7 +205,7 @@ class ApplePanelsProcessor extends BaseProcessor { private pixelToGrid( pixelX: number, pixelY: number, - cellSize: number = 25 + cellSize: number = 25, ): { gridX: number; gridY: number } { return { gridX: Math.floor(pixelX / cellSize), @@ -218,21 +229,28 @@ class ApplePanelsProcessor extends BaseProcessor { } async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { - const { readBinaryFromInput, readTextFromInput, pathExists, getFileSize, join } = - this.options.fileAdapter; + const { + readBinaryFromInput, + readTextFromInput, + pathExists, + getFileSize, + join, + } = this.options.fileAdapter; const filename = - typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.plist'; + typeof filePathOrBuffer === "string" + ? getBasename(filePathOrBuffer) + : "upload.plist"; let buffer: Uint8Array; try { - if (typeof filePathOrBuffer === 'string') { - if (filePathOrBuffer.endsWith('.ascconfig')) { + if (typeof filePathOrBuffer === "string") { + if (filePathOrBuffer.endsWith(".ascconfig")) { const panelDefsPath = join( filePathOrBuffer, - 'Contents', - 'Resources', - 'PanelDefinitions.plist' + "Contents", + "Resources", + "PanelDefinitions.plist", ); if (await pathExists(panelDefsPath)) { buffer = await readBinaryFromInput(panelDefsPath); @@ -240,12 +258,15 @@ class ApplePanelsProcessor extends BaseProcessor { const validation = buildValidationResultFromMessage({ filename, filesize: 0, - format: 'applepanels', + format: "applepanels", message: `Apple Panels file not found: ${panelDefsPath}`, - type: 'missing', - description: 'PanelDefinitions.plist', + type: "missing", + description: "PanelDefinitions.plist", }); - throw new ValidationFailureError('Apple Panels file not found', validation); + throw new ValidationFailureError( + "Apple Panels file not found", + validation, + ); } } else { buffer = await readBinaryFromInput(filePathOrBuffer); @@ -280,17 +301,20 @@ class ApplePanelsProcessor extends BaseProcessor { const validation = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: 'applepanels', - message: 'No panels found in Apple Panels file', - type: 'structure', - description: 'Panels definition', + format: "applepanels", + message: "No panels found in Apple Panels file", + type: "structure", + description: "Panels definition", }); - throw new ValidationFailureError('Apple Panels has no panels', validation); + throw new ValidationFailureError( + "Apple Panels has no panels", + validation, + ); } const data: ApplePanelsDocument = { panels: panelsData }; const tree = new AACTree(); - tree.metadata.format = 'applepanels'; + tree.metadata.format = "applepanels"; data.panels.forEach((panel) => { const page = new AACPage({ @@ -318,12 +342,12 @@ class ApplePanelsProcessor extends BaseProcessor { targetId: btn.targetPanel, platformData: { applePanels: { - actionType: 'ActionOpenPanel', + actionType: "ActionOpenPanel", parameters: { PanelID: `USER.${btn.targetPanel}` }, }, }, fallback: { - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId: btn.targetPanel, }, }; @@ -334,15 +358,15 @@ class ApplePanelsProcessor extends BaseProcessor { text: btn.message || btn.label, platformData: { applePanels: { - actionType: 'ActionPressKeyCharSequence', + actionType: "ActionPressKeyCharSequence", parameters: { - CharString: btn.message || btn.label || '', + CharString: btn.message || btn.label || "", isStickyKey: false, }, }, }, fallback: { - type: 'SPEAK', + type: "SPEAK", message: btn.message || btn.label, }, }; @@ -357,7 +381,7 @@ class ApplePanelsProcessor extends BaseProcessor { style: { backgroundColor: btn.DisplayColor, fontSize: btn.FontSize, - fontWeight: btn.DisplayImageWeight === 'bold' ? 'bold' : 'normal', + fontWeight: btn.DisplayImageWeight === "bold" ? "bold" : "normal", }, }); page.addButton(button); @@ -369,8 +393,16 @@ class ApplePanelsProcessor extends BaseProcessor { const gridWidth = Math.max(1, Math.ceil(rect.width / 25)); const gridHeight = Math.max(1, Math.ceil(rect.height / 25)); - for (let r = gridPos.gridY; r < gridPos.gridY + gridHeight && r < maxRows; r++) { - for (let c = gridPos.gridX; c < gridPos.gridX + gridWidth && c < maxCols; c++) { + for ( + let r = gridPos.gridY; + r < gridPos.gridY + gridHeight && r < maxRows; + r++ + ) { + for ( + let c = gridPos.gridX; + c < gridPos.gridX + gridWidth && c < maxCols; + c++ + ) { if (gridLayout[r] && gridLayout[r][c] === null) { gridLayout[r][c] = button; } @@ -392,26 +424,30 @@ class ApplePanelsProcessor extends BaseProcessor { const validation = buildValidationResultFromMessage({ filename, filesize: - typeof filePathOrBuffer === 'string' + typeof filePathOrBuffer === "string" ? await (async () => { return (await pathExists(filePathOrBuffer)) ? await getFileSize(filePathOrBuffer) : 0; })() : (await readBinaryFromInput(filePathOrBuffer)).byteLength, - format: 'applepanels', - message: err?.message || 'Failed to parse Apple Panels file', - type: 'parse', - description: 'Parse Apple Panels plist', + format: "applepanels", + message: err?.message || "Failed to parse Apple Panels file", + type: "parse", + description: "Parse Apple Panels plist", }); - throw new ValidationFailureError('Failed to load Apple Panels file', validation, err); + throw new ValidationFailureError( + "Failed to load Apple Panels file", + validation, + err, + ); } } async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, ): Promise { const { readBinaryFromInput, join } = this.options.fileAdapter; // Load the tree, apply translations, and save to new file @@ -444,18 +480,19 @@ class ApplePanelsProcessor extends BaseProcessor { if (button.semanticAction) { const intentStr = String(button.semanticAction.intent); - if (intentStr === 'SPEAK_TEXT' || intentStr === 'INSERT_TEXT') { - const updatedText = button.message || button.label || ''; + if (intentStr === "SPEAK_TEXT" || intentStr === "INSERT_TEXT") { + const updatedText = button.message || button.label || ""; button.semanticAction.text = updatedText; if (button.semanticAction.fallback) { button.semanticAction.fallback.message = updatedText; } - const platformParams = button.semanticAction.platformData?.applePanels?.parameters; - if (platformParams && typeof platformParams === 'object') { - if ('CharString' in platformParams) { + const platformParams = + button.semanticAction.platformData?.applePanels?.parameters; + if (platformParams && typeof platformParams === "object") { + if ("CharString" in platformParams) { platformParams.CharString = updatedText; } - if ('PanelID' in platformParams && button.targetPageId) { + if ("PanelID" in platformParams && button.targetPageId) { platformParams.PanelID = `USER.${button.targetPageId}`; } } @@ -467,56 +504,73 @@ class ApplePanelsProcessor extends BaseProcessor { // Save the translated tree to the requested location and return its content await this.saveFromTree(tree, outputPath); - if (outputPath.endsWith('.plist')) { + if (outputPath.endsWith(".plist")) { return await readBinaryFromInput(outputPath); } - const configPath = outputPath.endsWith('.ascconfig') ? outputPath : `${outputPath}.ascconfig`; - const panelDefsPath = join(configPath, 'Contents', 'Resources', 'PanelDefinitions.plist'); + const configPath = outputPath.endsWith(".ascconfig") + ? outputPath + : `${outputPath}.ascconfig`; + const panelDefsPath = join( + configPath, + "Contents", + "Resources", + "PanelDefinitions.plist", + ); return await readBinaryFromInput(panelDefsPath); } async saveFromTree(tree: AACTree, outputPath: string): Promise { - const { writeTextToPath, pathExists, mkDir, join, dirname } = this.options.fileAdapter; + const { writeTextToPath, pathExists, mkDir, join, dirname } = + this.options.fileAdapter; // Support two output modes: // 1) Single-file .plist (PanelDefinitions.plist content written directly) // 2) Apple Panels bundle folder (*.ascconfig) with Contents/Resources structure - const isSinglePlist = outputPath.endsWith('.plist'); + const isSinglePlist = outputPath.endsWith(".plist"); // Prepare folder structure only when exporting as bundle - let configPath = ''; - let contentsPath = ''; - let resourcesPath = ''; + let configPath = ""; + let contentsPath = ""; + let resourcesPath = ""; if (!isSinglePlist) { - configPath = outputPath.endsWith('.ascconfig') ? outputPath : `${outputPath}.ascconfig`; - contentsPath = join(configPath, 'Contents'); - resourcesPath = join(contentsPath, 'Resources'); - - if (!(await pathExists(configPath))) await mkDir(configPath, { recursive: true }); - if (!(await pathExists(contentsPath))) await mkDir(contentsPath, { recursive: true }); - if (!(await pathExists(resourcesPath))) await mkDir(resourcesPath, { recursive: true }); + configPath = outputPath.endsWith(".ascconfig") + ? outputPath + : `${outputPath}.ascconfig`; + contentsPath = join(configPath, "Contents"); + resourcesPath = join(contentsPath, "Resources"); + + if (!(await pathExists(configPath))) + await mkDir(configPath, { recursive: true }); + if (!(await pathExists(contentsPath))) + await mkDir(contentsPath, { recursive: true }); + if (!(await pathExists(resourcesPath))) + await mkDir(resourcesPath, { recursive: true }); // Create Info.plist (bundle mode only) const infoPlist = { - ASCConfigurationDisplayName: tree.metadata?.name || 'AAC Processors Export', + ASCConfigurationDisplayName: + tree.metadata?.name || "AAC Processors Export", ASCConfigurationIdentifier: `com.aacprocessors.${Date.now()}`, - ASCConfigurationProductSupportType: 'VirtualKeyboard', - ASCConfigurationVersion: tree.metadata?.version || '7.1', - CFBundleDevelopmentRegion: tree.metadata?.locale || 'en', - CFBundleIdentifier: 'com.aacprocessors.panel.export', - CFBundleName: tree.metadata?.name || 'AAC Processors Panels', - CFBundleShortVersionString: tree.metadata?.version || '1.0', - CFBundleVersion: '1', + ASCConfigurationProductSupportType: "VirtualKeyboard", + ASCConfigurationVersion: tree.metadata?.version || "7.1", + CFBundleDevelopmentRegion: tree.metadata?.locale || "en", + CFBundleIdentifier: "com.aacprocessors.panel.export", + CFBundleName: tree.metadata?.name || "AAC Processors Panels", + CFBundleShortVersionString: tree.metadata?.version || "1.0", + CFBundleVersion: "1", NSHumanReadableCopyright: tree.metadata?.copyright || - `Generated by AAC Processors${tree.metadata?.author ? ` - Author: ${tree.metadata.author}` : ''}`, + `Generated by AAC Processors${tree.metadata?.author ? ` - Author: ${tree.metadata.author}` : ""}`, }; const infoPlistContent = plist.build(infoPlist); - await writeTextToPath(join(contentsPath, 'Info.plist'), infoPlistContent); + await writeTextToPath(join(contentsPath, "Info.plist"), infoPlistContent); // Create AssetIndex.plist (empty) const assetIndexContent = plist.build({}); - await writeTextToPath(join(resourcesPath, 'AssetIndex.plist'), assetIndexContent); + await writeTextToPath( + join(resourcesPath, "AssetIndex.plist"), + assetIndexContent, + ); } // Build PanelDefinitions content from tree @@ -596,11 +650,11 @@ class ApplePanelsProcessor extends BaseProcessor { const buttonObj: ApplePanelsPanelObject = { ButtonType: 0, - DisplayText: button.label || 'Button', + DisplayText: button.label || "Button", FontSize: button.style?.fontSize || 12, ID: `Button.${button.id}`, - PanelObjectType: 'Button', - Rect: rect ?? '{{0, 0}, {100, 25}}', + PanelObjectType: "Button", + Rect: rect ?? "{{0, 0}, {100, 25}}", Actions: [], }; @@ -608,10 +662,10 @@ class ApplePanelsProcessor extends BaseProcessor { buttonObj.DisplayColor = button.style.backgroundColor; } - if (button.style?.fontWeight === 'bold') { - buttonObj.DisplayImageWeight = 'FontWeightBold'; + if (button.style?.fontWeight === "bold") { + buttonObj.DisplayImageWeight = "FontWeightBold"; } else { - buttonObj.DisplayImageWeight = 'FontWeightRegular'; + buttonObj.DisplayImageWeight = "FontWeightRegular"; } // Add actions - prefer semantic action if available @@ -631,18 +685,21 @@ class ApplePanelsProcessor extends BaseProcessor { HideSwitchDockContextualButtons: false, HideTitlebar: false, ID: panelId, - Name: page.name || 'Panel', + Name: page.name || "Panel", PanelObjects: panelObjects, - ProductSupportType: 'All', - Rect: '{{15, 75}, {425, 55}}', + ProductSupportType: "All", + Rect: "{{15, 75}, {425, 55}}", ScanStyle: 0, - ShowPanelLocationString: 'CustomPanelList', + ShowPanelLocationString: "CustomPanelList", UsesPinnedResizing: false, }; }); const panelsValue: Record = Object.fromEntries( - Object.entries(panelsDict).map(([key, value]) => [key, value as unknown as PlistValue]) + Object.entries(panelsDict).map(([key, value]) => [ + key, + value as unknown as PlistValue, + ]), ); const panelDefinitions: PlistValue = { @@ -662,7 +719,10 @@ class ApplePanelsProcessor extends BaseProcessor { await writeTextToPath(outputPath, panelDefsContent); } else { // Write into bundle structure - await writeTextToPath(join(resourcesPath, 'PanelDefinitions.plist'), panelDefsContent); + await writeTextToPath( + join(resourcesPath, "PanelDefinitions.plist"), + panelDefsContent, + ); } } @@ -682,36 +742,40 @@ class ApplePanelsProcessor extends BaseProcessor { if (button.semanticAction) { const intentStr = String(button.semanticAction.intent); switch (intentStr) { - case 'NAVIGATE_TO': + case "NAVIGATE_TO": return { ActionParam: { - PanelID: `USER.${button.semanticAction.targetId || button.targetPageId || ''}`, + PanelID: `USER.${button.semanticAction.targetId || button.targetPageId || ""}`, }, ActionRecordedOffset: 0.0, - ActionType: 'ActionOpenPanel', + ActionType: "ActionOpenPanel", ID: `Action.${button.id}`, }; - case 'SPEAK_TEXT': - case 'INSERT_TEXT': + case "SPEAK_TEXT": + case "INSERT_TEXT": return { ActionParam: { - CharString: button.semanticAction.text || button.message || button.label || '', + CharString: + button.semanticAction.text || + button.message || + button.label || + "", isStickyKey: false, }, ActionRecordedOffset: 0.0, - ActionType: 'ActionPressKeyCharSequence', + ActionType: "ActionPressKeyCharSequence", ID: `Action.${button.id}`, }; - case 'SEND_KEYS': + case "SEND_KEYS": return { ActionParam: { - CharString: button.semanticAction.text || '', + CharString: button.semanticAction.text || "", isStickyKey: false, }, ActionRecordedOffset: 0.0, - ActionType: 'ActionSendKeys', + ActionType: "ActionSendKeys", ID: `Action.${button.id}`, }; @@ -720,11 +784,14 @@ class ApplePanelsProcessor extends BaseProcessor { return { ActionParam: { CharString: - button.semanticAction.fallback?.message || button.message || button.label || '', + button.semanticAction.fallback?.message || + button.message || + button.label || + "", isStickyKey: false, }, ActionRecordedOffset: 0.0, - ActionType: 'ActionPressKeyCharSequence', + ActionType: "ActionPressKeyCharSequence", ID: `Action.${button.id}`, }; } @@ -733,11 +800,11 @@ class ApplePanelsProcessor extends BaseProcessor { // Default SPEAK action if no semantic action return { ActionParam: { - CharString: button.message || button.label || '', + CharString: button.message || button.label || "", isStickyKey: false, }, ActionRecordedOffset: 0.0, - ActionType: 'ActionPressKeyCharSequence', + ActionType: "ActionPressKeyCharSequence", ID: `Action.${button.id}`, }; } @@ -757,9 +824,13 @@ class ApplePanelsProcessor extends BaseProcessor { generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { - return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); + return this.generateTranslatedDownloadGeneric( + filePath, + translatedStrings, + sourceStrings, + ); } } diff --git a/src/processors/astericsGridProcessor.ts b/src/processors/astericsGridProcessor.ts index e925210..ed8364e 100644 --- a/src/processors/astericsGridProcessor.ts +++ b/src/processors/astericsGridProcessor.ts @@ -4,7 +4,7 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from '../core/baseProcessor'; +} from "../core/baseProcessor"; import { AACTree, AACPage, @@ -13,12 +13,12 @@ import { AACSemanticCategory, AACSemanticIntent, AstericsGridMetadata, -} from '../core/treeStructure'; +} from "../core/treeStructure"; import { ValidationFailureError, buildValidationResultFromMessage, -} from '../validation/validationTypes'; -import { ProcessorInput, getBasename, encodeBase64 } from '../utils/io'; +} from "../validation/validationTypes"; +import { ProcessorInput, getBasename, encodeBase64 } from "../utils/io"; // Asterics Grid data model interfaces interface GridData { @@ -111,333 +111,334 @@ interface ColorSchemeDefinition { const DEFAULT_COLOR_SCHEME_DEFINITIONS: ColorSchemeDefinition[] = [ { - name: 'CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT', + name: "CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT", categories: [ - 'CC_PRONOUN_PERSON_NAME', - 'CC_NOUN', - 'CC_VERB', - 'CC_DESCRIPTOR', - 'CC_SOCIAL_EXPRESSIONS', - 'CC_MISC', - 'CC_PLACE', - 'CC_CATEGORY', - 'CC_IMPORTANT', - 'CC_OTHERS', + "CC_PRONOUN_PERSON_NAME", + "CC_NOUN", + "CC_VERB", + "CC_DESCRIPTOR", + "CC_SOCIAL_EXPRESSIONS", + "CC_MISC", + "CC_PLACE", + "CC_CATEGORY", + "CC_IMPORTANT", + "CC_OTHERS", ], colors: [ - '#fafad0', - '#fbf3e4', - '#dff4df', - '#eaeffd', - '#fff0f6', - '#ffffff', - '#fbf2ff', - '#ddccc1', - '#FCE8E8', - '#e4e4e4', + "#fafad0", + "#fbf3e4", + "#dff4df", + "#eaeffd", + "#fff0f6", + "#ffffff", + "#fbf2ff", + "#ddccc1", + "#FCE8E8", + "#e4e4e4", ], mappings: { - CC_ADJECTIVE: 'CC_DESCRIPTOR', - CC_ADVERB: 'CC_DESCRIPTOR', - CC_ARTICLE: 'CC_MISC', - CC_PREPOSITION: 'CC_MISC', - CC_CONJUNCTION: 'CC_MISC', - CC_INTERJECTION: 'CC_SOCIAL_EXPRESSIONS', + CC_ADJECTIVE: "CC_DESCRIPTOR", + CC_ADVERB: "CC_DESCRIPTOR", + CC_ARTICLE: "CC_MISC", + CC_PREPOSITION: "CC_MISC", + CC_CONJUNCTION: "CC_MISC", + CC_INTERJECTION: "CC_SOCIAL_EXPRESSIONS", }, }, { - name: 'CS_MODIFIED_FITZGERALD_KEY_LIGHT', + name: "CS_MODIFIED_FITZGERALD_KEY_LIGHT", categories: [ - 'CC_PRONOUN_PERSON_NAME', - 'CC_NOUN', - 'CC_VERB', - 'CC_DESCRIPTOR', - 'CC_SOCIAL_EXPRESSIONS', - 'CC_MISC', - 'CC_PLACE', - 'CC_CATEGORY', - 'CC_IMPORTANT', - 'CC_OTHERS', + "CC_PRONOUN_PERSON_NAME", + "CC_NOUN", + "CC_VERB", + "CC_DESCRIPTOR", + "CC_SOCIAL_EXPRESSIONS", + "CC_MISC", + "CC_PLACE", + "CC_CATEGORY", + "CC_IMPORTANT", + "CC_OTHERS", ], colors: [ - '#fdfd96', - '#ffda89', - '#c7f3c7', - '#84b6f4', - '#fdcae1', - '#ffffff', - '#bc98f3', - '#d8af97', - '#ff9688', - '#bdbfbf', + "#fdfd96", + "#ffda89", + "#c7f3c7", + "#84b6f4", + "#fdcae1", + "#ffffff", + "#bc98f3", + "#d8af97", + "#ff9688", + "#bdbfbf", ], mappings: { - CC_ADJECTIVE: 'CC_DESCRIPTOR', - CC_ADVERB: 'CC_DESCRIPTOR', - CC_ARTICLE: 'CC_MISC', - CC_PREPOSITION: 'CC_MISC', - CC_CONJUNCTION: 'CC_MISC', - CC_INTERJECTION: 'CC_SOCIAL_EXPRESSIONS', + CC_ADJECTIVE: "CC_DESCRIPTOR", + CC_ADVERB: "CC_DESCRIPTOR", + CC_ARTICLE: "CC_MISC", + CC_PREPOSITION: "CC_MISC", + CC_CONJUNCTION: "CC_MISC", + CC_INTERJECTION: "CC_SOCIAL_EXPRESSIONS", }, }, { - name: 'CS_MODIFIED_FITZGERALD_KEY_MEDIUM', + name: "CS_MODIFIED_FITZGERALD_KEY_MEDIUM", categories: [ - 'CC_PRONOUN_PERSON_NAME', - 'CC_NOUN', - 'CC_VERB', - 'CC_DESCRIPTOR', - 'CC_SOCIAL_EXPRESSIONS', - 'CC_MISC', - 'CC_PLACE', - 'CC_CATEGORY', - 'CC_IMPORTANT', - 'CC_OTHERS', + "CC_PRONOUN_PERSON_NAME", + "CC_NOUN", + "CC_VERB", + "CC_DESCRIPTOR", + "CC_SOCIAL_EXPRESSIONS", + "CC_MISC", + "CC_PLACE", + "CC_CATEGORY", + "CC_IMPORTANT", + "CC_OTHERS", ], colors: [ - '#ffff6b', - '#ffb56b', - '#b5ff6b', - '#6bb5ff', - '#ff6bff', - '#ffffff', - '#ce6bff', - '#bf9075', - '#ff704d', - '#a3a3a3', + "#ffff6b", + "#ffb56b", + "#b5ff6b", + "#6bb5ff", + "#ff6bff", + "#ffffff", + "#ce6bff", + "#bf9075", + "#ff704d", + "#a3a3a3", ], mappings: { - CC_ADJECTIVE: 'CC_DESCRIPTOR', - CC_ADVERB: 'CC_DESCRIPTOR', - CC_ARTICLE: 'CC_MISC', - CC_PREPOSITION: 'CC_MISC', - CC_CONJUNCTION: 'CC_MISC', - CC_INTERJECTION: 'CC_SOCIAL_EXPRESSIONS', + CC_ADJECTIVE: "CC_DESCRIPTOR", + CC_ADVERB: "CC_DESCRIPTOR", + CC_ARTICLE: "CC_MISC", + CC_PREPOSITION: "CC_MISC", + CC_CONJUNCTION: "CC_MISC", + CC_INTERJECTION: "CC_SOCIAL_EXPRESSIONS", }, }, { - name: 'CS_MODIFIED_FITZGERALD_KEY_DARK', + name: "CS_MODIFIED_FITZGERALD_KEY_DARK", categories: [ - 'CC_PRONOUN_PERSON_NAME', - 'CC_NOUN', - 'CC_VERB', - 'CC_DESCRIPTOR', - 'CC_SOCIAL_EXPRESSIONS', - 'CC_MISC', - 'CC_PLACE', - 'CC_CATEGORY', - 'CC_IMPORTANT', - 'CC_OTHERS', + "CC_PRONOUN_PERSON_NAME", + "CC_NOUN", + "CC_VERB", + "CC_DESCRIPTOR", + "CC_SOCIAL_EXPRESSIONS", + "CC_MISC", + "CC_PLACE", + "CC_CATEGORY", + "CC_IMPORTANT", + "CC_OTHERS", ], colors: [ - '#79791F', - '#804c26', - '#4c8026', - '#264c80', - '#802680', - '#747474', - '#602680', - '#52331f', - '#80261a', - '#464646', + "#79791F", + "#804c26", + "#4c8026", + "#264c80", + "#802680", + "#747474", + "#602680", + "#52331f", + "#80261a", + "#464646", ], mappings: { - CC_ADJECTIVE: 'CC_DESCRIPTOR', - CC_ADVERB: 'CC_DESCRIPTOR', - CC_ARTICLE: 'CC_MISC', - CC_PREPOSITION: 'CC_MISC', - CC_CONJUNCTION: 'CC_MISC', - CC_INTERJECTION: 'CC_SOCIAL_EXPRESSIONS', + CC_ADJECTIVE: "CC_DESCRIPTOR", + CC_ADVERB: "CC_DESCRIPTOR", + CC_ARTICLE: "CC_MISC", + CC_PREPOSITION: "CC_MISC", + CC_CONJUNCTION: "CC_MISC", + CC_INTERJECTION: "CC_SOCIAL_EXPRESSIONS", }, }, { - name: 'CS_GOOSENS_VERY_LIGHT', + name: "CS_GOOSENS_VERY_LIGHT", categories: [ - 'CC_VERB', - 'CC_DESCRIPTOR', - 'CC_PREPOSITION', - 'CC_NOUN', - 'CC_QUESTION_NEGATION_PRONOUN', + "CC_VERB", + "CC_DESCRIPTOR", + "CC_PREPOSITION", + "CC_NOUN", + "CC_QUESTION_NEGATION_PRONOUN", ], - colors: ['#fff0f6', '#eaeffd', '#dff4df', '#fafad0', '#fbf3e4'], + colors: ["#fff0f6", "#eaeffd", "#dff4df", "#fafad0", "#fbf3e4"], }, { - name: 'CS_GOOSENS_LIGHT', + name: "CS_GOOSENS_LIGHT", categories: [ - 'CC_VERB', - 'CC_DESCRIPTOR', - 'CC_PREPOSITION', - 'CC_NOUN', - 'CC_QUESTION_NEGATION_PRONOUN', + "CC_VERB", + "CC_DESCRIPTOR", + "CC_PREPOSITION", + "CC_NOUN", + "CC_QUESTION_NEGATION_PRONOUN", ], - colors: ['#fdcae1', '#84b6f4', '#c7f3c7', '#fdfd96', '#ffda89'], + colors: ["#fdcae1", "#84b6f4", "#c7f3c7", "#fdfd96", "#ffda89"], }, { - name: 'CS_GOOSENS_MEDIUM', + name: "CS_GOOSENS_MEDIUM", categories: [ - 'CC_VERB', - 'CC_DESCRIPTOR', - 'CC_PREPOSITION', - 'CC_NOUN', - 'CC_QUESTION_NEGATION_PRONOUN', + "CC_VERB", + "CC_DESCRIPTOR", + "CC_PREPOSITION", + "CC_NOUN", + "CC_QUESTION_NEGATION_PRONOUN", ], - colors: ['#ff6bff', '#6bb5ff', '#b5ff6b', '#ffff6b', '#ffb56b'], + colors: ["#ff6bff", "#6bb5ff", "#b5ff6b", "#ffff6b", "#ffb56b"], }, { - name: 'CS_GOOSENS_DARK', + name: "CS_GOOSENS_DARK", categories: [ - 'CC_VERB', - 'CC_DESCRIPTOR', - 'CC_PREPOSITION', - 'CC_NOUN', - 'CC_QUESTION_NEGATION_PRONOUN', + "CC_VERB", + "CC_DESCRIPTOR", + "CC_PREPOSITION", + "CC_NOUN", + "CC_QUESTION_NEGATION_PRONOUN", ], - colors: ['#802680', '#264c80', '#4c8026', '#79791F', '#804c26'], + colors: ["#802680", "#264c80", "#4c8026", "#79791F", "#804c26"], }, { - name: 'CS_MONTESSORI_VERY_LIGHT', + name: "CS_MONTESSORI_VERY_LIGHT", categories: [ - 'CC_NOUN', - 'CC_ARTICLE', - 'CC_ADJECTIVE', - 'CC_VERB', - 'CC_PREPOSITION', - 'CC_ADVERB', - 'CC_PRONOUN_PERSON_NAME', - 'CC_CONJUNCTION', - 'CC_INTERJECTION', - 'CC_CATEGORY', + "CC_NOUN", + "CC_ARTICLE", + "CC_ADJECTIVE", + "CC_VERB", + "CC_PREPOSITION", + "CC_ADVERB", + "CC_PRONOUN_PERSON_NAME", + "CC_CONJUNCTION", + "CC_INTERJECTION", + "CC_CATEGORY", ], colors: [ - '#ffffff', - '#e3f5fa', - '#eaeffd', - '#FCE8E8', - '#dff4df', - '#fbf3e4', - '#fbf2ff', - '#fff0f6', - '#fbf7e4', - '#e4e4e4', + "#ffffff", + "#e3f5fa", + "#eaeffd", + "#FCE8E8", + "#dff4df", + "#fbf3e4", + "#fbf2ff", + "#fff0f6", + "#fbf7e4", + "#e4e4e4", ], customBorders: { - CC_NOUN: '#353535', + CC_NOUN: "#353535", }, }, { - name: 'CS_MONTESSORI_LIGHT', + name: "CS_MONTESSORI_LIGHT", categories: [ - 'CC_NOUN', - 'CC_ARTICLE', - 'CC_ADJECTIVE', - 'CC_VERB', - 'CC_PREPOSITION', - 'CC_ADVERB', - 'CC_PRONOUN_PERSON_NAME', - 'CC_CONJUNCTION', - 'CC_INTERJECTION', - 'CC_CATEGORY', + "CC_NOUN", + "CC_ARTICLE", + "CC_ADJECTIVE", + "CC_VERB", + "CC_PREPOSITION", + "CC_ADVERB", + "CC_PRONOUN_PERSON_NAME", + "CC_CONJUNCTION", + "CC_INTERJECTION", + "CC_CATEGORY", ], colors: [ - '#afafaf', - '#a8e0f0', - '#a5bbf7', - '#f4a8a8', - '#ace3ac', - '#f2d7a6', - '#e4a5ff', - '#ffa5c9', - '#f2e5a6', - '#d1d1d1', + "#afafaf", + "#a8e0f0", + "#a5bbf7", + "#f4a8a8", + "#ace3ac", + "#f2d7a6", + "#e4a5ff", + "#ffa5c9", + "#f2e5a6", + "#d1d1d1", ], }, { - name: 'CS_MONTESSORI_MEDIUM', + name: "CS_MONTESSORI_MEDIUM", categories: [ - 'CC_NOUN', - 'CC_ARTICLE', - 'CC_ADJECTIVE', - 'CC_VERB', - 'CC_PREPOSITION', - 'CC_ADVERB', - 'CC_PRONOUN_PERSON_NAME', - 'CC_CONJUNCTION', - 'CC_INTERJECTION', - 'CC_CATEGORY', + "CC_NOUN", + "CC_ARTICLE", + "CC_ADJECTIVE", + "CC_VERB", + "CC_PREPOSITION", + "CC_ADVERB", + "CC_PRONOUN_PERSON_NAME", + "CC_CONJUNCTION", + "CC_INTERJECTION", + "CC_CATEGORY", ], colors: [ - '#000000', - '#4ca6d9', - '#1347ae', - '#e73a0f', - '#04bf82', - '#fd9030', - '#6118a2', - '#f1c9d1', - '#aa996b', - '#d1d1d1', + "#000000", + "#4ca6d9", + "#1347ae", + "#e73a0f", + "#04bf82", + "#fd9030", + "#6118a2", + "#f1c9d1", + "#aa996b", + "#d1d1d1", ], }, { - name: 'CS_MONTESSORI_DARK', + name: "CS_MONTESSORI_DARK", categories: [ - 'CC_NOUN', - 'CC_ARTICLE', - 'CC_ADJECTIVE', - 'CC_VERB', - 'CC_PREPOSITION', - 'CC_ADVERB', - 'CC_PRONOUN_PERSON_NAME', - 'CC_CONJUNCTION', - 'CC_INTERJECTION', - 'CC_CATEGORY', + "CC_NOUN", + "CC_ARTICLE", + "CC_ADJECTIVE", + "CC_VERB", + "CC_PREPOSITION", + "CC_ADVERB", + "CC_PRONOUN_PERSON_NAME", + "CC_CONJUNCTION", + "CC_INTERJECTION", + "CC_CATEGORY", ], colors: [ - '#464646', - '#18728c', - '#0d3298', - '#931212', - '#287728', - '#BC5800', - '#7500a7', - '#a70043', - '#807351', - '#747474', + "#464646", + "#18728c", + "#0d3298", + "#931212", + "#287728", + "#BC5800", + "#7500a7", + "#a70043", + "#807351", + "#747474", ], }, ]; const COLOR_SCHEME_ALIASES: Record = { - CS_DEFAULT: 'CS_MODIFIED_FITZGERALD_KEY_LIGHT', - CS_MONTESSORI: 'CS_MONTESSORI_LIGHT', - CS_MONTESSORI_LIGHT: 'CS_MONTESSORI_LIGHT', - CS_MONTESSORI_MEDIUM: 'CS_MONTESSORI_MEDIUM', - CS_MONTESSORI_DARK: 'CS_MONTESSORI_DARK', - CS_MONTESSORI_VERY_LIGHT: 'CS_MONTESSORI_VERY_LIGHT', - CS_MODIFIED_FITZGERALD_KEY: 'CS_MODIFIED_FITZGERALD_KEY_LIGHT', - CS_MODIFIED_FITZGERALD_KEY_LIGHT: 'CS_MODIFIED_FITZGERALD_KEY_LIGHT', - CS_MODIFIED_FITZGERALD_KEY_MEDIUM: 'CS_MODIFIED_FITZGERALD_KEY_MEDIUM', - CS_MODIFIED_FITZGERALD_KEY_DARK: 'CS_MODIFIED_FITZGERALD_KEY_DARK', - CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT: 'CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT', - CS_GOOSENS: 'CS_GOOSENS_LIGHT', - CS_GOOSENS_LIGHT: 'CS_GOOSENS_LIGHT', - CS_GOOSENS_MEDIUM: 'CS_GOOSENS_MEDIUM', - CS_GOOSENS_DARK: 'CS_GOOSENS_DARK', - CS_GOOSENS_VERY_LIGHT: 'CS_GOOSENS_VERY_LIGHT', + CS_DEFAULT: "CS_MODIFIED_FITZGERALD_KEY_LIGHT", + CS_MONTESSORI: "CS_MONTESSORI_LIGHT", + CS_MONTESSORI_LIGHT: "CS_MONTESSORI_LIGHT", + CS_MONTESSORI_MEDIUM: "CS_MONTESSORI_MEDIUM", + CS_MONTESSORI_DARK: "CS_MONTESSORI_DARK", + CS_MONTESSORI_VERY_LIGHT: "CS_MONTESSORI_VERY_LIGHT", + CS_MODIFIED_FITZGERALD_KEY: "CS_MODIFIED_FITZGERALD_KEY_LIGHT", + CS_MODIFIED_FITZGERALD_KEY_LIGHT: "CS_MODIFIED_FITZGERALD_KEY_LIGHT", + CS_MODIFIED_FITZGERALD_KEY_MEDIUM: "CS_MODIFIED_FITZGERALD_KEY_MEDIUM", + CS_MODIFIED_FITZGERALD_KEY_DARK: "CS_MODIFIED_FITZGERALD_KEY_DARK", + CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT: + "CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT", + CS_GOOSENS: "CS_GOOSENS_LIGHT", + CS_GOOSENS_LIGHT: "CS_GOOSENS_LIGHT", + CS_GOOSENS_MEDIUM: "CS_GOOSENS_MEDIUM", + CS_GOOSENS_DARK: "CS_GOOSENS_DARK", + CS_GOOSENS_VERY_LIGHT: "CS_GOOSENS_VERY_LIGHT", }; export function normalizeHexColor(hexColor: string): string | null { - if (!hexColor || typeof hexColor !== 'string') return null; + if (!hexColor || typeof hexColor !== "string") return null; let value = hexColor.trim().toLowerCase(); - if (!value.startsWith('#')) { + if (!value.startsWith("#")) { return null; } value = value.slice(1); if (value.length === 3) { value = value - .split('') + .split("") .map((ch) => ch + ch) - .join(''); + .join(""); } if (value.length !== 6 || /[^0-9a-f]/.test(value)) { return null; @@ -460,22 +461,24 @@ export function adjustHexColor(hexColor: string, amount: number): string { export function getHighContrastNeutralColor(backgroundColor: string): string { const normalized = normalizeHexColor(backgroundColor); if (!normalized) { - return '#808080'; + return "#808080"; } - return calculateLuminance(normalized) < 0.5 ? '#f5f5f5' : '#808080'; + return calculateLuminance(normalized) < 0.5 ? "#f5f5f5" : "#808080"; } function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === "object" && value !== null; } -function normalizeStringRecord(input: unknown): Record | undefined { +function normalizeStringRecord( + input: unknown, +): Record | undefined { if (!isRecord(input)) { return undefined; } const entries: [string, string][] = []; Object.entries(input).forEach(([key, value]) => { - if (typeof value === 'string') { + if (typeof value === "string") { entries.push([key, value]); } }); @@ -489,7 +492,7 @@ function normalizeColorScheme(raw: unknown): ColorSchemeDefinition | null { if (!isRecord(raw)) return null; const scheme = raw; const nameCandidate = [scheme.name, scheme.key, scheme.id].find( - (value): value is string => typeof value === 'string' && value.length > 0 + (value): value is string => typeof value === "string" && value.length > 0, ); if (!nameCandidate) return null; @@ -497,15 +500,17 @@ function normalizeColorScheme(raw: unknown): ColorSchemeDefinition | null { let colors: string[] = []; if (Array.isArray(scheme.categories) && Array.isArray(scheme.colors)) { categories = scheme.categories.filter( - (value: unknown): value is string => typeof value === 'string' + (value: unknown): value is string => typeof value === "string", + ); + colors = scheme.colors.filter( + (value: unknown): value is string => typeof value === "string", ); - colors = scheme.colors.filter((value: unknown): value is string => typeof value === 'string'); } else if (isRecord(scheme.colorMap)) { const colorMap = scheme.colorMap; categories = Object.keys(colorMap); colors = categories.map((category) => { const colorValue = colorMap[category]; - return typeof colorValue === 'string' ? colorValue : '#ffffff'; + return typeof colorValue === "string" ? colorValue : "#ffffff"; }); } @@ -530,20 +535,25 @@ function normalizeColorScheme(raw: unknown): ColorSchemeDefinition | null { }; } -function getAllColorSchemeDefinitions(colorConfig?: AstericsColorConfig): ColorSchemeDefinition[] { - const rawAdditional: unknown[] = Array.isArray(colorConfig?.additionalColorSchemes) +function getAllColorSchemeDefinitions( + colorConfig?: AstericsColorConfig, +): ColorSchemeDefinition[] { + const rawAdditional: unknown[] = Array.isArray( + colorConfig?.additionalColorSchemes, + ) ? colorConfig.additionalColorSchemes : []; const additional = rawAdditional .map((scheme) => normalizeColorScheme(scheme)) - .filter((value: ColorSchemeDefinition | null): value is ColorSchemeDefinition => - Boolean(value) + .filter( + (value: ColorSchemeDefinition | null): value is ColorSchemeDefinition => + Boolean(value), ); return [...DEFAULT_COLOR_SCHEME_DEFINITIONS, ...additional]; } function getActiveColorSchemeDefinition( - colorConfig?: AstericsColorConfig + colorConfig?: AstericsColorConfig, ): ColorSchemeDefinition | null { if (!colorConfig || colorConfig.colorSchemesActivated === false) { return null; @@ -554,9 +564,12 @@ function getActiveColorSchemeDefinition( } const activeName: string | undefined = - (typeof colorConfig.activeColorScheme === 'string' && colorConfig.activeColorScheme) || + (typeof colorConfig.activeColorScheme === "string" && + colorConfig.activeColorScheme) || undefined; - const normalizedName = activeName ? COLOR_SCHEME_ALIASES[activeName] || activeName : undefined; + const normalizedName = activeName + ? COLOR_SCHEME_ALIASES[activeName] || activeName + : undefined; if (normalizedName) { const match = schemes.find((scheme) => scheme.name === normalizedName); @@ -571,7 +584,7 @@ function getActiveColorSchemeDefinition( function getSchemeColorForCategory( category: string | undefined, scheme: ColorSchemeDefinition | null, - fallback?: string + fallback?: string, ): string | undefined { if (!scheme || !category) return fallback; let index = scheme.categories.indexOf(category); @@ -582,7 +595,7 @@ function getSchemeColorForCategory( return fallback; } const color = scheme.colors[index]; - return typeof color === 'string' ? color : fallback; + return typeof color === "string" ? color : fallback; } function resolveBorderColor( @@ -591,68 +604,83 @@ function resolveBorderColor( scheme: ColorSchemeDefinition | null, backgroundColor: string, schemeColor?: string, - fallbackBorder?: string + fallbackBorder?: string, ): string { - const defaultBorderColor = (fallbackBorder || '#808080').toLowerCase(); + const defaultBorderColor = (fallbackBorder || "#808080").toLowerCase(); const colorMode = - typeof colorConfig.colorMode === 'string' ? colorConfig.colorMode : 'COLOR_MODE_BACKGROUND'; + typeof colorConfig.colorMode === "string" + ? colorConfig.colorMode + : "COLOR_MODE_BACKGROUND"; - if (colorMode === 'COLOR_MODE_BORDER') { + if (colorMode === "COLOR_MODE_BORDER") { return ( - getSchemeColorForCategory(element.colorCategory, scheme, fallbackBorder || '#808080') || + getSchemeColorForCategory( + element.colorCategory, + scheme, + fallbackBorder || "#808080", + ) || fallbackBorder || - '#808080' + "#808080" ); } - if (colorMode === 'COLOR_MODE_BOTH') { + if (colorMode === "COLOR_MODE_BOTH") { if (!element.colorCategory) { - return 'transparent'; + return "transparent"; } const customBorder = scheme?.customBorders?.[element.colorCategory]; - if (typeof customBorder === 'string') { + if (typeof customBorder === "string") { return customBorder; } const baseColor = schemeColor || - getSchemeColorForCategory(element.colorCategory, scheme, backgroundColor) || + getSchemeColorForCategory( + element.colorCategory, + scheme, + backgroundColor, + ) || backgroundColor; const isDark = calculateLuminance(baseColor) < 0.5; const adjustment = isDark ? 60 : -40; return adjustHexColor(baseColor, adjustment); } - if (defaultBorderColor !== '#808080') { - return fallbackBorder || '#808080'; + if (defaultBorderColor !== "#808080") { + return fallbackBorder || "#808080"; } const gridBackground = - typeof colorConfig.gridBackgroundColor === 'string' + typeof colorConfig.gridBackgroundColor === "string" ? colorConfig.gridBackgroundColor - : '#ffffff'; + : "#ffffff"; return getHighContrastNeutralColor(gridBackground); } function resolveButtonColors( element: GridElement, colorConfig: AstericsColorConfig = {}, - scheme?: ColorSchemeDefinition | null + scheme?: ColorSchemeDefinition | null, ): { backgroundColor: string; borderColor: string; fontColor: string } { const fallbackBackground = - typeof colorConfig.elementBackgroundColor === 'string' + typeof colorConfig.elementBackgroundColor === "string" ? colorConfig.elementBackgroundColor - : '#FFFFFF'; + : "#FFFFFF"; const fallbackBorder = - typeof colorConfig.elementBorderColor === 'string' ? colorConfig.elementBorderColor : '#808080'; + typeof colorConfig.elementBorderColor === "string" + ? colorConfig.elementBorderColor + : "#808080"; const colorMode = - typeof colorConfig.colorMode === 'string' ? colorConfig.colorMode : 'COLOR_MODE_BACKGROUND'; + typeof colorConfig.colorMode === "string" + ? colorConfig.colorMode + : "COLOR_MODE_BACKGROUND"; const isSchemeActive = colorConfig?.colorSchemesActivated !== false; const schemeColor = - isSchemeActive && colorMode !== 'COLOR_MODE_BORDER' + isSchemeActive && colorMode !== "COLOR_MODE_BORDER" ? getSchemeColorForCategory(element.colorCategory, scheme || null) : undefined; - const backgroundColor = element.backgroundColor || schemeColor || fallbackBackground || '#FFFFFF'; + const backgroundColor = + element.backgroundColor || schemeColor || fallbackBackground || "#FFFFFF"; const borderColor = resolveBorderColor( element, @@ -660,11 +688,13 @@ function resolveButtonColors( scheme || null, backgroundColor, schemeColor, - fallbackBorder + fallbackBorder, ); const fontColor = - element.fontColor || colorConfig?.fontColor || getContrastingTextColor(backgroundColor); + element.fontColor || + colorConfig?.fontColor || + getContrastingTextColor(backgroundColor); return { backgroundColor, @@ -680,7 +710,7 @@ function resolveButtonColors( */ export function calculateLuminance(hexColor: string): number { // Remove # if present - const hex = hexColor.replace('#', ''); + const hex = hexColor.replace("#", ""); // Parse RGB values const r = parseInt(hex.substring(0, 2), 16) / 255; @@ -704,7 +734,7 @@ export function calculateLuminance(hexColor: string): number { export function getContrastingTextColor(backgroundColor: string): string { const luminance = calculateLuminance(backgroundColor); // WCAG threshold: use white text if luminance < 0.5, black otherwise - return luminance < 0.5 ? '#FFFFFF' : '#000000'; + return luminance < 0.5 ? "#FFFFFF" : "#000000"; } /** @@ -712,11 +742,13 @@ export function getContrastingTextColor(backgroundColor: string): string { * Asterics Grid: true = hidden, false = visible * Maps to: 'Hidden' | 'Visible' | undefined */ -function mapAstericsVisibility(hidden: boolean | undefined): 'Hidden' | 'Visible' | undefined { +function mapAstericsVisibility( + hidden: boolean | undefined, +): "Hidden" | "Visible" | undefined { if (hidden === undefined) { return undefined; // Default to visible } - return hidden ? 'Hidden' : 'Visible'; + return hidden ? "Hidden" : "Visible"; } class AstericsGridProcessor extends BaseProcessor { @@ -752,7 +784,9 @@ class AstericsGridProcessor extends BaseProcessor { return texts; } - private async extractRawTexts(filePathOrBuffer: ProcessorInput): Promise { + private async extractRawTexts( + filePathOrBuffer: ProcessorInput, + ): Promise { const { readTextFromInput } = this.options.fileAdapter; let content = await readTextFromInput(filePathOrBuffer); @@ -769,14 +803,14 @@ class AstericsGridProcessor extends BaseProcessor { grdFile.grids.forEach((grid: GridData) => { // Extract grid labels Object.values(grid.label || {}).forEach((label) => { - if (label && typeof label === 'string') texts.push(label); + if (label && typeof label === "string") texts.push(label); }); // Extract element texts grid.gridElements.forEach((element: GridElement) => { // Element labels Object.values(element.label || {}).forEach((label) => { - if (label && typeof label === 'string') texts.push(label); + if (label && typeof label === "string") texts.push(label); }); // Word forms @@ -799,39 +833,39 @@ class AstericsGridProcessor extends BaseProcessor { private extractActionTexts(action: GridAction, texts: string[]): void { switch (action.modelName) { - case 'GridActionSpeakCustom': - if (action.speakText && typeof action.speakText === 'object') { + case "GridActionSpeakCustom": + if (action.speakText && typeof action.speakText === "object") { const speakTextMap = action.speakText as Record; Object.values(speakTextMap).forEach((textValue) => { - if (typeof textValue === 'string' && textValue.length > 0) { + if (typeof textValue === "string" && textValue.length > 0) { texts.push(textValue); } }); } break; - case 'GridActionChangeLang': - if (action.language && typeof action.language === 'string') { + case "GridActionChangeLang": + if (action.language && typeof action.language === "string") { texts.push(action.language); } - if (action.voice && typeof action.voice === 'string') { + if (action.voice && typeof action.voice === "string") { texts.push(action.voice); } break; - case 'GridActionHTTP': - if (action.restUrl && typeof action.restUrl === 'string') { + case "GridActionHTTP": + if (action.restUrl && typeof action.restUrl === "string") { texts.push(action.restUrl); } - if (action.body && typeof action.body === 'string') { + if (action.body && typeof action.body === "string") { texts.push(action.body); } break; - case 'GridActionOpenWebpage': - if (action.openURL && typeof action.openURL === 'string') { + case "GridActionOpenWebpage": + if (action.openURL && typeof action.openURL === "string") { texts.push(action.openURL); } break; - case 'GridActionMatrix': - if (action.sendText && typeof action.sendText === 'string') { + case "GridActionMatrix": + if (action.sendText && typeof action.sendText === "string") { texts.push(action.sendText); } break; @@ -844,7 +878,9 @@ class AstericsGridProcessor extends BaseProcessor { const tree = new AACTree(); const filename = - typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.grd'; + typeof filePathOrBuffer === "string" + ? getBasename(filePathOrBuffer) + : "upload.grd"; const buffer = await readBinaryFromInput(filePathOrBuffer); try { @@ -861,19 +897,25 @@ class AstericsGridProcessor extends BaseProcessor { const validationResult = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: 'asterics', - message: 'Missing grids array in Asterics .grd file', - type: 'structure', - description: 'Asterics grid collection', + format: "asterics", + message: "Missing grids array in Asterics .grd file", + type: "structure", + description: "Asterics grid collection", }); - throw new ValidationFailureError('Invalid Asterics grid file', validationResult); + throw new ValidationFailureError( + "Invalid Asterics grid file", + validationResult, + ); } const rawColorConfig = grdFile.metadata?.colorConfig; - const colorConfig: AstericsColorConfig | undefined = isRecord(rawColorConfig) + const colorConfig: AstericsColorConfig | undefined = isRecord( + rawColorConfig, + ) ? (rawColorConfig as AstericsColorConfig) : undefined; - const activeColorSchemeDefinition = getActiveColorSchemeDefinition(colorConfig); + const activeColorSchemeDefinition = + getActiveColorSchemeDefinition(colorConfig); grdFile.grids.forEach((grid: GridData) => { const page = new AACPage({ @@ -883,12 +925,14 @@ class AstericsGridProcessor extends BaseProcessor { buttons: [], parentId: null, style: { - backgroundColor: colorConfig?.gridBackgroundColor || '#FFFFFF', - borderColor: colorConfig?.elementBorderColor || '#CCCCCC', + backgroundColor: colorConfig?.gridBackgroundColor || "#FFFFFF", + borderColor: colorConfig?.elementBorderColor || "#CCCCCC", borderWidth: colorConfig?.borderWidth || 1, - fontFamily: colorConfig?.fontFamily || 'Arial', - fontSize: colorConfig?.fontSizePct ? colorConfig.fontSizePct * 16 : 16, - fontColor: colorConfig?.fontColor || '#000000', + fontFamily: colorConfig?.fontFamily || "Arial", + fontSize: colorConfig?.fontSizePct + ? colorConfig.fontSizePct * 16 + : 16, + fontColor: colorConfig?.fontColor || "#000000", }, }); tree.addPage(page); @@ -909,7 +953,7 @@ class AstericsGridProcessor extends BaseProcessor { const button = this.createButtonFromElement( element, colorConfig, - activeColorSchemeDefinition + activeColorSchemeDefinition, ); page.addButton(button); @@ -918,8 +962,16 @@ class AstericsGridProcessor extends BaseProcessor { const buttonWidth = element.width || 1; const buttonHeight = element.height || 1; - for (let r = buttonY; r < buttonY + buttonHeight && r < maxRows; r++) { - for (let c = buttonX; c < buttonX + buttonWidth && c < maxCols; c++) { + for ( + let r = buttonY; + r < buttonY + buttonHeight && r < maxRows; + r++ + ) { + for ( + let c = buttonX; + c < buttonX + buttonWidth && c < maxCols; + c++ + ) { if (gridLayout[r] && gridLayout[r][c] === null) { gridLayout[r][c] = button; } @@ -927,10 +979,12 @@ class AstericsGridProcessor extends BaseProcessor { } const navAction = element.actions.find( - (a: GridAction) => a.modelName === 'GridActionNavigate' + (a: GridAction) => a.modelName === "GridActionNavigate", ); const targetGridId = - navAction && typeof navAction.toGridId === 'string' ? navAction.toGridId : undefined; + navAction && typeof navAction.toGridId === "string" + ? navAction.toGridId + : undefined; if (targetGridId) { const targetPage = tree.getPage(targetGridId); if (targetPage) { @@ -943,7 +997,7 @@ class AstericsGridProcessor extends BaseProcessor { }); const astericsMetadata: AstericsGridMetadata = { - format: 'asterics', + format: "asterics", hasGlobalGrid: false, }; @@ -967,10 +1021,10 @@ class AstericsGridProcessor extends BaseProcessor { if (languages.size > 0) { astericsMetadata.languages = Array.from(languages).sort(); - astericsMetadata.locale = languages.has('en') - ? 'en' - : languages.has('de') - ? 'de' + astericsMetadata.locale = languages.has("en") + ? "en" + : languages.has("de") + ? "de" : astericsMetadata.languages[0]; } } @@ -989,75 +1043,99 @@ class AstericsGridProcessor extends BaseProcessor { const validationResult = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: 'asterics', - message: err?.message || 'Failed to parse Asterics grid file', - type: 'parse', - description: 'Parse Asterics grid JSON', + format: "asterics", + message: err?.message || "Failed to parse Asterics grid file", + type: "parse", + description: "Parse Asterics grid JSON", }); - throw new ValidationFailureError('Failed to load Asterics grid', validationResult, err); + throw new ValidationFailureError( + "Failed to load Asterics grid", + validationResult, + err, + ); } } - private getLocalizedLabel(labelMap: { [lang: string]: string } | undefined): string { - if (!labelMap) return ''; + private getLocalizedLabel( + labelMap: { [lang: string]: string } | undefined, + ): string { + if (!labelMap) return ""; // Prefer English, then any available language - return labelMap.en || labelMap.de || labelMap.es || Object.values(labelMap)[0] || ''; + return ( + labelMap.en || + labelMap.de || + labelMap.es || + Object.values(labelMap)[0] || + "" + ); } private getLocalizedText(text: unknown): string { - if (typeof text === 'string') return text; + if (typeof text === "string") return text; if (isRecord(text)) { - const preferred = ['en', 'de', 'es']; + const preferred = ["en", "de", "es"]; for (const lang of preferred) { const value = text[lang]; - if (typeof value === 'string' && value.length > 0) { + if (typeof value === "string" && value.length > 0) { return value; } } const fallback = Object.values(text).find( - (value): value is string => typeof value === 'string' && value.length > 0 + (value): value is string => + typeof value === "string" && value.length > 0, ); if (fallback) { return fallback; } } - return ''; + return ""; } private createButtonFromElement( element: GridElement, colorConfig?: AstericsColorConfig, - activeColorScheme?: ColorSchemeDefinition | null + activeColorScheme?: ColorSchemeDefinition | null, ): AACButton { let audioRecording; if (this.loadAudio) { const audioAction = element.actions.find( - (a: GridAction) => a.modelName === 'GridActionAudio' + (a: GridAction) => a.modelName === "GridActionAudio", ); - if (audioAction && typeof audioAction.dataBase64 === 'string') { + if (audioAction && typeof audioAction.dataBase64 === "string") { const parsedId = Number.parseInt(String(audioAction.id), 10); const metadata: Record = {}; - if (typeof audioAction.mimeType === 'string') { + if (typeof audioAction.mimeType === "string") { metadata.mimeType = audioAction.mimeType; } - if (typeof audioAction.durationMs === 'number') { + if (typeof audioAction.durationMs === "number") { metadata.durationMs = audioAction.durationMs; } audioRecording = { id: Number.isNaN(parsedId) ? undefined : parsedId, - data: Buffer.from(audioAction.dataBase64, 'base64'), - identifier: typeof audioAction.filename === 'string' ? audioAction.filename : undefined, + data: Buffer.from(audioAction.dataBase64, "base64"), + identifier: + typeof audioAction.filename === "string" + ? audioAction.filename + : undefined, metadata: JSON.stringify(metadata), }; } } - const colorStyles = resolveButtonColors(element, colorConfig, activeColorScheme); + const colorStyles = resolveButtonColors( + element, + colorConfig, + activeColorScheme, + ); - const navAction = element.actions.find((a: GridAction) => a.modelName === 'GridActionNavigate'); + const navAction = element.actions.find( + (a: GridAction) => a.modelName === "GridActionNavigate", + ); const targetPageId = - navAction && typeof navAction.toGridId === 'string' ? navAction.toGridId : null; + navAction && typeof navAction.toGridId === "string" + ? navAction.toGridId + : null; const label = this.getLocalizedLabel(element.label); @@ -1076,18 +1154,20 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId: targetPageId, }, }; } else { // Check for other action types - const collectAction = element.actions.find((a) => a.modelName === 'GridActionCollectElement'); + const collectAction = element.actions.find( + (a) => a.modelName === "GridActionCollectElement", + ); if (collectAction) { // Handle text editing actions switch (collectAction.action) { - case 'COLLECT_ACTION_REMOVE_WORD': + case "COLLECT_ACTION_REMOVE_WORD": semanticAction = { category: AACSemanticCategory.TEXT_EDITING, intent: AACSemanticIntent.DELETE_WORD, @@ -1098,13 +1178,13 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Delete word', + type: "ACTION", + message: "Delete word", }, }; break; - case 'COLLECT_ACTION_REMOVE_CHAR': + case "COLLECT_ACTION_REMOVE_CHAR": semanticAction = { category: AACSemanticCategory.TEXT_EDITING, intent: AACSemanticIntent.DELETE_CHARACTER, @@ -1115,13 +1195,13 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Delete character', + type: "ACTION", + message: "Delete character", }, }; break; - case 'COLLECT_ACTION_CLEAR': + case "COLLECT_ACTION_CLEAR": semanticAction = { category: AACSemanticCategory.TEXT_EDITING, intent: AACSemanticIntent.CLEAR_TEXT, @@ -1132,8 +1212,8 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Clear text', + type: "ACTION", + message: "Clear text", }, }; break; @@ -1143,7 +1223,7 @@ class AstericsGridProcessor extends BaseProcessor { // Check for navigation actions with special nav types if (!semanticAction && navAction) { switch (navAction.navType) { - case 'TO_LAST': + case "TO_LAST": semanticAction = { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.GO_BACK, @@ -1154,13 +1234,13 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Go back', + type: "ACTION", + message: "Go back", }, }; break; - case 'TO_HOME': + case "TO_HOME": semanticAction = { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.GO_HOME, @@ -1171,8 +1251,8 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Go home', + type: "ACTION", + message: "Go home", }, }; break; @@ -1182,12 +1262,14 @@ class AstericsGridProcessor extends BaseProcessor { // Check for speak actions if no other semantic action was found if (!semanticAction) { const speakAction = element.actions.find( - (a) => a.modelName === 'GridActionSpeakCustom' || a.modelName === 'GridActionSpeak' + (a) => + a.modelName === "GridActionSpeakCustom" || + a.modelName === "GridActionSpeak", ); if (speakAction) { const speakText = - speakAction.modelName === 'GridActionSpeakCustom' + speakAction.modelName === "GridActionSpeakCustom" ? this.getLocalizedText(speakAction.speakText) : label; @@ -1202,7 +1284,7 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: 'SPEAK', + type: "SPEAK", message: speakText, }, }; @@ -1214,12 +1296,12 @@ class AstericsGridProcessor extends BaseProcessor { text: label, platformData: { astericsGrid: { - modelName: 'GridActionSpeak', + modelName: "GridActionSpeak", properties: {}, }, }, fallback: { - type: 'SPEAK', + type: "SPEAK", message: label, }, }; @@ -1232,7 +1314,7 @@ class AstericsGridProcessor extends BaseProcessor { element.backgroundColor || colorStyles.backgroundColor || colorConfig?.elementBackgroundColor || - '#FFFFFF'; + "#FFFFFF"; // Determine font color with priority: // 1. Explicit element.fontColor (highest priority) @@ -1253,11 +1335,11 @@ class AstericsGridProcessor extends BaseProcessor { // We need to strip the Data URL prefix before decoding try { let base64Data = element.image.data; - let imageFormat = 'png'; // Default format + let imageFormat = "png"; // Default format // Check if this is a Data URL and extract the base64 part const dataUrlMatch = base64Data.match( - /^data:image\/(png|jpeg|jpg|gif|svg\+xml);base64,(.+)/ + /^data:image\/(png|jpeg|jpg|gif|svg\+xml);base64,(.+)/, ); if (dataUrlMatch) { imageFormat = dataUrlMatch[1]; @@ -1265,7 +1347,7 @@ class AstericsGridProcessor extends BaseProcessor { } // Decode the base64 data - imageData = Buffer.from(base64Data, 'base64'); + imageData = Buffer.from(base64Data, "base64"); // Use detected format for filename imageName = element.image.id || `image.${imageFormat}`; @@ -1287,19 +1369,27 @@ class AstericsGridProcessor extends BaseProcessor { image: imageName, // Store image filename/reference style: { backgroundColor: finalBackgroundColor, - borderColor: colorStyles.borderColor || colorConfig?.elementBorderColor || '#CCCCCC', + borderColor: + colorStyles.borderColor || + colorConfig?.elementBorderColor || + "#CCCCCC", borderWidth: colorConfig?.borderWidth || 1, - fontFamily: colorConfig?.fontFamily || 'Arial', + fontFamily: colorConfig?.fontFamily || "Arial", fontSize: colorConfig?.fontSizePct ? colorConfig.fontSizePct * 16 : 16, fontColor: fontColor, }, - wordForms: element.wordForms && element.wordForms.length > 0 ? element.wordForms : undefined, + wordForms: + element.wordForms && element.wordForms.length > 0 + ? element.wordForms + : undefined, parameters: { ...(imageData ? { imageData: imageData } : {}), - ...(element.actions?.some((a: GridAction) => a.modelName === 'GridActionWordForm') + ...(element.actions?.some( + (a: GridAction) => a.modelName === "GridActionWordForm", + ) ? { wordFormActions: element.actions.filter( - (a: GridAction) => a.modelName === 'GridActionWordForm' + (a: GridAction) => a.modelName === "GridActionWordForm", ), } : {}), @@ -1310,9 +1400,10 @@ class AstericsGridProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, ): Promise { - const { readTextFromInput, readBinaryFromInput, writeTextToPath } = this.options.fileAdapter; + const { readTextFromInput, readBinaryFromInput, writeTextToPath } = + this.options.fileAdapter; let content = await readTextFromInput(filePathOrBuffer); @@ -1333,7 +1424,7 @@ class AstericsGridProcessor extends BaseProcessor { private applyTranslationsToGridFile( grdFile: AstericsGridFile, - translations: Map + translations: Map, ): void { grdFile.grids.forEach((grid: GridData) => { // Translate grid labels @@ -1384,14 +1475,20 @@ class AstericsGridProcessor extends BaseProcessor { }); } - private applyTranslationsToAction(action: GridAction, translations: Map): void { + private applyTranslationsToAction( + action: GridAction, + translations: Map, + ): void { switch (action.modelName) { - case 'GridActionSpeakCustom': - if (action.speakText && typeof action.speakText === 'object') { + case "GridActionSpeakCustom": + if (action.speakText && typeof action.speakText === "object") { const speakTextMap = action.speakText as Record; Object.keys(speakTextMap).forEach((lang) => { const originalText = speakTextMap[lang]; - if (typeof originalText === 'string' && translations.has(originalText)) { + if ( + typeof originalText === "string" && + translations.has(originalText) + ) { const translation = translations.get(originalText); if (translation !== undefined) { speakTextMap[lang] = translation; @@ -1400,44 +1497,59 @@ class AstericsGridProcessor extends BaseProcessor { }); } break; - case 'GridActionChangeLang': - if (typeof action.language === 'string' && translations.has(action.language)) { + case "GridActionChangeLang": + if ( + typeof action.language === "string" && + translations.has(action.language) + ) { const translation = translations.get(action.language); if (translation !== undefined) { action.language = translation; } } - if (typeof action.voice === 'string' && translations.has(action.voice)) { + if ( + typeof action.voice === "string" && + translations.has(action.voice) + ) { const translation = translations.get(action.voice); if (translation !== undefined) { action.voice = translation; } } break; - case 'GridActionHTTP': - if (typeof action.restUrl === 'string' && translations.has(action.restUrl)) { + case "GridActionHTTP": + if ( + typeof action.restUrl === "string" && + translations.has(action.restUrl) + ) { const translation = translations.get(action.restUrl); if (translation !== undefined) { action.restUrl = translation; } } - if (typeof action.body === 'string' && translations.has(action.body)) { + if (typeof action.body === "string" && translations.has(action.body)) { const translation = translations.get(action.body); if (translation !== undefined) { action.body = translation; } } break; - case 'GridActionOpenWebpage': - if (typeof action.openURL === 'string' && translations.has(action.openURL)) { + case "GridActionOpenWebpage": + if ( + typeof action.openURL === "string" && + translations.has(action.openURL) + ) { const translation = translations.get(action.openURL); if (translation !== undefined) { action.openURL = translation; } } break; - case 'GridActionMatrix': - if (typeof action.sendText === 'string' && translations.has(action.sendText)) { + case "GridActionMatrix": + if ( + typeof action.sendText === "string" && + translations.has(action.sendText) + ) { const translation = translations.get(action.sendText); if (translation !== undefined) { action.sendText = translation; @@ -1454,12 +1566,12 @@ class AstericsGridProcessor extends BaseProcessor { // Use default Asterics Grid styling instead of taking from first page // This prevents issues where the first page has unusual colors (like purple) const defaultPageStyle = { - backgroundColor: '#FFFFFF', // White background by default - borderColor: '#CCCCCC', + backgroundColor: "#FFFFFF", // White background by default + borderColor: "#CCCCCC", borderWidth: 1, - fontFamily: 'Arial', + fontFamily: "Arial", fontSize: 16, - fontColor: '#000000', + fontColor: "#000000", }; const grids: GridData[] = Object.values(tree.pages).map((page) => { @@ -1480,148 +1592,175 @@ class AstericsGridProcessor extends BaseProcessor { // Filter out navigation/system buttons if configured const filteredButtons = this.filterPageButtons(page.buttons); - const gridElements: GridElement[] = filteredButtons.map((button, index) => { - // Use grid position if available, otherwise arrange in rows of 4 - const gridWidth = 4; - const position = buttonPositions.get(button.id); - const calculatedX = position ? position.x : index % gridWidth; - const calculatedY = position ? position.y : Math.floor(index / gridWidth); - const actions: GridAction[] = []; - - // Add appropriate actions - prefer semantic actions - if (button.semanticAction?.platformData?.astericsGrid) { - // Use original AstericsGrid action data - const astericsData = button.semanticAction.platformData.astericsGrid; - actions.push({ - id: `grid-action-${button.id}`, - ...astericsData.properties, - modelName: astericsData.modelName, - modelVersion: - astericsData.properties.modelVersion || '{"major": 5, "minor": 0, "patch": 0}', - }); - } else if (button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO) { - // Create navigation action from semantic data - const targetId = button.semanticAction.targetId || button.targetPageId; - actions.push({ - id: `grid-action-navigate-${button.id}`, - modelName: 'GridActionNavigate', - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - navType: 'navigateToGrid', - toGridId: targetId, - }); - } else if (button.semanticAction?.intent === AACSemanticIntent.GO_BACK) { - // Create back navigation action - actions.push({ - id: `grid-action-navigate-back-${button.id}`, - modelName: 'GridActionNavigate', - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - navType: 'TO_LAST', - }); - } else if (button.semanticAction?.intent === AACSemanticIntent.GO_HOME) { - // Create home navigation action - actions.push({ - id: `grid-action-navigate-home-${button.id}`, - modelName: 'GridActionNavigate', - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - navType: 'TO_HOME', - }); - } else if (button.semanticAction?.intent === AACSemanticIntent.DELETE_WORD) { - // Create delete word action - actions.push({ - id: `grid-action-delete-word-${button.id}`, - modelName: 'GridActionCollectElement', - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - action: 'COLLECT_ACTION_REMOVE_WORD', - }); - } else if (button.semanticAction?.intent === AACSemanticIntent.DELETE_CHARACTER) { - // Create delete character action - actions.push({ - id: `grid-action-delete-char-${button.id}`, - modelName: 'GridActionCollectElement', - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - action: 'COLLECT_ACTION_REMOVE_CHAR', - }); - } else if (button.semanticAction?.intent === AACSemanticIntent.CLEAR_TEXT) { - // Create clear text action - actions.push({ - id: `grid-action-clear-${button.id}`, - modelName: 'GridActionCollectElement', - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - action: 'COLLECT_ACTION_CLEAR', - }); - } else if (button.semanticAction?.intent === AACSemanticIntent.SPEAK_TEXT) { - // Create speak action from semantic data - if (button.semanticAction.text && button.semanticAction.text !== button.label) { + const gridElements: GridElement[] = filteredButtons.map( + (button, index) => { + // Use grid position if available, otherwise arrange in rows of 4 + const gridWidth = 4; + const position = buttonPositions.get(button.id); + const calculatedX = position ? position.x : index % gridWidth; + const calculatedY = position + ? position.y + : Math.floor(index / gridWidth); + const actions: GridAction[] = []; + + // Add appropriate actions - prefer semantic actions + if (button.semanticAction?.platformData?.astericsGrid) { + // Use original AstericsGrid action data + const astericsData = + button.semanticAction.platformData.astericsGrid; actions.push({ - id: `grid-action-speak-${button.id}`, - modelName: 'GridActionSpeakCustom', + id: `grid-action-${button.id}`, + ...astericsData.properties, + modelName: astericsData.modelName, + modelVersion: + astericsData.properties.modelVersion || + '{"major": 5, "minor": 0, "patch": 0}', + }); + } else if ( + button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO + ) { + // Create navigation action from semantic data + const targetId = + button.semanticAction.targetId || button.targetPageId; + actions.push({ + id: `grid-action-navigate-${button.id}`, + modelName: "GridActionNavigate", + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + navType: "navigateToGrid", + toGridId: targetId, + }); + } else if ( + button.semanticAction?.intent === AACSemanticIntent.GO_BACK + ) { + // Create back navigation action + actions.push({ + id: `grid-action-navigate-back-${button.id}`, + modelName: "GridActionNavigate", modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - speakText: { en: button.semanticAction.text }, + navType: "TO_LAST", }); + } else if ( + button.semanticAction?.intent === AACSemanticIntent.GO_HOME + ) { + // Create home navigation action + actions.push({ + id: `grid-action-navigate-home-${button.id}`, + modelName: "GridActionNavigate", + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + navType: "TO_HOME", + }); + } else if ( + button.semanticAction?.intent === AACSemanticIntent.DELETE_WORD + ) { + // Create delete word action + actions.push({ + id: `grid-action-delete-word-${button.id}`, + modelName: "GridActionCollectElement", + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + action: "COLLECT_ACTION_REMOVE_WORD", + }); + } else if ( + button.semanticAction?.intent === AACSemanticIntent.DELETE_CHARACTER + ) { + // Create delete character action + actions.push({ + id: `grid-action-delete-char-${button.id}`, + modelName: "GridActionCollectElement", + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + action: "COLLECT_ACTION_REMOVE_CHAR", + }); + } else if ( + button.semanticAction?.intent === AACSemanticIntent.CLEAR_TEXT + ) { + // Create clear text action + actions.push({ + id: `grid-action-clear-${button.id}`, + modelName: "GridActionCollectElement", + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + action: "COLLECT_ACTION_CLEAR", + }); + } else if ( + button.semanticAction?.intent === AACSemanticIntent.SPEAK_TEXT + ) { + // Create speak action from semantic data + if ( + button.semanticAction.text && + button.semanticAction.text !== button.label + ) { + actions.push({ + id: `grid-action-speak-${button.id}`, + modelName: "GridActionSpeakCustom", + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + speakText: { en: button.semanticAction.text }, + }); + } else { + actions.push({ + id: `grid-action-speak-${button.id}`, + modelName: "GridActionSpeak", + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + }); + } } else { + // Default to speak action if no semantic action actions.push({ id: `grid-action-speak-${button.id}`, - modelName: 'GridActionSpeak', + modelName: "GridActionSpeak", modelVersion: '{"major": 5, "minor": 0, "patch": 0}', }); } - } else { - // Default to speak action if no semantic action - actions.push({ - id: `grid-action-speak-${button.id}`, - modelName: 'GridActionSpeak', - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - }); - } - // Add audio action if present - if (button.audioRecording && button.audioRecording.data) { - const metadata = JSON.parse(button.audioRecording.metadata || '{}'); - actions.push({ - id: button.audioRecording.id?.toString() || `grid-action-audio-${button.id}`, - modelName: 'GridActionAudio', - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - dataBase64: encodeBase64(button.audioRecording.data), - mimeType: metadata.mimeType || 'audio/wav', - durationMs: metadata.durationMs || 0, - filename: button.audioRecording.identifier || `audio-${button.id}`, - }); - } + // Add audio action if present + if (button.audioRecording && button.audioRecording.data) { + const metadata = JSON.parse(button.audioRecording.metadata || "{}"); + actions.push({ + id: + button.audioRecording.id?.toString() || + `grid-action-audio-${button.id}`, + modelName: "GridActionAudio", + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + dataBase64: encodeBase64(button.audioRecording.data), + mimeType: metadata.mimeType || "audio/wav", + durationMs: metadata.durationMs || 0, + filename: + button.audioRecording.identifier || `audio-${button.id}`, + }); + } - const locale = tree.metadata?.locale || 'en'; + const locale = tree.metadata?.locale || "en"; - if ( - button.parameters?.wordFormActions && - Array.isArray(button.parameters.wordFormActions) - ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - actions.push(...button.parameters.wordFormActions); - } + if ( + button.parameters?.wordFormActions && + Array.isArray(button.parameters.wordFormActions) + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + actions.push(...button.parameters.wordFormActions); + } - return { - id: button.id, - modelName: 'GridElement', - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - width: 1, - height: 1, - x: calculatedX, - y: calculatedY, - label: { [locale]: button.label }, - wordForms: button.wordForms || [], - image: { - data: null, - author: undefined, - authorURL: undefined, - }, - actions: actions, - type: 'ELEMENT_TYPE_NORMAL', - additionalProps: {}, - backgroundColor: - button.style?.backgroundColor || - page.style?.backgroundColor || - defaultPageStyle.backgroundColor, - }; - }); + return { + id: button.id, + modelName: "GridElement", + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + width: 1, + height: 1, + x: calculatedX, + y: calculatedY, + label: { [locale]: button.label }, + wordForms: button.wordForms || [], + image: { + data: null, + author: undefined, + authorURL: undefined, + }, + actions: actions, + type: "ELEMENT_TYPE_NORMAL", + additionalProps: {}, + backgroundColor: + button.style?.backgroundColor || + page.style?.backgroundColor || + defaultPageStyle.backgroundColor, + }; + }, + ); // Calculate grid dimensions based on button count const gridWidth = 4; @@ -1631,9 +1770,9 @@ class AstericsGridProcessor extends BaseProcessor { return { id: page.id, - modelName: 'GridData', + modelName: "GridData", modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - label: { [tree.metadata?.locale || 'en']: page.name }, + label: { [tree.metadata?.locale || "en"]: page.name }, rowCount: calculatedRows, minColumnCount: calculatedCols, gridElements: gridElements, @@ -1641,7 +1780,8 @@ class AstericsGridProcessor extends BaseProcessor { }); // Determine the home grid ID from tree.rootId, fallback to first grid - const homeGridId = tree.rootId || (grids.length > 0 ? grids[0].id : undefined); + const homeGridId = + tree.rootId || (grids.length > 0 ? grids[0].id : undefined); const grdFile: AstericsGridFile = { grids: grids, @@ -1658,11 +1798,11 @@ class AstericsGridProcessor extends BaseProcessor { // Add additional properties that might be useful elementMargin: 2, // Default margin borderRadius: 4, // Default border radius - colorMode: 'default', + colorMode: "default", lineHeight: 1.2, maxLines: 2, - textPosition: 'center', - fittingMode: 'fit', + textPosition: "center", + fittingMode: "fit", }, }, }; @@ -1677,7 +1817,7 @@ class AstericsGridProcessor extends BaseProcessor { filePath: string, elementId: string, audioData: Buffer, - metadata?: string + metadata?: string, ): Promise { const { readTextFromInput, writeTextToPath } = this.options.fileAdapter; let content = await readTextFromInput(filePath); @@ -1697,15 +1837,17 @@ class AstericsGridProcessor extends BaseProcessor { elementFound = true; // Remove existing audio action if present - element.actions = element.actions.filter((a) => a.modelName !== 'GridActionAudio'); + element.actions = element.actions.filter( + (a) => a.modelName !== "GridActionAudio", + ); // Add new audio action const audioAction: GridAction = { id: `grid-action-audio-${elementId}`, - modelName: 'GridActionAudio', + modelName: "GridActionAudio", modelVersion: '{"major": 5, "minor": 0, "patch": 0}', dataBase64: encodeBase64(audioData), - mimeType: 'audio/wav', + mimeType: "audio/wav", durationMs: 0, // Could be calculated from audio data filename: `audio-${elementId}.wav`, }; @@ -1713,9 +1855,12 @@ class AstericsGridProcessor extends BaseProcessor { if (metadata) { try { const parsedMetadata = JSON.parse(metadata); - audioAction.mimeType = parsedMetadata.mimeType || audioAction.mimeType; - audioAction.durationMs = parsedMetadata.durationMs || audioAction.durationMs; - audioAction.filename = parsedMetadata.filename || audioAction.filename; + audioAction.mimeType = + parsedMetadata.mimeType || audioAction.mimeType; + audioAction.durationMs = + parsedMetadata.durationMs || audioAction.durationMs; + audioAction.filename = + parsedMetadata.filename || audioAction.filename; } catch (_e) { // Use defaults if metadata parsing fails } @@ -1740,11 +1885,14 @@ class AstericsGridProcessor extends BaseProcessor { async createAudioEnhancedGridFile( sourceFilePath: string, targetFilePath: string, - audioMappings: Map + audioMappings: Map, ): Promise { const { writeBinaryToPath, readBinaryFromInput } = this.options.fileAdapter; // Copy the source file to target - await writeBinaryToPath(targetFilePath, await readBinaryFromInput(sourceFilePath)); + await writeBinaryToPath( + targetFilePath, + await readBinaryFromInput(sourceFilePath), + ); // Add audio recordings to the copy await Promise.all( @@ -1755,13 +1903,13 @@ class AstericsGridProcessor extends BaseProcessor { targetFilePath, elementId, audioInfo.audioData as Buffer, - audioInfo.metadata as string + audioInfo.metadata as string, ); } catch (error) { // Failed to add audio to element - continue with others console.warn(`Failed to add audio to element ${elementId}:`, error); } - }) + }), ); } @@ -1797,7 +1945,10 @@ class AstericsGridProcessor extends BaseProcessor { /** * Check if an element has audio recording */ - async hasAudioRecording(filePathOrBuffer: ProcessorInput, elementId: string): Promise { + async hasAudioRecording( + filePathOrBuffer: ProcessorInput, + elementId: string, + ): Promise { const { readTextFromInput } = this.options.fileAdapter; let content = await readTextFromInput(filePathOrBuffer); @@ -1812,7 +1963,9 @@ class AstericsGridProcessor extends BaseProcessor { for (const grid of grdFile.grids) { for (const element of grid.gridElements) { if (element.id === elementId) { - return element.actions.some((action) => action.modelName === 'GridActionAudio'); + return element.actions.some( + (action) => action.modelName === "GridActionAudio", + ); } } } @@ -1838,9 +1991,13 @@ class AstericsGridProcessor extends BaseProcessor { generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { - return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); + return this.generateTranslatedDownloadGeneric( + filePath, + translatedStrings, + sourceStrings, + ); } } diff --git a/src/processors/dotProcessor.ts b/src/processors/dotProcessor.ts index 132c0e2..f398a79 100644 --- a/src/processors/dotProcessor.ts +++ b/src/processors/dotProcessor.ts @@ -4,13 +4,18 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from '../core/baseProcessor'; -import { AACTree, AACPage, AACButton, AACSemanticIntent } from '../core/treeStructure'; +} from "../core/baseProcessor"; +import { + AACTree, + AACPage, + AACButton, + AACSemanticIntent, +} from "../core/treeStructure"; import { ValidationFailureError, buildValidationResultFromMessage, -} from '../validation/validationTypes'; -import { ProcessorInput, getBasename, encodeText } from '../utils/io'; +} from "../validation/validationTypes"; +import { ProcessorInput, getBasename, encodeText } from "../utils/io"; interface DotNode { id: string; @@ -35,7 +40,8 @@ class DotProcessor extends BaseProcessor { const edges: DotEdge[] = []; // Extract all edge statements using regex to handle single-line DOT files - const edgeRegex = /"?([^"\s]+)"?\s*->\s*"?([^"\s]+)"?(?:\s*\[label="([^"]+)"\])?/g; + const edgeRegex = + /"?([^"\s]+)"?\s*->\s*"?([^"\s]+)"?(?:\s*\[label="([^"]+)"\])?/g; // We need to find nodes, but avoid matching the target of an edge which might look like a node definition // e.g. A -> B [label="L"] -- "B [label="L"]" looks like a node def @@ -59,7 +65,10 @@ class DotProcessor extends BaseProcessor { // Mask this edge in the content so we don't match it as a node // We replace it with spaces to preserve indices if needed, but simple replacement is enough here - maskedContent = maskedContent.replace(fullMatch, ' '.repeat(fullMatch.length)); + maskedContent = maskedContent.replace( + fullMatch, + " ".repeat(fullMatch.length), + ); } // Now find explicit node definitions in the masked content @@ -73,7 +82,7 @@ class DotProcessor extends BaseProcessor { while ((nodeMatch = nodeRegex.exec(maskedContent)) !== null) { const [, id, rawLabel] = nodeMatch; // Unescape the label: replace \" with " and \\ with \ - const label = rawLabel.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + const label = rawLabel.replace(/\\"/g, '"').replace(/\\\\/g, "\\"); // Only update if not already defined or if we want to override the implicit label nodes.set(id, { id, label }); } @@ -108,7 +117,9 @@ class DotProcessor extends BaseProcessor { const { readBinaryFromInput, readTextFromInput } = this.options.fileAdapter; const filename = - typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.dot'; + typeof filePathOrBuffer === "string" + ? getBasename(filePathOrBuffer) + : "upload.dot"; const buffer = await readBinaryFromInput(filePathOrBuffer); const filesize = buffer.byteLength; @@ -119,34 +130,38 @@ class DotProcessor extends BaseProcessor { const validation = buildValidationResultFromMessage({ filename, filesize, - format: 'dot', - message: 'DOT file is empty', - type: 'content', - description: 'DOT file content', + format: "dot", + message: "DOT file is empty", + type: "content", + description: "DOT file content", }); - throw new ValidationFailureError('Empty DOT content', validation); + throw new ValidationFailureError("Empty DOT content", validation); } // Check for binary data (contains null bytes or non-printable characters) const head = content.substring(0, 100); for (let i = 0; i < head.length; i++) { const code = head.charCodeAt(i); - if (code === 0 || (code >= 0 && code <= 8) || (code >= 14 && code <= 31)) { + if ( + code === 0 || + (code >= 0 && code <= 8) || + (code >= 14 && code <= 31) + ) { const validation = buildValidationResultFromMessage({ filename, filesize, - format: 'dot', - message: 'DOT appears to be binary data', - type: 'content', - description: 'DOT file content', + format: "dot", + message: "DOT appears to be binary data", + type: "content", + description: "DOT file content", }); - throw new ValidationFailureError('Invalid DOT content', validation); + throw new ValidationFailureError("Invalid DOT content", validation); } } const { nodes, edges } = this.parseDotFile(content); const tree = new AACTree(); - tree.metadata.format = 'dot'; + tree.metadata.format = "dot"; // Create pages for each node and add a self button representing the node label for (const node of nodes) { @@ -168,9 +183,9 @@ class DotProcessor extends BaseProcessor { semanticAction: { intent: AACSemanticIntent.SPEAK_TEXT, text: node.label, - fallback: { type: 'SPEAK', message: node.label }, + fallback: { type: "SPEAK", message: node.label }, }, - }) + }), ); } @@ -181,7 +196,7 @@ class DotProcessor extends BaseProcessor { const button = new AACButton({ id: `nav_${edge.from}_${edge.to}`, label: edge.label || edge.to, - message: '', + message: "", targetPageId: edge.to, }); @@ -198,19 +213,23 @@ class DotProcessor extends BaseProcessor { const validation = buildValidationResultFromMessage({ filename, filesize, - format: 'dot', - message: error?.message || 'Failed to parse DOT file', - type: 'parse', - description: 'Parse DOT graph', + format: "dot", + message: error?.message || "Failed to parse DOT file", + type: "parse", + description: "Parse DOT graph", }); - throw new ValidationFailureError('Failed to load DOT file', validation, error); + throw new ValidationFailureError( + "Failed to load DOT file", + validation, + error, + ); } } async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, ): Promise { const { readTextFromInput, writeBinaryToPath } = this.options.fileAdapter; @@ -218,19 +237,19 @@ class DotProcessor extends BaseProcessor { let translatedContent = content; translations.forEach((translation, text) => { - if (typeof text === 'string' && typeof translation === 'string') { + if (typeof text === "string" && typeof translation === "string") { // Escape special regex characters in the text - const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const escapedTranslation = translation.replace(/\$/g, '$$$$'); // Escape $ in replacement + const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const escapedTranslation = translation.replace(/\$/g, "$$$$"); // Escape $ in replacement translatedContent = translatedContent.replace( - new RegExp(`label="${escapedText}"`, 'g'), - `label="${escapedTranslation}"` + new RegExp(`label="${escapedText}"`, "g"), + `label="${escapedTranslation}"`, ); } }); - const resultBuffer = encodeText(translatedContent || ''); + const resultBuffer = encodeText(translatedContent || ""); await writeBinaryToPath(outputPath, resultBuffer); return resultBuffer; } @@ -238,11 +257,11 @@ class DotProcessor extends BaseProcessor { async saveFromTree(tree: AACTree, _outputPath: string): Promise { const { writeTextToPath } = this.options.fileAdapter; - let dotContent = `digraph "${tree.metadata?.name || 'AACBoard'}" {\n`; + let dotContent = `digraph "${tree.metadata?.name || "AACBoard"}" {\n`; // Helper to escape DOT string const escapeDotString = (str: string): string => { - return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); }; if (tree.metadata?.name) { @@ -262,7 +281,9 @@ class DotProcessor extends BaseProcessor { .filter((btn: AACButton) => { const intentStr = String(btn.semanticAction?.intent); return ( - intentStr === 'NAVIGATE_TO' || !!btn.targetPageId || !!btn.semanticAction?.targetId + intentStr === "NAVIGATE_TO" || + !!btn.targetPageId || + !!btn.semanticAction?.targetId ); }) .forEach((btn: AACButton) => { @@ -273,7 +294,7 @@ class DotProcessor extends BaseProcessor { }); } - dotContent += '}\n'; + dotContent += "}\n"; await writeTextToPath(_outputPath, dotContent); } @@ -281,7 +302,9 @@ class DotProcessor extends BaseProcessor { * Extract strings with metadata for aac-tools-platform compatibility * Uses the generic implementation from BaseProcessor */ - async extractStringsWithMetadata(filePath: string): Promise { + async extractStringsWithMetadata( + filePath: string, + ): Promise { return this.extractStringsWithMetadataGeneric(filePath); } @@ -292,9 +315,13 @@ class DotProcessor extends BaseProcessor { async generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { - return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); + return this.generateTranslatedDownloadGeneric( + filePath, + translatedStrings, + sourceStrings, + ); } } diff --git a/src/processors/excelProcessor.ts b/src/processors/excelProcessor.ts index 923ee74..e328900 100644 --- a/src/processors/excelProcessor.ts +++ b/src/processors/excelProcessor.ts @@ -1,13 +1,18 @@ -import { ProcessorInput } from '../utils/io'; -import * as ExcelJS from 'exceljs'; +import { ProcessorInput } from "../utils/io"; +import * as ExcelJS from "exceljs"; import { BaseProcessor, ExtractStringsResult, TranslatedString, SourceString, -} from '../core/baseProcessor'; -import { AACTree, AACPage, AACButton, AACSemanticIntent } from '../core/treeStructure'; -import { AACStyle } from '../types/aac'; +} from "../core/baseProcessor"; +import { + AACTree, + AACPage, + AACButton, + AACSemanticIntent, +} from "../core/treeStructure"; +import { AACStyle } from "../types/aac"; /** * Excel Processor for converting AAC grids to Excel format @@ -15,7 +20,13 @@ import { AACStyle } from '../types/aac'; * Supports visual styling, navigation links, and vocabulary analysis workflows */ export class ExcelProcessor extends BaseProcessor { - private static readonly NAVIGATION_BUTTONS = ['Home', 'Message Bar', 'Delete', 'Back', 'Clear']; + private static readonly NAVIGATION_BUTTONS = [ + "Home", + "Message Bar", + "Delete", + "Back", + "Clear", + ]; /** * Extract all text content from an Excel file @@ -23,7 +34,7 @@ export class ExcelProcessor extends BaseProcessor { * @returns Promise resolving to all text content found in the Excel file */ async extractTexts(_filePathOrBuffer: ProcessorInput): Promise { - console.warn('ExcelProcessor.extractTexts is not implemented yet.'); + console.warn("ExcelProcessor.extractTexts is not implemented yet."); return Promise.resolve([]); } @@ -33,9 +44,9 @@ export class ExcelProcessor extends BaseProcessor { * @returns Promise resolving to an AACTree representation of the Excel file */ async loadIntoTree(_filePathOrBuffer: ProcessorInput): Promise { - console.warn('ExcelProcessor.loadIntoTree is not implemented yet.'); + console.warn("ExcelProcessor.loadIntoTree is not implemented yet."); const tree = new AACTree(); - tree.metadata.format = 'excel'; + tree.metadata.format = "excel"; return Promise.resolve(tree); } @@ -49,10 +60,10 @@ export class ExcelProcessor extends BaseProcessor { async processTexts( _filePathOrBuffer: ProcessorInput, _translations: Map, - outputPath: string + outputPath: string, ): Promise { const { dirname, pathExists, mkDir } = this.options.fileAdapter; - console.warn('ExcelProcessor.processTexts is not implemented yet.'); + console.warn("ExcelProcessor.processTexts is not implemented yet."); const outputDir = dirname(outputPath); if (!(await pathExists(outputDir))) { await mkDir(outputDir, { recursive: true }); @@ -71,10 +82,13 @@ export class ExcelProcessor extends BaseProcessor { workbook: ExcelJS.Workbook, page: AACPage, tree: AACTree, - usedNames: Set = new Set() + usedNames: Set = new Set(), ): void { // Create worksheet with page name (sanitized for Excel and unique) - const worksheetName = this.getUniqueWorksheetName(page.name || page.id, usedNames); + const worksheetName = this.getUniqueWorksheetName( + page.name || page.id, + usedNames, + ); const worksheet = workbook.addWorksheet(worksheetName); // Determine grid dimensions @@ -138,7 +152,7 @@ export class ExcelProcessor extends BaseProcessor { private convertGridLayout( worksheet: ExcelJS.Worksheet, grid: Array>, - startRow: number + startRow: number, ): void { for (let row = 0; row < grid.length; row++) { for (let col = 0; col < grid[row].length; col++) { @@ -165,7 +179,7 @@ export class ExcelProcessor extends BaseProcessor { buttons: AACButton[], rows: number, cols: number, - startRow: number + startRow: number, ): void { for (let i = 0; i < buttons.length; i++) { const button = buttons[i]; @@ -191,12 +205,12 @@ export class ExcelProcessor extends BaseProcessor { worksheet: ExcelJS.Worksheet, button: AACButton, row: number, - col: number + col: number, ): void { const cell = worksheet.getCell(row, col); // Set cell value to button label - cell.value = button.label || ''; + cell.value = button.label || ""; // Add button message as cell comment if different from label if (button.message && button.message !== button.label) { @@ -209,7 +223,10 @@ export class ExcelProcessor extends BaseProcessor { } // Add navigation link if this is a navigation button - if (button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && button.targetPageId) { + if ( + button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && + button.targetPageId + ) { this.addNavigationLink(cell, button.targetPageId); } @@ -230,8 +247,8 @@ export class ExcelProcessor extends BaseProcessor { // Background color if (style.backgroundColor) { fill = { - type: 'pattern', - pattern: 'solid', + type: "pattern", + pattern: "solid", fgColor: { argb: this.convertColorToArgb(style.backgroundColor) }, }; } @@ -252,12 +269,12 @@ export class ExcelProcessor extends BaseProcessor { } // Font weight - if (style.fontWeight === 'bold') { + if (style.fontWeight === "bold") { font.bold = true; } // Font style - if (style.fontStyle === 'italic') { + if (style.fontStyle === "italic") { font.italic = true; } @@ -267,12 +284,12 @@ export class ExcelProcessor extends BaseProcessor { } // Border - if (style.borderColor || typeof style.borderWidth === 'number') { + if (style.borderColor || typeof style.borderWidth === "number") { const borderWidth = style.borderWidth ?? 1; - const borderStyle = borderWidth > 1 ? 'thick' : 'thin'; + const borderStyle = borderWidth > 1 ? "thick" : "thin"; const borderColor = style.borderColor ? { argb: this.convertColorToArgb(style.borderColor) } - : { argb: 'FF000000' }; // Default black + : { argb: "FF000000" }; // Default black border = { top: { style: borderStyle, color: borderColor }, @@ -295,8 +312,8 @@ export class ExcelProcessor extends BaseProcessor { // Center align text cell.alignment = { - vertical: 'middle', - horizontal: 'center', + vertical: "middle", + horizontal: "center", wrapText: true, }; } @@ -307,16 +324,16 @@ export class ExcelProcessor extends BaseProcessor { * @returns ARGB color string */ private convertColorToArgb(color?: string): string { - if (!color) return 'FFFFFFFF'; // Default white + if (!color) return "FFFFFFFF"; // Default white // Remove any whitespace color = color.trim(); // If already in hex format - if (color.startsWith('#')) { + if (color.startsWith("#")) { const hex = color.substring(1); if (hex.length === 6) { - return 'FF' + hex.toUpperCase(); // Add alpha channel + return "FF" + hex.toUpperCase(); // Add alpha channel } else if (hex.length === 8) { return hex.toUpperCase(); // Already has alpha } @@ -325,26 +342,30 @@ export class ExcelProcessor extends BaseProcessor { // Handle rgb() format const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (rgbMatch) { - const r = parseInt(rgbMatch[1]).toString(16).padStart(2, '0'); - const g = parseInt(rgbMatch[2]).toString(16).padStart(2, '0'); - const b = parseInt(rgbMatch[3]).toString(16).padStart(2, '0'); - return 'FF' + r.toUpperCase() + g.toUpperCase() + b.toUpperCase(); + const r = parseInt(rgbMatch[1]).toString(16).padStart(2, "0"); + const g = parseInt(rgbMatch[2]).toString(16).padStart(2, "0"); + const b = parseInt(rgbMatch[3]).toString(16).padStart(2, "0"); + return "FF" + r.toUpperCase() + g.toUpperCase() + b.toUpperCase(); } // Handle rgba() format - const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/); + const rgbaMatch = color.match( + /rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/, + ); if (rgbaMatch) { - const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, '0'); - const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, '0'); - const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, '0'); + const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, "0"); + const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, "0"); + const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, "0"); const a = Math.round(parseFloat(rgbaMatch[4]) * 255) .toString(16) - .padStart(2, '0'); - return a.toUpperCase() + r.toUpperCase() + g.toUpperCase() + b.toUpperCase(); + .padStart(2, "0"); + return ( + a.toUpperCase() + r.toUpperCase() + g.toUpperCase() + b.toUpperCase() + ); } // Default fallback - return 'FFFFFFFF'; + return "FFFFFFFF"; } /** @@ -357,11 +378,11 @@ export class ExcelProcessor extends BaseProcessor { const sanitizedTargetName = this.sanitizeWorksheetName(targetPageId); cell.value = { text: - typeof cell.value === 'string' + typeof cell.value === "string" ? cell.value - : typeof cell.value === 'number' || typeof cell.value === 'boolean' + : typeof cell.value === "number" || typeof cell.value === "boolean" ? String(cell.value) - : '', + : "", hyperlink: `#'${sanitizedTargetName}'!A1`, }; } @@ -372,7 +393,11 @@ export class ExcelProcessor extends BaseProcessor { * @param row - Row number * @param col - Column number */ - private setCellSize(worksheet: ExcelJS.Worksheet, row: number, col: number): void { + private setCellSize( + worksheet: ExcelJS.Worksheet, + row: number, + col: number, + ): void { // Set column width (approximately 15 characters wide) const column = worksheet.getColumn(col); if (!column.width || column.width < 15) { @@ -392,7 +417,11 @@ export class ExcelProcessor extends BaseProcessor { * @param page - Current AAC page * @param tree - Full AAC tree for navigation context */ - private addNavigationRow(worksheet: ExcelJS.Worksheet, page: AACPage, tree: AACTree): void { + private addNavigationRow( + worksheet: ExcelJS.Worksheet, + page: AACPage, + tree: AACTree, + ): void { const navButtons = ExcelProcessor.NAVIGATION_BUTTONS; for (let i = 0; i < navButtons.length; i++) { @@ -401,32 +430,32 @@ export class ExcelProcessor extends BaseProcessor { // Style navigation buttons differently cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE0E0E0' }, // Light gray background + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE0E0E0" }, // Light gray background }; cell.font = { bold: true, - color: { argb: 'FF000000' }, // Black text + color: { argb: "FF000000" }, // Black text }; cell.border = { - top: { style: 'thin', color: { argb: 'FF000000' } }, - left: { style: 'thin', color: { argb: 'FF000000' } }, - bottom: { style: 'thin', color: { argb: 'FF000000' } }, - right: { style: 'thin', color: { argb: 'FF000000' } }, + top: { style: "thin", color: { argb: "FF000000" } }, + left: { style: "thin", color: { argb: "FF000000" } }, + bottom: { style: "thin", color: { argb: "FF000000" } }, + right: { style: "thin", color: { argb: "FF000000" } }, }; cell.alignment = { - vertical: 'middle', - horizontal: 'center', + vertical: "middle", + horizontal: "center", }; // Add navigation functionality for specific buttons - if (navButtons[i] === 'Home' && tree.rootId) { + if (navButtons[i] === "Home" && tree.rootId) { this.addNavigationLink(cell, tree.rootId); - } else if (navButtons[i] === 'Back' && page.parentId) { + } else if (navButtons[i] === "Back" && page.parentId) { this.addNavigationLink(cell, page.parentId); } } @@ -443,7 +472,7 @@ export class ExcelProcessor extends BaseProcessor { worksheet: ExcelJS.Worksheet, rows: number, cols: number, - startRow: number + startRow: number, ): void { // Set default column widths for (let col = 1; col <= cols; col++) { @@ -463,7 +492,7 @@ export class ExcelProcessor extends BaseProcessor { // Freeze navigation row if present if (startRow > 1) { - worksheet.views = [{ state: 'frozen', ySplit: 1 }]; + worksheet.views = [{ state: "frozen", ySplit: 1 }]; } } @@ -477,12 +506,12 @@ export class ExcelProcessor extends BaseProcessor { // - Max 31 characters // - Cannot contain: \ / ? * [ ] : // - Cannot be empty - let cleaned = (name || '').replace(/[\\/?*:]/g, '_'); - cleaned = cleaned.replace(/\[/g, '_').replace(/\]/g, '_'); + let cleaned = (name || "").replace(/[\\/?*:]/g, "_"); + cleaned = cleaned.replace(/\[/g, "_").replace(/\]/g, "_"); cleaned = cleaned.substring(0, 31); if (cleaned.length === 0) { - return 'Sheet1'; + return "Sheet1"; } return cleaned; @@ -548,13 +577,16 @@ export class ExcelProcessor extends BaseProcessor { await this.saveFromTreeAsync(tree, outputPath); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - console.error('Failed to save Excel file:', message); + console.error("Failed to save Excel file:", message); try { - const fallbackPath = outputPath.replace(/\.xlsx$/i, '_error.txt'); + const fallbackPath = outputPath.replace(/\.xlsx$/i, "_error.txt"); await mkDir(dirname(fallbackPath), { recursive: true }); - await writeTextToPath(fallbackPath, `Error saving Excel file: ${message}`); + await writeTextToPath( + fallbackPath, + `Error saving Excel file: ${message}`, + ); } catch (writeError) { - console.error('Failed to write Excel error file:', writeError); + console.error("Failed to write Excel error file:", writeError); } } } @@ -562,22 +594,25 @@ export class ExcelProcessor extends BaseProcessor { /** * Async version of saveFromTree for internal use */ - private async saveFromTreeAsync(tree: AACTree, outputPath: string): Promise { + private async saveFromTreeAsync( + tree: AACTree, + outputPath: string, + ): Promise { const workbook = new ExcelJS.Workbook(); const metadata = tree.metadata; // Set workbook properties from tree metadata - workbook.creator = metadata?.author || 'AACProcessors'; - workbook.lastModifiedBy = 'AACProcessors'; + workbook.creator = metadata?.author || "AACProcessors"; + workbook.lastModifiedBy = "AACProcessors"; workbook.created = new Date(); workbook.modified = new Date(); - workbook.title = metadata?.name || ''; - workbook.subject = metadata?.description || ''; + workbook.title = metadata?.name || ""; + workbook.subject = metadata?.description || ""; // If no pages, create a default empty worksheet if (Object.keys(tree.pages).length === 0) { - const worksheet = workbook.addWorksheet('Empty'); - worksheet.getCell('A1').value = 'No AAC pages found'; + const worksheet = workbook.addWorksheet("Empty"); + worksheet.getCell("A1").value = "No AAC pages found"; await workbook.xlsx.writeFile(outputPath); return; } @@ -610,8 +645,12 @@ export class ExcelProcessor extends BaseProcessor { generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { - return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); + return this.generateTranslatedDownloadGeneric( + filePath, + translatedStrings, + sourceStrings, + ); } } diff --git a/src/processors/gridset/cellHelpers.ts b/src/processors/gridset/cellHelpers.ts new file mode 100644 index 0000000..41f35ff --- /dev/null +++ b/src/processors/gridset/cellHelpers.ts @@ -0,0 +1,123 @@ +/** + * Grid3 Cell Helpers + * + * Utilities for working with Grid 3 cells, including finding button positions + * and calculating cell spans in grid layouts. + */ + +import type { AACPage, AACButton } from "../../core/treeStructure"; + +/** + * Cell position with span information + */ +export interface CellPosition { + /** X coordinate (column) */ + x: number; + /** Y coordinate (row) */ + y: number; + /** Number of columns the cell spans */ + columnSpan: number; + /** Number of rows the cell spans */ + rowSpan: number; +} + +/** + * Find button position with span information + * + * Searches the page's grid layout for a button and calculates its position + * and span (how many columns/rows it occupies). + * + * @param page - The AAC page containing the button + * @param button - The button to locate + * @param fallbackIndex - Index to use if button not found in grid + * @returns Position and span information for the button + * + * @example + * const position = findButtonPosition(page, button, 0); + * console.log(`Button at ${position.x},${position.y} spans ${position.columnSpan}x${position.rowSpan}`); + */ +export function findButtonPosition( + page: AACPage, + button: AACButton, + fallbackIndex: number, +): CellPosition { + if (page.grid && page.grid.length > 0) { + // Search for button in grid layout and calculate span + for (let y = 0; y < page.grid.length; y++) { + for (let x = 0; x < page.grid[y].length; x++) { + const current = page.grid[y][x]; + if (current && current.id === button.id) { + // Calculate span by checking how far the same button extends + let columnSpan = 1; + let rowSpan = 1; + + // Check column span (rightward) + while (x + columnSpan < page.grid[y].length) { + const right = page.grid[y][x + columnSpan]; + if (right && right.id === button.id) { + columnSpan++; + } else { + break; + } + } + + // Check row span (downward) + while (y + rowSpan < page.grid.length) { + const below = page.grid[y + rowSpan][x]; + if (below && below.id === button.id) { + rowSpan++; + } else { + break; + } + } + + return { x, y, columnSpan, rowSpan }; + } + } + } + } + + // Fallback positioning + const gridCols = + page.grid?.[0]?.length || Math.ceil(Math.sqrt(page.buttons.length)); + return { + x: fallbackIndex % gridCols, + y: Math.floor(fallbackIndex / gridCols), + columnSpan: 1, + rowSpan: 1, + }; +} + +/** + * Calculate cell position key for Maps and Sets + * + * Creates a string key from X and Y coordinates for use as a Map key or Set entry. + * + * @param x - X coordinate + * @param y - Y coordinate + * @returns String key in format "x,y" + * + * @example + * const key = cellPositionKey(5, 3); + * console.log(key); // "5,3" + */ +export function cellPositionKey(x: number, y: number): string { + return `${x},${y}`; +} + +/** + * Parse cell position key + * + * Extracts X and Y coordinates from a position key string. + * + * @param key - Position key in format "x,y" + * @returns Object with x and y properties + * + * @example + * const pos = parseCellPositionKey("5,3"); + * console.log(pos); // { x: 5, y: 3 } + */ +export function parseCellPositionKey(key: string): { x: number; y: number } { + const [x, y] = key.split(",").map(Number); + return { x, y }; +} diff --git a/src/processors/gridset/colorUtils.ts b/src/processors/gridset/colorUtils.ts index 4f59fee..53b7863 100644 --- a/src/processors/gridset/colorUtils.ts +++ b/src/processors/gridset/colorUtils.ts @@ -168,7 +168,9 @@ const CSS_COLORS: Record = { * @param name - CSS color name (case-insensitive) * @returns RGB tuple [r, g, b] or undefined if not found */ -export function getNamedColor(name: string): [number, number, number] | undefined { +export function getNamedColor( + name: string, +): [number, number, number] | undefined { const color = CSS_COLORS[name.toLowerCase()]; return color; } @@ -196,7 +198,7 @@ export function rgbaToHex(r: number, g: number, b: number, a: number): string { */ export function channelToHex(value: number): string { const clamped = Math.max(0, Math.min(255, Math.round(value))); - return clamped.toString(16).padStart(2, '0').toUpperCase(); + return clamped.toString(16).padStart(2, "0").toUpperCase(); } /** @@ -231,14 +233,16 @@ export function clampAlpha(value: number): number { */ export function toHexColor(value: string): string | undefined { // Try hex format - const hexMatch = value.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i); + const hexMatch = value.match( + /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i, + ); if (hexMatch) { const hex = hexMatch[1]; if (hex.length === 3 || hex.length === 4) { return `#${hex - .split('') + .split("") .map((char) => char + char) - .join('')}`; + .join("")}`; } return `#${hex}`; } @@ -247,7 +251,7 @@ export function toHexColor(value: string): string | undefined { const rgbMatch = value.match(/^rgba?\((.+)\)$/i); if (rgbMatch) { const parts = rgbMatch[1] - .split(',') + .split(",") .map((part) => part.trim()) .filter(Boolean); if (parts.length === 3 || parts.length === 4) { @@ -278,7 +282,7 @@ export function toHexColor(value: string): string | undefined { export function darkenColor(hex: string, amount: number): string { const normalized = ensureAlphaChannel(hex).substring(1); // strip # const rgb = normalized.substring(0, 6); - const alpha = normalized.substring(6) || 'FF'; + const alpha = normalized.substring(6) || "FF"; const r = parseInt(rgb.substring(0, 2), 16); const g = parseInt(rgb.substring(2, 4), 16); const b = parseInt(rgb.substring(4, 6), 16); @@ -298,7 +302,7 @@ export function darkenColor(hex: string, amount: number): string { export function lightenColor(hex: string, amount: number): string { const normalized = ensureAlphaChannel(hex).substring(1); // strip # const rgb = normalized.substring(0, 6); - const alpha = normalized.substring(6) || 'FF'; + const alpha = normalized.substring(6) || "FF"; const r = parseInt(rgb.substring(0, 2), 16); const g = parseInt(rgb.substring(2, 4), 16); const b = parseInt(rgb.substring(4, 6), 16); @@ -322,7 +326,7 @@ export function hexToRgba(hex: string): { } { const normalized = ensureAlphaChannel(hex).substring(1); // strip # const rgb = normalized.substring(0, 6); - const alphaHex = normalized.substring(6) || 'FF'; + const alphaHex = normalized.substring(6) || "FF"; const r = parseInt(rgb.substring(0, 2), 16); const g = parseInt(rgb.substring(2, 4), 16); const b = parseInt(rgb.substring(4, 6), 16); @@ -336,7 +340,10 @@ export function hexToRgba(hex: string): { * @param fallback - Fallback color if input is invalid (default: white) * @returns Normalized color in format #AARRGGBBFF */ -export function normalizeColor(input: string, fallback: string = '#FFFFFFFF'): string { +export function normalizeColor( + input: string, + fallback: string = "#FFFFFFFF", +): string { const trimmed = input.trim(); if (!trimmed) { return fallback; @@ -356,11 +363,11 @@ export function normalizeColor(input: string, fallback: string = '#FFFFFFFF'): s * @returns Color with alpha channel in format #AARRGGBBFF */ export function ensureAlphaChannel(color: string | undefined): string { - if (!color) return '#FFFFFFFF'; + if (!color) return "#FFFFFFFF"; // If already 8 digits (with alpha), return as is if (color.match(/^#[0-9A-Fa-f]{8}$/)) return color; // If 6 digits (no alpha), add FF for fully opaque - if (color.match(/^#[0-9A-Fa-f]{6}$/)) return color + 'FF'; + if (color.match(/^#[0-9A-Fa-f]{6}$/)) return color + "FF"; // If 3 digits (shorthand), expand to 8 if (color.match(/^#[0-9A-Fa-f]{3}$/)) { const r = color[1]; @@ -369,5 +376,5 @@ export function ensureAlphaChannel(color: string | undefined): string { return `#${r}${r}${g}${g}${b}${b}FF`; } // Invalid or unknown format, return white - return '#FFFFFFFF'; + return "#FFFFFFFF"; } diff --git a/src/processors/gridset/commands.ts b/src/processors/gridset/commands.ts index 86e071d..77217cb 100644 --- a/src/processors/gridset/commands.ts +++ b/src/processors/gridset/commands.ts @@ -16,23 +16,23 @@ * Command categories in Grid 3 */ export enum Grid3CommandCategory { - NAVIGATION = 'navigation', - COMMUNICATION = 'communication', - TEXT_EDITING = 'text_editing', - COMPUTER_CONTROL = 'computer_control', - WEB_BROWSER = 'web_browser', - EMAIL = 'email', - PHONE = 'phone', - SMS = 'sms', - SYSTEM = 'system', - SETTINGS = 'settings', - SPEECH = 'speech', - AUTO_CONTENT = 'auto_content', - ENVIRONMENT_CONTROL = 'environment_control', - MOUSE = 'mouse', - WINDOW = 'window', - MEDIA = 'media', - CUSTOM = 'custom', + NAVIGATION = "navigation", + COMMUNICATION = "communication", + TEXT_EDITING = "text_editing", + COMPUTER_CONTROL = "computer_control", + WEB_BROWSER = "web_browser", + EMAIL = "email", + PHONE = "phone", + SMS = "sms", + SYSTEM = "system", + SETTINGS = "settings", + SPEECH = "speech", + AUTO_CONTENT = "auto_content", + ENVIRONMENT_CONTROL = "environment_control", + MOUSE = "mouse", + WINDOW = "window", + MEDIA = "media", + CUSTOM = "custom", } /** @@ -40,7 +40,7 @@ export enum Grid3CommandCategory { */ export interface CommandParameter { key: string; - type: 'string' | 'number' | 'boolean' | 'grid' | 'color' | 'font'; + type: "string" | "number" | "boolean" | "grid" | "color" | "font"; required: boolean; description?: string; } @@ -55,7 +55,7 @@ export interface Grid3CommandDefinition { displayName: string; description: string; parameters?: CommandParameter[]; - platforms?: ('desktop' | 'ios' | 'medicare' | 'medicareBionics')[]; + platforms?: ("desktop" | "ios" | "medicare" | "medicareBionics")[]; deprecated?: boolean; } @@ -67,50 +67,50 @@ export const GRID3_COMMANDS: Record = { // ======================================== // NAVIGATION COMMANDS // ======================================== - 'Jump.To': { - id: 'Jump.To', + "Jump.To": { + id: "Jump.To", category: Grid3CommandCategory.NAVIGATION, - pluginId: 'navigation', - displayName: 'Jump To', - description: 'Navigate to a specific grid', + pluginId: "navigation", + displayName: "Jump To", + description: "Navigate to a specific grid", parameters: [ { - key: 'grid', - type: 'grid', + key: "grid", + type: "grid", required: true, - description: 'Target grid name', + description: "Target grid name", }, ], - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Jump.Back': { - id: 'Jump.Back', + "Jump.Back": { + id: "Jump.Back", category: Grid3CommandCategory.NAVIGATION, - pluginId: 'navigation', - displayName: 'Jump Back', - description: 'Navigate to the previous grid', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "navigation", + displayName: "Jump Back", + description: "Navigate to the previous grid", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Jump.Home': { - id: 'Jump.Home', + "Jump.Home": { + id: "Jump.Home", category: Grid3CommandCategory.NAVIGATION, - pluginId: 'navigation', - displayName: 'Jump Home', - description: 'Navigate to the home/start grid', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "navigation", + displayName: "Jump Home", + description: "Navigate to the home/start grid", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Jump.Favorite': { - id: 'Jump.Favorite', + "Jump.Favorite": { + id: "Jump.Favorite", category: Grid3CommandCategory.NAVIGATION, - pluginId: 'navigation', - displayName: 'Jump To Favorite', - description: 'Navigate to a favorite grid', + pluginId: "navigation", + displayName: "Jump To Favorite", + description: "Navigate to a favorite grid", parameters: [ { - key: 'favorite', - type: 'number', + key: "favorite", + type: "number", required: true, - description: 'Favorite slot number', + description: "Favorite slot number", }, ], }, @@ -118,56 +118,56 @@ export const GRID3_COMMANDS: Record = { // ======================================== // COMMUNICATION COMMANDS // ======================================== - 'Action.Speak': { - id: 'Action.Speak', + "Action.Speak": { + id: "Action.Speak", category: Grid3CommandCategory.COMMUNICATION, - pluginId: 'speech', - displayName: 'Speak', - description: 'Speak the current message bar contents', + pluginId: "speech", + displayName: "Speak", + description: "Speak the current message bar contents", parameters: [ { - key: 'unit', - type: 'string', + key: "unit", + type: "string", required: false, - description: 'Speaking unit (sentence/word/character)', + description: "Speaking unit (sentence/word/character)", }, { - key: 'movecaret', - type: 'boolean', + key: "movecaret", + type: "boolean", required: false, - description: 'Move caret after speaking', + description: "Move caret after speaking", }, ], - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Action.InsertText': { - id: 'Action.InsertText', + "Action.InsertText": { + id: "Action.InsertText", category: Grid3CommandCategory.COMMUNICATION, - pluginId: 'core', - displayName: 'Insert Text', - description: 'Insert text into the message bar', + pluginId: "core", + displayName: "Insert Text", + description: "Insert text into the message bar", parameters: [ { - key: 'text', - type: 'string', + key: "text", + type: "string", required: true, - description: 'Text to insert', + description: "Text to insert", }, ], - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Action.InsertTextAndSpeak': { - id: 'Action.InsertTextAndSpeak', + "Action.InsertTextAndSpeak": { + id: "Action.InsertTextAndSpeak", category: Grid3CommandCategory.COMMUNICATION, - pluginId: 'core', - displayName: 'Insert Text and Speak', - description: 'Insert text and speak immediately', + pluginId: "core", + displayName: "Insert Text and Speak", + description: "Insert text and speak immediately", parameters: [ { - key: 'text', - type: 'string', + key: "text", + type: "string", required: true, - description: 'Text to insert and speak', + description: "Text to insert and speak", }, ], }, @@ -175,748 +175,750 @@ export const GRID3_COMMANDS: Record = { // ======================================== // TEXT EDITING COMMANDS // ======================================== - 'Action.DeleteWord': { - id: 'Action.DeleteWord', + "Action.DeleteWord": { + id: "Action.DeleteWord", category: Grid3CommandCategory.TEXT_EDITING, - pluginId: 'core', - displayName: 'Delete Word', - description: 'Delete the last word in the message bar', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "core", + displayName: "Delete Word", + description: "Delete the last word in the message bar", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Action.DeleteLetter': { - id: 'Action.DeleteLetter', + "Action.DeleteLetter": { + id: "Action.DeleteLetter", category: Grid3CommandCategory.TEXT_EDITING, - pluginId: 'core', - displayName: 'Delete Letter', - description: 'Delete the last character in the message bar', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "core", + displayName: "Delete Letter", + description: "Delete the last character in the message bar", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Action.Clear': { - id: 'Action.Clear', + "Action.Clear": { + id: "Action.Clear", category: Grid3CommandCategory.TEXT_EDITING, - pluginId: 'core', - displayName: 'Clear', - description: 'Clear the message bar', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "core", + displayName: "Clear", + description: "Clear the message bar", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Action.Letter': { - id: 'Action.Letter', + "Action.Letter": { + id: "Action.Letter", category: Grid3CommandCategory.TEXT_EDITING, - pluginId: 'core', - displayName: 'Insert Letter', - description: 'Insert a single letter', + pluginId: "core", + displayName: "Insert Letter", + description: "Insert a single letter", parameters: [ { - key: 'letter', - type: 'string', + key: "letter", + type: "string", required: true, - description: 'Letter to insert', + description: "Letter to insert", }, ], }, - 'Action.Space': { - id: 'Action.Space', + "Action.Space": { + id: "Action.Space", category: Grid3CommandCategory.TEXT_EDITING, - pluginId: 'core', - displayName: 'Space', - description: 'Insert a space', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "core", + displayName: "Space", + description: "Insert a space", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Action.Backspace': { - id: 'Action.Backspace', + "Action.Backspace": { + id: "Action.Backspace", category: Grid3CommandCategory.TEXT_EDITING, - pluginId: 'core', - displayName: 'Backspace', - description: 'Delete character before cursor', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "core", + displayName: "Backspace", + description: "Delete character before cursor", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, // ======================================== // SPEECH COMMANDS // ======================================== - 'Speech.ChangePublicVoice': { - id: 'Speech.ChangePublicVoice', + "Speech.ChangePublicVoice": { + id: "Speech.ChangePublicVoice", category: Grid3CommandCategory.SPEECH, - pluginId: 'speech', - displayName: 'Change Voice', - description: 'Change the public speaking voice', + pluginId: "speech", + displayName: "Change Voice", + description: "Change the public speaking voice", parameters: [ { - key: 'voice', - type: 'string', + key: "voice", + type: "string", required: true, - description: 'Voice name or ID', + description: "Voice name or ID", }, ], }, - 'Speech.ChangePublicSpeed': { - id: 'Speech.ChangePublicSpeed', + "Speech.ChangePublicSpeed": { + id: "Speech.ChangePublicSpeed", category: Grid3CommandCategory.SPEECH, - pluginId: 'speech', - displayName: 'Change Speech Speed', - description: 'Change the speaking speed', + pluginId: "speech", + displayName: "Change Speech Speed", + description: "Change the speaking speed", parameters: [ { - key: 'speed', - type: 'number', + key: "speed", + type: "number", required: true, - description: 'Speed percentage (50-200)', + description: "Speed percentage (50-200)", }, ], }, - 'Speech.ChangePublicPitch': { - id: 'Speech.ChangePublicPitch', + "Speech.ChangePublicPitch": { + id: "Speech.ChangePublicPitch", category: Grid3CommandCategory.SPEECH, - pluginId: 'speech', - displayName: 'Change Speech Pitch', - description: 'Change the voice pitch', + pluginId: "speech", + displayName: "Change Speech Pitch", + description: "Change the voice pitch", parameters: [ { - key: 'pitch', - type: 'number', + key: "pitch", + type: "number", required: true, - description: 'Pitch value', + description: "Pitch value", }, ], }, - 'Speech.ChangePublicVolume': { - id: 'Speech.ChangePublicVolume', + "Speech.ChangePublicVolume": { + id: "Speech.ChangePublicVolume", category: Grid3CommandCategory.SPEECH, - pluginId: 'speech', - displayName: 'Change Speech Volume', - description: 'Change the speech volume', + pluginId: "speech", + displayName: "Change Speech Volume", + description: "Change the speech volume", parameters: [ { - key: 'volume', - type: 'number', + key: "volume", + type: "number", required: true, - description: 'Volume percentage (0-100)', + description: "Volume percentage (0-100)", }, ], }, - 'Speech.SpeakNothing': { - id: 'Action.SpeakNothing', + "Speech.SpeakNothing": { + id: "Action.SpeakNothing", category: Grid3CommandCategory.SPEECH, - pluginId: 'speech', - displayName: 'Speak Nothing', - description: 'Speak without inserting text', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "speech", + displayName: "Speak Nothing", + description: "Speak without inserting text", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, // ======================================== // COMPUTER CONTROL COMMANDS // ======================================== - 'ComputerControl.LeftClick': { - id: 'ComputerControl.LeftClick', + "ComputerControl.LeftClick": { + id: "ComputerControl.LeftClick", category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: 'computercontrol', - displayName: 'Left Click', - description: 'Perform left mouse click', - platforms: ['desktop', 'medicareBionics'], + pluginId: "computercontrol", + displayName: "Left Click", + description: "Perform left mouse click", + platforms: ["desktop", "medicareBionics"], }, - 'ComputerControl.RightClick': { - id: 'ComputerControl.RightClick', + "ComputerControl.RightClick": { + id: "ComputerControl.RightClick", category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: 'computercontrol', - displayName: 'Right Click', - description: 'Perform right mouse click', - platforms: ['desktop', 'medicareBionics'], + pluginId: "computercontrol", + displayName: "Right Click", + description: "Perform right mouse click", + platforms: ["desktop", "medicareBionics"], }, - 'ComputerControl.DoubleClick': { - id: 'ComputerControl.DoubleClick', + "ComputerControl.DoubleClick": { + id: "ComputerControl.DoubleClick", category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: 'computercontrol', - displayName: 'Double Click', - description: 'Perform double mouse click', - platforms: ['desktop', 'medicareBionics'], + pluginId: "computercontrol", + displayName: "Double Click", + description: "Perform double mouse click", + platforms: ["desktop", "medicareBionics"], }, - 'ComputerControl.MouseMove': { - id: 'ComputerControl.MouseMove', + "ComputerControl.MouseMove": { + id: "ComputerControl.MouseMove", category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: 'computercontrol', - displayName: 'Move Mouse', - description: 'Move mouse pointer', + pluginId: "computercontrol", + displayName: "Move Mouse", + description: "Move mouse pointer", parameters: [ - { key: 'x', type: 'number', required: true, description: 'X coordinate' }, - { key: 'y', type: 'number', required: true, description: 'Y coordinate' }, + { key: "x", type: "number", required: true, description: "X coordinate" }, + { key: "y", type: "number", required: true, description: "Y coordinate" }, ], - platforms: ['desktop', 'medicareBionics'], + platforms: ["desktop", "medicareBionics"], }, - 'ComputerControl.SendKeys': { - id: 'ComputerControl.SendKeys', + "ComputerControl.SendKeys": { + id: "ComputerControl.SendKeys", category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: 'computercontrol', - displayName: 'Send Keys', - description: 'Send keyboard input', + pluginId: "computercontrol", + displayName: "Send Keys", + description: "Send keyboard input", parameters: [ { - key: 'keys', - type: 'string', + key: "keys", + type: "string", required: true, - description: 'Key sequence to send', + description: "Key sequence to send", }, ], - platforms: ['desktop', 'medicareBionics'], + platforms: ["desktop", "medicareBionics"], }, - 'ComputerControl.WindowsKey': { - id: 'ComputerControl.WindowsKey', + "ComputerControl.WindowsKey": { + id: "ComputerControl.WindowsKey", category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: 'computercontrol', - displayName: 'Windows Key', - description: 'Press Windows key', - platforms: ['desktop'], + pluginId: "computercontrol", + displayName: "Windows Key", + description: "Press Windows key", + platforms: ["desktop"], }, - 'ComputerControl.MenuKey': { - id: 'ComputerControl.MenuKey', + "ComputerControl.MenuKey": { + id: "ComputerControl.MenuKey", category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: 'computercontrol', - displayName: 'Menu Key', - description: 'Press context menu key', - platforms: ['desktop'], + pluginId: "computercontrol", + displayName: "Menu Key", + description: "Press context menu key", + platforms: ["desktop"], }, // ======================================== // WEB BROWSER COMMANDS // ======================================== - 'WebBrowser.Navigate': { - id: 'WebBrowser.Navigate', + "WebBrowser.Navigate": { + id: "WebBrowser.Navigate", category: Grid3CommandCategory.WEB_BROWSER, - pluginId: 'webbrowser', - displayName: 'Navigate to URL', - description: 'Open a URL in the web browser', + pluginId: "webbrowser", + displayName: "Navigate to URL", + description: "Open a URL in the web browser", parameters: [ { - key: 'url', - type: 'string', + key: "url", + type: "string", required: true, - description: 'URL to navigate to', + description: "URL to navigate to", }, ], - platforms: ['desktop', 'ios'], + platforms: ["desktop", "ios"], }, - 'WebBrowser.Back': { - id: 'WebBrowser.Back', + "WebBrowser.Back": { + id: "WebBrowser.Back", category: Grid3CommandCategory.WEB_BROWSER, - pluginId: 'webbrowser', - displayName: 'Browser Back', - description: 'Go back in browser history', - platforms: ['desktop', 'ios'], + pluginId: "webbrowser", + displayName: "Browser Back", + description: "Go back in browser history", + platforms: ["desktop", "ios"], }, - 'WebBrowser.Forward': { - id: 'WebBrowser.Forward', + "WebBrowser.Forward": { + id: "WebBrowser.Forward", category: Grid3CommandCategory.WEB_BROWSER, - pluginId: 'webbrowser', - displayName: 'Browser Forward', - description: 'Go forward in browser history', - platforms: ['desktop', 'ios'], + pluginId: "webbrowser", + displayName: "Browser Forward", + description: "Go forward in browser history", + platforms: ["desktop", "ios"], }, - 'WebBrowser.Refresh': { - id: 'WebBrowser.Refresh', + "WebBrowser.Refresh": { + id: "WebBrowser.Refresh", category: Grid3CommandCategory.WEB_BROWSER, - pluginId: 'webbrowser', - displayName: 'Refresh Page', - description: 'Refresh the current page', - platforms: ['desktop', 'ios'], + pluginId: "webbrowser", + displayName: "Refresh Page", + description: "Refresh the current page", + platforms: ["desktop", "ios"], }, - 'WebBrowser.Home': { - id: 'WebBrowser.Home', + "WebBrowser.Home": { + id: "WebBrowser.Home", category: Grid3CommandCategory.WEB_BROWSER, - pluginId: 'webbrowser', - displayName: 'Browser Home', - description: 'Navigate to browser home page', - platforms: ['desktop', 'ios'], + pluginId: "webbrowser", + displayName: "Browser Home", + description: "Navigate to browser home page", + platforms: ["desktop", "ios"], }, - 'WebBrowser.FavoriteAdd': { - id: 'WebBrowser.FavoriteAdd', + "WebBrowser.FavoriteAdd": { + id: "WebBrowser.FavoriteAdd", category: Grid3CommandCategory.WEB_BROWSER, - pluginId: 'webbrowser', - displayName: 'Add Favorite', - description: 'Add current page to favorites', - platforms: ['desktop', 'ios'], + pluginId: "webbrowser", + displayName: "Add Favorite", + description: "Add current page to favorites", + platforms: ["desktop", "ios"], }, - 'WebBrowser.ZoomIn': { - id: 'WebBrowser.ZoomIn', + "WebBrowser.ZoomIn": { + id: "WebBrowser.ZoomIn", category: Grid3CommandCategory.WEB_BROWSER, - pluginId: 'webbrowser', - displayName: 'Zoom In', - description: 'Zoom in the page', - platforms: ['desktop', 'ios'], + pluginId: "webbrowser", + displayName: "Zoom In", + description: "Zoom in the page", + platforms: ["desktop", "ios"], }, - 'WebBrowser.ZoomOut': { - id: 'WebBrowser.ZoomOut', + "WebBrowser.ZoomOut": { + id: "WebBrowser.ZoomOut", category: Grid3CommandCategory.WEB_BROWSER, - pluginId: 'webbrowser', - displayName: 'Zoom Out', - description: 'Zoom out the page', - platforms: ['desktop', 'ios'], + pluginId: "webbrowser", + displayName: "Zoom Out", + description: "Zoom out the page", + platforms: ["desktop", "ios"], }, // ======================================== // EMAIL COMMANDS // ======================================== - 'Email.SendTo': { - id: 'Email.SendTo', + "Email.SendTo": { + id: "Email.SendTo", category: Grid3CommandCategory.EMAIL, - pluginId: 'email', - displayName: 'Send Email To', - description: 'Send email to a recipient', + pluginId: "email", + displayName: "Send Email To", + description: "Send email to a recipient", parameters: [ { - key: 'recipient', - type: 'string', + key: "recipient", + type: "string", required: true, - description: 'Recipient email address', + description: "Recipient email address", }, { - key: 'subject', - type: 'string', + key: "subject", + type: "string", required: false, - description: 'Email subject', + description: "Email subject", }, ], - platforms: ['desktop', 'medicare', 'medicareBionics'], + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'Email.AddRecipient': { - id: 'Email.AddRecipient', + "Email.AddRecipient": { + id: "Email.AddRecipient", category: Grid3CommandCategory.EMAIL, - pluginId: 'email', - displayName: 'Add Recipient', - description: 'Add a recipient to the email', + pluginId: "email", + displayName: "Add Recipient", + description: "Add a recipient to the email", parameters: [ { - key: 'recipient', - type: 'string', + key: "recipient", + type: "string", required: true, - description: 'Recipient email address', + description: "Recipient email address", }, ], - platforms: ['desktop', 'medicare', 'medicareBionics'], + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'Email.SetSubject': { - id: 'Email.SetSubject', + "Email.SetSubject": { + id: "Email.SetSubject", category: Grid3CommandCategory.EMAIL, - pluginId: 'email', - displayName: 'Set Subject', - description: 'Set the email subject', + pluginId: "email", + displayName: "Set Subject", + description: "Set the email subject", parameters: [ { - key: 'subject', - type: 'string', + key: "subject", + type: "string", required: true, - description: 'Email subject', + description: "Email subject", }, ], - platforms: ['desktop', 'medicare', 'medicareBionics'], + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'Email.AttachFile': { - id: 'Email.AttachFile', + "Email.AttachFile": { + id: "Email.AttachFile", category: Grid3CommandCategory.EMAIL, - pluginId: 'email', - displayName: 'Attach File', - description: 'Attach a file to the email', + pluginId: "email", + displayName: "Attach File", + description: "Attach a file to the email", parameters: [ { - key: 'filepath', - type: 'string', + key: "filepath", + type: "string", required: true, - description: 'Path to file', + description: "Path to file", }, ], - platforms: ['desktop', 'medicare', 'medicareBionics'], + platforms: ["desktop", "medicare", "medicareBionics"], }, // ======================================== // PHONE COMMANDS // ======================================== - 'Phone.Call': { - id: 'Phone.Call', + "Phone.Call": { + id: "Phone.Call", category: Grid3CommandCategory.PHONE, - pluginId: 'phone', - displayName: 'Make Call', - description: 'Initiate a phone call', + pluginId: "phone", + displayName: "Make Call", + description: "Initiate a phone call", parameters: [ { - key: 'number', - type: 'string', + key: "number", + type: "string", required: true, - description: 'Phone number to call', + description: "Phone number to call", }, ], - platforms: ['desktop', 'medicare', 'medicareBionics'], + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'Phone.Answer': { - id: 'Phone.Answer', + "Phone.Answer": { + id: "Phone.Answer", category: Grid3CommandCategory.PHONE, - pluginId: 'phone', - displayName: 'Answer Call', - description: 'Answer incoming call', - platforms: ['desktop', 'medicare', 'medicareBionics'], + pluginId: "phone", + displayName: "Answer Call", + description: "Answer incoming call", + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'Phone.Hangup': { - id: 'Phone.Hangup', + "Phone.Hangup": { + id: "Phone.Hangup", category: Grid3CommandCategory.PHONE, - pluginId: 'phone', - displayName: 'End Call', - description: 'End current call', - platforms: ['desktop', 'medicare', 'medicareBionics'], + pluginId: "phone", + displayName: "End Call", + description: "End current call", + platforms: ["desktop", "medicare", "medicareBionics"], }, // ======================================== // SMS COMMANDS // ======================================== - 'Sms.SendTo': { - id: 'Sms.SendTo', + "Sms.SendTo": { + id: "Sms.SendTo", category: Grid3CommandCategory.SMS, - pluginId: 'sms', - displayName: 'Send SMS To', - description: 'Send text message to a recipient', + pluginId: "sms", + displayName: "Send SMS To", + description: "Send text message to a recipient", parameters: [ { - key: 'recipient', - type: 'string', + key: "recipient", + type: "string", required: true, - description: 'Phone number', + description: "Phone number", }, { - key: 'message', - type: 'string', + key: "message", + type: "string", required: false, - description: 'Message text', + description: "Message text", }, ], - platforms: ['desktop', 'medicare', 'medicareBionics'], + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'Sms.AddRecipient': { - id: 'Sms.AddRecipient', + "Sms.AddRecipient": { + id: "Sms.AddRecipient", category: Grid3CommandCategory.SMS, - pluginId: 'sms', - displayName: 'Add SMS Recipient', - description: 'Add a recipient to the SMS', + pluginId: "sms", + displayName: "Add SMS Recipient", + description: "Add a recipient to the SMS", parameters: [ { - key: 'recipient', - type: 'string', + key: "recipient", + type: "string", required: true, - description: 'Phone number', + description: "Phone number", }, ], - platforms: ['desktop', 'medicare', 'medicareBionics'], + platforms: ["desktop", "medicare", "medicareBionics"], }, // ======================================== // SYSTEM COMMANDS // ======================================== - 'System.LogOff': { - id: 'System.LogOff', + "System.LogOff": { + id: "System.LogOff", category: Grid3CommandCategory.SYSTEM, - pluginId: 'computersession', - displayName: 'Log Off', - description: 'Log off from Windows', - platforms: ['desktop', 'medicare', 'medicareBionics'], + pluginId: "computersession", + displayName: "Log Off", + description: "Log off from Windows", + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'System.Lock': { - id: 'System.Lock', + "System.Lock": { + id: "System.Lock", category: Grid3CommandCategory.SYSTEM, - pluginId: 'computersession', - displayName: 'Lock Computer', - description: 'Lock the computer', - platforms: ['desktop', 'medicare', 'medicareBionics'], + pluginId: "computersession", + displayName: "Lock Computer", + description: "Lock the computer", + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'System.Sleep': { - id: 'System.Sleep', + "System.Sleep": { + id: "System.Sleep", category: Grid3CommandCategory.SYSTEM, - pluginId: 'computersession', - displayName: 'Sleep', - description: 'Put computer to sleep', - platforms: ['desktop', 'medicare', 'medicareBionics'], + pluginId: "computersession", + displayName: "Sleep", + description: "Put computer to sleep", + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'System.Restart': { - id: 'System.Restart', + "System.Restart": { + id: "System.Restart", category: Grid3CommandCategory.SYSTEM, - pluginId: 'computersession', - displayName: 'Restart', - description: 'Restart the computer', - platforms: ['desktop', 'medicare', 'medicareBionics'], + pluginId: "computersession", + displayName: "Restart", + description: "Restart the computer", + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'System.ShutDown': { - id: 'System.ShutDown', + "System.ShutDown": { + id: "System.ShutDown", category: Grid3CommandCategory.SYSTEM, - pluginId: 'computersession', - displayName: 'Shut Down', - description: 'Shut down the computer', - platforms: ['desktop', 'medicare', 'medicareBionics'], + pluginId: "computersession", + displayName: "Shut Down", + description: "Shut down the computer", + platforms: ["desktop", "medicare", "medicareBionics"], }, // ======================================== // SETTINGS COMMANDS // ======================================== - 'Settings.RestoreAll': { - id: 'Settings.RestoreAll', + "Settings.RestoreAll": { + id: "Settings.RestoreAll", category: Grid3CommandCategory.SETTINGS, - pluginId: 'settings', - displayName: 'Restore All Settings', - description: 'Restore all settings to defaults', + pluginId: "settings", + displayName: "Restore All Settings", + description: "Restore all settings to defaults", parameters: [ { - key: 'indicatorenabled', - type: 'boolean', + key: "indicatorenabled", + type: "boolean", required: false, - description: 'Show indicator', + description: "Show indicator", }, { - key: 'action', - type: 'string', + key: "action", + type: "string", required: false, - description: 'Action to perform', + description: "Action to perform", }, ], - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Settings.Open': { - id: 'Settings.Open', + "Settings.Open": { + id: "Settings.Open", category: Grid3CommandCategory.SETTINGS, - pluginId: 'settings', - displayName: 'Open Settings', - description: 'Open the settings window', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "settings", + displayName: "Open Settings", + description: "Open the settings window", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Scanning.Start': { - id: 'Scanning.Start', + "Scanning.Start": { + id: "Scanning.Start", category: Grid3CommandCategory.SETTINGS, - pluginId: 'access', - displayName: 'Start Scanning', - description: 'Start scanning access method', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "access", + displayName: "Start Scanning", + description: "Start scanning access method", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Scanning.Stop': { - id: 'Scanning.Stop', + "Scanning.Stop": { + id: "Scanning.Stop", category: Grid3CommandCategory.SETTINGS, - pluginId: 'access', - displayName: 'Stop Scanning', - description: 'Stop scanning access method', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "access", + displayName: "Stop Scanning", + description: "Stop scanning access method", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, // ======================================== // AUTO CONTENT COMMANDS // ======================================== - 'AutoContent.Activate': { - id: 'AutoContent.Activate', + "AutoContent.Activate": { + id: "AutoContent.Activate", category: Grid3CommandCategory.AUTO_CONTENT, - pluginId: 'autocontent', - displayName: 'Activate Auto Content', - description: 'Activate an auto content cell', + pluginId: "autocontent", + displayName: "Activate Auto Content", + description: "Activate an auto content cell", parameters: [ { - key: 'autocontenttype', - type: 'string', + key: "autocontenttype", + type: "string", required: true, - description: 'Type of auto content', + description: "Type of auto content", }, ], - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Prediction.Clear': { - id: 'Prediction.Clear', + "Prediction.Clear": { + id: "Prediction.Clear", category: Grid3CommandCategory.AUTO_CONTENT, - pluginId: 'prediction', - displayName: 'Clear Prediction', - description: 'Clear word prediction buffer', - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + pluginId: "prediction", + displayName: "Clear Prediction", + description: "Clear word prediction buffer", + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, - 'Prediction.PredictThis': { - id: 'Prediction.PredictThis', + "Prediction.PredictThis": { + id: "Prediction.PredictThis", category: Grid3CommandCategory.AUTO_CONTENT, - pluginId: 'prediction', - displayName: 'Predict This', - description: 'Provide suggestions based on word list', + pluginId: "prediction", + displayName: "Predict This", + description: "Provide suggestions based on word list", parameters: [ { - key: 'wordlist', - type: 'string', // Actually highly structured, but string type is a placeholder + key: "wordlist", + type: "string", // Actually highly structured, but string type is a placeholder required: true, - description: 'Word list for prediction', + description: "Word list for prediction", }, ], }, - 'Grammar.Change': { - id: 'Grammar.Change', + "Grammar.Change": { + id: "Grammar.Change", category: Grid3CommandCategory.AUTO_CONTENT, - pluginId: 'grammar', - displayName: 'Change Grammar', - description: 'Change grammar context', + pluginId: "grammar", + displayName: "Change Grammar", + description: "Change grammar context", parameters: [ { - key: 'context', - type: 'string', + key: "context", + type: "string", required: true, - description: 'Grammar context', + description: "Grammar context", }, ], - platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], + platforms: ["desktop", "ios", "medicare", "medicareBionics"], }, // ======================================== // ENVIRONMENT CONTROL COMMANDS // ======================================== - 'EnvControl.Send': { - id: 'EnvControl.Send', + "EnvControl.Send": { + id: "EnvControl.Send", category: Grid3CommandCategory.ENVIRONMENT_CONTROL, - pluginId: 'environmentcontrol', - displayName: 'Send Environment Control', - description: 'Send environment control command', + pluginId: "environmentcontrol", + displayName: "Send Environment Control", + description: "Send environment control command", parameters: [ { - key: 'code', - type: 'string', + key: "code", + type: "string", required: true, - description: 'IR/EC code to send', + description: "IR/EC code to send", }, ], - platforms: ['desktop', 'medicare', 'medicareBionics'], + platforms: ["desktop", "medicare", "medicareBionics"], }, - 'EnvControl.Learn': { - id: 'EnvControl.Learn', + "EnvControl.Learn": { + id: "EnvControl.Learn", category: Grid3CommandCategory.ENVIRONMENT_CONTROL, - pluginId: 'environmentcontrol', - displayName: 'Learn Environment Control', - description: 'Learn environment control code', - platforms: ['desktop', 'medicare', 'medicareBionics'], + pluginId: "environmentcontrol", + displayName: "Learn Environment Control", + description: "Learn environment control code", + platforms: ["desktop", "medicare", "medicareBionics"], }, // ======================================== // MOUSE COMMANDS // ======================================== - 'Mouse.LeftClick': { - id: 'Mouse.LeftClick', + "Mouse.LeftClick": { + id: "Mouse.LeftClick", category: Grid3CommandCategory.MOUSE, - pluginId: 'computercontrol', - displayName: 'Mouse Left Click', - description: 'Left mouse click', - platforms: ['desktop', 'medicareBionics'], + pluginId: "computercontrol", + displayName: "Mouse Left Click", + description: "Left mouse click", + platforms: ["desktop", "medicareBionics"], }, - 'Mouse.RightClick': { - id: 'Mouse.RightClick', + "Mouse.RightClick": { + id: "Mouse.RightClick", category: Grid3CommandCategory.MOUSE, - pluginId: 'computercontrol', - displayName: 'Mouse Right Click', - description: 'Right mouse click', - platforms: ['desktop', 'medicareBionics'], + pluginId: "computercontrol", + displayName: "Mouse Right Click", + description: "Right mouse click", + platforms: ["desktop", "medicareBionics"], }, - 'Mouse.DoubleClick': { - id: 'Mouse.DoubleClick', + "Mouse.DoubleClick": { + id: "Mouse.DoubleClick", category: Grid3CommandCategory.MOUSE, - pluginId: 'computercontrol', - displayName: 'Mouse Double Click', - description: 'Double mouse click', - platforms: ['desktop', 'medicareBionics'], + pluginId: "computercontrol", + displayName: "Mouse Double Click", + description: "Double mouse click", + platforms: ["desktop", "medicareBionics"], }, - 'Mouse.Move': { - id: 'Mouse.Move', + "Mouse.Move": { + id: "Mouse.Move", category: Grid3CommandCategory.MOUSE, - pluginId: 'computercontrol', - displayName: 'Move Mouse', - description: 'Move mouse pointer', + pluginId: "computercontrol", + displayName: "Move Mouse", + description: "Move mouse pointer", parameters: [ - { key: 'x', type: 'number', required: true, description: 'X coordinate' }, - { key: 'y', type: 'number', required: true, description: 'Y coordinate' }, + { key: "x", type: "number", required: true, description: "X coordinate" }, + { key: "y", type: "number", required: true, description: "Y coordinate" }, ], - platforms: ['desktop', 'medicareBionics'], + platforms: ["desktop", "medicareBionics"], }, // ======================================== // WINDOW COMMANDS // ======================================== - 'Window.Minimize': { - id: 'Window.Minimize', + "Window.Minimize": { + id: "Window.Minimize", category: Grid3CommandCategory.WINDOW, - pluginId: 'computercontrol', - displayName: 'Minimize Window', - description: 'Minimize active window', - platforms: ['desktop', 'medicareBionics'], + pluginId: "computercontrol", + displayName: "Minimize Window", + description: "Minimize active window", + platforms: ["desktop", "medicareBionics"], }, - 'Window.Maximize': { - id: 'Window.Maximize', + "Window.Maximize": { + id: "Window.Maximize", category: Grid3CommandCategory.WINDOW, - pluginId: 'computercontrol', - displayName: 'Maximize Window', - description: 'Maximize active window', - platforms: ['desktop', 'medicareBionics'], + pluginId: "computercontrol", + displayName: "Maximize Window", + description: "Maximize active window", + platforms: ["desktop", "medicareBionics"], }, - 'Window.Close': { - id: 'Window.Close', + "Window.Close": { + id: "Window.Close", category: Grid3CommandCategory.WINDOW, - pluginId: 'computercontrol', - displayName: 'Close Window', - description: 'Close active window', - platforms: ['desktop', 'medicareBionics'], + pluginId: "computercontrol", + displayName: "Close Window", + description: "Close active window", + platforms: ["desktop", "medicareBionics"], }, - 'Window.Switch': { - id: 'Window.Switch', + "Window.Switch": { + id: "Window.Switch", category: Grid3CommandCategory.WINDOW, - pluginId: 'computercontrol', - displayName: 'Switch Window', - description: 'Switch to next window', - platforms: ['desktop', 'medicareBionics'], + pluginId: "computercontrol", + displayName: "Switch Window", + description: "Switch to next window", + platforms: ["desktop", "medicareBionics"], }, // ======================================== // MEDIA COMMANDS // ======================================== - 'Media.PlayPause': { - id: 'Media.PlayPause', + "Media.PlayPause": { + id: "Media.PlayPause", category: Grid3CommandCategory.MEDIA, - pluginId: 'musicvideo', - displayName: 'Play/Pause', - description: 'Toggle play/pause media', - platforms: ['desktop', 'ios'], + pluginId: "musicvideo", + displayName: "Play/Pause", + description: "Toggle play/pause media", + platforms: ["desktop", "ios"], }, - 'Media.Next': { - id: 'Media.Next', + "Media.Next": { + id: "Media.Next", category: Grid3CommandCategory.MEDIA, - pluginId: 'musicvideo', - displayName: 'Next Track', - description: 'Skip to next track', - platforms: ['desktop', 'ios'], + pluginId: "musicvideo", + displayName: "Next Track", + description: "Skip to next track", + platforms: ["desktop", "ios"], }, - 'Media.Previous': { - id: 'Media.Previous', + "Media.Previous": { + id: "Media.Previous", category: Grid3CommandCategory.MEDIA, - pluginId: 'musicvideo', - displayName: 'Previous Track', - description: 'Go to previous track', - platforms: ['desktop', 'ios'], + pluginId: "musicvideo", + displayName: "Previous Track", + description: "Go to previous track", + platforms: ["desktop", "ios"], }, - 'Media.Stop': { - id: 'Media.Stop', + "Media.Stop": { + id: "Media.Stop", category: Grid3CommandCategory.MEDIA, - pluginId: 'musicvideo', - displayName: 'Stop', - description: 'Stop media playback', - platforms: ['desktop', 'ios'], + pluginId: "musicvideo", + displayName: "Stop", + description: "Stop media playback", + platforms: ["desktop", "ios"], }, - 'Media.VolumeUp': { - id: 'Media.VolumeUp', + "Media.VolumeUp": { + id: "Media.VolumeUp", category: Grid3CommandCategory.MEDIA, - pluginId: 'musicvideo', - displayName: 'Volume Up', - description: 'Increase volume', - platforms: ['desktop', 'ios'], + pluginId: "musicvideo", + displayName: "Volume Up", + description: "Increase volume", + platforms: ["desktop", "ios"], }, - 'Media.VolumeDown': { - id: 'Media.VolumeDown', + "Media.VolumeDown": { + id: "Media.VolumeDown", category: Grid3CommandCategory.MEDIA, - pluginId: 'musicvideo', - displayName: 'Volume Down', - description: 'Decrease volume', - platforms: ['desktop', 'ios'], + pluginId: "musicvideo", + displayName: "Volume Down", + description: "Decrease volume", + platforms: ["desktop", "ios"], }, }; /** * Get command definition by ID */ -export function getCommandDefinition(commandId: string): Grid3CommandDefinition | undefined { +export function getCommandDefinition( + commandId: string, +): Grid3CommandDefinition | undefined { return GRID3_COMMANDS[commandId]; } @@ -930,15 +932,23 @@ export function isKnownCommand(commandId: string): boolean { /** * Get all commands for a specific plugin */ -export function getCommandsByPlugin(pluginId: string): Grid3CommandDefinition[] { - return Object.values(GRID3_COMMANDS).filter((cmd) => cmd.pluginId === pluginId); +export function getCommandsByPlugin( + pluginId: string, +): Grid3CommandDefinition[] { + return Object.values(GRID3_COMMANDS).filter( + (cmd) => cmd.pluginId === pluginId, + ); } /** * Get all commands in a category */ -export function getCommandsByCategory(category: Grid3CommandCategory): Grid3CommandDefinition[] { - return Object.values(GRID3_COMMANDS).filter((cmd) => cmd.category === category); +export function getCommandsByCategory( + category: Grid3CommandCategory, +): Grid3CommandDefinition[] { + return Object.values(GRID3_COMMANDS).filter( + (cmd) => cmd.category === category, + ); } /** @@ -952,7 +962,9 @@ export function getAllCommandIds(): string[] { * Get all plugin IDs that have commands */ export function getAllPluginIds(): string[] { - const plugins = new Set(Object.values(GRID3_COMMANDS).map((cmd) => cmd.pluginId)); + const plugins = new Set( + Object.values(GRID3_COMMANDS).map((cmd) => cmd.pluginId), + ); return Array.from(plugins).sort(); } @@ -972,18 +984,18 @@ export function extractCommandParameters(command: any): ExtractedParameters { const paramArray = Array.isArray(params) ? params : [params]; for (const param of paramArray) { - const key = param['@_Key'] || param.Key || param.key; - let value = param['#text'] ?? param.text ?? param.value; + const key = param["@_Key"] || param.Key || param.key; + let value = param["#text"] ?? param.text ?? param.value; if (key && value !== undefined) { // Try to convert to number if it looks numeric - if (typeof value === 'string' && /^\d+$/.test(value)) { + if (typeof value === "string" && /^\d+$/.test(value)) { value = parseInt(value, 10); - } else if (typeof value === 'string' && /^\d+\.\d+$/.test(value)) { + } else if (typeof value === "string" && /^\d+\.\d+$/.test(value)) { value = parseFloat(value); - } else if (value === 'true') { + } else if (value === "true") { value = true; - } else if (value === 'false') { + } else if (value === "false") { value = false; } @@ -1001,17 +1013,19 @@ export function detectCommand(commandObj: any): { id: string; definition?: Grid3CommandDefinition; parameters: ExtractedParameters; - category: Grid3CommandCategory | 'unknown'; - pluginId: string | 'unknown'; + category: Grid3CommandCategory | "unknown"; + pluginId: string | "unknown"; } { - const commandId = String(commandObj['@_ID'] || commandObj.ID || commandObj.id || ''); + const commandId = String( + commandObj["@_ID"] || commandObj.ID || commandObj.id || "", + ); if (!commandId) { return { - id: 'unknown', + id: "unknown", parameters: {}, - category: 'unknown' as any, - pluginId: 'unknown', + category: "unknown" as any, + pluginId: "unknown", }; } @@ -1022,7 +1036,7 @@ export function detectCommand(commandObj: any): { id: commandId, definition, parameters, - category: definition?.category || ('unknown' as any), - pluginId: definition?.pluginId || 'unknown', + category: definition?.category || ("unknown" as any), + pluginId: definition?.pluginId || "unknown", }; } diff --git a/src/processors/gridset/crypto.ts b/src/processors/gridset/crypto.ts index 63a7a71..528a82e 100644 --- a/src/processors/gridset/crypto.ts +++ b/src/processors/gridset/crypto.ts @@ -14,23 +14,28 @@ */ export function decryptGridsetEntry(buffer: Buffer, password?: string): Buffer { const nodeRequire = - typeof require === 'function' ? require : (undefined as undefined | ((id: string) => any)); + typeof require === "function" + ? require + : (undefined as undefined | ((id: string) => any)); if (!nodeRequire) { - throw new Error('Crypto utilities are not available in this environment.'); + throw new Error("Crypto utilities are not available in this environment."); } // Dynamic require to avoid breaking in browser environments - const cryptoModule = 'crypto'; - const zlibModule = 'zlib'; + const cryptoModule = "crypto"; + const zlibModule = "zlib"; const crypto = nodeRequire(cryptoModule); const zlib = nodeRequire(zlibModule); - const pwd = (password || 'Chocolate').padEnd(32, ' '); - const key = Buffer.from(pwd.slice(0, 32), 'utf8'); - const iv = Buffer.from(pwd.slice(0, 16), 'utf8'); + const pwd = (password || "Chocolate").padEnd(32, " "); + const key = Buffer.from(pwd.slice(0, 32), "utf8"); + const iv = Buffer.from(pwd.slice(0, 16), "utf8"); try { - const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); - const decrypted = Buffer.concat([decipher.update(buffer), decipher.final()]); + const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); + const decrypted = Buffer.concat([ + decipher.update(buffer), + decipher.final(), + ]); try { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return zlib.inflateSync(decrypted); @@ -49,10 +54,12 @@ export function decryptGridsetEntry(buffer: Buffer, password?: string): Buffer { export function isCryptoAvailable(): boolean { try { const nodeRequire = - typeof require === 'function' ? require : (undefined as undefined | ((id: string) => any)); + typeof require === "function" + ? require + : (undefined as undefined | ((id: string) => any)); if (!nodeRequire) return false; - const cryptoModule = 'crypto'; - const zlibModule = 'zlib'; + const cryptoModule = "crypto"; + const zlibModule = "zlib"; nodeRequire(cryptoModule); nodeRequire(zlibModule); return true; diff --git a/src/processors/gridset/gridCalculations.ts b/src/processors/gridset/gridCalculations.ts new file mode 100644 index 0000000..a7f6822 --- /dev/null +++ b/src/processors/gridset/gridCalculations.ts @@ -0,0 +1,82 @@ +/** + * Grid3 Grid Calculations + * + * Utilities for calculating grid dimensions and definitions + * based on page layout and button count. + */ + +import type { AACPage } from "../../core/treeStructure"; + +/** + * Grid definition structure for Grid 3 XML + */ +export interface GridDefinitions { + ColumnDefinition: any[]; +} + +export interface RowDefinitions { + RowDefinition: any[]; +} + +/** + * Calculate column definitions based on page layout + * + * Analyzes the page's grid structure to determine the number of columns. + * If no grid exists, estimates from button count. + * + * @param page - The AAC page to analyze + * @returns Column definitions object for Grid 3 XML + * + * @example + * const columns = calculateColumnDefinitions(page); + * // Returns: { ColumnDefinition: [{}, {}, {}, {}] } for 4 columns + */ +export function calculateColumnDefinitions(page: AACPage): GridDefinitions { + let maxCols = 4; // Default minimum + + if (page.grid && page.grid.length > 0) { + maxCols = Math.max(maxCols, page.grid[0]?.length || 0); + } else { + // Fallback: estimate from button count + maxCols = Math.max(4, Math.ceil(Math.sqrt(page.buttons.length))); + } + + return { + ColumnDefinition: Array(maxCols).fill({}), + }; +} + +/** + * Calculate row definitions based on page layout + * + * Analyzes the page's grid structure to determine the number of rows. + * If no grid exists, estimates from button count. + * + * @param page - The AAC page to analyze + * @param addWorkspaceOffset - Whether to add 1 row for workspace (default: false) + * @returns Row definitions object for Grid 3 XML + * + * @example + * const rows = calculateRowDefinitions(page, false); + * // Returns: { RowDefinition: [{}, {}, {}, {}] } for 4 rows + */ +export function calculateRowDefinitions( + page: AACPage, + addWorkspaceOffset = false, +): RowDefinitions { + let maxRows = 4; // Default minimum + const offset = addWorkspaceOffset ? 1 : 0; + + if (page.grid && page.grid.length > 0) { + maxRows = Math.max(maxRows, page.grid.length + offset); + } else { + // Fallback: estimate from button count + const estimatedCols = Math.ceil(Math.sqrt(page.buttons.length)); + maxRows = + Math.max(4, Math.ceil(page.buttons.length / estimatedCols)) + offset; + } + + return { + RowDefinition: Array(maxRows).fill({}), + }; +} diff --git a/src/processors/gridset/helpers.ts b/src/processors/gridset/helpers.ts index 9f00bda..bac9378 100644 --- a/src/processors/gridset/helpers.ts +++ b/src/processors/gridset/helpers.ts @@ -1,13 +1,16 @@ -import { XMLBuilder } from 'fast-xml-parser'; +import { XMLBuilder } from "fast-xml-parser"; import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent, -} from '../../core/treeStructure'; -import { dotNetTicksToDate } from '../../utils/dotnetTicks'; -import { getZipEntriesFromAdapter, resolveGridsetPasswordFromEnv } from './password'; +} from "../../core/treeStructure"; +import { dotNetTicksToDate } from "../../utils/dotnetTicks"; +import { + getZipEntriesFromAdapter, + resolveGridsetPasswordFromEnv, +} from "./password"; import { defaultFileAdapter, extname, @@ -15,14 +18,14 @@ import { getNodeRequire, joinWin32, ProcessorInput, -} from '../../utils/io'; -import { getZipAdapter, ZipAdapter } from '../../utils/zip'; -import { requireBetterSqlite3 } from '../../utils/sqlite'; +} from "../../utils/io"; +import { getZipAdapter, ZipAdapter } from "../../utils/zip"; +import { requireBetterSqlite3 } from "../../utils/sqlite"; function normalizeZipPath(p: string): string { - const unified = p.replace(/\\/g, '/'); + const unified = p.replace(/\\/g, "/"); try { - return unified.normalize('NFC'); + return unified.normalize("NFC"); } catch { return unified; } @@ -32,7 +35,10 @@ function normalizeZipPath(p: string): string { * Build a map of button IDs to resolved image entry paths for a specific page. * Helpful when rewriting zip entry names or validating images referenced in a grid. */ -export function getPageTokenImageMap(tree: AACTree, pageId: string): Map { +export function getPageTokenImageMap( + tree: AACTree, + pageId: string, +): Map { const map = new Map(); const page: AACPage | undefined = tree.getPage(pageId); if (!page) return map; @@ -52,7 +58,8 @@ export function getAllowedImageEntries(tree: AACTree): Set { const out = new Set(); Object.values(tree.pages).forEach((page) => { page.buttons.forEach((btn: AACButton) => { - if (btn.resolvedImageEntry) out.add(normalizeZipPath(String(btn.resolvedImageEntry))); + if (btn.resolvedImageEntry) + out.add(normalizeZipPath(String(btn.resolvedImageEntry))); }); }); return out; @@ -69,7 +76,7 @@ export async function openImage( entryPath: string, password = resolveGridsetPasswordFromEnv(), fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input?: ProcessorInput) => Promise + zipAdapter?: (input?: ProcessorInput) => Promise, ): Promise { try { const zip = zipAdapter @@ -80,7 +87,7 @@ export async function openImage( const entry = entries.find((e) => normalizeZipPath(e.entryName) === want); if (!entry) return null; const data = await entry.getData(); - if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') { + if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") { return Buffer.from(data); } return data; @@ -95,9 +102,9 @@ export async function openImage( * @returns A UUID v4-like string in the format xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx */ export function generateGrid3Guid(): string { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0; - const v = c === 'x' ? r : (r & 0x3) | 0x8; + const v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); } @@ -117,24 +124,24 @@ export function createSettingsXml( hoverTimeoutMs?: number; mouseclickEnabled?: boolean; language?: string; - } + }, ): string { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", }); const settingsData = { GridSetSettings: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", StartGrid: startGrid, - ScanEnabled: options?.scanEnabled?.toString() ?? 'false', - ScanTimeoutMs: options?.scanTimeoutMs?.toString() ?? '2000', - HoverEnabled: options?.hoverEnabled?.toString() ?? 'false', - HoverTimeoutMs: options?.hoverTimeoutMs?.toString() ?? '1000', - MouseclickEnabled: options?.mouseclickEnabled?.toString() ?? 'true', - Language: options?.language ?? 'en-US', + ScanEnabled: options?.scanEnabled?.toString() ?? "false", + ScanTimeoutMs: options?.scanTimeoutMs?.toString() ?? "2000", + HoverEnabled: options?.hoverEnabled?.toString() ?? "false", + HoverTimeoutMs: options?.hoverTimeoutMs?.toString() ?? "1000", + MouseclickEnabled: options?.mouseclickEnabled?.toString() ?? "true", + Language: options?.language ?? "en-US", }, }; @@ -147,16 +154,16 @@ export function createSettingsXml( * @returns XML string for FileMap.xml */ export function createFileMapXml( - grids: Array<{ name: string; path: string; dynamicFiles?: string[] }> + grids: Array<{ name: string; path: string; dynamicFiles?: string[] }>, ): string { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", }); const entries = grids.map((grid) => ({ - '@_StaticFile': grid.path, + "@_StaticFile": grid.path, ...(grid.dynamicFiles && grid.dynamicFiles.length > 0 ? { DynamicFiles: { File: grid.dynamicFiles } } : {}), @@ -164,7 +171,7 @@ export function createFileMapXml( const fileMapData = { FileMap: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", Entries: { Entry: entries, }, @@ -196,7 +203,7 @@ export interface Grid3HistoryEntry { timestamp: Date; latitude?: number | null; longitude?: number | null; - type?: 'button' | 'action' | 'utterance' | 'note' | 'other'; + type?: "button" | "action" | "utterance" | "note" | "other"; intent?: AACSemanticIntent | string; category?: AACSemanticCategory; }>; @@ -210,16 +217,21 @@ export interface Grid3HistoryEntry { */ export function getCommonDocumentsPath(): string { // Only works on Windows - if (process.platform !== 'win32') { - return ''; + if (process.platform !== "win32") { + return ""; } try { // Query registry for Common Documents path - const child_process = getNodeRequire()('child_process') as typeof import('child_process'); + const child_process = getNodeRequire()( + "child_process", + ) as typeof import("child_process"); const command = 'REG.EXE QUERY "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders" /V "Common Documents"'; - const output = child_process.execSync(command, { encoding: 'utf-8', windowsHide: true }); + const output = child_process.execSync(command, { + encoding: "utf-8", + windowsHide: true, + }); // Parse the output to extract the path const match = output.match(/Common Documents\s+REG_SZ\s+(.+)/); @@ -231,7 +243,7 @@ export function getCommonDocumentsPath(): string { } // Default fallback path - return 'C:\\Users\\Public\\Documents'; + return "C:\\Users\\Public\\Documents"; } /** @@ -243,20 +255,20 @@ export function getCommonDocumentsPath(): string { * @returns Array of Grid3 user path information */ export async function findGrid3UserPaths( - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { pathExists, listDir, isDirectory } = fileAdapter; const results: Grid3UserPath[] = []; // Only works on Windows - if (process.platform !== 'win32') { + if (process.platform !== "win32") { return results; } try { const commonDocs = getCommonDocumentsPath(); // Use Windows path joining so tests that mock a Windows platform stay consistent even on POSIX runners - const grid3BasePath = joinWin32(commonDocs, 'Smartbox', 'Grid 3', 'Users'); + const grid3BasePath = joinWin32(commonDocs, "Smartbox", "Grid 3", "Users"); // Check if Grid3 Users directory exists if (!(await pathExists(grid3BasePath))) { @@ -280,7 +292,7 @@ export async function findGrid3UserPaths( const langCode = langDir; const basePath = joinWin32(userPath, langCode); - const historyDbPath = joinWin32(basePath, 'Phrases', 'history.sqlite'); + const historyDbPath = joinWin32(basePath, "Phrases", "history.sqlite"); // Only include if history database exists if (await pathExists(historyDbPath)) { @@ -305,7 +317,9 @@ export async function findGrid3UserPaths( * Convenience method that returns just the database file paths * @returns Array of paths to history.sqlite files */ -export async function findGrid3HistoryDatabases(fileAdapter?: FileAdapter): Promise { +export async function findGrid3HistoryDatabases( + fileAdapter?: FileAdapter, +): Promise { const userPaths = await findGrid3UserPaths(fileAdapter); return userPaths.map((userPath) => userPath.historyDbPath); } @@ -324,17 +338,17 @@ export async function findGrid3Users(): Promise { */ export async function findGrid3Vocabularies( userName?: string, - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { pathExists, listDir, isDirectory } = fileAdapter; const results: Grid3VocabularyPath[] = []; - if (process.platform !== 'win32') { + if (process.platform !== "win32") { return results; } const commonDocs = getCommonDocumentsPath(); - const grid3BasePath = joinWin32(commonDocs, 'Smartbox', 'Grid 3', 'Users'); + const grid3BasePath = joinWin32(commonDocs, "Smartbox", "Grid 3", "Users"); if (!(await pathExists(grid3BasePath))) { return results; @@ -348,14 +362,19 @@ export async function findGrid3Vocabularies( if (normalizedUser && userDir.toLowerCase() !== normalizedUser) continue; const userRoot = joinWin32(grid3BasePath, userDir); - const gridSetsDir = joinWin32(userRoot, 'Grid Sets'); + const gridSetsDir = joinWin32(userRoot, "Grid Sets"); if (!(await pathExists(gridSetsDir))) continue; const entries = await listDir(gridSetsDir); for (const entry of entries) { if (!(await pathExists(entry)) || (await isDirectory(entry))) continue; const ext = extname(entry).toLowerCase(); - if (ext === '.gridset' || ext === '.gridsetx' || ext === '.grd' || ext === '.grdl') { + if ( + ext === ".gridset" || + ext === ".gridsetx" || + ext === ".grd" || + ext === ".grdl" + ) { results.push({ userName: userDir, gridsetPath: joinWin32(gridSetsDir, entry), @@ -376,7 +395,7 @@ export async function findGrid3Vocabularies( export async function findGrid3UserHistory( userName: string, langCode?: string, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { if (!userName) return null; @@ -387,7 +406,7 @@ export async function findGrid3UserHistory( const match = userPaths.find( (u) => u.userName.toLowerCase() === normalizedUser && - (!normalizedLang || u.langCode.toLowerCase() === normalizedLang) + (!normalizedLang || u.langCode.toLowerCase() === normalizedLang), ); return match?.historyDbPath ?? null; @@ -397,13 +416,13 @@ export async function findGrid3UserHistory( * Check whether Grid 3 appears to be installed (Windows only) */ export async function isGrid3Installed( - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { pathExists } = fileAdapter; - if (process.platform !== 'win32') return false; + if (process.platform !== "win32") return false; const commonDocs = getCommonDocumentsPath(); if (!commonDocs) return false; - const grid3BasePath = joinWin32(commonDocs, 'Smartbox', 'Grid 3', 'Users'); + const grid3BasePath = joinWin32(commonDocs, "Smartbox", "Grid 3", "Users"); return await pathExists(grid3BasePath); } @@ -415,9 +434,9 @@ function parseGrid3ContentXml(xmlContent: string): string { parts.push(match[1]); } if (parts.length > 0) { - return parts.join(''); + return parts.join(""); } - return xmlContent.replace(/<[^>]+>/g, '').trim(); + return xmlContent.replace(/<[^>]+>/g, "").trim(); } /** @@ -427,7 +446,7 @@ function parseGrid3ContentXml(xmlContent: string): string { */ export async function readGrid3History( historyDbPath: string, - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { pathExists } = fileAdapter; if (!(await pathExists(historyDbPath))) return []; @@ -447,7 +466,7 @@ export async function readGrid3History( INNER JOIN Phrases p ON p.Id = ph.PhraseId WHERE ph.Timestamp <> 0 ORDER BY ph.Timestamp ASC - ` + `, ) .all() as Array<{ PhraseId: number; @@ -462,11 +481,13 @@ export async function readGrid3History( for (const row of rows) { const phraseId: number = row.PhraseId; - const rawContentSource = [row.ContentXml, row.TextValue].find((candidate) => { - if (candidate === null || candidate === undefined) return false; - const asString = String(candidate); - return asString.trim().length > 0; - }); + const rawContentSource = [row.ContentXml, row.TextValue].find( + (candidate) => { + if (candidate === null || candidate === undefined) return false; + const asString = String(candidate); + return asString.trim().length > 0; + }, + ); if (rawContentSource === undefined) { continue; // Skip history rows with no usable text content } @@ -474,7 +495,7 @@ export async function readGrid3History( const rawContentText = String(rawContentSource); const contentText = parseGrid3ContentXml(rawContentText); const rawXml = - typeof row.ContentXml === 'string' && row.ContentXml.trim().length > 0 + typeof row.ContentXml === "string" && row.ContentXml.trim().length > 0 ? row.ContentXml : undefined; const entry = @@ -490,7 +511,7 @@ export async function readGrid3History( timestamp: dotNetTicksToDate(BigInt(row.TickValue ?? 0)), latitude: row.Latitude ?? null, longitude: row.Longitude ?? null, - type: 'utterance', + type: "utterance", intent: AACSemanticIntent.SPEAK_TEXT, category: AACSemanticCategory.COMMUNICATION, }); @@ -509,7 +530,7 @@ export async function readGrid3History( */ export async function readGrid3HistoryForUser( userName: string, - langCode?: string + langCode?: string, ): Promise { const dbPath = await findGrid3UserHistory(userName, langCode); if (!dbPath) return []; @@ -522,6 +543,8 @@ export async function readGrid3HistoryForUser( */ export async function readAllGrid3History(): Promise { const paths = await findGrid3HistoryDatabases(); - const history = await Promise.all(paths.map(async (p) => await readGrid3History(p))); + const history = await Promise.all( + paths.map(async (p) => await readGrid3History(p)), + ); return history.flat(); } diff --git a/src/processors/gridset/imageDebug.ts b/src/processors/gridset/imageDebug.ts index 1781859..ce64806 100644 --- a/src/processors/gridset/imageDebug.ts +++ b/src/processors/gridset/imageDebug.ts @@ -5,11 +5,16 @@ * correctly in Grid3 gridsets. */ -import { getZipEntriesFromAdapter } from './password'; -import { resolveGridsetPasswordFromEnv } from './password'; -import { XMLParser } from 'fast-xml-parser'; -import { decodeText, defaultFileAdapter, FileAdapter, type ProcessorInput } from '../../utils/io'; -import { getZipAdapter, ZipAdapter } from '../../utils/zip'; +import { getZipEntriesFromAdapter } from "./password"; +import { resolveGridsetPasswordFromEnv } from "./password"; +import { XMLParser } from "fast-xml-parser"; +import { + decodeText, + defaultFileAdapter, + FileAdapter, + type ProcessorInput, +} from "../../utils/io"; +import { getZipAdapter, ZipAdapter } from "../../utils/zip"; export interface ImageIssue { gridName: string; @@ -17,7 +22,7 @@ export interface ImageIssue { cellY: number; declaredImage: string | undefined; expectedPaths: string[]; - issue: 'not_found' | 'symbol_library' | 'external_reference'; + issue: "not_found" | "symbol_library" | "external_reference"; suggestion: string; } @@ -47,7 +52,7 @@ export async function auditGridsetImages( gridsetBuffer: Uint8Array, password = resolveGridsetPasswordFromEnv(), fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise + zipAdapter?: (input: ProcessorInput) => Promise, ): Promise { const issues: ImageIssue[] = []; const availableImages = new Set(); @@ -65,7 +70,15 @@ export async function auditGridsetImages( const parser = new XMLParser(); // Collect all image files in the gridset - const imageExtensions = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.emf', '.wmf']; + const imageExtensions = [ + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", + ".emf", + ".wmf", + ]; for (const entry of entries) { const name = entry.entryName.toLowerCase(); if (imageExtensions.some((ext) => name.endsWith(ext))) { @@ -75,7 +88,10 @@ export async function auditGridsetImages( // Process each grid file for (const entry of entries) { - if (!entry.entryName.startsWith('Grids/') || !entry.entryName.endsWith('grid.xml')) { + if ( + !entry.entryName.startsWith("Grids/") || + !entry.entryName.endsWith("grid.xml") + ) { continue; } @@ -88,27 +104,37 @@ export async function auditGridsetImages( const gridNameMatch = entry.entryName.match(/^Grids\/([^/]+)\//); const gridName = gridNameMatch ? gridNameMatch[1] : entry.entryName; - const gridEntryPath = entry.entryName.replace(/\\/g, '/'); - const baseDir = gridEntryPath.replace(/\/grid\.xml$/, '/'); + const gridEntryPath = entry.entryName.replace(/\\/g, "/"); + const baseDir = gridEntryPath.replace(/\/grid\.xml$/, "/"); // Check for FileMap.xml - const fileMapEntry = entries.find((e) => e.entryName === baseDir + 'FileMap.xml'); + const fileMapEntry = entries.find( + (e) => e.entryName === baseDir + "FileMap.xml", + ); const dynamicFilesMap = new Map(); if (fileMapEntry) { try { const fmXml = decodeText(await fileMapEntry.getData()); const fmData = parser.parse(fmXml); - const fileEntries = fmData?.FileMap?.Entries?.Entry || fmData?.fileMap?.entries?.entry; + const fileEntries = + fmData?.FileMap?.Entries?.Entry || + fmData?.fileMap?.entries?.entry; if (fileEntries) { - const arr = Array.isArray(fileEntries) ? fileEntries : [fileEntries]; + const arr = Array.isArray(fileEntries) + ? fileEntries + : [fileEntries]; for (const ent of arr) { - const rawStaticFile = ent['@_StaticFile'] || ent.StaticFile || ent.staticFile; + const rawStaticFile = + ent["@_StaticFile"] || ent.StaticFile || ent.staticFile; const staticFile = - typeof rawStaticFile === 'string' ? rawStaticFile.replace(/\\/g, '/') : ''; + typeof rawStaticFile === "string" + ? rawStaticFile.replace(/\\/g, "/") + : ""; if (!staticFile) continue; const df = ent.DynamicFiles || ent.dynamicFiles; - const candidates = df?.File || df?.file || df?.Files || df?.files; + const candidates = + df?.File || df?.file || df?.Files || df?.files; const list: string[] = Array.isArray(candidates) ? candidates : candidates @@ -133,7 +159,8 @@ export async function auditGridsetImages( const content = cell.Content; if (!content) continue; - const captionAndImage = content.CaptionAndImage || content.captionAndImage; + const captionAndImage = + content.CaptionAndImage || content.captionAndImage; const imageCandidate = captionAndImage?.Image || captionAndImage?.image || @@ -144,8 +171,14 @@ export async function auditGridsetImages( cellsWithImages++; - const cellX = Math.max(0, parseInt(String(cell['@_X'] || '1'), 10) - 1); - const cellY = Math.max(0, parseInt(String(cell['@_Y'] || '1'), 10) - 1); + const cellX = Math.max( + 0, + parseInt(String(cell["@_X"] || "1"), 10) - 1, + ); + const cellY = Math.max( + 0, + parseInt(String(cell["@_Y"] || "1"), 10) - 1, + ); // Try to resolve the image const imageName = String(imageCandidate).trim(); @@ -166,30 +199,38 @@ export async function auditGridsetImages( `${baseDir}${cellX + 1}-${cellY + 1}.png`, ]; - let issue: ImageIssue['issue']; + let issue: ImageIssue["issue"]; let suggestion: string; - if (imageName.startsWith('[')) { + if (imageName.startsWith("[")) { // Check if it's a symbol library reference - if (imageName.includes('widgit') || imageName.includes('Widgit')) { - issue = 'symbol_library'; + if ( + imageName.includes("widgit") || + imageName.includes("Widgit") + ) { + issue = "symbol_library"; suggestion = - 'This is a Widgit symbol library reference. These symbols are not stored in the gridset - they require the Widgit Symbols to be installed on the system.'; - } else if (imageName.includes('grid3x') || imageName.includes('Grid3')) { - issue = 'external_reference'; + "This is a Widgit symbol library reference. These symbols are not stored in the gridset - they require the Widgit Symbols to be installed on the system."; + } else if ( + imageName.includes("grid3x") || + imageName.includes("Grid3") + ) { + issue = "external_reference"; suggestion = - 'This is a built-in Grid3 resource reference. These images are not included in the gridset file.'; + "This is a built-in Grid3 resource reference. These images are not included in the gridset file."; } else { - issue = 'symbol_library'; + issue = "symbol_library"; suggestion = `External symbol library reference: ${imageName}. Symbol libraries are not embedded in gridset files.`; } } else { - issue = 'not_found'; + issue = "not_found"; const similarImages = Array.from(availableImages).filter((img) => - img.toLowerCase().includes(imageName.toLowerCase().substring(0, 10)) + img + .toLowerCase() + .includes(imageName.toLowerCase().substring(0, 10)), ); if (similarImages.length > 0) { - suggestion = `Image not found. Did you mean one of these?\n ${similarImages.slice(0, 3).join('\n ')}`; + suggestion = `Image not found. Did you mean one of these?\n ${similarImages.slice(0, 3).join("\n ")}`; } else { suggestion = `Image file not found in gridset. The file may have been excluded or the path is incorrect.`; } @@ -231,19 +272,19 @@ export async function auditGridsetImages( export function formatImageAuditSummary(audit: ImageAuditResult): string { const lines: string[] = []; - lines.push('=== Grid3 Image Audit Summary ==='); + lines.push("=== Grid3 Image Audit Summary ==="); lines.push(`Total cells: ${audit.totalCells}`); lines.push(`Cells with images: ${audit.cellsWithImages}`); lines.push(`Resolved images: ${audit.resolvedImages}`); lines.push(`Unresolved images: ${audit.unresolvedImages}`); lines.push(`Available image files: ${audit.availableImages.length}`); - lines.push(''); + lines.push(""); if (audit.issues.length > 0) { - lines.push('=== Image Issues ==='); + lines.push("=== Image Issues ==="); // Group by issue type - const byType = new Map(); + const byType = new Map(); for (const issue of audit.issues) { const list = byType.get(issue.issue) || []; list.push(issue); @@ -255,7 +296,7 @@ export function formatImageAuditSummary(audit: ImageAuditResult): string { for (const issue of issues.slice(0, 5)) { // Show first 5 of each type lines.push( - ` [${issue.gridName}] Cell (${issue.cellX}, ${issue.cellY}): ${issue.declaredImage}` + ` [${issue.gridName}] Cell (${issue.cellX}, ${issue.cellY}): ${issue.declaredImage}`, ); lines.push(` → ${issue.suggestion}`); } @@ -265,5 +306,5 @@ export function formatImageAuditSummary(audit: ImageAuditResult): string { } } - return lines.join('\n'); + return lines.join("\n"); } diff --git a/src/processors/gridset/index.ts b/src/processors/gridset/index.ts index 333ea17..9925b93 100644 --- a/src/processors/gridset/index.ts +++ b/src/processors/gridset/index.ts @@ -18,7 +18,7 @@ export { CATEGORY_STYLES, createDefaultStylesXml, createCategoryStyle, -} from './styleHelpers'; +} from "./styleHelpers"; // Plugin cell type detection export { @@ -33,7 +33,7 @@ export { isLiveCell, isAutoContentCell, isRegularCell, -} from './pluginTypes'; +} from "./pluginTypes"; // Command definitions and detection export { @@ -49,16 +49,22 @@ export { type CommandParameter, type ExtractedParameters, Grid3CommandCategory, -} from './commands'; +} from "./commands"; // Import for local use in constant definitions -import { getAllCommandIds, getAllPluginIds } from './commands'; -import { CellBackgroundShape } from './styleHelpers'; -import { Grid3CellType } from './pluginTypes'; -import { Grid3CommandCategory } from './commands'; +import { getAllCommandIds, getAllPluginIds } from "./commands"; +import { CellBackgroundShape } from "./styleHelpers"; +import { Grid3CellType } from "./pluginTypes"; +import { Grid3CommandCategory } from "./commands"; // Color utilities -export { ensureAlphaChannel, darkenColor, lightenColor, hexToRgba, rgbaToHex } from './colorUtils'; +export { + ensureAlphaChannel, + darkenColor, + lightenColor, + hexToRgba, + rgbaToHex, +} from "./colorUtils"; // Password handling export { @@ -66,7 +72,7 @@ export { getZipEntriesWithPassword, getZipEntriesFromAdapter, resolveGridsetPasswordFromEnv, -} from './password'; +} from "./password"; // Helper functions export { @@ -89,7 +95,7 @@ export { type Grid3UserPath, type Grid3VocabularyPath, type Grid3HistoryEntry, -} from './helpers'; +} from "./helpers"; // Symbol library handling export { @@ -116,17 +122,17 @@ export { type SymbolResolutionResult, type SymbolUsageStats, type SymbolLibraryName, -} from './symbols'; +} from "./symbols"; // Backward compatibility aliases for old function names -export { getSymbolsDir, getSymbolSearchDir } from './symbols'; +export { getSymbolsDir, getSymbolSearchDir } from "./symbols"; // Image resolution export { resolveGrid3CellImage, isSymbolLibraryReference, parseImageSymbolReference, -} from './resolver'; +} from "./resolver"; // Symbol extraction and conversion export { @@ -141,7 +147,7 @@ export { type SymbolExtractionOptions, type SymbolReport, type SymbolManifest, -} from './symbolExtractor'; +} from "./symbolExtractor"; // Symbol search functionality export { @@ -159,7 +165,7 @@ export { type SymbolSearchOptions, type LibrarySearchIndex, type SymbolSearchStats, -} from './symbolSearch'; +} from "./symbolSearch"; /** * Get all Grid 3 command IDs as a readonly array @@ -176,7 +182,9 @@ export const GRID3_PLUGIN_IDS = Object.freeze(getAllPluginIds()); * Grid 3 cell shapes enum values */ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -export const GRID3_CELL_SHAPES = Object.freeze(Object.values(CellBackgroundShape)); +export const GRID3_CELL_SHAPES = Object.freeze( + Object.values(CellBackgroundShape), +); /** * Grid 3 cell types enum values @@ -188,4 +196,6 @@ export const GRID3_CELL_TYPES = Object.freeze(Object.values(Grid3CellType)); * Grid 3 command categories enum values */ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -export const GRID3_COMMAND_CATEGORIES = Object.freeze(Object.values(Grid3CommandCategory)); +export const GRID3_COMMAND_CATEGORIES = Object.freeze( + Object.values(Grid3CommandCategory), +); diff --git a/src/processors/gridset/password.ts b/src/processors/gridset/password.ts index 75f8896..83b795c 100644 --- a/src/processors/gridset/password.ts +++ b/src/processors/gridset/password.ts @@ -1,11 +1,11 @@ -import type JSZip from 'jszip'; -import { ProcessorOptions } from '../../core/baseProcessor'; -import { ProcessorInput } from '../../utils/io'; -import { ZipAdapter } from '../../utils/zip'; +import type JSZip from "jszip"; +import { ProcessorOptions } from "../../core/baseProcessor"; +import { ProcessorInput } from "../../utils/io"; +import { ZipAdapter } from "../../utils/zip"; function getExtension(source: string): string { - const index = source.lastIndexOf('.'); - if (index === -1) return ''; + const index = source.lastIndexOf("."); + if (index === -1) return ""; return source.slice(index); } @@ -17,22 +17,25 @@ function getExtension(source: string): string { */ export function resolveGridsetPassword( options?: ProcessorOptions, - source?: ProcessorInput + source?: ProcessorInput, ): string | undefined { if (options?.gridsetPassword) return options.gridsetPassword; - const envPassword = typeof process !== 'undefined' ? process.env?.GRIDSET_PASSWORD : undefined; + const envPassword = + typeof process !== "undefined" ? process.env?.GRIDSET_PASSWORD : undefined; if (envPassword) return envPassword; - if (typeof source === 'string') { + if (typeof source === "string") { const ext = getExtension(source).toLowerCase(); - if (ext === '.gridsetx') return envPassword; + if (ext === ".gridsetx") return envPassword; } return undefined; } export function resolveGridsetPasswordFromEnv(): string | undefined { - return typeof process !== 'undefined' ? process.env?.GRIDSET_PASSWORD : undefined; + return typeof process !== "undefined" + ? process.env?.GRIDSET_PASSWORD + : undefined; } /** @@ -51,7 +54,10 @@ export type ZipEntry = { getData: () => Promise; }; -export function getZipEntriesWithPassword(zip: JSZip, password?: string): ZipEntry[] { +export function getZipEntriesWithPassword( + zip: JSZip, + password?: string, +): ZipEntry[] { const entries: Array<{ name: string; entryName: string; @@ -64,7 +70,7 @@ export function getZipEntriesWithPassword(zip: JSZip, password?: string): ZipEnt // in crypto.ts before the zip is loaded if (password) { console.warn( - 'JSZip does not support zip-level password protection. For .gridsetx encrypted files, password is handled at the archive level.' + "JSZip does not support zip-level password protection. For .gridsetx encrypted files, password is handled at the archive level.", ); } @@ -75,7 +81,7 @@ export function getZipEntriesWithPassword(zip: JSZip, password?: string): ZipEnt dir: file.dir || false, getData: async () => { // Use 'uint8array' which is supported everywhere - return await file.async('uint8array'); + return await file.async("uint8array"); }, }); }); @@ -83,9 +89,14 @@ export function getZipEntriesWithPassword(zip: JSZip, password?: string): ZipEnt return entries; } -export function getZipEntriesFromAdapter(zip: ZipAdapter, password?: string): ZipEntry[] { +export function getZipEntriesFromAdapter( + zip: ZipAdapter, + password?: string, +): ZipEntry[] { if (password) { - console.warn('Zip password support is handled at the archive level for .gridsetx files.'); + console.warn( + "Zip password support is handled at the archive level for .gridsetx files.", + ); } return zip.listFiles().map((entryName) => ({ diff --git a/src/processors/gridset/pluginTypes.ts b/src/processors/gridset/pluginTypes.ts index f314a95..0ed49fd 100644 --- a/src/processors/gridset/pluginTypes.ts +++ b/src/processors/gridset/pluginTypes.ts @@ -13,10 +13,10 @@ * Cell types in Grid 3 */ export enum Grid3CellType { - Regular = 'regular', - Workspace = 'workspace', - LiveCell = 'livecell', - AutoContent = 'autocontent', + Regular = "regular", + Workspace = "workspace", + LiveCell = "livecell", + AutoContent = "autocontent", } /** @@ -35,56 +35,56 @@ export interface Grid3PluginMetadata { * Known workspace types in Grid 3 */ export const WORKSPACE_TYPES = { - CHAT: 'Chat', - EMAIL: 'Email', - WORD_PROCESSOR: 'WordProcessor', - PHONE: 'Phone', - SMS: 'Sms', - WEB_BROWSER: 'WebBrowser', - COMPUTER_CONTROL: 'ComputerControl', - CALCULATOR: 'Calculator', - TIMER: 'Timer', - MUSIC_VIDEO: 'MusicVideo', - PHOTOS: 'Photos', - CONTACTS: 'Contacts', - INTERACTIVE_LEARNING: 'InteractiveLearning', - MESSAGE_BANKING: 'MessageBanking', - ENVIRONMENT_CONTROL: 'EnvironmentControl', - SETTINGS: 'Settings', + CHAT: "Chat", + EMAIL: "Email", + WORD_PROCESSOR: "WordProcessor", + PHONE: "Phone", + SMS: "Sms", + WEB_BROWSER: "WebBrowser", + COMPUTER_CONTROL: "ComputerControl", + CALCULATOR: "Calculator", + TIMER: "Timer", + MUSIC_VIDEO: "MusicVideo", + PHOTOS: "Photos", + CONTACTS: "Contacts", + INTERACTIVE_LEARNING: "InteractiveLearning", + MESSAGE_BANKING: "MessageBanking", + ENVIRONMENT_CONTROL: "EnvironmentControl", + SETTINGS: "Settings", } as const; /** * Known live cell types in Grid 3 */ export const LIVECELL_TYPES = { - DIGITAL_CLOCK: 'DigitalClock', - ANALOG_CLOCK: 'AnalogClock', - DATE_DISPLAY: 'DateDisplay', - PUBLIC_VOLUME: 'PublicVolume', - PUBLIC_SPEED: 'PublicSpeed', - PUBLIC_VOICE: 'PublicVoice', - MESSAGES: 'Messages', - BATTERY: 'Battery', - WIFI_STRENGTH: 'WifiStrength', - BLUETOOTH_STATUS: 'BluetoothStatus', + DIGITAL_CLOCK: "DigitalClock", + ANALOG_CLOCK: "AnalogClock", + DATE_DISPLAY: "DateDisplay", + PUBLIC_VOLUME: "PublicVolume", + PUBLIC_SPEED: "PublicSpeed", + PUBLIC_VOICE: "PublicVoice", + MESSAGES: "Messages", + BATTERY: "Battery", + WIFI_STRENGTH: "WifiStrength", + BLUETOOTH_STATUS: "BluetoothStatus", } as const; /** * Known auto content types in Grid 3 */ export const AUTOCONTENT_TYPES = { - CHANGE_PUBLIC_VOICE: 'ChangePublicVoice', - CHANGE_PUBLIC_SPEED: 'ChangePublicSpeed', - EMAIL_CONTACTS: 'EmailContacts', - EMAIL_RECIPIENTS: 'EmailRecipients', - PHONE_CONTACTS: 'PhoneContacts', - SMS_CONTACTS: 'SmsContacts', - WEB_FAVORITES: 'WebFavorites', - WEB_HISTORY: 'WebHistory', - PREDICTION: 'Prediction', - GRAMMAR: 'Grammar', - CONTEXTUAL: 'Contextual', - WORDLIST: 'WordList', + CHANGE_PUBLIC_VOICE: "ChangePublicVoice", + CHANGE_PUBLIC_SPEED: "ChangePublicSpeed", + EMAIL_CONTACTS: "EmailContacts", + EMAIL_RECIPIENTS: "EmailRecipients", + PHONE_CONTACTS: "PhoneContacts", + SMS_CONTACTS: "SmsContacts", + WEB_FAVORITES: "WebFavorites", + WEB_HISTORY: "WebHistory", + PREDICTION: "Prediction", + GRAMMAR: "Grammar", + CONTEXTUAL: "Contextual", + WORDLIST: "WordList", } as const; /** @@ -93,15 +93,15 @@ export const AUTOCONTENT_TYPES = { export function getCellTypeDisplayName(cellType: Grid3CellType): string { switch (cellType) { case Grid3CellType.Workspace: - return 'Workspace'; + return "Workspace"; case Grid3CellType.LiveCell: - return 'Live Cell'; + return "Live Cell"; case Grid3CellType.AutoContent: - return 'Auto Content'; + return "Auto Content"; case Grid3CellType.Regular: - return 'Regular'; + return "Regular"; default: - return 'Unknown'; + return "Unknown"; } } @@ -120,33 +120,44 @@ export function detectPluginCellType(content: any): Grid3PluginMetadata { const contentSubType = content.ContentSubType || content.contentsubtype; // Workspace cells - full editing workspaces - if (contentType === 'Workspace' || content.Style?.BasedOnStyle === 'Workspace') { + if ( + contentType === "Workspace" || + content.Style?.BasedOnStyle === "Workspace" + ) { return { cellType: Grid3CellType.Workspace, subType: contentSubType || undefined, - pluginId: inferWorkspacePlugin(String(contentSubType || '')), - displayName: contentSubType ? `${contentSubType} Workspace` : 'Workspace', + pluginId: inferWorkspacePlugin(String(contentSubType || "")), + displayName: contentSubType ? `${contentSubType} Workspace` : "Workspace", }; } // LiveCell detection - dynamic content displays - if (contentType === 'LiveCell' || content.Style?.BasedOnStyle === 'LiveCell') { + if ( + contentType === "LiveCell" || + content.Style?.BasedOnStyle === "LiveCell" + ) { return { cellType: Grid3CellType.LiveCell, liveCellType: contentSubType || undefined, - pluginId: inferLiveCellPlugin(String(contentSubType || '')), - displayName: contentSubType || 'Live Cell', + pluginId: inferLiveCellPlugin(String(contentSubType || "")), + displayName: contentSubType || "Live Cell", }; } // AutoContent detection - dynamic word/content suggestions - if (contentType === 'AutoContent' || content.Style?.BasedOnStyle === 'AutoContent') { + if ( + contentType === "AutoContent" || + content.Style?.BasedOnStyle === "AutoContent" + ) { const autoContentType = extractAutoContentType(content) || contentSubType; return { cellType: Grid3CellType.AutoContent, autoContentType: autoContentType ? String(autoContentType) : undefined, - pluginId: inferAutoContentPlugin(autoContentType ? String(autoContentType) : undefined), - displayName: autoContentType ? String(autoContentType) : 'Auto Content', + pluginId: inferAutoContentPlugin( + autoContentType ? String(autoContentType) : undefined, + ), + displayName: autoContentType ? String(autoContentType) : "Auto Content", }; } @@ -166,15 +177,19 @@ function extractAutoContentType(content: any): string | undefined { const commandArr = Array.isArray(commands) ? commands : [commands]; for (const command of commandArr) { - const commandId = command['@_ID'] || command.ID || command.id; - if (commandId === 'AutoContent.Activate') { + const commandId = command["@_ID"] || command.ID || command.id; + if (commandId === "AutoContent.Activate") { const parameters = command.Parameter || command.parameter; - const paramArr = Array.isArray(parameters) ? parameters : parameters ? [parameters] : []; + const paramArr = Array.isArray(parameters) + ? parameters + : parameters + ? [parameters] + : []; for (const param of paramArr) { - const key = param['@_Key'] || param.Key || param.key; - if (key === 'autocontenttype') { - return String(param['#text'] || param.text || param.value || ''); + const key = param["@_Key"] || param.Key || param.key; + if (key === "autocontenttype") { + return String(param["#text"] || param.text || param.value || ""); } } } @@ -191,23 +206,29 @@ function inferWorkspacePlugin(subType?: string): string | undefined { const normalized = subType.toLowerCase(); - if (normalized.includes('chat')) return 'Grid3.Chat'; - if (normalized.includes('email') || normalized.includes('mail')) return 'Grid3.Email'; - if (normalized.includes('word') || normalized.includes('doc')) return 'Grid3.WordProcessor'; - if (normalized.includes('phone')) return 'Grid3.Phone'; - if (normalized.includes('sms') || normalized.includes('text')) return 'Grid3.Sms'; - if (normalized.includes('browser') || normalized.includes('web')) return 'Grid3.WebBrowser'; - if (normalized.includes('computer')) return 'Grid3.ComputerControl'; - if (normalized.includes('calc')) return 'Grid3.Calculator'; - if (normalized.includes('timer')) return 'Grid3.Timer'; - if (normalized.includes('music') || normalized.includes('video')) return 'Grid3.MusicVideo'; - if (normalized.includes('photo') || normalized.includes('image')) return 'Grid3.Photos'; - if (normalized.includes('contact')) return 'Grid3.Contacts'; - if (normalized.includes('learning')) return 'Grid3.InteractiveLearning'; - if (normalized.includes('message') && normalized.includes('banking')) - return 'Grid3.MessageBanking'; - if (normalized.includes('control')) return 'Grid3.EnvironmentControl'; - if (normalized.includes('settings')) return 'Grid3.Settings'; + if (normalized.includes("chat")) return "Grid3.Chat"; + if (normalized.includes("email") || normalized.includes("mail")) + return "Grid3.Email"; + if (normalized.includes("word") || normalized.includes("doc")) + return "Grid3.WordProcessor"; + if (normalized.includes("phone")) return "Grid3.Phone"; + if (normalized.includes("sms") || normalized.includes("text")) + return "Grid3.Sms"; + if (normalized.includes("browser") || normalized.includes("web")) + return "Grid3.WebBrowser"; + if (normalized.includes("computer")) return "Grid3.ComputerControl"; + if (normalized.includes("calc")) return "Grid3.Calculator"; + if (normalized.includes("timer")) return "Grid3.Timer"; + if (normalized.includes("music") || normalized.includes("video")) + return "Grid3.MusicVideo"; + if (normalized.includes("photo") || normalized.includes("image")) + return "Grid3.Photos"; + if (normalized.includes("contact")) return "Grid3.Contacts"; + if (normalized.includes("learning")) return "Grid3.InteractiveLearning"; + if (normalized.includes("message") && normalized.includes("banking")) + return "Grid3.MessageBanking"; + if (normalized.includes("control")) return "Grid3.EnvironmentControl"; + if (normalized.includes("settings")) return "Grid3.Settings"; return `Grid3.${subType}`; } @@ -220,15 +241,15 @@ function inferLiveCellPlugin(liveCellType?: string): string | undefined { const normalized = liveCellType.toLowerCase(); - if (normalized.includes('clock')) return 'Grid3.Clock'; - if (normalized.includes('date')) return 'Grid3.Clock'; - if (normalized.includes('volume')) return 'Grid3.Volume'; - if (normalized.includes('speed')) return 'Grid3.Speed'; - if (normalized.includes('voice')) return 'Grid3.Speech'; - if (normalized.includes('message')) return 'Grid3.Chat'; - if (normalized.includes('battery')) return 'Grid3.Battery'; - if (normalized.includes('wifi')) return 'Grid3.Wifi'; - if (normalized.includes('bluetooth')) return 'Grid3.Bluetooth'; + if (normalized.includes("clock")) return "Grid3.Clock"; + if (normalized.includes("date")) return "Grid3.Clock"; + if (normalized.includes("volume")) return "Grid3.Volume"; + if (normalized.includes("speed")) return "Grid3.Speed"; + if (normalized.includes("voice")) return "Grid3.Speech"; + if (normalized.includes("message")) return "Grid3.Chat"; + if (normalized.includes("battery")) return "Grid3.Battery"; + if (normalized.includes("wifi")) return "Grid3.Wifi"; + if (normalized.includes("bluetooth")) return "Grid3.Bluetooth"; return `Grid3.${liveCellType}`; } @@ -241,20 +262,23 @@ function inferAutoContentPlugin(autoContentType?: string): string | undefined { const normalized = autoContentType.toLowerCase(); - if (normalized.includes('voice') || normalized.includes('speed')) return 'Grid3.Speech'; - if (normalized.includes('email') || normalized.includes('mail')) return 'Grid3.Email'; - if (normalized.includes('phone')) return 'Grid3.Phone'; - if (normalized.includes('sms') || normalized.includes('text')) return 'Grid3.Sms'; + if (normalized.includes("voice") || normalized.includes("speed")) + return "Grid3.Speech"; + if (normalized.includes("email") || normalized.includes("mail")) + return "Grid3.Email"; + if (normalized.includes("phone")) return "Grid3.Phone"; + if (normalized.includes("sms") || normalized.includes("text")) + return "Grid3.Sms"; if ( - normalized.includes('web') || - normalized.includes('favorite') || - normalized.includes('history') + normalized.includes("web") || + normalized.includes("favorite") || + normalized.includes("history") ) { - return 'Grid3.WebBrowser'; + return "Grid3.WebBrowser"; } - if (normalized.includes('prediction')) return 'Grid3.Prediction'; - if (normalized.includes('grammar')) return 'Grid3.Grammar'; - if (normalized.includes('context')) return 'Grid3.AutoContent'; + if (normalized.includes("prediction")) return "Grid3.Prediction"; + if (normalized.includes("grammar")) return "Grid3.Grammar"; + if (normalized.includes("context")) return "Grid3.AutoContent"; return undefined; } diff --git a/src/processors/gridset/resolver.ts b/src/processors/gridset/resolver.ts index 5fe396a..6b1ed21 100644 --- a/src/processors/gridset/resolver.ts +++ b/src/processors/gridset/resolver.ts @@ -1,9 +1,9 @@ -import { isSymbolReference, parseSymbolReference } from './symbols'; +import { isSymbolReference, parseSymbolReference } from "./symbols"; function normalizeZipPathLocal(p: string): string { - const unified = p.replace(/\\/g, '/'); + const unified = p.replace(/\\/g, "/"); try { - return unified.normalize('NFC'); + return unified.normalize("NFC"); } catch { return unified; } @@ -14,7 +14,7 @@ function listZipEntries(zip: any, zipEntries?: any[]): string[] { const raw: unknown = Array.isArray(zipEntries) && zipEntries.length > 0 ? zipEntries - : typeof zip?.getEntries === 'function' + : typeof zip?.getEntries === "function" ? zip.getEntries() : []; let entries: unknown[] = []; @@ -33,8 +33,8 @@ function extFromName(name?: string): string | undefined { } function joinBaseDir(baseDir: string, leaf: string): string { - const base = normalizeZipPathLocal(baseDir).replace(/\/?$/, '/'); - return normalizeZipPathLocal(base + leaf.replace(/^\//, '')); + const base = normalizeZipPathLocal(baseDir).replace(/\/?$/, "/"); + return normalizeZipPathLocal(base + leaf.replace(/^\//, "")); } export function resolveGrid3CellImage( @@ -47,7 +47,7 @@ export function resolveGrid3CellImage( dynamicFiles?: string[]; builtinHandler?: (name: string) => string | null; }, - zipEntries?: any[] + zipEntries?: any[], ): string | null { const { baseDir, dynamicFiles } = args; const imageName = args.imageName?.trim(); @@ -58,7 +58,8 @@ export function resolveGrid3CellImage( const has = (p: string): boolean => entries.has(normalizeZipPathLocal(p)); // Debug logging for cells that fail to resolve - const shouldDebug = imageName?.startsWith('-') && x !== undefined && y !== undefined; + const shouldDebug = + imageName?.startsWith("-") && x !== undefined && y !== undefined; const debugLog = (msg: string): void => { if (shouldDebug) { console.log(`[Resolver] ${baseDir} (${x},${y}) "${imageName}": ${msg}`); @@ -67,13 +68,13 @@ export function resolveGrid3CellImage( // Built-in resource like [grid3x]... (old format, not symbol library) // Check this BEFORE general symbol references to avoid misclassification - if (imageName && imageName.startsWith('[')) { + if (imageName && imageName.startsWith("[")) { // Check if it's a symbol library reference like [widgit]/food/apple.png // Symbol library references have a path after the library name if (isSymbolReference(imageName)) { const parsed = parseSymbolReference(imageName); // If it's grid3x, it's a built-in resource, not a symbol library - if (parsed.library !== 'grid3x') { + if (parsed.library !== "grid3x") { // Symbol library references are NOT stored as files in the gridset // They are resolved from the external Grid 3 installation // Return null to indicate this is an external symbol reference @@ -93,9 +94,11 @@ export function resolveGrid3CellImage( // Check for partial image names that start with '-' (common in Grid3) // These are coordinate-based suffixes like "-0-text-0.png" that need // to be prefixed with the cell coordinates - if (imageName.startsWith('-') && x != null && y != null) { + if (imageName.startsWith("-") && x != null && y != null) { const coordPrefixed = joinBaseDir(baseDir, `${x}-${y}${imageName}`); - debugLog(`trying coord-prefixed: ${coordPrefixed}, found: ${has(coordPrefixed)}`); + debugLog( + `trying coord-prefixed: ${coordPrefixed}, found: ${has(coordPrefixed)}`, + ); if (has(coordPrefixed)) return coordPrefixed; } @@ -134,7 +137,9 @@ export function resolveGrid3CellImage( `${x}-${y}.jpg`, `${x}-${y}.png`, ].map((n) => joinBaseDir(baseDir, n)); - debugLog(`trying candidates: ${candidates.filter(has).join(', ') || 'none found'}`); + debugLog( + `trying candidates: ${candidates.filter(has).join(", ") || "none found"}`, + ); for (const c of candidates) { if (has(c)) return c; } @@ -161,7 +166,7 @@ export function isSymbolLibraryReference(imageName?: string): boolean { * @returns Parsed reference or null if not a symbol reference */ export function parseImageSymbolReference( - imageName: string + imageName: string, ): ReturnType | null { if (!isSymbolLibraryReference(imageName)) { return null; diff --git a/src/processors/gridset/styleHelpers.ts b/src/processors/gridset/styleHelpers.ts index c10146c..d8d1fb4 100644 --- a/src/processors/gridset/styleHelpers.ts +++ b/src/processors/gridset/styleHelpers.ts @@ -5,8 +5,8 @@ * style XML generation, and style conversion utilities. */ -import { XMLBuilder } from 'fast-xml-parser'; -import { ensureAlphaChannel, darkenColor } from './colorUtils'; +import { XMLBuilder } from "fast-xml-parser"; +import { ensureAlphaChannel, darkenColor } from "./colorUtils"; /** * Cell background shapes supported by Grid 3 @@ -30,17 +30,17 @@ export enum CellBackgroundShape { * Human-readable shape names */ export const SHAPE_NAMES: Record = { - [CellBackgroundShape.Rectangle]: 'Rectangle', - [CellBackgroundShape.RoundedRectangle]: 'Rounded Rectangle', - [CellBackgroundShape.FoldedCorner]: 'Folded Corner', - [CellBackgroundShape.Octagon]: 'Octagon', - [CellBackgroundShape.Folder]: 'Folder', - [CellBackgroundShape.Ellipse]: 'Ellipse', - [CellBackgroundShape.SpeechBubble]: 'Speech Bubble', - [CellBackgroundShape.ThoughtBubble]: 'Thought Bubble', - [CellBackgroundShape.Star]: 'Star', - [CellBackgroundShape.Circle]: 'Circle', - [CellBackgroundShape.ColouredCorner]: 'Coloured Corner', + [CellBackgroundShape.Rectangle]: "Rectangle", + [CellBackgroundShape.RoundedRectangle]: "Rounded Rectangle", + [CellBackgroundShape.FoldedCorner]: "Folded Corner", + [CellBackgroundShape.Octagon]: "Octagon", + [CellBackgroundShape.Folder]: "Folder", + [CellBackgroundShape.Ellipse]: "Ellipse", + [CellBackgroundShape.SpeechBubble]: "Speech Bubble", + [CellBackgroundShape.ThoughtBubble]: "Thought Bubble", + [CellBackgroundShape.Star]: "Star", + [CellBackgroundShape.Circle]: "Circle", + [CellBackgroundShape.ColouredCorner]: "Coloured Corner", }; /** @@ -62,44 +62,44 @@ export interface Grid3Style { */ export const DEFAULT_GRID3_STYLES: Record = { Default: { - BackColour: '#E2EDF8FF', - TileColour: '#FFFFFFFF', - BorderColour: '#000000FF', - FontColour: '#000000FF', - FontName: 'Arial', - FontSize: '16', + BackColour: "#E2EDF8FF", + TileColour: "#FFFFFFFF", + BorderColour: "#000000FF", + FontColour: "#000000FF", + FontName: "Arial", + FontSize: "16", }, Workspace: { - BackColour: '#FFFFFFFF', - TileColour: '#FFFFFFFF', - BorderColour: '#CCCCCCFF', - FontColour: '#000000FF', - FontName: 'Arial', - FontSize: '14', + BackColour: "#FFFFFFFF", + TileColour: "#FFFFFFFF", + BorderColour: "#CCCCCCFF", + FontColour: "#000000FF", + FontName: "Arial", + FontSize: "14", }, - 'Auto content': { - BackColour: '#E8F4F8FF', - TileColour: '#E8F4F8FF', - BorderColour: '#2C82C9FF', - FontColour: '#000000FF', - FontName: 'Arial', - FontSize: '14', + "Auto content": { + BackColour: "#E8F4F8FF", + TileColour: "#E8F4F8FF", + BorderColour: "#2C82C9FF", + FontColour: "#000000FF", + FontName: "Arial", + FontSize: "14", }, - 'Vocab cell': { - BackColour: '#E8F4F8FF', - TileColour: '#E8F4F8FF', - BorderColour: '#2C82C9FF', - FontColour: '#000000FF', - FontName: 'Arial', - FontSize: '14', + "Vocab cell": { + BackColour: "#E8F4F8FF", + TileColour: "#E8F4F8FF", + BorderColour: "#2C82C9FF", + FontColour: "#000000FF", + FontName: "Arial", + FontSize: "14", }, - 'Keyboard key': { - BackColour: '#F0F0F0FF', - TileColour: '#F0F0F0FF', - BorderColour: '#808080FF', - FontColour: '#000000FF', - FontName: 'Arial', - FontSize: '12', + "Keyboard key": { + BackColour: "#F0F0F0FF", + TileColour: "#F0F0F0FF", + BorderColour: "#808080FF", + FontColour: "#000000FF", + FontName: "Arial", + FontSize: "12", }, }; @@ -107,61 +107,61 @@ export const DEFAULT_GRID3_STYLES: Record = { * Category-specific styles for navigation and organization */ export const CATEGORY_STYLES: Record = { - 'Actions category style': { - BackColour: '#4472C4FF', - TileColour: '#4472C4FF', - BorderColour: '#2F5496FF', - FontColour: '#FFFFFFFF', - FontName: 'Arial', - FontSize: '16', + "Actions category style": { + BackColour: "#4472C4FF", + TileColour: "#4472C4FF", + BorderColour: "#2F5496FF", + FontColour: "#FFFFFFFF", + FontName: "Arial", + FontSize: "16", }, - 'People category style': { - BackColour: '#ED7D31FF', - TileColour: '#ED7D31FF', - BorderColour: '#C65911FF', - FontColour: '#FFFFFFFF', - FontName: 'Arial', - FontSize: '16', + "People category style": { + BackColour: "#ED7D31FF", + TileColour: "#ED7D31FF", + BorderColour: "#C65911FF", + FontColour: "#FFFFFFFF", + FontName: "Arial", + FontSize: "16", }, - 'Places category style': { - BackColour: '#A5A5A5FF', - TileColour: '#A5A5A5FF', - BorderColour: '#595959FF', - FontColour: '#FFFFFFFF', - FontName: 'Arial', - FontSize: '16', + "Places category style": { + BackColour: "#A5A5A5FF", + TileColour: "#A5A5A5FF", + BorderColour: "#595959FF", + FontColour: "#FFFFFFFF", + FontName: "Arial", + FontSize: "16", }, - 'Descriptive category style': { - BackColour: '#70AD47FF', - TileColour: '#70AD47FF', - BorderColour: '#4F7C2FFF', - FontColour: '#FFFFFFFF', - FontName: 'Arial', - FontSize: '16', + "Descriptive category style": { + BackColour: "#70AD47FF", + TileColour: "#70AD47FF", + BorderColour: "#4F7C2FFF", + FontColour: "#FFFFFFFF", + FontName: "Arial", + FontSize: "16", }, - 'Social category style': { - BackColour: '#FFC000FF', - TileColour: '#FFC000FF', - BorderColour: '#BF8F00FF', - FontColour: '#000000FF', - FontName: 'Arial', - FontSize: '16', + "Social category style": { + BackColour: "#FFC000FF", + TileColour: "#FFC000FF", + BorderColour: "#BF8F00FF", + FontColour: "#000000FF", + FontName: "Arial", + FontSize: "16", }, - 'Questions category style': { - BackColour: '#5B9BD5FF', - TileColour: '#5B9BD5FF', - BorderColour: '#2E5C8AFF', - FontColour: '#FFFFFFFF', - FontName: 'Arial', - FontSize: '16', + "Questions category style": { + BackColour: "#5B9BD5FF", + TileColour: "#5B9BD5FF", + BorderColour: "#2E5C8AFF", + FontColour: "#FFFFFFFF", + FontName: "Arial", + FontSize: "16", }, - 'Little words category style': { - BackColour: '#C55A11FF', - TileColour: '#C55A11FF', - BorderColour: '#8B3F0AFF', - FontColour: '#FFFFFFFF', - FontName: 'Arial', - FontSize: '16', + "Little words category style": { + BackColour: "#C55A11FF", + TileColour: "#C55A11FF", + BorderColour: "#8B3F0AFF", + FontColour: "#FFFFFFFF", + FontName: "Arial", + FontSize: "16", }, }; @@ -169,18 +169,20 @@ export const CATEGORY_STYLES: Record = { * Re-export ensureAlphaChannel from colorUtils for backward compatibility * @deprecated Use ensureAlphaChannel from colorUtils instead */ -export { ensureAlphaChannel } from './colorUtils'; +export { ensureAlphaChannel } from "./colorUtils"; /** * Create a Grid3 style XML string with default and category styles * @param includeCategories - Whether to include category-specific styles (default: true) * @returns XML string for Settings0/styles.xml */ -export function createDefaultStylesXml(includeCategories: boolean = true): string { +export function createDefaultStylesXml( + includeCategories: boolean = true, +): string { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", }); const styles = { ...DEFAULT_GRID3_STYLES }; @@ -189,7 +191,7 @@ export function createDefaultStylesXml(includeCategories: boolean = true): strin } const styleArray = Object.entries(styles).map(([key, style]) => ({ - '@_Key': key, + "@_Key": key, BackColour: style.BackColour, TileColour: style.TileColour, BorderColour: style.BorderColour, @@ -200,7 +202,7 @@ export function createDefaultStylesXml(includeCategories: boolean = true): strin const stylesData = { StyleData: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", Styles: { Style: styleArray, }, @@ -220,14 +222,14 @@ export function createDefaultStylesXml(includeCategories: boolean = true): strin export function createCategoryStyle( categoryName: string, backgroundColor: string, - fontColor: string = '#FFFFFFFF' + fontColor: string = "#FFFFFFFF", ): Grid3Style { return { BackColour: ensureAlphaChannel(backgroundColor), TileColour: ensureAlphaChannel(backgroundColor), BorderColour: ensureAlphaChannel(darkenColor(backgroundColor, 30)), FontColour: ensureAlphaChannel(fontColor), - FontName: 'Arial', - FontSize: '16', + FontName: "Arial", + FontSize: "16", }; } diff --git a/src/processors/gridset/symbolAlignment.ts b/src/processors/gridset/symbolAlignment.ts index b26fe1e..99b3c15 100644 --- a/src/processors/gridset/symbolAlignment.ts +++ b/src/processors/gridset/symbolAlignment.ts @@ -61,10 +61,10 @@ export interface TranslatedMessage { */ export function parseMessageWithSymbols( message: string, - richTextSymbols?: Array<{ text: string; image?: string }> + richTextSymbols?: Array<{ text: string; image?: string }>, ): ParsedMessage { // Normalize whitespace for consistent tokenization - const normalizedMessage = message.trim().replace(/\s+/g, ' '); + const normalizedMessage = message.trim().replace(/\s+/g, " "); // Tokenize into words, preserving punctuation const words: string[] = []; @@ -99,7 +99,7 @@ export function parseMessageWithSymbols( if (wordIndex !== -1) { const pos = wordPositions[wordIndex]; symbols.push({ - symbolRef: sym.image || '', + symbolRef: sym.image || "", wordIndex, originalWord: sym.text, startPos: pos.start, @@ -107,15 +107,15 @@ export function parseMessageWithSymbols( }); } else { // Fuzzy match - find closest word (handles case differences, punctuation) - const normalizedSymText = sym.text.toLowerCase().replace(/[^\w]/g, ''); + const normalizedSymText = sym.text.toLowerCase().replace(/[^\w]/g, ""); const fuzzyIndex = words.findIndex( - (w) => w.toLowerCase().replace(/[^\w]/g, '') === normalizedSymText + (w) => w.toLowerCase().replace(/[^\w]/g, "") === normalizedSymText, ); if (fuzzyIndex !== -1) { const pos = wordPositions[fuzzyIndex]; symbols.push({ - symbolRef: sym.image || '', + symbolRef: sym.image || "", wordIndex: fuzzyIndex, originalWord: words[fuzzyIndex], startPos: pos.start, @@ -151,23 +151,23 @@ export function parseMessageWithSymbols( */ export function alignWords( originalWords: string[], - translatedWords: string[] -): TranslatedMessage['alignment'] { - const alignment: TranslatedMessage['alignment'] = []; + translatedWords: string[], +): TranslatedMessage["alignment"] { + const alignment: TranslatedMessage["alignment"] = []; // Strategy 1: Try to match identical words (numbers, names, cognates) const matchedTranslatedIndices = new Set(); for (let origIdx = 0; origIdx < originalWords.length; origIdx++) { const origWord = originalWords[origIdx]; - const normalizedOrig = origWord.toLowerCase().replace(/[^\w]/g, ''); + const normalizedOrig = origWord.toLowerCase().replace(/[^\w]/g, ""); // Try to find this word in the translation for (let transIdx = 0; transIdx < translatedWords.length; transIdx++) { if (matchedTranslatedIndices.has(transIdx)) continue; const transWord = translatedWords[transIdx]; - const normalizedTrans = transWord.toLowerCase().replace(/[^\w]/g, ''); + const normalizedTrans = transWord.toLowerCase().replace(/[^\w]/g, ""); // Exact match (case-insensitive, ignoring punctuation) if (normalizedOrig === normalizedTrans && normalizedOrig.length > 0) { @@ -231,7 +231,7 @@ export function alignWords( export function reattachSymbols( translatedText: string, originalParsed: ParsedMessage, - alignment: TranslatedMessage['alignment'] + alignment: TranslatedMessage["alignment"], ): { text: string; richTextSymbols: Array<{ text: string; image?: string }>; @@ -239,7 +239,7 @@ export function reattachSymbols( // Tokenize the translated text const translatedWords = translatedText .trim() - .replace(/\s+/g, ' ') + .replace(/\s+/g, " ") .split(/\s+/) .filter((w) => w.length > 0); @@ -248,9 +248,14 @@ export function reattachSymbols( for (const symbol of originalParsed.symbols) { // Find the alignment for this word - const wordAlignment = alignment.find((a) => a.originalIndex === symbol.wordIndex); - - if (wordAlignment && wordAlignment.translatedIndex < translatedWords.length) { + const wordAlignment = alignment.find( + (a) => a.originalIndex === symbol.wordIndex, + ); + + if ( + wordAlignment && + wordAlignment.translatedIndex < translatedWords.length + ) { const translatedWord = translatedWords[wordAlignment.translatedIndex]; // Attach the symbol to the translated word @@ -284,13 +289,16 @@ export function reattachSymbols( export function translateWithSymbols( originalMessage: string, translatedText: string, - richTextSymbols?: Array<{ text: string; image?: string }> + richTextSymbols?: Array<{ text: string; image?: string }>, ): { text: string; richTextSymbols: Array<{ text: string; image?: string }>; } { // Step 1: Parse original message - const parsedOriginal = parseMessageWithSymbols(originalMessage, richTextSymbols); + const parsedOriginal = parseMessageWithSymbols( + originalMessage, + richTextSymbols, + ); // If no symbols, return as-is if (parsedOriginal.symbols.length === 0) { @@ -303,7 +311,7 @@ export function translateWithSymbols( // Step 2: Tokenize translated text const translatedWords = translatedText .trim() - .replace(/\s+/g, ' ') + .replace(/\s+/g, " ") .split(/\s+/) .filter((w) => w.length > 0); @@ -327,7 +335,7 @@ export function translateWithSymbols( * @returns Array of symbol attachments */ export function extractSymbolsFromButton( - button: any + button: any, ): Array<{ text: string; image?: string }> | undefined { // First check richText structure if (button.semanticAction?.richText?.symbols) { @@ -340,7 +348,7 @@ export function extractSymbolsFromButton( // Check if button has a symbol library reference as image if (button.symbolLibrary && button.symbolPath) { // Create a symbol attachment for the label/message - const text = button.label || button.message || ''; + const text = button.label || button.message || ""; if (text) { return [ { @@ -352,8 +360,8 @@ export function extractSymbolsFromButton( } // Check if image field contains a symbol reference - if (button.image && button.image.startsWith('[')) { - const text = button.label || button.message || ''; + if (button.image && button.image.startsWith("[")) { + const text = button.label || button.message || ""; if (text) { return [ { diff --git a/src/processors/gridset/symbolExtractor.ts b/src/processors/gridset/symbolExtractor.ts index 1414ffe..1a974e3 100644 --- a/src/processors/gridset/symbolExtractor.ts +++ b/src/processors/gridset/symbolExtractor.ts @@ -12,9 +12,17 @@ * c. For Tawasol: provide alternative sources */ -import { resolveSymbolReference, parseSymbolReference, type SymbolReference } from './symbols'; -import { defaultFileAdapter, FileAdapter, ProcessorInput } from '../../utils/io'; -import { getZipAdapter, ZipAdapter } from '../../utils/zip'; +import { + resolveSymbolReference, + parseSymbolReference, + type SymbolReference, +} from "./symbols"; +import { + defaultFileAdapter, + FileAdapter, + ProcessorInput, +} from "../../utils/io"; +import { getZipAdapter, ZipAdapter } from "../../utils/zip"; /** * Image extraction result @@ -22,8 +30,8 @@ import { getZipAdapter, ZipAdapter } from '../../utils/zip'; export interface ExtractedImage { found: boolean; data?: Buffer; - format?: 'png' | 'jpg' | 'jpeg' | 'gif' | 'svg' | 'unknown'; - source: 'embedded' | 'symbol-library' | 'external-file' | 'not-found'; + format?: "png" | "jpg" | "jpeg" | "gif" | "svg" | "unknown"; + source: "embedded" | "symbol-library" | "external-file" | "not-found"; reference?: string; error?: string; metadata?: { @@ -61,22 +69,22 @@ const OPEN_LICENSE_SYMBOLS: { }; } = { tawasl: { - name: 'Tawasol', - attribution: 'Tawasol symbols by Mada (Qatar Assistive Technology Center)', - license: 'CC BY-SA 4.0', - url: 'https://mada.org.qa/en/resources/tawasol-symbols', - alternativeSources: ['https://github.com/mada-qatar/Tawasol'], + name: "Tawasol", + attribution: "Tawasol symbols by Mada (Qatar Assistive Technology Center)", + license: "CC BY-SA 4.0", + url: "https://mada.org.qa/en/resources/tawasol-symbols", + alternativeSources: ["https://github.com/mada-qatar/Tawasol"], }, blissx: { - name: 'Blissymbols', - attribution: 'Blissymbolics Communication International', - license: 'CC BY-ND 3.0', - url: 'https://blissymbolics.org', + name: "Blissymbols", + attribution: "Blissymbolics Communication International", + license: "CC BY-ND 3.0", + url: "https://blissymbolics.org", }, symoji: { - name: 'Symoji', - attribution: 'Smartbox Assistive Technology', - license: 'Proprietary - Free use in Grid 3', + name: "Symoji", + attribution: "Smartbox Assistive Technology", + license: "Proprietary - Free use in Grid 3", }, }; @@ -94,7 +102,7 @@ export async function extractButtonImage( symbolReference: string | undefined, options: SymbolExtractionOptions = {}, fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise + zipAdapter?: (input: ProcessorInput) => Promise, ): Promise { // Priority 1: Use embedded image if available if (resolvedImageEntry && options.preferEmbedded !== false) { @@ -112,7 +120,7 @@ export async function extractButtonImage( found: true, data, format, - source: 'embedded', + source: "embedded", reference: resolvedImageEntry, }; } @@ -129,7 +137,7 @@ export async function extractButtonImage( // Not found return { found: false, - source: 'not-found', + source: "not-found", }; } @@ -141,20 +149,21 @@ export async function extractButtonImage( */ export async function extractSymbolLibraryImage( reference: string, - options: SymbolExtractionOptions = {} + options: SymbolExtractionOptions = {}, ): Promise { const ref = parseSymbolReferenceSafe(reference); if (!ref || !ref.isValid) { return { found: false, - source: 'not-found', + source: "not-found", reference, }; } // Get library metadata - const libInfo = OPEN_LICENSE_SYMBOLS[ref.library as keyof typeof OPEN_LICENSE_SYMBOLS]; + const libInfo = + OPEN_LICENSE_SYMBOLS[ref.library as keyof typeof OPEN_LICENSE_SYMBOLS]; // Resolve symbol reference and extract from .symbols file const resolved = await resolveSymbolReference(reference, { @@ -176,7 +185,7 @@ export async function extractSymbolLibraryImage( return { found: false, - source: 'symbol-library', + source: "symbol-library", reference: reference, metadata, error: resolved.error, @@ -185,12 +194,12 @@ export async function extractSymbolLibraryImage( // Successfully extracted! const data = resolved.data; - const format = data ? detectImageFormat(data) : 'unknown'; + const format = data ? detectImageFormat(data) : "unknown"; return { found: true, data, format, - source: 'symbol-library', + source: "symbol-library", reference: reference, metadata, }; @@ -206,11 +215,11 @@ export function convertToAstericsImage(extracted: ExtractedImage): any { if (extracted.found && extracted.data) { // Embed as base64 - image.data = Buffer.from(extracted.data).toString('base64'); + image.data = Buffer.from(extracted.data).toString("base64"); } // Even if embedded, add attribution for symbol libraries - if (extracted.source === 'symbol-library') { + if (extracted.source === "symbol-library") { if (extracted.metadata?.attribution) { image.author = extracted.metadata.attribution; } @@ -283,14 +292,16 @@ export function analyzeSymbolExtraction(tree: any): SymbolReport { report.byLibrary[button.symbolLibrary] = (report.byLibrary[button.symbolLibrary] || 0) + 1; - const ref = `[${button.symbolLibrary}]${button.symbolPath || ''}`; + const ref = `[${button.symbolLibrary}]${button.symbolPath || ""}`; const libInfo = - OPEN_LICENSE_SYMBOLS[button.symbolLibrary as keyof typeof OPEN_LICENSE_SYMBOLS]; + OPEN_LICENSE_SYMBOLS[ + button.symbolLibrary as keyof typeof OPEN_LICENSE_SYMBOLS + ]; report.missingSymbols.push({ reference: ref, library: button.symbolLibrary, - path: button.symbolPath || '', + path: button.symbolPath || "", attribution: libInfo?.attribution, license: libInfo?.license, }); @@ -315,20 +326,29 @@ export function suggestExtractionStrategy(report: SymbolReport): string { const suggestions: string[] = []; if (report.embedded > 0) { - suggestions.push(`✓ Can extract ${report.embedded} embedded images directly`); + suggestions.push( + `✓ Can extract ${report.embedded} embedded images directly`, + ); } if (report.symbolLibraries > 0) { - suggestions.push(`⚠ ${report.symbolLibraries} symbol library references found:`); + suggestions.push( + `⚠ ${report.symbolLibraries} symbol library references found:`, + ); Object.entries(report.byLibrary).forEach(([lib, count]) => { - const libInfo = OPEN_LICENSE_SYMBOLS[lib as keyof typeof OPEN_LICENSE_SYMBOLS]; + const libInfo = + OPEN_LICENSE_SYMBOLS[lib as keyof typeof OPEN_LICENSE_SYMBOLS]; if (libInfo) { suggestions.push(` - ${lib}: ${count} symbols (${libInfo.license})`); if (libInfo.alternativeSources) { - suggestions.push(` Alternative: ${libInfo.alternativeSources.join(', ')}`); + suggestions.push( + ` Alternative: ${libInfo.alternativeSources.join(", ")}`, + ); } } else { - suggestions.push(` - ${lib}: ${count} symbols (Proprietary - requires Grid 3)`); + suggestions.push( + ` - ${lib}: ${count} symbols (Proprietary - requires Grid 3)`, + ); } }); } @@ -337,37 +357,52 @@ export function suggestExtractionStrategy(report: SymbolReport): string { suggestions.push(`✗ ${report.notFound} images not found`); } - return suggestions.join('\n'); + return suggestions.join("\n"); } /** * Detect image format from buffer */ -function detectImageFormat(buffer: Buffer): 'png' | 'jpg' | 'jpeg' | 'gif' | 'svg' | 'unknown' { - if (buffer.length < 4) return 'unknown'; +function detectImageFormat( + buffer: Buffer, +): "png" | "jpg" | "jpeg" | "gif" | "svg" | "unknown" { + if (buffer.length < 4) return "unknown"; // PNG: 89 50 4E 47 - if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) { - return 'png'; + if ( + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 + ) { + return "png"; } // JPEG: FF D8 FF if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { - return 'jpg'; + return "jpg"; } // GIF: 47 49 46 38 - if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) { - return 'gif'; + if ( + buffer[0] === 0x47 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + buffer[3] === 0x38 + ) { + return "gif"; } // SVG (check for { const { writeTextToPath } = fileAdapter; - const lines = ['Reference,Library,Path,Attribution,License']; + const lines = ["Reference,Library,Path,Attribution,License"]; for (const symbol of report.missingSymbols) { lines.push( - `"${symbol.reference}","${symbol.library}","${symbol.path}","${symbol.attribution || ''}","${symbol.license || ''}"` + `"${symbol.reference}","${symbol.library}","${symbol.path}","${symbol.attribution || ""}","${symbol.license || ""}"`, ); } - await writeTextToPath(outputPath, lines.join('\n')); + await writeTextToPath(outputPath, lines.join("\n")); } /** @@ -427,7 +462,10 @@ export interface SymbolManifest { }>; } -export function createSymbolManifest(tree: any, gridsetName: string): SymbolManifest { +export function createSymbolManifest( + tree: any, + gridsetName: string, +): SymbolManifest { const manifest: SymbolManifest = { generatedAt: new Date().toISOString(), gridset: gridsetName, @@ -455,7 +493,9 @@ export function createSymbolManifest(tree: any, gridsetName: string): SymbolMani if (!manifest.libraries[button.symbolLibrary]) { const libInfo = - OPEN_LICENSE_SYMBOLS[button.symbolLibrary as keyof typeof OPEN_LICENSE_SYMBOLS]; + OPEN_LICENSE_SYMBOLS[ + button.symbolLibrary as keyof typeof OPEN_LICENSE_SYMBOLS + ]; manifest.libraries[button.symbolLibrary] = { count: 0, attribution: libInfo?.attribution, @@ -466,7 +506,7 @@ export function createSymbolManifest(tree: any, gridsetName: string): SymbolMani manifest.libraries[button.symbolLibrary].count++; - const ref = `[${button.symbolLibrary}]${button.symbolPath || ''}`; + const ref = `[${button.symbolLibrary}]${button.symbolPath || ""}`; manifest.symbols.push({ pageId, buttonId: button.id, diff --git a/src/processors/gridset/symbolSearch.ts b/src/processors/gridset/symbolSearch.ts index ae32bf4..1474766 100644 --- a/src/processors/gridset/symbolSearch.ts +++ b/src/processors/gridset/symbolSearch.ts @@ -9,7 +9,7 @@ * active family=active family.png=active family */ -import { defaultFileAdapter, FileAdapter } from '../../utils/io'; +import { defaultFileAdapter, FileAdapter } from "../../utils/io"; /** * Symbol search result @@ -49,25 +49,25 @@ export interface LibrarySearchIndex { */ export async function parsePixFile( pixFilePath: string, - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { readTextFromInput, basename } = fileAdapter; const content = await readTextFromInput(pixFilePath); - const library = basename(pixFilePath, '.pix'); + const library = basename(pixFilePath, ".pix"); const searchTerms = new Map(); const filenames = new Map(); - const lines = content.split('\n'); + const lines = content.split("\n"); for (const line of lines) { const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('encoding=')) { + if (!trimmed || trimmed.startsWith("encoding=")) { continue; } // Format: searchTerm=symbolFilename=searchTerm - const parts = trimmed.split('='); + const parts = trimmed.split("="); if (parts.length >= 3) { const searchTerm = parts[0]; const symbolFilename = parts[1]; @@ -88,16 +88,16 @@ export async function parsePixFile( */ export async function loadSearchIndexes( options: SymbolSearchOptions = {}, - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise> { const { listDir, pathExists, join, basename } = fileAdapter; - const { grid3Path, locale = 'en-GB', libraries: specifiedLibs } = options; + const { grid3Path, locale = "en-GB", libraries: specifiedLibs } = options; if (!grid3Path) { - throw new Error('grid3Path is required for symbol search'); + throw new Error("grid3Path is required for symbol search"); } - const searchIndexesDir = join(grid3Path, 'Locale', locale, 'symbolsearch'); + const searchIndexesDir = join(grid3Path, "Locale", locale, "symbolsearch"); if (!(await pathExists(searchIndexesDir))) { throw new Error(`Symbol search directory not found: ${searchIndexesDir}`); @@ -107,15 +107,19 @@ export async function loadSearchIndexes( const files = await listDir(searchIndexesDir); for (const file of files) { - if (!file.endsWith('.pix')) { + if (!file.endsWith(".pix")) { continue; } - const libraryName = basename(file, '.pix'); + const libraryName = basename(file, ".pix"); // Filter libraries if specified if (specifiedLibs && specifiedLibs.length > 0) { - if (!specifiedLibs.some((lib) => lib.toLowerCase() === libraryName.toLowerCase())) { + if ( + !specifiedLibs.some( + (lib) => lib.toLowerCase() === libraryName.toLowerCase(), + ) + ) { continue; } } @@ -140,7 +144,7 @@ export async function loadSearchIndexes( */ export async function searchSymbols( searchTerm: string, - options: SymbolSearchOptions = {} + options: SymbolSearchOptions = {}, ): Promise { const indexes = await loadSearchIndexes(options); const results: SymbolSearchResult[] = []; @@ -168,7 +172,11 @@ export async function searchSymbols( if (term.includes(lowerSearchTerm) || lowerSearchTerm.includes(term)) { // Skip if already added as exact match if ( - results.some((r) => r.library === libraryName && r.symbolFilename === symbolFilename) + results.some( + (r) => + r.library === libraryName && + r.symbolFilename === symbolFilename, + ) ) { continue; } @@ -206,7 +214,7 @@ export async function searchSymbols( export async function getSymbolFilename( searchTerm: string, library: string, - options: SymbolSearchOptions = {} + options: SymbolSearchOptions = {}, ): Promise { const indexes = await loadSearchIndexes({ ...options, @@ -231,7 +239,7 @@ export async function getSymbolFilename( export async function getSymbolDisplayName( symbolFilename: string, library: string, - options: SymbolSearchOptions = {} + options: SymbolSearchOptions = {}, ): Promise { const indexes = await loadSearchIndexes({ ...options, @@ -254,7 +262,7 @@ export async function getSymbolDisplayName( */ export async function getAllSearchTerms( library: string, - options: SymbolSearchOptions = {} + options: SymbolSearchOptions = {}, ): Promise { const indexes = await loadSearchIndexes({ ...options, @@ -277,7 +285,7 @@ export async function getAllSearchTerms( */ export async function getSearchSuggestions( partialTerm: string, - options: SymbolSearchOptions = {} + options: SymbolSearchOptions = {}, ): Promise { const indexes = await loadSearchIndexes(options); const suggestions = new Set(); @@ -302,7 +310,7 @@ export async function getSearchSuggestions( */ export async function searchSymbolsWithReferences( searchTerm: string, - options: SymbolSearchOptions = {} + options: SymbolSearchOptions = {}, ): Promise { const results = await searchSymbols(searchTerm, options); @@ -315,7 +323,7 @@ export async function searchSymbolsWithReferences( * @returns Map of library name to symbol count */ export async function countLibrarySymbols( - options: SymbolSearchOptions = {} + options: SymbolSearchOptions = {}, ): Promise> { const indexes = await loadSearchIndexes(options); const counts = new Map(); @@ -348,7 +356,7 @@ export interface SymbolSearchStats { * @returns Statistics about available symbols */ export async function getSymbolSearchStats( - options: SymbolSearchOptions = {} + options: SymbolSearchOptions = {}, ): Promise { const indexes = await loadSearchIndexes(options); const stats: SymbolSearchStats = { diff --git a/src/processors/gridset/symbols.ts b/src/processors/gridset/symbols.ts index 5070b49..a72d3f7 100644 --- a/src/processors/gridset/symbols.ts +++ b/src/processors/gridset/symbols.ts @@ -13,52 +13,57 @@ * This module provides symbol resolution and metadata extraction. */ -import { defaultFileAdapter, FileAdapter, ProcessorInput } from '../../utils/io'; -import { getZipAdapter, ZipAdapter } from '../../utils/zip'; +import { + defaultFileAdapter, + FileAdapter, + ProcessorInput, +} from "../../utils/io"; +import { getZipAdapter, ZipAdapter } from "../../utils/zip"; /** * Default Grid 3 installation paths by platform */ const DEFAULT_GRID3_PATHS = { - win32: 'C:\\Program Files (x86)\\Smartbox\\Grid 3', - darwin: '/Applications/Grid 3.app/Contents/Resources', - linux: '/opt/smartbox/grid3', + win32: "C:\\Program Files (x86)\\Smartbox\\Grid 3", + darwin: "/Applications/Grid 3.app/Contents/Resources", + linux: "/opt/smartbox/grid3", }; /** * Path to Symbols directory within Grid 3 installation * Contains .symbols ZIP archives with actual images */ -const SYMBOLS_SUBDIR = 'Resources\\Symbols'; +const SYMBOLS_SUBDIR = "Resources\\Symbols"; /** * Path to symbol search indexes within Grid 3 installation * Contains .pix index files for searching */ -const SYMBOLSEARCH_SUBDIR = 'Locale'; +const SYMBOLSEARCH_SUBDIR = "Locale"; /** * Known symbol libraries in Grid 3 */ export const SYMBOL_LIBRARIES = { - WIDGIT: 'widgit', - TAWASL: 'tawasl', - SSNAPS: 'ssnaps', - GRID3X: 'grid3x', - GRID2X: 'grid2x', - BLISSX: 'blissx', - EYEGAZ: 'eyegaz', - INTERL: 'interl', - METACM: 'metacm', - MJPCS: 'mjpcs#', - PCSHC: 'pcshc#', - PCSTL: 'pcstl#', - SESENS: 'sesens', - SSTIX: 'sstix#', - SYMOJI: 'symoji', + WIDGIT: "widgit", + TAWASL: "tawasl", + SSNAPS: "ssnaps", + GRID3X: "grid3x", + GRID2X: "grid2x", + BLISSX: "blissx", + EYEGAZ: "eyegaz", + INTERL: "interl", + METACM: "metacm", + MJPCS: "mjpcs#", + PCSHC: "pcshc#", + PCSTL: "pcstl#", + SESENS: "sesens", + SSTIX: "sstix#", + SYMOJI: "symoji", } as const; -export type SymbolLibraryName = (typeof SYMBOL_LIBRARIES)[keyof typeof SYMBOL_LIBRARIES]; +export type SymbolLibraryName = + (typeof SYMBOL_LIBRARIES)[keyof typeof SYMBOL_LIBRARIES]; /** * Symbol reference parsed from Grid 3 format @@ -106,7 +111,7 @@ export interface SymbolResolutionResult { /** * Default locale to use */ -export const DEFAULT_LOCALE = 'en-GB'; +export const DEFAULT_LOCALE = "en-GB"; /** * Parse a symbol reference string @@ -121,7 +126,7 @@ export function parseSymbolReference(reference: string): SymbolReference { if (!match) { return { - library: '', + library: "", path: trimmed, fullReference: trimmed, isValid: false, @@ -132,7 +137,7 @@ export function parseSymbolReference(reference: string): SymbolReference { return { library: library.toLowerCase(), - path: symbolPath.replace(/^\\+/, '').trim(), // Remove leading slashes + path: symbolPath.replace(/^\\+/, "").trim(), // Remove leading slashes fullReference: trimmed, isValid: true, }; @@ -144,19 +149,23 @@ export function parseSymbolReference(reference: string): SymbolReference { * @returns True if it's a symbol reference like [widgit]/... */ export function isSymbolReference(reference: string): boolean { - return reference.trim().startsWith('['); + return reference.trim().startsWith("["); } /** * Get the default Grid 3 installation path for the current platform * @returns Default Grid 3 path or empty string if not found */ -export async function getDefaultGrid3Path(fileAdapter?: FileAdapter): Promise { +export async function getDefaultGrid3Path( + fileAdapter?: FileAdapter, +): Promise { const { pathExists } = fileAdapter ?? defaultFileAdapter; const platform = ( - typeof process !== 'undefined' && process.platform ? process.platform : 'unknown' + typeof process !== "undefined" && process.platform + ? process.platform + : "unknown" ) as keyof typeof DEFAULT_GRID3_PATHS; - const defaultPath = DEFAULT_GRID3_PATHS[platform] || ''; + const defaultPath = DEFAULT_GRID3_PATHS[platform] || ""; try { if (defaultPath && (await pathExists(defaultPath))) { @@ -165,11 +174,11 @@ export async function getDefaultGrid3Path(fileAdapter?: FileAdapter): Promise { - const { pathExists, getFileSize, listDir, join, basename } = fileAdapter ?? defaultFileAdapter; - const grid3Path = options.grid3Path || options.symbolDir || (await getDefaultGrid3Path()); + const { pathExists, getFileSize, listDir, join, basename } = + fileAdapter ?? defaultFileAdapter; + const grid3Path = + options.grid3Path || options.symbolDir || (await getDefaultGrid3Path()); if (!grid3Path) { return []; @@ -240,17 +251,17 @@ export async function getAvailableSymbolLibraries( const files = await listDir(symbolsDir); for (const file of files) { - if (file.endsWith('.symbols')) { + if (file.endsWith(".symbols")) { const fullPath = join(symbolsDir, file); const size = await getFileSize(fullPath); - const libraryName = basename(file, '.symbols'); + const libraryName = basename(file, ".symbols"); libraries.push({ name: libraryName, pixFile: fullPath, // Reuse this field for the .symbols file path exists: true, size, - locale: 'global', // .symbols files are not locale-specific + locale: "global", // .symbols files are not locale-specific }); } } @@ -267,10 +278,11 @@ export async function getAvailableSymbolLibraries( export async function getSymbolLibraryInfo( libraryName: string, options: SymbolResolutionOptions = {}, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { const { pathExists, getFileSize, join } = fileAdapter ?? defaultFileAdapter; - const grid3Path = options.grid3Path || options.symbolDir || (await getDefaultGrid3Path()); + const grid3Path = + options.grid3Path || options.symbolDir || (await getDefaultGrid3Path()); if (!grid3Path) { return undefined; @@ -281,9 +293,9 @@ export async function getSymbolLibraryInfo( // Try different case variations const variations = [ - normalizedLibName + '.symbols', - normalizedLibName.toUpperCase() + '.symbols', - libraryName + '.symbols', + normalizedLibName + ".symbols", + normalizedLibName.toUpperCase() + ".symbols", + libraryName + ".symbols", ]; for (const file of variations) { @@ -295,7 +307,7 @@ export async function getSymbolLibraryInfo( pixFile: fullPath, exists: true, size, - locale: 'global', + locale: "global", }; } } @@ -313,7 +325,7 @@ export async function resolveSymbolReference( reference: string, options: SymbolResolutionOptions = {}, fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise + zipAdapter?: (input: ProcessorInput) => Promise, ): Promise { const parsed = parseSymbolReference(reference); @@ -321,7 +333,7 @@ export async function resolveSymbolReference( return { reference: parsed, found: false, - error: 'Invalid symbol reference format', + error: "Invalid symbol reference format", }; } @@ -331,7 +343,7 @@ export async function resolveSymbolReference( return { reference: parsed, found: false, - error: 'Grid 3 installation not found. Please specify grid3Path.', + error: "Grid 3 installation not found. Please specify grid3Path.", }; } @@ -341,14 +353,16 @@ export async function resolveSymbolReference( return { reference: parsed, found: false, - error: `Symbol library '${parsed.library}' not found at ${libraryInfo?.pixFile || 'unknown'}`, + error: `Symbol library '${parsed.library}' not found at ${libraryInfo?.pixFile || "unknown"}`, }; } try { // .symbols files are ZIP archives const zipFile = libraryInfo.pixFile; - const zip = zipAdapter ? await zipAdapter(zipFile) : await getZipAdapter(zipFile, fileAdapter); + const zip = zipAdapter + ? await zipAdapter(zipFile) + : await getZipAdapter(zipFile, fileAdapter); // The path in the symbol reference becomes the path within the symbols/ folder // e.g., [tawasl]/above bw.png becomes symbols/above bw.png @@ -358,7 +372,9 @@ export async function resolveSymbolReference( if (!entry) { // Try without the symbols/ prefix (in case reference already includes it) - const altPath = parsed.path.startsWith('symbols/') ? parsed.path : `symbols/${parsed.path}`; + const altPath = parsed.path.startsWith("symbols/") + ? parsed.path + : `symbols/${parsed.path}`; const altEntry = await zip.readFile(altPath); if (!altEntry) { @@ -422,7 +438,7 @@ export function extractSymbolReferences(tree: any): string[] { // Check for symbol library metadata if (button.symbolLibrary) { - const ref = `[${button.symbolLibrary}]${button.symbolPath || ''}`; + const ref = `[${button.symbolLibrary}]${button.symbolPath || ""}`; references.add(ref); } } @@ -438,9 +454,12 @@ export function extractSymbolReferences(tree: any): string[] { * @param symbolPath - Path within the library * @returns Formatted symbol reference */ -export function createSymbolReference(library: string, symbolPath: string): string { - const normalizedLib = library.toLowerCase().replace(/\[|\]/g, ''); - const normalizedPath = symbolPath.replace(/^\\+/, ''); +export function createSymbolReference( + library: string, + symbolPath: string, +): string { + const normalizedLib = library.toLowerCase().replace(/\[|\]/g, ""); + const normalizedPath = symbolPath.replace(/^\\+/, ""); return `[${normalizedLib}]${normalizedPath}`; } @@ -470,8 +489,10 @@ export function getSymbolPath(reference: string): string { * @returns True if it's a known library */ export function isKnownSymbolLibrary(libraryName: string): boolean { - const normalized = libraryName.toLowerCase().replace(/\[|\]/g, ''); - return Object.values(SYMBOL_LIBRARIES).includes(normalized as SymbolLibraryName); + const normalized = libraryName.toLowerCase().replace(/\[|\]/g, ""); + return Object.values(SYMBOL_LIBRARIES).includes( + normalized as SymbolLibraryName, + ); } /** @@ -480,27 +501,30 @@ export function isKnownSymbolLibrary(libraryName: string): boolean { * @returns Human-readable display name */ export function getSymbolLibraryDisplayName(libraryName: string): string { - const normalized = libraryName.toLowerCase().replace(/\[|\]/g, ''); + const normalized = libraryName.toLowerCase().replace(/\[|\]/g, ""); const displayNames: Record = { - widgit: 'Widgit Symbols', - tawasl: 'Tawasol (Arabic)', - ssnaps: 'Smartbox Symbol Snapshots', - grid3x: 'Grid 3 Extended', - grid2x: 'Grid 2 Extended', - blissx: 'Blissymbols', - eyegaz: 'Eye Gaze Symbols', - interl: 'International Symbols', - metacm: 'MetaComm', - mjpcs: 'Mayer-Johnson PCS', - pcshc: 'PCS High Contrast', - pcstl: 'PCS Thin Line', - sesens: 'Sensory Software', - sstix: 'Smartbox TIX', - symoji: 'Symbol Emoji', + widgit: "Widgit Symbols", + tawasl: "Tawasol (Arabic)", + ssnaps: "Smartbox Symbol Snapshots", + grid3x: "Grid 3 Extended", + grid2x: "Grid 2 Extended", + blissx: "Blissymbols", + eyegaz: "Eye Gaze Symbols", + interl: "International Symbols", + metacm: "MetaComm", + mjpcs: "Mayer-Johnson PCS", + pcshc: "PCS High Contrast", + pcstl: "PCS Thin Line", + sesens: "Sensory Software", + sstix: "Smartbox TIX", + symoji: "Symbol Emoji", }; - return displayNames[normalized] || normalized.charAt(0).toUpperCase() + normalized.slice(1); + return ( + displayNames[normalized] || + normalized.charAt(0).toUpperCase() + normalized.slice(1) + ); } /** @@ -544,10 +568,14 @@ export function analyzeSymbolUsage(tree: any): SymbolUsageStats { * @param cellY - Cell Y coordinate * @returns Generated filename */ -export function symbolReferenceToFilename(reference: string, cellX: number, cellY: number): string { +export function symbolReferenceToFilename( + reference: string, + cellX: number, + cellY: number, +): string { const parsed = parseSymbolReference(reference); - const dotIndex = parsed.path.lastIndexOf('.'); - const ext = dotIndex >= 0 ? parsed.path.slice(dotIndex) : '.png'; + const dotIndex = parsed.path.lastIndexOf("."); + const ext = dotIndex >= 0 ? parsed.path.slice(dotIndex) : ".png"; // Grid 3 format: {x}-{y}-0-text-0.{ext} return `${cellX}-${cellY}-0-text-0${ext}`; @@ -569,6 +597,9 @@ export function getSymbolsDir(grid3Path: string): string { * @deprecated Use getSymbolSearchIndexesDir() instead - more descriptive name * Get the symbol search directory for a given locale (where .pix index files are) */ -export function getSymbolSearchDir(grid3Path: string, locale: string = DEFAULT_LOCALE): string { +export function getSymbolSearchDir( + grid3Path: string, + locale: string = DEFAULT_LOCALE, +): string { return getSymbolSearchIndexesDir(grid3Path, locale); } diff --git a/src/processors/gridset/wordlistHelpers.ts b/src/processors/gridset/wordlistHelpers.ts index 4bd8fb2..e5e3dcb 100644 --- a/src/processors/gridset/wordlistHelpers.ts +++ b/src/processors/gridset/wordlistHelpers.ts @@ -9,11 +9,18 @@ * do not have equivalent wordlist functionality. */ -import { XMLParser, XMLBuilder } from 'fast-xml-parser'; -import { getZipEntriesFromAdapter, resolveGridsetPasswordFromEnv } from './password'; -import { defaultFileAdapter, FileAdapter, type ProcessorInput } from '../../utils/io'; -import { decodeText } from '../../utils/io'; -import { getZipAdapter, ZipAdapter, ZipFile } from '../../utils/zip'; +import { XMLParser, XMLBuilder } from "fast-xml-parser"; +import { + getZipEntriesFromAdapter, + resolveGridsetPasswordFromEnv, +} from "./password"; +import { + defaultFileAdapter, + FileAdapter, + type ProcessorInput, +} from "../../utils/io"; +import { decodeText } from "../../utils/io"; +import { getZipAdapter, ZipAdapter, ZipFile } from "../../utils/zip"; /** * Represents a single item in a wordlist @@ -53,22 +60,22 @@ export interface WordList { * ]); */ export function createWordlist( - input: string[] | WordListItem[] | Record + input: string[] | WordListItem[] | Record, ): WordList { let items: WordListItem[] = []; if (Array.isArray(input)) { // Handle array input items = input.map((item) => { - if (typeof item === 'string') { + if (typeof item === "string") { return { text: item }; } return item; }); - } else if (typeof input === 'object') { + } else if (typeof input === "object") { // Handle dictionary/object input items = Object.entries(input).map(([, value]) => { - if (typeof value === 'string') { + if (typeof value === "string") { return { text: value }; } return value; @@ -89,20 +96,24 @@ export function wordlistToXml(wordlist: WordList): string { const items = wordlist.items.map((item) => ({ WordListItem: { Text: { - s: { - '@_Image': item.image || '', - r: item.text, + p: { + s: { + r: item.text, + }, }, }, - Image: item.image || '', - PartOfSpeech: item.partOfSpeech || 'Unknown', + Image: item.image || "", + PartOfSpeech: item.partOfSpeech || "Unknown", }, })); const wordlistData = { WordList: { Items: { - WordListItem: items.length === 1 ? items[0].WordListItem : items.map((i) => i.WordListItem), + WordListItem: + items.length === 1 + ? items[0].WordListItem + : items.map((i) => i.WordListItem), }, }, }; @@ -132,7 +143,7 @@ export async function extractWordlists( gridsetBuffer: Uint8Array, password = resolveGridsetPasswordFromEnv(), fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise + zipAdapter?: (input: ProcessorInput) => Promise, ): Promise> { const wordlists = new Map(); const parser = new XMLParser(); @@ -145,7 +156,10 @@ export async function extractWordlists( // Process each grid file for (const entry of entries) { - if (entry.entryName.startsWith('Grids/') && entry.entryName.endsWith('grid.xml')) { + if ( + entry.entryName.startsWith("Grids/") && + entry.entryName.endsWith("grid.xml") + ) { try { const xmlContent = decodeText(await entry.getData()); const data = parser.parse(xmlContent); @@ -174,9 +188,14 @@ export async function extractWordlists( : []; const items: WordListItem[] = itemArray.map((item: any) => ({ - text: item.Text?.s?.r || item.text?.s?.r || '', + text: + item.Text?.p?.s?.r || + item.Text?.s?.r || + item.text?.p?.s?.r || + item.text?.s?.r || + "", image: item.Image || item.image || undefined, - partOfSpeech: item.PartOfSpeech || item.partOfSpeech || 'Unknown', + partOfSpeech: item.PartOfSpeech || item.partOfSpeech || "Unknown", })); if (items.length > 0) { @@ -184,7 +203,10 @@ export async function extractWordlists( } } catch (error) { // Skip grids with parsing errors - console.warn(`Failed to extract wordlist from ${entry.entryName}:`, error); + console.warn( + `Failed to extract wordlist from ${entry.entryName}:`, + error, + ); } } } @@ -215,13 +237,13 @@ export async function updateWordlist( wordlist: WordList, password = resolveGridsetPasswordFromEnv(), fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise + zipAdapter?: (input: ProcessorInput) => Promise, ): Promise { const parser = new XMLParser(); const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", suppressEmptyNode: false, }); @@ -235,7 +257,10 @@ export async function updateWordlist( // Find and update the grid for (const entry of entries) { - if (entry.entryName.startsWith('Grids/') && entry.entryName.endsWith('grid.xml')) { + if ( + entry.entryName.startsWith("Grids/") && + entry.entryName.endsWith("grid.xml") + ) { const match = entry.entryName.match(/^Grids\/([^/]+)\//); const currentGridName = match ? match[1] : null; @@ -253,20 +278,23 @@ export async function updateWordlist( const items = wordlist.items.map((item) => ({ WordListItem: { Text: { - s: { - '@_Image': item.image || '', - r: item.text, + p: { + s: { + r: item.text, + }, }, }, - Image: item.image || '', - PartOfSpeech: item.partOfSpeech || 'Unknown', + Image: item.image || "", + PartOfSpeech: item.partOfSpeech || "Unknown", }, })); grid.WordList = { Items: { WordListItem: - items.length === 1 ? items[0].WordListItem : items.map((i) => i.WordListItem), + items.length === 1 + ? items[0].WordListItem + : items.map((i) => i.WordListItem), }, }; @@ -278,8 +306,11 @@ export async function updateWordlist( }); found = true; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to update wordlist in grid "${gridName}": ${message}`); + const message = + error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to update wordlist in grid "${gridName}": ${message}`, + ); } } } diff --git a/src/processors/gridset/xmlFormatter.ts b/src/processors/gridset/xmlFormatter.ts new file mode 100644 index 0000000..f08d962 --- /dev/null +++ b/src/processors/gridset/xmlFormatter.ts @@ -0,0 +1,108 @@ +/** + * Grid3 XML Formatter + * + * Utilities for formatting XML to match Grid 3's specific requirements. + * Grid 3 has strict formatting requirements including line endings, self-closing + * tag spacing, and specific tag expansion rules. + */ + +/** + * Tags that Grid 3 requires in full opening/closing format instead of self-closing + * Grid 3 cannot parse - it requires + */ +const TAGS_NEEDING_EXPANSION = ["AudioDescription", "VideoDescription"]; + +/** + * Format XML string to match Grid 3's requirements + * + * Grid 3 requires specific formatting: + * - Windows line endings (\r\n) + * - Space before /> in self-closing tags: not + * - Plain apostrophes instead of ' + * - Specific tags expanded to full opening/closing format + * - CDATA for empty/whitespace captions and tags + * + * @param xml - The XML string to format + * @returns Formatted XML string compatible with Grid 3 + * + * @example + * const formatted = formatGrid3Xml(''); + * // Returns: '\r\n\r\n' + */ +export function formatGrid3Xml(xml: string): string { + let formatted = xml; + + // Convert Unix line endings to Windows (\r\n) for Grid 3 compatibility + formatted = formatted.replace(/\n/g, "\r\n"); + + // Add space before /> in self-closing tags to match Grid 3's expected format + // Grid 3 original files use not + formatted = formatted.replace(/<(\w+)([^>]*)\/>/g, "<$1$2 />"); + + // Decode XML entities back to plain text to match Grid 3's expected format + // Grid 3 expects plain apostrophes, not ' + formatted = formatted.replace(/'/g, "'"); + formatted = formatted.replace(/"/g, '"'); + formatted = formatted.replace(/</g, "<"); + formatted = formatted.replace(/>/g, ">"); + + // Expand only specific self-closing tags that Grid 3 requires in full opening/closing format + // This must be done AFTER adding spaces, so we need to match the format with spaces + for (const tag of TAGS_NEEDING_EXPANSION) { + formatted = formatted.replace( + new RegExp(`<${tag}(\\s+[^>]*)? />`, "g"), + `<${tag}$1>`, + ); + } + + return formatted; +} + +/** + * Format empty/whitespace captions with CDATA for Grid 3 compatibility + * + * Grid 3 requires for empty captions, not plain text. + * Also handles tags which need CDATA for spaces to prevent stripping. + * + * @param xml - The XML string to format + * @returns XML string with CDATA-wrapped empty content + */ +export function formatEmptyCaptionsWithCdata(xml: string): string { + let formatted = xml; + + // Convert empty/whitespace captions to CDATA format for Grid 3 compatibility + // Grid 3 requires for empty captions, not plain text + formatted = formatted.replace( + /<\/Caption>/g, + " ", + ); + formatted = formatted.replace( + / <\/Caption>/g, + " ", + ); + formatted = formatted.replace( + / {2}<\/Caption>/g, + " ", + ); + + // Preserve CDATA in tags for text parameters + // Spaces in tags must use CDATA or they get stripped during rendering + // e.g., becomes + formatted = formatted.replace(/ <\/r>/g, " "); + formatted = formatted.replace(/ {2}<\/r>/g, " "); + + return formatted; +} + +/** + * Complete XML formatting for Grid 3 compatibility + * Combines all Grid 3 XML formatting requirements + * + * @param xml - The XML string to format + * @returns Fully formatted XML string compatible with Grid 3 + */ +export function formatGrid3XmlComplete(xml: string): string { + let formatted = formatGrid3Xml(xml); + formatted = formatEmptyCaptionsWithCdata(formatted); + return formatted; +} diff --git a/src/processors/gridsetProcessor.ts b/src/processors/gridsetProcessor.ts index ab862bd..145a92d 100644 --- a/src/processors/gridsetProcessor.ts +++ b/src/processors/gridsetProcessor.ts @@ -4,7 +4,7 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from '../core/baseProcessor'; +} from "../core/baseProcessor"; import { AACTree, AACPage, @@ -13,33 +13,42 @@ import { AACSemanticCategory, AACSemanticIntent, GridSetMetadata, -} from '../core/treeStructure'; -import { AACStyle } from '../types/aac'; -import { XMLParser, XMLBuilder } from 'fast-xml-parser'; -import { resolveGrid3CellImage } from './gridset/resolver'; +} from "../core/treeStructure"; +import { AACStyle } from "../types/aac"; +import { XMLParser, XMLBuilder } from "fast-xml-parser"; +import { resolveGrid3CellImage } from "./gridset/resolver"; import { extractAllButtonsForTranslation, validateTranslationResults, type ButtonForTranslation, type LLMLTranslationResult, -} from '../utilities/translation/translationProcessor'; +} from "../utilities/translation/translationProcessor"; import { getZipEntriesFromAdapter, resolveGridsetPassword, type ZipEntry, -} from './gridset/password'; -import { decryptGridsetEntry } from './gridset/crypto'; -import { GridsetValidator } from '../validation/gridsetValidator'; -import { ValidationResult } from '../validation/validationTypes'; +} from "./gridset/password"; +import { decryptGridsetEntry } from "./gridset/crypto"; +import { formatGrid3XmlComplete } from "./gridset/xmlFormatter"; +import { + calculateColumnDefinitions as calcColumnDefs, + calculateRowDefinitions as calcRowDefs, +} from "./gridset/gridCalculations"; +import { findButtonPosition as findButtonPos } from "./gridset/cellHelpers"; +import { GridsetValidator } from "../validation/gridsetValidator"; +import { ValidationResult } from "../validation/validationTypes"; // New imports for enhanced Grid 3 support -import { detectPluginCellType, Grid3CellType } from './gridset/pluginTypes'; -import { detectCommand } from './gridset/commands'; -import { type SymbolReference, parseSymbolReference } from './gridset/symbols'; -import { isSymbolLibraryReference } from './gridset/resolver'; -import { generateCloneId } from '../utilities/analytics/utils/idGenerator'; -import { translateWithSymbols, extractSymbolsFromButton } from './gridset/symbolAlignment'; -import { ProcessorInput, decodeText } from '../utils/io'; -import { ZipFile } from '../utils/zip'; +import { detectPluginCellType, Grid3CellType } from "./gridset/pluginTypes"; +import { detectCommand } from "./gridset/commands"; +import { type SymbolReference, parseSymbolReference } from "./gridset/symbols"; +import { isSymbolLibraryReference } from "./gridset/resolver"; +import { generateCloneId } from "../utilities/analytics/utils/idGenerator"; +import { + translateWithSymbols, + extractSymbolsFromButton, +} from "./gridset/symbolAlignment"; +import { ProcessorInput, decodeText } from "../utils/io"; +import { ZipFile } from "../utils/zip"; class GridsetProcessor extends BaseProcessor { constructor(options?: ProcessorOptions) { @@ -53,10 +62,12 @@ class GridsetProcessor extends BaseProcessor { // Helper function to ensure color has alpha channel (Grid3 format) private ensureAlphaChannel(color: string | undefined): string { - if (!color) return '#FFFFFFFF'; + if (!color) return "#FFFFFFFF"; // Handle rgb() and rgba() formats - const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); + const rgbMatch = color.match( + /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/, + ); if (rgbMatch) { const r = parseInt(rgbMatch[1]); const g = parseInt(rgbMatch[2]); @@ -65,14 +76,14 @@ class GridsetProcessor extends BaseProcessor { const alphaHex = Math.round(a * 255) .toString(16) .toUpperCase() - .padStart(2, '0'); - return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${alphaHex}`; + .padStart(2, "0"); + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}${alphaHex}`; } // If already 8 digits (with alpha), return as is if (color.match(/^#[0-9A-Fa-f]{8}$/)) return color; // If 6 digits (no alpha), add FF for fully opaque - if (color.match(/^#[0-9A-Fa-f]{6}$/)) return color + 'FF'; + if (color.match(/^#[0-9A-Fa-f]{6}$/)) return color + "FF"; // If 3 digits (shorthand), expand to 8 if (color.match(/^#[0-9A-Fa-f]{3}$/)) { const r = color[1]; @@ -81,7 +92,7 @@ class GridsetProcessor extends BaseProcessor { return `#${r}${r}${g}${g}${b}${b}FF`; } // Invalid or unknown format, return white - return '#FFFFFFFF'; + return "#FFFFFFFF"; } /** @@ -89,7 +100,7 @@ class GridsetProcessor extends BaseProcessor { * Uses WCAG relative luminance formula to determine contrast */ private getContrastFontColor(backgroundColor: string | undefined): string { - if (!backgroundColor) return '#FF000000FF'; // Default to black + if (!backgroundColor) return "#FF000000FF"; // Default to black // Parse color from various formats let r = 255, @@ -97,7 +108,9 @@ class GridsetProcessor extends BaseProcessor { b = 255; // Handle hex colors - const hexMatch = backgroundColor.match(/#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/); + const hexMatch = backgroundColor.match( + /#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/, + ); if (hexMatch) { r = parseInt(hexMatch[1], 16); g = parseInt(hexMatch[2], 16); @@ -117,7 +130,7 @@ class GridsetProcessor extends BaseProcessor { // Use white text for dark backgrounds (luminance < 0.5), black for light backgrounds // Return 6-digit hex (ensureAlphaChannel will add FF for alpha) - return luminance < 0.5 ? '#FFFFFF' : '#000000'; + return luminance < 0.5 ? "#FFFFFF" : "#000000"; } /** @@ -128,10 +141,13 @@ class GridsetProcessor extends BaseProcessor { // Sometimes the param itself is the WordList, sometimes it has a WordList property const wordList = - param.WordList || param.wordlist || (param.Items || param.items ? param : undefined); + param.WordList || + param.wordlist || + (param.Items || param.items ? param : undefined); if (!wordList || !(wordList.Items || wordList.items)) return []; - const items = wordList.Items?.WordListItem || wordList.items?.wordlistitem || []; + const items = + wordList.Items?.WordListItem || wordList.items?.wordlistitem || []; const itemArr = Array.isArray(items) ? items : [items]; const words: string[] = []; @@ -140,9 +156,9 @@ class GridsetProcessor extends BaseProcessor { if (text) { const val = this.textOf(text); if (val) words.push(val); - } else if (item['#text'] !== undefined) { - words.push(String(item['#text'])); - } else if (typeof item === 'string') { + } else if (item["#text"] !== undefined) { + words.push(String(item["#text"])); + } else if (typeof item === "string") { words.push(item); } } @@ -150,29 +166,32 @@ class GridsetProcessor extends BaseProcessor { } // Helper function to generate Grid3 commands from semantic actions - private generateCommandsFromSemanticAction(button: AACButton, tree?: AACTree): any { + private generateCommandsFromSemanticAction( + button: AACButton, + tree?: AACTree, + ): any { const semanticAction = button.semanticAction; if (!semanticAction) { // Default to insert text action with structured XML format // Use two elements: one for the word, one for the space (CDATA preserves whitespace) - let text = button.message || button.label || ''; + let text = button.message || button.label || ""; // Remove trailing space from message if present (we'll add it as separate segment) - if (text.endsWith(' ')) { + if (text.endsWith(" ")) { text = text.slice(0, -1); } return { Command: { - '@_ID': 'Action.InsertText', + "@_ID": "Action.InsertText", Parameter: { - '@_Key': 'text', + "@_Key": "text", p: { s: [ { r: text, }, { - r: { __cdata: ' ' }, + r: { __cdata: " " }, }, ], }, @@ -184,14 +203,16 @@ class GridsetProcessor extends BaseProcessor { // Use platform-specific Grid3 data if available if (semanticAction.platformData?.grid3) { const grid3Data = semanticAction.platformData.grid3; - const params = Object.entries(grid3Data.parameters || {}).map(([key, value]) => ({ - '@_Key': key, - '#text': String(value), - })); + const params = Object.entries(grid3Data.parameters || {}).map( + ([key, value]) => ({ + "@_Key": key, + "#text": String(value), + }), + ); return { Command: { - '@_ID': grid3Data.commandId, + "@_ID": grid3Data.commandId, ...(params.length > 0 ? { Parameter: params } : {}), }, }; @@ -200,9 +221,9 @@ class GridsetProcessor extends BaseProcessor { // Convert semantic actions to Grid3 commands const intentStr = String(semanticAction.intent); switch (intentStr) { - case 'NAVIGATE_TO': { + case "NAVIGATE_TO": { // For Grid3, we need to use the grid name, not the ID - let targetGridName = semanticAction.targetId || ''; + let targetGridName = semanticAction.targetId || ""; if (tree && semanticAction.targetId) { const targetPage = tree.getPage(semanticAction.targetId); if (targetPage) { @@ -211,70 +232,70 @@ class GridsetProcessor extends BaseProcessor { } return { Command: { - '@_ID': 'Jump.To', + "@_ID": "Jump.To", Parameter: { - '@_Key': 'grid', - '#text': targetGridName, + "@_Key": "grid", + "#text": targetGridName, }, }, }; } - case 'GO_BACK': + case "GO_BACK": return { Command: { - '@_ID': 'Jump.Back', + "@_ID": "Jump.Back", }, }; - case 'GO_HOME': + case "GO_HOME": return { Command: { - '@_ID': 'Jump.Home', + "@_ID": "Jump.Home", }, }; - case 'DELETE_WORD': + case "DELETE_WORD": return { Command: { - '@_ID': 'Action.DeleteWord', + "@_ID": "Action.DeleteWord", }, }; - case 'DELETE_CHARACTER': + case "DELETE_CHARACTER": return { Command: { - '@_ID': 'Action.DeleteLetter', + "@_ID": "Action.DeleteLetter", }, }; - case 'CLEAR_TEXT': + case "CLEAR_TEXT": return { Command: { - '@_ID': 'Action.Clear', + "@_ID": "Action.Clear", }, }; - case 'SPEAK_TEXT': - case 'SPEAK_IMMEDIATE': { + case "SPEAK_TEXT": + case "SPEAK_IMMEDIATE": { // Users can speak the complete sentence with a dedicated Speak button // Use two elements: one for the word, one for the space (CDATA preserves whitespace) // Grid3 requires explicit trailing space for automatic word spacing // For communication buttons, insert text into message bar (sentence building) - let text = semanticAction.text || button.message || button.label || ''; + let text = semanticAction.text || button.message || button.label || ""; // Remove trailing space from message if present (we'll add it as separate segment) - if (text.endsWith(' ')) { + if (text.endsWith(" ")) { text = text.slice(0, -1); } return { Command: { - '@_ID': 'Action.InsertText', + "@_ID": "Action.InsertText", Parameter: { - '@_Key': 'text', + "@_Key": "text", p: { s: [ { r: text, }, { - r: { __cdata: ' ' }, + r: { __cdata: " " }, }, ], }, @@ -283,25 +304,25 @@ class GridsetProcessor extends BaseProcessor { }; } - case 'INSERT_TEXT': { + case "INSERT_TEXT": { // Use two elements: one for the word, one for the space (CDATA preserves whitespace) // Add trailing space for word buttons to enable sentence building - let text = semanticAction.text || button.message || button.label || ''; + let text = semanticAction.text || button.message || button.label || ""; // Remove trailing space from message if present (we'll add it as separate segment) - if (text.endsWith(' ')) { + if (text.endsWith(" ")) { text = text.slice(0, -1); } return { Command: { - '@_ID': 'Action.InsertText', + "@_ID": "Action.InsertText", Parameter: { - '@_Key': 'text', + "@_Key": "text", p: { s: [ { r: text, }, { - r: { __cdata: ' ' }, + r: { __cdata: " " }, }, ], }, @@ -313,23 +334,23 @@ class GridsetProcessor extends BaseProcessor { default: { // Use two elements: one for the word, one for the space (CDATA preserves whitespace) // Fallback to insert text with structured XML format - let text = semanticAction.text || button.message || button.label || ''; + let text = semanticAction.text || button.message || button.label || ""; // Remove trailing space from message if present (we'll add it as separate segment) - if (text.endsWith(' ')) { + if (text.endsWith(" ")) { text = text.slice(0, -1); } return { Command: { - '@_ID': 'Action.InsertText', + "@_ID": "Action.InsertText", Parameter: { - '@_Key': 'text', + "@_Key": "text", p: { s: [ { r: text, }, { - r: { __cdata: ' ' }, + r: { __cdata: " " }, }, ], }, @@ -349,7 +370,9 @@ class GridsetProcessor extends BaseProcessor { borderColor: grid3Style.BorderColour, fontColor: grid3Style.FontColour, fontFamily: grid3Style.FontName, - fontSize: grid3Style.FontSize ? parseInt(String(grid3Style.FontSize)) : undefined, + fontSize: grid3Style.FontSize + ? parseInt(String(grid3Style.FontSize)) + : undefined, backgroundShape: grid3Style.BackgroundShape !== undefined ? parseInt(String(grid3Style.BackgroundShape)) @@ -367,10 +390,10 @@ class GridsetProcessor extends BaseProcessor { // Helper to safely extract text from XML parser values private textOf(val: any): string | undefined { if (!val) return undefined; - if (typeof val === 'string') return val; - if (typeof val === 'number') return String(val); + if (typeof val === "string") return val; + if (typeof val === "number") return String(val); - if (typeof val === 'object') { + if (typeof val === "object") { // Don't immediately return #text - it might be whitespace alongside structured content // Process structured format first:

text

@@ -382,18 +405,18 @@ class GridsetProcessor extends BaseProcessor { if (s.r !== undefined) { const rElements = Array.isArray(s.r) ? s.r : [s.r]; for (const r of rElements) { - if (typeof r === 'number') { + if (typeof r === "number") { if (r !== 0) { parts.push(String(r)); } continue; } - if (typeof r === 'object' && r !== null) { + if (typeof r === "object" && r !== null) { // Check for #text (regular text) or #cdata (CDATA sections) - if ('#text' in r) { - parts.push(String(r['#text'])); - } else if ('#cdata' in r) { - parts.push(String(r['#cdata'])); + if ("#text" in r) { + parts.push(String(r["#text"])); + } else if ("#cdata" in r) { + parts.push(String(r["#cdata"])); } else { parts.push(String(r)); } @@ -416,7 +439,7 @@ class GridsetProcessor extends BaseProcessor { } if (parts.length > 0) { - return parts.join('').trim(); + return parts.join("").trim(); } } return undefined; @@ -456,17 +479,18 @@ class GridsetProcessor extends BaseProcessor { ignoreDeclaration: true, parseTagValue: false, trimValues: false, - textNodeName: '#text', - cdataProp: '#cdata', + textNodeName: "#text", + cdataProp: "#cdata", }; const parser = new XMLParser(options); const isEncryptedArchive = - typeof filePathOrBuffer === 'string' && filePathOrBuffer.toLowerCase().endsWith('.gridsetx'); + typeof filePathOrBuffer === "string" && + filePathOrBuffer.toLowerCase().endsWith(".gridsetx"); const encryptedContentPassword = this.getGridsetPassword(filePathOrBuffer); // Initialize metadata const metadata: GridSetMetadata = { - format: 'gridset', + format: "gridset", isSmartBox: isEncryptedArchive, // SmartBox files are .gridsetx encrypted archives passwordProtected: !!password, }; @@ -482,27 +506,35 @@ class GridsetProcessor extends BaseProcessor { // Parse FileMap.xml if present to index dynamic files per grid const fileMapIndex = new Map(); try { - const fmEntry = entries.find((e) => e.entryName.endsWith('FileMap.xml')); + const fmEntry = entries.find((e) => e.entryName.endsWith("FileMap.xml")); if (fmEntry) { const fmXml = decodeText(await readEntryBuffer(fmEntry)); const fmData = parser.parse(fmXml); - const entries = fmData?.FileMap?.Entries?.Entry || fmData?.fileMap?.entries?.entry; + const entries = + fmData?.FileMap?.Entries?.Entry || fmData?.fileMap?.entries?.entry; if (entries) { const arr = Array.isArray(entries) ? entries : [entries]; for (const ent of arr) { - const rawStaticFile = ent['@_StaticFile'] || ent.StaticFile || ent.staticFile; + const rawStaticFile = + ent["@_StaticFile"] || ent.StaticFile || ent.staticFile; const staticFile = - typeof rawStaticFile === 'string' ? rawStaticFile.replace(/\\/g, '/') : ''; + typeof rawStaticFile === "string" + ? rawStaticFile.replace(/\\/g, "/") + : ""; if (!staticFile) continue; const df = ent.DynamicFiles || ent.dynamicFiles; const candidates = df?.File || df?.file || df?.Files || df?.files; - const list = Array.isArray(candidates) ? candidates : candidates ? [candidates] : []; + const list = Array.isArray(candidates) + ? candidates + : candidates + ? [candidates] + : []; const files: string[] = []; for (const v of list) { if (!v) continue; - if (typeof v === 'string') files.push(v.replace(/\\/g, '/')); - else if (typeof v === 'object' && '#text' in v) - files.push(String(v['#text']).replace(/\\/g, '/')); + if (typeof v === "string") files.push(v.replace(/\\/g, "/")); + else if (typeof v === "object" && "#text" in v) + files.push(String(v["#text"]).replace(/\\/g, "/")); } fileMapIndex.set(staticFile, files); } @@ -515,7 +547,9 @@ class GridsetProcessor extends BaseProcessor { // First, load styles from Settings0/Styles/styles.xml (Grid3 format) const styles = new Map(); const styleEntry = entries.find( - (entry) => entry.entryName.endsWith('styles.xml') || entry.entryName.endsWith('style.xml') + (entry) => + entry.entryName.endsWith("styles.xml") || + entry.entryName.endsWith("style.xml"), ); if (styleEntry) { try { @@ -528,8 +562,8 @@ class GridsetProcessor extends BaseProcessor { ? styleData.StyleData.Styles.Style : [styleData.StyleData.Styles.Style]; styleArray.forEach((style: any) => { - if (style['@_Key']) { - styles.set(String(style['@_Key']), style); + if (style["@_Key"]) { + styles.set(String(style["@_Key"]), style); } }); } @@ -539,31 +573,31 @@ class GridsetProcessor extends BaseProcessor { ? styleData.Styles.Style : [styleData.Styles.Style]; styleArray.forEach((style: any) => { - if (style['@_ID']) { - styles.set(String(style['@_ID']), style); + if (style["@_ID"]) { + styles.set(String(style["@_ID"]), style); } }); } } catch (e) { - console.warn('Failed to parse styles.xml:', e); + console.warn("Failed to parse styles.xml:", e); } } // Debug: log all entry names - console.log('[Gridset] Total zip entries:', entries.length); + console.log("[Gridset] Total zip entries:", entries.length); const normalizeEntryName = (entryName: string): string => - entryName.replace(/\\/g, '/').toLowerCase(); + entryName.replace(/\\/g, "/").toLowerCase(); const isGridXmlEntry = (entryName: string): boolean => { const normalized = normalizeEntryName(entryName); - if (!normalized.endsWith('grid.xml')) return false; - return normalized.startsWith('grids/') || normalized.includes('/grids/'); + if (!normalized.endsWith("grid.xml")) return false; + return normalized.startsWith("grids/") || normalized.includes("/grids/"); }; const gridEntries = entries.filter((e) => isGridXmlEntry(e.entryName)); - console.log('[Gridset] Grid XML entries found:', gridEntries.length); + console.log("[Gridset] Grid XML entries found:", gridEntries.length); if (gridEntries.length > 0) { console.log( - '[Gridset] First few grid entries:', - gridEntries.slice(0, 3).map((e) => e.entryName) + "[Gridset] First few grid entries:", + gridEntries.slice(0, 3).map((e) => e.entryName), ); } @@ -572,11 +606,11 @@ class GridsetProcessor extends BaseProcessor { const imageEntries = entries.filter((e) => { const name = e.entryName.toLowerCase(); return ( - name.endsWith('.png') || - name.endsWith('.jpg') || - name.endsWith('.jpeg') || - name.endsWith('.gif') || - name.endsWith('.svg') + name.endsWith(".png") || + name.endsWith(".jpg") || + name.endsWith(".jpeg") || + name.endsWith(".gif") || + name.endsWith(".svg") ); }); @@ -586,7 +620,7 @@ class GridsetProcessor extends BaseProcessor { const data = isEncryptedArchive ? decryptGridsetEntry(Buffer.from(raw), encryptedContentPassword) : Buffer.from(raw); - const normalizedEntry = imageEntry.entryName.replace(/\\/g, '/'); + const normalizedEntry = imageEntry.entryName.replace(/\\/g, "/"); imageDataCache.set(normalizedEntry, data); } catch (_err) { // Silently fail - individual image loading failures shouldn't break the entire load @@ -607,7 +641,9 @@ class GridsetProcessor extends BaseProcessor { const gridId = this.textOf(grid.GridGuid || grid.gridGuid || grid.id); const gridName = - this.textOf(grid.Name) || this.textOf(grid.name) || this.textOf(grid['@_Name']); + this.textOf(grid.Name) || + this.textOf(grid.name) || + this.textOf(grid["@_Name"]); const folderMatch = entry.entryName.match(/^Grids\/([^/]+)\//); const folderName = folderMatch ? folderMatch[1] : undefined; @@ -641,7 +677,7 @@ class GridsetProcessor extends BaseProcessor { xmlContent = decodeText(buffer); console.log( `[Gridset] Raw XML content (first 200 chars) for ${entry.entryName}:`, - xmlContent.substring(0, 200) + xmlContent.substring(0, 200), ); } catch (_e) { // Skip unreadable files @@ -650,7 +686,10 @@ class GridsetProcessor extends BaseProcessor { let data: Record; try { data = parser.parse(xmlContent) as Record; - console.log(`[Gridset] Parsed ${entry.entryName}, root keys:`, Object.keys(data)); + console.log( + `[Gridset] Parsed ${entry.entryName}, root keys:`, + Object.keys(data), + ); } catch (error: any) { // Skip malformed XML but log the specific error console.warn(`Malformed XML in ${entry.entryName}: ${error.message}`); @@ -658,7 +697,9 @@ class GridsetProcessor extends BaseProcessor { } // Grid3 XML: root - const grid = (data as { Grid?: any; grid?: any }).Grid || (data as { grid?: any }).grid; + const grid = + (data as { Grid?: any; grid?: any }).Grid || + (data as { grid?: any }).grid; if (!grid) { console.warn(`[Gridset] No Grid/grid found in ${entry.entryName}`); continue; @@ -666,7 +707,9 @@ class GridsetProcessor extends BaseProcessor { // Defensive: GridGuid and Name required const gridId = this.textOf(grid.GridGuid || grid.gridGuid || grid.id); let gridName = - this.textOf(grid.Name) || this.textOf(grid.name) || this.textOf(grid['@_Name']); + this.textOf(grid.Name) || + this.textOf(grid.name) || + this.textOf(grid["@_Name"]); if (!gridName) { // Fallback: get folder name from entry path const match = entry.entryName.match(/^Grids\/([^/]+)\//); @@ -690,8 +733,16 @@ class GridsetProcessor extends BaseProcessor { // Calculate grid dimensions from ColumnDefinitions and RowDefinitions const columnDefs = grid.ColumnDefinitions?.ColumnDefinition || []; const rowDefs = grid.RowDefinitions?.RowDefinition || []; - const maxCols = Array.isArray(columnDefs) ? columnDefs.length : columnDefs ? 1 : 5; - const maxRows = Array.isArray(rowDefs) ? rowDefs.length : rowDefs ? 1 : 4; + const maxCols = Array.isArray(columnDefs) + ? columnDefs.length + : columnDefs + ? 1 + : 5; + const maxRows = Array.isArray(rowDefs) + ? rowDefs.length + : rowDefs + ? 1 + : 4; // Process buttons: const cells = grid.Cells?.Cell || grid.cells?.cell; @@ -711,7 +762,8 @@ class GridsetProcessor extends BaseProcessor { // Extract words from grid-level AutoContentCommands (e.g., Prediction Bar) if (grid.AutoContentCommands) { - const collections = grid.AutoContentCommands.AutoContentCommandCollection; + const collections = + grid.AutoContentCommands.AutoContentCommandCollection; const collectionArr = Array.isArray(collections) ? collections : collections @@ -720,15 +772,23 @@ class GridsetProcessor extends BaseProcessor { collectionArr.forEach((collection: any) => { const commands = collection.Commands?.Command; - const commandArr = Array.isArray(commands) ? commands : commands ? [commands] : []; + const commandArr = Array.isArray(commands) + ? commands + : commands + ? [commands] + : []; commandArr.forEach((command: any) => { - const commandId = command['@_ID'] || command.ID || command.id; - if (commandId === 'Prediction.PredictThis') { + const commandId = command["@_ID"] || command.ID || command.id; + if (commandId === "Prediction.PredictThis") { const params = command.Parameter; - const paramArr = Array.isArray(params) ? params : params ? [params] : []; + const paramArr = Array.isArray(params) + ? params + : params + ? [params] + : []; const wordListParam = paramArr.find( - (p: any) => (p['@_Key'] || p.Key || p.key) === 'wordlist' + (p: any) => (p["@_Key"] || p.Key || p.key) === "wordlist", ); if (wordListParam) { @@ -751,7 +811,9 @@ class GridsetProcessor extends BaseProcessor { const pageWordListItems: PageWordListItem[] = []; if (grid.WordList && grid.WordList.Items) { const items = - grid.WordList.Items.WordListItem || grid.WordList.Items.wordlistitem || []; + grid.WordList.Items.WordListItem || + grid.WordList.Items.wordlistitem || + []; const itemArr = Array.isArray(items) ? items : items ? [items] : []; for (const item of itemArr) { @@ -762,19 +824,20 @@ class GridsetProcessor extends BaseProcessor { // Debug: log WordList items with spaces to check extraction if (pageWordListItems.length < 3) { console.log( - `[WordList] Extracted text: "${val}" (length: ${val.length}, has spaces: ${val.includes(' ')})` + `[WordList] Extracted text: "${val}" (length: ${val.length}, has spaces: ${val.includes(" ")})`, ); console.log( `[WordList] Chars:`, Array.from(val) .map((c) => `"${c}" (${c.charCodeAt(0)})`) - .join(', ') + .join(", "), ); } pageWordListItems.push({ text: val, image: item.Image || item.image || undefined, - partOfSpeech: item.PartOfSpeech || item.partOfSpeech || undefined, + partOfSpeech: + item.PartOfSpeech || item.partOfSpeech || undefined, }); } } @@ -795,7 +858,7 @@ class GridsetProcessor extends BaseProcessor { const findNextAvailablePosition = ( width: number, height: number, - gridLayout: (AACButton | null)[][] + gridLayout: (AACButton | null)[][], ): { x: number; y: number } => { for (let y = 0; y < maxRows; y++) { for (let x = 0; x <= maxCols - width; x++) { @@ -823,7 +886,7 @@ class GridsetProcessor extends BaseProcessor { const findNextAvailableXInRow = ( rowY: number, width: number, - gridLayout: (AACButton | null)[][] + gridLayout: (AACButton | null)[][], ): number => { for (let x = 0; x <= maxCols - width; x++) { let fits = true; @@ -851,8 +914,8 @@ class GridsetProcessor extends BaseProcessor { cellArr.forEach((cell: any, idx: number) => { if (!cell || !cell.Content) return; - const hasX = cell['@_X'] !== undefined; - const hasY = cell['@_Y'] !== undefined; + const hasX = cell["@_X"] !== undefined; + const hasY = cell["@_Y"] !== undefined; if (hasX && hasY) { cellsWithExplicitPosition.push({ cell, idx }); @@ -875,12 +938,12 @@ class GridsetProcessor extends BaseProcessor { allCellsToProcess.forEach(({ cell, idx }) => { // Extract span information first - const colSpan = parseInt(String(cell['@_ColumnSpan'] || '1'), 10); - const rowSpan = parseInt(String(cell['@_RowSpan'] || '1'), 10); + const colSpan = parseInt(String(cell["@_ColumnSpan"] || "1"), 10); + const rowSpan = parseInt(String(cell["@_RowSpan"] || "1"), 10); // Determine position based on what attributes are present - const hasX = cell['@_X'] !== undefined; - const hasY = cell['@_Y'] !== undefined; + const hasX = cell["@_X"] !== undefined; + const hasY = cell["@_Y"] !== undefined; let cellX: number; let cellY: number; @@ -888,17 +951,17 @@ class GridsetProcessor extends BaseProcessor { if (hasX && hasY) { // Explicit position: both X and Y provided // Grid 3 XML coordinates are already 0-based, use them directly - cellX = Math.max(0, parseInt(String(cell['@_X']), 10)); - cellY = Math.max(0, parseInt(String(cell['@_Y']), 10)); + cellX = Math.max(0, parseInt(String(cell["@_X"]), 10)); + cellY = Math.max(0, parseInt(String(cell["@_Y"]), 10)); } else if (hasY && !hasX) { // Y-only: auto-flow X in the specified row // Grid 3 XML coordinates are already 0-based, use them directly - cellY = Math.max(0, parseInt(String(cell['@_Y']), 10)); + cellY = Math.max(0, parseInt(String(cell["@_Y"]), 10)); cellX = findNextAvailableXInRow(cellY, colSpan, gridLayout); } else if (!hasY && hasX) { // X-only: place at specified X in next available row // Grid 3 XML coordinates are already 0-based, use them directly - cellX = Math.max(0, parseInt(String(cell['@_X']), 10)); + cellX = Math.max(0, parseInt(String(cell["@_X"]), 10)); // Find first row where this X position is available cellY = 0; let found = false; @@ -918,19 +981,27 @@ class GridsetProcessor extends BaseProcessor { } if (!found) { // No available row found, use auto-flow - const pos = findNextAvailablePosition(colSpan, rowSpan, gridLayout); + const pos = findNextAvailablePosition( + colSpan, + rowSpan, + gridLayout, + ); cellX = pos.x; cellY = pos.y; } } else { // No position: auto-flow both X and Y - const pos = findNextAvailablePosition(colSpan, rowSpan, gridLayout); + const pos = findNextAvailablePosition( + colSpan, + rowSpan, + gridLayout, + ); cellX = pos.x; cellY = pos.y; } // Extract scan block number (1-8) for block scanning support - const scanBlock = parseInt(String(cell['@_ScanBlock'] || '1'), 10); + const scanBlock = parseInt(String(cell["@_ScanBlock"] || "1"), 10); // Extract visibility from Grid 3's child element // Grid 3 stores visibility as a child element, not an attribute @@ -940,30 +1011,30 @@ class GridsetProcessor extends BaseProcessor { // Map Grid 3 visibility values to AAC standard values // Grid 3 can have additional values like TouchOnly, PointerOnly that map to PointerAndTouchOnly let cellVisibility: - | 'Visible' - | 'Hidden' - | 'Disabled' - | 'PointerAndTouchOnly' - | 'Empty' + | "Visible" + | "Hidden" + | "Disabled" + | "PointerAndTouchOnly" + | "Empty" | undefined; if (grid3Visibility) { const vis = String(grid3Visibility); // Direct mapping for standard values if ( - vis === 'Visible' || - vis === 'Hidden' || - vis === 'Disabled' || - vis === 'PointerAndTouchOnly' + vis === "Visible" || + vis === "Hidden" || + vis === "Disabled" || + vis === "PointerAndTouchOnly" ) { cellVisibility = vis; } // Map Grid 3 specific values to AAC standard - else if (vis === 'TouchOnly' || vis === 'PointerOnly') { - cellVisibility = 'PointerAndTouchOnly'; + else if (vis === "TouchOnly" || vis === "PointerOnly") { + cellVisibility = "PointerAndTouchOnly"; } // Grid 3 may use 'Empty' for cells that exist but have no content - else if (vis === 'Empty') { - cellVisibility = 'Empty'; + else if (vis === "Empty") { + cellVisibility = "Empty"; } // Unknown visibility - default to Visible else { @@ -973,8 +1044,12 @@ class GridsetProcessor extends BaseProcessor { // Extract label from CaptionAndImage/Caption const content = cell.Content; - const captionAndImage = content.CaptionAndImage || content.captionAndImage; - let label = this.textOf(captionAndImage?.Caption || captionAndImage?.caption) || ''; + const captionAndImage = + content.CaptionAndImage || content.captionAndImage; + let label = + this.textOf( + captionAndImage?.Caption || captionAndImage?.caption, + ) || ""; // Check if cell has an image/symbol (needed to decide if we should keep it) const hasImageCandidate = !!( @@ -989,12 +1064,12 @@ class GridsetProcessor extends BaseProcessor { // If no caption, try other sources or create a placeholder if (!label) { // For cells without captions, check if they have images/symbols before skipping - if (content.ContentType === 'AutoContent') { + if (content.ContentType === "AutoContent") { label = `AutoContent_${idx}`; } else if ( hasImageCandidate || - content.ContentType === 'Workspace' || - content.ContentType === 'LiveCell' + content.ContentType === "Workspace" || + content.ContentType === "LiveCell" ) { // Keep cells with images/symbols even if no caption label = `Cell_${idx}`; @@ -1010,18 +1085,18 @@ class GridsetProcessor extends BaseProcessor { // Friendly labels for workspace/prediction cells when captions are missing if (pluginMetadata.cellType === Grid3CellType.Workspace) { - if (!label || label.startsWith('Cell_')) { + if (!label || label.startsWith("Cell_")) { label = pluginMetadata.displayName || pluginMetadata.subType || pluginMetadata.pluginId || - 'Workspace'; + "Workspace"; } } if ( pluginMetadata.cellType === Grid3CellType.AutoContent && - pluginMetadata.autoContentType === 'Prediction' + pluginMetadata.autoContentType === "Prediction" ) { predictionCellCounter += 1; // Always surface a friendly label for predictions even if a placeholder exists @@ -1032,7 +1107,7 @@ class GridsetProcessor extends BaseProcessor { let isMoreButton = false; if ( pluginMetadata.cellType === Grid3CellType.AutoContent && - pluginMetadata.autoContentType === 'WordList' && + pluginMetadata.autoContentType === "WordList" && pageWordListItems.length > 0 ) { // Track this cell for potential "more" button @@ -1047,12 +1122,13 @@ class GridsetProcessor extends BaseProcessor { // The "more" button replaces the last WordList cell const cellsNeededForWordList = pageWordListItems.length; const availableWordListCells = wordListAutoContentCells.length; - const isLastWordListCell = availableWordListCells === cellsNeededForWordList + 1; // +1 for "more" button + const isLastWordListCell = + availableWordListCells === cellsNeededForWordList + 1; // +1 for "more" button if (isLastWordListCell) { // This cell becomes the "more" button - label = 'more...'; - message = 'more...'; + label = "more..."; + message = "more..."; isMoreButton = true; } else if (wordListCellIndex < pageWordListItems.length) { // Populate this cell with the next WordList item @@ -1078,7 +1154,8 @@ class GridsetProcessor extends BaseProcessor { let detectedCommands: any[] = []; // Store detected command metadata let buttonPos: string | undefined; // Part-of-speech from Action.InsertText - const commands = content.Commands?.Command || content.commands?.command; + const commands = + content.Commands?.Command || content.commands?.command; let predictionWords: string[] | undefined; // Resolve image for this cell using FileMap and coordinate heuristics @@ -1089,9 +1166,11 @@ class GridsetProcessor extends BaseProcessor { captionAndImage?.imageName || captionAndImage?.Symbol || captionAndImage?.symbol; - const declaredImageName = imageCandidate ? this.textOf(imageCandidate) : undefined; - const gridEntryPath = entry.entryName.replace(/\\/g, '/'); - const baseDir = gridEntryPath.replace(/\/grid\.xml$/, '/'); + const declaredImageName = imageCandidate + ? this.textOf(imageCandidate) + : undefined; + const gridEntryPath = entry.entryName.replace(/\\/g, "/"); + const baseDir = gridEntryPath.replace(/\/grid\.xml$/, "/"); const dynamicFiles = fileMapIndex.get(gridEntryPath) || []; const resolvedImageEntry = resolveGrid3CellImage( @@ -1103,17 +1182,17 @@ class GridsetProcessor extends BaseProcessor { y: cellY, dynamicFiles, }, - entries + entries, ) || undefined; // Debug: log resolution for cells with images if (declaredImageName && resolvedImageEntry) { console.log( - `[GridsetProcessor] Cell (${cellX + 1},${cellY + 1}) [XML coords]: ${declaredImageName} -> ${resolvedImageEntry}` + `[GridsetProcessor] Cell (${cellX + 1},${cellY + 1}) [XML coords]: ${declaredImageName} -> ${resolvedImageEntry}`, ); } else if (declaredImageName && !resolvedImageEntry) { console.log( - `[GridsetProcessor] Cell (${cellX + 1},${cellY + 1}) [XML coords]: ${declaredImageName} -> NOT FOUND` + `[GridsetProcessor] Cell (${cellX + 1},${cellY + 1}) [XML coords]: ${declaredImageName} -> NOT FOUND`, ); } @@ -1124,23 +1203,36 @@ class GridsetProcessor extends BaseProcessor { // Check if image is a symbol library reference let symbolLibraryRef: SymbolReference | null = null; - if (declaredImageName && isSymbolLibraryReference(declaredImageName)) { + if ( + declaredImageName && + isSymbolLibraryReference(declaredImageName) + ) { symbolLibraryRef = parseSymbolReference(declaredImageName); } if (commands) { - const commandArr = Array.isArray(commands) ? commands : [commands]; + const commandArr = Array.isArray(commands) + ? commands + : [commands]; detectedCommands = commandArr.map((cmd) => detectCommand(cmd)); // Scan all commands for vocabulary (predictions) before identifying primary action commandArr.forEach((cmd) => { - const id = cmd['@_ID'] || cmd.ID || cmd.id; - if (id === 'Prediction.PredictThis') { + const id = cmd["@_ID"] || cmd.ID || cmd.id; + if (id === "Prediction.PredictThis") { const params = cmd.Parameter || cmd.parameter; - const pArr = params ? (Array.isArray(params) ? params : [params]) : []; + const pArr = params + ? Array.isArray(params) + ? params + : [params] + : []; let wlP: any; for (const p of pArr) { - if (p['@_Key'] === 'wordlist' || p.Key === 'wordlist' || p.key === 'wordlist') { + if ( + p["@_Key"] === "wordlist" || + p.Key === "wordlist" || + p.key === "wordlist" + ) { wlP = p; break; } @@ -1155,7 +1247,7 @@ class GridsetProcessor extends BaseProcessor { }); for (const command of commandArr) { - const commandId = command['@_ID'] || command.ID || command.id; + const commandId = command["@_ID"] || command.ID || command.id; const parameters = command.Parameter || command.parameter; const paramArr = parameters ? Array.isArray(parameters) @@ -1166,7 +1258,11 @@ class GridsetProcessor extends BaseProcessor { // Helper to get raw parameter object const getRawParam = (key: string): any | undefined => { for (const param of paramArr) { - if (param['@_Key'] === key || param.Key === key || param.key === key) { + if ( + param["@_Key"] === key || + param.Key === key || + param.key === key + ) { return param; } } @@ -1177,20 +1273,24 @@ class GridsetProcessor extends BaseProcessor { const getParam = (key: string): string | undefined => { const param = getRawParam(key); if (param === undefined) return undefined; - const simpleValue = param['#text'] ?? param.text ?? param.value; - if (typeof simpleValue === 'string') return simpleValue; - if (typeof simpleValue === 'number') return String(simpleValue); + const simpleValue = + param["#text"] ?? param.text ?? param.value; + if (typeof simpleValue === "string") return simpleValue; + if (typeof simpleValue === "number") + return String(simpleValue); const structuredValue = this.textOf(param); if (structuredValue !== undefined) return structuredValue; - if (typeof param === 'string') return param; + if (typeof param === "string") return param; return undefined; }; // Skip PredictThis in primary action loop as it was handled in pre-pass // unless we need a primary action and nothing else exists - if (commandId === 'Prediction.PredictThis') { - const wlParam = getRawParam('wordlist'); - const words = wlParam ? this._extractWordsFromWordList(wlParam) : []; + if (commandId === "Prediction.PredictThis") { + const wlParam = getRawParam("wordlist"); + const words = wlParam + ? this._extractWordsFromWordList(wlParam) + : []; if (words.length > 0) { predictionWords = words; } @@ -1199,22 +1299,23 @@ class GridsetProcessor extends BaseProcessor { semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.PLATFORM_SPECIFIC, - text: words.slice(0, 3).join(', '), + text: words.slice(0, 3).join(", "), platformData: { grid3: { commandId, parameters: { wordlist: words } }, }, - fallback: { type: 'ACTION', message: 'Predict words' }, + fallback: { type: "ACTION", message: "Predict words" }, }; } continue; } switch (commandId) { - case 'Jump.To': { - const gridTarget = getParam('grid'); + case "Jump.To": { + const gridTarget = getParam("grid"); if (gridTarget) { // Resolve grid name to grid ID for navigation - const targetGridId = gridNameToIdMap.get(gridTarget) || gridTarget; + const targetGridId = + gridNameToIdMap.get(gridTarget) || gridTarget; // Always set navigationTarget even if another command already // set semanticAction (e.g. Jump.SetBookmark + Jump.To). navigationTarget = targetGridId; @@ -1231,12 +1332,12 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId: targetGridId, }, }; _legacyAction = { - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId: targetGridId, }; } @@ -1244,7 +1345,7 @@ class GridsetProcessor extends BaseProcessor { break; } - case 'Jump.Back': + case "Jump.Back": if (!semanticAction) { semanticAction = { category: AACSemanticCategory.NAVIGATION, @@ -1256,19 +1357,20 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Go back', + type: "ACTION", + message: "Go back", }, }; _legacyAction = { - type: 'GO_BACK', + type: "GO_BACK", }; } break; - case 'Jump.Home': - case 'Jump.SetHome': - if (!navigationTarget) navigationTarget = tree.rootId || undefined; + case "Jump.Home": + case "Jump.SetHome": + if (!navigationTarget) + navigationTarget = tree.rootId || undefined; if (!semanticAction) { semanticAction = { category: AACSemanticCategory.NAVIGATION, @@ -1281,23 +1383,25 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Go home', + type: "ACTION", + message: "Go home", }, }; _legacyAction = { - type: 'GO_HOME', + type: "GO_HOME", }; } break; - case 'Jump.ToKeyboard': { + case "Jump.ToKeyboard": { // Prefer explicit keyboard page metadata when available. // Some Gridsets resolve the keyboard page in metadata // without preserving tree.keyboardGridName during parse. - const keyboardGridName = (tree as any).keyboardGridName as string; + const keyboardGridName = (tree as any) + .keyboardGridName as string; const keyboardPageId = - tree.metadata?.defaultKeyboardPageId || gridNameToIdMap.get(keyboardGridName); + tree.metadata?.defaultKeyboardPageId || + gridNameToIdMap.get(keyboardGridName); if (keyboardPageId && !navigationTarget) { navigationTarget = keyboardPageId; } @@ -1313,7 +1417,7 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId: keyboardPageId, }, }; @@ -1321,9 +1425,9 @@ class GridsetProcessor extends BaseProcessor { break; } - case 'Action.InsertTextAndSpeak': { + case "Action.InsertTextAndSpeak": { if (!semanticAction) { - const insertText = getParam('text'); + const insertText = getParam("text"); semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_IMMEDIATE, @@ -1335,7 +1439,7 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'SPEAK', + type: "SPEAK", message: insertText, }, }; @@ -1343,16 +1447,18 @@ class GridsetProcessor extends BaseProcessor { break; } - case 'Prediction.PredictThis': { - const wlParam = getRawParam('wordlist'); - const words = wlParam ? this._extractWordsFromWordList(wlParam) : []; + case "Prediction.PredictThis": { + const wlParam = getRawParam("wordlist"); + const words = wlParam + ? this._extractWordsFromWordList(wlParam) + : []; if (words.length > 0) { predictionWords = words; if (!semanticAction) { semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.PLATFORM_SPECIFIC, - text: words.slice(0, 3).join(', '), // Provide first few as preview + text: words.slice(0, 3).join(", "), // Provide first few as preview platformData: { grid3: { commandId, @@ -1360,8 +1466,8 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Predict words', + type: "ACTION", + message: "Predict words", }, }; } @@ -1370,10 +1476,10 @@ class GridsetProcessor extends BaseProcessor { continue; } - case 'Action.Speak': { + case "Action.Speak": { // speak - const speakUnit = getParam('unit'); - const moveCaret = getParam('movecaret'); + const speakUnit = getParam("unit"); + const moveCaret = getParam("movecaret"); if (!semanticAction) { semanticAction = { category: AACSemanticCategory.COMMUNICATION, @@ -1388,22 +1494,24 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'SPEAK', - message: 'Speak text', + type: "SPEAK", + message: "Speak text", }, }; _legacyAction = { - type: 'SPEAK', + type: "SPEAK", unit: speakUnit, - moveCaret: moveCaret ? parseInt(String(moveCaret)) : undefined, + moveCaret: moveCaret + ? parseInt(String(moveCaret)) + : undefined, }; } break; } - case 'Action.InsertText': { - const insertText = getParam('text'); - const posParam = getParam('pos'); + case "Action.InsertText": { + const insertText = getParam("text"); + const posParam = getParam("pos"); // Always extract POS even if semanticAction is already set if (posParam) { buttonPos = posParam; @@ -1420,19 +1528,19 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'SPEAK', + type: "SPEAK", message: insertText, }, }; _legacyAction = { - type: 'INSERT_TEXT', + type: "INSERT_TEXT", text: insertText, }; } break; } - case 'Action.DeleteWord': + case "Action.DeleteWord": if (!semanticAction) { semanticAction = { category: AACSemanticCategory.TEXT_EDITING, @@ -1444,17 +1552,17 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Delete word', + type: "ACTION", + message: "Delete word", }, }; _legacyAction = { - type: 'DELETE_WORD', + type: "DELETE_WORD", }; } break; - case 'Action.DeleteLetter': + case "Action.DeleteLetter": if (!semanticAction) { semanticAction = { category: AACSemanticCategory.TEXT_EDITING, @@ -1466,17 +1574,17 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Delete character', + type: "ACTION", + message: "Delete character", }, }; _legacyAction = { - type: 'DELETE_CHARACTER', + type: "DELETE_CHARACTER", }; } break; - case 'Action.Clear': + case "Action.Clear": // action if (!semanticAction) { semanticAction = { @@ -1489,19 +1597,19 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Clear text', + type: "ACTION", + message: "Clear text", }, }; _legacyAction = { - type: 'CLEAR_TEXT', + type: "CLEAR_TEXT", }; } break; - case 'Action.Letter': { + case "Action.Letter": { // action - const letter = getParam('letter'); + const letter = getParam("letter"); if (!semanticAction) { semanticAction = { category: AACSemanticCategory.TEXT_EDITING, @@ -1514,19 +1622,19 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', + type: "ACTION", message: letter, }, }; _legacyAction = { - type: 'INSERT_LETTER', + type: "INSERT_LETTER", letter, }; } break; } - case 'Settings.RestAll': + case "Settings.RestAll": // action if (!semanticAction) { semanticAction = { @@ -1536,25 +1644,25 @@ class GridsetProcessor extends BaseProcessor { grid3: { commandId, parameters: { - indicatorenabled: getParam('indicatorenabled'), - action: getParam('action'), + indicatorenabled: getParam("indicatorenabled"), + action: getParam("action"), }, }, }, fallback: { - type: 'ACTION', - message: 'Settings action', + type: "ACTION", + message: "Settings action", }, }; _legacyAction = { - type: 'SETTINGS', - indicatorEnabled: getParam('indicatorenabled') === '1', - settingsAction: getParam('action'), + type: "SETTINGS", + indicatorEnabled: getParam("indicatorenabled") === "1", + settingsAction: getParam("action"), }; } break; - case 'AutoContent.Activate': + case "AutoContent.Activate": // action if (!semanticAction) { semanticAction = { @@ -1564,18 +1672,18 @@ class GridsetProcessor extends BaseProcessor { grid3: { commandId, parameters: { - autocontenttype: getParam('autocontenttype'), + autocontenttype: getParam("autocontenttype"), }, }, }, fallback: { - type: 'ACTION', - message: 'Auto content', + type: "ACTION", + message: "Auto content", }, }; _legacyAction = { - type: 'AUTO_CONTENT', - autoContentType: getParam('autocontenttype'), + type: "AUTO_CONTENT", + autoContentType: getParam("autocontenttype"), }; } break; @@ -1584,7 +1692,7 @@ class GridsetProcessor extends BaseProcessor { // Unknown command - preserve as generic action if (commandId && !semanticAction) { const allParams = Object.fromEntries( - paramArr.map((p) => [p.Key || p.key, p['#text']]) + paramArr.map((p) => [p.Key || p.key, p["#text"]]), ); semanticAction = { category: AACSemanticCategory.CUSTOM, @@ -1596,8 +1704,8 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: 'ACTION', - message: 'Unknown command', + type: "ACTION", + message: "Unknown command", }, }; // legacy action not needed for unknown commands @@ -1618,14 +1726,14 @@ class GridsetProcessor extends BaseProcessor { intent: AACSemanticIntent.SPEAK_TEXT, text: String(message), fallback: { - type: 'SPEAK', + type: "SPEAK", message: String(message), }, }; } // Get style information from cell attributes and Content.Style - let cellStyleId = cell['@_StyleID'] || cell['@_styleid']; + let cellStyleId = cell["@_StyleID"] || cell["@_styleid"]; // Grid3 format: check Content.Style.BasedOnStyle if (!cellStyleId && content.Style?.BasedOnStyle) { @@ -1634,21 +1742,28 @@ class GridsetProcessor extends BaseProcessor { const cellStyle = this.getStyleById( styles, - cellStyleId ? String(cellStyleId) : undefined + cellStyleId ? String(cellStyleId) : undefined, ); // Also check for inline style overrides const inlineStyle: any = {}; - if (cell['@_BackColour']) inlineStyle.backgroundColor = cell['@_BackColour']; - if (cell['@_FontColour']) inlineStyle.fontColor = cell['@_FontColour']; - if (cell['@_BorderColour']) inlineStyle.borderColor = cell['@_BorderColour']; + if (cell["@_BackColour"]) + inlineStyle.backgroundColor = cell["@_BackColour"]; + if (cell["@_FontColour"]) + inlineStyle.fontColor = cell["@_FontColour"]; + if (cell["@_BorderColour"]) + inlineStyle.borderColor = cell["@_BorderColour"]; // Grid3 inline styles from Content.Style if (content.Style) { - if (content.Style.BackColour) inlineStyle.backgroundColor = content.Style.BackColour; - if (content.Style.FontColour) inlineStyle.fontColor = content.Style.FontColour; - if (content.Style.BorderColour) inlineStyle.borderColor = content.Style.BorderColour; - if (content.Style.FontName) inlineStyle.fontFamily = content.Style.FontName; + if (content.Style.BackColour) + inlineStyle.backgroundColor = content.Style.BackColour; + if (content.Style.FontColour) + inlineStyle.fontColor = content.Style.FontColour; + if (content.Style.BorderColour) + inlineStyle.borderColor = content.Style.BorderColour; + if (content.Style.FontName) + inlineStyle.fontFamily = content.Style.FontName; if (content.Style.FontSize) inlineStyle.fontSize = parseInt(String(content.Style.FontSize)); } @@ -1657,10 +1772,12 @@ class GridsetProcessor extends BaseProcessor { const grammar: Record = {}; if (buttonPos) grammar.pos = buttonPos; detectedCommands.forEach((cmd) => { - if (!grammar.pos && cmd.parameters.pos) grammar.pos = cmd.parameters.pos; + if (!grammar.pos && cmd.parameters.pos) + grammar.pos = cmd.parameters.pos; if (cmd.parameters.person) grammar.person = cmd.parameters.person; if (cmd.parameters.number) grammar.number = cmd.parameters.number; - if (cmd.parameters.feature) grammar.feature = cmd.parameters.feature; + if (cmd.parameters.feature) + grammar.feature = cmd.parameters.feature; }); const isSmartGrammarCell = Object.keys(grammar).length > 0; const effectivePos = buttonPos || grammar.pos || undefined; @@ -1669,7 +1786,9 @@ class GridsetProcessor extends BaseProcessor { id: `${gridId}_btn_${idx}`, label: String(label), message: String(message), - targetPageId: navigationTarget ? String(navigationTarget) : undefined, + targetPageId: navigationTarget + ? String(navigationTarget) + : undefined, semanticAction: semanticAction, semantic_id: cell.semantic_id || cell.SemanticId || undefined, // Extract semantic_id if present image: declaredImageName, @@ -1681,12 +1800,12 @@ class GridsetProcessor extends BaseProcessor { scanBlock: scanBlock, // Add scan block number for block scanning metrics contentType: pluginMetadata.cellType === Grid3CellType.Regular - ? 'Normal' + ? "Normal" : pluginMetadata.cellType === Grid3CellType.Workspace - ? 'Workspace' + ? "Workspace" : pluginMetadata.cellType === Grid3CellType.LiveCell - ? 'LiveCell' - : 'AutoContent', + ? "LiveCell" + : "AutoContent", contentSubType: pluginMetadata.subType || pluginMetadata.liveCellType || @@ -1718,7 +1837,7 @@ class GridsetProcessor extends BaseProcessor { : undefined, predictionSlot: pluginMetadata.cellType === Grid3CellType.AutoContent && - pluginMetadata.autoContentType === 'Prediction' + pluginMetadata.autoContentType === "Prediction" ? predictionCellCounter : undefined, // Store page name for Grid3 image lookup @@ -1727,12 +1846,14 @@ class GridsetProcessor extends BaseProcessor { isMoreButton: isMoreButton || undefined, wordListItemIndex: pluginMetadata.cellType === Grid3CellType.AutoContent && - pluginMetadata.autoContentType === 'WordList' && + pluginMetadata.autoContentType === "WordList" && !isMoreButton ? wordListCellIndex - 1 : undefined, // Store binary image data for conversion to other formats - ...(imageData ? { imageData, image_id: resolvedImageEntry } : {}), + ...(imageData + ? { imageData, image_id: resolvedImageEntry } + : {}), }, }); @@ -1760,7 +1881,13 @@ class GridsetProcessor extends BaseProcessor { row.forEach((btn, colIndex) => { if (btn) { // Generate clone_id based on position and label - btn.clone_id = generateCloneId(maxRows, maxCols, rowIndex, colIndex, btn.label); + btn.clone_id = generateCloneId( + maxRows, + maxCols, + rowIndex, + colIndex, + btn.label, + ); cloneIds.push(btn.clone_id); // Track semantic_id if present @@ -1788,7 +1915,10 @@ class GridsetProcessor extends BaseProcessor { for (const pageId in tree.pages) { const page = tree.pages[pageId]; page.buttons.forEach((btn: AACButton) => { - if (btn.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && btn.targetPageId) { + if ( + btn.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && + btn.targetPageId + ) { const targetPage = tree.getPage(btn.targetPageId); if (targetPage) { targetPage.parentId = page.id; @@ -1799,7 +1929,9 @@ class GridsetProcessor extends BaseProcessor { // Read settings.xml to get the StartGrid (home page) try { - const settingsEntry = entries.find((e) => e.entryName.endsWith('settings.xml')); + const settingsEntry = entries.find((e) => + e.entryName.endsWith("settings.xml"), + ); if (settingsEntry) { const settingsXml = decodeText(await readEntryBuffer(settingsEntry)); const settingsData = parser.parse(settingsXml); @@ -1819,7 +1951,7 @@ class GridsetProcessor extends BaseProcessor { settingsData?.GridSetSettings?.PrimaryLanguage || settingsData?.gridSetSettings?.primaryLanguage || settingsData?.GridsetSettings?.PrimaryLanguage; - if (gsLang && typeof gsLang === 'string') { + if (gsLang && typeof gsLang === "string") { metadata.locale = gsLang; metadata.languages = [gsLang]; } @@ -1858,9 +1990,12 @@ class GridsetProcessor extends BaseProcessor { if (thumbBg) metadata.thumbnailBackground = thumbBg; const picSearchKeys = - settingsData?.GridSetSettings?.PictureSearch?.PictureSearchKeys?.PictureSearchKey || - settingsData?.gridSetSettings?.pictureSearch?.pictureSearchKeys?.pictureSearchKey || - settingsData?.GridsetSettings?.PictureSearch?.PictureSearchKeys?.PictureSearchKey; + settingsData?.GridSetSettings?.PictureSearch?.PictureSearchKeys + ?.PictureSearchKey || + settingsData?.gridSetSettings?.pictureSearch?.pictureSearchKeys + ?.pictureSearchKey || + settingsData?.GridsetSettings?.PictureSearch?.PictureSearchKeys + ?.PictureSearchKey; if (picSearchKeys) { metadata.pictureSearchKeys = Array.isArray(picSearchKeys) ? picSearchKeys @@ -1874,8 +2009,8 @@ class GridsetProcessor extends BaseProcessor { if (appearance) { metadata.appearance = { textAtTop: - appearance.TextAtTop === '1' || - appearance.textAtTop === '1' || + appearance.TextAtTop === "1" || + appearance.textAtTop === "1" || appearance.TextAtTop === 1, computerControlCellSize: appearance.ComputerControlCellSize ? parseFloat(String(appearance.ComputerControlCellSize)) @@ -1888,7 +2023,7 @@ class GridsetProcessor extends BaseProcessor { settingsData?.gridSetSettings?.startGrid || settingsData?.GridsetSettings?.StartGrid; - if (startGridName && typeof startGridName === 'string') { + if (startGridName && typeof startGridName === "string") { // Resolve the grid name to grid ID const homeGridId = gridNameToIdMap.get(startGridName); if (homeGridId) { @@ -1902,9 +2037,10 @@ class GridsetProcessor extends BaseProcessor { settingsData?.GridSetSettings?.KeyboardGrid || settingsData?.gridSetSettings?.keyboardGrid || settingsData?.GridsetSettings?.KeyboardGrid; - if (keyboardGridName && typeof keyboardGridName === 'string') { + if (keyboardGridName && typeof keyboardGridName === "string") { (tree as any).keyboardGridName = keyboardGridName; - metadata.defaultKeyboardPageId = gridNameToIdMap.get(keyboardGridName); + metadata.defaultKeyboardPageId = + gridNameToIdMap.get(keyboardGridName); } } } catch (_e) { @@ -1917,14 +2053,16 @@ class GridsetProcessor extends BaseProcessor { Object.values(tree.pages).forEach((page) => { page.buttons.forEach((button) => { if ( - button?.semanticAction?.platformData?.grid3?.commandId === 'Jump.ToKeyboard' && + button?.semanticAction?.platformData?.grid3?.commandId === + "Jump.ToKeyboard" && !button.targetPageId ) { button.targetPageId = metadata.defaultKeyboardPageId; if (button.semanticAction) { button.semanticAction.targetId = metadata.defaultKeyboardPageId; - if (button.semanticAction.fallback?.type === 'NAVIGATE') { - button.semanticAction.fallback.targetPageId = metadata.defaultKeyboardPageId; + if (button.semanticAction.fallback?.type === "NAVIGATE") { + button.semanticAction.fallback.targetPageId = + metadata.defaultKeyboardPageId; } } } @@ -1938,7 +2076,7 @@ class GridsetProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, ): Promise { const { readBinaryFromInput } = this.options.fileAdapter; // Load the tree, apply translations, and save to new file @@ -1971,7 +2109,11 @@ class GridsetProcessor extends BaseProcessor { if (symbols && symbols.length > 0) { // Use symbol-aware translation to preserve symbol positions - const result = translateWithSymbols(originalMessage, translatedText, symbols); + const result = translateWithSymbols( + originalMessage, + translatedText, + symbols, + ); // Update the message button.message = result.text; @@ -2017,7 +2159,9 @@ class GridsetProcessor extends BaseProcessor { * @param filePathOrBuffer - Path to gridset file or buffer * @returns Promise resolving to symbol information for LLM processing */ - async extractSymbolsForLLM(filePathOrBuffer: string | Buffer): Promise { + async extractSymbolsForLLM( + filePathOrBuffer: string | Buffer, + ): Promise { const tree = await this.loadIntoTree(filePathOrBuffer); // Collect all buttons from all pages @@ -2054,13 +2198,15 @@ class GridsetProcessor extends BaseProcessor { filePathOrBuffer: string | Buffer, llmTranslations: LLMLTranslationResult[], outputPath: string, - options?: { allowPartial?: boolean } + options?: { allowPartial?: boolean }, ): Promise { const { readBinaryFromInput } = this.options.fileAdapter; const tree = await this.loadIntoTree(filePathOrBuffer); // Validate translations using shared utility - const buttonIds = Object.values(tree.pages).flatMap((page) => page.buttons.map((b) => b.id)); + const buttonIds = Object.values(tree.pages).flatMap((page) => + page.buttons.map((b) => b.id), + ); validateTranslationResults(llmTranslations, buttonIds, options); // Create a map for quick lookup @@ -2130,7 +2276,7 @@ class GridsetProcessor extends BaseProcessor { // Helper function to add style and return its ID const addStyle = (style: AACStyle | undefined): string => { - if (!style) return ''; + if (!style) return ""; const normalizedStyle: AACStyle = { ...style }; const styleKey = JSON.stringify(normalizedStyle); const existing = uniqueStyles.get(styleKey); @@ -2151,7 +2297,7 @@ class GridsetProcessor extends BaseProcessor { // Get the home/start grid from tree.rootId, fallback to first page const pages = Object.values(tree.pages); - let startGrid = ''; + let startGrid = ""; if (tree.rootId) { const homePage = tree.getPage(tree.rootId); @@ -2167,64 +2313,68 @@ class GridsetProcessor extends BaseProcessor { // Create Settings0/settings.xml with proper Grid3 structure const settingsData = { - '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, + "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, GridSetSettings: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - Name: tree.metadata?.name || '', - Description: tree.metadata?.description || '', - Author: tree.metadata?.author || '', - PrimaryLanguage: tree.metadata?.locale || 'en-US', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + Name: tree.metadata?.name || "", + Description: tree.metadata?.description || "", + Author: tree.metadata?.author || "", + PrimaryLanguage: tree.metadata?.locale || "en-US", StartGrid: startGrid, // Add other common Grid3 settings - Thumbnail: (tree.metadata as any)?.thumbnail || '', - ThumbnailBackground: (tree.metadata as any)?.thumbnailBackground || '', - DocumentationUrl: tree.metadata?.homepageUrl || tree.metadata?.url || '', - DocumentationSlug: (tree.metadata as any)?.documentationSlug || '', - ScanEnabled: 'false', - ScanTimeoutMs: '2000', - HoverEnabled: 'false', - HoverTimeoutMs: '1000', - MouseclickEnabled: 'true', - Language: tree.metadata?.locale || 'en-US', + Thumbnail: (tree.metadata as any)?.thumbnail || "", + ThumbnailBackground: (tree.metadata as any)?.thumbnailBackground || "", + DocumentationUrl: + tree.metadata?.homepageUrl || tree.metadata?.url || "", + DocumentationSlug: (tree.metadata as any)?.documentationSlug || "", + ScanEnabled: "false", + ScanTimeoutMs: "2000", + HoverEnabled: "false", + HoverTimeoutMs: "1000", + MouseclickEnabled: "true", + Language: tree.metadata?.locale || "en-US", }, }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", suppressEmptyNode: true, }); const settingsXmlContent = settingsBuilder.build(settingsData); files.push({ - name: 'Settings0/settings.xml', + name: "Settings0/settings.xml", data: settingsXmlContent, }); // Create Settings0/Styles/style.xml if there are styles if (uniqueStyles.size > 0) { - const stylesArray = Array.from(uniqueStyles.values()).map(({ id, style }) => { - const styleObj = { - '@_Key': id, - // When TileColour is present, BackColour is the surround (outer area) - // For "None" surround, just use BackColour for the fill (no TileColour) - BackColour: this.ensureAlphaChannel(style.backgroundColor), - BorderColour: this.ensureAlphaChannel(style.borderColor), - // Calculate font color based on background if not explicitly set - FontColour: this.ensureAlphaChannel( - style.fontColor || this.getContrastFontColor(style.backgroundColor) - ), - FontName: style.fontFamily || 'Arial', - FontSize: style.fontSize?.toString() || '16', - }; - // Don't add TileColour - just use BackColour as the fill color - return styleObj; - }); + const stylesArray = Array.from(uniqueStyles.values()).map( + ({ id, style }) => { + const styleObj = { + "@_Key": id, + // When TileColour is present, BackColour is the surround (outer area) + // For "None" surround, just use BackColour for the fill (no TileColour) + BackColour: this.ensureAlphaChannel(style.backgroundColor), + BorderColour: this.ensureAlphaChannel(style.borderColor), + // Calculate font color based on background if not explicitly set + FontColour: this.ensureAlphaChannel( + style.fontColor || + this.getContrastFontColor(style.backgroundColor), + ), + FontName: style.fontFamily || "Arial", + FontSize: style.fontSize?.toString() || "16", + }; + // Don't add TileColour - just use BackColour as the fill color + return styleObj; + }, + ); const styleData = { - '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, + "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, StyleData: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", Styles: { Style: stylesArray, }, @@ -2234,11 +2384,11 @@ class GridsetProcessor extends BaseProcessor { const styleBuilder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", }); const styleXmlContent = styleBuilder.build(styleData); files.push({ - name: 'Settings0/Styles/styles.xml', + name: "Settings0/Styles/styles.xml", data: styleXmlContent, }); } @@ -2250,146 +2400,168 @@ class GridsetProcessor extends BaseProcessor { Object.values(tree.pages).forEach((page) => { const gridData = { Grid: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", GridGuid: page.id, // Calculate grid dimensions based on actual layout ColumnDefinitions: this.calculateColumnDefinitions(page), RowDefinitions: this.calculateRowDefinitions(page, false), // No automatic workspace row injection - AutoContentCommands: '', + AutoContentCommands: "", Cells: page.buttons.length > 0 ? { Cell: [ // Regular button cells - ...this.filterPageButtons(page.buttons).map((button, btnIndex) => { - const buttonStyleId = button.style ? addStyle(button.style) : ''; - - // Find button position in grid layout - const position = this.findButtonPosition(page, button, btnIndex); - - // Use position directly from tree - const yOffset = 0; - - // Build CaptionAndImage object - const captionAndImage: Record = { - Caption: button.label || '', - }; + ...this.filterPageButtons(page.buttons).map( + (button, btnIndex) => { + const buttonStyleId = button.style + ? addStyle(button.style) + : ""; + + // Find button position in grid layout + const position = this.findButtonPosition( + page, + button, + btnIndex, + ); + + // Use position directly from tree + const yOffset = 0; + + // Build CaptionAndImage object + const captionAndImage: Record = { + Caption: button.label || "", + }; - // Add image reference if button has an image - // Grid3 uses coordinate-based naming: {x}-{y}-0-text-0.{ext} - if (button.image) { - // Try to determine file extension from image name or default to PNG - let imageExt = 'png'; - const imageMatch = button.image.match(/\.(png|jpg|jpeg|gif|svg)$/i); - if (imageMatch) { - imageExt = imageMatch[1].toLowerCase(); - } + // Add image reference if button has an image + // Grid3 uses coordinate-based naming: {x}-{y}-0-text-0.{ext} + if (button.image) { + // Try to determine file extension from image name or default to PNG + let imageExt = "png"; + const imageMatch = button.image.match( + /\.(png|jpg|jpeg|gif|svg)$/i, + ); + if (imageMatch) { + imageExt = imageMatch[1].toLowerCase(); + } - // Extract image data from button parameters if available - // (AstericsGridProcessor stores it there during loadIntoTree) - // Also handle data URLs from OBZ conversion - let imageData = Buffer.alloc(0); - let hasImageData = false; - - if ( - button.parameters && - button.parameters.imageData && - Buffer.isBuffer(button.parameters.imageData) - ) { - imageData = button.parameters.imageData as any; - hasImageData = imageData.length > 0; - } else if ( - button.image && - typeof button.image === 'string' && - button.image.startsWith('data:image') - ) { - // Convert data URL to Buffer (for OBZ → Grid3 conversion) - try { - const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/); - if (matches) { - const extension = matches[1]; // e.g., 'png', 'jpeg', 'gif' - const base64Data = matches[2]; - imageData = Buffer.from(base64Data, 'base64'); - imageExt = extension; // Override the detected extension - hasImageData = imageData.length > 0; + // Extract image data from button parameters if available + // (AstericsGridProcessor stores it there during loadIntoTree) + // Also handle data URLs from OBZ conversion + let imageData = Buffer.alloc(0); + let hasImageData = false; + + if ( + button.parameters && + button.parameters.imageData && + Buffer.isBuffer(button.parameters.imageData) + ) { + imageData = button.parameters.imageData as any; + hasImageData = imageData.length > 0; + } else if ( + button.image && + typeof button.image === "string" && + button.image.startsWith("data:image") + ) { + // Convert data URL to Buffer (for OBZ → Grid3 conversion) + try { + const matches = button.image.match( + /^data:image\/(\w+);base64,(.+)$/, + ); + if (matches) { + const extension = matches[1]; // e.g., 'png', 'jpeg', 'gif' + const base64Data = matches[2]; + imageData = Buffer.from(base64Data, "base64"); + imageExt = extension; // Override the detected extension + hasImageData = imageData.length > 0; + } + } catch (err) { + console.warn( + `[Grid3] Failed to convert data URL to Buffer for button ${button.id}:`, + err, + ); } - } catch (err) { - console.warn( - `[Grid3] Failed to convert data URL to Buffer for button ${button.id}:`, - err - ); } - } - // Only add image reference if we have actual image data - if (hasImageData) { - // Grid3 dynamically constructs image filenames by prepending cell coordinates - // The XML should only contain the suffix: -0-text-0.{ext} - // Grid3 automatically adds the X-Y prefix based on the Cell's position - captionAndImage.Image = `-0-text-0.${imageExt}`; - - // Store image data for later writing to ZIP - buttonImages.set(button.id, { - imageData: imageData, - ext: imageExt, - pageName: page.name || page.id, - x: position.x, - y: position.y + yOffset, - }); + // Only add image reference if we have actual image data + if (hasImageData) { + // Grid3 dynamically constructs image filenames by prepending cell coordinates + // The XML should only contain the suffix: -0-text-0.{ext} + // Grid3 automatically adds the X-Y prefix based on the Cell's position + captionAndImage.Image = `-0-text-0.${imageExt}`; + + // Store image data for later writing to ZIP + buttonImages.set(button.id, { + imageData: imageData, + ext: imageExt, + pageName: page.name || page.id, + x: position.x, + y: position.y + yOffset, + }); + } } - } - const cellData: Record = { - '@_X': position.x + 1, // Grid3 uses 1-based X coordinates - '@_Y': position.y + yOffset + 1, // Grid3 uses 1-based Y coordinates with workspace offset - '@_ColumnSpan': position.columnSpan, - '@_RowSpan': position.rowSpan, - Content: { - ContentType: - button.contentType === 'Normal' ? undefined : button.contentType, - ContentSubType: button.contentSubType, - Commands: this.generateCommandsFromSemanticAction(button, tree), - CaptionAndImage: captionAndImage, - }, - }; + const cellData: Record = { + "@_X": position.x + 1, // Grid3 uses 1-based X coordinates + "@_Y": position.y + yOffset + 1, // Grid3 uses 1-based Y coordinates with workspace offset + "@_ColumnSpan": position.columnSpan, + "@_RowSpan": position.rowSpan, + Content: { + ContentType: + button.contentType === "Normal" + ? undefined + : button.contentType, + ContentSubType: button.contentSubType, + Commands: this.generateCommandsFromSemanticAction( + button, + tree, + ), + CaptionAndImage: captionAndImage, + }, + }; - // Add style reference and inline color overrides if available - // Some Grid3 versions need inline colors in addition to style references - if (buttonStyleId || button.style) { - const styleObj: any = {}; + // Add style reference and inline color overrides if available + // Some Grid3 versions need inline colors in addition to style references + if (buttonStyleId || button.style) { + const styleObj: any = {}; - // Add style reference if we have one - if (buttonStyleId) { - styleObj.BasedOnStyle = buttonStyleId; - } + // Add style reference if we have one + if (buttonStyleId) { + styleObj.BasedOnStyle = buttonStyleId; + } - // Add inline color overrides for better Grid3 compatibility - if (button.style?.backgroundColor) { - // Use BackColour for fill (no TileColour means no surround, just the fill) - styleObj.BackColour = this.ensureAlphaChannel( - button.style.backgroundColor - ); - } - if (button.style?.borderColor) { - styleObj.BorderColour = this.ensureAlphaChannel(button.style.borderColor); - } - // Always add font color inline - either from button style or calculated from background - const fontColor = - button.style?.fontColor || - this.getContrastFontColor(button.style?.backgroundColor); - styleObj.FontColour = this.ensureAlphaChannel(fontColor); - if (button.style?.fontFamily) { - styleObj.FontName = button.style.fontFamily; - } - if (button.style?.fontSize) { - styleObj.FontSize = button.style.fontSize; - } + // Add inline color overrides for better Grid3 compatibility + if (button.style?.backgroundColor) { + // Use BackColour for fill (no TileColour means no surround, just the fill) + styleObj.BackColour = this.ensureAlphaChannel( + button.style.backgroundColor, + ); + } + if (button.style?.borderColor) { + styleObj.BorderColour = this.ensureAlphaChannel( + button.style.borderColor, + ); + } + // Always add font color inline - either from button style or calculated from background + const fontColor = + button.style?.fontColor || + this.getContrastFontColor( + button.style?.backgroundColor, + ); + styleObj.FontColour = + this.ensureAlphaChannel(fontColor); + if (button.style?.fontFamily) { + styleObj.FontName = button.style.fontFamily; + } + if (button.style?.fontSize) { + styleObj.FontSize = button.style.fontSize; + } - (cellData as any).Content.Style = styleObj; - } + (cellData as any).Content.Style = styleObj; + } - return cellData; - }), + return cellData; + }, + ), ], } : { Cell: [] }, @@ -2400,9 +2572,9 @@ class GridsetProcessor extends BaseProcessor { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", suppressEmptyNode: true, - cdataPropName: '__cdata', + cdataPropName: "__cdata", }); const xmlContent = builder.build(gridData); @@ -2429,26 +2601,30 @@ class GridsetProcessor extends BaseProcessor { // Create FileMap.xml to map all grid files with their dynamic image files const fileMapData = { - '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, + "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, FileMap: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", Entries: { Entry: gridFilePaths.map((gridPath) => { // Find all image files for this grid - const gridName = gridPath.match(/Grids\/([^/]+)\/grid\.xml$/)?.[1] || ''; + const gridName = + gridPath.match(/Grids\/([^/]+)\/grid\.xml$/)?.[1] || ""; const imageFiles: string[] = []; // Collect image filenames for buttons on this page // IMPORTANT: FileMap.xml requires full paths like "Grids/PageName/1-5-0-text-0.png" buttonImages.forEach((imgData) => { - if (imgData.pageName === gridName && imgData.imageData.length > 0) { + if ( + imgData.pageName === gridName && + imgData.imageData.length > 0 + ) { const imagePath = `Grids/${gridName}/${imgData.x}-${imgData.y}-0-text-0.${imgData.ext}`; imageFiles.push(imagePath); } }); return { - '@_StaticFile': gridPath, + "@_StaticFile": gridPath, DynamicFiles: imageFiles.length > 0 ? { @@ -2464,11 +2640,11 @@ class GridsetProcessor extends BaseProcessor { const fileMapBuilder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", }); const fileMapXmlContent = fileMapBuilder.build(fileMapData); files.push({ - name: 'FileMap.xml', + name: "FileMap.xml", data: fileMapXmlContent, }); @@ -2481,39 +2657,15 @@ class GridsetProcessor extends BaseProcessor { private calculateColumnDefinitions(page: AACPage): { ColumnDefinition: any[]; } { - let maxCols = 4; // Default minimum - - if (page.grid && page.grid.length > 0) { - maxCols = Math.max(maxCols, page.grid[0]?.length || 0); - } else { - // Fallback: estimate from button count - maxCols = Math.max(4, Math.ceil(Math.sqrt(page.buttons.length))); - } - - return { - ColumnDefinition: Array(maxCols).fill({}), - }; + return calcColumnDefs(page); } // Helper method to calculate row definitions based on page layout private calculateRowDefinitions( page: AACPage, - addWorkspaceOffset = false + addWorkspaceOffset = false, ): { RowDefinition: any[] } { - let maxRows = 4; // Default minimum - const offset = addWorkspaceOffset ? 1 : 0; - - if (page.grid && page.grid.length > 0) { - maxRows = Math.max(maxRows, page.grid.length + offset); - } else { - // Fallback: estimate from button count - const estimatedCols = Math.ceil(Math.sqrt(page.buttons.length)); - maxRows = Math.max(4, Math.ceil(page.buttons.length / estimatedCols)) + offset; - } - - return { - RowDefinition: Array(maxRows).fill({}), - }; + return calcRowDefs(page, addWorkspaceOffset); } /** @@ -2525,7 +2677,11 @@ class GridsetProcessor extends BaseProcessor { * @param tree - Modified AACTree with pages to save * @param outputPath - Path where the modified gridset should be saved */ - async saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise { + async saveModifiedTree( + originalPath: string, + tree: AACTree, + outputPath: string, + ): Promise { const { readBinaryFromInput, writeBinaryToPath } = this.options.fileAdapter; if (Object.keys(tree.pages).length === 0) { @@ -2535,7 +2691,7 @@ class GridsetProcessor extends BaseProcessor { return; } - const AdmZip = (await import('adm-zip')).default; + const AdmZip = (await import("adm-zip")).default; const originalZip = new AdmZip(originalPath); const outputZip = new AdmZip(); @@ -2554,12 +2710,12 @@ class GridsetProcessor extends BaseProcessor { // Create XML parser and builder const parser = new XMLParser({ ignoreAttributes: false, - attributeNamePrefix: '@_', + attributeNamePrefix: "@_", }); const gridBuilder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", suppressEmptyNode: true, // Preserve Grid 3 XML formatting requirements suppressBooleanAttributes: false, @@ -2580,7 +2736,7 @@ class GridsetProcessor extends BaseProcessor { } // Parse the original grid XML - const originalContent = originalEntry.getData().toString('utf-8'); + const originalContent = originalEntry.getData().toString("utf-8"); const originalGrid = parser.parse(originalContent); if (!originalGrid.Grid) { @@ -2601,27 +2757,35 @@ class GridsetProcessor extends BaseProcessor { // Update cells in the original grid const originalCells = originalGrid.Grid.Cells?.Cell; if (originalCells) { - const cellArray = Array.isArray(originalCells) ? originalCells : [originalCells]; + const cellArray = Array.isArray(originalCells) + ? originalCells + : [originalCells]; for (const cell of cellArray) { if (!cell.Content) continue; // Get cell position - const x = parseInt(String(cell['@_X'] || cell['@_Column'] || '0'), 10); - const y = parseInt(String(cell['@_Y'] || cell['@_Row'] || '0'), 10); + const x = parseInt( + String(cell["@_X"] || cell["@_Column"] || "0"), + 10, + ); + const y = parseInt(String(cell["@_Y"] || cell["@_Row"] || "0"), 10); const key = `${x},${y}`; // Check if there's a modified button for this position const modifiedButton = buttonsByPosition.get(key); if (modifiedButton) { // Check if this is an AutoContent/WordList cell - const contentType = cell.Content.ContentType || cell.Content.contentType; - const contentSubType = cell.Content.ContentSubType || cell.Content.contentsubtype; + const contentType = + cell.Content.ContentType || cell.Content.contentType; + const contentSubType = + cell.Content.ContentSubType || cell.Content.contentsubtype; - const isWordListCell = contentType === 'AutoContent' && contentSubType === 'WordList'; + const isWordListCell = + contentType === "AutoContent" && contentSubType === "WordList"; const isPredictionCell = - contentType === 'AutoContent' && contentSubType === 'Prediction'; + contentType === "AutoContent" && contentSubType === "Prediction"; if (isWordListCell) { // For WordList cells, we need to add the word to the page's WordList @@ -2641,23 +2805,27 @@ class GridsetProcessor extends BaseProcessor { // For regular cells, update the caption directly // CDATA wrapping for empty captions will be done in post-processing if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) { - const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage; + const captionAndImage = + cell.Content.CaptionAndImage || cell.Content.captionAndImage; // Check if the label is a placeholder (generated during extraction) const isPlaceholderLabel = !modifiedButton.label || - modifiedButton.label.startsWith('Cell_') || - modifiedButton.label.startsWith('AutoContent_') || - modifiedButton.label.startsWith('Prediction '); + modifiedButton.label.startsWith("Cell_") || + modifiedButton.label.startsWith("AutoContent_") || + modifiedButton.label.startsWith("Prediction "); if (!isPlaceholderLabel) { // Only update caption with real content, not placeholders captionAndImage.Caption = modifiedButton.label; // Remove xsi:nil attribute when adding content - if (captionAndImage['@_xsi:nil'] || captionAndImage['xsi:nil']) { - delete captionAndImage['@_xsi:nil']; - delete captionAndImage['xsi:nil']; + if ( + captionAndImage["@_xsi:nil"] || + captionAndImage["xsi:nil"] + ) { + delete captionAndImage["@_xsi:nil"]; + delete captionAndImage["xsi:nil"]; } } } @@ -2666,9 +2834,9 @@ class GridsetProcessor extends BaseProcessor { // But skip placeholder labels const isPlaceholderMessage = !modifiedButton.message || - modifiedButton.message.startsWith('Cell_') || - modifiedButton.message.startsWith('AutoContent_') || - modifiedButton.message.startsWith('Prediction '); + modifiedButton.message.startsWith("Cell_") || + modifiedButton.message.startsWith("AutoContent_") || + modifiedButton.message.startsWith("Prediction "); if ( !isPlaceholderMessage && @@ -2677,13 +2845,16 @@ class GridsetProcessor extends BaseProcessor { ) { // For simple text content if (!cell.Content.Commands) { - cell.Content['#text'] = modifiedButton.message; + cell.Content["#text"] = modifiedButton.message; } } // Update image if present if (modifiedButton.image) { - if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) { + if ( + cell.Content.CaptionAndImage || + cell.Content.captionAndImage + ) { const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage; captionAndImage.Image = modifiedButton.image; @@ -2693,6 +2864,10 @@ class GridsetProcessor extends BaseProcessor { } } + // DO NOT create new cells - the system should only modify existing content + // Personalized vocabulary is added to WordList cells via the WordList.Items array + // Creating new cells would corrupt the grid structure + // Update the page's WordList with new words from modified buttons // Collect all modified buttons that should be added to the WordList const newWordListItems: any[] = []; @@ -2708,30 +2883,49 @@ class GridsetProcessor extends BaseProcessor { : []; const cell = cellArray.find((c: any) => { - const cellX = parseInt(String(c['@_X'] || '0'), 10); - const cellY = parseInt(String(c['@_Y'] || '0'), 10); - return cellX === pos.x && cellY === pos.y; + const cellY = parseInt(String(c["@_Y"] || c["@_Row"] || "0"), 10); + // Check Y position first + if (cellY !== pos.y) { + return false; + } + + const cellX = + c["@_X"] !== undefined ? parseInt(String(c["@_X"]), 10) : undefined; + + // If cell has no X attribute (full-width cell), it matches any button at this Y + if (cellX === undefined) { + return true; + } + + // Otherwise, check exact X match + return cellX === pos.x; }); if (cell) { - const contentType = cell.Content?.ContentType || cell.Content?.contentType; - const contentSubType = cell.Content?.ContentSubType || cell.Content?.contentsubtype; + const contentType = + cell.Content?.ContentType || cell.Content?.contentType; + const contentSubType = + cell.Content?.ContentSubType || cell.Content?.contentsubtype; - const isWordListCell = contentType === 'AutoContent' && contentSubType === 'WordList'; + const isWordListCell = + contentType === "AutoContent" && contentSubType === "WordList"; // Note: Prediction cells are already skipped earlier, so they won't reach here if (isWordListCell) { // Add this button to the WordList with proper Grid 3 format - // Format: label + // Format:

label

+ // Note:

wrapper is required by Grid 3's WordList format newWordListItems.push({ Text: { - s: { - r: button.label, + p: { + s: { + r: button.label, + }, }, }, - Image: '', // No image for user-added words - PartOfSpeech: 'Unknown', + Image: "", // No image for user-added words + PartOfSpeech: "Unknown", }); } } @@ -2742,8 +2936,12 @@ class GridsetProcessor extends BaseProcessor { const existingWordList = originalGrid.Grid.WordList; if (existingWordList && existingWordList.Items) { const existingItems = - existingWordList.Items.WordListItem || existingWordList.Items.wordlistitem || []; - const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems]; + existingWordList.Items.WordListItem || + existingWordList.Items.wordlistitem || + []; + const itemsArray = Array.isArray(existingItems) + ? existingItems + : [existingItems]; // Merge existing and new items const allItems = [...itemsArray, ...newWordListItems]; @@ -2759,23 +2957,9 @@ class GridsetProcessor extends BaseProcessor { } } - // Build the updated grid XML and convert to Windows line endings + // Build the updated grid XML and format for Grid 3 compatibility let builtXml = gridBuilder.build(originalGrid); - // Convert Unix line endings to Windows (\r\n) for Grid 3 compatibility - builtXml = builtXml.replace(/\n/g, '\r\n'); - // Expand self-closing tags to full opening/closing tags for Grid 3 compatibility - // Grid 3 cannot parse - it requires - builtXml = builtXml.replace(/<(\w+)(\s+[^>]*)?\s*\/>/g, '<$1$2>'); - // Convert empty/whitespace captions to CDATA format for Grid 3 compatibility - // Grid 3 requires for empty captions, not plain text - builtXml = builtXml.replace(/<\/Caption>/g, ''); - builtXml = builtXml.replace(/ <\/Caption>/g, ''); - builtXml = builtXml.replace(/ {2}<\/Caption>/g, ''); - // Preserve CDATA in tags for text parameters - // Spaces in tags must use CDATA or they get stripped during rendering - // e.g., becomes - builtXml = builtXml.replace(/ <\/r>/g, ''); - builtXml = builtXml.replace(/ {2}<\/r>/g, ''); + builtXml = formatGrid3XmlComplete(builtXml); newGridFiles.set(gridPath, builtXml); } @@ -2787,7 +2971,7 @@ class GridsetProcessor extends BaseProcessor { if (modifiedGridFiles.has(entry.entryName)) { const newContent = newGridFiles.get(entry.entryName); if (newContent) { - outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8')); + outputZip.addFile(entry.entryName, Buffer.from(newContent, "utf8")); } continue; } @@ -2807,40 +2991,47 @@ class GridsetProcessor extends BaseProcessor { private createBasicGridXml(page: AACPage): string { const gridData = { Grid: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", GridGuid: page.id, ColumnDefinitions: this.calculateColumnDefinitions(page), RowDefinitions: this.calculateRowDefinitions(page, false), - AutoContentCommands: '', + AutoContentCommands: "", Cells: page.buttons.length > 0 ? { - Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => { - const position = this.findButtonPosition(page, button, btnIndex); - - const cell: Record = { - '@_X': position.x, - '@_Y': position.y, - Content: { - CaptionAndImage: { - Caption: button.label || '', + Cell: this.filterPageButtons(page.buttons).map( + (button, btnIndex) => { + const position = this.findButtonPosition( + page, + button, + btnIndex, + ); + + const cell: Record = { + "@_X": position.x, + "@_Y": position.y, + Content: { + CaptionAndImage: { + Caption: button.label || "", + }, }, - }, - }; + }; - if (button.image) { - (cell.Content as any).CaptionAndImage.Image = button.image; - } + if (button.image) { + (cell.Content as any).CaptionAndImage.Image = + button.image; + } - if (position.columnSpan > 1) { - cell['@_ColumnSpan'] = position.columnSpan; - } - if (position.rowSpan > 1) { - cell['@_RowSpan'] = position.rowSpan; - } + if (position.columnSpan > 1) { + cell["@_ColumnSpan"] = position.columnSpan; + } + if (position.rowSpan > 1) { + cell["@_RowSpan"] = position.rowSpan; + } - return cell; - }), + return cell; + }, + ), } : undefined, }, @@ -2849,17 +3040,15 @@ class GridsetProcessor extends BaseProcessor { const gridBuilder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", suppressEmptyNode: true, // Preserve Grid 3 XML formatting requirements suppressBooleanAttributes: false, }); - // Build the grid XML and convert to Windows line endings for Grid 3 compatibility + // Build the grid XML and format for Grid 3 compatibility let builtXml = gridBuilder.build(gridData); - builtXml = builtXml.replace(/\n/g, '\r\n'); - // Expand self-closing tags to full opening/closing tags for Grid 3 compatibility - builtXml = builtXml.replace(/<(\w+)(\s+[^>]*)?\s*\/>/g, '<$1$2>'); + builtXml = formatGrid3XmlComplete(builtXml); return builtXml; } @@ -2867,57 +3056,14 @@ class GridsetProcessor extends BaseProcessor { private findButtonPosition( page: AACPage, button: AACButton, - fallbackIndex: number + fallbackIndex: number, ): { x: number; y: number; columnSpan: number; rowSpan: number; } { - if (page.grid && page.grid.length > 0) { - // Search for button in grid layout and calculate span - for (let y = 0; y < page.grid.length; y++) { - for (let x = 0; x < page.grid[y].length; x++) { - const current = page.grid[y][x]; - if (current && current.id === button.id) { - // Calculate span by checking how far the same button extends - let columnSpan = 1; - let rowSpan = 1; - - // Check column span (rightward) - while (x + columnSpan < page.grid[y].length) { - const right = page.grid[y][x + columnSpan]; - if (right && right.id === button.id) { - columnSpan++; - } else { - break; - } - } - - // Check row span (downward) - while (y + rowSpan < page.grid.length) { - const below = page.grid[y + rowSpan][x]; - if (below && below.id === button.id) { - rowSpan++; - } else { - break; - } - } - - return { x, y, columnSpan, rowSpan }; - } - } - } - } - - // Fallback positioning - const gridCols = page.grid?.[0]?.length || Math.ceil(Math.sqrt(page.buttons.length)); - return { - x: fallbackIndex % gridCols, - y: Math.floor(fallbackIndex / gridCols), - columnSpan: 1, - rowSpan: 1, - }; + return findButtonPos(page, button, fallbackIndex); } /** @@ -2935,9 +3081,13 @@ class GridsetProcessor extends BaseProcessor { generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { - return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); + return this.generateTranslatedDownloadGeneric( + filePath, + translatedStrings, + sourceStrings, + ); } /** diff --git a/src/processors/index.ts b/src/processors/index.ts index a3f04aa..be22444 100644 --- a/src/processors/index.ts +++ b/src/processors/index.ts @@ -7,13 +7,13 @@ * - import { Snap } from 'aac-processors/snap'; */ -export { ApplePanelsProcessor } from './applePanelsProcessor'; -export { DotProcessor } from './dotProcessor'; -export { ExcelProcessor } from './excelProcessor'; -export { GridsetProcessor } from './gridsetProcessor'; -export { ObfProcessor } from './obfProcessor'; -export { OpmlProcessor } from './opmlProcessor'; -export { SnapProcessor } from './snapProcessor'; -export { TouchChatProcessor } from './touchchatProcessor'; -export { AstericsGridProcessor } from './astericsGridProcessor'; -export { ObfsetProcessor } from './obfsetProcessor'; +export { ApplePanelsProcessor } from "./applePanelsProcessor"; +export { DotProcessor } from "./dotProcessor"; +export { ExcelProcessor } from "./excelProcessor"; +export { GridsetProcessor } from "./gridsetProcessor"; +export { ObfProcessor } from "./obfProcessor"; +export { OpmlProcessor } from "./opmlProcessor"; +export { SnapProcessor } from "./snapProcessor"; +export { TouchChatProcessor } from "./touchchatProcessor"; +export { AstericsGridProcessor } from "./astericsGridProcessor"; +export { ObfsetProcessor } from "./obfsetProcessor"; diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index 85d0603..9601014 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -4,7 +4,7 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from '../core/baseProcessor'; +} from "../core/baseProcessor"; import { AACTree, AACPage, @@ -13,19 +13,19 @@ import { AACSemanticCategory, AACSemanticIntent, AACTreeMetadata, -} from '../core/treeStructure'; -import { generateCloneId } from '../utilities/analytics/utils/idGenerator'; -import { ValidationResult } from '../validation/validationTypes'; +} from "../core/treeStructure"; +import { generateCloneId } from "../utilities/analytics/utils/idGenerator"; +import { ValidationResult } from "../validation/validationTypes"; import { extractAllButtonsForTranslation, validateTranslationResults, type ButtonForTranslation, type LLMLTranslationResult, -} from '../utilities/translation/translationProcessor'; -import { ProcessorInput, encodeBase64, decodeText } from '../utils/io'; -import { ZipAdapter } from '../utils/zip'; +} from "../utilities/translation/translationProcessor"; +import { ProcessorInput, encodeBase64, decodeText } from "../utils/io"; +import { ZipAdapter } from "../utils/zip"; -const OBF_FORMAT_VERSION = 'open-board-0.1'; +const OBF_FORMAT_VERSION = "open-board-0.1"; interface ObfButton { id: string; @@ -57,11 +57,13 @@ interface ObfManifest { * OBF: true = hidden, false/undefined = visible * Maps to: 'Hidden' | 'Visible' | undefined */ -function mapObfVisibility(hidden: boolean | undefined): 'Hidden' | 'Visible' | undefined { +function mapObfVisibility( + hidden: boolean | undefined, +): "Hidden" | "Visible" | undefined { if (hidden === undefined) { return undefined; // Default to visible } - return hidden ? 'Hidden' : 'Visible'; + return hidden ? "Hidden" : "Visible"; } interface ObfGrid { @@ -112,7 +114,10 @@ class ObfProcessor extends BaseProcessor { /** * Extract an image from the ZIP file as a Buffer */ - private async extractImageAsBuffer(imageId: string, images: any[]): Promise { + private async extractImageAsBuffer( + imageId: string, + images: any[], + ): Promise { if (!this.zipFile || !images) { return null; } @@ -134,7 +139,7 @@ class ObfProcessor extends BaseProcessor { try { const buffer = await this.zipFile.readFile(imagePath as string); if (buffer) { - if (typeof Buffer !== 'undefined') { + if (typeof Buffer !== "undefined") { return Buffer.from(buffer); } return null; @@ -150,7 +155,10 @@ class ObfProcessor extends BaseProcessor { /** * Extract an image from the ZIP file and convert to data URL */ - private async extractImageAsDataUrl(imageId: string, images: ObfImage[]): Promise { + private async extractImageAsDataUrl( + imageId: string, + images: ObfImage[], + ): Promise { // Check cache first if (this.imageCache.has(imageId)) { return this.imageCache.get(imageId) ?? null; @@ -209,36 +217,41 @@ class ObfProcessor extends BaseProcessor { } private getMimeTypeFromFilename(filename: string): string { - const ext = filename.toLowerCase().split('.').pop(); + const ext = filename.toLowerCase().split(".").pop(); switch (ext) { - case 'png': - return 'image/png'; - case 'jpg': - case 'jpeg': - return 'image/jpeg'; - case 'gif': - return 'image/gif'; - case 'svg': - return 'image/svg+xml'; - case 'webp': - return 'image/webp'; + case "png": + return "image/png"; + case "jpg": + case "jpeg": + return "image/jpeg"; + case "gif": + return "image/gif"; + case "svg": + return "image/svg+xml"; + case "webp": + return "image/webp"; default: - return 'image/png'; + return "image/png"; } } private getPageFilename(id: string, metadata: any): string { if (metadata._obfPagePaths && id in metadata._obfPagePaths) return metadata._obfPagePaths[id] as string; - if (id.endsWith('.obf')) return id; + if (id.endsWith(".obf")) return id; return `${id}.obf`; } - private async processBoard(boardData: ObfBoard, _boardPath: string): Promise { + private async processBoard( + boardData: ObfBoard, + _boardPath: string, + ): Promise { const sourceButtons = boardData.buttons || []; // Calculate page ID first (used to make button IDs unique) - const pageId = boardData?.id ? String(boardData.id) : _boardPath?.split(/[/\\]/).pop() || ''; + const pageId = boardData?.id + ? String(boardData.id) + : _boardPath?.split(/[/\\]/).pop() || ""; const images = boardData.images; @@ -250,17 +263,17 @@ class ObfProcessor extends BaseProcessor { intent: AACSemanticIntent.NAVIGATE_TO, targetId: btn.load_board.path, fallback: { - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId: btn.load_board.path, }, } : { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: String(btn?.vocalization || btn?.label || ''), + text: String(btn?.vocalization || btn?.label || ""), fallback: { - type: 'SPEAK', - message: String(btn?.vocalization || btn?.label || ''), + type: "SPEAK", + message: String(btn?.vocalization || btn?.label || ""), }, }; @@ -268,12 +281,18 @@ class ObfProcessor extends BaseProcessor { let resolvedImage: string | undefined; let imageBuffer: Buffer | undefined; if (btn.image_id && images) { - resolvedImage = (await this.extractImageAsDataUrl(btn.image_id, images)) || undefined; - imageBuffer = (await this.extractImageAsBuffer(btn.image_id, images)) || undefined; + resolvedImage = + (await this.extractImageAsDataUrl(btn.image_id, images)) || + undefined; + imageBuffer = + (await this.extractImageAsBuffer(btn.image_id, images)) || + undefined; // save image data if (images) { - const imageIndex = images?.findIndex((img: any) => img.id === btn.image_id); + const imageIndex = images?.findIndex( + (img: any) => img.id === btn.image_id, + ); if (imageIndex !== -1) { images[imageIndex].data = resolvedImage; } @@ -281,7 +300,11 @@ class ObfProcessor extends BaseProcessor { } // Build parameters object for Grid3 export compatibility - const buttonParameters: { imageData?: Buffer; image_id?: string; [key: string]: any } = {}; + const buttonParameters: { + imageData?: Buffer; + image_id?: string; + [key: string]: any; + } = {}; if (imageBuffer) { buttonParameters.imageData = imageBuffer; } @@ -292,8 +315,8 @@ class ObfProcessor extends BaseProcessor { return new AACButton({ id: String(btn.id), - label: String(btn?.label || ''), - message: String(btn?.vocalization || btn?.label || ''), + label: String(btn?.label || ""), + message: String(btn?.vocalization || btn?.label || ""), visibility: mapObfVisibility(btn.hidden), style: { backgroundColor: btn.background_color, @@ -301,19 +324,22 @@ class ObfProcessor extends BaseProcessor { }, image: resolvedImage, // Set the resolved image data URL resolvedImageEntry: resolvedImage, - parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined, + parameters: + Object.keys(buttonParameters).length > 0 + ? buttonParameters + : undefined, semanticAction, targetPageId: btn.load_board?.path, semantic_id: btn.semantic_id, // Extract semantic_id if present }); - }) + }), ); const buttonMap = new Map(buttons.map((btn) => [btn.id, btn])); const page = new AACPage({ id: pageId, // Use the page ID we calculated earlier - name: String(boardData?.name || ''), + name: String(boardData?.name || ""), grid: [], buttons, parentId: null, @@ -326,25 +352,30 @@ class ObfProcessor extends BaseProcessor { // Process grid layout if available if (boardData.grid) { const rows = - typeof boardData.grid.rows === 'number' + typeof boardData.grid.rows === "number" ? boardData.grid.rows : boardData.grid.order?.length || 0; const cols = - typeof boardData.grid.columns === 'number' + typeof boardData.grid.columns === "number" ? boardData.grid.columns : boardData.grid.order ? boardData.grid.order.reduce( - (max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), - 0 + (max, row) => + Math.max(max, Array.isArray(row) ? row.length : 0), + 0, ) : 0; if (rows > 0 && cols > 0) { - const grid: Array> = Array.from({ length: rows }, () => - Array.from({ length: cols }, () => null) + const grid: Array> = Array.from( + { length: rows }, + () => Array.from({ length: cols }, () => null), ); - if (Array.isArray(boardData.grid.order) && boardData.grid.order.length) { + if ( + Array.isArray(boardData.grid.order) && + boardData.grid.order.length + ) { boardData.grid.order.forEach((orderRow, rowIndex) => { if (!Array.isArray(orderRow)) return; orderRow.forEach((cellId, colIndex) => { @@ -358,7 +389,7 @@ class ObfProcessor extends BaseProcessor { }); } else { for (const btn of sourceButtons) { - if (typeof btn.box_id === 'number') { + if (typeof btn.box_id === "number") { const row = Math.floor(btn.box_id / cols); const col = btn.box_id % cols; if (row < rows && col < cols) { @@ -381,7 +412,13 @@ class ObfProcessor extends BaseProcessor { row.forEach((btn, colIndex) => { if (btn) { // Generate clone_id based on position and label - btn.clone_id = generateCloneId(rows, cols, rowIndex, colIndex, btn.label); + btn.clone_id = generateCloneId( + rows, + cols, + rowIndex, + colIndex, + btn.label, + ); cloneIds.push(btn.clone_id); // Track semantic_id if present @@ -413,8 +450,9 @@ class ObfProcessor extends BaseProcessor { const page = tree.pages[pageId]; if (page.name) texts.push(page.name); page.buttons.forEach((btn) => { - if (typeof btn.label === 'string') texts.push(btn.label); - if (typeof btn.message === 'string' && btn.message !== btn.label) texts.push(btn.message); + if (typeof btn.label === "string") texts.push(btn.label); + if (typeof btn.message === "string" && btn.message !== btn.label) + texts.push(btn.message); }); } @@ -422,27 +460,36 @@ class ObfProcessor extends BaseProcessor { } async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { - const { readBinaryFromInput, readTextFromInput, listDir, join, isDirectory } = - this.options.fileAdapter; + const { + readBinaryFromInput, + readTextFromInput, + listDir, + join, + isDirectory, + } = this.options.fileAdapter; // Detailed logging for debugging input const bufferLength = - typeof filePathOrBuffer === 'string' + typeof filePathOrBuffer === "string" ? null : (await readBinaryFromInput(filePathOrBuffer)).byteLength; - console.log('[OBF] loadIntoTree called with:', { + console.log("[OBF] loadIntoTree called with:", { type: typeof filePathOrBuffer, - isBuffer: typeof Buffer !== 'undefined' && Buffer.isBuffer(filePathOrBuffer), + isBuffer: + typeof Buffer !== "undefined" && Buffer.isBuffer(filePathOrBuffer), value: - typeof filePathOrBuffer === 'string' + typeof filePathOrBuffer === "string" ? filePathOrBuffer : `[Buffer of length ${bufferLength ?? 0}]`, }); const tree = new AACTree(); // Helper: try to parse JSON OBF - async function tryParseObfJson(data: ProcessorInput): Promise { + async function tryParseObfJson( + data: ProcessorInput, + ): Promise { try { - const str = typeof data === 'string' ? data : await readTextFromInput(data); + const str = + typeof data === "string" ? data : await readTextFromInput(data); // Check for empty or whitespace-only content if (!str.trim()) { @@ -450,10 +497,10 @@ class ObfProcessor extends BaseProcessor { } const obj = JSON.parse(str); - if (obj && typeof obj === 'object' && 'id' in obj && 'buttons' in obj) { + if (obj && typeof obj === "object" && "id" in obj && "buttons" in obj) { // Validate buttons is an array if (!Array.isArray(obj.buttons)) { - throw new Error('Invalid OBF: buttons must be an array'); + throw new Error("Invalid OBF: buttons must be an array"); } return obj as ObfBoard; } @@ -464,17 +511,20 @@ class ObfProcessor extends BaseProcessor { } // If input is a string path and ends with .obf, treat as JSON - if (typeof filePathOrBuffer === 'string' && filePathOrBuffer.toLowerCase().endsWith('.obf')) { + if ( + typeof filePathOrBuffer === "string" && + filePathOrBuffer.toLowerCase().endsWith(".obf") + ) { try { const content = await readTextFromInput(filePathOrBuffer); const boardData = await tryParseObfJson(content); if (boardData) { - console.log('[OBF] Detected .obf file, parsed as JSON'); + console.log("[OBF] Detected .obf file, parsed as JSON"); const page = await this.processBoard(boardData, filePathOrBuffer); tree.addPage(page); // Set metadata from root board - tree.metadata.format = 'obf'; + tree.metadata.format = "obf"; tree.metadata.name = boardData.name; tree.metadata.description = boardData.description_html; tree.metadata.locale = boardData.locale; @@ -485,38 +535,40 @@ class ObfProcessor extends BaseProcessor { return tree; } else { - throw new Error('Invalid OBF JSON content'); + throw new Error("Invalid OBF JSON content"); } } catch (err) { - console.error('[OBF] Error reading .obf file:', err); + console.error("[OBF] Error reading .obf file:", err); throw err; } } // Determine if input is ZIP, directory, or OBF JSON string/buffer - let fileType: 'obf' | 'zip' | 'dir' = 'obf'; - if (typeof filePathOrBuffer !== 'string') { + let fileType: "obf" | "zip" | "dir" = "obf"; + if (typeof filePathOrBuffer !== "string") { const bytes = await readBinaryFromInput(filePathOrBuffer); - if (bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b) fileType = 'zip'; + if (bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b) + fileType = "zip"; } else { if (await isDirectory(filePathOrBuffer)) { - fileType = 'dir'; + fileType = "dir"; } else { const lowered = filePathOrBuffer.toLowerCase(); - if (lowered.endsWith('.zip') || lowered.endsWith('.obz')) fileType = 'zip'; + if (lowered.endsWith(".zip") || lowered.endsWith(".obz")) + fileType = "zip"; } } // Check if input is a buffer or string that parses as OBF JSON; throw if neither JSON nor ZIP - if (fileType === 'obf') { + if (fileType === "obf") { const asJson = await tryParseObfJson(filePathOrBuffer); - if (!asJson) throw new Error('Invalid OBF content: not JSON and not ZIP'); - console.log('[OBF] Detected buffer/string as OBF JSON'); - const page = await this.processBoard(asJson, '[bufferOrString]'); + if (!asJson) throw new Error("Invalid OBF content: not JSON and not ZIP"); + console.log("[OBF] Detected buffer/string as OBF JSON"); + const page = await this.processBoard(asJson, "[bufferOrString]"); tree.addPage(page); // Set metadata from root board - tree.metadata.format = 'obf'; + tree.metadata.format = "obf"; tree.metadata.name = asJson.name; tree.metadata.description = asJson.description_html; tree.metadata.locale = asJson.locale; @@ -532,20 +584,22 @@ class ObfProcessor extends BaseProcessor { this.zipFile = { readFile: async (name: string): Promise => { - return await readBinaryFromInput(join(filePathOrBuffer as string, name)); + return await readBinaryFromInput( + join(filePathOrBuffer as string, name), + ); }, listFiles: () => { - throw new Error('Not implemented for directory input'); + throw new Error("Not implemented for directory input"); }, writeFiles: () => { - throw new Error('Not implemented for directory input'); + throw new Error("Not implemented for directory input"); }, }; - if (fileType === 'zip') { + if (fileType === "zip") { try { this.zipFile = await this.options.zipAdapter(filePathOrBuffer); } catch (err) { - console.error('[OBF] Error loading ZIP:', err); + console.error("[OBF] Error loading ZIP:", err); throw err; } } @@ -553,23 +607,32 @@ class ObfProcessor extends BaseProcessor { // Store the ZIP file reference for image extraction this.imageCache.clear(); // Clear cache for new file - console.log('[OBF] Detected zip archive or directory, extracting .obf files'); + console.log( + "[OBF] Detected zip archive or directory, extracting .obf files", + ); // List manifest and OBF files const filesInZip = - fileType === 'zip' ? this.zipFile.listFiles() : await listDir(filePathOrBuffer as string); - const manifestFile = filesInZip.filter((name) => name.toLowerCase() === 'manifest.json'); - let obfEntries = filesInZip.filter((name) => name.toLowerCase().endsWith('.obf')); + fileType === "zip" + ? this.zipFile.listFiles() + : await listDir(filePathOrBuffer as string); + const manifestFile = filesInZip.filter( + (name) => name.toLowerCase() === "manifest.json", + ); + let obfEntries = filesInZip.filter((name) => + name.toLowerCase().endsWith(".obf"), + ); // Attempt to read manifest if (manifestFile && manifestFile.length === 1) { try { const content = await this.zipFile.readFile(manifestFile[0]); const data = decodeText(content); - const str = typeof data === 'string' ? data : await readTextFromInput(data); - if (!str.trim()) throw new Error('Manifest object missing'); + const str = + typeof data === "string" ? data : await readTextFromInput(data); + if (!str.trim()) throw new Error("Manifest object missing"); const manifestObject = JSON.parse(str) as ObfManifest; - if (!manifestObject) throw new Error('Manifest object is empty'); + if (!manifestObject) throw new Error("Manifest object is empty"); // Replace OBF file list if (manifestObject.paths && manifestObject.paths.boards) { @@ -578,11 +641,13 @@ class ObfProcessor extends BaseProcessor { // Move root board to top of list if (manifestObject.root) { - obfEntries = obfEntries.filter((item) => item !== manifestObject.root); + obfEntries = obfEntries.filter( + (item) => item !== manifestObject.root, + ); obfEntries.unshift(manifestObject.root); } } catch (err) { - console.warn('[OBF] Error processing mainfest', err); + console.warn("[OBF] Error processing mainfest", err); } } @@ -597,7 +662,7 @@ class ObfProcessor extends BaseProcessor { // Set metadata if not already set (use first board as reference) if (!tree.metadata.format) { - tree.metadata.format = 'obf'; + tree.metadata.format = "obf"; tree.metadata.name = boardData.name; tree.metadata.description = boardData.description_html; tree.metadata.locale = boardData.locale; @@ -610,10 +675,10 @@ class ObfProcessor extends BaseProcessor { tree.metadata._obfPagePaths[page.id] = entryName; } } else { - console.warn('[OBF] Skipped entry (not valid OBF JSON):', entryName); + console.warn("[OBF] Skipped entry (not valid OBF JSON):", entryName); } } catch (err) { - console.warn('[OBF] Error processing entry:', entryName, err); + console.warn("[OBF] Error processing entry:", entryName, err); } } @@ -630,7 +695,10 @@ class ObfProcessor extends BaseProcessor { const totalRows = Array.isArray(page.grid) ? page.grid.length : 0; const totalColumns = totalRows > 0 - ? page.grid.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0) + ? page.grid.reduce( + (max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), + 0, + ) : 0; if (totalRows === 0 || totalColumns === 0) { @@ -638,7 +706,7 @@ class ObfProcessor extends BaseProcessor { return { rows: 0, columns: 0, order: [], buttonPositions }; } const fallbackRow: string[] = page.buttons.map((button, index) => { - const id = String(button.id ?? ''); + const id = String(button.id ?? ""); buttonPositions.set(id, index); return id; }); @@ -658,7 +726,7 @@ class ObfProcessor extends BaseProcessor { for (let colIndex = 0; colIndex < totalColumns; colIndex++) { const cell = sourceRow[colIndex] || null; if (cell) { - const id = String(cell.id ?? ''); + const id = String(cell.id ?? ""); orderRow.push(id); buttonPositions.set(id, rowIndex * totalColumns + colIndex); } else { @@ -675,9 +743,10 @@ class ObfProcessor extends BaseProcessor { page: AACPage, fallbackName: string, metadata?: AACTreeMetadata, - embedData = false + embedData = false, ): ObfBoard { - const { rows, columns, order, buttonPositions } = this.buildGridMetadata(page); + const { rows, columns, order, buttonPositions } = + this.buildGridMetadata(page); const boardName = metadata?.name && page.id === metadata?.defaultHomePageId ? metadata.name @@ -694,7 +763,7 @@ class ObfProcessor extends BaseProcessor { format: OBF_FORMAT_VERSION, id: page.id, url: metadata?.url, - locale: metadata?.locale || page.locale || 'en', + locale: metadata?.locale || page.locale || "en", name: boardName, description_html: metadata?.description && page.id === metadata?.defaultHomePageId @@ -706,7 +775,10 @@ class ObfProcessor extends BaseProcessor { order, }, buttons: page.buttons.map((button) => { - const extraButtonInfo = button as AACButton & { image_id?: string; imageId?: string }; + const extraButtonInfo = button as AACButton & { + image_id?: string; + imageId?: string; + }; const imageId = button.parameters?.image_id || button.parameters?.imageId || @@ -718,16 +790,17 @@ class ObfProcessor extends BaseProcessor { label: button.label, vocalization: button.message || button.label, load_board: - button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && button.targetPageId + button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && + button.targetPageId ? { path: button.targetPageId, } : undefined, background_color: button.style?.backgroundColor, border_color: button.style?.borderColor, - box_id: buttonPositions.get(String(button.id ?? '')), + box_id: buttonPositions.get(String(button.id ?? "")), image_id: imageId, - hidden: button.visibility === 'Hidden' || false, + hidden: button.visibility === "Hidden" || false, }; }), images, @@ -738,7 +811,7 @@ class ObfProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, ): Promise { const { readBinaryFromInput } = this.options.fileAdapter; // Load the tree, apply translations, and save to new file @@ -776,26 +849,37 @@ class ObfProcessor extends BaseProcessor { return await readBinaryFromInput(outputPath); } - async saveFromTree(tree: AACTree, outputPath: string, embedData = false): Promise { + async saveFromTree( + tree: AACTree, + outputPath: string, + embedData = false, + ): Promise { const { writeTextToPath, writeBinaryToPath, pathExists, mkDir, join } = this.options.fileAdapter; - if (outputPath.endsWith('.obf')) { + if (outputPath.endsWith(".obf")) { // Save as single OBF JSON file - const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0]; + const rootPage = tree.rootId + ? tree.getPage(tree.rootId) + : Object.values(tree.pages)[0]; if (!rootPage) { - throw new Error('No pages to save'); + throw new Error("No pages to save"); } const obfBoard = this.createObfBoardFromPage( rootPage, - 'Exported Board', + "Exported Board", tree.metadata, - embedData + embedData, ); await writeTextToPath(outputPath, JSON.stringify(obfBoard, null, 2)); } else { const files = Object.values(tree.pages).map((page) => { - const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata, embedData); + const obfBoard = this.createObfBoardFromPage( + page, + "Board", + tree.metadata, + embedData, + ); const obfContent = JSON.stringify(obfBoard, null, 2); const name = this.getPageFilename(page.id, tree.metadata); return { @@ -811,28 +895,28 @@ class ObfProcessor extends BaseProcessor { Object.entries(tree.pages).map(([id, page]) => [ id, this.getPageFilename(page.id, tree.metadata), - ]) + ]), ), images: {}, //TODO Add support for saving images as files sounds: {}, //TODO Add support for saving sounds as files }, }; files.push({ - name: 'manifest.json', + name: "manifest.json", data: new TextEncoder().encode(JSON.stringify(manifest)), }); - if (outputPath.endsWith('.obz') || outputPath.endsWith('.zip')) { - console.log('[OBF] Saving to ZIP file:', outputPath); + if (outputPath.endsWith(".obz") || outputPath.endsWith(".zip")) { + console.log("[OBF] Saving to ZIP file:", outputPath); const fileExists = await pathExists(outputPath); this.zipFile = await this.options.zipAdapter( fileExists ? outputPath : undefined, - this.options.fileAdapter + this.options.fileAdapter, ); const zipData = await this.zipFile.writeFiles(files); await writeBinaryToPath(outputPath, zipData); } else { - console.log('[OBF] Saving to directory:', outputPath); + console.log("[OBF] Saving to directory:", outputPath); if (!(await pathExists(outputPath))) await mkDir(outputPath); for (const file of files) { const filePath = join(outputPath, file.name); @@ -850,11 +934,15 @@ class ObfProcessor extends BaseProcessor { * @param tree - Modified AACTree with pages to save * @param outputPath - Path where the modified file should be saved */ - async saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise { + async saveModifiedTree( + originalPath: string, + tree: AACTree, + outputPath: string, + ): Promise { const { writeBinaryToPath, readBinaryFromInput } = this.options.fileAdapter; // If output is .obf (single file), use regular save - if (outputPath.endsWith('.obf')) { + if (outputPath.endsWith(".obf")) { await this.saveFromTree(tree, outputPath); return; } @@ -866,7 +954,7 @@ class ObfProcessor extends BaseProcessor { return; } - const AdmZip = (await import('adm-zip')).default; + const AdmZip = (await import("adm-zip")).default; const originalZip = new AdmZip(originalPath); const outputZip = new AdmZip(); @@ -880,14 +968,18 @@ class ObfProcessor extends BaseProcessor { const obfFilename = this.getPageFilename(page.id, tree.metadata); modifiedObfFiles.add(obfFilename); - const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata); + const obfBoard = this.createObfBoardFromPage( + page, + "Board", + tree.metadata, + ); const obfContent = JSON.stringify(obfBoard, null, 2); newObfFiles.set(obfFilename, obfContent); } // Generate updated manifest if we have pages if (Object.keys(tree.pages).length > 0) { - modifiedObfFiles.add('manifest.json'); + modifiedObfFiles.add("manifest.json"); const manifest: ObfManifest = { format: OBF_FORMAT_VERSION, @@ -897,14 +989,14 @@ class ObfProcessor extends BaseProcessor { Object.entries(tree.pages).map(([id, page]) => [ id, this.getPageFilename(page.id, tree.metadata), - ]) + ]), ), images: {}, sounds: {}, }, }; - newObfFiles.set('manifest.json', JSON.stringify(manifest)); + newObfFiles.set("manifest.json", JSON.stringify(manifest)); } // Copy all files from original zip, replacing modified .obf files @@ -915,7 +1007,7 @@ class ObfProcessor extends BaseProcessor { if (modifiedObfFiles.has(entry.entryName)) { const newContent = newObfFiles.get(entry.entryName); if (newContent) { - outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8')); + outputZip.addFile(entry.entryName, Buffer.from(newContent, "utf8")); } continue; } @@ -933,7 +1025,9 @@ class ObfProcessor extends BaseProcessor { * Extract strings with metadata for aac-tools-platform compatibility * Uses the generic implementation from BaseProcessor */ - async extractStringsWithMetadata(filePath: string): Promise { + async extractStringsWithMetadata( + filePath: string, + ): Promise { return this.extractStringsWithMetadataGeneric(filePath); } @@ -944,9 +1038,13 @@ class ObfProcessor extends BaseProcessor { async generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { - return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); + return this.generateTranslatedDownloadGeneric( + filePath, + translatedStrings, + sourceStrings, + ); } /** @@ -968,7 +1066,9 @@ class ObfProcessor extends BaseProcessor { * @param filePathOrBuffer - Path to OBF/OBZ file or buffer * @returns Promise resolving to symbol information for LLM processing */ - async extractSymbolsForLLM(filePathOrBuffer: ProcessorInput): Promise { + async extractSymbolsForLLM( + filePathOrBuffer: ProcessorInput, + ): Promise { const tree = await this.loadIntoTree(filePathOrBuffer); // Collect all buttons from all pages @@ -1005,13 +1105,15 @@ class ObfProcessor extends BaseProcessor { filePathOrBuffer: ProcessorInput, llmTranslations: LLMLTranslationResult[], outputPath: string, - options?: { allowPartial?: boolean } + options?: { allowPartial?: boolean }, ): Promise { const { readBinaryFromInput } = this.options.fileAdapter; const tree = await this.loadIntoTree(filePathOrBuffer); // Validate translations using shared utility - const buttonIds = Object.values(tree.pages).flatMap((page) => page.buttons.map((b) => b.id)); + const buttonIds = Object.values(tree.pages).flatMap((page) => + page.buttons.map((b) => b.id), + ); validateTranslationResults(llmTranslations, buttonIds, options); // Create a map for quick lookup @@ -1056,12 +1158,14 @@ class ObfProcessor extends BaseProcessor { return await readBinaryFromInput(outputPath); } - private getObfValidator(): typeof import('../validation/obfValidator').ObfValidator { + private getObfValidator(): typeof import("../validation/obfValidator").ObfValidator { try { // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-return - return require('../validation/obfValidator').ObfValidator; + return require("../validation/obfValidator").ObfValidator; } catch (_error) { - throw new Error('Validation utilities are not available in this environment.'); + throw new Error( + "Validation utilities are not available in this environment.", + ); } } } diff --git a/src/processors/obfsetProcessor.ts b/src/processors/obfsetProcessor.ts index 92feb1c..ee6aff4 100644 --- a/src/processors/obfsetProcessor.ts +++ b/src/processors/obfsetProcessor.ts @@ -3,16 +3,16 @@ * These are pre-extracted board sets in JSON array format */ -import { AACTree } from '../core/treeStructure'; +import { AACTree } from "../core/treeStructure"; import { AACPage, AACButton, AACSemanticAction, AACSemanticCategory, AACSemanticIntent, -} from '../core/treeStructure'; -import { BaseProcessor, ProcessorOptions } from '../core/baseProcessor'; -import { ProcessorInput } from '../utils/io'; +} from "../core/treeStructure"; +import { BaseProcessor, ProcessorOptions } from "../core/baseProcessor"; +import { ProcessorInput } from "../utils/io"; interface ObfsetButton { id: string; @@ -64,7 +64,7 @@ export class ObfsetProcessor extends BaseProcessor { async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { const { readTextFromInput } = this.options.fileAdapter; const tree = new AACTree(); - tree.metadata.format = 'obfset'; + tree.metadata.format = "obfset"; const content = await readTextFromInput(filePathOrBuffer); const boards: ObfsetBoard[] = JSON.parse(content); @@ -98,7 +98,9 @@ export class ObfsetProcessor extends BaseProcessor { const cols = boardData.grid?.columns || 6; // Initialize grid with nulls - page.grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => null)); + page.grid = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => null), + ); // Create button map by ID const buttonMap = new Map(); @@ -127,14 +129,14 @@ export class ObfsetProcessor extends BaseProcessor { intent: AACSemanticIntent.NAVIGATE_TO, targetId: btnData.load_board.id, fallback: { - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId: btnData.load_board.id, add_to_sentence: btnData.load_board.add_to_sentence, temporary_home: btnData.load_board.temporary_home, }, platformData: { grid3: { - commandId: 'GO_TO_BOARD', + commandId: "GO_TO_BOARD", parameters: { add_to_sentence: btnData.load_board.add_to_sentence, temporary_home: btnData.load_board.temporary_home, @@ -147,15 +149,15 @@ export class ObfsetProcessor extends BaseProcessor { semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: btnData.label || '', - fallback: { type: 'SPEAK', message: btnData.label || '' }, + text: btnData.label || "", + fallback: { type: "SPEAK", message: btnData.label || "" }, }; } const button = new AACButton({ id: btnData.id, - label: btnData.label || '', - message: btnData.label || '', + label: btnData.label || "", + message: btnData.label || "", targetPageId: btnData.load_board?.id, semanticAction, semantic_id: btnData.semantic_id, @@ -208,10 +210,10 @@ export class ObfsetProcessor extends BaseProcessor { async processTexts( _filePathOrBuffer: ProcessorInput, _translations: Map, - _outputPath: string + _outputPath: string, ): Promise { await Promise.resolve(); - throw new Error('processTexts is not supported for .obfset currently'); + throw new Error("processTexts is not supported for .obfset currently"); } /** @@ -219,10 +221,10 @@ export class ObfsetProcessor extends BaseProcessor { */ async saveFromTree(_tree: AACTree, _outputPath: string): Promise { await Promise.resolve(); - throw new Error('saveFromTree is not supported for .obfset currently'); + throw new Error("saveFromTree is not supported for .obfset currently"); } supportsExtension(extension: string): boolean { - return extension === '.obfset'; + return extension === ".obfset"; } } diff --git a/src/processors/opmlProcessor.ts b/src/processors/opmlProcessor.ts index f66bf23..a4bd798 100644 --- a/src/processors/opmlProcessor.ts +++ b/src/processors/opmlProcessor.ts @@ -4,17 +4,22 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from '../core/baseProcessor'; -import { AACTree, AACPage, AACButton, AACSemanticIntent } from '../core/treeStructure'; -import { XMLParser, XMLValidator, XMLBuilder } from 'fast-xml-parser'; +} from "../core/baseProcessor"; +import { + AACTree, + AACPage, + AACButton, + AACSemanticIntent, +} from "../core/treeStructure"; +import { XMLParser, XMLValidator, XMLBuilder } from "fast-xml-parser"; import { ValidationFailureError, buildValidationResultFromMessage, -} from '../validation/validationTypes'; -import { ProcessorInput, getBasename, encodeText } from '../utils/io'; +} from "../validation/validationTypes"; +import { ProcessorInput, getBasename, encodeText } from "../utils/io"; interface OpmlOutline { - '@_text'?: string; + "@_text"?: string; text?: string; _attributes?: { text: string; @@ -37,21 +42,21 @@ class OpmlProcessor extends BaseProcessor { } private processOutline( outline: OpmlOutline, - parentId: string | null = null + parentId: string | null = null, ): { page: AACPage | null; childPages: AACPage[] } { - if (!outline || typeof outline !== 'object') { + if (!outline || typeof outline !== "object") { return { page: null, childPages: [] }; } const text = - outline['@_text'] || + outline["@_text"] || (outline._attributes && outline._attributes.text) || (outline as any).text; - if (!text || typeof text !== 'string') { + if (!text || typeof text !== "string") { // Skip invalid outlines return { page: null, childPages: [] }; } const page = new AACPage({ - id: text.replace(/[^a-zA-Z0-9]/g, '_'), + id: text.replace(/[^a-zA-Z0-9]/g, "_"), name: text, grid: [], buttons: [], @@ -61,24 +66,27 @@ class OpmlProcessor extends BaseProcessor { const childPages: AACPage[] = []; if (outline.outline) { - const children = Array.isArray(outline.outline) ? outline.outline : [outline.outline]; + const children = Array.isArray(outline.outline) + ? outline.outline + : [outline.outline]; children.forEach((child) => { const childText = - child['@_text'] || (child._attributes && child._attributes.text) || (child as any).text; - if (childText && typeof childText === 'string') { + child["@_text"] || + (child._attributes && child._attributes.text) || + (child as any).text; + if (childText && typeof childText === "string") { const button = new AACButton({ id: `nav_${page.id}_${childText}`, label: childText, - message: '', - targetPageId: childText.replace(/[^a-zA-Z0-9]/g, '_'), + message: "", + targetPageId: childText.replace(/[^a-zA-Z0-9]/g, "_"), }); page.addButton(button); - const { page: childPage, childPages: grandChildren } = this.processOutline( - child, - page.id - ); - if (childPage && childPage.id) childPages.push(childPage, ...grandChildren); + const { page: childPage, childPages: grandChildren } = + this.processOutline(child, page.id); + if (childPage && childPage.id) + childPages.push(childPage, ...grandChildren); } }); } @@ -100,11 +108,15 @@ class OpmlProcessor extends BaseProcessor { // Handle different attribute formats let textValue: string | undefined; - if (node && node._attributes && typeof node._attributes.text === 'string') { + if ( + node && + node._attributes && + typeof node._attributes.text === "string" + ) { textValue = node._attributes.text; - } else if (node && typeof node['@_text'] === 'string') { - textValue = node['@_text']; - } else if (node && typeof node.text === 'string') { + } else if (node && typeof node["@_text"] === "string") { + textValue = node["@_text"]; + } else if (node && typeof node.text === "string") { textValue = node.text; } @@ -113,7 +125,9 @@ class OpmlProcessor extends BaseProcessor { } if (node && node.outline) { - const children = Array.isArray(node.outline) ? node.outline : [node.outline]; + const children = Array.isArray(node.outline) + ? node.outline + : [node.outline]; children.forEach(processNode); } } @@ -129,7 +143,9 @@ class OpmlProcessor extends BaseProcessor { const { readBinaryFromInput, readTextFromInput } = this.options.fileAdapter; const filename = - typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.opml'; + typeof filePathOrBuffer === "string" + ? getBasename(filePathOrBuffer) + : "upload.opml"; const buffer = await readBinaryFromInput(filePathOrBuffer); const content = await readTextFromInput(buffer); @@ -138,33 +154,38 @@ class OpmlProcessor extends BaseProcessor { const validationResult = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: 'opml', - message: 'Empty OPML content', - type: 'content', - description: 'OPML content is empty', + format: "opml", + message: "Empty OPML content", + type: "content", + description: "OPML content is empty", }); - throw new ValidationFailureError('Empty OPML content', validationResult); + throw new ValidationFailureError( + "Empty OPML content", + validationResult, + ); } // Validate XML before parsing, fast-xml-parser is permissive by default const validationResult = XMLValidator.validate(content); if (validationResult !== true) { - const reason = (validationResult as any)?.err?.msg || JSON.stringify(validationResult); + const reason = + (validationResult as any)?.err?.msg || + JSON.stringify(validationResult); const structured = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: 'opml', + format: "opml", message: `Invalid OPML XML: ${reason}`, - type: 'xml', - description: 'OPML XML validation', + type: "xml", + description: "OPML XML validation", }); - throw new ValidationFailureError('Invalid OPML XML', structured); + throw new ValidationFailureError("Invalid OPML XML", structured); } const parser = new XMLParser({ ignoreAttributes: false }); const data = parser.parse(content) as OpmlDocument; const tree = new AACTree(); - tree.metadata.format = 'opml'; + tree.metadata.format = "opml"; // Handle case where body.outline might not exist or be in different formats const bodyOutline = data.opml?.body?.outline; @@ -172,12 +193,12 @@ class OpmlProcessor extends BaseProcessor { const structured = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: 'opml', - message: 'Missing body.outline in OPML document', - type: 'structure', - description: 'OPML outline root', + format: "opml", + message: "Missing body.outline in OPML document", + type: "structure", + description: "OPML outline root", }); - throw new ValidationFailureError('Invalid OPML structure', structured); + throw new ValidationFailureError("Invalid OPML structure", structured); } const outlines = Array.isArray(bodyOutline) ? bodyOutline : [bodyOutline]; @@ -206,19 +227,23 @@ class OpmlProcessor extends BaseProcessor { const validationResult = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: 'opml', - message: err?.message || 'Failed to parse OPML', - type: 'parse', - description: 'Parse OPML XML', + format: "opml", + message: err?.message || "Failed to parse OPML", + type: "parse", + description: "Parse OPML XML", }); - throw new ValidationFailureError('Failed to load OPML file', validationResult, err); + throw new ValidationFailureError( + "Failed to load OPML file", + validationResult, + err, + ); } } async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, ): Promise { const { writeBinaryToPath, readTextFromInput } = this.options.fileAdapter; @@ -228,13 +253,16 @@ class OpmlProcessor extends BaseProcessor { // Apply translations to text attributes in OPML outline elements translations.forEach((translation, originalText) => { - if (typeof originalText === 'string' && typeof translation === 'string') { + if (typeof originalText === "string" && typeof translation === "string") { // Replace text attributes in outline elements const textAttrRegex = new RegExp( - `text="${originalText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`, - 'g' + `text="${originalText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`, + "g", + ); + translatedContent = translatedContent.replace( + textAttrRegex, + `text="${translation}"`, ); - translatedContent = translatedContent.replace(textAttrRegex, `text="${translation}"`); } }); @@ -247,18 +275,21 @@ class OpmlProcessor extends BaseProcessor { const { writeTextToPath } = this.options.fileAdapter; // Helper to recursively build outline nodes with cycle detection - function buildOutline(page: AACPage, visited: Set = new Set()): OpmlOutline { + function buildOutline( + page: AACPage, + visited: Set = new Set(), + ): OpmlOutline { // Prevent infinite recursion by tracking visited pages if (visited.has(page.id)) { return { - '@_text': `${page.name || page.id} (circular reference)`, + "@_text": `${page.name || page.id} (circular reference)`, }; } visited.add(page.id); const outline: OpmlOutline = { - '@_text': page.name || page.id, + "@_text": page.name || page.id, }; // Find child pages (by NAVIGATE buttons) @@ -267,7 +298,7 @@ class OpmlProcessor extends BaseProcessor { (b) => b.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && !!b.targetPageId && - !!tree.pages[b.targetPageId] + !!tree.pages[b.targetPageId], ) .map((b) => { const targetId = b.targetPageId; @@ -280,7 +311,9 @@ class OpmlProcessor extends BaseProcessor { } return buildOutline(targetPage, new Set(visited)); }) - .filter((childOutline): childOutline is OpmlOutline => childOutline !== null); + .filter( + (childOutline): childOutline is OpmlOutline => childOutline !== null, + ); if (childOutlines.length) outline.outline = childOutlines; return outline; } @@ -288,18 +321,27 @@ class OpmlProcessor extends BaseProcessor { const navigatedIds = new Set(); Object.values(tree.pages).forEach((page) => { page.buttons.forEach((b) => { - if (b.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && b.targetPageId) + if ( + b.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && + b.targetPageId + ) navigatedIds.add(b.targetPageId); }); }); - let rootPages = Object.values(tree.pages).filter((page) => !navigatedIds.has(page.id)); + let rootPages = Object.values(tree.pages).filter( + (page) => !navigatedIds.has(page.id), + ); // If no rootPages, fall back to tree.rootId const treeRootId = tree.rootId; - if ((!rootPages || rootPages.length === 0) && treeRootId && tree.pages[treeRootId]) { + if ( + (!rootPages || rootPages.length === 0) && + treeRootId && + tree.pages[treeRootId] + ) { rootPages = [tree.pages[treeRootId]]; } else if (treeRootId) { rootPages = rootPages.sort((a, b) => - a.id === treeRootId ? -1 : b.id === treeRootId ? 1 : 0 + a.id === treeRootId ? -1 : b.id === treeRootId ? 1 : 0, ); } // Build outlines @@ -307,8 +349,8 @@ class OpmlProcessor extends BaseProcessor { // Compose OPML document const opmlObj = { opml: { - '@_version': '2.0', - head: { title: 'Exported OPML' }, + "@_version": "2.0", + head: { title: "Exported OPML" }, body: { outline: outlines }, }, }; @@ -316,11 +358,12 @@ class OpmlProcessor extends BaseProcessor { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: ' ', + indentBy: " ", suppressEmptyNode: false, - attributeNamePrefix: '@_', + attributeNamePrefix: "@_", }); - const xml = '\n' + builder.build(opmlObj); + const xml = + '\n' + builder.build(opmlObj); await writeTextToPath(outputPath, xml); } @@ -339,9 +382,13 @@ class OpmlProcessor extends BaseProcessor { generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { - return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); + return this.generateTranslatedDownloadGeneric( + filePath, + translatedStrings, + sourceStrings, + ); } } diff --git a/src/processors/snap/helpers.ts b/src/processors/snap/helpers.ts index af3957e..7d02528 100644 --- a/src/processors/snap/helpers.ts +++ b/src/processors/snap/helpers.ts @@ -3,16 +3,16 @@ import { AACSemanticCategory, AACSemanticIntent, AACButton, -} from '../../core/treeStructure'; -import { dotNetTicksToDate } from '../../utils/dotnetTicks'; +} from "../../core/treeStructure"; +import { dotNetTicksToDate } from "../../utils/dotnetTicks"; import { defaultFileAdapter, extname, FileAdapter, getNodeRequire, ProcessorInput, -} from '../../utils/io'; -import { requireBetterSqlite3 } from '../../utils/sqlite'; +} from "../../utils/io"; +import { requireBetterSqlite3 } from "../../utils/sqlite"; // Minimal Snap helpers (stubs) to align with processors//helpers pattern // NOTE: Snap files can store different types of image data in PageSetData: @@ -27,11 +27,13 @@ async function collectFiles( root: string, matcher: (fullPath: string) => boolean, maxDepth = 3, - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { listDir, join, isDirectory } = fileAdapter; const results = new Set(); - const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; + const stack: Array<{ dir: string; depth: number }> = [ + { dir: root, depth: 0 }, + ]; while (stack.length > 0) { const current = stack.pop(); @@ -62,7 +64,10 @@ async function collectFiles( * Build a map of button IDs to resolved image entries for a specific page. * Mirrors the Grid helper for consumers that expect image reference data. */ -export function getPageTokenImageMap(tree: AACTree, pageId: string): Map { +export function getPageTokenImageMap( + tree: AACTree, + pageId: string, +): Map { const map = new Map(); const page = tree.getPage(pageId); if (!page) return map; @@ -81,13 +86,19 @@ export function getAllowedImageEntries(tree: AACTree): Set { Object.values(tree.pages).forEach((page) => { page.buttons.forEach((btn: AACButton) => { // Extract image_id from parameters if it exists - if (btn.parameters?.image_id && typeof btn.parameters.image_id === 'string') { + if ( + btn.parameters?.image_id && + typeof btn.parameters.image_id === "string" + ) { out.add(btn.parameters.image_id); } // Also add resolvedImageEntry if it's a symbol identifier - if (btn.resolvedImageEntry && typeof btn.resolvedImageEntry === 'string') { + if ( + btn.resolvedImageEntry && + typeof btn.resolvedImageEntry === "string" + ) { const entry = btn.resolvedImageEntry; - if (entry.startsWith('SYM:')) { + if (entry.startsWith("SYM:")) { out.add(entry); } } @@ -105,33 +116,38 @@ export function getAllowedImageEntries(tree: AACTree): Set { export async function openImage( dbOrFile: ProcessorInput, entryPath: string, - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { - const { mkTempDir, join, writeBinaryToPath, removePath, dirname } = fileAdapter; + const { mkTempDir, join, writeBinaryToPath, removePath, dirname } = + fileAdapter; let dbPath: string; let cleanupNeeded = false; // Handle Buffer input by writing to temp file if (Buffer.isBuffer(dbOrFile)) { - const tempDir = await mkTempDir(join(process.cwd(), 'snap-')); - dbPath = join(tempDir, 'temp.sps'); + const tempDir = await mkTempDir(join(process.cwd(), "snap-")); + dbPath = join(tempDir, "temp.sps"); await writeBinaryToPath(dbPath, dbOrFile); cleanupNeeded = true; - } else if (typeof dbOrFile === 'string') { + } else if (typeof dbOrFile === "string") { dbPath = dbOrFile; } else { return null; } - const better_sqlite3 = getNodeRequire()('better-sqlite3'); + const better_sqlite3 = getNodeRequire()("better-sqlite3"); let db = null; try { db = new better_sqlite3.Database(dbPath, { readonly: true }); // Query PageSetData for the symbol const row = db - .prepare('SELECT Id, Identifier, Data FROM PageSetData WHERE Identifier = ?') - .get(entryPath) as { Id: number; Identifier: string; Data: Buffer } | undefined; + .prepare( + "SELECT Id, Identifier, Data FROM PageSetData WHERE Identifier = ?", + ) + .get(entryPath) as + | { Id: number; Identifier: string; Data: Buffer } + | undefined; if (row && row.Data && row.Data.length > 0) { // Snap files can store different types of image data: @@ -181,7 +197,7 @@ export interface SnapUsageEntry { timestamp: Date; modeling?: boolean; accessMethod?: number | null; - type?: 'button' | 'action' | 'utterance' | 'note' | 'other'; + type?: "button" | "action" | "utterance" | "note" | "other"; buttonId?: string | null; intent?: AACSemanticIntent | string; category?: AACSemanticCategory; @@ -200,14 +216,14 @@ export interface SnapUsageEntry { * @returns Array of Snap package path information */ export async function findSnapPackages( - packageNamePattern = 'TobiiDynavox', - fileAdapter: FileAdapter = defaultFileAdapter + packageNamePattern = "TobiiDynavox", + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { join, listDir, isDirectory, pathExists } = fileAdapter; const results: SnapPackagePath[] = []; // Only works on Windows - if (process.platform !== 'win32') { + if (process.platform !== "win32") { return results; } @@ -217,7 +233,7 @@ export async function findSnapPackages( return results; } - const packagesPath = join(localAppData, 'Packages'); + const packagesPath = join(localAppData, "Packages"); // Check if Packages directory exists if (!(await pathExists(packagesPath))) { @@ -254,8 +270,8 @@ export async function findSnapPackages( * @returns Path to the first matching Snap package, or null if not found */ export async function findSnapPackagePath( - packageNamePattern = 'TobiiDynavox', - fileAdapter?: FileAdapter + packageNamePattern = "TobiiDynavox", + fileAdapter?: FileAdapter, ): Promise { const packages = await findSnapPackages(packageNamePattern, fileAdapter); return packages.length > 0 ? packages[0].packagePath : null; @@ -269,22 +285,25 @@ export async function findSnapPackagePath( * @returns Array of user info with vocab paths */ export async function findSnapUsers( - packageNamePattern = 'TobiiDynavox', - fileAdapter: FileAdapter = defaultFileAdapter + packageNamePattern = "TobiiDynavox", + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { join, listDir, isDirectory, pathExists } = fileAdapter; const results: SnapUserInfo[] = []; - if (process.platform !== 'win32') { + if (process.platform !== "win32") { return results; } - const packagePath = await findSnapPackagePath(packageNamePattern, fileAdapter); + const packagePath = await findSnapPackagePath( + packageNamePattern, + fileAdapter, + ); if (!packagePath) { return results; } - const usersRoot = join(packagePath, 'LocalState', 'Users'); + const usersRoot = join(packagePath, "LocalState", "Users"); if (!(await pathExists(usersRoot))) { return results; } @@ -292,17 +311,17 @@ export async function findSnapUsers( const entries = await listDir(usersRoot); for (const entry of entries) { if (!(await isDirectory(entry))) continue; - if (entry.toLowerCase().startsWith('swiftkey')) continue; + if (entry.toLowerCase().startsWith("swiftkey")) continue; const userPath = join(usersRoot, entry); const vocabPaths = await collectFiles( userPath, (full) => { const ext = extname(full).toLowerCase(); - return ext === '.sps' || ext === '.spb'; + return ext === ".sps" || ext === ".spb"; }, 2, - fileAdapter + fileAdapter, ); results.push({ @@ -323,8 +342,8 @@ export async function findSnapUsers( */ export async function findSnapUserVocabularies( userId?: string, - packageNamePattern = 'TobiiDynavox', - fileAdapter?: FileAdapter + packageNamePattern = "TobiiDynavox", + fileAdapter?: FileAdapter, ): Promise { const allUsers = await findSnapUsers(packageNamePattern, fileAdapter); const users = allUsers.filter((u) => !userId || u.userId === userId); @@ -340,8 +359,8 @@ export async function findSnapUserVocabularies( */ export async function findSnapUserHistory( userId: string, - packageNamePattern = 'TobiiDynavox', - fileAdapter: FileAdapter = defaultFileAdapter + packageNamePattern = "TobiiDynavox", + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { basename } = fileAdapter; const allUsers = await findSnapUsers(packageNamePattern, fileAdapter); @@ -350,17 +369,17 @@ export async function findSnapUserHistory( return await collectFiles( user.userPath, - (full) => basename(full).toLowerCase().includes('history'), + (full) => basename(full).toLowerCase().includes("history"), 2, - fileAdapter + fileAdapter, ); } /** * Check whether TD Snap appears to be installed (Windows only) */ -export function isSnapInstalled(packageNamePattern = 'TobiiDynavox'): boolean { - if (process.platform !== 'win32') return false; +export function isSnapInstalled(packageNamePattern = "TobiiDynavox"): boolean { + if (process.platform !== "win32") return false; return Boolean(findSnapPackagePath(packageNamePattern)); } @@ -369,7 +388,7 @@ export function isSnapInstalled(packageNamePattern = 'TobiiDynavox'): boolean { */ export async function readSnapUsage( pagesetPath: string, - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { pathExists } = fileAdapter; if (!(await pathExists(pagesetPath))) return []; @@ -379,7 +398,7 @@ export async function readSnapUsage( const tableCheck = db .prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('ButtonUsage','Button')" + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('ButtonUsage','Button')", ) .all(); if (tableCheck.length < 2) return []; @@ -398,7 +417,7 @@ export async function readSnapUsage( LEFT JOIN Button b ON bu.ButtonUniqueId = b.UniqueId WHERE bu.Timestamp IS NOT NULL ORDER BY bu.Timestamp ASC - ` + `, ) .all() as Array<{ ButtonId?: string; @@ -412,10 +431,10 @@ export async function readSnapUsage( const events = new Map(); for (const row of rows) { - const buttonId: string = row.ButtonId ?? 'unknown'; + const buttonId: string = row.ButtonId ?? "unknown"; const label = row.Label ?? undefined; const message = row.Message ?? undefined; - const content = message || label || ''; + const content = message || label || ""; const entry = events.get(buttonId) ?? @@ -434,7 +453,7 @@ export async function readSnapUsage( timestamp: dotNetTicksToDate(BigInt(row.TickValue ?? 0)), modeling: row.Modeling === 1, accessMethod: row.AccessMethod ?? null, - type: 'button', + type: "button", buttonId: row.ButtonId, intent: AACSemanticIntent.SPEAK_TEXT, category: AACSemanticCategory.COMMUNICATION, @@ -451,11 +470,13 @@ export async function readSnapUsage( */ export async function readSnapUsageForUser( userId?: string, - packageNamePattern = 'TobiiDynavox' + packageNamePattern = "TobiiDynavox", ): Promise { const allUsers = await findSnapUsers(packageNamePattern); const users = allUsers.filter((u) => !userId || u.userId === userId); const pagesets = users.flatMap((u) => u.vocabPaths); - const usage = await Promise.all(pagesets.map(async (p) => await readSnapUsage(p))); + const usage = await Promise.all( + pagesets.map(async (p) => await readSnapUsage(p)), + ); return usage.flat(); } diff --git a/src/processors/snapProcessor.ts b/src/processors/snapProcessor.ts index 15e021e..62b7fba 100644 --- a/src/processors/snapProcessor.ts +++ b/src/processors/snapProcessor.ts @@ -4,7 +4,7 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from '../core/baseProcessor'; +} from "../core/baseProcessor"; import { AACTree, AACPage, @@ -13,12 +13,12 @@ import { AACSemanticCategory, AACSemanticIntent, SnapMetadata, -} from '../core/treeStructure'; -import { generateCloneId } from '../utilities/analytics/utils/idGenerator'; -import { SnapValidator } from '../validation/snapValidator'; -import { ValidationResult } from '../validation/validationTypes'; -import { ProcessorInput, getNodeRequire, isNodeRuntime } from '../utils/io'; -import { openSqliteDatabase, requireBetterSqlite3 } from '../utils/sqlite'; +} from "../core/treeStructure"; +import { generateCloneId } from "../utilities/analytics/utils/idGenerator"; +import { SnapValidator } from "../validation/snapValidator"; +import { ValidationResult } from "../validation/validationTypes"; +import { ProcessorInput, getNodeRequire, isNodeRuntime } from "../utils/io"; +import { openSqliteDatabase, requireBetterSqlite3 } from "../utils/sqlite"; /** * Convert a Buffer or Uint8Array to base64 string (browser and Node compatible) @@ -27,13 +27,13 @@ import { openSqliteDatabase, requireBetterSqlite3 } from '../utils/sqlite'; */ function arrayBufferToBase64(data: Buffer | Uint8Array): string { // Node.js environment - Buffer has built-in base64 encoding - if (typeof Buffer !== 'undefined' && data instanceof Buffer) { - return data.toString('base64'); + if (typeof Buffer !== "undefined" && data instanceof Buffer) { + return data.toString("base64"); } // Browser environment - use btoa with binary string conversion const bytes = new Uint8Array(data); - let binary = ''; + let binary = ""; const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); @@ -66,11 +66,13 @@ interface SnapButton { * Snap: 0 = hidden, 1 (or non-zero) = visible * Maps to: 'Hidden' | 'Visible' | undefined */ -function mapSnapVisibility(visible: number | null | undefined): 'Hidden' | 'Visible' | undefined { +function mapSnapVisibility( + visible: number | null | undefined, +): "Hidden" | "Visible" | undefined { if (visible === null || visible === undefined) { return undefined; // Default to visible } - return visible === 0 ? 'Hidden' : 'Visible'; + return visible === 0 ? "Hidden" : "Visible"; } interface SnapPage { @@ -84,20 +86,24 @@ interface SnapPage { class SnapProcessor extends BaseProcessor { private symbolResolver: unknown | null = null; private loadAudio: boolean = false; - private pageLayoutPreference: 'largest' | 'smallest' | 'scanning' | number = 'scanning'; // Default to scanning for metrics + private pageLayoutPreference: "largest" | "smallest" | "scanning" | number = + "scanning"; // Default to scanning for metrics constructor( symbolResolver: unknown | null = null, options?: ProcessorOptions & { loadAudio?: boolean; - pageLayoutPreference?: 'largest' | 'smallest' | 'scanning' | number; - } + pageLayoutPreference?: "largest" | "smallest" | "scanning" | number; + }, ) { super(options); this.symbolResolver = symbolResolver; - this.loadAudio = options?.loadAudio !== undefined ? options.loadAudio : true; + this.loadAudio = + options?.loadAudio !== undefined ? options.loadAudio : true; this.pageLayoutPreference = - options?.pageLayoutPreference !== undefined ? options.pageLayoutPreference : 'scanning'; // Default to scanning + options?.pageLayoutPreference !== undefined + ? options.pageLayoutPreference + : "scanning"; // Default to scanning } async extractTexts(filePathOrBuffer: ProcessorInput): Promise { @@ -120,7 +126,8 @@ class SnapProcessor extends BaseProcessor { } async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { - const { writeBinaryToPath, removePath, mkTempDir, basename, join } = this.options.fileAdapter; + const { writeBinaryToPath, removePath, mkTempDir, basename, join } = + this.options.fileAdapter; const tree = new AACTree(); let dbResult: Awaited> | null = null; let cleanupTempZip: (() => Promise) | null = null; @@ -129,20 +136,23 @@ class SnapProcessor extends BaseProcessor { // Handle .sub.zip files (Snap pageset backups containing .sps files) let inputFile = filePathOrBuffer; - if (typeof filePathOrBuffer === 'string') { + if (typeof filePathOrBuffer === "string") { const fileName = basename(filePathOrBuffer).toLowerCase(); - if (fileName.endsWith('.sub.zip') || filePathOrBuffer.endsWith('.sub')) { + if ( + fileName.endsWith(".sub.zip") || + filePathOrBuffer.endsWith(".sub") + ) { // Extract .sub.zip to find the embedded .sps file - const tempDir = await mkTempDir('snap-sub-'); + const tempDir = await mkTempDir("snap-sub-"); const zip = await this.options.zipAdapter(filePathOrBuffer); // Find the .sps file in the archive const files = zip.listFiles(); - const spsFile = files.find((f) => f.endsWith('.sps')); + const spsFile = files.find((f) => f.endsWith(".sps")); if (!spsFile) { await removePath(tempDir, { recursive: true, force: true }); - throw new Error('No .sps file found in .sub.zip archive'); + throw new Error("No .sps file found in .sub.zip archive"); } // Extract the .sps file @@ -165,7 +175,9 @@ class SnapProcessor extends BaseProcessor { const getTableColumns = (tableName: string): Set => { try { - const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ + const rows = db + .prepare(`PRAGMA table_info(${tableName})`) + .all() as Array<{ name: string; }>; return new Set(rows.map((row) => row.name)); @@ -175,7 +187,7 @@ class SnapProcessor extends BaseProcessor { }; // Load pages first, using UniqueId as canonical id - const pages = db.prepare('SELECT * FROM Page').all(); + const pages = db.prepare("SELECT * FROM Page").all(); // Load PageSetProperties to find default Keyboard and Home pages let defaultKeyboardPageId: string | undefined; @@ -183,7 +195,7 @@ class SnapProcessor extends BaseProcessor { let dashboardPageId: string | undefined; let toolbarId: string | undefined; try { - const properties = db.prepare('SELECT * FROM PageSetProperties').get(); + const properties = db.prepare("SELECT * FROM PageSetProperties").get(); if (properties) { defaultKeyboardPageId = properties.DefaultKeyboardPageUniqueId; defaultHomePageId = properties.DefaultHomePageUniqueId; @@ -191,11 +203,11 @@ class SnapProcessor extends BaseProcessor { toolbarId = properties.ToolBarUniqueId; const hasGlobalToolbar = - toolbarId && toolbarId !== '00000000-0000-0000-0000-000000000000'; + toolbarId && toolbarId !== "00000000-0000-0000-0000-000000000000"; // Store metadata in tree const metadata: SnapMetadata = { - format: 'snap', + format: "snap", name: properties.Name || properties.PageSetName || undefined, description: properties.Description || undefined, author: properties.Author || undefined, @@ -218,7 +230,7 @@ class SnapProcessor extends BaseProcessor { } } } catch (e) { - console.warn('[SnapProcessor] Failed to load PageSetProperties:', e); + console.warn("[SnapProcessor] Failed to load PageSetProperties:", e); } // If still no root, fallback to first page (but don't override a valid defaultHomePageId) @@ -250,10 +262,13 @@ class SnapProcessor extends BaseProcessor { // Try to find toolbar page even if not set in PageSetProperties // Some SNAP files have a toolbar page but don't set ToolBarUniqueId // This must be done AFTER pages are added to the tree - if (!tree.toolbarId || tree.toolbarId === '00000000-0000-0000-0000-000000000000') { + if ( + !tree.toolbarId || + tree.toolbarId === "00000000-0000-0000-0000-000000000000" + ) { const toolbarPage = Object.values(tree.pages).find((p) => { - const name = (p.name || '').toLowerCase(); - return name === 'tool bar' || name === 'toolbar'; + const name = (p.name || "").toLowerCase(); + return name === "tool bar" || name === "toolbar"; }); if (toolbarPage) { tree.toolbarId = toolbarPage.id; @@ -275,7 +290,9 @@ class SnapProcessor extends BaseProcessor { const scanGroupsByPageLayout = new Map(); try { const scanGroupRows = db - .prepare('SELECT Id, SerializedGridPositions, PageLayoutId FROM ScanGroup ORDER BY Id') + .prepare( + "SELECT Id, SerializedGridPositions, PageLayoutId FROM ScanGroup ORDER BY Id", + ) .all() as { Id: number; SerializedGridPositions: string; @@ -325,7 +342,7 @@ class SnapProcessor extends BaseProcessor { } } catch (e) { // No ScanGroups table or error loading, continue without scan blocks - console.warn('[SnapProcessor] Failed to load ScanGroups:', e); + console.warn("[SnapProcessor] Failed to load ScanGroups:", e); } // Load buttons per page, using UniqueId for page id @@ -337,25 +354,27 @@ class SnapProcessor extends BaseProcessor { let selectedPageLayoutId: number | null = null; try { const pageLayouts = db - .prepare('SELECT Id, PageLayoutSetting FROM PageLayout WHERE PageId = ?') + .prepare( + "SELECT Id, PageLayoutSetting FROM PageLayout WHERE PageId = ?", + ) .all(pageRow.Id) as { Id: number; PageLayoutSetting: string }[]; if (pageLayouts && pageLayouts.length > 0) { // Parse PageLayoutSetting: "columns,rows,hasScanGroups,?" const layoutsWithInfo = pageLayouts.map((pl) => { - const parts = pl.PageLayoutSetting.split(','); + const parts = pl.PageLayoutSetting.split(","); const cols = parseInt(parts[0], 10) || 0; const rows = parseInt(parts[1], 10) || 0; - const hasScanning = parts[2] === 'True'; + const hasScanning = parts[2] === "True"; const size = cols * rows; return { id: pl.Id, cols, rows, size, hasScanning }; }); // Select based on preference - if (typeof this.pageLayoutPreference === 'number') { + if (typeof this.pageLayoutPreference === "number") { // Specific PageLayoutId selectedPageLayoutId = this.pageLayoutPreference; - } else if (this.pageLayoutPreference === 'largest') { + } else if (this.pageLayoutPreference === "largest") { // Select layout with largest grid size, prefer layouts with ScanGroups layoutsWithInfo.sort((a, b) => { const sizeDiff = b.size - a.size; @@ -366,7 +385,7 @@ class SnapProcessor extends BaseProcessor { return (bHasScanning ? 1 : 0) - (aHasScanning ? 1 : 0); }); selectedPageLayoutId = layoutsWithInfo[0].id; - } else if (this.pageLayoutPreference === 'smallest') { + } else if (this.pageLayoutPreference === "smallest") { // Select layout with smallest grid size, prefer layouts with ScanGroups layoutsWithInfo.sort((a, b) => { const sizeDiff = a.size - b.size; @@ -377,10 +396,10 @@ class SnapProcessor extends BaseProcessor { return (bHasScanning ? 1 : 0) - (aHasScanning ? 1 : 0); }); selectedPageLayoutId = layoutsWithInfo[0].id; - } else if (this.pageLayoutPreference === 'scanning') { + } else if (this.pageLayoutPreference === "scanning") { // Select layout with scanning enabled (check against actual ScanGroups) const scanningLayouts = layoutsWithInfo.filter((l) => - scanGroupsByPageLayout.has(l.id) + scanGroupsByPageLayout.has(l.id), ); if (scanningLayouts.length > 0) { scanningLayouts.sort((a, b) => b.size - a.size); @@ -394,98 +413,134 @@ class SnapProcessor extends BaseProcessor { } } catch (e) { // Error selecting PageLayout, will load all buttons - console.warn(`[SnapProcessor] Failed to select PageLayout for page ${pageRow.Id}:`, e); + console.warn( + `[SnapProcessor] Failed to select PageLayout for page ${pageRow.Id}:`, + e, + ); } // Load buttons let buttons: any[] = []; try { - const buttonColumns = getTableColumns('Button'); + const buttonColumns = getTableColumns("Button"); const selectFields = [ - 'b.Id', - 'b.Label', - 'b.Message', - buttonColumns.has('LibrarySymbolId') ? 'b.LibrarySymbolId' : 'NULL AS LibrarySymbolId', - buttonColumns.has('PageSetImageId') ? 'b.PageSetImageId' : 'NULL AS PageSetImageId', - buttonColumns.has('BorderColor') ? 'b.BorderColor' : 'NULL AS BorderColor', - buttonColumns.has('BorderThickness') ? 'b.BorderThickness' : 'NULL AS BorderThickness', - buttonColumns.has('FontSize') ? 'b.FontSize' : 'NULL AS FontSize', - buttonColumns.has('FontFamily') ? 'b.FontFamily' : 'NULL AS FontFamily', - buttonColumns.has('FontStyle') ? 'b.FontStyle' : 'NULL AS FontStyle', - buttonColumns.has('LabelColor') ? 'b.LabelColor' : 'NULL AS LabelColor', - buttonColumns.has('BackgroundColor') ? 'b.BackgroundColor' : 'NULL AS BackgroundColor', - buttonColumns.has('NavigatePageId') ? 'b.NavigatePageId' : 'NULL AS NavigatePageId', - buttonColumns.has('ContentType') ? 'b.ContentType' : 'NULL AS ContentType', + "b.Id", + "b.Label", + "b.Message", + buttonColumns.has("LibrarySymbolId") + ? "b.LibrarySymbolId" + : "NULL AS LibrarySymbolId", + buttonColumns.has("PageSetImageId") + ? "b.PageSetImageId" + : "NULL AS PageSetImageId", + buttonColumns.has("BorderColor") + ? "b.BorderColor" + : "NULL AS BorderColor", + buttonColumns.has("BorderThickness") + ? "b.BorderThickness" + : "NULL AS BorderThickness", + buttonColumns.has("FontSize") ? "b.FontSize" : "NULL AS FontSize", + buttonColumns.has("FontFamily") + ? "b.FontFamily" + : "NULL AS FontFamily", + buttonColumns.has("FontStyle") + ? "b.FontStyle" + : "NULL AS FontStyle", + buttonColumns.has("LabelColor") + ? "b.LabelColor" + : "NULL AS LabelColor", + buttonColumns.has("BackgroundColor") + ? "b.BackgroundColor" + : "NULL AS BackgroundColor", + buttonColumns.has("NavigatePageId") + ? "b.NavigatePageId" + : "NULL AS NavigatePageId", + buttonColumns.has("ContentType") + ? "b.ContentType" + : "NULL AS ContentType", ]; if (this.loadAudio) { selectFields.push( - buttonColumns.has('MessageRecordingId') - ? 'b.MessageRecordingId' - : 'NULL AS MessageRecordingId' + buttonColumns.has("MessageRecordingId") + ? "b.MessageRecordingId" + : "NULL AS MessageRecordingId", ); selectFields.push( - buttonColumns.has('UseMessageRecording') - ? 'b.UseMessageRecording' - : 'NULL AS UseMessageRecording' + buttonColumns.has("UseMessageRecording") + ? "b.UseMessageRecording" + : "NULL AS UseMessageRecording", ); selectFields.push( - buttonColumns.has('SerializedMessageSoundMetadata') - ? 'b.SerializedMessageSoundMetadata' - : 'NULL AS SerializedMessageSoundMetadata' + buttonColumns.has("SerializedMessageSoundMetadata") + ? "b.SerializedMessageSoundMetadata" + : "NULL AS SerializedMessageSoundMetadata", ); } - const placementColumns = getTableColumns('ElementPlacement'); - const hasButtonPageLink = getTableColumns('ButtonPageLink').size > 0; + const placementColumns = getTableColumns("ElementPlacement"); + const hasButtonPageLink = getTableColumns("ButtonPageLink").size > 0; selectFields.push( - placementColumns.has('GridPosition') ? 'ep.GridPosition' : 'NULL AS GridPosition', - placementColumns.has('PageLayoutId') ? 'ep.PageLayoutId' : 'NULL AS PageLayoutId', - placementColumns.has('Visible') ? 'ep.Visible' : 'NULL AS Visible', - 'er.PageId as ButtonPageId' + placementColumns.has("GridPosition") + ? "ep.GridPosition" + : "NULL AS GridPosition", + placementColumns.has("PageLayoutId") + ? "ep.PageLayoutId" + : "NULL AS PageLayoutId", + placementColumns.has("Visible") ? "ep.Visible" : "NULL AS Visible", + "er.PageId as ButtonPageId", ); if (hasButtonPageLink) { - selectFields.push('bpl.PageUniqueId AS LinkedPageUniqueId'); + selectFields.push("bpl.PageUniqueId AS LinkedPageUniqueId"); } else { - selectFields.push('NULL AS LinkedPageUniqueId'); + selectFields.push("NULL AS LinkedPageUniqueId"); } - const hasCommandSequence = getTableColumns('CommandSequence').size > 0; + const hasCommandSequence = + getTableColumns("CommandSequence").size > 0; if (hasCommandSequence) { - selectFields.push('cs.SerializedCommands'); + selectFields.push("cs.SerializedCommands"); } else { - selectFields.push('NULL AS SerializedCommands'); + selectFields.push("NULL AS SerializedCommands"); } const buttonQuery = ` - SELECT ${selectFields.join(', ')} + SELECT ${selectFields.join(", ")} FROM Button b INNER JOIN ElementReference er ON b.ElementReferenceId = er.Id LEFT JOIN ElementPlacement ep ON ep.ElementReferenceId = er.Id - ${hasButtonPageLink ? 'LEFT JOIN ButtonPageLink bpl ON b.Id = bpl.ButtonId' : ''} - ${hasCommandSequence ? 'LEFT JOIN CommandSequence cs ON b.Id = cs.ButtonId' : ''} - WHERE er.PageId = ? ${selectedPageLayoutId ? 'AND ep.PageLayoutId = ?' : ''} + ${hasButtonPageLink ? "LEFT JOIN ButtonPageLink bpl ON b.Id = bpl.ButtonId" : ""} + ${hasCommandSequence ? "LEFT JOIN CommandSequence cs ON b.Id = cs.ButtonId" : ""} + WHERE er.PageId = ? ${selectedPageLayoutId ? "AND ep.PageLayoutId = ?" : ""} `; if (selectedPageLayoutId) { - buttons = db.prepare(buttonQuery).all(pageRow.Id, selectedPageLayoutId); + buttons = db + .prepare(buttonQuery) + .all(pageRow.Id, selectedPageLayoutId); } else { buttons = db.prepare(buttonQuery).all(pageRow.Id); } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); const errorCode = - err && typeof err === 'object' && 'code' in err ? (err as any).code : undefined; + err && typeof err === "object" && "code" in err + ? (err as any).code + : undefined; if ( - errorCode === 'SQLITE_CORRUPT' || - errorCode === 'SQLITE_NOTADB' || + errorCode === "SQLITE_CORRUPT" || + errorCode === "SQLITE_NOTADB" || /malformed/i.test(errorMessage) ) { - throw new Error(`Snap database is corrupted or incomplete: ${errorMessage}`); + throw new Error( + `Snap database is corrupted or incomplete: ${errorMessage}`, + ); } - console.warn(`Failed to load buttons for page ${pageRow.Id}: ${errorMessage}`); + console.warn( + `Failed to load buttons for page ${pageRow.Id}: ${errorMessage}`, + ); // Skip this page instead of loading all buttons buttons = []; } @@ -511,7 +566,10 @@ class SnapProcessor extends BaseProcessor { buttons.forEach((btnRow) => { // Determine navigation target UniqueId, if possible let targetPageUniqueId: string | undefined = undefined; - if (btnRow.NavigatePageId && idToUniqueId[String(btnRow.NavigatePageId)]) { + if ( + btnRow.NavigatePageId && + idToUniqueId[String(btnRow.NavigatePageId)] + ) { targetPageUniqueId = idToUniqueId[String(btnRow.NavigatePageId)]; } else if (btnRow.LinkedPageUniqueId) { targetPageUniqueId = String(btnRow.LinkedPageUniqueId); @@ -525,16 +583,16 @@ class SnapProcessor extends BaseProcessor { const commands = JSON.parse(btnRow.SerializedCommands as string); const values = commands.$values || []; for (const cmd of values) { - if (cmd.$type === '2' && cmd.LinkedPageId) { + if (cmd.$type === "2" && cmd.LinkedPageId) { // Normal Navigation targetPageUniqueId = String(cmd.LinkedPageId); - } else if (cmd.$type === '16') { + } else if (cmd.$type === "16") { // Go to Home targetPageUniqueId = defaultHomePageId; - } else if (cmd.$type === '17') { + } else if (cmd.$type === "17") { // Go to Keyboard targetPageUniqueId = defaultKeyboardPageId; - } else if (cmd.$type === '18') { + } else if (cmd.$type === "18") { // Go to Dashboard targetPageUniqueId = dashboardPageId; } @@ -545,19 +603,27 @@ class SnapProcessor extends BaseProcessor { } // Determine parent page association for this button - const parentPageId = btnRow.ButtonPageId ? String(btnRow.ButtonPageId) : undefined; + const parentPageId = btnRow.ButtonPageId + ? String(btnRow.ButtonPageId) + : undefined; const parentUniqueId = - parentPageId && idToUniqueId[parentPageId] ? idToUniqueId[parentPageId] : uniqueId; + parentPageId && idToUniqueId[parentPageId] + ? idToUniqueId[parentPageId] + : uniqueId; // Load audio recording if requested and available let audioRecording; - if (this.loadAudio && btnRow.MessageRecordingId && btnRow.MessageRecordingId > 0) { + if ( + this.loadAudio && + btnRow.MessageRecordingId && + btnRow.MessageRecordingId > 0 + ) { try { const recordingData = db .prepare( ` SELECT Id, Identifier, Data FROM PageSetData WHERE Id = ? - ` + `, ) .get(btnRow.MessageRecordingId) as | { Id: number; Identifier: string; Data: Buffer } @@ -572,7 +638,10 @@ class SnapProcessor extends BaseProcessor { }; } } catch (e) { - console.warn(`[SnapProcessor] Failed to load audio for button ${btnRow.Id}:`, e); + console.warn( + `[SnapProcessor] Failed to load audio for button ${btnRow.Id}:`, + e, + ); } } @@ -587,7 +656,7 @@ class SnapProcessor extends BaseProcessor { .prepare( ` SELECT Id, Identifier, Data FROM PageSetData WHERE Id = ? - ` + `, ) .get(btnRow.PageSetImageId) as | { Id: number; Identifier: string; Data: Buffer } @@ -608,11 +677,14 @@ class SnapProcessor extends BaseProcessor { data[3] === 0x47; // Check for JPEG: FF D8 FF const isJpeg = - data.length > 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff; + data.length > 3 && + data[0] === 0xff && + data[1] === 0xd8 && + data[2] === 0xff; if (isPng || isJpeg) { // Actual PNG/JPEG image - can be displayed - const mimeType = isPng ? 'image/png' : 'image/jpeg'; + const mimeType = isPng ? "image/png" : "image/jpeg"; const base64 = arrayBufferToBase64(data); buttonImage = `data:${mimeType};base64,${base64}`; buttonParameters.image_id = imageData.Identifier; @@ -625,7 +697,7 @@ class SnapProcessor extends BaseProcessor { } catch (e) { console.warn( `[SnapProcessor] Failed to load image for button ${btnRow.Id} (PageSetImageId: ${btnRow.PageSetImageId}):`, - e + e, ); } } @@ -645,7 +717,7 @@ class SnapProcessor extends BaseProcessor { }, }, fallback: { - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId: targetPageUniqueId, }, }; @@ -653,28 +725,30 @@ class SnapProcessor extends BaseProcessor { semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: btnRow.Message || btnRow.Label || '', + text: btnRow.Message || btnRow.Label || "", platformData: { snap: { elementReferenceId: btnRow.Id, }, }, fallback: { - type: 'SPEAK', - message: btnRow.Message || btnRow.Label || '', + type: "SPEAK", + message: btnRow.Message || btnRow.Label || "", }, }; } const button = new AACButton({ id: String(btnRow.Id), - label: btnRow.Label || (btnRow.ContentType === 1 ? '[Prediction]' : ''), + label: + btnRow.Label || (btnRow.ContentType === 1 ? "[Prediction]" : ""), message: - btnRow.Message || (btnRow.ContentType === 1 ? '[Prediction]' : btnRow.Label || ''), + btnRow.Message || + (btnRow.ContentType === 1 ? "[Prediction]" : btnRow.Label || ""), targetPageId: targetPageUniqueId, semanticAction: semanticAction, - contentType: btnRow.ContentType === 1 ? 'AutoContent' : undefined, - contentSubType: btnRow.ContentType === 1 ? 'Prediction' : undefined, + contentType: btnRow.ContentType === 1 ? "AutoContent" : undefined, + contentSubType: btnRow.ContentType === 1 ? "Prediction" : undefined, audioRecording: audioRecording, visibility: mapSnapVisibility(btnRow.Visible as number), semantic_id: btnRow.LibrarySymbolId @@ -682,14 +756,21 @@ class SnapProcessor extends BaseProcessor { : undefined, // Extract semantic_id from LibrarySymbolId image: buttonImage, resolvedImageEntry: buttonImage, - parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined, + parameters: + Object.keys(buttonParameters).length > 0 + ? buttonParameters + : undefined, style: { backgroundColor: btnRow.BackgroundColor ? `#${btnRow.BackgroundColor.toString(16)}` : undefined, - borderColor: btnRow.BorderColor ? `#${btnRow.BorderColor.toString(16)}` : undefined, + borderColor: btnRow.BorderColor + ? `#${btnRow.BorderColor.toString(16)}` + : undefined, borderWidth: btnRow.BorderThickness, - fontColor: btnRow.LabelColor ? `#${btnRow.LabelColor.toString(16)}` : undefined, + fontColor: btnRow.LabelColor + ? `#${btnRow.LabelColor.toString(16)}` + : undefined, fontSize: btnRow.FontSize, fontFamily: btnRow.FontFamily, fontStyle: btnRow.FontStyle?.toString(), @@ -702,10 +783,10 @@ class SnapProcessor extends BaseProcessor { parentPage.addButton(button); // Add button to grid layout if position data is available - const gridPositionStr = String(btnRow.GridPosition || ''); - if (gridPositionStr && gridPositionStr.includes(',')) { + const gridPositionStr = String(btnRow.GridPosition || ""); + if (gridPositionStr && gridPositionStr.includes(",")) { // Parse comma-separated coordinates "x,y" - const [xStr, yStr] = gridPositionStr.split(','); + const [xStr, yStr] = gridPositionStr.split(","); const gridX = parseInt(xStr, 10); const gridY = parseInt(yStr, 10); @@ -718,18 +799,25 @@ class SnapProcessor extends BaseProcessor { // IMPORTANT: Only match against ScanGroups from the SAME PageLayout // A button can exist in multiple layouts with different positions const buttonPageLayoutId = btnRow.PageLayoutId as number; - if (buttonPageLayoutId && scanGroupsByPageLayout.has(buttonPageLayoutId)) { - const scanGroups = scanGroupsByPageLayout.get(buttonPageLayoutId); + if ( + buttonPageLayoutId && + scanGroupsByPageLayout.has(buttonPageLayoutId) + ) { + const scanGroups = + scanGroupsByPageLayout.get(buttonPageLayoutId); if (scanGroups && scanGroups.length > 0) { // Find which ScanGroup contains this button's position for (const scanGroup of scanGroups) { // Skip if positions array is null or undefined - if (!scanGroup.positions || !Array.isArray(scanGroup.positions)) { + if ( + !scanGroup.positions || + !Array.isArray(scanGroup.positions) + ) { continue; } const foundInGroup = scanGroup.positions.some( - (pos) => pos.Column === gridX && pos.Row === gridY + (pos) => pos.Column === gridX && pos.Row === gridY, ); if (foundInGroup) { @@ -757,7 +845,13 @@ class SnapProcessor extends BaseProcessor { // Generate clone_id for button at this position const rows = pageGrid.length; const cols = pageGrid[0] ? pageGrid[0].length : 10; - button.clone_id = generateCloneId(rows, cols, gridY, gridX, button.label); + button.clone_id = generateCloneId( + rows, + cols, + gridY, + gridX, + button.label, + ); pageGrid[gridY][gridX] = button; } } @@ -806,15 +900,17 @@ class SnapProcessor extends BaseProcessor { return tree; } catch (error: any) { const fileIdentifier = - typeof filePathOrBuffer === 'string' ? filePathOrBuffer : '[buffer input]'; + typeof filePathOrBuffer === "string" + ? filePathOrBuffer + : "[buffer input]"; // Provide more specific error messages - if (error.code === 'SQLITE_NOTADB') { + if (error.code === "SQLITE_NOTADB") { throw new Error( - `Invalid SQLite database file: ${typeof filePathOrBuffer === 'string' ? filePathOrBuffer : 'buffer'}` + `Invalid SQLite database file: ${typeof filePathOrBuffer === "string" ? filePathOrBuffer : "buffer"}`, ); - } else if (error.code === 'ENOENT') { + } else if (error.code === "ENOENT") { throw new Error(`File not found: ${fileIdentifier}`); - } else if (error.code === 'EACCES') { + } else if (error.code === "EACCES") { throw new Error(`Permission denied accessing file: ${fileIdentifier}`); } else { throw new Error(`Failed to load Snap file: ${error.message}`); @@ -830,7 +926,10 @@ class SnapProcessor extends BaseProcessor { try { await cleanupTempZip(); } catch (e) { - console.warn('[SnapProcessor] Failed to clean up temporary .sps file:', e); + console.warn( + "[SnapProcessor] Failed to clean up temporary .sps file:", + e, + ); } } } @@ -839,15 +938,23 @@ class SnapProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, ): Promise { - const { pathExists, mkDir, writeBinaryToPath, readBinaryFromInput, removePath, dirname } = - this.options.fileAdapter; + const { + pathExists, + mkDir, + writeBinaryToPath, + readBinaryFromInput, + removePath, + dirname, + } = this.options.fileAdapter; if (!isNodeRuntime()) { - throw new Error('processTexts is only supported in Node.js environments for Snap files.'); + throw new Error( + "processTexts is only supported in Node.js environments for Snap files.", + ); } - if (typeof filePathOrBuffer === 'string') { + if (typeof filePathOrBuffer === "string") { const inputPath = filePathOrBuffer; const outputDir = dirname(outputPath); const dirExists = await pathExists(outputDir); @@ -864,7 +971,9 @@ class SnapProcessor extends BaseProcessor { try { const getColumns = (tableName: string): Set => { try { - const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ + const rows = db + .prepare(`PRAGMA table_info(${tableName})`) + .all() as Array<{ name: string; }>; return new Set(rows.map((row) => row.name)); @@ -873,36 +982,36 @@ class SnapProcessor extends BaseProcessor { } }; - const pageColumns = getColumns('Page'); - const buttonColumns = getColumns('Button'); + const pageColumns = getColumns("Page"); + const buttonColumns = getColumns("Button"); const pageUpdates: string[] = []; const pageWhere: string[] = []; - const pageColumnsToUse: Array<'Name' | 'Title'> = []; + const pageColumnsToUse: Array<"Name" | "Title"> = []; - if (pageColumns.has('Name')) { - pageUpdates.push('Name = ?'); - pageWhere.push('Name = ?'); - pageColumnsToUse.push('Name'); + if (pageColumns.has("Name")) { + pageUpdates.push("Name = ?"); + pageWhere.push("Name = ?"); + pageColumnsToUse.push("Name"); } - if (pageColumns.has('Title')) { - pageUpdates.push('Title = ?'); - pageWhere.push('Title = ?'); - pageColumnsToUse.push('Title'); + if (pageColumns.has("Title")) { + pageUpdates.push("Title = ?"); + pageWhere.push("Title = ?"); + pageColumnsToUse.push("Title"); } const updatePage = pageUpdates.length > 0 ? db.prepare( - `UPDATE Page SET ${pageUpdates.join(', ')} WHERE ${pageWhere.join(' OR ')}` + `UPDATE Page SET ${pageUpdates.join(", ")} WHERE ${pageWhere.join(" OR ")}`, ) : null; - const updateLabel = buttonColumns.has('Label') - ? db.prepare('UPDATE Button SET Label = ? WHERE Label = ?') + const updateLabel = buttonColumns.has("Label") + ? db.prepare("UPDATE Button SET Label = ? WHERE Label = ?") : null; - const updateMessage = buttonColumns.has('Message') - ? db.prepare('UPDATE Button SET Message = ? WHERE Message = ?') + const updateMessage = buttonColumns.has("Message") + ? db.prepare("UPDATE Button SET Message = ? WHERE Message = ?") : null; const entries = Array.from(translations.entries()); @@ -967,7 +1076,9 @@ class SnapProcessor extends BaseProcessor { async saveFromTree(tree: AACTree, outputPath: string): Promise { const { pathExists, mkDir, removePath, dirname } = this.options.fileAdapter; if (!isNodeRuntime()) { - throw new Error('saveFromTree is only supported in Node.js environments for Snap files.'); + throw new Error( + "saveFromTree is only supported in Node.js environments for Snap files.", + ); } const outputDir = dirname(outputPath); const dirExists = await pathExists(outputDir); @@ -1055,10 +1166,10 @@ class SnapProcessor extends BaseProcessor { const pageIdMap = new Map(); const pageSetDataIdentifierMap = new Map(); const insertPageSetData = db.prepare( - 'INSERT INTO PageSetData (Id, Identifier, Data, RefCount) VALUES (?, ?, ?, ?)' + "INSERT INTO PageSetData (Id, Identifier, Data, RefCount) VALUES (?, ?, ?, ?)", ); const incrementRefCount = db.prepare( - 'UPDATE PageSetData SET RefCount = RefCount + 1 WHERE Id = ?' + "UPDATE PageSetData SET RefCount = RefCount + 1 WHERE Id = ?", ); // First pass: create all pages @@ -1067,16 +1178,16 @@ class SnapProcessor extends BaseProcessor { pageIdMap.set(page.id, numericPageId); const insertPage = db.prepare( - 'INSERT INTO Page (Id, UniqueId, Title, Name, BackgroundColor) VALUES (?, ?, ?, ?, ?)' + "INSERT INTO Page (Id, UniqueId, Title, Name, BackgroundColor) VALUES (?, ?, ?, ?, ?)", ); insertPage.run( numericPageId, page.id, - page.name || '', - page.name || '', + page.name || "", + page.name || "", page.style?.backgroundColor - ? parseInt(page.style.backgroundColor.replace('#', ''), 16) - : null + ? parseInt(page.style.backgroundColor.replace("#", ""), 16) + : null, ); }); @@ -1108,7 +1219,7 @@ class SnapProcessor extends BaseProcessor { // Insert ElementReference const insertElementRef = db.prepare( - 'INSERT INTO ElementReference (Id, PageId) VALUES (?, ?)' + "INSERT INTO ElementReference (Id, PageId) VALUES (?, ?)", ); insertElementRef.run(elementRefId, numericPageId); @@ -1117,12 +1228,13 @@ class SnapProcessor extends BaseProcessor { // Use semantic action if available if (button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO) { - const targetId = button.semanticAction.targetId || button.targetPageId; + const targetId = + button.semanticAction.targetId || button.targetPageId; navigatePageId = targetId ? pageIdMap.get(targetId) || null : null; } const insertButton = db.prepare( - 'INSERT INTO Button (Id, Label, Message, NavigatePageId, ElementReferenceId, LibrarySymbolId, PageSetImageId, MessageRecordingId, SerializedMessageSoundMetadata, UseMessageRecording, LabelColor, BackgroundColor, BorderColor, BorderThickness, FontSize, FontFamily, FontStyle) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + "INSERT INTO Button (Id, Label, Message, NavigatePageId, ElementReferenceId, LibrarySymbolId, PageSetImageId, MessageRecordingId, SerializedMessageSoundMetadata, UseMessageRecording, LabelColor, BackgroundColor, BorderColor, BorderThickness, FontSize, FontFamily, FontStyle) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ); const audio = button.audioRecording; @@ -1152,14 +1264,23 @@ class SnapProcessor extends BaseProcessor { // Handle image data from button.parameters.imageData or button.image (data URL) let pageSetImageId: number | null = null; - if (button.parameters?.imageData && Buffer.isBuffer(button.parameters.imageData)) { + if ( + button.parameters?.imageData && + Buffer.isBuffer(button.parameters.imageData) + ) { // Use existing image data buffer const imageIdentifier: string = - (button.parameters.image_id as string) || `IMG_${buttonIdCounter}`; + (button.parameters.image_id as string) || + `IMG_${buttonIdCounter}`; let imageId = pageSetDataIdentifierMap.get(imageIdentifier); if (!imageId) { imageId = pageSetDataIdCounter++; - insertPageSetData.run(imageId, imageIdentifier, button.parameters.imageData, 1); + insertPageSetData.run( + imageId, + imageIdentifier, + button.parameters.imageData, + 1, + ); pageSetDataIdentifierMap.set(imageIdentifier, imageId); } else { incrementRefCount.run(imageId); @@ -1167,16 +1288,19 @@ class SnapProcessor extends BaseProcessor { pageSetImageId = imageId; } else if ( button.image && - typeof button.image === 'string' && - button.image.startsWith('data:image') + typeof button.image === "string" && + button.image.startsWith("data:image") ) { // Convert data URL to buffer try { - const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/); + const matches = button.image.match( + /^data:image\/(\w+);base64,(.+)$/, + ); if (matches && matches[2]) { - const imageData = Buffer.from(matches[2], 'base64'); + const imageData = Buffer.from(matches[2], "base64"); const imageIdentifier: string = - (button.parameters?.image_id as string) || `IMG_${buttonIdCounter}`; + (button.parameters?.image_id as string) || + `IMG_${buttonIdCounter}`; let imageId = pageSetDataIdentifierMap.get(imageIdentifier); if (!imageId) { imageId = pageSetDataIdCounter++; @@ -1190,7 +1314,7 @@ class SnapProcessor extends BaseProcessor { } catch (err) { console.warn( `[SnapProcessor] Failed to convert data URL to Buffer for button ${button.id}:`, - err + err, ); } } @@ -1201,8 +1325,8 @@ class SnapProcessor extends BaseProcessor { try { insertButton.run( buttonIdCounter++, - button.label || '', - button.message || button.label || '', + button.label || "", + button.message || button.label || "", navigatePageId, elementRefId, null, // LibrarySymbolId - not used for embedded images @@ -1211,22 +1335,24 @@ class SnapProcessor extends BaseProcessor { serializedMetadata, useMessageRecording, button.style?.fontColor - ? parseInt(button.style.fontColor.replace('#', ''), 16) + ? parseInt(button.style.fontColor.replace("#", ""), 16) : null, button.style?.backgroundColor - ? parseInt(button.style.backgroundColor.replace('#', ''), 16) + ? parseInt(button.style.backgroundColor.replace("#", ""), 16) : null, button.style?.borderColor - ? parseInt(button.style.borderColor.replace('#', ''), 16) + ? parseInt(button.style.borderColor.replace("#", ""), 16) : null, button.style?.borderWidth, button.style?.fontSize, button.style?.fontFamily, - button.style?.fontStyle ? parseInt(button.style.fontStyle) : null + button.style?.fontStyle + ? parseInt(button.style.fontStyle) + : null, ); break; // Success } catch (err: any) { - if (err.code === 'SQLITE_IOERR' && retries > 1) { + if (err.code === "SQLITE_IOERR" && retries > 1) { retries--; // Wait a bit before retrying const now = Date.now(); @@ -1241,7 +1367,7 @@ class SnapProcessor extends BaseProcessor { // Insert ElementPlacement const insertPlacement = db.prepare( - 'INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition) VALUES (?, ?, ?)' + "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition) VALUES (?, ?, ?)", ); insertPlacement.run(placementIdCounter++, elementRefId, gridPosition); }); @@ -1265,7 +1391,9 @@ class SnapProcessor extends BaseProcessor { tree.metadata?.defaultHomePageId || tree.rootId || null, tree.metadata?.defaultKeyboardPageId || null, tree.metadata?.dashboardId || null, - tree.metadata?.hasGlobalToolbar ? tree.metadata.toolbarId || null : null + tree.metadata?.hasGlobalToolbar + ? tree.metadata.toolbarId || null + : null, ); } finally { db.close(); @@ -1279,13 +1407,15 @@ class SnapProcessor extends BaseProcessor { dbPath: string, buttonId: number, audioData: Uint8Array, - metadata?: string + metadata?: string, ): Promise { if (!isNodeRuntime()) { - throw new Error('addAudioToButton is only supported in Node.js environments.'); + throw new Error( + "addAudioToButton is only supported in Node.js environments.", + ); } const Database = requireBetterSqlite3(); - const crypto = getNodeRequire()('crypto') as typeof import('crypto'); + const crypto = getNodeRequire()("crypto") as typeof import("crypto"); const db = new Database(dbPath, { fileMustExist: true }); try { @@ -1299,13 +1429,16 @@ class SnapProcessor extends BaseProcessor { `); // Generate SHA1 hash for the identifier - const sha1Hash = crypto.createHash('sha1').update(audioData).digest('hex'); + const sha1Hash = crypto + .createHash("sha1") + .update(audioData) + .digest("hex"); const identifier = `SND:${sha1Hash}`; // Check if audio with this identifier already exists let audioId; const existingAudio = db - .prepare('SELECT Id FROM PageSetData WHERE Identifier = ?') + .prepare("SELECT Id FROM PageSetData WHERE Identifier = ?") .get(identifier) as { Id: number } | undefined; if (existingAudio) { @@ -1313,16 +1446,18 @@ class SnapProcessor extends BaseProcessor { } else { // Insert new audio data const result = db - .prepare('INSERT INTO PageSetData (Identifier, Data) VALUES (?, ?)') + .prepare("INSERT INTO PageSetData (Identifier, Data) VALUES (?, ?)") .run(identifier, audioData); audioId = Number(result.lastInsertRowid); } // Update button to reference the audio const updateButton = db.prepare( - 'UPDATE Button SET MessageRecordingId = ?, UseMessageRecording = 1, SerializedMessageSoundMetadata = ? WHERE Id = ?' + "UPDATE Button SET MessageRecordingId = ?, UseMessageRecording = 1, SerializedMessageSoundMetadata = ? WHERE Id = ?", ); - const metadataJson = metadata ? JSON.stringify({ FileName: metadata }) : null; + const metadataJson = metadata + ? JSON.stringify({ FileName: metadata }) + : null; updateButton.run(audioId, metadataJson, buttonId); return Promise.resolve(audioId); @@ -1337,18 +1472,28 @@ class SnapProcessor extends BaseProcessor { async createAudioEnhancedPageset( sourceDbPath: string, targetDbPath: string, - audioMappings: Map + audioMappings: Map, ): Promise { const { writeBinaryToPath, readBinaryFromInput } = this.options.fileAdapter; if (!isNodeRuntime()) { - throw new Error('createAudioEnhancedPageset is only supported in Node.js environments.'); + throw new Error( + "createAudioEnhancedPageset is only supported in Node.js environments.", + ); } // Copy the source database to target - await writeBinaryToPath(targetDbPath, await readBinaryFromInput(sourceDbPath)); + await writeBinaryToPath( + targetDbPath, + await readBinaryFromInput(sourceDbPath), + ); // Add audio recordings to the copy for (const [buttonId, audioInfo] of audioMappings.entries()) { - await this.addAudioToButton(targetDbPath, buttonId, audioInfo.audioData, audioInfo.metadata); + await this.addAudioToButton( + targetDbPath, + buttonId, + audioInfo.audioData, + audioInfo.metadata, + ); } } @@ -1357,7 +1502,7 @@ class SnapProcessor extends BaseProcessor { */ extractButtonsForAudio( dbPath: string, - pageUniqueId: string + pageUniqueId: string, ): Array<{ id: number; label: string; @@ -1365,16 +1510,18 @@ class SnapProcessor extends BaseProcessor { hasAudio: boolean; }> { if (!isNodeRuntime()) { - throw new Error('extractButtonsForAudio is only supported in Node.js environments.'); + throw new Error( + "extractButtonsForAudio is only supported in Node.js environments.", + ); } const Database = requireBetterSqlite3(); const db = new Database(dbPath, { readonly: true }); try { // Find the page by UniqueId - const page = db.prepare('SELECT * FROM Page WHERE UniqueId = ?').get(pageUniqueId) as - | { Id: number } - | undefined; + const page = db + .prepare("SELECT * FROM Page WHERE UniqueId = ?") + .get(pageUniqueId) as { Id: number } | undefined; if (!page) { throw new Error(`Page with UniqueId ${pageUniqueId} not found`); } @@ -1388,7 +1535,7 @@ class SnapProcessor extends BaseProcessor { FROM Button b JOIN ElementReference er ON b.ElementReferenceId = er.Id WHERE er.PageId = ? - ` + `, ) .all(page.Id) as Array<{ Id: number; @@ -1400,8 +1547,8 @@ class SnapProcessor extends BaseProcessor { return buttons.map((btn) => ({ id: btn.Id, - label: btn.Label || '', - message: btn.Message || btn.Label || '', + label: btn.Label || "", + message: btn.Message || btn.Label || "", hasAudio: !!(btn.MessageRecordingId && btn.MessageRecordingId > 0), })); } finally { @@ -1413,7 +1560,9 @@ class SnapProcessor extends BaseProcessor { * Extract strings with metadata for aac-tools-platform compatibility * Uses the generic implementation from BaseProcessor */ - async extractStringsWithMetadata(filePath: string): Promise { + async extractStringsWithMetadata( + filePath: string, + ): Promise { return this.extractStringsWithMetadataGeneric(filePath); } @@ -1424,9 +1573,13 @@ class SnapProcessor extends BaseProcessor { async generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { - return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); + return this.generateTranslatedDownloadGeneric( + filePath, + translatedStrings, + sourceStrings, + ); } /** @@ -1445,11 +1598,15 @@ class SnapProcessor extends BaseProcessor { * @returns Promise resolving to available PageLayouts with their dimensions */ async getAvailablePageLayouts(filePath: string): Promise { - const { writeBinaryToPath, removePath, pathExists, join } = this.options.fileAdapter; + const { writeBinaryToPath, removePath, pathExists, join } = + this.options.fileAdapter; if (!isNodeRuntime()) { - throw new Error('getAvailablePageLayouts is only supported in Node.js environments.'); + throw new Error( + "getAvailablePageLayouts is only supported in Node.js environments.", + ); } - const dbPath = typeof filePath === 'string' ? filePath : join(process.cwd(), 'temp.spb'); + const dbPath = + typeof filePath === "string" ? filePath : join(process.cwd(), "temp.spb"); if (Buffer.isBuffer(filePath)) { await writeBinaryToPath(dbPath, filePath); @@ -1470,16 +1627,16 @@ class SnapProcessor extends BaseProcessor { FROM PageLayout pl GROUP BY pl.PageLayoutSetting ORDER BY pl.PageLayoutSetting - ` + `, ) .all() as Array<{ Id: number; PageLayoutSetting: string }>; // Parse the PageLayoutSetting format: "columns,rows,hasScanGroups,?" const layouts: PageLayoutInfo[] = pageLayouts.map((pl) => { - const parts = pl.PageLayoutSetting.split(','); + const parts = pl.PageLayoutSetting.split(","); const cols = parseInt(parts[0], 10) || 0; const rows = parseInt(parts[1], 10) || 0; - const hasScanning = parts[2] === 'True'; + const hasScanning = parts[2] === "True"; return { id: pl.Id, @@ -1487,7 +1644,7 @@ class SnapProcessor extends BaseProcessor { rows, size: cols * rows, hasScanning, - label: `${cols}×${rows}${hasScanning ? ' (with scanning)' : ''}`, + label: `${cols}×${rows}${hasScanning ? " (with scanning)" : ""}`, }; }); @@ -1500,7 +1657,10 @@ class SnapProcessor extends BaseProcessor { return layouts; } catch (error) { - console.error('[SnapProcessor] Failed to get available page layouts:', error); + console.error( + "[SnapProcessor] Failed to get available page layouts:", + error, + ); return []; } finally { if (db) { @@ -1513,7 +1673,7 @@ class SnapProcessor extends BaseProcessor { try { await removePath(dbPath); } catch (e) { - console.warn('Failed to clean up temporary file:', e); + console.warn("Failed to clean up temporary file:", e); } } } diff --git a/src/processors/touchchat/helpers.ts b/src/processors/touchchat/helpers.ts index 08b636d..6d11cbf 100644 --- a/src/processors/touchchat/helpers.ts +++ b/src/processors/touchchat/helpers.ts @@ -1,4 +1,4 @@ -import { AACTree } from '../../core/treeStructure'; +import { AACTree } from "../../core/treeStructure"; // Minimal TouchChat helpers (stubs) to align with processors//helpers pattern // NOTE: TouchChat buttons currently do not populate resolvedImageEntry; these helpers @@ -8,7 +8,10 @@ import { AACTree } from '../../core/treeStructure'; * Build a map of button IDs to resolved image entry strings for a page. * Returns an empty map when no images are present. */ -export function getPageTokenImageMap(tree: AACTree, pageId: string): Map { +export function getPageTokenImageMap( + tree: AACTree, + pageId: string, +): Map { const map = new Map(); const page = tree.getPage(pageId); if (!page) return map; @@ -30,6 +33,9 @@ export function getAllowedImageEntries(_tree: AACTree): Set { * Read a binary asset from a .ce file. * Not implemented yet; provided for API symmetry with other processors. */ -export function openImage(_ceFile: string | Buffer, _entryPath: string): Buffer | null { +export function openImage( + _ceFile: string | Buffer, + _entryPath: string, +): Buffer | null { return null; } diff --git a/src/processors/touchchatProcessor.ts b/src/processors/touchchatProcessor.ts index 3cc772a..575a0d4 100644 --- a/src/processors/touchchatProcessor.ts +++ b/src/processors/touchchatProcessor.ts @@ -6,7 +6,7 @@ import { SourceString, VocabLocation, ExtractedString, -} from '../core/baseProcessor'; +} from "../core/baseProcessor"; import { AACTree, AACPage, @@ -14,25 +14,25 @@ import { AACSemanticAction, AACSemanticCategory, AACSemanticIntent, -} from '../core/treeStructure'; -import { generateCloneId } from '../utilities/analytics/utils/idGenerator'; -import { detectCasing, isNumericOrEmpty } from '../core/stringCasing'; -import { TouchChatValidator } from '../validation/touchChatValidator'; -import { ValidationResult } from '../validation/validationTypes'; -import { ProcessorInput, isNodeRuntime } from '../utils/io'; +} from "../core/treeStructure"; +import { generateCloneId } from "../utilities/analytics/utils/idGenerator"; +import { detectCasing, isNumericOrEmpty } from "../core/stringCasing"; +import { TouchChatValidator } from "../validation/touchChatValidator"; +import { ValidationResult } from "../validation/validationTypes"; +import { ProcessorInput, isNodeRuntime } from "../utils/io"; import { extractAllButtonsForTranslation, validateTranslationResults, type ButtonForTranslation, type LLMLTranslationResult, -} from '../utilities/translation/translationProcessor'; +} from "../utilities/translation/translationProcessor"; import { openSqliteDatabase, requireBetterSqlite3, type SqliteDatabaseAdapter, -} from '../utils/sqlite'; -import { getZipEntriesFromAdapter } from './gridset'; -import { ZipFile } from '../utils/zip'; +} from "../utils/sqlite"; +import { getZipEntriesFromAdapter } from "./gridset"; +import { ZipFile } from "../utils/zip"; interface TouchChatButton { id: number; @@ -56,14 +56,18 @@ interface TouchChatPage { feature: number | null; } -const toNumberOrUndefined = (value: number | null | undefined): number | undefined => - typeof value === 'number' ? value : undefined; +const toNumberOrUndefined = ( + value: number | null | undefined, +): number | undefined => (typeof value === "number" ? value : undefined); -const toStringOrUndefined = (value: string | null | undefined): string | undefined => - typeof value === 'string' && value.length > 0 ? value : undefined; +const toStringOrUndefined = ( + value: string | null | undefined, +): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; -const toBooleanOrUndefined = (value: number | null | undefined): boolean | undefined => - typeof value === 'number' ? value !== 0 : undefined; +const toBooleanOrUndefined = ( + value: number | null | undefined, +): boolean | undefined => (typeof value === "number" ? value !== 0 : undefined); interface TouchChatButtonStyle { id: number; @@ -86,11 +90,11 @@ interface TouchChatPageStyle { } function intToHex(colorInt: number | null | undefined): string | undefined { - if (colorInt === null || typeof colorInt === 'undefined') { + if (colorInt === null || typeof colorInt === "undefined") { return undefined; } // Assuming the color is in ARGB format, we mask out the alpha channel - return `#${(colorInt & 0x00ffffff).toString(16).padStart(6, '0')}`; + return `#${(colorInt & 0x00ffffff).toString(16).padStart(6, "0")}`; } /** @@ -99,12 +103,12 @@ function intToHex(colorInt: number | null | undefined): string | undefined { * Maps to: 'Hidden' | 'Visible' | undefined */ function mapTouchChatVisibility( - visible: number | null | undefined -): 'Visible' | 'Hidden' | undefined { + visible: number | null | undefined, +): "Visible" | "Hidden" | undefined { if (visible === null || visible === undefined) { return undefined; // Default to visible } - return visible === 0 ? 'Hidden' : 'Visible'; + return visible === 0 ? "Hidden" : "Visible"; } class TouchChatProcessor extends BaseProcessor { @@ -121,7 +125,7 @@ class TouchChatProcessor extends BaseProcessor { this.tree = await this.loadIntoTree(filePathOrBuffer); } if (!this.tree) { - throw new Error('No tree available - call loadIntoTree first'); + throw new Error("No tree available - call loadIntoTree first"); } const texts: string[] = []; for (const pageId in this.tree.pages) { @@ -148,9 +152,9 @@ class TouchChatProcessor extends BaseProcessor { // Step 1: Unzip const zipInput = await readBinaryFromInput(filePathOrBuffer); const zip = await this.options.zipAdapter(zipInput); - const vocabEntry = zip.listFiles().find((name) => name.endsWith('.c4v')); + const vocabEntry = zip.listFiles().find((name) => name.endsWith(".c4v")); if (!vocabEntry) { - throw new Error('No .c4v vocab DB found in TouchChat export'); + throw new Error("No .c4v vocab DB found in TouchChat export"); } const dbBuffer = await zip.readFile(vocabEntry); const dbResult = await openSqliteDatabase(dbBuffer, { @@ -169,7 +173,9 @@ class TouchChatProcessor extends BaseProcessor { const getTableColumns = (tableName: string): Set => { if (!db) return new Set(); try { - const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ + const rows = db + .prepare(`PRAGMA table_info(${tableName})`) + .all() as Array<{ name: string; }>; return new Set(rows.map((row) => row.name)); @@ -182,7 +188,8 @@ class TouchChatProcessor extends BaseProcessor { const idMappings = new Map(); const numericToRid = new Map(); try { - const mappingQuery = 'SELECT numeric_id, string_id FROM page_id_mapping'; + const mappingQuery = + "SELECT numeric_id, string_id FROM page_id_mapping"; const mappings = db.prepare(mappingQuery).all() as { numeric_id: number; string_id: string; @@ -199,12 +206,14 @@ class TouchChatProcessor extends BaseProcessor { const pageStyles = new Map(); try { const buttonStyleRows = db - .prepare('SELECT * FROM button_styles') + .prepare("SELECT * FROM button_styles") .all() as TouchChatButtonStyle[]; buttonStyleRows.forEach((style) => { buttonStyles.set(style.id, style); }); - const pageStyleRows = db.prepare('SELECT * FROM page_styles').all() as TouchChatPageStyle[]; + const pageStyleRows = db + .prepare("SELECT * FROM page_styles") + .all() as TouchChatPageStyle[]; pageStyleRows.forEach((style) => { pageStyles.set(style.id, style); }); @@ -213,11 +222,11 @@ class TouchChatProcessor extends BaseProcessor { } // First, load all pages and get their names from resources - const resourceColumns = getTableColumns('resources'); - const hasRid = resourceColumns.has('rid'); + const resourceColumns = getTableColumns("resources"); + const hasRid = resourceColumns.has("rid"); const pageQuery = ` - SELECT p.*, r.name${hasRid ? ', r.rid' : ''} + SELECT p.*, r.name${hasRid ? ", r.rid" : ""} FROM pages p JOIN resources r ON r.id = p.resource_id `; @@ -228,13 +237,15 @@ class TouchChatProcessor extends BaseProcessor { pages.forEach((pageRow) => { // Use resource RID (UUID) if available, otherwise mapped string ID, then numeric ID const pageId = - (hasRid ? pageRow.rid : null) || idMappings.get(pageRow.id) || String(pageRow.id); + (hasRid ? pageRow.rid : null) || + idMappings.get(pageRow.id) || + String(pageRow.id); numericToRid.set(pageRow.id, pageId); const style = pageStyles.get(pageRow.page_style_id); const page = new AACPage({ id: pageId, - name: pageRow.name || '', + name: pageRow.name || "", grid: [], buttons: [], parentId: null, @@ -258,7 +269,9 @@ class TouchChatProcessor extends BaseProcessor { JOIN button_boxes bb ON bb.id = bbc.button_box_id `; try { - const buttonBoxCells = db.prepare(buttonBoxQuery).all() as (TouchChatButton & { + const buttonBoxCells = db + .prepare(buttonBoxQuery) + .all() as (TouchChatButton & { box_id: number; layout_x: number; layout_y: number; @@ -294,31 +307,31 @@ class TouchChatProcessor extends BaseProcessor { const semanticAction: AACSemanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: cell.message || cell.label || '', + text: cell.message || cell.label || "", platformData: { touchChat: { actionCode: 0, // Default speak action - actionData: cell.message || cell.label || '', + actionData: cell.message || cell.label || "", resourceId: cell.resource_id, }, }, fallback: { - type: 'SPEAK', - message: cell.message || cell.label || '', + type: "SPEAK", + message: cell.message || cell.label || "", }, }; const button = new AACButton({ id: String(cell.id), - label: cell.label || '', - message: cell.message || '', + label: cell.label || "", + message: cell.message || "", semanticAction: semanticAction, semantic_id: (((cell as any).symbol_link_id || (cell as any).symbolLinkId) as | string | undefined) || undefined, // Extract semantic_id from symbol_link_id visibility: mapTouchChatVisibility( - ((cell as any).visible as number | null | undefined) || undefined + ((cell as any).visible as number | null | undefined) || undefined, ), // Note: TouchChat does not use scan blocks in the file // Scanning is a runtime feature (linear/row-column patterns) @@ -330,8 +343,8 @@ class TouchChatProcessor extends BaseProcessor { fontColor: intToHex(style?.font_color), fontSize: toNumberOrUndefined(style?.font_height), fontFamily: toStringOrUndefined(style?.font_name), - fontWeight: style?.font_bold ? 'bold' : undefined, - fontStyle: style?.font_italic ? 'italic' : undefined, + fontWeight: style?.font_bold ? "bold" : undefined, + fontStyle: style?.font_italic ? "italic" : undefined, textUnderline: toBooleanOrUndefined(style?.font_underline), transparent: toBooleanOrUndefined(style?.transparent), labelOnTop: toBooleanOrUndefined(style?.label_on_top), @@ -346,7 +359,9 @@ class TouchChatProcessor extends BaseProcessor { }); // Map button boxes to pages - const boxInstances = db.prepare('SELECT * FROM button_box_instances').all() as { + const boxInstances = db + .prepare("SELECT * FROM button_box_instances") + .all() as { id: number; page_id: number; button_box_id: number; @@ -361,7 +376,8 @@ class TouchChatProcessor extends BaseProcessor { boxInstances.forEach((instance) => { // Use mapped string ID if available, otherwise use numeric ID as string - const pageId = numericToRid.get(instance.page_id) || String(instance.page_id); + const pageId = + numericToRid.get(instance.page_id) || String(instance.page_id); const page = tree.getPage(pageId); const boxData = buttonBoxes.get(instance.button_box_id); if (page && boxData) { @@ -404,8 +420,16 @@ class TouchChatProcessor extends BaseProcessor { page.addButton(button); // Place button in grid (handle span) - for (let r = absoluteY; r < absoluteY + safeSpanY && r < 10; r++) { - for (let c = absoluteX; c < absoluteX + safeSpanX && c < 10; c++) { + for ( + let r = absoluteY; + r < absoluteY + safeSpanY && r < 10; + r++ + ) { + for ( + let c = absoluteX; + c < absoluteX + safeSpanX && c < 10; + c++ + ) { if (pageGrid && pageGrid[r] && pageGrid[r][c] === null) { pageGrid[r][c] = button; } @@ -431,7 +455,13 @@ class TouchChatProcessor extends BaseProcessor { // Generate clone_id based on position and label const rows = grid.length; const cols = grid[0] ? grid[0].length : 10; - btn.clone_id = generateCloneId(rows, cols, rowIndex, colIndex, btn.label); + btn.clone_id = generateCloneId( + rows, + cols, + rowIndex, + colIndex, + btn.label, + ); cloneIds.push(btn.clone_id); // Track semantic_id if present @@ -463,7 +493,9 @@ class TouchChatProcessor extends BaseProcessor { WHERE r.type = 7 `; try { - const pageButtons = db.prepare(pageButtonsQuery).all() as (TouchChatButton & { + const pageButtons = db + .prepare(pageButtonsQuery) + .all() as (TouchChatButton & { type: number; })[]; pageButtons.forEach((btnRow) => { @@ -472,23 +504,23 @@ class TouchChatProcessor extends BaseProcessor { const semanticAction: AACSemanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: btnRow.message || btnRow.label || '', + text: btnRow.message || btnRow.label || "", platformData: { touchChat: { actionCode: 0, // Default speak action - actionData: btnRow.message || btnRow.label || '', + actionData: btnRow.message || btnRow.label || "", }, }, fallback: { - type: 'SPEAK', - message: btnRow.message || btnRow.label || '', + type: "SPEAK", + message: btnRow.message || btnRow.label || "", }, }; const button = new AACButton({ id: String(btnRow.id), - label: btnRow.label || '', - message: btnRow.message || '', + label: btnRow.label || "", + message: btnRow.message || "", semanticAction: semanticAction, visibility: mapTouchChatVisibility(btnRow.visible), // Note: TouchChat does not use scan blocks in the file @@ -501,8 +533,8 @@ class TouchChatProcessor extends BaseProcessor { fontColor: intToHex(style?.font_color), fontSize: toNumberOrUndefined(style?.font_height), fontFamily: toStringOrUndefined(style?.font_name), - fontWeight: style?.font_bold ? 'bold' : undefined, - fontStyle: style?.font_italic ? 'italic' : undefined, + fontWeight: style?.font_bold ? "bold" : undefined, + fontStyle: style?.font_italic ? "italic" : undefined, textUnderline: toBooleanOrUndefined(style?.font_underline), transparent: toBooleanOrUndefined(style?.transparent), labelOnTop: toBooleanOrUndefined(style?.label_on_top), @@ -510,7 +542,7 @@ class TouchChatProcessor extends BaseProcessor { }); // Find the page that references this resource const page = Object.values(tree.pages).find( - (p) => p.id === (numericToRid.get(btnRow.id) || String(btnRow.id)) + (p) => p.id === (numericToRid.get(btnRow.id) || String(btnRow.id)), ); if (page) page.addButton(button); }); @@ -520,10 +552,10 @@ class TouchChatProcessor extends BaseProcessor { // Load navigation actions const navActionsQuery = ` - SELECT a.resource_id, COALESCE(${hasRid ? 'r_rid.rid, r_id.rid, ' : ''}r_id.id, ad.value) as target_page_id + SELECT a.resource_id, COALESCE(${hasRid ? "r_rid.rid, r_id.rid, " : ""}r_id.id, ad.value) as target_page_id FROM actions a JOIN action_data ad ON ad.action_id = a.id - ${hasRid ? 'LEFT JOIN resources r_rid ON r_rid.rid = ad.value AND r_rid.type = 7' : ''} + ${hasRid ? "LEFT JOIN resources r_rid ON r_rid.rid = ad.value AND r_rid.type = 7" : ""} LEFT JOIN resources r_id ON (CASE WHEN ad.value GLOB '[0-9]*' THEN CAST(ad.value AS INTEGER) ELSE -1 END) = r_id.id AND r_id.type = 7 WHERE a.code IN (1, 8, 9) `; @@ -537,7 +569,9 @@ class TouchChatProcessor extends BaseProcessor { for (const pageId in tree.pages) { const page = tree.pages[pageId]; const button = page.buttons.find( - (b) => b.semanticAction?.platformData?.touchChat?.resourceId === nav.resource_id + (b) => + b.semanticAction?.platformData?.touchChat?.resourceId === + nav.resource_id, ); if (button) { // Use mapped string ID for target page if available @@ -559,7 +593,7 @@ class TouchChatProcessor extends BaseProcessor { }, }, fallback: { - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId: String(targetPageId), }, }; @@ -575,8 +609,11 @@ class TouchChatProcessor extends BaseProcessor { // Try to load root ID from multiple sources in order of priority try { // First, try to get HOME page from special_pages table (TouchChat specific) - const specialPagesQuery = "SELECT page_id FROM special_pages WHERE name = 'HOME'"; - const homePageRow = db.prepare(specialPagesQuery).get() as { page_id: number } | undefined; + const specialPagesQuery = + "SELECT page_id FROM special_pages WHERE name = 'HOME'"; + const homePageRow = db.prepare(specialPagesQuery).get() as + | { page_id: number } + | undefined; if (homePageRow) { // The page_id is the page's id (not resource_id), need to get the RID @@ -587,7 +624,9 @@ class TouchChatProcessor extends BaseProcessor { WHERE p.id = ? LIMIT 1 `; - const homePage = db.prepare(homePageIdQuery).get(homePageRow.page_id) as + const homePage = db + .prepare(homePageIdQuery) + .get(homePageRow.page_id) as | { id: number; rid?: string; @@ -605,8 +644,11 @@ class TouchChatProcessor extends BaseProcessor { // If no HOME page found, try tree_metadata table (general fallback) if (!tree.rootId) { - const metadataQuery = "SELECT value FROM tree_metadata WHERE key = 'rootId'"; - const rootIdRow = db.prepare(metadataQuery).get() as { value: string } | undefined; + const metadataQuery = + "SELECT value FROM tree_metadata WHERE key = 'rootId'"; + const rootIdRow = db.prepare(metadataQuery).get() as + | { value: string } + | undefined; if (rootIdRow && tree.getPage(rootIdRow.value)) { tree.rootId = rootIdRow.value; tree.metadata.defaultHomePageId = rootIdRow.value; @@ -627,7 +669,7 @@ class TouchChatProcessor extends BaseProcessor { } // Set metadata for TouchChat files - tree.metadata.format = 'touchchat'; + tree.metadata.format = "touchchat"; return tree; } finally { @@ -643,7 +685,7 @@ class TouchChatProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, ): Promise { const { pathExists, @@ -657,7 +699,7 @@ class TouchChatProcessor extends BaseProcessor { } = this.options.fileAdapter; if (!isNodeRuntime()) { throw new Error( - 'processTexts is only supported in Node.js environments for TouchChat files.' + "processTexts is only supported in Node.js environments for TouchChat files.", ); } /** @@ -668,7 +710,7 @@ class TouchChatProcessor extends BaseProcessor { * For file paths, we preserve the original archive and update text in-place * within the embedded SQLite database, ensuring assets and metadata remain intact. */ - if (typeof filePathOrBuffer === 'string') { + if (typeof filePathOrBuffer === "string") { const inputPath = filePathOrBuffer; const outputDir = dirname(outputPath); const dirExists = await pathExists(outputDir); @@ -681,13 +723,15 @@ class TouchChatProcessor extends BaseProcessor { const zip = await this.options.zipAdapter(inputPath); const entries = getZipEntriesFromAdapter(zip); - const vocabEntry = entries.find((entry) => entry.entryName.endsWith('.c4v')); + const vocabEntry = entries.find((entry) => + entry.entryName.endsWith(".c4v"), + ); if (!vocabEntry) { - throw new Error('No .c4v vocab DB found in TouchChat export'); + throw new Error("No .c4v vocab DB found in TouchChat export"); } - const tempDir = await mkTempDir('touchchat-translate-'); - const dbPath = join(tempDir, 'vocab.c4v'); + const tempDir = await mkTempDir("touchchat-translate-"); + const dbPath = join(tempDir, "vocab.c4v"); try { await writeBinaryToPath(dbPath, await vocabEntry.getData()); @@ -696,7 +740,9 @@ class TouchChatProcessor extends BaseProcessor { try { const getColumns = (tableName: string): Set => { try { - const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ + const rows = db + .prepare(`PRAGMA table_info(${tableName})`) + .all() as Array<{ name: string; }>; return new Set(rows.map((row) => row.name)); @@ -705,23 +751,23 @@ class TouchChatProcessor extends BaseProcessor { } }; - const resourceColumns = getColumns('resources'); - const pageColumns = getColumns('pages'); - const buttonColumns = getColumns('buttons'); + const resourceColumns = getColumns("resources"); + const pageColumns = getColumns("pages"); + const buttonColumns = getColumns("buttons"); - const updatePageResourceName = resourceColumns.has('name') + const updatePageResourceName = resourceColumns.has("name") ? db.prepare( - 'UPDATE resources SET name = ? WHERE name = ? AND id IN (SELECT resource_id FROM pages)' + "UPDATE resources SET name = ? WHERE name = ? AND id IN (SELECT resource_id FROM pages)", ) : null; - const updatePageName = pageColumns.has('name') - ? db.prepare('UPDATE pages SET name = ? WHERE name = ?') + const updatePageName = pageColumns.has("name") + ? db.prepare("UPDATE pages SET name = ? WHERE name = ?") : null; - const updateButtonLabel = buttonColumns.has('label') - ? db.prepare('UPDATE buttons SET label = ? WHERE label = ?') + const updateButtonLabel = buttonColumns.has("label") + ? db.prepare("UPDATE buttons SET label = ? WHERE label = ?") : null; - const updateButtonMessage = buttonColumns.has('message') - ? db.prepare('UPDATE buttons SET message = ? WHERE message = ?') + const updateButtonMessage = buttonColumns.has("message") + ? db.prepare("UPDATE buttons SET message = ? WHERE message = ?") : null; const entriesToUpdate = Array.from(translations.entries()); @@ -810,17 +856,23 @@ class TouchChatProcessor extends BaseProcessor { } async saveFromTree(tree: AACTree, outputPath: string): Promise { - const { writeBinaryToPath, mkTempDir, readBinaryFromInput, pathExists, removePath, join } = - this.options.fileAdapter; + const { + writeBinaryToPath, + mkTempDir, + readBinaryFromInput, + pathExists, + removePath, + join, + } = this.options.fileAdapter; if (!isNodeRuntime()) { throw new Error( - 'saveFromTree is only supported in Node.js environments for TouchChat files.' + "saveFromTree is only supported in Node.js environments for TouchChat files.", ); } // Create a TouchChat database that matches the expected schema for loading - const tmpDir = await mkTempDir('touchchat-export-'); - const dbPath = join(tmpDir, 'vocab.c4v'); + const tmpDir = await mkTempDir("touchchat-export-"); + const dbPath = join(tmpDir, "vocab.c4v"); try { const Database = requireBetterSqlite3(); @@ -953,13 +1005,13 @@ class TouchChatProcessor extends BaseProcessor { `); // Insert default styles - db.prepare('INSERT INTO button_styles (id) VALUES (1)').run(); - db.prepare('INSERT INTO page_styles (id) VALUES (1)').run(); + db.prepare("INSERT INTO button_styles (id) VALUES (1)").run(); + db.prepare("INSERT INTO page_styles (id) VALUES (1)").run(); // Helper function to convert hex color to integer const hexToInt = (hexColor?: string): number | null => { if (!hexColor) return null; - const hex = hexColor.replace('#', ''); + const hex = hexColor.replace("#", ""); return parseInt(hex, 16); }; @@ -982,7 +1034,9 @@ class TouchChatProcessor extends BaseProcessor { // First pass: create pages and map IDs Object.values(tree.pages).forEach((page) => { // Try to use numeric ID if possible, otherwise assign sequential ID - const numericPageId = /^\d+$/.test(page.id) ? parseInt(page.id) : pageIdCounter++; + const numericPageId = /^\d+$/.test(page.id) + ? parseInt(page.id) + : pageIdCounter++; pageIdMap.set(page.id, numericPageId); // Create page style if needed @@ -994,16 +1048,16 @@ class TouchChatProcessor extends BaseProcessor { pageStyleMap.set(styleKey, pageStyleId); const insertPageStyle = db.prepare( - 'INSERT INTO page_styles (id, bg_color, force_bg_color) VALUES (?, ?, ?)' + "INSERT INTO page_styles (id, bg_color, force_bg_color) VALUES (?, ?, ?)", ); insertPageStyle.run( pageStyleId, hexToInt(page.style.backgroundColor), - page.style.backgroundColor ? 1 : 0 + page.style.backgroundColor ? 1 : 0, ); } else { const existingPageStyleId = pageStyleMap.get(styleKey); - if (typeof existingPageStyleId === 'number') { + if (typeof existingPageStyleId === "number") { pageStyleId = existingPageStyleId; } } @@ -1012,19 +1066,24 @@ class TouchChatProcessor extends BaseProcessor { // Insert resource for page name const pageResourceId = resourceIdCounter++; const insertResource = db.prepare( - 'INSERT INTO resources (id, name, type) VALUES (?, ?, ?)' + "INSERT INTO resources (id, name, type) VALUES (?, ?, ?)", ); - insertResource.run(pageResourceId, page.name || 'Page', 0); + insertResource.run(pageResourceId, page.name || "Page", 0); // Insert page with original ID preserved and style const insertPage = db.prepare( - 'INSERT INTO pages (id, resource_id, name, page_style_id) VALUES (?, ?, ?, ?)' + "INSERT INTO pages (id, resource_id, name, page_style_id) VALUES (?, ?, ?, ?)", + ); + insertPage.run( + numericPageId, + pageResourceId, + page.name || "Page", + pageStyleId, ); - insertPage.run(numericPageId, pageResourceId, page.name || 'Page', pageStyleId); // Store ID mapping const insertIdMapping = db.prepare( - 'INSERT INTO page_id_mapping (numeric_id, string_id) VALUES (?, ?)' + "INSERT INTO page_id_mapping (numeric_id, string_id) VALUES (?, ?)", ); insertIdMapping.run(numericPageId, page.id); }); @@ -1052,13 +1111,17 @@ class TouchChatProcessor extends BaseProcessor { // Create a resource for the button box const buttonBoxResourceId = resourceIdCounter++; const insertButtonBoxResource = db.prepare( - 'INSERT INTO resources (id, name, type) VALUES (?, ?, ?)' + "INSERT INTO resources (id, name, type) VALUES (?, ?, ?)", + ); + insertButtonBoxResource.run( + buttonBoxResourceId, + page.name || "ButtonBox", + 0, ); - insertButtonBoxResource.run(buttonBoxResourceId, page.name || 'ButtonBox', 0); // Insert button box with layout dimensions const insertButtonBox = db.prepare( - 'INSERT INTO button_boxes (id, resource_id, layout_x, layout_y, init_size_x, init_size_y) VALUES (?, ?, ?, ?, ?, ?)' + "INSERT INTO button_boxes (id, resource_id, layout_x, layout_y, init_size_x, init_size_y) VALUES (?, ?, ?, ?, ?, ?)", ); insertButtonBox.run( buttonBoxId, @@ -1066,12 +1129,12 @@ class TouchChatProcessor extends BaseProcessor { gridWidth, gridHeight, 10000, // init_size_x in internal units - 10000 // init_size_y in internal units + 10000, // init_size_y in internal units ); // Create button box instance with calculated dimensions const insertButtonBoxInstance = db.prepare( - 'INSERT INTO button_box_instances (id, page_id, button_box_id, position_x, position_y, size_x, size_y) VALUES (?, ?, ?, ?, ?, ?, ?)' + "INSERT INTO button_box_instances (id, page_id, button_box_id, position_x, position_y, size_x, size_y) VALUES (?, ?, ?, ?, ?, ?, ?)", ); insertButtonBoxInstance.run( buttonBoxInstanceIdCounter++, @@ -1080,7 +1143,7 @@ class TouchChatProcessor extends BaseProcessor { 0, // Box starts at origin 0, gridWidth, - gridHeight + gridHeight, ); // Insert buttons @@ -1110,9 +1173,9 @@ class TouchChatProcessor extends BaseProcessor { } const buttonResourceId = resourceIdCounter++; const insertResource = db.prepare( - 'INSERT INTO resources (id, name, type) VALUES (?, ?, ?)' + "INSERT INTO resources (id, name, type) VALUES (?, ?, ?)", ); - insertResource.run(buttonResourceId, button.label || 'Button', 7); + insertResource.run(buttonResourceId, button.label || "Button", 7); const numericButtonId = parseInt(button.id) || buttonIdCounter++; @@ -1125,7 +1188,7 @@ class TouchChatProcessor extends BaseProcessor { buttonStyleMap.set(styleKey, buttonStyleId); const insertButtonStyle = db.prepare( - 'INSERT INTO button_styles (id, label_on_top, transparent, font_color, body_color, border_color, border_width, font_name, font_bold, font_underline, font_italic, font_height) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + "INSERT INTO button_styles (id, label_on_top, transparent, font_color, body_color, border_color, border_width, font_name, font_bold, font_underline, font_italic, font_height) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ); insertButtonStyle.run( buttonStyleId, @@ -1136,14 +1199,14 @@ class TouchChatProcessor extends BaseProcessor { hexToInt(button.style.borderColor), button.style.borderWidth, button.style.fontFamily, - button.style.fontWeight === 'bold' ? 1 : 0, + button.style.fontWeight === "bold" ? 1 : 0, button.style.textUnderline ? 1 : 0, - button.style.fontStyle === 'italic' ? 1 : 0, - button.style.fontSize + button.style.fontStyle === "italic" ? 1 : 0, + button.style.fontSize, ); } else { const existingButtonStyleId = buttonStyleMap.get(styleKey); - if (typeof existingButtonStyleId === 'number') { + if (typeof existingButtonStyleId === "number") { buttonStyleId = existingButtonStyleId; } } @@ -1151,22 +1214,22 @@ class TouchChatProcessor extends BaseProcessor { if (!insertedButtonIds.has(numericButtonId)) { const insertButton = db.prepare( - 'INSERT INTO buttons (id, resource_id, label, message, visible, button_style_id) VALUES (?, ?, ?, ?, ?, ?)' + "INSERT INTO buttons (id, resource_id, label, message, visible, button_style_id) VALUES (?, ?, ?, ?, ?, ?)", ); insertButton.run( numericButtonId, buttonResourceId, - button.label || '', - button.message || button.label || '', + button.label || "", + button.message || button.label || "", 1, - buttonStyleId + buttonStyleId, ); insertedButtonIds.add(numericButtonId); } // Insert button box cell with styling const insertButtonBoxCell = db.prepare( - 'INSERT INTO button_box_cells (button_box_id, resource_id, location, span_x, span_y, button_style_id, label, message, box_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + "INSERT INTO button_box_cells (button_box_id, resource_id, location, span_x, span_y, button_style_id, label, message, box_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ); insertButtonBoxCell.run( buttonBoxId, @@ -1175,29 +1238,35 @@ class TouchChatProcessor extends BaseProcessor { buttonSpanX, buttonSpanY, buttonStyleId, - button.label || '', - button.message || button.label || '', - buttonLocation + button.label || "", + button.message || button.label || "", + buttonLocation, ); // Handle actions - prefer semantic actions - if (button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO) { - const targetId = button.semanticAction.targetId || button.targetPageId; + if ( + button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO + ) { + const targetId = + button.semanticAction.targetId || button.targetPageId; const targetPageId = targetId ? pageIdMap.get(targetId) : null; if (targetPageId) { // Insert navigation action const insertAction = db.prepare( - 'INSERT INTO actions (id, resource_id, code) VALUES (?, ?, ?)' + "INSERT INTO actions (id, resource_id, code) VALUES (?, ?, ?)", ); - const actionCode = button.semanticAction.platformData?.touchChat?.actionCode || 1; + const actionCode = + button.semanticAction.platformData?.touchChat?.actionCode || + 1; insertAction.run(actionIdCounter, buttonResourceId, actionCode); // Insert action data const insertActionData = db.prepare( - 'INSERT INTO action_data (action_id, value) VALUES (?, ?)' + "INSERT INTO action_data (action_id, value) VALUES (?, ?)", ); const actionData = - button.semanticAction.platformData?.touchChat?.actionData || String(targetPageId); + button.semanticAction.platformData?.touchChat?.actionData || + String(targetPageId); insertActionData.run(actionIdCounter, actionData); actionIdCounter++; } @@ -1208,8 +1277,10 @@ class TouchChatProcessor extends BaseProcessor { // Save tree metadata (root ID) if (tree.rootId) { - const insertMetadata = db.prepare('INSERT INTO tree_metadata (key, value) VALUES (?, ?)'); - insertMetadata.run('rootId', tree.rootId); + const insertMetadata = db.prepare( + "INSERT INTO tree_metadata (key, value) VALUES (?, ?)", + ); + insertMetadata.run("rootId", tree.rootId); } db.close(); @@ -1219,7 +1290,7 @@ class TouchChatProcessor extends BaseProcessor { const data = await readBinaryFromInput(dbPath); const zipData = await zip.writeFiles([ { - name: 'vocab.c4v', + name: "vocab.c4v", data, }, ]); @@ -1238,7 +1309,9 @@ class TouchChatProcessor extends BaseProcessor { * @param filePath - Path to the TouchChat .ce file * @returns Promise with extracted strings and any errors */ - async extractStringsWithMetadata(filePath: string): Promise { + async extractStringsWithMetadata( + filePath: string, + ): Promise { try { const tree = await this.loadIntoTree(filePath); const extractedMap = new Map(); @@ -1247,16 +1320,25 @@ class TouchChatProcessor extends BaseProcessor { Object.values(tree.pages).forEach((page) => { page.buttons.forEach((button) => { // Process button labels - if (button.label && button.label.trim().length > 1 && !isNumericOrEmpty(button.label)) { + if ( + button.label && + button.label.trim().length > 1 && + !isNumericOrEmpty(button.label) + ) { const key = button.label.trim().toLowerCase(); const vocabLocation: VocabLocation = { - table: 'buttons', + table: "buttons", id: parseInt(button.id) || 0, - column: 'LABEL', + column: "LABEL", casing: detectCasing(button.label), }; - this.addToExtractedMap(extractedMap, key, button.label.trim(), vocabLocation); + this.addToExtractedMap( + extractedMap, + key, + button.label.trim(), + vocabLocation, + ); } // Process button messages (if different from label) @@ -1268,13 +1350,18 @@ class TouchChatProcessor extends BaseProcessor { ) { const key = button.message.trim().toLowerCase(); const vocabLocation: VocabLocation = { - table: 'buttons', + table: "buttons", id: parseInt(button.id) || 0, - column: 'MESSAGE', + column: "MESSAGE", casing: detectCasing(button.message), }; - this.addToExtractedMap(extractedMap, key, button.message.trim(), vocabLocation); + this.addToExtractedMap( + extractedMap, + key, + button.message.trim(), + vocabLocation, + ); } }); }); @@ -1285,8 +1372,11 @@ class TouchChatProcessor extends BaseProcessor { return Promise.resolve({ errors: [ { - message: error instanceof Error ? error.message : 'Unknown extraction error', - step: 'EXTRACT' as const, + message: + error instanceof Error + ? error.message + : "Unknown extraction error", + step: "EXTRACT" as const, }, ], extractedStrings: [], @@ -1304,7 +1394,7 @@ class TouchChatProcessor extends BaseProcessor { async generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { try { // Build translation map from the provided data @@ -1312,7 +1402,7 @@ class TouchChatProcessor extends BaseProcessor { sourceStrings.forEach((sourceString) => { const translated = translatedStrings.find( - (ts) => ts.sourcestringid.toString() === sourceString.id.toString() + (ts) => ts.sourcestringid.toString() === sourceString.id.toString(), ); if (translated) { @@ -1325,7 +1415,7 @@ class TouchChatProcessor extends BaseProcessor { }); // Generate output path for TouchChat files - const outputPath = filePath.replace(/\.ce$/, '_translated.ce'); + const outputPath = filePath.replace(/\.ce$/, "_translated.ce"); // Use existing processTexts method await this.processTexts(filePath, translations, outputPath); @@ -1334,8 +1424,8 @@ class TouchChatProcessor extends BaseProcessor { } catch (error) { return Promise.reject( new Error( - `Failed to generate translated download: ${error instanceof Error ? error.message : 'Unknown error'}` - ) + `Failed to generate translated download: ${error instanceof Error ? error.message : "Unknown error"}`, + ), ); } } @@ -1346,7 +1436,10 @@ class TouchChatProcessor extends BaseProcessor { * @returns Promise with validation result */ async validate(filePath: string): Promise { - return await TouchChatValidator.validateFile(filePath, this.options.fileAdapter); + return await TouchChatValidator.validateFile( + filePath, + this.options.fileAdapter, + ); } /** @@ -1358,7 +1451,9 @@ class TouchChatProcessor extends BaseProcessor { * @param filePathOrBuffer - Path to TouchChat .ce file or buffer * @returns Promise resolving to symbol information for LLM processing */ - async extractSymbolsForLLM(filePathOrBuffer: string | Buffer): Promise { + async extractSymbolsForLLM( + filePathOrBuffer: string | Buffer, + ): Promise { const tree = await this.loadIntoTree(filePathOrBuffer); // Collect all buttons from all pages @@ -1395,18 +1490,20 @@ class TouchChatProcessor extends BaseProcessor { filePathOrBuffer: string | Uint8Array, llmTranslations: LLMLTranslationResult[], outputPath: string, - options?: { allowPartial?: boolean } + options?: { allowPartial?: boolean }, ): Promise { const { readBinaryFromInput } = this.options.fileAdapter; if (!isNodeRuntime()) { throw new Error( - 'processLLMTranslations is only supported in Node.js environments for TouchChat files.' + "processLLMTranslations is only supported in Node.js environments for TouchChat files.", ); } const tree = await this.loadIntoTree(filePathOrBuffer); // Validate translations using shared utility - const buttonIds = Object.values(tree.pages).flatMap((page) => page.buttons.map((b) => b.id)); + const buttonIds = Object.values(tree.pages).flatMap((page) => + page.buttons.map((b) => b.id), + ); validateTranslationResults(llmTranslations, buttonIds, options); // Create a map for quick lookup diff --git a/src/snap.ts b/src/snap.ts index 6c93d67..7a23245 100644 --- a/src/snap.ts +++ b/src/snap.ts @@ -5,7 +5,7 @@ */ // Processor class -export { SnapProcessor } from './processors/snapProcessor'; +export { SnapProcessor } from "./processors/snapProcessor"; // === Snap Helpers === export { @@ -23,4 +23,4 @@ export { type SnapPackagePath, type SnapUserInfo, type SnapUsageEntry, -} from './processors/snap/helpers'; +} from "./processors/snap/helpers"; diff --git a/src/touchchat.ts b/src/touchchat.ts index 75f76f2..b4fc70f 100644 --- a/src/touchchat.ts +++ b/src/touchchat.ts @@ -5,11 +5,11 @@ */ // Processor class -export { TouchChatProcessor } from './processors/touchchatProcessor'; +export { TouchChatProcessor } from "./processors/touchchatProcessor"; // === TouchChat Helpers === export { getPageTokenImageMap, getAllowedImageEntries, openImage, -} from './processors/touchchat/helpers'; +} from "./processors/touchchat/helpers"; diff --git a/src/translation.ts b/src/translation.ts index 1784421..5734c24 100644 --- a/src/translation.ts +++ b/src/translation.ts @@ -20,7 +20,7 @@ export { type SymbolInfo, type ButtonForTranslation, type LLMLTranslationResult, -} from './utilities/translation/translationProcessor'; +} from "./utilities/translation/translationProcessor"; // Translation types -export { type TranslatedString, type SourceString } from './core/baseProcessor'; +export { type TranslatedString, type SourceString } from "./core/baseProcessor"; diff --git a/src/types/aac.ts b/src/types/aac.ts index 6bb0fcd..9066ffc 100644 --- a/src/types/aac.ts +++ b/src/types/aac.ts @@ -7,19 +7,19 @@ */ export enum ScanningSelectionMethod { /** Automatically advance through items at timed intervals (1 Switch) */ - AutoScan = 'AutoScan', + AutoScan = "AutoScan", /** Automatic scanning with overscan (two-stage scanning) */ - AutoScanWithOverscan = 'AutoScanWithOverscan', + AutoScanWithOverscan = "AutoScanWithOverscan", /** Hold switch to advance, release to select */ - HoldToAdvance = 'HoldToAdvance', + HoldToAdvance = "HoldToAdvance", /** Hold to advance with overscan */ - HoldToAdvanceWithOverscan = 'HoldToAdvanceWithOverscan', + HoldToAdvanceWithOverscan = "HoldToAdvanceWithOverscan", /** Tap switch to advance, tap again to select (Automatic) */ - TapToAdvance = 'TapToAdvance', + TapToAdvance = "TapToAdvance", /** Tap switch to advance, another switch to select (2 Switch Step Scan) */ - StepScan2Switch = 'StepScan2Switch', + StepScan2Switch = "StepScan2Switch", /** Tap switch 1 to advance, tap switch 1 again to select (1 Switch Step Scan) */ - StepScan1Switch = 'StepScan1Switch', + StepScan1Switch = "StepScan1Switch", } /** @@ -28,13 +28,13 @@ export enum ScanningSelectionMethod { */ export enum CellScanningOrder { /** Simple linear scan across rows (left-to-right, top-to-bottom) */ - SimpleScan = 'SimpleScan', + SimpleScan = "SimpleScan", /** Simple linear scan down columns (top-to-bottom, left-to-right) */ - SimpleScanColumnsFirst = 'SimpleScanColumnsFirst', + SimpleScanColumnsFirst = "SimpleScanColumnsFirst", /** Row-group scanning: highlight rows first, then cells within selected row */ - RowColumnScan = 'RowColumnScan', + RowColumnScan = "RowColumnScan", /** Column-group scanning: highlight columns first, then cells within selected column */ - ColumnRowScan = 'ColumnRowScan', + ColumnRowScan = "ColumnRowScan", } /** @@ -55,7 +55,7 @@ export interface ScanningConfig { /** Time in milliseconds to wait before auto-accepting selection */ dwellTime?: number; /** How the selection is accepted */ - acceptScanMethod?: 'Switch' | 'Timeout' | 'Hold'; + acceptScanMethod?: "Switch" | "Timeout" | "Hold"; /** Whether to factor in error correction effort (e.g., missed hits) */ errorCorrectionEnabled?: boolean; /** Maximum number of loops before the scan times out */ @@ -92,7 +92,7 @@ export interface AACButton { metadata?: string; }; // Extended properties for advanced platforms - contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell'; + contentType?: "Normal" | "AutoContent" | "Workspace" | "LiveCell"; contentSubType?: string; image?: string; resolvedImageEntry?: string; // normalized zip path to resolved image, if present @@ -114,7 +114,12 @@ export interface AACButton { * Reduces scanning effort by grouping buttons */ scanBlock?: number; - visibility?: 'Visible' | 'Hidden' | 'Disabled' | 'PointerAndTouchOnly' | 'Empty'; + visibility?: + | "Visible" + | "Hidden" + | "Disabled" + | "PointerAndTouchOnly" + | "Empty"; directActivate?: boolean; audioDescription?: string; parameters?: { [key: string]: any }; @@ -179,7 +184,7 @@ export interface AACTreeMetadata { * Snap-specific metadata */ export interface SnapMetadata extends AACTreeMetadata { - format: 'snap'; + format: "snap"; dashboardId?: string; } @@ -187,7 +192,7 @@ export interface SnapMetadata extends AACTreeMetadata { * GridSet-specific metadata */ export interface GridSetMetadata extends AACTreeMetadata { - format: 'gridset'; + format: "gridset"; isSmartBox?: boolean; passwordProtected?: boolean; pictureSearchKeys?: string[]; @@ -205,7 +210,7 @@ export interface GridSetMetadata extends AACTreeMetadata { * Asterics-specific metadata */ export interface AstericsGridMetadata extends AACTreeMetadata { - format: 'asterics'; + format: "asterics"; hasGlobalGrid?: boolean; globalGridId?: string; } @@ -214,7 +219,7 @@ export interface AstericsGridMetadata extends AACTreeMetadata { * TouchChat-specific metadata */ export interface TouchChatMetadata extends AACTreeMetadata { - format: 'touchchat'; + format: "touchchat"; } export interface AACTree { diff --git a/src/utilities/analytics/history.ts b/src/utilities/analytics/history.ts index 682abcf..62e25f3 100644 --- a/src/utilities/analytics/history.ts +++ b/src/utilities/analytics/history.ts @@ -1,20 +1,23 @@ -import { dotNetTicksToDate } from '../../utils/dotnetTicks'; +import { dotNetTicksToDate } from "../../utils/dotnetTicks"; import { findGrid3Users, Grid3UserPath, readAllGrid3History as readAllGrid3HistoryImpl, readGrid3History as readGrid3HistoryImpl, readGrid3HistoryForUser as readGrid3HistoryForUserImpl, -} from '../../processors/gridset/helpers'; +} from "../../processors/gridset/helpers"; import { findSnapUsers, readSnapUsage as readSnapUsageImpl, readSnapUsageForUser as readSnapUsageForUserImpl, SnapUserInfo, -} from '../../processors/snap/helpers'; -import { AACSemanticCategory, AACSemanticIntent } from '../../core/treeStructure'; +} from "../../processors/snap/helpers"; +import { + AACSemanticCategory, + AACSemanticIntent, +} from "../../core/treeStructure"; -export type HistorySource = 'Grid' | 'Snap' | 'OBL' | string; +export type HistorySource = "Grid" | "Snap" | "OBL" | string; export interface HistoryOccurrence { timestamp: Date; @@ -30,7 +33,7 @@ export interface HistoryOccurrence { vocalization?: string; imageUrl?: string; actions?: any[]; // For OBL actions - type?: 'button' | 'action' | 'utterance' | 'note' | 'other'; + type?: "button" | "action" | "utterance" | "note" | "other"; // Semantic semantic alignment intent?: AACSemanticIntent | string; category?: AACSemanticCategory; @@ -78,12 +81,14 @@ export interface BatonExport { } const generateUuid = (): string => { - if (typeof globalThis.crypto?.randomUUID === 'function') { + if (typeof globalThis.crypto?.randomUUID === "function") { return globalThis.crypto.randomUUID(); } // RFC4122-ish fallback for Node without crypto.randomUUID - const hex = '0123456789abcdef'; - const bytes = Array.from({ length: 16 }, () => Math.floor(Math.random() * 256)); + const hex = "0123456789abcdef"; + const bytes = Array.from({ length: 16 }, () => + Math.floor(Math.random() * 256), + ); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const toHex = (b: number): string => hex[(b >> 4) & 0x0f] + hex[b & 0x0f]; @@ -92,16 +97,16 @@ const generateUuid = (): string => { toHex(bytes[1]) + toHex(bytes[2]) + toHex(bytes[3]) + - '-' + + "-" + toHex(bytes[4]) + toHex(bytes[5]) + - '-' + + "-" + toHex(bytes[6]) + toHex(bytes[7]) + - '-' + + "-" + toHex(bytes[8]) + toHex(bytes[9]) + - '-' + + "-" + toHex(bytes[10]) + toHex(bytes[11]) + toHex(bytes[12]) + @@ -118,7 +123,7 @@ export function exportHistoryToBaton( exportDate?: string | Date; encryption?: string; anonymousUUID?: string; - } + }, ): BatonExport { const exportDate = options?.exportDate instanceof Date @@ -138,9 +143,9 @@ export function exportHistoryToBaton( })); return { - version: options?.version || '1.0', + version: options?.version || "1.0", exportDate, - encryption: options?.encryption || 'none', + encryption: options?.encryption || "none", sentenceCount: sentences.length, sentences, }; @@ -149,11 +154,13 @@ export function exportHistoryToBaton( /** * Read Grid 3 phrase history from a history.sqlite database and tag entries with their source. */ -export async function readGrid3History(historyDbPath: string): Promise { +export async function readGrid3History( + historyDbPath: string, +): Promise { const history = await readGrid3HistoryImpl(historyDbPath); return history.map((e) => ({ ...e, - source: 'Grid', + source: "Grid", })); } @@ -162,12 +169,12 @@ export async function readGrid3History(historyDbPath: string): Promise { const history = await readGrid3HistoryForUserImpl(userName, langCode); return history.map((e) => ({ ...e, - source: 'Grid', + source: "Grid", })); } @@ -176,15 +183,17 @@ export async function readGrid3HistoryForUser( */ export async function readAllGrid3History(): Promise { const history = await readAllGrid3HistoryImpl(); - return history.map((e) => ({ ...e, source: 'Grid' })); + return history.map((e) => ({ ...e, source: "Grid" })); } /** * Read Snap button usage from a pageset database and tag entries with source. */ -export async function readSnapUsage(pagesetPath: string): Promise { +export async function readSnapUsage( + pagesetPath: string, +): Promise { const usage = await readSnapUsageImpl(pagesetPath); - return usage.map((e) => ({ ...e, source: 'Snap' })); + return usage.map((e) => ({ ...e, source: "Snap" })); } /** @@ -192,12 +201,12 @@ export async function readSnapUsage(pagesetPath: string): Promise { const usage = await readSnapUsageForUserImpl(userId, packageNamePattern); return usage.map((e) => ({ ...e, - source: 'Snap', + source: "Snap", })); } @@ -220,7 +229,7 @@ export async function collectUnifiedHistory(): Promise { const gridHistory = await readAllGrid3History(); const users = await findSnapUsers(); const snapHistory = await Promise.all( - users.map(async (u) => await readSnapUsageForUser(u.userId)) + users.map(async (u) => await readSnapUsageForUser(u.userId)), ); return [...gridHistory, ...snapHistory.flat()]; } diff --git a/src/utilities/analytics/index.ts b/src/utilities/analytics/index.ts index 579711b..c036b13 100644 --- a/src/utilities/analytics/index.ts +++ b/src/utilities/analytics/index.ts @@ -10,55 +10,55 @@ * @module */ -import { defaultFileAdapter, FileAdapter } from '../../utils/io'; +import { defaultFileAdapter, FileAdapter } from "../../utils/io"; // Always-available exports -export * from './metrics/types'; -export * from './metrics/effort'; -export * from './utils/idGenerator'; +export * from "./metrics/types"; +export * from "./metrics/effort"; +export * from "./utils/idGenerator"; // Export history functionality -export * from './history'; +export * from "./history"; // Export OBL logging support -export * from './metrics/obl-types'; -export { OblUtil, OblAnonymizer } from './metrics/obl'; +export * from "./metrics/obl-types"; +export { OblUtil, OblAnonymizer } from "./metrics/obl"; // Export core metrics calculator -export { MetricsCalculator } from './metrics/core'; +export { MetricsCalculator } from "./metrics/core"; // Export vocabulary and comparison analyzers -export { VocabularyAnalyzer } from './metrics/vocabulary'; -export { SentenceAnalyzer } from './metrics/sentence'; -export { ComparisonAnalyzer } from './metrics/comparison'; -export { ReferenceLoader } from './reference'; +export { VocabularyAnalyzer } from "./metrics/vocabulary"; +export { SentenceAnalyzer } from "./metrics/sentence"; +export { ComparisonAnalyzer } from "./metrics/comparison"; +export { ReferenceLoader } from "./reference"; /** * Get the default reference data path */ export function getReferenceDataPath(fileAdapter: FileAdapter): string { const { join } = fileAdapter; - return join(__dirname, 'reference', 'data'); + return join(__dirname, "reference", "data"); } /** * Check if reference data files exist */ export async function hasReferenceData( - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { pathExists, join } = fileAdapter; const dataPath = getReferenceDataPath(fileAdapter); const requiredFiles = [ - 'core_lists.en.json', - 'common_words.en.json', - 'sentences.en.json', - 'synonyms.en.json', - 'fringe.en.json', + "core_lists.en.json", + "common_words.en.json", + "sentences.en.json", + "synonyms.en.json", + "fringe.en.json", ]; const existingPaths = await Promise.all( - requiredFiles.map(async (file) => await pathExists(join(dataPath, file))) + requiredFiles.map(async (file) => await pathExists(join(dataPath, file))), ); return existingPaths.every((exists) => exists); } diff --git a/src/utilities/analytics/metrics/comparison.ts b/src/utilities/analytics/metrics/comparison.ts index 372cdb1..6419221 100644 --- a/src/utilities/analytics/metrics/comparison.ts +++ b/src/utilities/analytics/metrics/comparison.ts @@ -5,12 +5,15 @@ * analyze vocabulary differences, and generate CARE component scores. */ -import { MetricsResult, ButtonMetrics, ComparisonResult } from './types'; -import { SentenceAnalyzer } from './sentence'; -import { VocabularyAnalyzer } from './vocabulary'; -import { ReferenceLoader, type ReferenceDataProvider } from '../reference/index'; -import { spellingEffort, predictionEffort } from './effort'; -import { MetricsOptions } from './types'; +import { MetricsResult, ButtonMetrics, ComparisonResult } from "./types"; +import { SentenceAnalyzer } from "./sentence"; +import { VocabularyAnalyzer } from "./vocabulary"; +import { + ReferenceLoader, + type ReferenceDataProvider, +} from "../reference/index"; +import { spellingEffort, predictionEffort } from "./effort"; +import { MetricsOptions } from "./types"; export class ComparisonAnalyzer { private vocabAnalyzer: VocabularyAnalyzer; @@ -27,7 +30,7 @@ export class ComparisonAnalyzer { return word .toLowerCase() .trim() - .replace(/[.?!,]/g, ''); + .replace(/[.?!,]/g, ""); } /** @@ -39,7 +42,7 @@ export class ComparisonAnalyzer { options?: { includeSentences?: boolean; locale?: string; - } & Partial + } & Partial, ): Promise { // Create base result from target const baseResult = { ...targetResult }; @@ -105,7 +108,7 @@ export class ComparisonAnalyzer { targetResult, compareResult, overlappingWords, - options + options, ); // Analyze high/low effort words @@ -129,16 +132,20 @@ export class ComparisonAnalyzer { highEffortWords.sort((a, b) => { const targetBtnA = targetWords.get(a); const targetBtnB = targetWords.get(b); - const diffA = (targetBtnA?.effort || 0) - (compareWords.get(a)?.effort || 0); - const diffB = (targetBtnB?.effort || 0) - (compareWords.get(b)?.effort || 0); + const diffA = + (targetBtnA?.effort || 0) - (compareWords.get(a)?.effort || 0); + const diffB = + (targetBtnB?.effort || 0) - (compareWords.get(b)?.effort || 0); return diffB - diffA; }); lowEffortWords.sort((a, b) => { const targetBtnA = targetWords.get(a); const targetBtnB = targetWords.get(b); - const diffA = (compareWords.get(a)?.effort || 0) - (targetBtnA?.effort || 0); - const diffB = (compareWords.get(b)?.effort || 0) - (targetBtnB?.effort || 0); + const diffA = + (compareWords.get(a)?.effort || 0) - (targetBtnA?.effort || 0); + const diffB = + (compareWords.get(b)?.effort || 0) - (targetBtnB?.effort || 0); return diffB - diffA; }); @@ -146,8 +153,14 @@ export class ComparisonAnalyzer { let sentences: any[] = []; if (options?.includeSentences) { const testSentences = await this.referenceLoader.loadSentences(); - const targetSentences = this.sentenceAnalyzer.analyzeSentences(targetResult, testSentences); - const compareSentences = this.sentenceAnalyzer.analyzeSentences(compareResult, testSentences); + const targetSentences = this.sentenceAnalyzer.analyzeSentences( + targetResult, + testSentences, + ); + const compareSentences = this.sentenceAnalyzer.analyzeSentences( + compareResult, + testSentences, + ); sentences = targetSentences.map((ts, idx) => ({ sentence: ts.sentence, @@ -216,7 +229,10 @@ export class ComparisonAnalyzer { // Fringe vocabulary analysis const fringeWords = await this.analyzeFringe(targetWords, compareWords); - const commonFringeWords = await this.analyzeCommonFringe(targetWords, compareWords); + const commonFringeWords = await this.analyzeCommonFringe( + targetWords, + compareWords, + ); return { ...baseResult, @@ -276,8 +292,8 @@ export class ComparisonAnalyzer { options?: { includeSentences?: boolean; locale?: string; - } & Partial - ): Promise { + } & Partial, + ): Promise { // Load common words with baseline efforts (matching Ruby line 527-534) const commonWordsData = await this.referenceLoader.loadCommonWords(); const commonWords = new Map(); @@ -288,13 +304,13 @@ export class ComparisonAnalyzer { // Determine prediction settings (default: use common words efforts, not prediction) const usePrediction = options?.usePrediction || false; // Default FALSE (use common words) const predictionSelections = options?.predictionSelections || 1.5; - const debugMode = process.env.DEBUG_METRICS === 'true'; + const debugMode = process.env.DEBUG_METRICS === "true"; // Helper function to calculate fallback effort const getFallbackEffort = ( word: string, hasPrediction: boolean, - spellingBaseEffort?: number + spellingBaseEffort?: number, ): number => { const wordLower = word.toLowerCase(); @@ -306,7 +322,12 @@ export class ComparisonAnalyzer { // If usePrediction is true and prediction is available, use prediction if (usePrediction && hasPrediction && spellingBaseEffort !== undefined) { - return predictionEffort(spellingBaseEffort, 2.5, predictionSelections, 2); + return predictionEffort( + spellingBaseEffort, + 2.5, + predictionSelections, + 2, + ); } // Fallback to manual spelling (matching Ruby spelling_effort: 10 + word.length * 2.5) @@ -315,16 +336,18 @@ export class ComparisonAnalyzer { // Debug: Check settings const targetHasPrediction = - targetResult.has_dynamic_prediction && targetResult.spelling_effort_base !== undefined; + targetResult.has_dynamic_prediction && + targetResult.spelling_effort_base !== undefined; const _compareHasPrediction = - compareResult.has_dynamic_prediction && compareResult.spelling_effort_base !== undefined; + compareResult.has_dynamic_prediction && + compareResult.spelling_effort_base !== undefined; if (debugMode) { console.log(`\n🔍 DEBUG Fallback Effort Settings:`); console.log(` Common words loaded: ${commonWords.size}`); console.log(` usePrediction option: ${usePrediction}`); console.log(` Target has prediction capability: ${targetHasPrediction}`); console.log( - ` Target spelling_base: ${targetResult.spelling_effort_base?.toFixed(2) || 'undefined'}` + ` Target spelling_base: ${targetResult.spelling_effort_base?.toFixed(2) || "undefined"}`, ); } // Create word maps with normalized keys @@ -375,7 +398,7 @@ export class ComparisonAnalyzer { targetCoreEffort += getFallbackEffort( word, targetResult.has_dynamic_prediction || false, - targetResult.spelling_effort_base + targetResult.spelling_effort_base, ); } @@ -386,13 +409,15 @@ export class ComparisonAnalyzer { compCoreEffort += getFallbackEffort( word, compareResult.has_dynamic_prediction || false, - compareResult.spelling_effort_base + compareResult.spelling_effort_base, ); } }); - const avgCoreEffort = allCoreWords.size > 0 ? targetCoreEffort / allCoreWords.size : 0; - const avgCompCoreEffort = allCoreWords.size > 0 ? compCoreEffort / allCoreWords.size : 0; + const avgCoreEffort = + allCoreWords.size > 0 ? targetCoreEffort / allCoreWords.size : 0; + const avgCompCoreEffort = + allCoreWords.size > 0 ? compCoreEffort / allCoreWords.size : 0; // Calculate core component scores (matching Ruby lines 644-647) const coreScore = avgCoreEffort * 5.0; @@ -417,7 +442,7 @@ export class ComparisonAnalyzer { targetSentenceEffort += getFallbackEffort( word, targetResult.has_dynamic_prediction || false, - targetResult.spelling_effort_base + targetResult.spelling_effort_base, ); } @@ -427,7 +452,7 @@ export class ComparisonAnalyzer { compSentenceEffort += getFallbackEffort( word, compareResult.has_dynamic_prediction || false, - compareResult.spelling_effort_base + compareResult.spelling_effort_base, ); } }); @@ -443,7 +468,8 @@ export class ComparisonAnalyzer { : 0; const compAvgSentenceEffort = compSentenceEfforts.length > 0 - ? compSentenceEfforts.reduce((a, b) => a + b, 0) / compSentenceEfforts.length + ? compSentenceEfforts.reduce((a, b) => a + b, 0) / + compSentenceEfforts.length : 0; // Sentence component scores (matching Ruby line 665-668) @@ -469,8 +495,8 @@ export class ComparisonAnalyzer { getFallbackEffort( word, targetResult.has_dynamic_prediction || false, - targetResult.spelling_effort_base - ) + targetResult.spelling_effort_base, + ), ); } @@ -482,8 +508,8 @@ export class ComparisonAnalyzer { getFallbackEffort( word, compareResult.has_dynamic_prediction || false, - compareResult.spelling_effort_base - ) + compareResult.spelling_effort_base, + ), ); } }); @@ -494,7 +520,8 @@ export class ComparisonAnalyzer { : 0; const avgCompFringeEffort = compFringeEfforts.length > 0 - ? compFringeEfforts.reduce((a, b) => a + b, 0) / compFringeEfforts.length + ? compFringeEfforts.reduce((a, b) => a + b, 0) / + compFringeEfforts.length : 0; // Fringe component scores (matching Ruby line 684-687) @@ -520,11 +547,13 @@ export class ComparisonAnalyzer { const avgCommonFringeEffort = commonFringeEfforts.length > 0 - ? commonFringeEfforts.reduce((a, b) => a + b, 0) / commonFringeEfforts.length + ? commonFringeEfforts.reduce((a, b) => a + b, 0) / + commonFringeEfforts.length : 0; const avgCompCommonFringeEffort = compCommonFringeEfforts.length > 0 - ? compCommonFringeEfforts.reduce((a, b) => a + b, 0) / compCommonFringeEfforts.length + ? compCommonFringeEfforts.reduce((a, b) => a + b, 0) / + compCommonFringeEfforts.length : 0; // Common fringe component scores (matching Ruby line 702-705) @@ -536,7 +565,11 @@ export class ComparisonAnalyzer { const targetEffortTally = coreScore + sentenceScore + fringeScore + commonFringeScore + PLACEHOLDER; const compEffortTally = - compCoreScore + compSentenceScore + compFringeScore + compCommonFringeScore + PLACEHOLDER; + compCoreScore + + compSentenceScore + + compFringeScore + + compCommonFringeScore + + PLACEHOLDER; // Calculate final CARE scores (matching Ruby line 710-711) // res[:target_effort_score] = [0.0, 350.0 - target_effort_tally].max @@ -563,10 +596,11 @@ export class ComparisonAnalyzer { */ private async analyzeFringe( targetWords: Map, - compareWords: Map + compareWords: Map, ): Promise> { const fringe = await this.referenceLoader.loadFringe(); - const result: Array<{ word: string; effort: number; comp_effort: number }> = []; + const result: Array<{ word: string; effort: number; comp_effort: number }> = + []; fringe.forEach((word) => { const key = this.normalize(word); @@ -591,10 +625,11 @@ export class ComparisonAnalyzer { */ private async analyzeCommonFringe( targetWords: Map, - compareWords: Map + compareWords: Map, ): Promise> { const fringe = await this.referenceLoader.loadFringe(); - const result: Array<{ word: string; effort: number; comp_effort: number }> = []; + const result: Array<{ word: string; effort: number; comp_effort: number }> = + []; fringe.forEach((word) => { const key = this.normalize(word); diff --git a/src/utilities/analytics/metrics/core.ts b/src/utilities/analytics/metrics/core.ts index e26c49d..f049a2f 100644 --- a/src/utilities/analytics/metrics/core.ts +++ b/src/utilities/analytics/metrics/core.ts @@ -13,9 +13,9 @@ import { AACButton, AACSemanticCategory, AACScanType, -} from '../../../core/treeStructure'; -import { CellScanningOrder, ScanningSelectionMethod } from '../../../types/aac'; -import { ButtonMetrics, MetricsOptions, MetricsResult } from './types'; +} from "../../../core/treeStructure"; +import { CellScanningOrder, ScanningSelectionMethod } from "../../../types/aac"; +import { ButtonMetrics, MetricsOptions, MetricsResult } from "./types"; import { baseBoardEffort, distanceEffort, @@ -23,8 +23,8 @@ import { EFFORT_CONSTANTS, localScanEffort, scanningEffort, -} from './effort'; -import { MorphologyEngine } from '../morphology'; +} from "./effort"; +import { MorphologyEngine } from "../morphology"; interface ToVisitItem { board: AACPage; @@ -38,7 +38,7 @@ interface ToVisitItem { } export class MetricsCalculator { - private locale: string = 'en'; + private locale: string = "en"; /** * Main analysis function - calculates metrics for an AAC tree @@ -57,10 +57,10 @@ export class MetricsCalculator { rootBoard = Object.values(tree.pages).find((p: AACPage) => !p.parentId); } if (!rootBoard) { - throw new Error('No root board found in tree'); + throw new Error("No root board found in tree"); } - this.locale = tree.metadata?.locale || (rootBoard as any).locale || 'en'; + this.locale = tree.metadata?.locale || (rootBoard as any).locale || "en"; // Step 1: Build semantic/clone reference maps const { setRefs, setPcts } = this.buildReferenceMaps(tree); @@ -74,9 +74,9 @@ export class MetricsCalculator { if (btn.targetPageId && btn.semanticAction) { // Check for temporary_home in platformData or fallback const tempHome = - btn.semanticAction.platformData?.grid3?.parameters?.temporary_home || - btn.semanticAction.fallback?.temporary_home; - if (tempHome === 'prior') { + btn.semanticAction.platformData?.grid3?.parameters + ?.temporary_home || btn.semanticAction.fallback?.temporary_home; + if (tempHome === "prior") { startBoards.push(board); } else if (tempHome === true && btn.targetPageId) { const targetBoard = tree.getPage(btn.targetPageId); @@ -98,7 +98,13 @@ export class MetricsCalculator { // Analyze from each starting board startBoards.forEach((startBoard) => { - const result = this.analyzeFrom(tree, startBoard, setPcts, startBoard === rootBoard, options); + const result = this.analyzeFrom( + tree, + startBoard, + setPcts, + startBoard === rootBoard, + options, + ); result.buttons.forEach((btn) => { const existing = knownButtons.get(btn.label); @@ -117,10 +123,13 @@ export class MetricsCalculator { }); // Update buttons using dynamic spelling effort if applicable - const buttons = Array.from(knownButtons.values()).sort((a, b) => a.effort - b.effort); + const buttons = Array.from(knownButtons.values()).sort( + (a, b) => a.effort - b.effort, + ); // Expand morphological predictions from POS tags if enabled or auto-detected - const useSmartGrammar = options.useSmartGrammar === true || this.treeHasPosTags(tree); + const useSmartGrammar = + options.useSmartGrammar === true || this.treeHasPosTags(tree); if (useSmartGrammar) { this.expandMorphologicalPredictions(tree, options); } @@ -129,11 +138,13 @@ export class MetricsCalculator { const { wordFormMetrics, replacedLabels } = this.calculateWordFormMetrics( tree, buttons, - options + options, ); // Remove buttons that were replaced by lower-effort word forms - const filteredButtons = buttons.filter((btn) => !replacedLabels.has(btn.label.toLowerCase())); + const filteredButtons = buttons.filter( + (btn) => !replacedLabels.has(btn.label.toLowerCase()), + ); // Add word forms and re-sort filteredButtons.push(...wordFormMetrics); @@ -154,12 +165,20 @@ export class MetricsCalculator { // A page is prediction-capable if it has an AutoContent Prediction button reachable from root // We already have analyzed from rootBoard if (rootBoard) { - const rootAnalysis = this.analyzeFrom(tree, rootBoard, setPcts, true, options); + const rootAnalysis = this.analyzeFrom( + tree, + rootBoard, + setPcts, + true, + options, + ); // Scan reached pages for prediction slots for (const [pageId, _] of rootAnalysis.visitedBoardEfforts) { const page = tree.getPage(pageId); const hasPredictionSlot = page?.buttons.some( - (b) => b.contentType === 'AutoContent' && b.contentSubType === 'Prediction' + (b) => + b.contentType === "AutoContent" && + b.contentSubType === "Prediction", ); if (hasPredictionSlot) { hasDynamicPrediction = true; @@ -170,7 +189,7 @@ export class MetricsCalculator { } return { - analysis_version: '0.2', + analysis_version: "0.2", locale: this.locale, total_boards: Object.keys(tree.pages).length, total_buttons: totalButtons, @@ -193,7 +212,7 @@ export class MetricsCalculator { private identifySpellingMetrics( tree: AACTree, options: MetricsOptions, - setPcts: { [id: string]: number } + setPcts: { [id: string]: number }, ): { spellingPage: AACPage | null; spellingBaseEffort: number; @@ -214,7 +233,11 @@ export class MetricsCalculator { spellingPage = Object.values(tree.pages).find((p) => { const name = p.name.toLowerCase(); - return name.includes('keyboard') || name.includes('spelling') || name.includes('abc'); + return ( + name.includes("keyboard") || + name.includes("spelling") || + name.includes("abc") + ); }) || null; } @@ -239,24 +262,33 @@ export class MetricsCalculator { // Analyze specifically to find the lowest effort path to the spelling page const result = this.analyzeFrom(tree, rootBoard, setPcts, true, options); - const spellingBaseEffort = result.visitedBoardEfforts.get(spellingPage.id) ?? 10; + const spellingBaseEffort = + result.visitedBoardEfforts.get(spellingPage.id) ?? 10; // Calculate average effort of alphabetical buttons on that page const letters = spellingPage.buttons.filter( - (b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label) + (b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label), ); let avgEffort = 2.5; if (letters.length > 0) { // We need to calculate the effort of these buttons relative to the spelling page itself // (as if the user is already on the keyboard) - const keyboardResult = this.analyzeFrom(tree, spellingPage, setPcts, false, options); + const keyboardResult = this.analyzeFrom( + tree, + spellingPage, + setPcts, + false, + options, + ); const keyboardLetters = keyboardResult.buttons.filter( - (b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label) + (b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label), ); if (keyboardLetters.length > 0) { - avgEffort = keyboardLetters.reduce((sum, b) => sum + b.effort, 0) / keyboardLetters.length; + avgEffort = + keyboardLetters.reduce((sum, b) => sum + b.effort, 0) / + keyboardLetters.length; } } @@ -307,7 +339,7 @@ export class MetricsCalculator { Object.entries(setRefs).forEach(([id, count]) => { // Extract location from ID (Ruby uses id.split(/-/)[1]) - const parts = id.split('-'); + const parts = id.split("-"); if (parts.length >= 2) { const loc = parts[1]; const cellCount = cellRefs[loc] || totalBoards; @@ -332,7 +364,7 @@ export class MetricsCalculator { board: AACPage, currentRowIndex: number, currentColIndex: number, - priorScanBlocks: Set + priorScanBlocks: Set, ): number { // Block scanning: count unique scan blocks before current position // Reuse the priorScanBlocks set from the parent scope @@ -340,12 +372,15 @@ export class MetricsCalculator { const row = board.grid[r]; if (!row) continue; for (let c = 0; c < row.length; c++) { - if (r === currentRowIndex && c === currentColIndex) return priorScanBlocks.size; + if (r === currentRowIndex && c === currentColIndex) + return priorScanBlocks.size; const btn = row[c]; if (btn && (btn.label || btn.id).length > 0) { const block = btn.scanBlock || - (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null); + (btn.scanBlocks && btn.scanBlocks.length > 0 + ? btn.scanBlocks[0] + : null); if (block !== null) priorScanBlocks.add(block); } } @@ -361,7 +396,7 @@ export class MetricsCalculator { brd: AACPage, setPcts: { [id: string]: number }, _isRoot: boolean, - options: MetricsOptions = {} + options: MetricsOptions = {}, ): { buttons: ButtonMetrics[]; levels: { [level: number]: ButtonMetrics[] }; @@ -385,7 +420,14 @@ export class MetricsCalculator { while (toVisit.length > 0) { const item = toVisit.shift(); if (!item) break; - const { board, level, entryX, entryY, priorEffort = 0, temporaryHomeId } = item; + const { + board, + level, + entryX, + entryY, + priorEffort = 0, + temporaryHomeId, + } = item; // Skip if already visited at a lower level with equal or better prior effort // Skip if already visited at a strictly lower level @@ -411,10 +453,13 @@ export class MetricsCalculator { if (!btn) return; if (btn.clone_id && setPcts[btn.clone_id]) { - reuseDiscount += EFFORT_CONSTANTS.REUSED_CLONE_FROM_OTHER_BONUS * setPcts[btn.clone_id]; + reuseDiscount += + EFFORT_CONSTANTS.REUSED_CLONE_FROM_OTHER_BONUS * + setPcts[btn.clone_id]; } else if (btn.semantic_id && setPcts[btn.semantic_id]) { reuseDiscount += - EFFORT_CONSTANTS.REUSED_SEMANTIC_FROM_OTHER_BONUS * setPcts[btn.semantic_id]; + EFFORT_CONSTANTS.REUSED_SEMANTIC_FROM_OTHER_BONUS * + setPcts[btn.semantic_id]; } }); }); @@ -458,12 +503,14 @@ export class MetricsCalculator { let buttonEffort = boardEffort; // Debug for specific button (disabled for production) - const debugSpecificButton = btn.label === '$938c2cc0dc'; + const debugSpecificButton = btn.label === "$938c2cc0dc"; if (debugSpecificButton) { console.log( - `\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:` + `\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`, + ); + console.log( + ` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`, ); - console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`); console.log(` Current level: ${level}`); console.log(` Prior positions: ${priorItems}`); console.log(` Starting effort: ${buttonEffort.toFixed(6)}`); @@ -472,14 +519,18 @@ export class MetricsCalculator { // Apply semantic_id discounts if (btn.semantic_id && boardPcts[btn.semantic_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / + boardPcts[btn.semantic_id]; const old = buttonEffort; buttonEffort = Math.min(buttonEffort, buttonEffort * discount); if (debugSpecificButton) console.log( - ` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})` + ` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`, ); - } else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) { + } else if ( + btn.semantic_id && + boardPcts[`upstream-${btn.semantic_id}`] + ) { const discount = EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT / boardPcts[`upstream-${btn.semantic_id}`]; @@ -487,14 +538,15 @@ export class MetricsCalculator { buttonEffort = Math.min(buttonEffort, buttonEffort * discount); if (debugSpecificButton) console.log( - ` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})` + ` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`, ); } // Apply clone_id discounts if (btn.clone_id && boardPcts[btn.clone_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / + boardPcts[btn.clone_id]; buttonEffort = Math.min(buttonEffort, buttonEffort * discount); } else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) { const discount = @@ -511,31 +563,42 @@ export class MetricsCalculator { btn, rowIndex, colIndex, - scanningConfig + scanningConfig, ); // Determine effective costs based on selection method - let currentStepCost = options.scanStepCost ?? EFFORT_CONSTANTS.SCAN_STEP_COST; + let currentStepCost = + options.scanStepCost ?? EFFORT_CONSTANTS.SCAN_STEP_COST; const currentSelectionCost = options.scanSelectionCost ?? EFFORT_CONSTANTS.SCAN_SELECTION_COST; // Step Scan 2 Switch: Every step is a physical selection with Switch 1 - if (scanningConfig?.selectionMethod === ScanningSelectionMethod.StepScan2Switch) { + if ( + scanningConfig?.selectionMethod === + ScanningSelectionMethod.StepScan2Switch + ) { // The cost of moving is now a selection cost currentStepCost = currentSelectionCost; } else if ( - scanningConfig?.selectionMethod === ScanningSelectionMethod.StepScan1Switch + scanningConfig?.selectionMethod === + ScanningSelectionMethod.StepScan1Switch ) { // Single switch step scan: every step is a physical selection currentStepCost = currentSelectionCost; } - let sEffort = scanningEffort(steps, selections, currentStepCost, currentSelectionCost); + let sEffort = scanningEffort( + steps, + selections, + currentStepCost, + currentSelectionCost, + ); // Factor in error correction if enabled if (scanningConfig?.errorCorrectionEnabled) { const errorRate = - scanningConfig.errorRate ?? EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE; + scanningConfig.errorRate ?? + EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE; // A "miss" results in needing to wait for a loop (or part of one) // We model this as errorRate * (loopSteps * stepCost) const retryPenalty = loopSteps * currentStepCost; @@ -545,11 +608,13 @@ export class MetricsCalculator { // Apply discounts to scanning effort (similar to touch) if (btn.semantic_id && boardPcts[btn.semantic_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / + boardPcts[btn.semantic_id]; sEffort = Math.min(sEffort, sEffort * discount); } else if (btn.clone_id && boardPcts[btn.clone_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / + boardPcts[btn.clone_id]; sEffort = Math.min(sEffort, sEffort * discount); } @@ -562,7 +627,8 @@ export class MetricsCalculator { if (btn.semantic_id) { if (boardPcts[btn.semantic_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / + boardPcts[btn.semantic_id]; distance = Math.min(distance, distance * discount); } else if (boardPcts[`upstream-${btn.semantic_id}`]) { const discount = @@ -579,7 +645,8 @@ export class MetricsCalculator { if (btn.clone_id) { if (boardPcts[btn.clone_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / + boardPcts[btn.clone_id]; distance = Math.min(distance, distance * discount); } else if (boardPcts[`upstream-${btn.clone_id}`]) { const discount = @@ -588,7 +655,8 @@ export class MetricsCalculator { distance = Math.min(distance, distance * discount); } else if (level > 0 && setPcts[btn.clone_id]) { const discount = - EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id]; + EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / + setPcts[btn.clone_id]; distance = Math.min(distance, distance * discount); } } @@ -597,7 +665,8 @@ export class MetricsCalculator { // Add visual scan or local scan effort if ( - distance > EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN || + distance > + EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN || (entryX === 1.0 && entryY === 1.0) ) { buttonEffort += visualScanEffort(priorItems); @@ -625,11 +694,14 @@ export class MetricsCalculator { // The visitedBoardIds map stores the *lowest* level a board was visited. // If it's already in the map, it means we've processed it or scheduled it at a lower level. if (visitedBoardIds.get(nextBoard.id) === undefined) { - const changeEffort = EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT; + const changeEffort = + EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT; const tempHomeId = - btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior' + btn.semanticAction?.platformData?.grid3?.parameters + ?.temporary_home === "prior" ? board.id - : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true + : btn.semanticAction?.platformData?.grid3?.parameters + ?.temporary_home === true ? btn.targetPageId : temporaryHomeId; @@ -649,26 +721,29 @@ export class MetricsCalculator { // Track word if it speaks or adds to sentence const isSpeak = - btn.semanticAction?.category === AACSemanticCategory.COMMUNICATION && !btn.targetPageId; // Must not be a navigation button + btn.semanticAction?.category === + AACSemanticCategory.COMMUNICATION && !btn.targetPageId; // Must not be a navigation button const addToSentence = - btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence || + btn.semanticAction?.platformData?.grid3?.parameters + ?.add_to_sentence || btn.semanticAction?.fallback?.add_to_sentence; if (isSpeak || addToSentence) { let finalEffort = buttonEffort; // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350) - const changeEffort = EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT; + const changeEffort = + EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT; if (btn.clone_id && boardPcts[btn.clone_id]) { const discount = Math.min( changeEffort, - (changeEffort * 0.3) / boardPcts[btn.clone_id] + (changeEffort * 0.3) / boardPcts[btn.clone_id], ); finalEffort -= discount; } else if (btn.semantic_id && boardPcts[btn.semantic_id]) { const discount = Math.min( changeEffort, - (changeEffort * 0.5) / boardPcts[btn.semantic_id] + (changeEffort * 0.5) / boardPcts[btn.semantic_id], ); finalEffort -= discount; } @@ -704,7 +779,10 @@ export class MetricsCalculator { // Calculate total_buttons as sum of all button counts (matching Ruby line 136) // Ruby: total_buttons: buttons.map{|b| b[:count] || 1}.sum - const calculatedTotalButtons = buttons.reduce((sum, btn) => sum + (btn.count || 1), 0); + const calculatedTotalButtons = buttons.reduce( + (sum, btn) => sum + (btn.count || 1), + 0, + ); return { buttons, @@ -717,7 +795,10 @@ export class MetricsCalculator { /** * Calculate what percentage of links to this board match semantic_id/clone_id */ - private calculateBoardLinkPercentages(tree: AACTree, board: AACPage): { [id: string]: number } { + private calculateBoardLinkPercentages( + tree: AACTree, + board: AACPage, + ): { [id: string]: number } { const boardPcts: { [id: string]: number } = {}; let totalLinks = 0; @@ -734,10 +815,12 @@ export class MetricsCalculator { // Also count IDs present on the source board that links to this one sourceBoard.semantic_ids?.forEach((id: string) => { - boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1; + boardPcts[`upstream-${id}`] = + (boardPcts[`upstream-${id}`] || 0) + 1; }); sourceBoard.clone_ids?.forEach((id: string) => { - boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1; + boardPcts[`upstream-${id}`] = + (boardPcts[`upstream-${id}`] || 0) + 1; }); } }); @@ -750,7 +833,7 @@ export class MetricsCalculator { }); } - boardPcts['all'] = totalLinks; + boardPcts["all"] = totalLinks; return boardPcts; } @@ -762,7 +845,7 @@ export class MetricsCalculator { for (const page of Object.values(tree.pages)) { for (const row of page.grid) { for (const btn of row) { - if (btn?.pos && btn.pos !== 'Unknown' && btn.pos !== 'Ignore') { + if (btn?.pos && btn.pos !== "Unknown" && btn.pos !== "Ignore") { return true; } } @@ -779,92 +862,95 @@ export class MetricsCalculator { * button's predictions array. This is done as a pre-processing step * before calculateWordFormMetrics assigns effort to each form. */ - private expandMorphologicalPredictions(tree: AACTree, options: MetricsOptions): void { - const locale = options.morphologyLocale || 'en-gb'; + private expandMorphologicalPredictions( + tree: AACTree, + options: MetricsOptions, + ): void { + const locale = options.morphologyLocale || "en-gb"; const morph = new MorphologyEngine(locale); // Words that should never be POS-inferred (function words, determiners, etc.) const skipInference = new Set([ - 'a', - 'an', - 'the', - 'to', - 'in', - 'on', - 'at', - 'of', - 'for', - 'and', - 'or', - 'but', - 'not', - 'no', - 'yes', - 'is', - 'am', - 'are', - 'was', - 'were', - 'be', - 'been', - 'being', - 'has', - 'have', - 'had', - 'do', - 'does', - 'did', - 'will', - 'would', - 'could', - 'should', - 'shall', - 'may', - 'might', - 'can', - 'must', - 'with', - 'from', - 'by', - 'up', - 'down', - 'out', - 'off', - 'over', - 'under', - 'again', - 'then', - 'than', - 'so', - 'if', - 'when', - 'where', - 'how', - 'what', - 'who', - 'which', - 'that', - 'this', - 'these', - 'those', - 'here', - 'there', - 'now', - 'very', - 'just', - 'more', - 'also', - 'too', - 'please', - 'thank', - 'hi', - 'hello', - 'bye', - 'goodbye', - 'okay', - 'oh', - 'wow', - 'sorry', + "a", + "an", + "the", + "to", + "in", + "on", + "at", + "of", + "for", + "and", + "or", + "but", + "not", + "no", + "yes", + "is", + "am", + "are", + "was", + "were", + "be", + "been", + "being", + "has", + "have", + "had", + "do", + "does", + "did", + "will", + "would", + "could", + "should", + "shall", + "may", + "might", + "can", + "must", + "with", + "from", + "by", + "up", + "down", + "out", + "off", + "over", + "under", + "again", + "then", + "than", + "so", + "if", + "when", + "where", + "how", + "what", + "who", + "which", + "that", + "this", + "these", + "those", + "here", + "there", + "now", + "very", + "just", + "more", + "also", + "too", + "please", + "thank", + "hi", + "hello", + "bye", + "goodbye", + "okay", + "oh", + "wow", + "sorry", ]); for (const page of Object.values(tree.pages)) { @@ -879,11 +965,15 @@ export class MetricsCalculator { // they are clearly nouns (e.g., "bird", "tree", "cloud"). // Strategy: check irregular tables first for confident POS, // then fall back to Noun for single-word content labels. - if (!pos || pos === 'Unknown' || pos === 'Ignore') { + if (!pos || pos === "Unknown" || pos === "Ignore") { const lower = btn.label.toLowerCase(); // Skip function words and multi-word labels - if (!skipInference.has(lower) && !lower.includes(' ') && lower.length > 1) { + if ( + !skipInference.has(lower) && + !lower.includes(" ") && + lower.length > 1 + ) { // Check irregular tables for confident POS assignment const inferredPOS = morph.inferPOS(lower); if (inferredPOS) { @@ -892,13 +982,13 @@ export class MetricsCalculator { } else { // Default to Noun for untagged content words. // This generates plurals (e.g., bird → birds, tree → trees). - pos = 'Noun'; - btn.pos = 'Noun'; + pos = "Noun"; + btn.pos = "Noun"; } } } - if (!pos || pos === 'Unknown' || pos === 'Ignore') continue; + if (!pos || pos === "Unknown" || pos === "Ignore") continue; const forms = morph.inflect(btn.label, pos); if (forms.length > 0) { @@ -930,7 +1020,7 @@ export class MetricsCalculator { private calculateWordFormMetrics( tree: AACTree, buttons: ButtonMetrics[], - _options: MetricsOptions = {} + _options: MetricsOptions = {}, ): { wordFormMetrics: ButtonMetrics[]; replacedLabels: Set } { const wordFormMetrics: ButtonMetrics[] = []; const replacedLabels = new Set(); @@ -948,7 +1038,7 @@ export class MetricsCalculator { row.forEach((btn: AACButton | null) => { if (!btn || !btn.label) return; const lower = btn.label.toLowerCase(); - if (btn.pos && btn.pos !== 'Unknown' && btn.pos !== 'Ignore') { + if (btn.pos && btn.pos !== "Unknown" && btn.pos !== "Ignore") { treePosMap.set(lower, btn.pos); } if (btn.predictions && btn.predictions.length > 0) { @@ -965,7 +1055,7 @@ export class MetricsCalculator { // propagate the POS tag so it's available in the output. buttons.forEach((btn) => { const lower = btn.label.toLowerCase(); - if (!btn.pos || btn.pos === 'Unknown' || btn.pos === 'Ignore') { + if (!btn.pos || btn.pos === "Unknown" || btn.pos === "Ignore") { const treePos = treePosMap.get(lower); if (treePos) btn.pos = treePos; } @@ -993,7 +1083,9 @@ export class MetricsCalculator { // These require an extra confirmation tap from the user. Smart grammar // morphology outcomes are generated automatically and need no extra tap. const suggestWordsSet = new Set( - ((btn.parameters?.predictions || []) as string[]).map((w) => w.toLowerCase()) + ((btn.parameters?.predictions || []) as string[]).map((w) => + w.toLowerCase(), + ), ); // Calculate effort for each word form @@ -1010,7 +1102,8 @@ export class MetricsCalculator { // Using similar logic to button scanning effort const predictionPriorItems = predictionRowIndex * predictionsGridCols + predictionColIndex; - const predictionSelectionEffort = visualScanEffort(predictionPriorItems); + const predictionSelectionEffort = + visualScanEffort(predictionPriorItems); // Add confirmation cost for Suggest Words outcomes only. // Suggest Words requires an explicit tap on the prediction bar, @@ -1021,7 +1114,9 @@ export class MetricsCalculator { // Word form effort = parent button's cumulative effort + selection effort + confirmation const wordFormEffort = - parentMetrics.effort + predictionSelectionEffort + suggestWordsConfirmation; + parentMetrics.effort + + predictionSelectionEffort + + suggestWordsConfirmation; // Check if this word already exists as a regular button const existingBtn = existingLabels.get(wordFormLower); @@ -1062,8 +1157,8 @@ export class MetricsCalculator { console.log( `📝 Calculated ${wordFormMetrics.length} word form metrics` + (replacedLabels.size > 0 - ? ` (${replacedLabels.size} replaced higher-effort buttons: ${Array.from(replacedLabels).join(', ')})` - : '') + ? ` (${replacedLabels.size} replaced higher-effort buttons: ${Array.from(replacedLabels).join(", ")})` + : ""), ); return { wordFormMetrics, replacedLabels }; @@ -1100,7 +1195,7 @@ export class MetricsCalculator { btn: AACButton, rowIndex: number, colIndex: number, - overrideConfig?: any + overrideConfig?: any, ): { steps: number; selections: number; loopSteps: number } { const config = overrideConfig || board.scanningConfig; // Determine scanning type from local scanType or scanningConfig @@ -1108,10 +1203,14 @@ export class MetricsCalculator { if (config?.cellScanningOrder) { const order = config.cellScanningOrder; // String matching for CellScanningOrder - if (order === CellScanningOrder.RowColumnScan) type = AACScanType.ROW_COLUMN; - else if (order === CellScanningOrder.ColumnRowScan) type = AACScanType.COLUMN_ROW; - else if (order === CellScanningOrder.SimpleScanColumnsFirst) type = AACScanType.COLUMN_ROW; - else if (order === CellScanningOrder.SimpleScan) type = AACScanType.LINEAR; + if (order === CellScanningOrder.RowColumnScan) + type = AACScanType.ROW_COLUMN; + else if (order === CellScanningOrder.ColumnRowScan) + type = AACScanType.COLUMN_ROW; + else if (order === CellScanningOrder.SimpleScanColumnsFirst) + type = AACScanType.COLUMN_ROW; + else if (order === CellScanningOrder.SimpleScan) + type = AACScanType.LINEAR; } // Force block scan if enabled in config @@ -1122,7 +1221,10 @@ export class MetricsCalculator { if (isBlockScan) { const blockId = - btn.scanBlock || (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null); + btn.scanBlock || + (btn.scanBlocks && btn.scanBlocks.length > 0 + ? btn.scanBlocks[0] + : null); // If no block assigned, treat as its own block at the end (fallback) if (blockId === null) { @@ -1175,7 +1277,7 @@ export class MetricsCalculator { for (let r = 0; r < board.grid.length; r++) { for (let c = 0; c < board.grid[r].length; c++) { const b = board.grid[r][c]; - if (b && (b.label || '').length > 0) { + if (b && (b.label || "").length > 0) { totalVisible++; if (!found) { if (b === btn) { diff --git a/src/utilities/analytics/metrics/effort.ts b/src/utilities/analytics/metrics/effort.ts index 406460e..eba0fce 100644 --- a/src/utilities/analytics/metrics/effort.ts +++ b/src/utilities/analytics/metrics/effort.ts @@ -83,10 +83,12 @@ export function distanceEffort( x: number, y: number, entryX: number = 1.0, - entryY: number = 1.0 + entryY: number = 1.0, ): number { const distance = Math.sqrt(Math.pow(x - entryX, 2) + Math.pow(y - entryY, 2)); - return (distance / EFFORT_CONSTANTS.SQRT2) * EFFORT_CONSTANTS.DISTANCE_MULTIPLIER; + return ( + (distance / EFFORT_CONSTANTS.SQRT2) * EFFORT_CONSTANTS.DISTANCE_MULTIPLIER + ); } /** @@ -100,7 +102,7 @@ export function distanceEffort( export function spellingEffort( word: string, entryEffort: number = 10, - perLetterEffort: number = 2.5 + perLetterEffort: number = 2.5, ): number { return entryEffort + word.length * perLetterEffort; } @@ -123,7 +125,7 @@ export function predictionEffort( entryEffort: number = 10, perLetterEffort: number = 2.5, avgSelections: number = 1.5, - lettersToType: number = 2 + lettersToType: number = 2, ): number { // Cost to navigate to keyboard + type first few letters + select from predictions const typingCost = lettersToType * perLetterEffort; @@ -140,7 +142,11 @@ export function predictionEffort( * @param buttonCount - Number of visible buttons * @returns Base board effort score */ -export function baseBoardEffort(rows: number, cols: number, buttonCount: number): number { +export function baseBoardEffort( + rows: number, + cols: number, + buttonCount: number, +): number { const sizeEffort = buttonSizeEffort(rows, cols); const fieldEffort = fieldSizeEffort(buttonCount); return sizeEffort + fieldEffort; @@ -153,7 +159,10 @@ export function baseBoardEffort(rows: number, cols: number, buttonCount: number) * @param reuseDiscount - Calculated reuse discount * @returns Adjusted board effort */ -export function applyReuseDiscount(boardEffort: number, reuseDiscount: number): number { +export function applyReuseDiscount( + boardEffort: number, + reuseDiscount: number, +): number { return Math.max(0, boardEffort - reuseDiscount); } @@ -168,20 +177,23 @@ export function applyReuseDiscount(boardEffort: number, reuseDiscount: number): export function calculateButtonEffort( baseEffort: number, boardPcts: { [id: string]: number }, - button: { semantic_id?: string; clone_id?: string } + button: { semantic_id?: string; clone_id?: string }, ): number { let buttonEffort = baseEffort; // Apply discounts for semantic_id if (button.semantic_id && boardPcts[button.semantic_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.semantic_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / + boardPcts[button.semantic_id]; buttonEffort = Math.min(buttonEffort, buttonEffort * discount); } // Apply discounts for clone_id if (button.clone_id && boardPcts[button.clone_id]) { - const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.clone_id]; + const discount = + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / + boardPcts[button.clone_id]; buttonEffort = Math.min(buttonEffort, buttonEffort * discount); } @@ -201,7 +213,7 @@ export function calculateDistanceWithDiscounts( distance: number, boardPcts: { [id: string]: number }, button: { semantic_id?: string; clone_id?: string }, - setPcts: { [id: string]: number } + setPcts: { [id: string]: number }, ): number { let adjustedDistance = distance; @@ -209,12 +221,20 @@ export function calculateDistanceWithDiscounts( if (button.semantic_id) { if (boardPcts[button.semantic_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.semantic_id]; - adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount); + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / + boardPcts[button.semantic_id]; + adjustedDistance = Math.min( + adjustedDistance, + adjustedDistance * discount, + ); } else if (setPcts[button.semantic_id]) { const discount = - EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT / setPcts[button.semantic_id]; - adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount); + EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT / + setPcts[button.semantic_id]; + adjustedDistance = Math.min( + adjustedDistance, + adjustedDistance * discount, + ); } } @@ -222,12 +242,20 @@ export function calculateDistanceWithDiscounts( if (button.clone_id) { if (boardPcts[button.clone_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.clone_id]; - adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount); + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / + boardPcts[button.clone_id]; + adjustedDistance = Math.min( + adjustedDistance, + adjustedDistance * discount, + ); } else if (setPcts[button.clone_id]) { const discount = - EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[button.clone_id]; - adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount); + EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / + setPcts[button.clone_id]; + adjustedDistance = Math.min( + adjustedDistance, + adjustedDistance * discount, + ); } } @@ -267,7 +295,7 @@ export function scanningEffort( steps: number, selections: number, stepCost: number = EFFORT_CONSTANTS.SCAN_STEP_COST, - selectionCost: number = EFFORT_CONSTANTS.SCAN_SELECTION_COST + selectionCost: number = EFFORT_CONSTANTS.SCAN_SELECTION_COST, ): number { return steps * stepCost + selections * selectionCost; } diff --git a/src/utilities/analytics/metrics/index.ts b/src/utilities/analytics/metrics/index.ts index 03a09b6..50cf540 100644 --- a/src/utilities/analytics/metrics/index.ts +++ b/src/utilities/analytics/metrics/index.ts @@ -8,10 +8,10 @@ * - Comparative analysis between board sets */ -export { MetricsCalculator } from './core'; -export { VocabularyAnalyzer } from './vocabulary'; -export { SentenceAnalyzer } from './sentence'; -export { ComparisonAnalyzer } from './comparison'; +export { MetricsCalculator } from "./core"; +export { VocabularyAnalyzer } from "./vocabulary"; +export { SentenceAnalyzer } from "./sentence"; +export { ComparisonAnalyzer } from "./comparison"; -export * from './types'; -export * from './effort'; +export * from "./types"; +export * from "./effort"; diff --git a/src/utilities/analytics/metrics/obl-types.ts b/src/utilities/analytics/metrics/obl-types.ts index f13be02..b06bbcc 100644 --- a/src/utilities/analytics/metrics/obl-types.ts +++ b/src/utilities/analytics/metrics/obl-types.ts @@ -15,7 +15,7 @@ export interface OblAction { export interface OblEventBase { id: string; timestamp: string; // ISO 8601 - type: 'button' | 'action' | 'utterance' | 'note' | 'other' | string; + type: "button" | "action" | "utterance" | "note" | "other" | string; locale?: string; geo?: [number, number, number?]; // lat, long, alt location_id?: string; @@ -29,7 +29,7 @@ export interface OblEventBase { } export interface OblButtonEvent extends OblEventBase { - type: 'button'; + type: "button"; label: string; spoken: boolean; button_id?: string; @@ -40,7 +40,7 @@ export interface OblButtonEvent extends OblEventBase { } export interface OblActionEvent extends OblEventBase { - type: 'action'; + type: "action"; action: string; destination_board_id?: string; text?: string; @@ -48,7 +48,7 @@ export interface OblActionEvent extends OblEventBase { } export interface OblUtteranceEvent extends OblEventBase { - type: 'utterance'; + type: "utterance"; text: string; buttons?: Array<{ id?: string; @@ -61,7 +61,7 @@ export interface OblUtteranceEvent extends OblEventBase { } export interface OblNoteEvent extends OblEventBase { - type: 'note'; + type: "note"; text: string; author_name?: string; author_email?: string; @@ -77,7 +77,7 @@ export type OblEvent = export interface OblSession { id: string; - type: 'log' | string; + type: "log" | string; started: string; // ISO 8601 ended: string; // ISO 8601 device_id?: string; @@ -88,7 +88,7 @@ export interface OblSession { } export interface OblFile { - format: 'open-board-log-0.1' | string; + format: "open-board-log-0.1" | string; user_id: string; user_name?: string; source?: string; diff --git a/src/utilities/analytics/metrics/obl.ts b/src/utilities/analytics/metrics/obl.ts index 2a3e1cc..85bf07c 100644 --- a/src/utilities/analytics/metrics/obl.ts +++ b/src/utilities/analytics/metrics/obl.ts @@ -6,9 +6,12 @@ import { OblUtteranceEvent, OblActionEvent, OblNoteEvent, -} from './obl-types'; -import { HistoryEntry, HistoryOccurrence } from '../history'; -import { AACSemanticIntent, AACSemanticCategory } from '../../../core/treeStructure'; +} from "./obl-types"; +import { HistoryEntry, HistoryOccurrence } from "../history"; +import { + AACSemanticIntent, + AACSemanticCategory, +} from "../../../core/treeStructure"; /** * .obl (Open Board Logging) Utility @@ -23,8 +26,8 @@ export class OblUtil { static parse(json: string): OblFile { // Remove potential comment at the start let cleanJson = json.trim(); - if (cleanJson.startsWith('/*')) { - const endComment = cleanJson.indexOf('*/'); + if (cleanJson.startsWith("/*")) { + const endComment = cleanJson.indexOf("*/"); if (endComment !== -1) { cleanJson = cleanJson.substring(endComment + 2).trim(); } @@ -49,7 +52,7 @@ export class OblUtil { */ static toHistoryEntries(obl: OblFile): HistoryEntry[] { const entries: HistoryEntry[] = []; - const source = obl.source || 'OBL'; + const source = obl.source || "OBL"; // OBL is session-based and event-based. // HistoryEntry is content-based with occurrences. @@ -58,7 +61,7 @@ export class OblUtil { for (const session of obl.sessions) { for (const event of session.events) { - let content = ''; + let content = ""; const evtAny = event as any; const occurrence: HistoryOccurrence = { timestamp: new Date(event.timestamp), @@ -66,7 +69,7 @@ export class OblUtil { pageId: evtAny.board_id || null, latitude: event.geo?.[0] || null, longitude: event.geo?.[1] || null, - type: event.type as HistoryOccurrence['type'], + type: event.type as HistoryOccurrence["type"], // Store all other OBL fields in the occurrence buttonId: evtAny.button_id || null, boardId: evtAny.board_id || null, @@ -76,21 +79,21 @@ export class OblUtil { actions: evtAny.actions, }; - if (event.type === 'button') { + if (event.type === "button") { const btn = event as OblButtonEvent; content = btn.vocalization || btn.label; - } else if (event.type === 'utterance') { + } else if (event.type === "utterance") { const utt = event as OblUtteranceEvent; content = utt.text; - } else if (event.type === 'action') { + } else if (event.type === "action") { const act = event as OblActionEvent; content = act.action; - } else if (event.type === 'note') { + } else if (event.type === "note") { const note = event as OblNoteEvent; content = note.text; } else { const evtAny = event as any; - content = evtAny.label || evtAny.text || evtAny.action || 'unknown'; + content = evtAny.label || evtAny.text || evtAny.action || "unknown"; } const occurrences = contentMap.get(content) || []; @@ -104,7 +107,9 @@ export class OblUtil { id: `obl:${content}`, source: source, content: content, - occurrences: occurrences.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()), + occurrences: occurrences.sort( + (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), + ), }); }); @@ -114,7 +119,11 @@ export class OblUtil { /** * Convert HistoryEntries to an OBL file object. */ - static fromHistoryEntries(entries: HistoryEntry[], userId: string, source?: string): OblFile { + static fromHistoryEntries( + entries: HistoryEntry[], + userId: string, + source?: string, + ): OblFile { const events: OblEvent[] = []; for (const entry of entries) { @@ -122,33 +131,33 @@ export class OblUtil { const timestamp = occ.timestamp.toISOString(); const intent = occ.intent as string; - let oblType: OblEvent['type'] = occ.type || 'button'; + let oblType: OblEvent["type"] = occ.type || "button"; let actionStr: string | undefined = undefined; // Smart mapping based on AACSemanticIntent if (intent === (AACSemanticIntent.CLEAR_TEXT as string)) { - oblType = 'action'; - actionStr = ':clear'; + oblType = "action"; + actionStr = ":clear"; } else if (intent === (AACSemanticIntent.GO_HOME as string)) { - oblType = 'action'; - actionStr = ':home'; + oblType = "action"; + actionStr = ":home"; } else if (intent === (AACSemanticIntent.NAVIGATE_TO as string)) { - oblType = 'action'; - actionStr = ':open_board'; + oblType = "action"; + actionStr = ":open_board"; } else if (intent === (AACSemanticIntent.GO_BACK as string)) { - oblType = 'action'; - actionStr = ':back'; + oblType = "action"; + actionStr = ":back"; } else if (intent === (AACSemanticIntent.DELETE_CHARACTER as string)) { - oblType = 'action'; - actionStr = ':backspace'; + oblType = "action"; + actionStr = ":backspace"; } else if ( intent === (AACSemanticIntent.SPEAK_IMMEDIATE as string) || intent === (AACSemanticIntent.SPEAK_TEXT as string) ) { // Speak could be a button or an utterance or an action - if (oblType !== 'utterance' && oblType !== 'button') { - oblType = 'action'; - actionStr = ':speak'; + if (oblType !== "utterance" && oblType !== "button") { + oblType = "action"; + actionStr = ":speak"; } } @@ -168,19 +177,22 @@ export class OblUtil { common.geo = [occ.latitude, occ.longitude]; } - if (oblType === 'utterance') { + if (oblType === "utterance") { events.push({ ...common, text: entry.content, } as OblUtteranceEvent); - } else if (oblType === 'action') { + } else if (oblType === "action") { events.push({ ...common, action: actionStr || entry.content, destination_board_id: occ.boardId || undefined, - text: intent === (AACSemanticIntent.SPEAK_TEXT as string) ? entry.content : undefined, + text: + intent === (AACSemanticIntent.SPEAK_TEXT as string) + ? entry.content + : undefined, } as OblActionEvent); - } else if (oblType === 'note') { + } else if (oblType === "note") { events.push({ ...common, text: entry.content, @@ -189,11 +201,12 @@ export class OblUtil { // Default to button events.push({ ...common, - type: 'button', + type: "button", label: occ.vocalization ? entry.content : entry.content, spoken: occ.spoken ?? - (occ.category as string) === (AACSemanticCategory.COMMUNICATION as string), + (occ.category as string) === + (AACSemanticCategory.COMMUNICATION as string), button_id: occ.buttonId || undefined, board_id: occ.boardId || occ.pageId || undefined, vocalization: occ.vocalization || undefined, @@ -207,22 +220,25 @@ export class OblUtil { // Sort events by timestamp events.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); - const started = events.length > 0 ? events[0].timestamp : new Date().toISOString(); + const started = + events.length > 0 ? events[0].timestamp : new Date().toISOString(); const ended = - events.length > 0 ? events[events.length - 1].timestamp : new Date().toISOString(); + events.length > 0 + ? events[events.length - 1].timestamp + : new Date().toISOString(); const session: OblSession = { - id: 'session-1', - type: 'log', + id: "session-1", + type: "log", started, ended, events, }; return { - format: 'open-board-log-0.1', + format: "open-board-log-0.1", user_id: userId, - source: source || 'aac-processors', + source: source || "aac-processors", sessions: [session], }; } @@ -242,28 +258,28 @@ export class OblAnonymizer { for (const session of newObl.sessions) { session.anonymizations = session.anonymizations || []; - if (types.includes('timestamp_shift')) { + if (types.includes("timestamp_shift")) { this.applyTimestampShift(session); - if (!session.anonymizations.includes('timestamp_shift')) - session.anonymizations.push('timestamp_shift'); + if (!session.anonymizations.includes("timestamp_shift")) + session.anonymizations.push("timestamp_shift"); } - if (types.includes('geolocation_masking')) { + if (types.includes("geolocation_masking")) { this.applyGeolocationMasking(session); - if (!session.anonymizations.includes('geolocation_masking')) - session.anonymizations.push('geolocation_masking'); + if (!session.anonymizations.includes("geolocation_masking")) + session.anonymizations.push("geolocation_masking"); } - if (types.includes('url_stripping')) { + if (types.includes("url_stripping")) { this.applyUrlStripping(session); - if (!session.anonymizations.includes('url_stripping')) - session.anonymizations.push('url_stripping'); + if (!session.anonymizations.includes("url_stripping")) + session.anonymizations.push("url_stripping"); } - if (types.includes('name_masking')) { + if (types.includes("name_masking")) { this.applyNameMasking(newObl, session); - if (!session.anonymizations.includes('name_masking')) - session.anonymizations.push('name_masking'); + if (!session.anonymizations.includes("name_masking")) + session.anonymizations.push("name_masking"); } } @@ -274,20 +290,30 @@ export class OblAnonymizer { if (session.events.length === 0) return; const firstEventTime = - session.events.length > 0 ? new Date(session.events[0].timestamp).getTime() : Infinity; - const sessionStartTime = session.started ? new Date(session.started).getTime() : Infinity; + session.events.length > 0 + ? new Date(session.events[0].timestamp).getTime() + : Infinity; + const sessionStartTime = session.started + ? new Date(session.started).getTime() + : Infinity; const firstTimestamp = Math.min(firstEventTime, sessionStartTime); if (firstTimestamp === Infinity) return; - const targetStart = new Date('2000-01-01T00:00:00.000Z').getTime(); + const targetStart = new Date("2000-01-01T00:00:00.000Z").getTime(); const offset = targetStart - firstTimestamp; - session.started = new Date(new Date(session.started).getTime() + offset).toISOString(); - session.ended = new Date(new Date(session.ended).getTime() + offset).toISOString(); + session.started = new Date( + new Date(session.started).getTime() + offset, + ).toISOString(); + session.ended = new Date( + new Date(session.ended).getTime() + offset, + ).toISOString(); for (const event of session.events) { - event.timestamp = new Date(new Date(event.timestamp).getTime() + offset).toISOString(); + event.timestamp = new Date( + new Date(event.timestamp).getTime() + offset, + ).toISOString(); } } @@ -300,10 +326,10 @@ export class OblAnonymizer { private static applyUrlStripping(session: OblSession): void { for (const event of session.events) { - if (event.type === 'button') { + if (event.type === "button") { delete (event as OblButtonEvent).image_url; } - if (event.type === 'note') { + if (event.type === "note") { delete (event as OblNoteEvent).author_url; delete (event as OblNoteEvent).author_email; } @@ -313,7 +339,7 @@ export class OblAnonymizer { private static applyNameMasking(obl: OblFile, session: OblSession): void { delete obl.user_name; for (const event of session.events) { - if (event.type === 'note') { + if (event.type === "note") { delete (event as OblNoteEvent).author_name; } } diff --git a/src/utilities/analytics/metrics/sentence.ts b/src/utilities/analytics/metrics/sentence.ts index c30425e..456d5fd 100644 --- a/src/utilities/analytics/metrics/sentence.ts +++ b/src/utilities/analytics/metrics/sentence.ts @@ -5,8 +5,8 @@ * from the AAC board set, including spelling fallback for missing words. */ -import { MetricsResult } from './types'; -import { spellingEffort } from './effort'; +import { MetricsResult } from "./types"; +import { spellingEffort } from "./effort"; export interface SentenceAnalysis { sentence: string; // Full sentence text @@ -22,7 +22,10 @@ export class SentenceAnalyzer { /** * Analyze effort to construct a set of test sentences */ - analyzeSentences(metrics: MetricsResult, sentences: string[][]): SentenceAnalysis[] { + analyzeSentences( + metrics: MetricsResult, + sentences: string[][], + ): SentenceAnalysis[] { return sentences.map((words) => this.analyzeSentence(metrics, words)); } @@ -30,7 +33,8 @@ export class SentenceAnalyzer { * Analyze effort to construct a single sentence */ analyzeSentence(metrics: MetricsResult, words: string[]): SentenceAnalysis { - const wordEfforts: Array<{ word: string; effort: number; typed: boolean }> = []; + const wordEfforts: Array<{ word: string; effort: number; typed: boolean }> = + []; let totalEffort = 0; let typing = false; const missingWords: string[] = []; @@ -60,7 +64,7 @@ export class SentenceAnalyzer { const baseSpell = spellingEffort( word, metrics.spelling_effort_base || 10, - metrics.spelling_effort_per_letter || 2.5 + metrics.spelling_effort_per_letter || 2.5, ); if (metrics.has_dynamic_prediction) { @@ -111,7 +115,7 @@ export class SentenceAnalyzer { } return word.toLowerCase(); }) - .join(' '); + .join(" "); } /** @@ -134,7 +138,8 @@ export class SentenceAnalyzer { const sentencesWithoutTyping = totalSentences - sentencesRequiringTyping; const efforts = analyses.map((a) => a.effort); - const averageEffort = efforts.reduce((sum, e) => sum + e, 0) / efforts.length; + const averageEffort = + efforts.reduce((sum, e) => sum + e, 0) / efforts.length; const minEffort = Math.min(...efforts); const maxEffort = Math.max(...efforts); @@ -142,12 +147,16 @@ export class SentenceAnalyzer { const sortedEfforts = [...efforts].sort((a, b) => a - b); const medianEffort = sortedEfforts.length % 2 === 0 - ? (sortedEfforts[sortedEfforts.length / 2 - 1] + sortedEfforts[sortedEfforts.length / 2]) / + ? (sortedEfforts[sortedEfforts.length / 2 - 1] + + sortedEfforts[sortedEfforts.length / 2]) / 2 : sortedEfforts[Math.floor(sortedEfforts.length / 2)]; const totalWords = analyses.reduce((sum, a) => sum + a.words.length, 0); - const wordsRequiringTyping = analyses.reduce((sum, a) => sum + a.missing_words.length, 0); + const wordsRequiringTyping = analyses.reduce( + (sum, a) => sum + a.missing_words.length, + 0, + ); const typingPercent = (wordsRequiringTyping / totalWords) * 100; return { diff --git a/src/utilities/analytics/metrics/types.ts b/src/utilities/analytics/metrics/types.ts index 3747bc7..4f00abf 100644 --- a/src/utilities/analytics/metrics/types.ts +++ b/src/utilities/analytics/metrics/types.ts @@ -4,7 +4,7 @@ * Defines the data structures used for AAC metrics analysis */ -import { ScanningConfig } from '../../../types/aac'; +import { ScanningConfig } from "../../../types/aac"; // import { AACTree } from '../../../types/aac'; diff --git a/src/utilities/analytics/metrics/vocabulary.ts b/src/utilities/analytics/metrics/vocabulary.ts index 01adc5a..fa8b6cf 100644 --- a/src/utilities/analytics/metrics/vocabulary.ts +++ b/src/utilities/analytics/metrics/vocabulary.ts @@ -5,9 +5,12 @@ * and identifies missing/extra words compared to reference lists. */ -import { MetricsResult, CoreList } from './types'; -import { ReferenceLoader, type ReferenceDataProvider } from '../reference/index'; -import { spellingEffort } from './effort'; +import { MetricsResult, CoreList } from "./types"; +import { + ReferenceLoader, + type ReferenceDataProvider, +} from "../reference/index"; +import { spellingEffort } from "./effort"; export interface VocabularyAnalysis { // Coverage statistics for each core list @@ -52,7 +55,7 @@ export class VocabularyAnalyzer { locale?: string; highEffortThreshold?: number; lowEffortThreshold?: number; - } + }, ): Promise { // const locale = options?.locale || metrics.locale || 'en'; const highEffortThreshold = options?.highEffortThreshold || 5.0; @@ -72,7 +75,7 @@ export class VocabularyAnalyzer { }); // Analyze each core list - const core_coverage: VocabularyAnalysis['core_coverage'] = {}; + const core_coverage: VocabularyAnalysis["core_coverage"] = {}; coreLists.forEach((list) => { const analysis = this.analyzeCoreList(list, wordEffortMap); @@ -124,8 +127,8 @@ export class VocabularyAnalyzer { */ private analyzeCoreList( list: CoreList, - wordEffortMap: Map - ): VocabularyAnalysis['core_coverage'][string] { + wordEffortMap: Map, + ): VocabularyAnalysis["core_coverage"][string] { const covered: string[] = []; const missing: string[] = []; let totalEffort = 0; @@ -159,13 +162,15 @@ export class VocabularyAnalyzer { */ calculateCoverage( wordList: string[], - metrics: MetricsResult + metrics: MetricsResult, ): { covered: string[]; missing: string[]; coverage_percent: number; } { - const wordSet = new Set(metrics.buttons.map((btn) => btn.label.toLowerCase())); + const wordSet = new Set( + metrics.buttons.map((btn) => btn.label.toLowerCase()), + ); const covered: string[] = []; const missing: string[] = []; @@ -189,11 +194,17 @@ export class VocabularyAnalyzer { * Get effort for a word, or calculate spelling effort if missing */ getWordEffort(word: string, metrics: MetricsResult): number { - const btn = metrics.buttons.find((b) => b.label.toLowerCase() === word.toLowerCase()); + const btn = metrics.buttons.find( + (b) => b.label.toLowerCase() === word.toLowerCase(), + ); if (btn) { return btn.effort; } - return spellingEffort(word, metrics.spelling_effort_base, metrics.spelling_effort_per_letter); + return spellingEffort( + word, + metrics.spelling_effort_base, + metrics.spelling_effort_per_letter, + ); } /** diff --git a/src/utilities/analytics/morphology/engine.ts b/src/utilities/analytics/morphology/engine.ts index 54d40c8..88f0574 100644 --- a/src/utilities/analytics/morphology/engine.ts +++ b/src/utilities/analytics/morphology/engine.ts @@ -1,5 +1,5 @@ -import { MorphRuleSet, MorphRule } from './types'; -import type { Grid3VerbForms } from './grid3VerbsParser'; +import { MorphRuleSet, MorphRule } from "./types"; +import type { Grid3VerbForms } from "./grid3VerbsParser"; export class MorphologyEngine { private ruleSet: MorphRuleSet; @@ -7,7 +7,7 @@ export class MorphologyEngine { private cache = new Map(); constructor(ruleSetOrLocale: string | MorphRuleSet) { - if (typeof ruleSetOrLocale === 'string') { + if (typeof ruleSetOrLocale === "string") { this.ruleSet = this.loadBundled(ruleSetOrLocale); } else { this.ruleSet = ruleSetOrLocale; @@ -35,7 +35,8 @@ export class MorphologyEngine { if (cached) return cached; if (this.grid3Verbs) { - const forms = this.grid3Verbs.get(base) || this.grid3Verbs.get(base.toLowerCase()); + const forms = + this.grid3Verbs.get(base) || this.grid3Verbs.get(base.toLowerCase()); if (forms) { this.cache.set(key, forms); return forms; @@ -54,12 +55,12 @@ export class MorphologyEngine { } expandVocabulary( - buttons: Array<{ label: string; pos?: string; predictions?: string[] }> + buttons: Array<{ label: string; pos?: string; predictions?: string[] }>, ): Map { const result = new Map(); for (const btn of buttons) { const pos = btn.pos; - if (!pos || pos === 'Unknown' || pos === 'Ignore') continue; + if (!pos || pos === "Unknown" || pos === "Ignore") continue; const forms = this.inflect(btn.label, pos); if (forms.length > 0) { result.set(btn.label, forms); @@ -68,7 +69,10 @@ export class MorphologyEngine { return result; } - inflectWithSlots(base: string, pos: string): Array<{ slot: string; form: string }> { + inflectWithSlots( + base: string, + pos: string, + ): Array<{ slot: string; form: string }> { const lower = base.toLowerCase(); const result: Array<{ slot: string; form: string }> = []; const seen = new Set(); @@ -78,7 +82,7 @@ export class MorphologyEngine { if (irregular) { for (const [slot, value] of Object.entries(irregular)) { - if (slot === 'extra' && Array.isArray(value)) { + if (slot === "extra" && Array.isArray(value)) { value.forEach((v) => { if (!seen.has(v)) { seen.add(v); @@ -107,9 +111,9 @@ export class MorphologyEngine { if (irregular && irregular[slot] !== undefined) continue; let rules: MorphRule[]; - if (typeof rulesOrAlias === 'string') { + if (typeof rulesOrAlias === "string") { const aliased = regularSlots[rulesOrAlias]; - if (!aliased || typeof aliased === 'string') continue; + if (!aliased || typeof aliased === "string") continue; rules = aliased; } else { rules = rulesOrAlias; @@ -134,7 +138,7 @@ export class MorphologyEngine { if (irregular) { for (const [slot, value] of Object.entries(irregular)) { - if (slot === 'extra' && Array.isArray(value)) { + if (slot === "extra" && Array.isArray(value)) { value.forEach((v) => forms.add(v)); } else if (Array.isArray(value)) { value.forEach((v) => forms.add(v)); @@ -152,9 +156,9 @@ export class MorphologyEngine { if (irregular && irregular[slot] !== undefined) continue; let rules: MorphRule[]; - if (typeof rulesOrAlias === 'string') { + if (typeof rulesOrAlias === "string") { const aliased = regularSlots[rulesOrAlias]; - if (!aliased || typeof aliased === 'string') continue; + if (!aliased || typeof aliased === "string") continue; rules = aliased; } else { rules = rulesOrAlias; @@ -171,9 +175,9 @@ export class MorphologyEngine { private applyRules(word: string, rules: MorphRule[]): string | undefined { for (const rule of rules) { - const regex = new RegExp(rule.match, 'i'); + const regex = new RegExp(rule.match, "i"); if (regex.test(word)) { - return word.replace(new RegExp(rule.match, 'i'), rule.replace); + return word.replace(new RegExp(rule.match, "i"), rule.replace); } } return undefined; @@ -186,7 +190,7 @@ export class MorphologyEngine { */ inferPOS(word: string): string | null { const lower = word.toLowerCase(); - for (const pos of ['Verb', 'Noun', 'Adjective', 'Pronoun']) { + for (const pos of ["Verb", "Noun", "Adjective", "Pronoun"]) { if (this.ruleSet.irregular[pos]?.[lower]) { return pos; } @@ -195,15 +199,15 @@ export class MorphologyEngine { } private loadBundled(locale: string): MorphRuleSet { - const normalized = locale.toLowerCase().replace('_', '-'); + const normalized = locale.toLowerCase().replace("_", "-"); switch (normalized) { - case 'en-gb': - case 'en-us': - case 'en-au': - case 'en-ca': - case 'en-nz': - case 'en-za': - case 'en': + case "en-gb": + case "en-us": + case "en-au": + case "en-ca": + case "en-nz": + case "en-za": + case "en": return builtinEn(); default: return { locale, version: 1, irregular: {}, regular: {} }; @@ -213,717 +217,717 @@ export class MorphologyEngine { function builtinEn(): MorphRuleSet { return { - locale: 'en-gb', + locale: "en-gb", version: 1, irregular: { Verb: { be: { - '3sg': 'is', - past: 'was', - pastPart: 'been', - presPart: 'being', - extra: ['am', 'are', 'were'], + "3sg": "is", + past: "was", + pastPart: "been", + presPart: "being", + extra: ["am", "are", "were"], }, have: { - '3sg': 'has', - past: 'had', - pastPart: 'had', - presPart: 'having', + "3sg": "has", + past: "had", + pastPart: "had", + presPart: "having", }, - do: { '3sg': 'does', past: 'did', pastPart: 'done', presPart: 'doing' }, + do: { "3sg": "does", past: "did", pastPart: "done", presPart: "doing" }, go: { - '3sg': 'goes', - past: 'went', - pastPart: 'gone', - presPart: 'going', + "3sg": "goes", + past: "went", + pastPart: "gone", + presPart: "going", }, say: { - '3sg': 'says', - past: 'said', - pastPart: 'said', - presPart: 'saying', + "3sg": "says", + past: "said", + pastPart: "said", + presPart: "saying", }, get: { - '3sg': 'gets', - past: 'got', - pastPart: 'got', - presPart: 'getting', + "3sg": "gets", + past: "got", + pastPart: "got", + presPart: "getting", }, make: { - '3sg': 'makes', - past: 'made', - pastPart: 'made', - presPart: 'making', + "3sg": "makes", + past: "made", + pastPart: "made", + presPart: "making", }, come: { - '3sg': 'comes', - past: 'came', - pastPart: 'come', - presPart: 'coming', + "3sg": "comes", + past: "came", + pastPart: "come", + presPart: "coming", }, take: { - '3sg': 'takes', - past: 'took', - pastPart: 'taken', - presPart: 'taking', + "3sg": "takes", + past: "took", + pastPart: "taken", + presPart: "taking", }, know: { - '3sg': 'knows', - past: 'knew', - pastPart: 'known', - presPart: 'knowing', + "3sg": "knows", + past: "knew", + pastPart: "known", + presPart: "knowing", }, think: { - '3sg': 'thinks', - past: 'thought', - pastPart: 'thought', - presPart: 'thinking', + "3sg": "thinks", + past: "thought", + pastPart: "thought", + presPart: "thinking", }, see: { - '3sg': 'sees', - past: 'saw', - pastPart: 'seen', - presPart: 'seeing', + "3sg": "sees", + past: "saw", + pastPart: "seen", + presPart: "seeing", }, give: { - '3sg': 'gives', - past: 'gave', - pastPart: 'given', - presPart: 'giving', + "3sg": "gives", + past: "gave", + pastPart: "given", + presPart: "giving", }, find: { - '3sg': 'finds', - past: 'found', - pastPart: 'found', - presPart: 'finding', + "3sg": "finds", + past: "found", + pastPart: "found", + presPart: "finding", }, tell: { - '3sg': 'tells', - past: 'told', - pastPart: 'told', - presPart: 'telling', + "3sg": "tells", + past: "told", + pastPart: "told", + presPart: "telling", }, feel: { - '3sg': 'feels', - past: 'felt', - pastPart: 'felt', - presPart: 'feeling', + "3sg": "feels", + past: "felt", + pastPart: "felt", + presPart: "feeling", }, run: { - '3sg': 'runs', - past: 'ran', - pastPart: 'run', - presPart: 'running', + "3sg": "runs", + past: "ran", + pastPart: "run", + presPart: "running", }, fly: { - '3sg': 'flies', - past: 'flew', - pastPart: 'flown', - presPart: 'flying', + "3sg": "flies", + past: "flew", + pastPart: "flown", + presPart: "flying", }, try: { - '3sg': 'tries', - past: 'tried', - pastPart: 'tried', - presPart: 'trying', + "3sg": "tries", + past: "tried", + pastPart: "tried", + presPart: "trying", }, leave: { - '3sg': 'leaves', - past: 'left', - pastPart: 'left', - presPart: 'leaving', + "3sg": "leaves", + past: "left", + pastPart: "left", + presPart: "leaving", }, call: { - '3sg': 'calls', - past: 'called', - pastPart: 'called', - presPart: 'calling', + "3sg": "calls", + past: "called", + pastPart: "called", + presPart: "calling", }, ask: { - '3sg': 'asks', - past: 'asked', - pastPart: 'asked', - presPart: 'asking', + "3sg": "asks", + past: "asked", + pastPart: "asked", + presPart: "asking", }, put: { - '3sg': 'puts', - past: 'put', - pastPart: 'put', - presPart: 'putting', + "3sg": "puts", + past: "put", + pastPart: "put", + presPart: "putting", }, read: { - '3sg': 'reads', - past: 'read', - pastPart: 'read', - presPart: 'reading', + "3sg": "reads", + past: "read", + pastPart: "read", + presPart: "reading", }, eat: { - '3sg': 'eats', - past: 'ate', - pastPart: 'eaten', - presPart: 'eating', + "3sg": "eats", + past: "ate", + pastPart: "eaten", + presPart: "eating", }, drink: { - '3sg': 'drinks', - past: 'drank', - pastPart: 'drunk', - presPart: 'drinking', + "3sg": "drinks", + past: "drank", + pastPart: "drunk", + presPart: "drinking", }, sleep: { - '3sg': 'sleeps', - past: 'slept', - pastPart: 'slept', - presPart: 'sleeping', + "3sg": "sleeps", + past: "slept", + pastPart: "slept", + presPart: "sleeping", }, speak: { - '3sg': 'speaks', - past: 'spoke', - pastPart: 'spoken', - presPart: 'speaking', + "3sg": "speaks", + past: "spoke", + pastPart: "spoken", + presPart: "speaking", }, write: { - '3sg': 'writes', - past: 'wrote', - pastPart: 'written', - presPart: 'writing', + "3sg": "writes", + past: "wrote", + pastPart: "written", + presPart: "writing", }, sit: { - '3sg': 'sits', - past: 'sat', - pastPart: 'sat', - presPart: 'sitting', + "3sg": "sits", + past: "sat", + pastPart: "sat", + presPart: "sitting", }, stand: { - '3sg': 'stands', - past: 'stood', - pastPart: 'stood', - presPart: 'standing', + "3sg": "stands", + past: "stood", + pastPart: "stood", + presPart: "standing", }, fall: { - '3sg': 'falls', - past: 'fell', - pastPart: 'fallen', - presPart: 'falling', + "3sg": "falls", + past: "fell", + pastPart: "fallen", + presPart: "falling", }, hold: { - '3sg': 'holds', - past: 'held', - pastPart: 'held', - presPart: 'holding', + "3sg": "holds", + past: "held", + pastPart: "held", + presPart: "holding", }, keep: { - '3sg': 'keeps', - past: 'kept', - pastPart: 'kept', - presPart: 'keeping', + "3sg": "keeps", + past: "kept", + pastPart: "kept", + presPart: "keeping", }, buy: { - '3sg': 'buys', - past: 'bought', - pastPart: 'bought', - presPart: 'buying', + "3sg": "buys", + past: "bought", + pastPart: "bought", + presPart: "buying", }, bring: { - '3sg': 'brings', - past: 'brought', - pastPart: 'brought', - presPart: 'bringing', + "3sg": "brings", + past: "brought", + pastPart: "brought", + presPart: "bringing", }, catch: { - '3sg': 'catches', - past: 'caught', - pastPart: 'caught', - presPart: 'catching', + "3sg": "catches", + past: "caught", + pastPart: "caught", + presPart: "catching", }, teach: { - '3sg': 'teaches', - past: 'taught', - pastPart: 'taught', - presPart: 'teaching', + "3sg": "teaches", + past: "taught", + pastPart: "taught", + presPart: "teaching", }, fight: { - '3sg': 'fights', - past: 'fought', - pastPart: 'fought', - presPart: 'fighting', + "3sg": "fights", + past: "fought", + pastPart: "fought", + presPart: "fighting", }, swim: { - '3sg': 'swims', - past: 'swam', - pastPart: 'swum', - presPart: 'swimming', + "3sg": "swims", + past: "swam", + pastPart: "swum", + presPart: "swimming", }, sing: { - '3sg': 'sings', - past: 'sang', - pastPart: 'sung', - presPart: 'singing', + "3sg": "sings", + past: "sang", + pastPart: "sung", + presPart: "singing", }, draw: { - '3sg': 'draws', - past: 'drew', - pastPart: 'drawn', - presPart: 'drawing', + "3sg": "draws", + past: "drew", + pastPart: "drawn", + presPart: "drawing", }, drive: { - '3sg': 'drives', - past: 'drove', - pastPart: 'driven', - presPart: 'driving', + "3sg": "drives", + past: "drove", + pastPart: "driven", + presPart: "driving", }, ride: { - '3sg': 'rides', - past: 'rode', - pastPart: 'ridden', - presPart: 'riding', + "3sg": "rides", + past: "rode", + pastPart: "ridden", + presPart: "riding", }, grow: { - '3sg': 'grows', - past: 'grew', - pastPart: 'grown', - presPart: 'growing', + "3sg": "grows", + past: "grew", + pastPart: "grown", + presPart: "growing", }, throw: { - '3sg': 'throws', - past: 'threw', - pastPart: 'thrown', - presPart: 'throwing', + "3sg": "throws", + past: "threw", + pastPart: "thrown", + presPart: "throwing", }, break: { - '3sg': 'breaks', - past: 'broke', - pastPart: 'broken', - presPart: 'breaking', + "3sg": "breaks", + past: "broke", + pastPart: "broken", + presPart: "breaking", }, wake: { - '3sg': 'wakes', - past: 'woke', - pastPart: 'woken', - presPart: 'waking', + "3sg": "wakes", + past: "woke", + pastPart: "woken", + presPart: "waking", }, wear: { - '3sg': 'wears', - past: 'wore', - pastPart: 'worn', - presPart: 'wearing', + "3sg": "wears", + past: "wore", + pastPart: "worn", + presPart: "wearing", }, win: { - '3sg': 'wins', - past: 'won', - pastPart: 'won', - presPart: 'winning', + "3sg": "wins", + past: "won", + pastPart: "won", + presPart: "winning", }, choose: { - '3sg': 'chooses', - past: 'chose', - pastPart: 'chosen', - presPart: 'choosing', + "3sg": "chooses", + past: "chose", + pastPart: "chosen", + presPart: "choosing", }, hide: { - '3sg': 'hides', - past: 'hid', - pastPart: 'hidden', - presPart: 'hiding', + "3sg": "hides", + past: "hid", + pastPart: "hidden", + presPart: "hiding", }, steal: { - '3sg': 'steals', - past: 'stole', - pastPart: 'stolen', - presPart: 'stealing', + "3sg": "steals", + past: "stole", + pastPart: "stolen", + presPart: "stealing", }, begin: { - '3sg': 'begins', - past: 'began', - pastPart: 'begun', - presPart: 'beginning', + "3sg": "begins", + past: "began", + pastPart: "begun", + presPart: "beginning", }, ring: { - '3sg': 'rings', - past: 'rang', - pastPart: 'rung', - presPart: 'ringing', + "3sg": "rings", + past: "rang", + pastPart: "rung", + presPart: "ringing", }, swing: { - '3sg': 'swings', - past: 'swung', - pastPart: 'swung', - presPart: 'swinging', + "3sg": "swings", + past: "swung", + pastPart: "swung", + presPart: "swinging", }, blow: { - '3sg': 'blows', - past: 'blew', - pastPart: 'blown', - presPart: 'blowing', + "3sg": "blows", + past: "blew", + pastPart: "blown", + presPart: "blowing", }, show: { - '3sg': 'shows', - past: 'showed', - pastPart: 'shown', - presPart: 'showing', + "3sg": "shows", + past: "showed", + pastPart: "shown", + presPart: "showing", }, shut: { - '3sg': 'shuts', - past: 'shut', - pastPart: 'shut', - presPart: 'shutting', + "3sg": "shuts", + past: "shut", + pastPart: "shut", + presPart: "shutting", }, cut: { - '3sg': 'cuts', - past: 'cut', - pastPart: 'cut', - presPart: 'cutting', + "3sg": "cuts", + past: "cut", + pastPart: "cut", + presPart: "cutting", }, hit: { - '3sg': 'hits', - past: 'hit', - pastPart: 'hit', - presPart: 'hitting', + "3sg": "hits", + past: "hit", + pastPart: "hit", + presPart: "hitting", }, hurt: { - '3sg': 'hurts', - past: 'hurt', - pastPart: 'hurt', - presPart: 'hurting', + "3sg": "hurts", + past: "hurt", + pastPart: "hurt", + presPart: "hurting", }, let: { - '3sg': 'lets', - past: 'let', - pastPart: 'let', - presPart: 'letting', + "3sg": "lets", + past: "let", + pastPart: "let", + presPart: "letting", }, set: { - '3sg': 'sets', - past: 'set', - pastPart: 'set', - presPart: 'setting', + "3sg": "sets", + past: "set", + pastPart: "set", + presPart: "setting", }, cost: { - '3sg': 'costs', - past: 'cost', - pastPart: 'cost', - presPart: 'costing', + "3sg": "costs", + past: "cost", + pastPart: "cost", + presPart: "costing", }, send: { - '3sg': 'sends', - past: 'sent', - pastPart: 'sent', - presPart: 'sending', + "3sg": "sends", + past: "sent", + pastPart: "sent", + presPart: "sending", }, build: { - '3sg': 'builds', - past: 'built', - pastPart: 'built', - presPart: 'building', + "3sg": "builds", + past: "built", + pastPart: "built", + presPart: "building", }, spend: { - '3sg': 'spends', - past: 'spent', - pastPart: 'spent', - presPart: 'spending', + "3sg": "spends", + past: "spent", + pastPart: "spent", + presPart: "spending", }, lend: { - '3sg': 'lends', - past: 'lent', - pastPart: 'lent', - presPart: 'lending', + "3sg": "lends", + past: "lent", + pastPart: "lent", + presPart: "lending", }, lose: { - '3sg': 'loses', - past: 'lost', - pastPart: 'lost', - presPart: 'losing', + "3sg": "loses", + past: "lost", + pastPart: "lost", + presPart: "losing", }, mean: { - '3sg': 'means', - past: 'meant', - pastPart: 'meant', - presPart: 'meaning', + "3sg": "means", + past: "meant", + pastPart: "meant", + presPart: "meaning", }, meet: { - '3sg': 'meets', - past: 'met', - pastPart: 'met', - presPart: 'meeting', + "3sg": "meets", + past: "met", + pastPart: "met", + presPart: "meeting", }, pay: { - '3sg': 'pays', - past: 'paid', - pastPart: 'paid', - presPart: 'paying', + "3sg": "pays", + past: "paid", + pastPart: "paid", + presPart: "paying", }, sell: { - '3sg': 'sells', - past: 'sold', - pastPart: 'sold', - presPart: 'selling', + "3sg": "sells", + past: "sold", + pastPart: "sold", + presPart: "selling", }, hang: { - '3sg': 'hangs', - past: 'hung', - pastPart: 'hung', - presPart: 'hanging', + "3sg": "hangs", + past: "hung", + pastPart: "hung", + presPart: "hanging", }, shine: { - '3sg': 'shines', - past: 'shone', - pastPart: 'shone', - presPart: 'shining', + "3sg": "shines", + past: "shone", + pastPart: "shone", + presPart: "shining", }, dig: { - '3sg': 'digs', - past: 'dug', - pastPart: 'dug', - presPart: 'digging', + "3sg": "digs", + past: "dug", + pastPart: "dug", + presPart: "digging", }, stick: { - '3sg': 'sticks', - past: 'stuck', - pastPart: 'stuck', - presPart: 'sticking', + "3sg": "sticks", + past: "stuck", + pastPart: "stuck", + presPart: "sticking", }, spin: { - '3sg': 'spins', - past: 'spun', - pastPart: 'spun', - presPart: 'spinning', + "3sg": "spins", + past: "spun", + pastPart: "spun", + presPart: "spinning", }, spread: { - '3sg': 'spreads', - past: 'spread', - pastPart: 'spread', - presPart: 'spreading', + "3sg": "spreads", + past: "spread", + pastPart: "spread", + presPart: "spreading", }, bite: { - '3sg': 'bites', - past: 'bit', - pastPart: 'bitten', - presPart: 'biting', + "3sg": "bites", + past: "bit", + pastPart: "bitten", + presPart: "biting", }, feed: { - '3sg': 'feeds', - past: 'fed', - pastPart: 'fed', - presPart: 'feeding', + "3sg": "feeds", + past: "fed", + pastPart: "fed", + presPart: "feeding", }, lead: { - '3sg': 'leads', - past: 'led', - pastPart: 'led', - presPart: 'leading', + "3sg": "leads", + past: "led", + pastPart: "led", + presPart: "leading", }, light: { - '3sg': 'lights', - past: 'lit', - pastPart: 'lit', - presPart: 'lighting', + "3sg": "lights", + past: "lit", + pastPart: "lit", + presPart: "lighting", }, shoot: { - '3sg': 'shoots', - past: 'shot', - pastPart: 'shot', - presPart: 'shooting', + "3sg": "shoots", + past: "shot", + pastPart: "shot", + presPart: "shooting", }, slide: { - '3sg': 'slides', - past: 'slid', - pastPart: 'slid', - presPart: 'sliding', + "3sg": "slides", + past: "slid", + pastPart: "slid", + presPart: "sliding", }, }, Noun: { - child: { plural: 'children' }, - person: { plural: 'people' }, - man: { plural: 'men' }, - woman: { plural: 'women' }, - mouse: { plural: 'mice' }, - foot: { plural: 'feet' }, - tooth: { plural: 'teeth' }, - goose: { plural: 'geese' }, - sheep: { plural: 'sheep' }, - fish: { plural: 'fish' }, - deer: { plural: 'deer' }, - ox: { plural: 'oxen' }, - leaf: { plural: 'leaves' }, - loaf: { plural: 'loaves' }, - wolf: { plural: 'wolves' }, - calf: { plural: 'calves' }, - half: { plural: 'halves' }, - knife: { plural: 'knives' }, - life: { plural: 'lives' }, - wife: { plural: 'wives' }, - self: { plural: 'selves' }, - shelf: { plural: 'shelves' }, - elf: { plural: 'elves' }, - thief: { plural: 'thieves' }, - roof: { plural: 'roofs' }, - chief: { plural: 'chiefs' }, - belief: { plural: 'beliefs' }, - proof: { plural: 'proofs' }, - hoof: { plural: 'hooves' }, - scarf: { plural: 'scarves' }, - wharf: { plural: 'wharves' }, - bus: { plural: 'buses' }, - glass: { plural: 'glasses' }, - class: { plural: 'classes' }, - box: { plural: 'boxes' }, - fox: { plural: 'foxes' }, - watch: { plural: 'watches' }, - match: { plural: 'matches' }, - brush: { plural: 'brushes' }, - dish: { plural: 'dishes' }, - wish: { plural: 'wishes' }, - wash: { plural: 'washes' }, - bush: { plural: 'bushes' }, - push: { plural: 'pushes' }, - potato: { plural: 'potatoes' }, - tomato: { plural: 'tomatoes' }, - hero: { plural: 'heroes' }, - echo: { plural: 'echoes' }, - veto: { plural: 'vetoes' }, - mango: { plural: 'mangoes' }, - mosquito: { plural: 'mosquitoes' }, - tornado: { plural: 'tornadoes' }, - volcano: { plural: 'volcanoes' }, - radio: { plural: 'radios' }, - studio: { plural: 'studios' }, - video: { plural: 'videos' }, - piano: { plural: 'pianos' }, - photo: { plural: 'photos' }, - zoo: { plural: 'zoos' }, - bamboo: { plural: 'bamboos' }, - embryo: { plural: 'embryos' }, - ratio: { plural: 'ratios' }, - scenario: { plural: 'scenarios' }, - analysis: { plural: 'analyses' }, - basis: { plural: 'bases' }, - crisis: { plural: 'crises' }, - diagnosis: { plural: 'diagnoses' }, - hypothesis: { plural: 'hypotheses' }, - oasis: { plural: 'oases' }, - parenthesis: { plural: 'parentheses' }, - synthesis: { plural: 'syntheses' }, - thesis: { plural: 'theses' }, - phenomenon: { plural: 'phenomena' }, - criterion: { plural: 'criteria' }, - datum: { plural: 'data' }, - medium: { plural: 'media' }, - curriculum: { plural: 'curricula' }, - bacterium: { plural: 'bacteria' }, - stimulus: { plural: 'stimuli' }, - syllabus: { plural: 'syllabi' }, - focus: { plural: 'foci' }, - nucleus: { plural: 'nuclei' }, - fungus: { plural: 'fungi' }, - cactus: { plural: 'cacti' }, - appendix: { plural: 'appendices' }, - index: { plural: 'indices' }, - matrix: { plural: 'matrices' }, - vertex: { plural: 'vertices' }, + child: { plural: "children" }, + person: { plural: "people" }, + man: { plural: "men" }, + woman: { plural: "women" }, + mouse: { plural: "mice" }, + foot: { plural: "feet" }, + tooth: { plural: "teeth" }, + goose: { plural: "geese" }, + sheep: { plural: "sheep" }, + fish: { plural: "fish" }, + deer: { plural: "deer" }, + ox: { plural: "oxen" }, + leaf: { plural: "leaves" }, + loaf: { plural: "loaves" }, + wolf: { plural: "wolves" }, + calf: { plural: "calves" }, + half: { plural: "halves" }, + knife: { plural: "knives" }, + life: { plural: "lives" }, + wife: { plural: "wives" }, + self: { plural: "selves" }, + shelf: { plural: "shelves" }, + elf: { plural: "elves" }, + thief: { plural: "thieves" }, + roof: { plural: "roofs" }, + chief: { plural: "chiefs" }, + belief: { plural: "beliefs" }, + proof: { plural: "proofs" }, + hoof: { plural: "hooves" }, + scarf: { plural: "scarves" }, + wharf: { plural: "wharves" }, + bus: { plural: "buses" }, + glass: { plural: "glasses" }, + class: { plural: "classes" }, + box: { plural: "boxes" }, + fox: { plural: "foxes" }, + watch: { plural: "watches" }, + match: { plural: "matches" }, + brush: { plural: "brushes" }, + dish: { plural: "dishes" }, + wish: { plural: "wishes" }, + wash: { plural: "washes" }, + bush: { plural: "bushes" }, + push: { plural: "pushes" }, + potato: { plural: "potatoes" }, + tomato: { plural: "tomatoes" }, + hero: { plural: "heroes" }, + echo: { plural: "echoes" }, + veto: { plural: "vetoes" }, + mango: { plural: "mangoes" }, + mosquito: { plural: "mosquitoes" }, + tornado: { plural: "tornadoes" }, + volcano: { plural: "volcanoes" }, + radio: { plural: "radios" }, + studio: { plural: "studios" }, + video: { plural: "videos" }, + piano: { plural: "pianos" }, + photo: { plural: "photos" }, + zoo: { plural: "zoos" }, + bamboo: { plural: "bamboos" }, + embryo: { plural: "embryos" }, + ratio: { plural: "ratios" }, + scenario: { plural: "scenarios" }, + analysis: { plural: "analyses" }, + basis: { plural: "bases" }, + crisis: { plural: "crises" }, + diagnosis: { plural: "diagnoses" }, + hypothesis: { plural: "hypotheses" }, + oasis: { plural: "oases" }, + parenthesis: { plural: "parentheses" }, + synthesis: { plural: "syntheses" }, + thesis: { plural: "theses" }, + phenomenon: { plural: "phenomena" }, + criterion: { plural: "criteria" }, + datum: { plural: "data" }, + medium: { plural: "media" }, + curriculum: { plural: "curricula" }, + bacterium: { plural: "bacteria" }, + stimulus: { plural: "stimuli" }, + syllabus: { plural: "syllabi" }, + focus: { plural: "foci" }, + nucleus: { plural: "nuclei" }, + fungus: { plural: "fungi" }, + cactus: { plural: "cacti" }, + appendix: { plural: "appendices" }, + index: { plural: "indices" }, + matrix: { plural: "matrices" }, + vertex: { plural: "vertices" }, }, Adjective: { - good: { comparative: 'better', superlative: 'best' }, - bad: { comparative: 'worse', superlative: 'worst' }, - far: { comparative: 'farther', superlative: 'farthest' }, - little: { comparative: 'less', superlative: 'least' }, - much: { comparative: 'more', superlative: 'most' }, - many: { comparative: 'more', superlative: 'most' }, - well: { comparative: 'better', superlative: 'best' }, + good: { comparative: "better", superlative: "best" }, + bad: { comparative: "worse", superlative: "worst" }, + far: { comparative: "farther", superlative: "farthest" }, + little: { comparative: "less", superlative: "least" }, + much: { comparative: "more", superlative: "most" }, + many: { comparative: "more", superlative: "most" }, + well: { comparative: "better", superlative: "best" }, old: { - comparative: 'older', - superlative: 'oldest', - extra: ['elder', 'eldest'], + comparative: "older", + superlative: "oldest", + extra: ["elder", "eldest"], }, late: { - comparative: 'later', - superlative: 'latest', - extra: ['latter', 'last'], + comparative: "later", + superlative: "latest", + extra: ["latter", "last"], }, }, Pronoun: { i: { - objective: 'me', - possessive: 'my', - possessivePronoun: 'mine', + objective: "me", + possessive: "my", + possessivePronoun: "mine", }, you: { - objective: 'you', - possessive: 'your', - possessivePronoun: 'yours', + objective: "you", + possessive: "your", + possessivePronoun: "yours", }, he: { - objective: 'him', - possessive: 'his', - possessivePronoun: 'his', + objective: "him", + possessive: "his", + possessivePronoun: "his", }, she: { - objective: 'her', - possessive: 'her', - possessivePronoun: 'hers', + objective: "her", + possessive: "her", + possessivePronoun: "hers", }, it: { - objective: 'it', - possessive: 'its', + objective: "it", + possessive: "its", }, we: { - objective: 'us', - possessive: 'our', - possessivePronoun: 'ours', + objective: "us", + possessive: "our", + possessivePronoun: "ours", }, they: { - objective: 'them', - possessive: 'their', - possessivePronoun: 'theirs', - }, - mine: { extra: ['my'] }, - yours: { extra: ['your'] }, - his: { extra: ['him'] }, - hers: { extra: ['her'] }, - ours: { extra: ['our'] }, - theirs: { extra: ['their'] }, + objective: "them", + possessive: "their", + possessivePronoun: "theirs", + }, + mine: { extra: ["my"] }, + yours: { extra: ["your"] }, + his: { extra: ["him"] }, + hers: { extra: ["her"] }, + ours: { extra: ["our"] }, + theirs: { extra: ["their"] }, }, }, regular: { Verb: { - '3sg': [ - { match: '(ss|sh|ch|x|z|o)$', replace: '$1es' }, - { match: '([^aeiou])y$', replace: '$1ies' }, - { match: '$', replace: 's' }, + "3sg": [ + { match: "(ss|sh|ch|x|z|o)$", replace: "$1es" }, + { match: "([^aeiou])y$", replace: "$1ies" }, + { match: "$", replace: "s" }, ], past: [ - { match: '([^aeiou])y$', replace: '$1ied' }, - { match: '([^aeiou][aeiou])([^aeiouwxy])$', replace: '$1$2$2ed' }, - { match: '(.*)e$', replace: '$1ed' }, - { match: '$', replace: 'ed' }, + { match: "([^aeiou])y$", replace: "$1ied" }, + { match: "([^aeiou][aeiou])([^aeiouwxy])$", replace: "$1$2$2ed" }, + { match: "(.*)e$", replace: "$1ed" }, + { match: "$", replace: "ed" }, ], - pastPart: 'past', + pastPart: "past", presPart: [ - { match: 'ie$', replace: 'ying' }, - { match: '(.*)e$', replace: '$1ing' }, - { match: '([^aeiou][aeiou])([^aeiouwxy])$', replace: '$1$2$2ing' }, - { match: '$', replace: 'ing' }, + { match: "ie$", replace: "ying" }, + { match: "(.*)e$", replace: "$1ing" }, + { match: "([^aeiou][aeiou])([^aeiouwxy])$", replace: "$1$2$2ing" }, + { match: "$", replace: "ing" }, ], }, Noun: { plural: [ - { match: '(ss|sh|ch|x|z)$', replace: '$1es' }, - { match: '([^aeiou])y$', replace: '$1ies' }, - { match: 'fe$', replace: 'ves' }, - { match: 'f$', replace: 'ves' }, - { match: '$', replace: 's' }, + { match: "(ss|sh|ch|x|z)$", replace: "$1es" }, + { match: "([^aeiou])y$", replace: "$1ies" }, + { match: "fe$", replace: "ves" }, + { match: "f$", replace: "ves" }, + { match: "$", replace: "s" }, ], }, Adjective: { comparative: [ - { match: 'e$', replace: 'r' }, - { match: '([^aeiou])y$', replace: '$1ier' }, - { match: '([^aeiou][aeiou])([^aeiouwxy])$', replace: '$1$2$2er' }, - { match: '$', replace: 'er' }, + { match: "e$", replace: "r" }, + { match: "([^aeiou])y$", replace: "$1ier" }, + { match: "([^aeiou][aeiou])([^aeiouwxy])$", replace: "$1$2$2er" }, + { match: "$", replace: "er" }, ], superlative: [ - { match: 'e$', replace: 'st' }, - { match: '([^aeiou])y$', replace: '$1iest' }, - { match: '([^aeiou][aeiou])([^aeiouwxy])$', replace: '$1$2$2est' }, - { match: '$', replace: 'est' }, + { match: "e$", replace: "st" }, + { match: "([^aeiou])y$", replace: "$1iest" }, + { match: "([^aeiou][aeiou])([^aeiouwxy])$", replace: "$1$2$2est" }, + { match: "$", replace: "est" }, ], }, }, diff --git a/src/utilities/analytics/morphology/grid3VerbsParser.ts b/src/utilities/analytics/morphology/grid3VerbsParser.ts index abb0d1b..a9f7693 100644 --- a/src/utilities/analytics/morphology/grid3VerbsParser.ts +++ b/src/utilities/analytics/morphology/grid3VerbsParser.ts @@ -1,7 +1,7 @@ -import { XMLParser } from 'fast-xml-parser'; -import AdmZip from 'adm-zip'; -import { join, dirname, basename } from 'path'; -import type { VerbFormWithConditions, Grid3VerbFormsDetailed } from './types'; +import { XMLParser } from "fast-xml-parser"; +import AdmZip from "adm-zip"; +import { join, dirname, basename } from "path"; +import type { VerbFormWithConditions, Grid3VerbFormsDetailed } from "./types"; export interface Grid3VerbForms { locale: string; @@ -30,17 +30,18 @@ export class Grid3VerbsParser { private parser = new XMLParser({ ignoreAttributes: false, ignoreDeclaration: true, - textNodeName: '#text', + textNodeName: "#text", }); parseXml(xmlContent: string, locale?: string): Grid3VerbForms { const data = this.parser.parse(xmlContent); const verbdata = data.verbdata || data.Verbdata; if (!verbdata) { - return { locale: locale || 'unknown', verbs: new Map() }; + return { locale: locale || "unknown", verbs: new Map() }; } - const detectedLocale = verbdata['@_locale'] || verbdata.locale || locale || 'unknown'; + const detectedLocale = + verbdata["@_locale"] || verbdata.locale || locale || "unknown"; const ruleSets = this.parseRuleSets(verbdata); const verbs = this.parseVerbs(verbdata); const result = new Map(); @@ -55,14 +56,18 @@ export class Grid3VerbsParser { return { locale: detectedLocale, verbs: result }; } - parseXmlDetailed(xmlContent: string, locale?: string): Grid3VerbFormsDetailed { + parseXmlDetailed( + xmlContent: string, + locale?: string, + ): Grid3VerbFormsDetailed { const data = this.parser.parse(xmlContent); const verbdata = data.verbdata || data.Verbdata; if (!verbdata) { - return { locale: locale || 'unknown', verbs: new Map() }; + return { locale: locale || "unknown", verbs: new Map() }; } - const detectedLocale = verbdata['@_locale'] || verbdata.locale || locale || 'unknown'; + const detectedLocale = + verbdata["@_locale"] || verbdata.locale || locale || "unknown"; const ruleSets = this.parseRuleSets(verbdata); const verbs = this.parseVerbs(verbdata); const result = new Map(); @@ -79,8 +84,8 @@ export class Grid3VerbsParser { /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-argument */ parseXmlFileDetailed(filePath: string): Grid3VerbFormsDetailed { - const fs = require('fs'); - const xml = fs.readFileSync(filePath, 'utf-8'); + const fs = require("fs"); + const xml = fs.readFileSync(filePath, "utf-8"); return this.parseXmlDetailed(xml); } /* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-argument */ @@ -88,34 +93,41 @@ export class Grid3VerbsParser { parseZip(zipPath: string): Grid3VerbForms { const zip = new AdmZip(zipPath); const entries = zip.getEntries(); - const verbEntry = entries.find((e) => e.entryName.toLowerCase().endsWith('verbs.xml')); + const verbEntry = entries.find((e) => + e.entryName.toLowerCase().endsWith("verbs.xml"), + ); if (!verbEntry) { const locale = basename(dirname(zipPath)); return { locale, verbs: new Map() }; } - const xml = verbEntry.getData().toString('utf-8'); + const xml = verbEntry.getData().toString("utf-8"); return this.parseXml(xml); } parseZipDetailed(zipPath: string): Grid3VerbFormsDetailed { const zip = new AdmZip(zipPath); const entries = zip.getEntries(); - const verbEntry = entries.find((e) => e.entryName.toLowerCase().endsWith('verbs.xml')); + const verbEntry = entries.find((e) => + e.entryName.toLowerCase().endsWith("verbs.xml"), + ); if (!verbEntry) { const locale = basename(dirname(zipPath)); return { locale, verbs: new Map() }; } - const xml = verbEntry.getData().toString('utf-8'); + const xml = verbEntry.getData().toString("utf-8"); return this.parseXmlDetailed(xml); } // eslint-disable-next-line @typescript-eslint/require-await - async parseLocale(locale: string, grid3InstallPath?: string): Promise { + async parseLocale( + locale: string, + grid3InstallPath?: string, + ): Promise { const installPath = grid3InstallPath || this.getDefaultInstallPath(); if (!installPath) { return { locale, verbs: new Map() }; } - const zipPath = join(installPath, 'Locale', locale, 'verbs', 'verbs.zip'); + const zipPath = join(installPath, "Locale", locale, "verbs", "verbs.zip"); try { return this.parseZip(zipPath); } catch { @@ -123,13 +135,15 @@ export class Grid3VerbsParser { } } - async parseInstalledLocales(grid3InstallPath?: string): Promise> { + async parseInstalledLocales( + grid3InstallPath?: string, + ): Promise> { const installPath = grid3InstallPath || this.getDefaultInstallPath(); const results = new Map(); if (!installPath) return results; - const fs = await import('fs'); - const localeDir = join(installPath, 'Locale'); + const fs = await import("fs"); + const localeDir = join(installPath, "Locale"); let locales: string[]; try { locales = fs @@ -140,7 +154,7 @@ export class Grid3VerbsParser { } for (const locale of locales) { - const verbsZip = join(localeDir, locale, 'verbs', 'verbs.zip'); + const verbsZip = join(localeDir, locale, "verbs", "verbs.zip"); try { fs.accessSync(verbsZip, fs.constants.R_OK); const forms = this.parseZip(verbsZip); @@ -164,18 +178,23 @@ export class Grid3VerbsParser { * This allows users to supply morphology data copied from any Grid 3 * installation without needing Grid 3 installed on this machine. */ - parseCustomDirectory(dirPath: string, detailed?: false): Map; parseCustomDirectory( dirPath: string, - detailed: true - ): Map; + detailed?: false, + ): Map; + parseCustomDirectory( + dirPath: string, + detailed: true, + ): Map; // eslint-disable-next-line @typescript-eslint/no-var-requires parseCustomDirectory( dirPath: string, - detailed = false - ): Map | Map { + detailed = false, + ): + | Map + | Map { /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ - const fs = require('fs'); + const fs = require("fs"); let locales: string[]; try { locales = fs @@ -186,9 +205,12 @@ export class Grid3VerbsParser { } if (detailed) { - const results = new Map(); + const results = new Map< + string, + import("./types").Grid3VerbFormsDetailed + >(); for (const locale of locales) { - const verbsZip = join(dirPath, locale, 'verbs', 'verbs.zip'); + const verbsZip = join(dirPath, locale, "verbs", "verbs.zip"); try { fs.accessSync(verbsZip, fs.constants.R_OK); results.set(locale, this.parseZipDetailed(verbsZip)); @@ -201,7 +223,7 @@ export class Grid3VerbsParser { const results = new Map(); for (const locale of locales) { - const verbsZip = join(dirPath, locale, 'verbs', 'verbs.zip'); + const verbsZip = join(dirPath, locale, "verbs", "verbs.zip"); try { fs.accessSync(verbsZip, fs.constants.R_OK); results.set(locale, this.parseZip(verbsZip)); @@ -214,16 +236,16 @@ export class Grid3VerbsParser { } private getDefaultInstallPath(): string | null { - if (typeof process === 'undefined' || process.platform !== 'win32') { + if (typeof process === "undefined" || process.platform !== "win32") { return null; } const paths = [ - 'C:\\Program Files (x86)\\Smartbox\\Grid 3', - 'C:\\Program Files\\Smartbox\\Grid 3', + "C:\\Program Files (x86)\\Smartbox\\Grid 3", + "C:\\Program Files\\Smartbox\\Grid 3", ]; try { // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require('fs'); + const fs = require("fs"); for (const p of paths) { if (fs.existsSync(p)) return p; } @@ -248,7 +270,7 @@ export class Grid3VerbsParser { if (ph) { const phArr = Array.isArray(ph) ? ph : [ph]; for (const p of phArr) { - const val = typeof p === 'string' ? p : p['#text'] || p; + const val = typeof p === "string" ? p : p["#text"] || p; if (val) placeholders.push(val); } } @@ -258,8 +280,8 @@ export class Grid3VerbsParser { if (pr) { const prArr = Array.isArray(pr) ? pr : [pr]; for (const rule of prArr) { - const type = rule['@_type'] || rule.type; - const value = rule['@_value'] || rule.value; + const type = rule["@_type"] || rule.type; + const value = rule["@_value"] || rule.value; if (type && value) { participleRules.set(type, value); } @@ -274,21 +296,21 @@ export class Grid3VerbsParser { if (cr) { const crArr = Array.isArray(cr) ? cr : [cr]; for (const rule of crArr) { - const value = rule['@_value'] || rule.value; + const value = rule["@_value"] || rule.value; if (!value) continue; const conditions = new Map(); for (const attr of [ - 'time', - 'number', - 'person', - 'aspect', - 'mood', - 'voice', - 'tense', - 'polarity', + "time", + "number", + "person", + "aspect", + "mood", + "voice", + "tense", + "polarity", ]) { const v = rule[`@_${attr}`] || rule[attr]; - if (v && v !== '*') { + if (v && v !== "*") { conditions.set(attr, String(v)); } } @@ -310,18 +332,18 @@ export class Grid3VerbsParser { const arr = Array.isArray(verbsData) ? verbsData : [verbsData]; for (const v of arr) { - const root = v['@_root'] || v.root; + const root = v["@_root"] || v.root; if (!root) continue; - const ruleId = v['@_ruleid'] || v.ruleid || undefined; + const ruleId = v["@_ruleid"] || v.ruleid || undefined; const placeholderValues = new Map(); const rphv = v.ruleplaceholdervalues?.ruleplaceholdervalue; if (rphv) { const rphvArr = Array.isArray(rphv) ? rphv : [rphv]; for (const ph of rphvArr) { - const placeholder = ph['@_placeholder'] || ph.placeholder; - const value = ph['@_value'] || ph.value; + const placeholder = ph["@_placeholder"] || ph.placeholder; + const value = ph["@_value"] || ph.value; if (placeholder && value) { placeholderValues.set(placeholder, value); } @@ -333,8 +355,8 @@ export class Grid3VerbsParser { if (parts) { const partsArr = Array.isArray(parts) ? parts : [parts]; for (const p of partsArr) { - const type = p['@_type'] || p.type; - const value = p['@_value'] || p.value; + const type = p["@_type"] || p.type; + const value = p["@_value"] || p.value; if (type && value) { participleOverrides.set(type, value); } @@ -349,16 +371,23 @@ export class Grid3VerbsParser { if (conjs) { const conjArr = Array.isArray(conjs) ? conjs : [conjs]; for (const c of conjArr) { - const value = c['@_value'] || c.value; + const value = c["@_value"] || c.value; if (!value) continue; const conditions = new Map(); const parts2 = c.part; if (parts2) { const pArr = Array.isArray(parts2) ? parts2 : [parts2]; for (const p of pArr) { - for (const attr of ['time', 'number', 'person', 'aspect', 'mood', 'voice']) { + for (const attr of [ + "time", + "number", + "person", + "aspect", + "mood", + "voice", + ]) { const pv = p[`@_${attr}`] || p[attr]; - if (pv && pv !== '*') { + if (pv && pv !== "*") { conditions.set(attr, String(pv)); } } @@ -380,7 +409,10 @@ export class Grid3VerbsParser { return verbs; } - private generateForms(verb: ParsedVerb, ruleSets: Map): string[] { + private generateForms( + verb: ParsedVerb, + ruleSets: Map, + ): string[] { const forms = new Set(); const resolvedParticiples = new Map(); @@ -430,7 +462,7 @@ export class Grid3VerbsParser { private generateFormsDetailed( verb: ParsedVerb, - ruleSets: Map + ruleSets: Map, ): VerbFormWithConditions[] { const forms = new Map>(); const resolvedParticiples = new Map(); @@ -463,7 +495,7 @@ export class Grid3VerbsParser { for (const conjRule of appliedRule.conjugationRules) { const resolved = this.resolveTemplate(conjRule.value, fullContext); - if (resolved && !resolved.includes(' ') && resolved !== '-') { + if (resolved && !resolved.includes(" ") && resolved !== "-") { const trimmed = resolved.trim(); if (trimmed.length > 0 && trimmed !== verb.root) { const existing = forms.get(trimmed); @@ -480,9 +512,14 @@ export class Grid3VerbsParser { } for (const [type, value] of resolvedParticiples) { - if (!value.includes(' ') && value !== '-' && value.trim().length > 0 && value !== verb.root) { + if ( + !value.includes(" ") && + value !== "-" && + value.trim().length > 0 && + value !== verb.root + ) { const conditions = forms.get(value) || new Map(); - conditions.set('participleType', type); + conditions.set("participleType", type); forms.set(value, conditions); } } @@ -490,8 +527,8 @@ export class Grid3VerbsParser { for (const conj of verb.conjugationOverrides) { if ( conj.value && - !conj.value.includes(' ') && - conj.value !== '-' && + !conj.value.includes(" ") && + conj.value !== "-" && conj.value.trim().length > 0 && conj.value !== verb.root ) { @@ -512,13 +549,13 @@ export class Grid3VerbsParser { private buildContext( verb: ParsedVerb, - resolvedParticiples: Map + resolvedParticiples: Map, ): Map { const context = new Map(); - context.set('{root}', verb.root); + context.set("{root}", verb.root); for (const [key, value] of verb.placeholderValues) { context.set(key, value); - if (!key.startsWith('{')) { + if (!key.startsWith("{")) { context.set(`{${key}}`, value); } } @@ -528,10 +565,13 @@ export class Grid3VerbsParser { return context; } - private resolveTemplate(template: string, context: Map): string { + private resolveTemplate( + template: string, + context: Map, + ): string { let result = template; for (const [key, value] of context) { - const keyToReplace = key.startsWith('{') ? key : `{${key}}`; + const keyToReplace = key.startsWith("{") ? key : `{${key}}`; result = result.split(keyToReplace).join(value); } return result; @@ -539,8 +579,8 @@ export class Grid3VerbsParser { private addIfSingleWord(form: string, set: Set): void { if (!form) return; - if (form.includes(' ')) return; - if (form === '-') return; + if (form.includes(" ")) return; + if (form === "-") return; const trimmed = form.trim(); if (trimmed.length > 0) { set.add(trimmed); diff --git a/src/utilities/analytics/morphology/index.ts b/src/utilities/analytics/morphology/index.ts index eaa37a3..ef093e1 100644 --- a/src/utilities/analytics/morphology/index.ts +++ b/src/utilities/analytics/morphology/index.ts @@ -1,5 +1,5 @@ -export { MorphologyEngine } from './engine'; -export { WordFormGenerator } from './wordFormGenerator'; +export { MorphologyEngine } from "./engine"; +export { WordFormGenerator } from "./wordFormGenerator"; export type { MorphRuleSet, MorphRule, @@ -7,5 +7,5 @@ export type { AstericsWordForm, VerbFormWithConditions, Grid3VerbFormsDetailed, -} from './types'; -export type { Grid3VerbForms } from './grid3VerbsParser'; +} from "./types"; +export type { Grid3VerbForms } from "./grid3VerbsParser"; diff --git a/src/utilities/analytics/morphology/wordFormGenerator.ts b/src/utilities/analytics/morphology/wordFormGenerator.ts index cfe4d6d..0119e1e 100644 --- a/src/utilities/analytics/morphology/wordFormGenerator.ts +++ b/src/utilities/analytics/morphology/wordFormGenerator.ts @@ -1,31 +1,31 @@ -import { MorphologyEngine } from './engine'; -import { Grid3VerbsParser } from './grid3VerbsParser'; -import type { AstericsWordForm, VerbFormWithConditions } from './types'; +import { MorphologyEngine } from "./engine"; +import { Grid3VerbsParser } from "./grid3VerbsParser"; +import type { AstericsWordForm, VerbFormWithConditions } from "./types"; const SLOT_TAG_MAP: Record = { - '3sg': ['3.PERS'], - past: ['PAST'], - pastPart: ['PAST', 'PARTICIPLE'], - presPart: ['GERUND'], - plural: ['PLURAL'], - comparative: ['COMPARATIVE'], - superlative: ['SUPERLATIVE'], + "3sg": ["3.PERS"], + past: ["PAST"], + pastPart: ["PAST", "PARTICIPLE"], + presPart: ["GERUND"], + plural: ["PLURAL"], + comparative: ["COMPARATIVE"], + superlative: ["SUPERLATIVE"], }; const CONDITION_TAG_MAP: Record> = { - person: { first: ['1.PERS'], second: ['2.PERS'], third: ['3.PERS'] }, - number: { singular: [], plural: ['PLURAL'] }, - time: { present: ['PRESENT'], past: ['PAST'], future: ['FUTURE'] }, - aspect: { simple: [], continuous: ['CONTINUOUS'], perfect: ['PERFECT'] }, + person: { first: ["1.PERS"], second: ["2.PERS"], third: ["3.PERS"] }, + number: { singular: [], plural: ["PLURAL"] }, + time: { present: ["PRESENT"], past: ["PAST"], future: ["FUTURE"] }, + aspect: { simple: [], continuous: ["CONTINUOUS"], perfect: ["PERFECT"] }, mood: { - imperative: ['IMPERATIVE'], + imperative: ["IMPERATIVE"], indicative: [], - conditional: ['CONDITIONAL'], + conditional: ["CONDITIONAL"], }, participleType: { - presentparticiple: ['GERUND'], - pastparticiple: ['PAST', 'PARTICIPLE'], - infinitive: ['BASE'], + presentparticiple: ["GERUND"], + pastparticiple: ["PAST", "PARTICIPLE"], + infinitive: ["BASE"], }, }; @@ -34,10 +34,10 @@ export class WordFormGenerator { base: string, pos: string, engine: MorphologyEngine, - lang: string = 'en' + lang: string = "en", ): AstericsWordForm[] { const forms = engine.inflectWithSlots(base, pos); - const result: AstericsWordForm[] = [{ lang, tags: ['BASE'], value: base }]; + const result: AstericsWordForm[] = [{ lang, tags: ["BASE"], value: base }]; for (const { slot, form } of forms) { const tags = SLOT_TAG_MAP[slot] || [slot.toUpperCase()]; @@ -50,9 +50,9 @@ export class WordFormGenerator { generateFromGrid3Conditions( base: string, formsWithConditions: VerbFormWithConditions[], - lang: string = 'en' + lang: string = "en", ): AstericsWordForm[] { - const result: AstericsWordForm[] = [{ lang, tags: ['BASE'], value: base }]; + const result: AstericsWordForm[] = [{ lang, tags: ["BASE"], value: base }]; for (const form of formsWithConditions) { const tags = this.conditionsToTags(form.conditions); @@ -68,11 +68,12 @@ export class WordFormGenerator { engine: MorphologyEngine, grid3Parser: Grid3VerbsParser, verbsZipPath?: string, - lang: string = 'en' + lang: string = "en", ): AstericsWordForm[] { if (verbsZipPath) { const detailed = grid3Parser.parseZipDetailed(verbsZipPath); - const forms = detailed.verbs.get(base) || detailed.verbs.get(base.toLowerCase()); + const forms = + detailed.verbs.get(base) || detailed.verbs.get(base.toLowerCase()); if (forms && forms.length > 0) { return this.generateFromGrid3Conditions(base, forms, lang); } @@ -89,13 +90,13 @@ export class WordFormGenerator { tags.push(...mapped); } } - return tags.length > 0 ? tags : ['UNKNOWN']; + return tags.length > 0 ? tags : ["UNKNOWN"]; } private deduplicate(forms: AstericsWordForm[]): AstericsWordForm[] { const seen = new Set(); return forms.filter((f) => { - const key = `${f.value}|${f.tags.sort().join(',')}`; + const key = `${f.value}|${f.tags.sort().join(",")}`; if (seen.has(key)) return false; seen.add(key); return true; diff --git a/src/utilities/analytics/reference/browser.ts b/src/utilities/analytics/reference/browser.ts index b8e158c..38503bb 100644 --- a/src/utilities/analytics/reference/browser.ts +++ b/src/utilities/analytics/reference/browser.ts @@ -2,8 +2,8 @@ * Browser-friendly reference data loader using fetch. */ -import type { CoreList, CommonWordsData, SynonymsData } from '../metrics/types'; -import type { ReferenceDataProvider } from './index'; +import type { CoreList, CommonWordsData, SynonymsData } from "../metrics/types"; +import type { ReferenceDataProvider } from "./index"; export interface ReferenceData { coreLists: CoreList[]; @@ -46,12 +46,16 @@ export class InMemoryReferenceLoader implements ReferenceDataProvider { } async loadCommonFringe(): Promise { - const commonWords = new Set(this.data.commonWords.words.map((w) => w.toLowerCase())); + const commonWords = new Set( + this.data.commonWords.words.map((w) => w.toLowerCase()), + ); const coreWords = new Set(); this.data.coreLists.forEach((list) => { list.words.forEach((word) => coreWords.add(word.toLowerCase())); }); - return Promise.resolve(Array.from(commonWords).filter((word) => !coreWords.has(word))); + return Promise.resolve( + Array.from(commonWords).filter((word) => !coreWords.has(word)), + ); } async loadAll(): Promise { @@ -61,9 +65,9 @@ export class InMemoryReferenceLoader implements ReferenceDataProvider { export async function loadReferenceDataFromUrl( baseUrl: string, - locale = 'en' + locale = "en", ): Promise { - const root = baseUrl.replace(/\/$/, ''); + const root = baseUrl.replace(/\/$/, ""); const fetchJson = async (name: string): Promise => { const res = await fetch(`${root}/${name}.${locale}.json`); if (!res.ok) { @@ -72,14 +76,15 @@ export async function loadReferenceDataFromUrl( return (await res.json()) as T; }; - const [coreLists, commonWords, synonyms, sentences, fringe, baseWords] = await Promise.all([ - fetchJson('core_lists'), - fetchJson('common_words'), - fetchJson('synonyms'), - fetchJson('sentences'), - fetchJson('fringe'), - fetchJson<{ [word: string]: boolean }>('base_words'), - ]); + const [coreLists, commonWords, synonyms, sentences, fringe, baseWords] = + await Promise.all([ + fetchJson("core_lists"), + fetchJson("common_words"), + fetchJson("synonyms"), + fetchJson("sentences"), + fetchJson("fringe"), + fetchJson<{ [word: string]: boolean }>("base_words"), + ]); return { coreLists, @@ -93,7 +98,7 @@ export async function loadReferenceDataFromUrl( export async function createBrowserReferenceLoader( baseUrl: string, - locale = 'en' + locale = "en", ): Promise { const data = await loadReferenceDataFromUrl(baseUrl, locale); return new InMemoryReferenceLoader(data); diff --git a/src/utilities/analytics/reference/index.ts b/src/utilities/analytics/reference/index.ts index 235fcd3..94a62b2 100644 --- a/src/utilities/analytics/reference/index.ts +++ b/src/utilities/analytics/reference/index.ts @@ -5,8 +5,8 @@ * for AAC metrics analysis. */ -import { CoreList, CommonWordsData, SynonymsData } from '../metrics/types'; -import { defaultFileAdapter, FileAdapter } from '../../../utils/io'; +import { CoreList, CommonWordsData, SynonymsData } from "../metrics/types"; +import { defaultFileAdapter, FileAdapter } from "../../../utils/io"; export interface ReferenceDataProvider { loadCoreLists(): Promise; @@ -33,8 +33,8 @@ export class ReferenceLoader { constructor( dataDir?: string, - locale: string = 'en', - fileAdapter: FileAdapter = defaultFileAdapter + locale: string = "en", + fileAdapter: FileAdapter = defaultFileAdapter, ) { this.locale = locale; this.fileAdapter = fileAdapter; @@ -44,7 +44,7 @@ export class ReferenceLoader { } else { // Resolve the data directory relative to this file's location // Use __dirname which works correctly after compilation - this.dataDir = this.fileAdapter.join(__dirname, 'data'); + this.dataDir = this.fileAdapter.join(__dirname, "data"); } } @@ -53,7 +53,10 @@ export class ReferenceLoader { */ async loadCoreLists(): Promise { const { readTextFromInput } = this.fileAdapter; - const filePath = this.fileAdapter.join(this.dataDir, `core_lists.${this.locale}.json`); + const filePath = this.fileAdapter.join( + this.dataDir, + `core_lists.${this.locale}.json`, + ); const content = await readTextFromInput(filePath); return JSON.parse(String(content)) as CoreList[]; } @@ -128,7 +131,9 @@ export class ReferenceLoader { */ async loadCommonFringe(): Promise { const commonWordsData = await this.loadCommonWords(); - const commonWords = new Set(commonWordsData.words.map((w) => w.toLowerCase())); + const commonWords = new Set( + commonWordsData.words.map((w) => w.toLowerCase()), + ); const coreLists = await this.loadCoreLists(); const coreWords = new Set(); @@ -137,7 +142,9 @@ export class ReferenceLoader { }); // Common fringe = common words - core words - const commonFringe = Array.from(commonWords).filter((word) => !coreWords.has(word)); + const commonFringe = Array.from(commonWords).filter( + (word) => !coreWords.has(word), + ); return commonFringe; } @@ -166,27 +173,29 @@ export class ReferenceLoader { /** * Get the default reference data path */ -export function getReferenceDataPath(fileAdapter: FileAdapter = defaultFileAdapter): string { - return String(fileAdapter.join(__dirname, 'data')); +export function getReferenceDataPath( + fileAdapter: FileAdapter = defaultFileAdapter, +): string { + return String(fileAdapter.join(__dirname, "data")); } /** * Check if reference data files exist */ export async function hasReferenceData( - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { pathExists, join } = fileAdapter; const dataPath = getReferenceDataPath(); const requiredFiles = [ - 'core_lists.en.json', - 'common_words.en.json', - 'sentences.en.json', - 'synonyms.en.json', - 'fringe.en.json', + "core_lists.en.json", + "common_words.en.json", + "sentences.en.json", + "synonyms.en.json", + "fringe.en.json", ]; const existingPaths = await Promise.all( - requiredFiles.map(async (file) => await pathExists(join(dataPath, file))) + requiredFiles.map(async (file) => await pathExists(join(dataPath, file))), ); return existingPaths.every((exists) => exists); } diff --git a/src/utilities/analytics/utils/idGenerator.ts b/src/utilities/analytics/utils/idGenerator.ts index 6cde77c..06560a6 100644 --- a/src/utilities/analytics/utils/idGenerator.ts +++ b/src/utilities/analytics/utils/idGenerator.ts @@ -16,8 +16,8 @@ export function normalizeLabelForCloneId(label: string): string { return label .toLowerCase() - .replace(/['']/g, '') // Remove apostrophes - .replace(/\s+/g, '_') // Replace spaces with underscores + .replace(/['']/g, "") // Remove apostrophes + .replace(/\s+/g, "_") // Replace spaces with underscores .trim(); } @@ -39,7 +39,7 @@ export function generateCloneId( cols: number, row: number, col: number, - label: string + label: string, ): string { const normalizedLabel = normalizeLabelForCloneId(label); return `${rows}x${cols}-${row}.${col}-${normalizedLabel}`; @@ -57,7 +57,7 @@ export function generateCloneId( * @returns A semantic_id string (hash-based) */ export function generateSemanticId(message: string, label: string): string { - const content = `${message || ''}::${label || ''}`; + const content = `${message || ""}::${label || ""}`; // Simple hash function (djb2 algorithm) let hash = 5381; for (let i = 0; i < content.length; i++) { @@ -73,7 +73,9 @@ export function generateSemanticId(message: string, label: string): string { * @param buttons - Array of buttons to scan * @returns Array of unique semantic_id strings */ -export function extractSemanticIds(buttons: Array<{ semantic_id?: string }>): string[] { +export function extractSemanticIds( + buttons: Array<{ semantic_id?: string }>, +): string[] { const ids = new Set(); for (const button of buttons) { if (button.semantic_id) { @@ -89,7 +91,9 @@ export function extractSemanticIds(buttons: Array<{ semantic_id?: string }>): st * @param buttons - Array of buttons to scan * @returns Array of unique clone_id strings */ -export function extractCloneIds(buttons: Array<{ clone_id?: string }>): string[] { +export function extractCloneIds( + buttons: Array<{ clone_id?: string }>, +): string[] { const ids = new Set(); for (const button of buttons) { if (button.clone_id) { diff --git a/src/utilities/symbolTools.ts b/src/utilities/symbolTools.ts index ae69264..70dc3fd 100644 --- a/src/utilities/symbolTools.ts +++ b/src/utilities/symbolTools.ts @@ -1,10 +1,10 @@ -import { extractSymbolReferences } from '../processors/gridset/symbols'; -import { defaultFileAdapter, FileAdapter } from '../utils/io'; +import { extractSymbolReferences } from "../processors/gridset/symbols"; +import { defaultFileAdapter, FileAdapter } from "../utils/io"; // Dynamic imports for optional dependencies -type Database = typeof import('better-sqlite3'); -type AdmZip = typeof import('adm-zip'); -type XMLParser = typeof import('fast-xml-parser').XMLParser; +type Database = typeof import("better-sqlite3"); +type AdmZip = typeof import("adm-zip"); +type XMLParser = typeof import("fast-xml-parser").XMLParser; // --- Base Classes --- export abstract class SymbolExtractor { @@ -16,7 +16,11 @@ export abstract class SymbolResolver { protected dbPath: string; protected fileAdapter: FileAdapter; - constructor(symbolPath: string, dbPath: string, fileAdapter: FileAdapter = defaultFileAdapter) { + constructor( + symbolPath: string, + dbPath: string, + fileAdapter: FileAdapter = defaultFileAdapter, + ) { this.symbolPath = symbolPath; this.dbPath = dbPath; this.fileAdapter = fileAdapter; @@ -29,17 +33,19 @@ export abstract class SymbolResolver { let Database: Database | null = null; try { // eslint-disable-next-line @typescript-eslint/no-var-requires - Database = require('better-sqlite3'); + Database = require("better-sqlite3"); } catch { Database = null; } export class SnapSymbolExtractor extends SymbolExtractor { getSymbolReferences(filePath: string): string[] { - if (!Database) throw new Error('better-sqlite3 not installed'); + if (!Database) throw new Error("better-sqlite3 not installed"); const db = new Database(filePath, { readonly: true }); const rows = db - .prepare('SELECT DISTINCT LibrarySymbolId FROM Button WHERE LibrarySymbolId IS NOT NULL') + .prepare( + "SELECT DISTINCT LibrarySymbolId FROM Button WHERE LibrarySymbolId IS NOT NULL", + ) .all() as { LibrarySymbolId: number }[]; db.close(); return rows.map((row) => String(row.LibrarySymbolId)); @@ -49,10 +55,12 @@ export class SnapSymbolExtractor extends SymbolExtractor { export class SnapSymbolResolver extends SymbolResolver { async resolveSymbol(symbolRef: string): Promise { const { join, writeBinaryToPath } = this.fileAdapter; - if (!Database) throw new Error('better-sqlite3 not installed'); + if (!Database) throw new Error("better-sqlite3 not installed"); const db = new Database(this.dbPath, { readonly: true }); - const query = 'SELECT ImageData FROM Symbol WHERE Id = ?'; - const row = db.prepare(query).get(symbolRef) as { ImageData: Buffer } | undefined; + const query = "SELECT ImageData FROM Symbol WHERE Id = ?"; + const row = db.prepare(query).get(symbolRef) as + | { ImageData: Buffer } + | undefined; db.close(); if (!row) return null; @@ -68,9 +76,9 @@ let XMLParser: XMLParser | null = null; try { // Dynamic requires for optional dependencies // eslint-disable-next-line @typescript-eslint/no-var-requires - const admZipModule = require('adm-zip'); + const admZipModule = require("adm-zip"); // eslint-disable-next-line @typescript-eslint/no-var-requires - const fxpModule = require('fast-xml-parser'); + const fxpModule = require("fast-xml-parser"); AdmZip = admZipModule; XMLParser = fxpModule.XMLParser; } catch { @@ -80,11 +88,12 @@ try { export class Grid3SymbolExtractor extends SymbolExtractor { getSymbolReferences(filePath: string): string[] { - if (!AdmZip || !XMLParser) throw new Error('adm-zip or fast-xml-parser not installed'); + if (!AdmZip || !XMLParser) + throw new Error("adm-zip or fast-xml-parser not installed"); // Import GridsetProcessor dynamically to avoid circular dependencies // eslint-disable-next-line @typescript-eslint/no-var-requires - const { GridsetProcessor } = require('../processors/gridsetProcessor'); + const { GridsetProcessor } = require("../processors/gridsetProcessor"); const proc = new GridsetProcessor(); const tree = proc.loadIntoTree(filePath); @@ -125,11 +134,11 @@ export class TouchChatSymbolResolver extends SymbolResolver { export async function resolveSymbol( label: string, symbolDir: string, - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { const { join, pathExists } = fileAdapter; - const cleanLabel = label.toLowerCase().replace(/[^a-z0-9]/g, ''); - const exts = ['.png', '.jpg', '.svg']; + const cleanLabel = label.toLowerCase().replace(/[^a-z0-9]/g, ""); + const exts = [".png", ".jpg", ".svg"]; for (const ext of exts) { const symbolPath = join(symbolDir, cleanLabel + ext); diff --git a/src/utilities/translation/translationProcessor.ts b/src/utilities/translation/translationProcessor.ts index d21ebc0..831712a 100644 --- a/src/utilities/translation/translationProcessor.ts +++ b/src/utilities/translation/translationProcessor.ts @@ -79,7 +79,7 @@ export function normalizeButtonForTranslation( pageId?: string; pageName?: string; }, - grammar?: any + grammar?: any, ): ButtonForTranslation { return { buttonId, @@ -103,12 +103,15 @@ export function normalizeButtonForTranslation( * @param button - Button object from any AAC format * @returns Array of symbol info, or undefined if no symbols */ -export function extractSymbolsFromButton(button: any): SymbolInfo[] | undefined { +export function extractSymbolsFromButton( + button: any, +): SymbolInfo[] | undefined { const symbols: SymbolInfo[] = []; // Method 1: Check for semanticAction.richText.symbols (gridset format) if (button.semanticAction?.richText?.symbols) { - const richTextSymbols = button.semanticAction.richText.symbols as SymbolInfo[]; + const richTextSymbols = button.semanticAction.richText + .symbols as SymbolInfo[]; if (Array.isArray(richTextSymbols) && richTextSymbols.length > 0) { symbols.push(...richTextSymbols); return symbols; @@ -116,7 +119,7 @@ export function extractSymbolsFromButton(button: any): SymbolInfo[] | undefined } // Determine the text to attach symbol to - const text = button.label || button.message || ''; + const text = button.label || button.message || ""; if (!text) { return undefined; } @@ -133,7 +136,11 @@ export function extractSymbolsFromButton(button: any): SymbolInfo[] | undefined } // Method 3: Check if image field contains a symbol reference - if (button.image && typeof button.image === 'string' && button.image.startsWith('[')) { + if ( + button.image && + typeof button.image === "string" && + button.image.startsWith("[") + ) { symbols.push({ text, image: button.image, @@ -157,16 +164,18 @@ export function extractSymbolsFromButton(button: any): SymbolInfo[] | undefined */ export function extractAllButtonsForTranslation( buttons: any[], - contextFn?: (button: any) => { pageId?: string; pageName?: string } + contextFn?: (button: any) => { pageId?: string; pageName?: string }, ): ButtonForTranslation[] { const results: ButtonForTranslation[] = []; for (const button of buttons) { if (!button) continue; - const buttonId = (button.id || button.buttonId || `button_${results.length}`) as string; - const label = (button.label || '') as string; - const message = (button.message || '') as string; + const buttonId = (button.id || + button.buttonId || + `button_${results.length}`) as string; + const label = (button.label || "") as string; + const message = (button.message || "") as string; const symbols = extractSymbolsFromButton(button); // Only include buttons that have text to translate @@ -176,7 +185,14 @@ export function extractAllButtonsForTranslation( const grammar = button.parameters?.grammar || undefined; results.push( - normalizeButtonForTranslation(buttonId, label, message, symbols || [], context, grammar) + normalizeButtonForTranslation( + buttonId, + label, + message, + symbols || [], + context, + grammar, + ), ); } @@ -195,7 +211,7 @@ export function extractAllButtonsForTranslation( */ export function createTranslationPrompt( buttons: ButtonForTranslation[], - targetLanguage: string + targetLanguage: string, ): string { const buttonsData = JSON.stringify(buttons, null, 2); @@ -248,10 +264,10 @@ Ensure all symbol image references are preserved exactly as provided.`; export function validateTranslationResults( translations: LLMLTranslationResult[], originalButtonIds?: string[], - options?: { allowPartial?: boolean } + options?: { allowPartial?: boolean }, ): void { if (!Array.isArray(translations)) { - throw new Error('Translation results must be an array'); + throw new Error("Translation results must be an array"); } const translatedIds = new Set(translations.map((t) => t.buttonId)); @@ -268,10 +284,12 @@ export function validateTranslationResults( // Check each translation has required fields for (const trans of translations) { if (!trans.buttonId) { - throw new Error('Translation missing buttonId'); + throw new Error("Translation missing buttonId"); } if (!trans.translatedMessage && !trans.translatedLabel) { - throw new Error(`Translation for ${trans.buttonId} has no translated text`); + throw new Error( + `Translation for ${trans.buttonId} has no translated text`, + ); } } } diff --git a/src/utils/io.ts b/src/utils/io.ts index eedacc5..e71f511 100644 --- a/src/utils/io.ts +++ b/src/utils/io.ts @@ -4,7 +4,10 @@ export type BinaryOutput = Buffer | Uint8Array; export interface FileAdapter { readBinaryFromInput: (input: ProcessorInput) => Promise; - readTextFromInput: (input: ProcessorInput, encoding?: BufferEncoding) => Promise; + readTextFromInput: ( + input: ProcessorInput, + encoding?: BufferEncoding, + ) => Promise; writeBinaryToPath: (outputPath: string, data: BinaryOutput) => Promise; writeTextToPath: (outputPath: string, text: string) => Promise; pathExists: (path: string) => Promise; @@ -12,103 +15,115 @@ export interface FileAdapter { getFileSize: (path: string) => Promise; mkDir: (path: string, options?: { recursive?: boolean }) => Promise; listDir: (path: string) => Promise; - removePath: (path: string, options?: { recursive?: boolean; force?: boolean }) => Promise; + removePath: ( + path: string, + options?: { recursive?: boolean; force?: boolean }, + ) => Promise; mkTempDir: (prefix: string) => Promise; join: (...pathParts: string[]) => string; dirname: (path: string) => string; basename: (path: string, suffix?: string) => string; } -let cachedFs: typeof import('node:fs') | null = null; -let cachedPath: typeof import('path') | null = null; -let cachedOs: typeof import('os') | null = null; +let cachedFs: typeof import("node:fs") | null = null; +let cachedPath: typeof import("path") | null = null; +let cachedOs: typeof import("os") | null = null; let cachedRequire: NodeRequire | null | undefined = undefined; type NodeRequire = (id: string) => any; export function getNodeRequire(): NodeRequire { if (cachedRequire === undefined) { - if (typeof require === 'function') { + if (typeof require === "function") { cachedRequire = require; - } else if (typeof globalThis !== 'undefined') { + } else if (typeof globalThis !== "undefined") { const maybeRequire = (globalThis as { require?: unknown }).require; - cachedRequire = typeof maybeRequire === 'function' ? (maybeRequire as NodeRequire) : null; + cachedRequire = + typeof maybeRequire === "function" + ? (maybeRequire as NodeRequire) + : null; } else { cachedRequire = null; } } if (!cachedRequire) { - throw new Error('File system access is not available in this environment.'); + throw new Error("File system access is not available in this environment."); } return cachedRequire; } -function getFs(): typeof import('node:fs') { +function getFs(): typeof import("node:fs") { if (!cachedFs) { try { const nodeRequire = getNodeRequire(); - const fsModule = 'node:fs'; + const fsModule = "node:fs"; cachedFs = nodeRequire(fsModule); } catch { - throw new Error('File system access is not available in this environment.'); + throw new Error( + "File system access is not available in this environment.", + ); } } if (!cachedFs) { - throw new Error('File system access is not available in this environment.'); + throw new Error("File system access is not available in this environment."); } return cachedFs; } -function getPath(): typeof import('path') { +function getPath(): typeof import("path") { if (!cachedPath) { try { const nodeRequire = getNodeRequire(); - const pathModule = 'path'; + const pathModule = "path"; cachedPath = nodeRequire(pathModule); } catch { - throw new Error('Path utilities are not available in this environment.'); + throw new Error("Path utilities are not available in this environment."); } } if (!cachedPath) { - throw new Error('Path utilities are not available in this environment.'); + throw new Error("Path utilities are not available in this environment."); } return cachedPath; } -export function getOs(): typeof import('os') { +export function getOs(): typeof import("os") { if (!cachedOs) { try { const nodeRequire = getNodeRequire(); - const osModule = 'os'; + const osModule = "os"; cachedOs = nodeRequire(osModule); } catch { - throw new Error('OS utilities are not available in this environment.'); + throw new Error("OS utilities are not available in this environment."); } } if (!cachedOs) { - throw new Error('OS utilities are not available in this environment.'); + throw new Error("OS utilities are not available in this environment."); } return cachedOs; } export function isNodeRuntime(): boolean { - return typeof process !== 'undefined' && !!process.versions?.node; + return typeof process !== "undefined" && !!process.versions?.node; } export function getBasename(filePath: string): string { - const trimmed = filePath.replace(/[/\\]+$/, '') || filePath; + const trimmed = filePath.replace(/[/\\]+$/, "") || filePath; const parts = trimmed.split(/[/\\]/); return parts[parts.length - 1] || trimmed; } -export function toUint8Array(input: Uint8Array | ArrayBuffer | Buffer): Uint8Array { +export function toUint8Array( + input: Uint8Array | ArrayBuffer | Buffer, +): Uint8Array { if (input instanceof Uint8Array) { return input; } return new Uint8Array(input); } -export function toArrayBuffer(input: Uint8Array | ArrayBuffer | Buffer): ArrayBuffer { +export function toArrayBuffer( + input: Uint8Array | ArrayBuffer | Buffer, +): ArrayBuffer { if (input instanceof ArrayBuffer) { return input; } @@ -117,19 +132,19 @@ export function toArrayBuffer(input: Uint8Array | ArrayBuffer | Buffer): ArrayBu } export function decodeText(input: Uint8Array): string { - if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { - return input.toString('utf8'); + if (typeof Buffer !== "undefined" && Buffer.isBuffer(input)) { + return input.toString("utf8"); } - const decoder = new TextDecoder('utf-8'); + const decoder = new TextDecoder("utf-8"); return decoder.decode(input); } export function encodeBase64(input: Uint8Array): string { - if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { - return input.toString('base64'); + if (typeof Buffer !== "undefined" && Buffer.isBuffer(input)) { + return input.toString("base64"); } // Browser fallback using btoa - let binary = ''; + let binary = ""; const len = input.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(input[i]); @@ -138,25 +153,27 @@ export function encodeBase64(input: Uint8Array): string { } export function encodeText(text: string): BinaryOutput { - if (typeof Buffer !== 'undefined') { - return Buffer.from(text, 'utf8'); + if (typeof Buffer !== "undefined") { + return Buffer.from(text, "utf8"); } return new TextEncoder().encode(text); } // extname algorithm from node:path -const splitDeviceRe = /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/; //eslint-disable-line -const splitTailRe = /^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/; //eslint-disable-line +const splitDeviceRe = + /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/; //eslint-disable-line +const splitTailRe = + /^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/; //eslint-disable-line export function extname(path: string): string { - const tail = splitDeviceRe.exec(path)?.at(3) ?? ''; - return splitTailRe.exec(tail)?.at(3) ?? ''; + const tail = splitDeviceRe.exec(path)?.at(3) ?? ""; + return splitTailRe.exec(tail)?.at(3) ?? ""; } async function readBinaryFromInput(input: ProcessorInput): Promise { - if (typeof input === 'string') { + if (typeof input === "string") { return Promise.resolve(getFs().readFileSync(input)); } - if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { + if (typeof Buffer !== "undefined" && Buffer.isBuffer(input)) { return Promise.resolve(input); } if (input instanceof ArrayBuffer) { @@ -167,12 +184,12 @@ async function readBinaryFromInput(input: ProcessorInput): Promise { async function readTextFromInput( input: ProcessorInput, - encoding: BufferEncoding = 'utf8' + encoding: BufferEncoding = "utf8", ): Promise { - if (typeof input === 'string') { + if (typeof input === "string") { return Promise.resolve(getFs().readFileSync(input, encoding)); } - if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { + if (typeof Buffer !== "undefined" && Buffer.isBuffer(input)) { return Promise.resolve(input.toString(encoding)); } if (input instanceof ArrayBuffer) { @@ -181,13 +198,19 @@ async function readTextFromInput( return Promise.resolve(decodeText(input)); } -async function writeBinaryToPath(outputPath: string, data: BinaryOutput): Promise { +async function writeBinaryToPath( + outputPath: string, + data: BinaryOutput, +): Promise { getFs().writeFileSync(outputPath, data); await Promise.resolve(); } -async function writeTextToPath(outputPath: string, text: string): Promise { - getFs().writeFileSync(outputPath, text, 'utf8'); +async function writeTextToPath( + outputPath: string, + text: string, +): Promise { + getFs().writeFileSync(outputPath, text, "utf8"); await Promise.resolve(); } @@ -203,7 +226,10 @@ async function getFileSize(path: string): Promise { return Promise.resolve(getFs().statSync(path).size); } -async function mkDir(path: string, options?: { recursive?: boolean }): Promise { +async function mkDir( + path: string, + options?: { recursive?: boolean }, +): Promise { getFs().mkdirSync(path, options); await Promise.resolve(); } @@ -214,7 +240,7 @@ async function listDir(path: string): Promise { async function removePath( path: string, - options?: { recursive?: boolean; force?: boolean } + options?: { recursive?: boolean; force?: boolean }, ): Promise { getFs().rmSync(path, options); await Promise.resolve(); diff --git a/src/utils/sqlite.ts b/src/utils/sqlite.ts index 93bbea3..5a85009 100644 --- a/src/utils/sqlite.ts +++ b/src/utils/sqlite.ts @@ -1,5 +1,10 @@ -import type { SqlJsConfig, SqlJsStatic, InitSqlJsStatic } from 'sql.js'; -import { defaultFileAdapter, FileAdapter, getNodeRequire, isNodeRuntime } from './io'; +import type { SqlJsConfig, SqlJsStatic, InitSqlJsStatic } from "sql.js"; +import { + defaultFileAdapter, + FileAdapter, + getNodeRequire, + isNodeRuntime, +} from "./io"; export interface SqliteStatementAdapter { all(...params: unknown[]): any[]; @@ -32,10 +37,13 @@ export function configureSqlJs(config: SqlJsConfig): void { async function getSqlJsBrowser(): Promise { if (!sqlJsPromise) { - const isBrowser = typeof globalThis !== 'undefined' && (globalThis as any).window !== undefined; - if (!isBrowser) throw new Error('Must be run in a browser'); + const isBrowser = + typeof globalThis !== "undefined" && + (globalThis as any).window !== undefined; + if (!isBrowser) throw new Error("Must be run in a browser"); const window = (globalThis as any).window; - if (!('initSqlJs' in window)) throw new Error('Need to add sql-wasm.js script element to DOM'); + if (!("initSqlJs" in window)) + throw new Error("Need to add sql-wasm.js script element to DOM"); const initSqlJs = window.initSqlJs as InitSqlJsStatic; sqlJsPromise = initSqlJs(sqlJsConfig ?? {}); } @@ -91,31 +99,40 @@ function createSqlJsAdapter(db: { }; } -function getBetterSqlite3(): typeof import('better-sqlite3') { +function getBetterSqlite3(): typeof import("better-sqlite3") { try { const nodeRequire = getNodeRequire(); - return nodeRequire('better-sqlite3') as typeof import('better-sqlite3'); + return nodeRequire("better-sqlite3") as typeof import("better-sqlite3"); } catch { - throw new Error('better-sqlite3 is not available in this environment.'); + throw new Error("better-sqlite3 is not available in this environment."); } } -export function requireBetterSqlite3(): typeof import('better-sqlite3') { +export function requireBetterSqlite3(): typeof import("better-sqlite3") { return getBetterSqlite3(); } export async function openSqliteDatabase( input: string | Uint8Array | ArrayBuffer | Buffer, - options: SqliteOpenOptions = {} + options: SqliteOpenOptions = {}, ): Promise { - const { readBinaryFromInput, mkTempDir, writeBinaryToPath, removePath, join } = - options.fileAdapter ?? defaultFileAdapter; - if (typeof input === 'string') { + const { + readBinaryFromInput, + mkTempDir, + writeBinaryToPath, + removePath, + join, + } = options.fileAdapter ?? defaultFileAdapter; + if (typeof input === "string") { if (!isNodeRuntime()) { - throw new Error('SQLite file paths are not supported in browser environments.'); + throw new Error( + "SQLite file paths are not supported in browser environments.", + ); } const Database = getBetterSqlite3(); - const db = new Database(input, { readonly: options.readonly ?? true }) as SqliteDatabaseAdapter; + const db = new Database(input, { + readonly: options.readonly ?? true, + }) as SqliteDatabaseAdapter; return { db }; } @@ -127,12 +144,14 @@ export async function openSqliteDatabase( return { db: createSqlJsAdapter(db) }; } - const tempDir = await mkTempDir('aac-sqlite-'); - const dbPath = join(tempDir, 'input.sqlite'); + const tempDir = await mkTempDir("aac-sqlite-"); + const dbPath = join(tempDir, "input.sqlite"); await writeBinaryToPath(dbPath, data); const Database = getBetterSqlite3(); - const db = new Database(dbPath, { readonly: options.readonly ?? true }) as SqliteDatabaseAdapter; + const db = new Database(dbPath, { + readonly: options.readonly ?? true, + }) as SqliteDatabaseAdapter; const cleanup = async (): Promise => { try { db.close(); @@ -140,7 +159,7 @@ export async function openSqliteDatabase( try { await removePath(tempDir, { recursive: true, force: true }); } catch (error) { - console.warn('Failed to clean up temporary SQLite files:', error); + console.warn("Failed to clean up temporary SQLite files:", error); } } }; diff --git a/src/utils/zip.ts b/src/utils/zip.ts index 9b344d9..88b96ca 100644 --- a/src/utils/zip.ts +++ b/src/utils/zip.ts @@ -4,7 +4,7 @@ import { ProcessorInput, FileAdapter, defaultFileAdapter, -} from './io'; +} from "./io"; export interface ZipAdapter { listFiles(): string[]; @@ -19,15 +19,15 @@ export interface ZipFile { export async function getZipAdapter( input?: ProcessorInput, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { const adapter = fileAdapter ?? defaultFileAdapter; if (isNodeRuntime()) { - const AdmZip = getNodeRequire()('adm-zip') as typeof import('adm-zip'); + const AdmZip = getNodeRequire()("adm-zip") as typeof import("adm-zip"); const zip = input === undefined ? new AdmZip(input) - : typeof input === 'string' + : typeof input === "string" ? new AdmZip(input) : new AdmZip(Buffer.from(await adapter.readBinaryFromInput(input))); return { @@ -51,10 +51,12 @@ export async function getZipAdapter( }; } - const module = await import('jszip'); + const module = await import("jszip"); const JSZip = module.default || module; - const zip = input ? await JSZip.loadAsync(await adapter.readBinaryFromInput(input)) : new JSZip(); + const zip = input + ? await JSZip.loadAsync(await adapter.readBinaryFromInput(input)) + : new JSZip(); return { listFiles: (): string[] => { return Object.entries(zip.files) @@ -64,13 +66,13 @@ export async function getZipAdapter( readFile: async (name: string): Promise => { const file = zip.file(name); if (!file) throw new Error(`Zip entry not found: ${name}`); - return file.async('uint8array'); + return file.async("uint8array"); }, writeFiles: async (files: ZipFile[]): Promise => { files.forEach((file) => { zip.file(file.name, file.data); }); - return await zip.generateAsync({ type: 'uint8array' }); + return await zip.generateAsync({ type: "uint8array" }); }, }; } diff --git a/src/validation.ts b/src/validation.ts index a8494b8..43761cb 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -14,26 +14,26 @@ export { ValidationRule, ValidationFailureError, buildValidationResultFromMessage, -} from './validation/validationTypes'; +} from "./validation/validationTypes"; // Base validator -export { BaseValidator } from './validation/baseValidator'; +export { BaseValidator } from "./validation/baseValidator"; // Format-specific validators -export { ObfValidator } from './validation/obfValidator'; -export { GridsetValidator } from './validation/gridsetValidator'; -export { SnapValidator } from './validation/snapValidator'; -export { TouchChatValidator } from './validation/touchChatValidator'; -export { AstericsGridValidator } from './validation/astericsValidator'; -export { ExcelValidator } from './validation/excelValidator'; -export { OpmlValidator } from './validation/opmlValidator'; -export { DotValidator } from './validation/dotValidator'; -export { ApplePanelsValidator } from './validation/applePanelsValidator'; -export { ObfsetValidator } from './validation/obfsetValidator'; +export { ObfValidator } from "./validation/obfValidator"; +export { GridsetValidator } from "./validation/gridsetValidator"; +export { SnapValidator } from "./validation/snapValidator"; +export { TouchChatValidator } from "./validation/touchChatValidator"; +export { AstericsGridValidator } from "./validation/astericsValidator"; +export { ExcelValidator } from "./validation/excelValidator"; +export { OpmlValidator } from "./validation/opmlValidator"; +export { DotValidator } from "./validation/dotValidator"; +export { ApplePanelsValidator } from "./validation/applePanelsValidator"; +export { ObfsetValidator } from "./validation/obfsetValidator"; // Validator factory functions export { getValidatorForFormat, getValidatorForFile, validateFileOrBuffer, -} from './validation/index'; +} from "./validation/index"; diff --git a/src/validation/applePanelsValidator.ts b/src/validation/applePanelsValidator.ts index 5d0e35d..645f2fb 100644 --- a/src/validation/applePanelsValidator.ts +++ b/src/validation/applePanelsValidator.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/require-await */ -import plist from 'plist'; -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; +import plist from "plist"; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from '../utils/io'; +} from "../utils/io"; type PanelsContainer = { panels?: any; Panels?: Record }; @@ -18,17 +18,23 @@ type PanelsContainer = { panels?: any; Panels?: Record }; export class ApplePanelsValidator extends BaseValidator { static async validateFile( filePath: string, - fileAdapter: FileAdapter = defaultFileAdapter + fileAdapter: FileAdapter = defaultFileAdapter, ): Promise { - const { pathExists, isDirectory, getFileSize, readBinaryFromInput, join } = fileAdapter; + const { pathExists, isDirectory, getFileSize, readBinaryFromInput, join } = + fileAdapter; const validator = new ApplePanelsValidator(); let content: Uint8Array; const filename = getBasename(filePath); let size = 0; const isDir = await isDirectory(filePath); - if (isDir && filename.toLowerCase().endsWith('.ascconfig')) { - const panelPath = join(filePath, 'Contents', 'Resources', 'PanelDefinitions.plist'); + if (isDir && filename.toLowerCase().endsWith(".ascconfig")) { + const panelPath = join( + filePath, + "Contents", + "Resources", + "PanelDefinitions.plist", + ); if (!(await pathExists(panelPath))) { return validator.validate(Buffer.alloc(0), filename, 0); } @@ -42,21 +48,27 @@ export class ApplePanelsValidator extends BaseValidator { return validator.validate(content, filename, size); } - static async identifyFormat(content: any, filename: string): Promise { + static async identifyFormat( + content: any, + filename: string, + ): Promise { const name = filename.toLowerCase(); - if (name.endsWith('.plist') || name.endsWith('.ascconfig')) { + if (name.endsWith(".plist") || name.endsWith(".ascconfig")) { return true; } try { if ( - typeof content !== 'string' && + typeof content !== "string" && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const str = typeof content === 'string' ? content : decodeText(toUint8Array(content)); + const str = + typeof content === "string" + ? content + : decodeText(toUint8Array(content)); const parsed = plist.parse(str) as PanelsContainer; return Boolean(parsed.panels || parsed.Panels); } catch { @@ -67,18 +79,18 @@ export class ApplePanelsValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { this.reset(); - await this.add_check('filename', 'file extension', async () => { + await this.add_check("filename", "file extension", async () => { if (!filename.toLowerCase().match(/\.(plist|ascconfig)$/)) { - this.warn('filename should end with .plist or .ascconfig'); + this.warn("filename should end with .plist or .ascconfig"); } }); let parsed: PanelsContainer | null = null; - await this.add_check('plist_parse', 'valid plist/XML', async () => { + await this.add_check("plist_parse", "valid plist/XML", async () => { try { const str = decodeText(content); parsed = plist.parse(str) as PanelsContainer; @@ -88,17 +100,17 @@ export class ApplePanelsValidator extends BaseValidator { }); if (!parsed) { - return this.buildResult(filename, filesize, 'applepanels'); + return this.buildResult(filename, filesize, "applepanels"); } let panels: any[] = []; - await this.add_check('panels', 'panels present', async () => { + await this.add_check("panels", "panels present", async () => { if (Array.isArray(parsed?.panels)) { panels = parsed?.panels; - } else if (parsed?.Panels && typeof parsed.Panels === 'object') { + } else if (parsed?.Panels && typeof parsed.Panels === "object") { panels = Object.values(parsed.Panels); } else { - this.err('missing panels/PanelDefinitions content', true); + this.err("missing panels/PanelDefinitions content", true); } }); @@ -106,20 +118,22 @@ export class ApplePanelsValidator extends BaseValidator { const prefix = `panel[${idx}]`; this.add_check_sync(`${prefix}_id`, `${prefix} id`, () => { if (!panel?.ID && !panel?.id) { - this.err('panel missing ID'); + this.err("panel missing ID"); } }); this.add_check_sync(`${prefix}_buttons`, `${prefix} buttons`, () => { const buttons = Array.isArray(panel?.PanelObjects) - ? panel.PanelObjects.filter((obj: any) => obj?.PanelObjectType === 'Button') + ? panel.PanelObjects.filter( + (obj: any) => obj?.PanelObjectType === "Button", + ) : []; if (buttons.length === 0) { - this.warn('panel has no buttons'); + this.warn("panel has no buttons"); } }); }); - return this.buildResult(filename, filesize, 'applepanels'); + return this.buildResult(filename, filesize, "applepanels"); } } diff --git a/src/validation/astericsValidator.ts b/src/validation/astericsValidator.ts index e45851f..5b60930 100644 --- a/src/validation/astericsValidator.ts +++ b/src/validation/astericsValidator.ts @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/require-await */ -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from '../utils/io'; +} from "../utils/io"; /** * Validator for Asterics Grid (.grd) JSON files @@ -18,9 +18,10 @@ export class AstericsGridValidator extends BaseValidator { */ static async validateFile( filePath: string, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { - const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = + fileAdapter ?? defaultFileAdapter; const validator = new AstericsGridValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); @@ -30,21 +31,27 @@ export class AstericsGridValidator extends BaseValidator { /** * Identify whether the content appears to be an Asterics .grd file */ - static async identifyFormat(content: any, filename: string): Promise { + static async identifyFormat( + content: any, + filename: string, + ): Promise { const name = filename.toLowerCase(); - if (name.endsWith('.grd')) { + if (name.endsWith(".grd")) { return true; } try { if ( - typeof content !== 'string' && + typeof content !== "string" && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const str = typeof content === 'string' ? content : decodeText(toUint8Array(content)); + const str = + typeof content === "string" + ? content + : decodeText(toUint8Array(content)); const json = JSON.parse(str); return Array.isArray(json?.grids); } catch { @@ -55,18 +62,18 @@ export class AstericsGridValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { this.reset(); - await this.add_check('filename', 'file extension', async () => { - if (!filename.toLowerCase().endsWith('.grd')) { - this.warn('filename should end with .grd'); + await this.add_check("filename", "file extension", async () => { + if (!filename.toLowerCase().endsWith(".grd")) { + this.warn("filename should end with .grd"); } }); let json: any = null; - await this.add_check('json_parse', 'valid JSON', async () => { + await this.add_check("json_parse", "valid JSON", async () => { try { let str = decodeText(content); if (str.charCodeAt(0) === 0xfeff) { @@ -79,12 +86,12 @@ export class AstericsGridValidator extends BaseValidator { }); if (!json) { - return this.buildResult(filename, filesize, 'asterics'); + return this.buildResult(filename, filesize, "asterics"); } - await this.add_check('grids', 'grids array', async () => { + await this.add_check("grids", "grids array", async () => { if (!Array.isArray(json.grids) || json.grids.length === 0) { - this.err('missing grids array in file', true); + this.err("missing grids array in file", true); } }); @@ -93,28 +100,28 @@ export class AstericsGridValidator extends BaseValidator { grids.forEach((grid: any, idx: number) => { const prefix = `grid[${idx}]`; this.add_check_sync(`${prefix}_id`, `${prefix} id`, () => { - if (!grid?.id || typeof grid.id !== 'string') { - this.err('grid is missing an id'); + if (!grid?.id || typeof grid.id !== "string") { + this.err("grid is missing an id"); } }); this.add_check_sync(`${prefix}_rows`, `${prefix} rowCount`, () => { - if (typeof grid?.rowCount !== 'number' || grid.rowCount <= 0) { - this.err('rowCount must be a positive number'); + if (typeof grid?.rowCount !== "number" || grid.rowCount <= 0) { + this.err("rowCount must be a positive number"); } }); this.add_check_sync(`${prefix}_elements`, `${prefix} elements`, () => { if (!Array.isArray(grid?.gridElements)) { - this.err('gridElements must be an array'); + this.err("gridElements must be an array"); return; } if (grid.gridElements.length === 0) { - this.warn('grid has no elements'); + this.warn("grid has no elements"); } }); }); - return this.buildResult(filename, filesize, 'asterics'); + return this.buildResult(filename, filesize, "asterics"); } } diff --git a/src/validation/baseValidator.ts b/src/validation/baseValidator.ts index 2c05fa8..322b856 100644 --- a/src/validation/baseValidator.ts +++ b/src/validation/baseValidator.ts @@ -1,12 +1,12 @@ -import { defaultFileAdapter } from '../utils/io'; -import { getZipAdapter } from '../utils/zip'; +import { defaultFileAdapter } from "../utils/io"; +import { getZipAdapter } from "../utils/zip"; import { ValidationError, ValidationResult, ValidationCheck, ValidationOptions, ValidationConfig, -} from './validationTypes'; +} from "./validationTypes"; /** * Base class for all format validators @@ -52,7 +52,7 @@ export abstract class BaseValidator { protected async add_check( type: string, description: string, - checkFn: () => Promise + checkFn: () => Promise, ): Promise { // Skip if blocked by a previous error if (this._blocked && this._options.stopOnBlocker) { @@ -86,7 +86,11 @@ export abstract class BaseValidator { /** * Add a synchronous validation check */ - protected add_check_sync(type: string, description: string, checkFn: () => void): void { + protected add_check_sync( + type: string, + description: string, + checkFn: () => void, + ): void { // Convert sync to async for consistency // eslint-disable-next-line @typescript-eslint/require-await void this.add_check(type, description, async () => checkFn()); @@ -156,7 +160,11 @@ export abstract class BaseValidator { /** * Build the final validation result */ - protected buildResult(filename: string, filesize: number, format: string): ValidationResult { + protected buildResult( + filename: string, + filesize: number, + format: string, + ): ValidationResult { return { filename, filesize, @@ -175,7 +183,11 @@ export abstract class BaseValidator { * @param filename - Name of the file being validated * @param filesize - Size of the file in bytes */ - abstract validate(content: any, filename: string, filesize: number): Promise; + abstract validate( + content: any, + filename: string, + filesize: number, + ): Promise; /** * Static helper to validate from file path @@ -183,14 +195,17 @@ export abstract class BaseValidator { */ // eslint-disable-next-line @typescript-eslint/require-await static async validateFile(_filePath: string): Promise { - throw new Error('validateFile must be implemented by subclass'); + throw new Error("validateFile must be implemented by subclass"); } /** * Static helper to identify if content is this validator's format */ // eslint-disable-next-line @typescript-eslint/require-await - static async identifyFormat(_content: any, _filename: string): Promise { - throw new Error('identifyFormat must be implemented by subclass'); + static async identifyFormat( + _content: any, + _filename: string, + ): Promise { + throw new Error("identifyFormat must be implemented by subclass"); } } diff --git a/src/validation/dotValidator.ts b/src/validation/dotValidator.ts index ae29a50..24ea62a 100644 --- a/src/validation/dotValidator.ts +++ b/src/validation/dotValidator.ts @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/require-await */ -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from '../utils/io'; +} from "../utils/io"; /** * Validator for Graphviz DOT files @@ -15,29 +15,36 @@ import { export class DotValidator extends BaseValidator { static async validateFile( filePath: string, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { - const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = + fileAdapter ?? defaultFileAdapter; const validator = new DotValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); return validator.validate(content, getBasename(filePath), size); } - static async identifyFormat(content: any, filename: string): Promise { + static async identifyFormat( + content: any, + filename: string, + ): Promise { const name = filename.toLowerCase(); - if (name.endsWith('.dot')) return true; + if (name.endsWith(".dot")) return true; try { if ( - typeof content !== 'string' && + typeof content !== "string" && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const str = typeof content === 'string' ? content : decodeText(toUint8Array(content)); - return str.includes('digraph') || str.includes('->'); + const str = + typeof content === "string" + ? content + : decodeText(toUint8Array(content)); + return str.includes("digraph") || str.includes("->"); } catch { return false; } @@ -46,41 +53,42 @@ export class DotValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { this.reset(); - await this.add_check('filename', 'file extension', async () => { - if (!filename.toLowerCase().endsWith('.dot')) { - this.warn('filename should end with .dot'); + await this.add_check("filename", "file extension", async () => { + if (!filename.toLowerCase().endsWith(".dot")) { + this.warn("filename should end with .dot"); } }); - let text = ''; - await this.add_check('text', 'text content', async () => { + let text = ""; + await this.add_check("text", "text content", async () => { text = decodeText(content); if (!text.trim()) { - this.err('DOT file is empty', true); + this.err("DOT file is empty", true); } // Basic control character check const head = text.substring(0, 200); for (let i = 0; i < head.length; i++) { const code = head.charCodeAt(i); if (code === 0) { - this.err('DOT appears to be binary data', true); + this.err("DOT appears to be binary data", true); } } }); if (!text) { - return this.buildResult(filename, filesize, 'dot'); + return this.buildResult(filename, filesize, "dot"); } let nodes: Array<{ id: string; label: string }> = []; let edges: Array<{ from: string; to: string; label?: string }> = []; - await this.add_check('structure', 'graph structure', async () => { - const edgeRegex = /"?([^"\s]+)"?\s*->\s*"?([^"\s]+)"?(?:\s*\[label="([^"]+)"\])?/g; + await this.add_check("structure", "graph structure", async () => { + const edgeRegex = + /"?([^"\s]+)"?\s*->\s*"?([^"\s]+)"?(?:\s*\[label="([^"]+)"\])?/g; let maskedContent = text; let edgeMatch; edges = []; @@ -91,29 +99,32 @@ export class DotValidator extends BaseValidator { edges.push({ from, to, label }); nodeMap.set(from, { id: from, label: from }); nodeMap.set(to, { id: to, label: to }); - maskedContent = maskedContent.replace(fullMatch, ' '.repeat(fullMatch.length)); + maskedContent = maskedContent.replace( + fullMatch, + " ".repeat(fullMatch.length), + ); } const nodeRegex = /"?([^"\s]+)"?\s*\[label="((?:[^"\\]|\\.)*)"\]/g; let nodeMatch; while ((nodeMatch = nodeRegex.exec(maskedContent)) !== null) { const [, id, rawLabel] = nodeMatch; - const label = rawLabel.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + const label = rawLabel.replace(/\\"/g, '"').replace(/\\\\/g, "\\"); nodeMap.set(id, { id, label }); } nodes = Array.from(nodeMap.values()); if (nodes.length === 0 && edges.length === 0) { - this.err('no nodes or edges found in DOT content', true); + this.err("no nodes or edges found in DOT content", true); } }); - await this.add_check('connections', 'navigation edges', async () => { + await this.add_check("connections", "navigation edges", async () => { if (edges.length === 0) { - this.warn('graph contains no edges; navigation buttons may be missing'); + this.warn("graph contains no edges; navigation buttons may be missing"); } }); - return this.buildResult(filename, filesize, 'dot'); + return this.buildResult(filename, filesize, "dot"); } } diff --git a/src/validation/excelValidator.ts b/src/validation/excelValidator.ts index f8f03d3..ba85978 100644 --- a/src/validation/excelValidator.ts +++ b/src/validation/excelValidator.ts @@ -1,8 +1,13 @@ /* eslint-disable @typescript-eslint/require-await */ -import * as ExcelJS from 'exceljs'; -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; -import { defaultFileAdapter, FileAdapter, getBasename, toArrayBuffer } from '../utils/io'; +import * as ExcelJS from "exceljs"; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; +import { + defaultFileAdapter, + FileAdapter, + getBasename, + toArrayBuffer, +} from "../utils/io"; /** * Validator for Excel imports (.xlsx/.xls) @@ -10,48 +15,57 @@ import { defaultFileAdapter, FileAdapter, getBasename, toArrayBuffer } from '../ export class ExcelValidator extends BaseValidator { static async validateFile( filePath: string, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { - const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = + fileAdapter ?? defaultFileAdapter; const validator = new ExcelValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); return validator.validate(content, getBasename(filePath), size); } - static async identifyFormat(_content: any, filename: string): Promise { + static async identifyFormat( + _content: any, + filename: string, + ): Promise { const name = filename.toLowerCase(); - return name.endsWith('.xlsx') || name.endsWith('.xls'); + return name.endsWith(".xlsx") || name.endsWith(".xls"); } async validate( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { this.reset(); - const ext = filename.toLowerCase().split('.').pop() || ''; + const ext = filename.toLowerCase().split(".").pop() || ""; - await this.add_check('filename', 'file extension', async () => { - if (!['xlsx', 'xls'].includes(ext)) { - this.warn('filename should end with .xlsx or .xls'); + await this.add_check("filename", "file extension", async () => { + if (!["xlsx", "xls"].includes(ext)) { + this.warn("filename should end with .xlsx or .xls"); } }); - if (ext === 'xls') { + if (ext === "xls") { // exceljs cannot parse legacy .xls files - await this.add_check('xls_support', 'legacy Excel format', async () => { - this.err('legacy .xls files are not supported; please provide .xlsx', true); + await this.add_check("xls_support", "legacy Excel format", async () => { + this.err( + "legacy .xls files are not supported; please provide .xlsx", + true, + ); }); - return this.buildResult(filename, filesize, 'excel'); + return this.buildResult(filename, filesize, "excel"); } const buffer = - typeof Buffer !== 'undefined' && Buffer.isBuffer(content) ? content : toArrayBuffer(content); + typeof Buffer !== "undefined" && Buffer.isBuffer(content) + ? content + : toArrayBuffer(content); const workbook = new ExcelJS.Workbook(); - await this.add_check('open', 'open workbook', async () => { + await this.add_check("open", "open workbook", async () => { try { await workbook.xlsx.load(buffer); } catch (e: any) { @@ -59,23 +73,23 @@ export class ExcelValidator extends BaseValidator { } }); - await this.add_check('worksheets', 'worksheets exist', async () => { + await this.add_check("worksheets", "worksheets exist", async () => { if (workbook.worksheets.length === 0) { - this.err('Excel workbook has no worksheets', true); + this.err("Excel workbook has no worksheets", true); } }); const firstSheet = workbook.worksheets[0]; if (firstSheet) { - await this.add_check('content', 'worksheet has content', async () => { + await this.add_check("content", "worksheet has content", async () => { const rows = firstSheet.actualRowCount || firstSheet.rowCount; const cols = firstSheet.columnCount; if (!rows || !cols) { - this.err('first worksheet is empty', true); + this.err("first worksheet is empty", true); } }); } - return this.buildResult(filename, filesize, 'excel'); + return this.buildResult(filename, filesize, "excel"); } } diff --git a/src/validation/gridsetValidator.ts b/src/validation/gridsetValidator.ts index 338abe9..f9e324b 100644 --- a/src/validation/gridsetValidator.ts +++ b/src/validation/gridsetValidator.ts @@ -1,16 +1,16 @@ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-return */ -import * as xml2js from 'xml2js'; -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; +import * as xml2js from "xml2js"; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from '../utils/io'; +} from "../utils/io"; /** * Validator for Grid3/Smartbox Gridset files (.gridset, .gridsetx) @@ -25,9 +25,10 @@ export class GridsetValidator extends BaseValidator { */ static async validateFile( filePath: string, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { - const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = + fileAdapter ?? defaultFileAdapter; const validator = new GridsetValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); @@ -37,15 +38,21 @@ export class GridsetValidator extends BaseValidator { /** * Check if content is Gridset format */ - static async identifyFormat(content: any, filename: string): Promise { + static async identifyFormat( + content: any, + filename: string, + ): Promise { const name = filename.toLowerCase(); - if (name.endsWith('.gridset') || name.endsWith('.gridsetx')) { + if (name.endsWith(".gridset") || name.endsWith(".gridsetx")) { return true; } // Try to parse as XML and check for gridset structure try { - const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content)); + const contentStr = + typeof content === "string" + ? content + : decodeText(toUint8Array(content)); const parser = new xml2js.Parser(); const result = await parser.parseStringPromise(contentStr); return result && (result.gridset || result.Gridset); @@ -60,26 +67,32 @@ export class GridsetValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { this.reset(); - const isEncrypted = filename.toLowerCase().endsWith('.gridsetx'); + const isEncrypted = filename.toLowerCase().endsWith(".gridsetx"); // eslint-disable-next-line @typescript-eslint/require-await - await this.add_check('filename', 'file extension', async () => { + await this.add_check("filename", "file extension", async () => { if (!filename.match(/\.gridsetx?$/)) { - this.warn('filename should end with .gridset or .gridsetx'); + this.warn("filename should end with .gridset or .gridsetx"); } }); // For encrypted .gridsetx files, we can't validate the content if (isEncrypted) { // eslint-disable-next-line @typescript-eslint/require-await - await this.add_check('encrypted_format', 'encrypted gridsetx file', async () => { - this.warn('gridsetx files are encrypted and cannot be fully validated'); - }); - return this.buildResult(filename, filesize, 'gridset'); + await this.add_check( + "encrypted_format", + "encrypted gridsetx file", + async () => { + this.warn( + "gridsetx files are encrypted and cannot be fully validated", + ); + }, + ); + return this.buildResult(filename, filesize, "gridset"); } const isZip = this.isZip(content); @@ -90,7 +103,7 @@ export class GridsetValidator extends BaseValidator { await this.validateSingleXml(content, filename, filesize); } - return this.buildResult(filename, filesize, 'gridset'); + return this.buildResult(filename, filesize, "gridset"); } /** @@ -98,7 +111,12 @@ export class GridsetValidator extends BaseValidator { */ private isZip(content: Buffer | Uint8Array): boolean { if (content.length < 4) return false; - return content[0] === 0x50 && content[1] === 0x4b && content[2] === 0x03 && content[3] === 0x04; + return ( + content[0] === 0x50 && + content[1] === 0x4b && + content[2] === 0x03 && + content[3] === 0x04 + ); } /** @@ -107,10 +125,10 @@ export class GridsetValidator extends BaseValidator { private async validateSingleXml( content: Buffer | Uint8Array, filename: string, - _filesize: number + _filesize: number, ): Promise { let xmlObj: any = null; - await this.add_check('xml_parse', 'valid XML', async () => { + await this.add_check("xml_parse", "valid XML", async () => { try { const parser = new xml2js.Parser(); const contentStr = decodeText(content); @@ -122,9 +140,9 @@ export class GridsetValidator extends BaseValidator { if (!xmlObj) return; - await this.add_check('xml_structure', 'gridset root element', async () => { + await this.add_check("xml_structure", "gridset root element", async () => { if (!xmlObj.gridset && !xmlObj.Gridset) { - this.err('missing root gridset element', true); + this.err("missing root gridset element", true); } }); @@ -140,58 +158,78 @@ export class GridsetValidator extends BaseValidator { private async validateZipArchive( content: Buffer | Uint8Array, filename: string, - _filesize: number + _filesize: number, ): Promise { const zip = await this._options.zipAdapter(content); const entries = zip.listFiles(); // Check for gridset.xml (required) - await this.add_check('gridset_xml_presence', 'gridset.xml presence', async () => { - const gridsetEntry = entries.find((e) => e.toLowerCase() === 'gridset.xml'); - if (!gridsetEntry) { - this.err('Missing gridset.xml in archive', true); - } else { - try { - const gridsetXml = await zip.readFile(gridsetEntry); - const parser = new xml2js.Parser(); - const xmlObj = await parser.parseStringPromise(gridsetXml); - const gridset = xmlObj.gridset || xmlObj.Gridset; - if (!gridset) { - this.err('Invalid gridset.xml structure', true); - } else { - await this.validateGridsetStructure(gridset, filename, new Uint8Array()); + await this.add_check( + "gridset_xml_presence", + "gridset.xml presence", + async () => { + const gridsetEntry = entries.find( + (e) => e.toLowerCase() === "gridset.xml", + ); + if (!gridsetEntry) { + this.err("Missing gridset.xml in archive", true); + } else { + try { + const gridsetXml = await zip.readFile(gridsetEntry); + const parser = new xml2js.Parser(); + const xmlObj = await parser.parseStringPromise(gridsetXml); + const gridset = xmlObj.gridset || xmlObj.Gridset; + if (!gridset) { + this.err("Invalid gridset.xml structure", true); + } else { + await this.validateGridsetStructure( + gridset, + filename, + new Uint8Array(), + ); + } + } catch (e: any) { + this.err(`Failed to parse gridset.xml: ${e.message}`, true); } - } catch (e: any) { - this.err(`Failed to parse gridset.xml: ${e.message}`, true); } - } - }); + }, + ); // Check for settings.xml (highly recommended/required for metadata) - await this.add_check('settings_xml_presence', 'settings.xml presence', async () => { - const settingsEntry = entries.find((e) => e.toLowerCase() === 'settings.xml'); - if (!settingsEntry) { - this.warn('Missing settings.xml in archive (required for full metadata)'); - } else { - try { - const settingsXml = await zip.readFile(settingsEntry); - const parser = new xml2js.Parser(); - const xmlObj = await parser.parseStringPromise(settingsXml); - const settings = - xmlObj.GridSetSettings || xmlObj.gridSetSettings || xmlObj.GridsetSettings; - if (!settings) { - this.warn('Invalid settings.xml structure'); - } else { - // Basic validation of settings.xml - if (!settings.StartGrid && !settings.startGrid) { - this.warn('settings.xml missing StartGrid element'); + await this.add_check( + "settings_xml_presence", + "settings.xml presence", + async () => { + const settingsEntry = entries.find( + (e) => e.toLowerCase() === "settings.xml", + ); + if (!settingsEntry) { + this.warn( + "Missing settings.xml in archive (required for full metadata)", + ); + } else { + try { + const settingsXml = await zip.readFile(settingsEntry); + const parser = new xml2js.Parser(); + const xmlObj = await parser.parseStringPromise(settingsXml); + const settings = + xmlObj.GridSetSettings || + xmlObj.gridSetSettings || + xmlObj.GridsetSettings; + if (!settings) { + this.warn("Invalid settings.xml structure"); + } else { + // Basic validation of settings.xml + if (!settings.StartGrid && !settings.startGrid) { + this.warn("settings.xml missing StartGrid element"); + } } + } catch (e: any) { + this.warn(`Failed to parse settings.xml: ${e.message}`); } - } catch (e: any) { - this.warn(`Failed to parse settings.xml: ${e.message}`); } - } - }); + }, + ); } /** @@ -200,31 +238,31 @@ export class GridsetValidator extends BaseValidator { private async validateGridsetStructure( gridset: any, _filename: string, - _content: Buffer | Uint8Array + _content: Buffer | Uint8Array, ): Promise { // Check for required elements - await this.add_check('gridset_id', 'gridset id', async () => { + await this.add_check("gridset_id", "gridset id", async () => { const id = gridset.$.id || gridset.$.Id; if (!id) { - this.warn('gridset should have an id attribute'); + this.warn("gridset should have an id attribute"); } }); - await this.add_check('gridset_name', 'gridset name', async () => { + await this.add_check("gridset_name", "gridset name", async () => { const name = gridset.$.name || gridset.$.Name || gridset.name?.[0]; if (!name) { - this.warn('gridset should have a name attribute or element'); + this.warn("gridset should have a name attribute or element"); } }); // Check for pages - await this.add_check('pages', 'pages element', async () => { + await this.add_check("pages", "pages element", async () => { if (!gridset.pages && !gridset.Pages) { - this.err('gridset must have a pages element'); + this.err("gridset must have a pages element"); } else { const pages = gridset.pages || gridset.Pages; if (!pages[0] || !Array.isArray(pages[0].page)) { - this.warn('pages should contain at least one page element'); + this.warn("pages should contain at least one page element"); } } }); @@ -232,9 +270,9 @@ export class GridsetValidator extends BaseValidator { // Validate individual pages const pages = gridset.pages?.[0] || gridset.Pages?.[0]; if (pages && Array.isArray(pages.page)) { - await this.add_check('page_count', 'page count', async () => { + await this.add_check("page_count", "page count", async () => { if (pages.page.length === 0) { - this.err('gridset must contain at least one page'); + this.err("gridset must contain at least one page"); } }); @@ -246,31 +284,41 @@ export class GridsetValidator extends BaseValidator { } // Check for fixedCellSize - await this.add_check('fixed_cell_size', 'fixedCellSize element', async () => { - const fixedSize = gridset.fixedCellSize || gridset.FixedCellSize; - if (!fixedSize) { - this.warn('gridset should have a fixedCellSize element for consistency'); - } else { - // Validate fixedCellSize structure - const size = fixedSize[0]; - if (size) { - const width = size.$.width || size.$.Width; - const height = size.$.height || size.$.Height; - - if (!width || !height) { - this.warn('fixedCellSize should have both width and height attributes'); - } else if (isNaN(parseInt(width)) || isNaN(parseInt(height))) { - this.err('fixedCellSize width and height must be valid numbers'); + await this.add_check( + "fixed_cell_size", + "fixedCellSize element", + async () => { + const fixedSize = gridset.fixedCellSize || gridset.FixedCellSize; + if (!fixedSize) { + this.warn( + "gridset should have a fixedCellSize element for consistency", + ); + } else { + // Validate fixedCellSize structure + const size = fixedSize[0]; + if (size) { + const width = size.$.width || size.$.Width; + const height = size.$.height || size.$.Height; + + if (!width || !height) { + this.warn( + "fixedCellSize should have both width and height attributes", + ); + } else if (isNaN(parseInt(width)) || isNaN(parseInt(height))) { + this.err("fixedCellSize width and height must be valid numbers"); + } } } - } - }); + }, + ); // Check for styles - await this.add_check('styles', 'styles element', async () => { + await this.add_check("styles", "styles element", async () => { const styles = gridset.styles || gridset.Styles; if (!styles) { - this.warn('gridset should have a styles element for consistent formatting'); + this.warn( + "gridset should have a styles element for consistent formatting", + ); } }); } @@ -286,25 +334,37 @@ export class GridsetValidator extends BaseValidator { } }); - await this.add_check(`page[${index}]_name`, `page ${index} name`, async () => { - const name = page.$.name || page.$.Name || page.name?.[0]; - if (!name) { - this.warn(`page ${index} should have a name`); - } - }); + await this.add_check( + `page[${index}]_name`, + `page ${index} name`, + async () => { + const name = page.$.name || page.$.Name || page.name?.[0]; + if (!name) { + this.warn(`page ${index} should have a name`); + } + }, + ); // Check for cells - await this.add_check(`page[${index}]_cells`, `page ${index} cells`, async () => { - const cells = page.cells || page.Cells; - if (!cells) { - this.warn(`page ${index} should have a cells element`); - } else { - const cellArray = cells[0]?.cell || cells[0]?.Cell; - if (!cellArray || !Array.isArray(cellArray) || cellArray.length === 0) { - this.warn(`page ${index} should contain at least one cell`); + await this.add_check( + `page[${index}]_cells`, + `page ${index} cells`, + async () => { + const cells = page.cells || page.Cells; + if (!cells) { + this.warn(`page ${index} should have a cells element`); + } else { + const cellArray = cells[0]?.cell || cells[0]?.Cell; + if ( + !cellArray || + !Array.isArray(cellArray) || + cellArray.length === 0 + ) { + this.warn(`page ${index} should contain at least one cell`); + } } - } - }); + }, + ); // Validate cells if present const cells = page.cells?.[0] || page.Cells?.[0]; @@ -323,22 +383,38 @@ export class GridsetValidator extends BaseValidator { /** * Validate a single cell */ - private async validateCell(cell: any, pageIdx: number, cellIdx: number): Promise { - await this.add_check(`page[${pageIdx}]_cell[${cellIdx}]_id`, `cell id`, async () => { - const id = cell.$.id || cell.$.Id; - if (!id) { - this.warn(`cell ${cellIdx} on page ${pageIdx} is missing id attribute`); - } - }); + private async validateCell( + cell: any, + pageIdx: number, + cellIdx: number, + ): Promise { + await this.add_check( + `page[${pageIdx}]_cell[${cellIdx}]_id`, + `cell id`, + async () => { + const id = cell.$.id || cell.$.Id; + if (!id) { + this.warn( + `cell ${cellIdx} on page ${pageIdx} is missing id attribute`, + ); + } + }, + ); - await this.add_check(`page[${pageIdx}]_cell[${cellIdx}]_content`, `cell content`, async () => { - const label = cell.$.label || cell.$.Label; - const image = cell.$.image || cell.$.Image; + await this.add_check( + `page[${pageIdx}]_cell[${cellIdx}]_content`, + `cell content`, + async () => { + const label = cell.$.label || cell.$.Label; + const image = cell.$.image || cell.$.Image; - if (!label && !image) { - this.warn(`cell ${cellIdx} on page ${pageIdx} should have a label or image`); - } - }); + if (!label && !image) { + this.warn( + `cell ${cellIdx} on page ${pageIdx} should have a label or image`, + ); + } + }, + ); // Validate scan block number (Grid 3 attribute) await this.add_check( @@ -351,11 +427,11 @@ export class GridsetValidator extends BaseValidator { if (isNaN(blockNum) || blockNum < 1 || blockNum > 8) { this.err( `cell ${cellIdx} on page ${pageIdx} has invalid scanBlock value: ${scanBlock} (must be 1-8)`, - false + false, ); } } - } + }, ); // Check for color attributes @@ -372,7 +448,7 @@ export class GridsetValidator extends BaseValidator { if (backgroundColor.length === 0) { this.warn(`cell ${cellIdx} has empty background color`); } - } + }, ); } @@ -383,10 +459,10 @@ export class GridsetValidator extends BaseValidator { `page[${pageIdx}]_cell[${cellIdx}]_jump`, `cell jump reference`, async () => { - if (typeof jump !== 'string' || jump.length === 0) { + if (typeof jump !== "string" || jump.length === 0) { this.warn(`cell ${cellIdx} has invalid jump reference`); } - } + }, ); } } @@ -401,12 +477,12 @@ export class GridsetValidator extends BaseValidator { if (/^[a-zA-Z]+$/.test(color)) return true; // ARGB format: #AARRGGBB or #RRGGBB - if (color.startsWith('#')) { + if (color.startsWith("#")) { return color.length === 7 || color.length === 9; } // RGB format: rgb(r,g,b) or rgba(r,g,b,a) - if (color.startsWith('rgb')) { + if (color.startsWith("rgb")) { return true; // Simplified check } diff --git a/src/validation/index.ts b/src/validation/index.ts index 26b3266..29d74f3 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -11,38 +11,38 @@ export { ValidationRule, ValidationFailureError, buildValidationResultFromMessage, -} from './validationTypes'; +} from "./validationTypes"; -export { BaseValidator } from './baseValidator'; +export { BaseValidator } from "./baseValidator"; // Individual format validators -export { ObfValidator } from './obfValidator'; -export { GridsetValidator } from './gridsetValidator'; -export { SnapValidator } from './snapValidator'; -export { TouchChatValidator } from './touchChatValidator'; -export { AstericsGridValidator } from './astericsValidator'; -export { ExcelValidator } from './excelValidator'; -export { OpmlValidator } from './opmlValidator'; -export { DotValidator } from './dotValidator'; -export { ApplePanelsValidator } from './applePanelsValidator'; -export { ObfsetValidator } from './obfsetValidator'; +export { ObfValidator } from "./obfValidator"; +export { GridsetValidator } from "./gridsetValidator"; +export { SnapValidator } from "./snapValidator"; +export { TouchChatValidator } from "./touchChatValidator"; +export { AstericsGridValidator } from "./astericsValidator"; +export { ExcelValidator } from "./excelValidator"; +export { OpmlValidator } from "./opmlValidator"; +export { DotValidator } from "./dotValidator"; +export { ApplePanelsValidator } from "./applePanelsValidator"; +export { ObfsetValidator } from "./obfsetValidator"; /** * Main validator factory * Returns the appropriate validator for a given format */ -import { ObfValidator } from './obfValidator'; -import { GridsetValidator } from './gridsetValidator'; -import { SnapValidator } from './snapValidator'; -import { TouchChatValidator } from './touchChatValidator'; -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; -import { AstericsGridValidator } from './astericsValidator'; -import { ExcelValidator } from './excelValidator'; -import { OpmlValidator } from './opmlValidator'; -import { DotValidator } from './dotValidator'; -import { ApplePanelsValidator } from './applePanelsValidator'; -import { ObfsetValidator } from './obfsetValidator'; +import { ObfValidator } from "./obfValidator"; +import { GridsetValidator } from "./gridsetValidator"; +import { SnapValidator } from "./snapValidator"; +import { TouchChatValidator } from "./touchChatValidator"; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; +import { AstericsGridValidator } from "./astericsValidator"; +import { ExcelValidator } from "./excelValidator"; +import { OpmlValidator } from "./opmlValidator"; +import { DotValidator } from "./dotValidator"; +import { ApplePanelsValidator } from "./applePanelsValidator"; +import { ObfsetValidator } from "./obfsetValidator"; import { defaultFileAdapter, FileAdapter, @@ -50,39 +50,39 @@ import { isNodeRuntime, toUint8Array, type ProcessorInput, -} from '../utils/io'; +} from "../utils/io"; export function getValidatorForFormat(format: string): BaseValidator | null { switch (format.toLowerCase()) { - case 'obf': - case 'obz': + case "obf": + case "obz": return new ObfValidator(); - case 'gridset': - case 'gridsetx': + case "gridset": + case "gridsetx": return new GridsetValidator(); - case 'snap': - case 'spb': - case 'sps': + case "snap": + case "spb": + case "sps": return new SnapValidator(); - case 'touchchat': - case 'ce': + case "touchchat": + case "ce": return new TouchChatValidator(); - case 'asterics': - case 'grd': + case "asterics": + case "grd": return new AstericsGridValidator(); - case 'excel': - case 'xlsx': - case 'xls': + case "excel": + case "xlsx": + case "xls": return new ExcelValidator(); - case 'opml': + case "opml": return new OpmlValidator(); - case 'dot': + case "dot": return new DotValidator(); - case 'applepanels': - case 'plist': - case 'ascconfig': + case "applepanels": + case "plist": + case "ascconfig": return new ApplePanelsValidator(); - case 'obfset': + case "obfset": return new ObfsetValidator(); default: return null; @@ -90,34 +90,34 @@ export function getValidatorForFormat(format: string): BaseValidator | null { } export function getValidatorForFile(filename: string): BaseValidator | null { - const ext = filename.toLowerCase().split('.').pop(); + const ext = filename.toLowerCase().split(".").pop(); if (!ext) return null; switch (ext) { - case 'obf': - case 'obz': + case "obf": + case "obz": return new ObfValidator(); - case 'gridset': - case 'gridsetx': + case "gridset": + case "gridsetx": return new GridsetValidator(); - case 'spb': - case 'sps': + case "spb": + case "sps": return new SnapValidator(); - case 'ce': + case "ce": return new TouchChatValidator(); - case 'grd': + case "grd": return new AstericsGridValidator(); - case 'xlsx': - case 'xls': + case "xlsx": + case "xls": return new ExcelValidator(); - case 'opml': + case "opml": return new OpmlValidator(); - case 'dot': + case "dot": return new DotValidator(); - case 'plist': - case 'ascconfig': + case "plist": + case "ascconfig": return new ApplePanelsValidator(); - case 'obfset': + case "obfset": return new ObfsetValidator(); default: return null; @@ -132,10 +132,11 @@ export function getValidatorForFile(filename: string): BaseValidator | null { export async function validateFileOrBuffer( filePathOrBuffer: ProcessorInput, fileAdapter?: FileAdapter, - filenameHint?: string + filenameHint?: string, ): Promise { - const isPath = typeof filePathOrBuffer === 'string'; - const name = filenameHint || (isPath ? getBasename(filePathOrBuffer) : 'upload'); + const isPath = typeof filePathOrBuffer === "string"; + const name = + filenameHint || (isPath ? getBasename(filePathOrBuffer) : "upload"); const validator = getValidatorForFile(name) || getValidatorForFormat(name); const adapter = fileAdapter ?? defaultFileAdapter; @@ -145,13 +146,15 @@ export async function validateFileOrBuffer( if (isPath) { if (!isNodeRuntime()) { - throw new Error('File path validation is only supported in Node.js environments.'); + throw new Error( + "File path validation is only supported in Node.js environments.", + ); } const ctor = validator.constructor as typeof BaseValidator & { validateFile?: (filePath: string) => Promise; }; - if (typeof ctor.validateFile === 'function') { + if (typeof ctor.validateFile === "function") { return ctor.validateFile(filePathOrBuffer); } diff --git a/src/validation/obfValidator.ts b/src/validation/obfValidator.ts index 571335e..e89b71c 100644 --- a/src/validation/obfValidator.ts +++ b/src/validation/obfValidator.ts @@ -3,18 +3,18 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ -import JSZip from 'jszip'; -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; +import JSZip from "jszip"; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from '../utils/io'; +} from "../utils/io"; -const OBF_FORMAT = 'open-board-0.1'; +const OBF_FORMAT = "open-board-0.1"; const OBF_FORMAT_CURRENT_VERSION = 0.1; /** @@ -30,9 +30,10 @@ export class ObfValidator extends BaseValidator { */ static async validateFile( filePath: string, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { - const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = + fileAdapter ?? defaultFileAdapter; const validator = new ObfValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); @@ -42,24 +43,30 @@ export class ObfValidator extends BaseValidator { /** * Check if content is OBF format */ - static async identifyFormat(content: any, filename: string): Promise { + static async identifyFormat( + content: any, + filename: string, + ): Promise { const name = filename.toLowerCase(); - if (name.endsWith('.obf') || name.endsWith('.obz')) { + if (name.endsWith(".obf") || name.endsWith(".obz")) { return true; } // Try to parse as JSON and check format try { if ( - typeof content !== 'string' && + typeof content !== "string" && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content)); + const contentStr = + typeof content === "string" + ? content + : decodeText(toUint8Array(content)); const json = JSON.parse(contentStr); - return json && json.format && json.format.startsWith('open-board-'); + return json && json.format && json.format.startsWith("open-board-"); } catch { return false; } @@ -71,12 +78,12 @@ export class ObfValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { this.reset(); // Determine if it's OBF or OBZ - const isObz = filename.toLowerCase().endsWith('.obz'); + const isObz = filename.toLowerCase().endsWith(".obz"); if (isObz) { return await this.validateObz(content, filename, filesize); @@ -91,16 +98,16 @@ export class ObfValidator extends BaseValidator { private async validateObf( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { - await this.add_check('filename', 'file name', async () => { + await this.add_check("filename", "file name", async () => { if (!filename.match(/\.obf$/)) { - this.warn('filename should end with .obf'); + this.warn("filename should end with .obf"); } }); let json: any = null; - await this.add_check('valid_json', 'JSON file', async () => { + await this.add_check("valid_json", "JSON file", async () => { try { json = JSON.parse(decodeText(content)); } catch { @@ -109,12 +116,12 @@ export class ObfValidator extends BaseValidator { }); if (!json) { - return this.buildResult(filename, filesize, 'obf'); + return this.buildResult(filename, filesize, "obf"); } await this.validateBoardStructure(json); - return this.buildResult(filename, filesize, 'obf'); + return this.buildResult(filename, filesize, "obf"); } /** @@ -123,23 +130,23 @@ export class ObfValidator extends BaseValidator { private async validateObz( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { - await this.add_check('filename', 'file name', async () => { + await this.add_check("filename", "file name", async () => { if (!filename.match(/\.obz$/)) { - this.warn('filename should end with .obz'); + this.warn("filename should end with .obz"); } }); let zip: JSZip | null = null; let validZip = false; - await this.add_check('zip', 'valid zip', async () => { + await this.add_check("zip", "valid zip", async () => { try { zip = await JSZip.loadAsync(content); validZip = true; } catch { - this.err('file is not a valid zip package'); + this.err("file is not a valid zip package"); } }); @@ -147,250 +154,288 @@ export class ObfValidator extends BaseValidator { await this.validateObzStructure(zip); } - return this.buildResult(filename, filesize, 'obz'); + return this.buildResult(filename, filesize, "obz"); } /** * Validate OBF board structure */ private async validateBoardStructure(board: any): Promise { - await this.add_check('format_version', 'format version', async () => { + await this.add_check("format_version", "format version", async () => { if (!board.format) { this.err(`format attribute is required, set to ${OBF_FORMAT}`); return; } - const version = parseFloat(board.format.split('-').pop() || '0'); + const version = parseFloat(board.format.split("-").pop() || "0"); if (version > OBF_FORMAT_CURRENT_VERSION) { this.err( - `format version (${version}) is invalid, current version is ${OBF_FORMAT_CURRENT_VERSION}` + `format version (${version}) is invalid, current version is ${OBF_FORMAT_CURRENT_VERSION}`, ); } else if (version < OBF_FORMAT_CURRENT_VERSION) { this.warn( - `format version (${version}) is old, consider updating to ${OBF_FORMAT_CURRENT_VERSION}` + `format version (${version}) is old, consider updating to ${OBF_FORMAT_CURRENT_VERSION}`, ); } }); - await this.add_check('id', 'board ID', async () => { + await this.add_check("id", "board ID", async () => { if (!board.id) { - this.err('id attribute is required'); + this.err("id attribute is required"); } }); - await this.add_check('locale', 'locale', async () => { + await this.add_check("locale", "locale", async () => { if (!board.locale) { - this.err('locale attribute is required, please set to "en" for English'); + this.err( + 'locale attribute is required, please set to "en" for English', + ); } }); - await this.add_check('extras', 'extra attributes', async () => { + await this.add_check("extras", "extra attributes", async () => { const attrs = [ - 'format', - 'id', - 'locale', - 'url', - 'data_url', - 'name', - 'description_html', - 'default_layout', - 'buttons', - 'images', - 'sounds', - 'grid', - 'license', + "format", + "id", + "locale", + "url", + "data_url", + "name", + "description_html", + "default_layout", + "buttons", + "images", + "sounds", + "grid", + "license", ]; Object.keys(board).forEach((key) => { - if (!attrs.includes(key) && !key.startsWith('ext_')) { + if (!attrs.includes(key) && !key.startsWith("ext_")) { this.warn( - `${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_` + `${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_`, ); } }); }); - await this.add_check('description', 'descriptive attributes', async () => { + await this.add_check("description", "descriptive attributes", async () => { if (!board.name) { - this.warn('name attribute is strongly recommended'); + this.warn("name attribute is strongly recommended"); } if (!board.description_html) { - this.warn('description_html attribute is recommended'); + this.warn("description_html attribute is recommended"); } }); - await this.add_check('background', 'background attribute', async () => { - if (board.background && typeof board.background !== 'object') { - this.err('background attribute must be a hash'); + await this.add_check("background", "background attribute", async () => { + if (board.background && typeof board.background !== "object") { + this.err("background attribute must be a hash"); } }); - await this.add_check('buttons', 'buttons attribute', async () => { + await this.add_check("buttons", "buttons attribute", async () => { if (!board.buttons) { - this.err('buttons attribute is required'); + this.err("buttons attribute is required"); } else if (!Array.isArray(board.buttons)) { - this.err('buttons attribute must be an array'); + this.err("buttons attribute must be an array"); } }); - await this.add_check('grid', 'grid attribute', async () => { + await this.add_check("grid", "grid attribute", async () => { if (!board.grid) { - this.err('grid attribute is required'); + this.err("grid attribute is required"); return; } - if (typeof board.grid !== 'object') { - this.err('grid attribute must be a hash'); + if (typeof board.grid !== "object") { + this.err("grid attribute must be a hash"); return; } - if (typeof board.grid.rows !== 'number' || board.grid.rows < 1) { - this.err('grid.rows must be a positive number'); + if (typeof board.grid.rows !== "number" || board.grid.rows < 1) { + this.err("grid.rows must be a positive number"); } - if (typeof board.grid.columns !== 'number' || board.grid.columns < 1) { - this.err('grid.columns must be a positive number'); + if (typeof board.grid.columns !== "number" || board.grid.columns < 1) { + this.err("grid.columns must be a positive number"); } if (!board.grid.order || !Array.isArray(board.grid.order)) { - this.err('grid.order must be an array of arrays'); + this.err("grid.order must be an array of arrays"); return; } if (board.grid.order.length !== board.grid.rows) { this.err( - `grid.order length (${board.grid.order.length}) must match grid.rows (${board.grid.rows})` + `grid.order length (${board.grid.order.length}) must match grid.rows (${board.grid.rows})`, ); } if ( - !board.grid.order.every((r: any) => Array.isArray(r) && r.length === board.grid.columns) + !board.grid.order.every( + (r: any) => Array.isArray(r) && r.length === board.grid.columns, + ) ) { this.err( - `grid.order must contain ${board.grid.rows} arrays each of size ${board.grid.columns}` + `grid.order must contain ${board.grid.rows} arrays each of size ${board.grid.columns}`, ); } }); - await this.add_check('grid_ids', 'button IDs in grid.order attribute', async () => { - const buttonIds = (board.buttons || []).map((b: any) => b.id); - const usedButtonIds: string[] = []; - if (board.grid && board.grid.order) { - board.grid.order.forEach((row: any) => { - if (Array.isArray(row)) { - row.forEach((id: any) => { - if (id !== null && id !== undefined) { - usedButtonIds.push(id); - if (!buttonIds.includes(id)) { - this.err( - `grid.order references button with id ${id} but no button with that id found in buttons attribute` - ); + await this.add_check( + "grid_ids", + "button IDs in grid.order attribute", + async () => { + const buttonIds = (board.buttons || []).map((b: any) => b.id); + const usedButtonIds: string[] = []; + if (board.grid && board.grid.order) { + board.grid.order.forEach((row: any) => { + if (Array.isArray(row)) { + row.forEach((id: any) => { + if (id !== null && id !== undefined) { + usedButtonIds.push(id); + if (!buttonIds.includes(id)) { + this.err( + `grid.order references button with id ${id} but no button with that id found in buttons attribute`, + ); + } } - } - }); - } - }); - } - if (usedButtonIds.length === 0) { - this.warn('board has no buttons defined in the grid'); - } + }); + } + }); + } + if (usedButtonIds.length === 0) { + this.warn("board has no buttons defined in the grid"); + } - const unusedIds = buttonIds.filter((id: any) => !usedButtonIds.includes(id)); - if (unusedIds.length > 0) { - this.warn( - `not all defined buttons were included in the grid order (${unusedIds.join(',')})` + const unusedIds = buttonIds.filter( + (id: any) => !usedButtonIds.includes(id), ); - } - }); + if (unusedIds.length > 0) { + this.warn( + `not all defined buttons were included in the grid order (${unusedIds.join(",")})`, + ); + } + }, + ); - await this.add_check('images', 'images attribute', async () => { + await this.add_check("images", "images attribute", async () => { if (!board.images) { - this.err('images attribute is required'); + this.err("images attribute is required"); } else if (!Array.isArray(board.images)) { - this.err('images attribute must be an array'); + this.err("images attribute must be an array"); } }); if (Array.isArray(board.images)) { for (let i = 0; i < board.images.length; i++) { const image = board.images[i]; - await this.add_check(`image[${i}]`, `image at images[${i}]`, async () => { - if (typeof image !== 'object') { - this.err('image must be a hash'); - return; - } - if (!image.id) { - this.err('image.id is required'); - } - if (!image.width || typeof image.width !== 'number' || image.width < 1) { - this.warn('image.width should be a valid positive number'); - } - if (!image.height || typeof image.height !== 'number' || image.height < 1) { - this.warn('image.height should be a valid positive number'); - } - if (!image.content_type || !image.content_type.match(/^image\/.+$/)) { - this.err('image.content_type must be a valid image mime type'); - } - if (!image.url && !image.data && !image.symbol && !image.path) { - this.err('image must have data, url, path or symbol attribute defined'); - } - - const imageAttrs = [ - 'id', - 'width', - 'height', - 'content_type', - 'data', - 'url', - 'symbol', - 'path', - 'data_url', - 'license', - ]; - Object.keys(image).forEach((key) => { - if (!imageAttrs.includes(key) && !key.startsWith('ext_')) { - this.warn( - `image.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_` + await this.add_check( + `image[${i}]`, + `image at images[${i}]`, + async () => { + if (typeof image !== "object") { + this.err("image must be a hash"); + return; + } + if (!image.id) { + this.err("image.id is required"); + } + if ( + !image.width || + typeof image.width !== "number" || + image.width < 1 + ) { + this.warn("image.width should be a valid positive number"); + } + if ( + !image.height || + typeof image.height !== "number" || + image.height < 1 + ) { + this.warn("image.height should be a valid positive number"); + } + if ( + !image.content_type || + !image.content_type.match(/^image\/.+$/) + ) { + this.err("image.content_type must be a valid image mime type"); + } + if (!image.url && !image.data && !image.symbol && !image.path) { + this.err( + "image must have data, url, path or symbol attribute defined", ); } - }); - }); + + const imageAttrs = [ + "id", + "width", + "height", + "content_type", + "data", + "url", + "symbol", + "path", + "data_url", + "license", + ]; + Object.keys(image).forEach((key) => { + if (!imageAttrs.includes(key) && !key.startsWith("ext_")) { + this.warn( + `image.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_`, + ); + } + }); + }, + ); } } - await this.add_check('sounds', 'sounds attribute', async () => { + await this.add_check("sounds", "sounds attribute", async () => { if (!board.sounds) { - this.err('sounds attribute is required'); + this.err("sounds attribute is required"); } else if (!Array.isArray(board.sounds)) { - this.err('sounds attribute must be an array'); + this.err("sounds attribute must be an array"); } }); if (Array.isArray(board.sounds)) { for (let i = 0; i < board.sounds.length; i++) { const sound = board.sounds[i]; - await this.add_check(`sounds[${i}]`, `sound at sounds[${i}]`, async () => { - if (typeof sound !== 'object') { - this.err('sound must be a hash'); - return; - } - if (!sound.id) { - this.err('sound.id is required'); - } - if ( - sound.duration !== undefined && - (typeof sound.duration !== 'number' || sound.duration < 0) - ) { - this.err('sound.duration must be a valid positive number'); - } - if (!sound.content_type || !sound.content_type.match(/^audio\/.+$/)) { - this.err('sound.content_type must be a valid audio mime type'); - } - if (!sound.url && !sound.data && !sound.path) { - this.err('sound must have data, url, or path attribute defined'); - } - }); + await this.add_check( + `sounds[${i}]`, + `sound at sounds[${i}]`, + async () => { + if (typeof sound !== "object") { + this.err("sound must be a hash"); + return; + } + if (!sound.id) { + this.err("sound.id is required"); + } + if ( + sound.duration !== undefined && + (typeof sound.duration !== "number" || sound.duration < 0) + ) { + this.err("sound.duration must be a valid positive number"); + } + if ( + !sound.content_type || + !sound.content_type.match(/^audio\/.+$/) + ) { + this.err("sound.content_type must be a valid audio mime type"); + } + if (!sound.url && !sound.data && !sound.path) { + this.err("sound must have data, url, or path attribute defined"); + } + }, + ); } } if (Array.isArray(board.buttons)) { for (let i = 0; i < board.buttons.length; i++) { const button = board.buttons[i]; - await this.add_check(`buttons[${i}]`, `button at buttons[${i}]`, async () => { - await this.validateButton(button); - }); + await this.add_check( + `buttons[${i}]`, + `button at buttons[${i}]`, + async () => { + await this.validateButton(button); + }, + ); } } } @@ -399,72 +444,81 @@ export class ObfValidator extends BaseValidator { * Validate a single button */ private async validateButton(button: any): Promise { - if (typeof button !== 'object') { - this.err('button must be a hash'); + if (typeof button !== "object") { + this.err("button must be a hash"); return; } if (!button.id) { - this.err('button.id is required'); + this.err("button.id is required"); } if (!button.label) { - this.err('button.label is required'); + this.err("button.label is required"); } - ['top', 'left', 'width', 'height'].forEach((attr) => { - if (button[attr] !== undefined && (typeof button[attr] !== 'number' || button[attr] < 0)) { + ["top", "left", "width", "height"].forEach((attr) => { + if ( + button[attr] !== undefined && + (typeof button[attr] !== "number" || button[attr] < 0) + ) { this.warn(`button.${attr} should be a positive number`); } }); - ['background_color', 'border_color'].forEach((color) => { + ["background_color", "border_color"].forEach((color) => { if (button[color]) { if ( - !button[color].match(/^\s*rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(,\s*[01]?\.?\d*)?\)\s*/) + !button[color].match( + /^\s*rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(,\s*[01]?\.?\d*)?\)\s*/, + ) ) { this.err( - `button.${color} must be a valid rgb or rgba value if defined ("${button[color]}" is invalid)` + `button.${color} must be a valid rgb or rgba value if defined ("${button[color]}" is invalid)`, ); } } }); - if (button.hidden !== undefined && typeof button.hidden !== 'boolean') { - this.err('button.hidden must be a boolean if defined'); + if (button.hidden !== undefined && typeof button.hidden !== "boolean") { + this.err("button.hidden must be a boolean if defined"); } if (!button.image_id) { - this.warn('button.image_id is recommended'); + this.warn("button.image_id is recommended"); } - if (button.action && typeof button.action === 'string' && !button.action.match(/^(:|\+)/)) { - this.err('button.action must start with either : or + if defined'); + if ( + button.action && + typeof button.action === "string" && + !button.action.match(/^(:|\+)/) + ) { + this.err("button.action must start with either : or + if defined"); } if (button.actions && !Array.isArray(button.actions)) { - this.err('button.actions must be an array of strings'); + this.err("button.actions must be an array of strings"); } const buttonAttrs = [ - 'id', - 'label', - 'vocalization', - 'image_id', - 'sound_id', - 'hidden', - 'background_color', - 'border_color', - 'action', - 'actions', - 'load_board', - 'top', - 'left', - 'width', - 'height', + "id", + "label", + "vocalization", + "image_id", + "sound_id", + "hidden", + "background_color", + "border_color", + "action", + "actions", + "load_board", + "top", + "left", + "width", + "height", ]; Object.keys(button).forEach((key) => { - if (!buttonAttrs.includes(key) && !key.startsWith('ext_')) { + if (!buttonAttrs.includes(key) && !key.startsWith("ext_")) { this.warn( - `button.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_` + `button.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_`, ); } }); @@ -476,22 +530,22 @@ export class ObfValidator extends BaseValidator { private async validateObzStructure(zip: JSZip): Promise { let json: any = null; - await this.add_check('manifest', 'manifest.json', async () => { - const manifestFile = zip.file('manifest.json'); + await this.add_check("manifest", "manifest.json", async () => { + const manifestFile = zip.file("manifest.json"); if (!manifestFile) { - this.err('manifest.json is required in the zip package'); + this.err("manifest.json is required in the zip package"); return; } try { - const manifestStr = await manifestFile.async('string'); + const manifestStr = await manifestFile.async("string"); json = JSON.parse(manifestStr); } catch { json = null; } if (!json) { - this.err('manifest.json must contain a valid JSON structure'); + this.err("manifest.json must contain a valid JSON structure"); } }); @@ -504,60 +558,79 @@ export class ObfValidator extends BaseValidator { * Validate manifest structure */ private async validateManifest(manifest: any, zip: JSZip): Promise { - await this.add_check('manifest_format', 'manifest.json format version', async () => { - if (!manifest.format) { - this.err(`format attribute is required, set to ${OBF_FORMAT}`); - return; - } - const version = parseFloat(manifest.format.split('-').pop()); - if (version > OBF_FORMAT_CURRENT_VERSION) { - this.err( - `format version (${version}) is invalid, current version is ${OBF_FORMAT_CURRENT_VERSION}` - ); - } else if (version < OBF_FORMAT_CURRENT_VERSION) { - this.warn( - `format version (${version}) is old, consider updating to ${OBF_FORMAT_CURRENT_VERSION}` - ); - } - }); - - await this.add_check('manifest_root', 'manifest.json root attribute', async () => { - if (!manifest.root) { - this.err('root attribute is required'); - } - if (!zip.file(manifest.root)) { - this.err('root attribute must reference a file in the package'); - } - }); - - await this.add_check('manifest_paths', 'manifest.json paths attribute', async () => { - if (!manifest.paths || typeof manifest.paths !== 'object') { - this.err('paths attribute must be a valid hash'); - } - if (!manifest.paths.boards || typeof manifest.paths.boards !== 'object') { - this.err('paths.boards must be a valid hash'); - } - }); - - await this.add_check('manifest_extras', 'manifest.json extra attributes', async () => { - const attrs = ['format', 'root', 'paths']; - Object.keys(manifest).forEach((key) => { - if (!attrs.includes(key) && !key.startsWith('ext_')) { - this.warn( - `${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_` - ); + await this.add_check( + "manifest_format", + "manifest.json format version", + async () => { + if (!manifest.format) { + this.err(`format attribute is required, set to ${OBF_FORMAT}`); + return; } - }); - - const pathAttrs = ['boards', 'images', 'sounds']; - Object.keys(manifest.paths || {}).forEach((key) => { - if (!pathAttrs.includes(key) && !key.startsWith('ext_')) { + const version = parseFloat(manifest.format.split("-").pop()); + if (version > OBF_FORMAT_CURRENT_VERSION) { + this.err( + `format version (${version}) is invalid, current version is ${OBF_FORMAT_CURRENT_VERSION}`, + ); + } else if (version < OBF_FORMAT_CURRENT_VERSION) { this.warn( - `paths.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_` + `format version (${version}) is old, consider updating to ${OBF_FORMAT_CURRENT_VERSION}`, ); } - }); - }); + }, + ); + + await this.add_check( + "manifest_root", + "manifest.json root attribute", + async () => { + if (!manifest.root) { + this.err("root attribute is required"); + } + if (!zip.file(manifest.root)) { + this.err("root attribute must reference a file in the package"); + } + }, + ); + + await this.add_check( + "manifest_paths", + "manifest.json paths attribute", + async () => { + if (!manifest.paths || typeof manifest.paths !== "object") { + this.err("paths attribute must be a valid hash"); + } + if ( + !manifest.paths.boards || + typeof manifest.paths.boards !== "object" + ) { + this.err("paths.boards must be a valid hash"); + } + }, + ); + + await this.add_check( + "manifest_extras", + "manifest.json extra attributes", + async () => { + const attrs = ["format", "root", "paths"]; + Object.keys(manifest).forEach((key) => { + if (!attrs.includes(key) && !key.startsWith("ext_")) { + this.warn( + `${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_`, + ); + } + }); + + const pathAttrs = ["boards", "images", "sounds"]; + Object.keys(manifest.paths || {}).forEach((key) => { + if (!pathAttrs.includes(key) && !key.startsWith("ext_")) { + this.warn( + `paths.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_`, + ); + } + }); + }, + ); // Validate boards referenced in manifest if (manifest.paths && manifest.paths.boards) { @@ -568,22 +641,24 @@ export class ObfValidator extends BaseValidator { async () => { const bFile = zip.file(boardPath as string); if (!bFile) { - this.err(`board path (${boardPath}) not found in the zip package`); + this.err( + `board path (${boardPath}) not found in the zip package`, + ); return; } try { - const boardStr = await bFile.async('string'); + const boardStr = await bFile.async("string"); const boardJson = JSON.parse(boardStr); if (!boardJson || boardJson.id !== id) { - const boardId = (boardJson && boardJson.id) || 'null'; + const boardId = (boardJson && boardJson.id) || "null"; this.err( - `board at path (${boardPath}) defined in manifest with id "${id}" but actually has id "${boardId}"` + `board at path (${boardPath}) defined in manifest with id "${id}" but actually has id "${boardId}"`, ); } } catch { this.err(`could not parse board at path (${boardPath})`); } - } + }, ); } } @@ -598,7 +673,7 @@ export class ObfValidator extends BaseValidator { if (!zip.file(imgPath as string)) { this.err(`image path (${imgPath}) not found in the zip package`); } - } + }, ); } } @@ -611,9 +686,11 @@ export class ObfValidator extends BaseValidator { `manifest.json path.sounds.${id}`, async () => { if (!zip.file(soundPath as string)) { - this.err(`sound path (${soundPath}) not found in the zip package`); + this.err( + `sound path (${soundPath}) not found in the zip package`, + ); } - } + }, ); } } diff --git a/src/validation/obfsetValidator.ts b/src/validation/obfsetValidator.ts index 203d215..9c2863d 100644 --- a/src/validation/obfsetValidator.ts +++ b/src/validation/obfsetValidator.ts @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/require-await */ -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from '../utils/io'; +} from "../utils/io"; /** * Validator for OBF set bundles (.obfset) - JSON arrays of boards @@ -15,28 +15,35 @@ import { export class ObfsetValidator extends BaseValidator { static async validateFile( filePath: string, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { - const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = + fileAdapter ?? defaultFileAdapter; const validator = new ObfsetValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); return validator.validate(content, getBasename(filePath), size); } - static async identifyFormat(content: any, filename: string): Promise { + static async identifyFormat( + content: any, + filename: string, + ): Promise { const name = filename.toLowerCase(); - if (name.endsWith('.obfset')) return true; + if (name.endsWith(".obfset")) return true; try { if ( - typeof content !== 'string' && + typeof content !== "string" && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const str = typeof content === 'string' ? content : decodeText(toUint8Array(content)); + const str = + typeof content === "string" + ? content + : decodeText(toUint8Array(content)); const parsed = JSON.parse(str); return Array.isArray(parsed); } catch { @@ -47,23 +54,23 @@ export class ObfsetValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { this.reset(); - await this.add_check('filename', 'file extension', async () => { - if (!filename.toLowerCase().endsWith('.obfset')) { - this.warn('filename should end with .obfset'); + await this.add_check("filename", "file extension", async () => { + if (!filename.toLowerCase().endsWith(".obfset")) { + this.warn("filename should end with .obfset"); } }); let boards: any[] | null = null; - await this.add_check('json_parse', 'valid JSON array', async () => { + await this.add_check("json_parse", "valid JSON array", async () => { try { const str = decodeText(content); const parsed = JSON.parse(str); if (!Array.isArray(parsed)) { - this.err('root must be a JSON array of boards', true); + this.err("root must be a JSON array of boards", true); } else { boards = parsed; } @@ -73,7 +80,7 @@ export class ObfsetValidator extends BaseValidator { }); if (!boards) { - return this.buildResult(filename, filesize, 'obfset'); + return this.buildResult(filename, filesize, "obfset"); } const safeBoards = boards as any[]; @@ -81,22 +88,26 @@ export class ObfsetValidator extends BaseValidator { const prefix = `board[${idx}]`; this.add_check_sync(`${prefix}_id`, `${prefix} id`, () => { if (!board?.id) { - this.err('board is missing id'); + this.err("board is missing id"); } }); this.add_check_sync(`${prefix}_buttons`, `${prefix} buttons`, () => { if (!Array.isArray(board?.buttons)) { - this.warn('board has no buttons array'); + this.warn("board has no buttons array"); } }); this.add_check_sync(`${prefix}_grid`, `${prefix} grid definition`, () => { const grid = board?.grid; - if (!grid || typeof grid.rows !== 'number' || typeof grid.columns !== 'number') { - this.warn('grid rows/columns missing; layout may be invalid'); + if ( + !grid || + typeof grid.rows !== "number" || + typeof grid.columns !== "number" + ) { + this.warn("grid rows/columns missing; layout may be invalid"); } }); }); - return this.buildResult(filename, filesize, 'obfset'); + return this.buildResult(filename, filesize, "obfset"); } } diff --git a/src/validation/opmlValidator.ts b/src/validation/opmlValidator.ts index e029df5..80f3947 100644 --- a/src/validation/opmlValidator.ts +++ b/src/validation/opmlValidator.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/require-await */ -import { XMLParser, XMLValidator } from 'fast-xml-parser'; -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; +import { XMLParser, XMLValidator } from "fast-xml-parser"; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from '../utils/io'; +} from "../utils/io"; /** * Validator for OPML files @@ -16,30 +16,37 @@ import { export class OpmlValidator extends BaseValidator { static async validateFile( filePath: string, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { - const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = + fileAdapter ?? defaultFileAdapter; const validator = new OpmlValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); return validator.validate(content, getBasename(filePath), size); } - static async identifyFormat(content: any, filename: string): Promise { + static async identifyFormat( + content: any, + filename: string, + ): Promise { const name = filename.toLowerCase(); - if (name.endsWith('.opml')) { + if (name.endsWith(".opml")) { return true; } try { if ( - typeof content !== 'string' && + typeof content !== "string" && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const str = typeof content === 'string' ? content : decodeText(toUint8Array(content)); + const str = + typeof content === "string" + ? content + : decodeText(toUint8Array(content)); const validation = XMLValidator.validate(str); if (validation !== true) { return false; @@ -55,38 +62,38 @@ export class OpmlValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { this.reset(); const parser = new XMLParser({ ignoreAttributes: false }); - await this.add_check('filename', 'file extension', async () => { - if (!filename.toLowerCase().endsWith('.opml')) { - this.warn('filename should end with .opml'); + await this.add_check("filename", "file extension", async () => { + if (!filename.toLowerCase().endsWith(".opml")) { + this.warn("filename should end with .opml"); } }); - let text = ''; - await this.add_check('content', 'non-empty content', async () => { + let text = ""; + await this.add_check("content", "non-empty content", async () => { text = decodeText(content); if (!text.trim()) { - this.err('OPML file is empty', true); + this.err("OPML file is empty", true); } }); - await this.add_check('xml', 'valid XML', async () => { + await this.add_check("xml", "valid XML", async () => { const validation = XMLValidator.validate(text); if (validation !== true) { - const msg = String((validation as any)?.err?.msg || 'Invalid OPML XML'); + const msg = String((validation as any)?.err?.msg || "Invalid OPML XML"); this.err(msg, true); } }); let parsed: any = null; - await this.add_check('structure', 'outline structure', async () => { + await this.add_check("structure", "outline structure", async () => { parsed = parser.parse(text); if (!parsed?.opml?.body?.outline) { - this.err('missing body.outline', true); + this.err("missing body.outline", true); } }); @@ -94,18 +101,21 @@ export class OpmlValidator extends BaseValidator { const outlines = Array.isArray(parsed.opml.body.outline) ? parsed.opml.body.outline : [parsed.opml.body.outline]; - await this.add_check('outline_nodes', 'outline nodes', async () => { + await this.add_check("outline_nodes", "outline nodes", async () => { const hasText = outlines.some((node: any) => { const textValue = - node?.['@_text'] || node?._attributes?.text || node?.text || node?.['@_title']; - return typeof textValue === 'string' && textValue.trim().length > 0; + node?.["@_text"] || + node?._attributes?.text || + node?.text || + node?.["@_title"]; + return typeof textValue === "string" && textValue.trim().length > 0; }); if (!hasText) { - this.err('outline nodes missing text attributes'); + this.err("outline nodes missing text attributes"); } }); } - return this.buildResult(filename, filesize, 'opml'); + return this.buildResult(filename, filesize, "opml"); } } diff --git a/src/validation/snapValidator.ts b/src/validation/snapValidator.ts index a43a08c..0dedbd1 100644 --- a/src/validation/snapValidator.ts +++ b/src/validation/snapValidator.ts @@ -1,11 +1,16 @@ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -import * as xml2js from 'xml2js'; -import JSZip from 'jszip'; -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; -import { defaultFileAdapter, FileAdapter, getBasename, toUint8Array } from '../utils/io'; -import { openSqliteDatabase } from '../utils/sqlite'; +import * as xml2js from "xml2js"; +import JSZip from "jszip"; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; +import { + defaultFileAdapter, + FileAdapter, + getBasename, + toUint8Array, +} from "../utils/io"; +import { openSqliteDatabase } from "../utils/sqlite"; /** * Validator for Snap files (.spb, .sps) @@ -21,9 +26,10 @@ export class SnapValidator extends BaseValidator { */ static async validateFile( filePath: string, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { - const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = + fileAdapter ?? defaultFileAdapter; const validator = new SnapValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); @@ -34,9 +40,12 @@ export class SnapValidator extends BaseValidator { * Check if content is Snap format */ // eslint-disable-next-line @typescript-eslint/require-await - static async identifyFormat(content: any, filename: string): Promise { + static async identifyFormat( + content: any, + filename: string, + ): Promise { const name = filename.toLowerCase(); - if (name.endsWith('.spb') || name.endsWith('.sps')) { + if (name.endsWith(".spb") || name.endsWith(".sps")) { return true; } @@ -45,7 +54,8 @@ export class SnapValidator extends BaseValidator { const zip = await JSZip.loadAsync(toUint8Array(content)); const entries = Object.values(zip.files).filter((entry) => !entry.dir); return entries.some( - (entry) => entry.name.includes('settings') || entry.name.includes('.xml') + (entry) => + entry.name.includes("settings") || entry.name.includes(".xml"), ); } catch { return false; @@ -58,25 +68,25 @@ export class SnapValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { this.reset(); - await this.add_check('filename', 'file extension', async () => { + await this.add_check("filename", "file extension", async () => { if (!filename.match(/\.(spb|sps)$/)) { - this.warn('filename should end with .spb or .sps'); + this.warn("filename should end with .spb or .sps"); } }); if (this.isSQLiteBuffer(content)) { await this.validateSqliteStructure(content, filename); - return this.buildResult(filename, filesize, 'snap'); + return this.buildResult(filename, filesize, "snap"); } let zip: JSZip | null = null; let validZip = false; - await this.add_check('zip', 'valid zip package', async () => { + await this.add_check("zip", "valid zip package", async () => { try { zip = await JSZip.loadAsync(toUint8Array(content)); const entries = Object.values(zip.files); @@ -87,39 +97,50 @@ export class SnapValidator extends BaseValidator { }); if (!validZip || !zip) { - return this.buildResult(filename, filesize, 'snap'); + return this.buildResult(filename, filesize, "snap"); } await this.validateSnapStructure(zip, filename); - return this.buildResult(filename, filesize, 'snap'); + return this.buildResult(filename, filesize, "snap"); } /** * Validate Snap package structure */ - private async validateSnapStructure(zip: JSZip, _filename: string): Promise { + private async validateSnapStructure( + zip: JSZip, + _filename: string, + ): Promise { // Check for required files - await this.add_check('required_files', 'required package files', async () => { - const entries = Object.values(zip.files); - const entryNames = entries.map((e) => e.name); - - // Look for common Snap files - const hasSettings = entryNames.some((n) => n.toLowerCase().includes('settings')); - const hasXml = entryNames.some((n) => n.toLowerCase().endsWith('.xml')); - - if (!hasSettings && !hasXml) { - this.err('Snap package must contain settings.xml or similar configuration file'); - } + await this.add_check( + "required_files", + "required package files", + async () => { + const entries = Object.values(zip.files); + const entryNames = entries.map((e) => e.name); + + // Look for common Snap files + const hasSettings = entryNames.some((n) => + n.toLowerCase().includes("settings"), + ); + const hasXml = entryNames.some((n) => n.toLowerCase().endsWith(".xml")); + + if (!hasSettings && !hasXml) { + this.err( + "Snap package must contain settings.xml or similar configuration file", + ); + } - if (entries.length === 0) { - this.err('Snap package is empty'); - } - }); + if (entries.length === 0) { + this.err("Snap package is empty"); + } + }, + ); // Try to parse and validate the main settings file const settingsEntry = Object.values(zip.files).find( - (entry) => !entry.dir && entry.name.toLowerCase().includes('settings') + (entry) => !entry.dir && entry.name.toLowerCase().includes("settings"), ); if (settingsEntry) { @@ -128,12 +149,12 @@ export class SnapValidator extends BaseValidator { // Check for pages const pageEntries = Object.values(zip.files).filter( - (entry) => !entry.dir && entry.name.toLowerCase().includes('page') + (entry) => !entry.dir && entry.name.toLowerCase().includes("page"), ); - await this.add_check('pages', 'pages in package', async () => { + await this.add_check("pages", "pages in package", async () => { if (pageEntries.length === 0) { - this.warn('Snap package should contain at least one page file'); + this.warn("Snap package should contain at least one page file"); } }); @@ -145,21 +166,24 @@ export class SnapValidator extends BaseValidator { // Check for images const imageEntries = Object.values(zip.files).filter( - (entry) => !entry.dir && entry.name.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp)$/i) + (entry) => + !entry.dir && + entry.name.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp)$/i), ); - await this.add_check('images', 'image files', async () => { + await this.add_check("images", "image files", async () => { if (imageEntries.length === 0) { - this.warn('Snap package should contain image files for buttons'); + this.warn("Snap package should contain image files for buttons"); } }); // Check for audio files const audioEntries = Object.values(zip.files).filter( - (entry) => !entry.dir && entry.name.toLowerCase().match(/\.(wav|mp3|m4a|ogg)$/i) + (entry) => + !entry.dir && entry.name.toLowerCase().match(/\.(wav|mp3|m4a|ogg)$/i), ); - await this.add_check('audio', 'audio files', async () => { + await this.add_check("audio", "audio files", async () => { // Audio files are optional, so just warn if missing if (audioEntries.length === 0) { // This is informational, not a warning @@ -167,28 +191,39 @@ export class SnapValidator extends BaseValidator { }); // Check for unexpected files - await this.add_check('unexpected_files', 'unexpected file types', async () => { - const entries = Object.values(zip.files).filter((entry) => !entry.dir); - const unexpectedFiles = entries.filter((entry) => { - const name = entry.name.toLowerCase(); - // Skip common system files and directories - if (name.startsWith('__macosx') || name.startsWith('.ds_store')) { - return false; - } - // Allowed file types - return !name.match(/\.(xml|png|jpg|jpeg|gif|bmp|wav|mp3|m4a|ogg|json)$/i); - }); + await this.add_check( + "unexpected_files", + "unexpected file types", + async () => { + const entries = Object.values(zip.files).filter((entry) => !entry.dir); + const unexpectedFiles = entries.filter((entry) => { + const name = entry.name.toLowerCase(); + // Skip common system files and directories + if (name.startsWith("__macosx") || name.startsWith(".ds_store")) { + return false; + } + // Allowed file types + return !name.match( + /\.(xml|png|jpg|jpeg|gif|bmp|wav|mp3|m4a|ogg|json)$/i, + ); + }); - if (unexpectedFiles.length > 0) { - const unexpectedNames = unexpectedFiles.map((f) => f.name).slice(0, 5); - this.warn(`Package contains unexpected file types: ${unexpectedNames.join(', ')}`); - } - }); + if (unexpectedFiles.length > 0) { + const unexpectedNames = unexpectedFiles + .map((f) => f.name) + .slice(0, 5); + this.warn( + `Package contains unexpected file types: ${unexpectedNames.join(", ")}`, + ); + } + }, + ); } private isSQLiteBuffer(content: Buffer | Uint8Array): boolean { - const header = 'SQLite format 3\u0000'; - const bytes = content instanceof Uint8Array ? content : new Uint8Array(content); + const header = "SQLite format 3\u0000"; + const bytes = + content instanceof Uint8Array ? content : new Uint8Array(content); if (bytes.length < header.length) { return false; } @@ -202,9 +237,9 @@ export class SnapValidator extends BaseValidator { private async validateSqliteStructure( content: Buffer | Uint8Array, - _filename: string + _filename: string, ): Promise { - await this.add_check('sqlite', 'valid SQLite database', async () => { + await this.add_check("sqlite", "valid SQLite database", async () => { let cleanup: (() => Promise) | undefined; try { const result = await openSqliteDatabase(content, { @@ -215,50 +250,65 @@ export class SnapValidator extends BaseValidator { cleanup = result.cleanup; const tableRows = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", + ) .all() as Array<{ name: string }>; const tables = new Set(tableRows.map((row) => row.name)); const requiredTables = [ - 'Page', - 'Button', - 'ElementReference', - 'ElementPlacement', - 'PageSetProperties', + "Page", + "Button", + "ElementReference", + "ElementPlacement", + "PageSetProperties", ]; const missingTables = requiredTables.filter((t) => !tables.has(t)); if (missingTables.length > 0) { - this.err(`Missing required Snap tables: ${missingTables.join(', ')}`); + this.err(`Missing required Snap tables: ${missingTables.join(", ")}`); } - const pageColumns = db.prepare('PRAGMA table_info(Page)').all() as Array<{ name: string }>; + const pageColumns = db + .prepare("PRAGMA table_info(Page)") + .all() as Array<{ name: string }>; const pageColumnNames = new Set(pageColumns.map((c) => c.name)); - if (!pageColumnNames.has('UniqueId')) { - this.err('Page table missing UniqueId column'); + if (!pageColumnNames.has("UniqueId")) { + this.err("Page table missing UniqueId column"); } - if (!pageColumnNames.has('Name') && !pageColumnNames.has('Title')) { - this.err('Page table missing Name/Title columns'); + if (!pageColumnNames.has("Name") && !pageColumnNames.has("Title")) { + this.err("Page table missing Name/Title columns"); } - const buttonColumns = db.prepare('PRAGMA table_info(Button)').all() as Array<{ + const buttonColumns = db + .prepare("PRAGMA table_info(Button)") + .all() as Array<{ name: string; }>; const buttonColumnNames = new Set(buttonColumns.map((c) => c.name)); - if (!buttonColumnNames.has('Label') && !buttonColumnNames.has('Message')) { - this.err('Button table missing Label/Message columns'); + if ( + !buttonColumnNames.has("Label") && + !buttonColumnNames.has("Message") + ) { + this.err("Button table missing Label/Message columns"); } - const pageCount = db.prepare('SELECT COUNT(*) as c FROM Page').get() as { c: number }; + const pageCount = db + .prepare("SELECT COUNT(*) as c FROM Page") + .get() as { c: number }; if (!pageCount || pageCount.c === 0) { - this.warn('Snap database has no pages'); + this.warn("Snap database has no pages"); } - if (tables.has('PageSetData')) { - const dataCount = db.prepare('SELECT COUNT(*) as c FROM PageSetData').get() as { + if (tables.has("PageSetData")) { + const dataCount = db + .prepare("SELECT COUNT(*) as c FROM PageSetData") + .get() as { c: number; }; if (!dataCount || dataCount.c === 0) { - this.warn('Snap database has no PageSetData assets (images/audio may be missing)'); + this.warn( + "Snap database has no PageSetData assets (images/audio may be missing)", + ); } } } catch (e: any) { @@ -275,63 +325,74 @@ export class SnapValidator extends BaseValidator { * Validate the main settings file */ private async validateSettingsFile(entry: JSZip.JSZipObject): Promise { - await this.add_check('settings_format', 'settings file format', async () => { - try { - const content = await entry.async('string'); - const parser = new xml2js.Parser(); - const xml = await parser.parseStringPromise(content); - - // Check for expected root element - if (!xml.settings && !xml.Settings && !xml.page && !xml.Page) { - this.warn('settings file does not contain expected root element'); - } + await this.add_check( + "settings_format", + "settings file format", + async () => { + try { + const content = await entry.async("string"); + const parser = new xml2js.Parser(); + const xml = await parser.parseStringPromise(content); + + // Check for expected root element + if (!xml.settings && !xml.Settings && !xml.page && !xml.Page) { + this.warn("settings file does not contain expected root element"); + } - // Check for required settings attributes if present - const settings = xml.settings || xml.Settings; - if (settings) { - const id = settings.$?.id || settings.$?.Id; - const name = settings.$?.name || settings.$?.Name; + // Check for required settings attributes if present + const settings = xml.settings || xml.Settings; + if (settings) { + const id = settings.$?.id || settings.$?.Id; + const name = settings.$?.name || settings.$?.Name; - if (!id && !name) { - this.warn('settings should have an id or name attribute'); + if (!id && !name) { + this.warn("settings should have an id or name attribute"); + } } + } catch (e: any) { + this.err(`Failed to parse settings file: ${e.message}`); } - } catch (e: any) { - this.err(`Failed to parse settings file: ${e.message}`); - } - }); + }, + ); } /** * Validate a page file */ - private async validatePageFile(entry: JSZip.JSZipObject, index: number): Promise { - await this.add_check(`page[${index}]`, `page file ${index}: ${entry.name}`, async () => { - try { - const content = await entry.async('string'); - const parser = new xml2js.Parser(); - const xml = await parser.parseStringPromise(content); - - const page = xml.page || xml.Page; - if (!page) { - this.err(`Page file ${entry.name} does not contain a page element`); - return; - } + private async validatePageFile( + entry: JSZip.JSZipObject, + index: number, + ): Promise { + await this.add_check( + `page[${index}]`, + `page file ${index}: ${entry.name}`, + async () => { + try { + const content = await entry.async("string"); + const parser = new xml2js.Parser(); + const xml = await parser.parseStringPromise(content); + + const page = xml.page || xml.Page; + if (!page) { + this.err(`Page file ${entry.name} does not contain a page element`); + return; + } - // Check page attributes - const pageId = page.$?.id || page.$?.Id; - if (!pageId) { - this.warn(`Page ${entry.name} is missing an id attribute`); - } + // Check page attributes + const pageId = page.$?.id || page.$?.Id; + if (!pageId) { + this.warn(`Page ${entry.name} is missing an id attribute`); + } - // Check for cells/buttons - const cells = page.cells || page.Cells || page.button || page.Button; - if (!cells || (Array.isArray(cells) && cells.length === 0)) { - this.warn(`Page ${entry.name} has no cells or buttons`); + // Check for cells/buttons + const cells = page.cells || page.Cells || page.button || page.Button; + if (!cells || (Array.isArray(cells) && cells.length === 0)) { + this.warn(`Page ${entry.name} has no cells or buttons`); + } + } catch (e: any) { + this.err(`Failed to parse page file ${entry.name}: ${e.message}`); } - } catch (e: any) { - this.err(`Failed to parse page file ${entry.name}: ${e.message}`); - } - }); + }, + ); } } diff --git a/src/validation/touchChatValidator.ts b/src/validation/touchChatValidator.ts index 5835178..b36854f 100644 --- a/src/validation/touchChatValidator.ts +++ b/src/validation/touchChatValidator.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-return */ -import * as xml2js from 'xml2js'; -import { BaseValidator } from './baseValidator'; -import { ValidationResult } from './validationTypes'; +import * as xml2js from "xml2js"; +import { BaseValidator } from "./baseValidator"; +import { ValidationResult } from "./validationTypes"; import { decodeText, defaultFileAdapter, @@ -11,9 +11,9 @@ import { getBasename, type ProcessorInput, toUint8Array, -} from '../utils/io'; -import { openSqliteDatabase } from '../utils/sqlite'; -import { getZipAdapter, ZipAdapter } from '../utils/zip'; +} from "../utils/io"; +import { openSqliteDatabase } from "../utils/sqlite"; +import { getZipAdapter, ZipAdapter } from "../utils/zip"; /** * Validator for TouchChat files (.ce) @@ -30,9 +30,10 @@ export class TouchChatValidator extends BaseValidator { */ static async validateFile( filePath: string, - fileAdapter?: FileAdapter + fileAdapter?: FileAdapter, ): Promise { - const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = + fileAdapter ?? defaultFileAdapter; const validator = new TouchChatValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); @@ -46,10 +47,10 @@ export class TouchChatValidator extends BaseValidator { content: any, filename: string, fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise + zipAdapter?: (input: ProcessorInput) => Promise, ): Promise { const name = filename.toLowerCase(); - if (name.endsWith('.ce')) { + if (name.endsWith(".ce")) { return true; } @@ -59,7 +60,7 @@ export class TouchChatValidator extends BaseValidator { ? await zipAdapter(content) : await getZipAdapter(content, fileAdapter); const entries = zip.listFiles(); - if (entries.some((entry) => entry.toLowerCase().endsWith('.c4v'))) { + if (entries.some((entry) => entry.toLowerCase().endsWith(".c4v"))) { return true; } } catch { @@ -68,11 +69,17 @@ export class TouchChatValidator extends BaseValidator { // Try to parse as XML and check for TouchChat structure try { - const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content)); + const contentStr = + typeof content === "string" + ? content + : decodeText(toUint8Array(content)); const parser = new xml2js.Parser(); const result = await parser.parseStringPromise(contentStr); // TouchChat files typically have specific structure - return result && (result.PageSet || result.Pageset || result.page || result.Page); + return ( + result && + (result.PageSet || result.Pageset || result.page || result.Page) + ); } catch { return false; } @@ -84,13 +91,13 @@ export class TouchChatValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number + filesize: number, ): Promise { this.reset(); - await this.add_check('filename', 'file extension', async () => { + await this.add_check("filename", "file extension", async () => { if (!filename.match(/\.ce$/i)) { - this.warn('filename should end with .ce'); + this.warn("filename should end with .ce"); } }); @@ -100,11 +107,11 @@ export class TouchChatValidator extends BaseValidator { : await this.tryValidateZipSqlite( content, this._options.fileAdapter, - this._options.zipAdapter + this._options.zipAdapter, ); if (!zipped) { let xmlObj: any = null; - await this.add_check('xml_parse', 'valid XML', async () => { + await this.add_check("xml_parse", "valid XML", async () => { try { const parser = new xml2js.Parser(); const contentStr = decodeText(content); @@ -115,23 +122,27 @@ export class TouchChatValidator extends BaseValidator { }); if (!xmlObj) { - return this.buildResult(filename, filesize, 'touchchat'); + return this.buildResult(filename, filesize, "touchchat"); } - await this.add_check('xml_structure', 'TouchChat root element', async () => { - // TouchChat can have different root elements - const hasValidRoot = - xmlObj.PageSet || - xmlObj.Pageset || - xmlObj.page || - xmlObj.Page || - xmlObj.pages || - xmlObj.Pages; - - if (!hasValidRoot) { - this.err('file does not contain a recognized TouchChat structure'); - } - }); + await this.add_check( + "xml_structure", + "TouchChat root element", + async () => { + // TouchChat can have different root elements + const hasValidRoot = + xmlObj.PageSet || + xmlObj.Pageset || + xmlObj.page || + xmlObj.Page || + xmlObj.pages || + xmlObj.Pages; + + if (!hasValidRoot) { + this.err("file does not contain a recognized TouchChat structure"); + } + }, + ); const root = xmlObj.PageSet || @@ -145,7 +156,7 @@ export class TouchChatValidator extends BaseValidator { } } - return this.buildResult(filename, filesize, 'touchchat'); + return this.buildResult(filename, filesize, "touchchat"); } /** @@ -153,37 +164,37 @@ export class TouchChatValidator extends BaseValidator { */ private async validateTouchChatStructure(root: any): Promise { // Check for ID - await this.add_check('root_id', 'root element ID', async () => { + await this.add_check("root_id", "root element ID", async () => { const id = root.$?.id || root.$?.Id; if (!id) { - this.warn('root element should have an id attribute'); + this.warn("root element should have an id attribute"); } }); // Check for name - await this.add_check('root_name', 'root element name', async () => { + await this.add_check("root_name", "root element name", async () => { const name = root.$?.name || root.$?.Name || root.name?.[0]; if (!name) { - this.warn('root element should have a name'); + this.warn("root element should have a name"); } }); // Check for pages - await this.add_check('pages', 'pages collection', async () => { + await this.add_check("pages", "pages collection", async () => { const pages = root.page || root.Page || root.pages || root.Pages; if (!pages) { - this.err('TouchChat file must contain pages'); + this.err("TouchChat file must contain pages"); } else if (!Array.isArray(pages) || pages.length === 0) { - this.err('TouchChat file must contain at least one page'); + this.err("TouchChat file must contain at least one page"); } }); // Validate individual pages const pages = root.page || root.Page || root.pages || root.Pages; if (pages && Array.isArray(pages)) { - await this.add_check('page_count', 'page count', async () => { + await this.add_check("page_count", "page count", async () => { if (pages.length === 0) { - this.err('Must contain at least one page'); + this.err("Must contain at least one page"); } }); @@ -206,22 +217,30 @@ export class TouchChatValidator extends BaseValidator { } }); - await this.add_check(`page[${index}]_name`, `page ${index} name`, async () => { - const name = page.$?.name || page.$?.Name || page.name?.[0]; - if (!name) { - this.warn(`page ${index} should have a name`); - } - }); + await this.add_check( + `page[${index}]_name`, + `page ${index} name`, + async () => { + const name = page.$?.name || page.$?.Name || page.name?.[0]; + if (!name) { + this.warn(`page ${index} should have a name`); + } + }, + ); // Check for buttons/items - await this.add_check(`page[${index}]_buttons`, `page ${index} buttons`, async () => { - const buttons = page.button || page.Button || page.item || page.Item; - if (!buttons) { - this.warn(`page ${index} has no buttons/items`); - } else if (Array.isArray(buttons) && buttons.length === 0) { - this.warn(`page ${index} should contain at least one button`); - } - }); + await this.add_check( + `page[${index}]_buttons`, + `page ${index} buttons`, + async () => { + const buttons = page.button || page.Button || page.item || page.Item; + if (!buttons) { + this.warn(`page ${index} has no buttons/items`); + } else if (Array.isArray(buttons) && buttons.length === 0) { + this.warn(`page ${index} should contain at least one button`); + } + }, + ); // Validate button references const buttons = page.button || page.Button || page.item || page.Item; @@ -236,16 +255,22 @@ export class TouchChatValidator extends BaseValidator { /** * Validate a single button */ - private async validateButton(button: any, pageIdx: number, buttonIdx: number): Promise { + private async validateButton( + button: any, + pageIdx: number, + buttonIdx: number, + ): Promise { await this.add_check( `page[${pageIdx}]_button[${buttonIdx}]_label`, `button label`, async () => { const label = button.$?.label || button.$?.Label || button.label?.[0]; if (!label) { - this.warn(`button ${buttonIdx} on page ${pageIdx} should have a label`); + this.warn( + `button ${buttonIdx} on page ${pageIdx} should have a label`, + ); } - } + }, ); await this.add_check( @@ -253,11 +278,13 @@ export class TouchChatValidator extends BaseValidator { `button vocalization`, async () => { const vocalization = - button.$?.vocalization || button.$?.Vocalization || button.vocalization?.[0]; + button.$?.vocalization || + button.$?.Vocalization || + button.vocalization?.[0]; if (!vocalization) { // Vocalization is optional, so just info } - } + }, ); // Check for image reference @@ -267,9 +294,11 @@ export class TouchChatValidator extends BaseValidator { async () => { const image = button.$?.image || button.$?.Image || button.img?.[0]; if (!image) { - this.warn(`button ${buttonIdx} on page ${pageIdx} should have an image reference`); + this.warn( + `button ${buttonIdx} on page ${pageIdx} should have an image reference`, + ); } - } + }, ); // Check for link/action @@ -282,13 +311,14 @@ export class TouchChatValidator extends BaseValidator { if (!link && !action) { // Not all buttons need actions, they can just speak } - } + }, ); } private isSQLiteBuffer(content: Buffer | Uint8Array): boolean { - const header = 'SQLite format 3\u0000'; - const bytes = content instanceof Uint8Array ? content : new Uint8Array(content); + const header = "SQLite format 3\u0000"; + const bytes = + content instanceof Uint8Array ? content : new Uint8Array(content); if (bytes.length < header.length) { return false; } @@ -301,7 +331,8 @@ export class TouchChatValidator extends BaseValidator { } private isXmlBuffer(content: Buffer | Uint8Array): boolean { - const bytes = content instanceof Uint8Array ? content : new Uint8Array(content); + const bytes = + content instanceof Uint8Array ? content : new Uint8Array(content); const max = Math.min(bytes.length, 256); let start = 0; while (start < max) { @@ -321,104 +352,121 @@ export class TouchChatValidator extends BaseValidator { private async tryValidateZipSqlite( content: Buffer | Uint8Array, fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise + zipAdapter?: (input: ProcessorInput) => Promise, ): Promise { let usedZip = false; - await this.add_check('zip', 'TouchChat ZIP package', async () => { + await this.add_check("zip", "TouchChat ZIP package", async () => { try { const zip = zipAdapter ? await zipAdapter(content) : await getZipAdapter(content, fileAdapter); const entries = zip.listFiles(); - const vocabEntry = entries.find((name) => name.toLowerCase().endsWith('.c4v')); + const vocabEntry = entries.find((name) => + name.toLowerCase().endsWith(".c4v"), + ); if (!vocabEntry) { - this.err('TouchChat package missing .c4v database', true); + this.err("TouchChat package missing .c4v database", true); return; } const dbBuffer = await zip.readFile(vocabEntry); if (!this.isSQLiteBuffer(dbBuffer)) { - this.err('TouchChat .c4v is not a valid SQLite database', true); + this.err("TouchChat .c4v is not a valid SQLite database", true); return; } usedZip = true; await this.validateSqliteStructure(dbBuffer); } catch (e: any) { - this.err(`file is not a valid TouchChat ZIP package: ${e.message}`, true); + this.err( + `file is not a valid TouchChat ZIP package: ${e.message}`, + true, + ); } }); return usedZip; } - private async validateSqliteStructure(content: Buffer | Uint8Array): Promise { - await this.add_check('sqlite', 'valid TouchChat SQLite database', async () => { - let cleanup: (() => Promise) | undefined; - try { - const result = await openSqliteDatabase(content, { - readonly: true, - fileAdapter: this._options.fileAdapter, - }); - const db = result.db; - cleanup = result.cleanup; - - const tableRows = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - .all() as Array<{ name: string }>; - const tables = new Set(tableRows.map((row) => row.name)); - - const requiredTables = [ - 'resources', - 'pages', - 'buttons', - 'button_boxes', - 'button_box_cells', - 'button_box_instances', - ]; - const missingTables = requiredTables.filter((t) => !tables.has(t)); - if (missingTables.length > 0) { - this.err(`Missing required TouchChat tables: ${missingTables.join(', ')}`); - } - - const resourcesCols = new Set( - db - .prepare('PRAGMA table_info(resources)') - .all() - .map((row: any) => row.name) - ); - if (!resourcesCols.has('id') || !resourcesCols.has('name')) { - this.err('resources table missing id/name columns'); - } - - const pagesCols = new Set( - db - .prepare('PRAGMA table_info(pages)') - .all() - .map((row: any) => row.name) - ); - if (!pagesCols.has('id') || !pagesCols.has('resource_id')) { - this.err('pages table missing id/resource_id columns'); - } - - const buttonsCols = new Set( - db - .prepare('PRAGMA table_info(buttons)') - .all() - .map((row: any) => row.name) - ); - if (!buttonsCols.has('id') || !buttonsCols.has('resource_id')) { - this.err('buttons table missing id/resource_id columns'); - } - - const pageCount = db.prepare('SELECT COUNT(*) as c FROM pages').get() as { c: number }; - if (!pageCount || pageCount.c === 0) { - this.warn('TouchChat database has no pages'); - } - } catch (e: any) { - this.err(`TouchChat database validation failed: ${e.message}`, true); - } finally { - if (cleanup) { - await cleanup(); + private async validateSqliteStructure( + content: Buffer | Uint8Array, + ): Promise { + await this.add_check( + "sqlite", + "valid TouchChat SQLite database", + async () => { + let cleanup: (() => Promise) | undefined; + try { + const result = await openSqliteDatabase(content, { + readonly: true, + fileAdapter: this._options.fileAdapter, + }); + const db = result.db; + cleanup = result.cleanup; + + const tableRows = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", + ) + .all() as Array<{ name: string }>; + const tables = new Set(tableRows.map((row) => row.name)); + + const requiredTables = [ + "resources", + "pages", + "buttons", + "button_boxes", + "button_box_cells", + "button_box_instances", + ]; + const missingTables = requiredTables.filter((t) => !tables.has(t)); + if (missingTables.length > 0) { + this.err( + `Missing required TouchChat tables: ${missingTables.join(", ")}`, + ); + } + + const resourcesCols = new Set( + db + .prepare("PRAGMA table_info(resources)") + .all() + .map((row: any) => row.name), + ); + if (!resourcesCols.has("id") || !resourcesCols.has("name")) { + this.err("resources table missing id/name columns"); + } + + const pagesCols = new Set( + db + .prepare("PRAGMA table_info(pages)") + .all() + .map((row: any) => row.name), + ); + if (!pagesCols.has("id") || !pagesCols.has("resource_id")) { + this.err("pages table missing id/resource_id columns"); + } + + const buttonsCols = new Set( + db + .prepare("PRAGMA table_info(buttons)") + .all() + .map((row: any) => row.name), + ); + if (!buttonsCols.has("id") || !buttonsCols.has("resource_id")) { + this.err("buttons table missing id/resource_id columns"); + } + + const pageCount = db + .prepare("SELECT COUNT(*) as c FROM pages") + .get() as { c: number }; + if (!pageCount || pageCount.c === 0) { + this.warn("TouchChat database has no pages"); + } + } catch (e: any) { + this.err(`TouchChat database validation failed: ${e.message}`, true); + } finally { + if (cleanup) { + await cleanup(); + } } - } - }); + }, + ); } } diff --git a/src/validation/validationTypes.ts b/src/validation/validationTypes.ts index 26864e6..2ca647f 100644 --- a/src/validation/validationTypes.ts +++ b/src/validation/validationTypes.ts @@ -1,5 +1,5 @@ -import { FileAdapter, ProcessorInput } from '../utils/io'; -import { ZipAdapter } from '../utils/zip'; +import { FileAdapter, ProcessorInput } from "../utils/io"; +import { ZipAdapter } from "../utils/zip"; /** * Custom error class for validation errors @@ -10,7 +10,7 @@ export class ValidationError extends Error { constructor(message: string, blocker = false) { super(message); - this.name = 'ValidationError'; + this.name = "ValidationError"; this.blocker = blocker; } } @@ -88,9 +88,13 @@ export class ValidationFailureError extends Error { validationResult: ValidationResult; originalError?: unknown; - constructor(message: string, validationResult: ValidationResult, originalError?: unknown) { + constructor( + message: string, + validationResult: ValidationResult, + originalError?: unknown, + ) { super(message); - this.name = 'ValidationFailureError'; + this.name = "ValidationFailureError"; this.validationResult = validationResult; this.originalError = originalError; } @@ -118,8 +122,8 @@ export function buildValidationResultFromMessage(params: { warnings: 0, results: [ { - type: params.type || 'parse', - description: params.description || 'parse', + type: params.type || "parse", + description: params.description || "parse", valid: false, error: params.message, }, diff --git a/test/advancedScenarios.test.ts b/test/advancedScenarios.test.ts index 51c4794..dbb3d16 100644 --- a/test/advancedScenarios.test.ts +++ b/test/advancedScenarios.test.ts @@ -1,42 +1,47 @@ // Advanced scenario testing for complex real-world use cases -import fs from 'fs'; -import path from 'path'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; -import { getProcessor } from '../src/index'; -import { TreeFactory, PageFactory, TestDataUtils } from './utils/testFactories'; -import { TestEnvironmentManager, PerformanceHelper, AsyncTestHelper } from './utils/testHelpers'; - -describe('Advanced Scenario Testing', () => { +import fs from "fs"; +import path from "path"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; +import { getProcessor } from "../src/index"; +import { TreeFactory, PageFactory, TestDataUtils } from "./utils/testFactories"; +import { + TestEnvironmentManager, + PerformanceHelper, + AsyncTestHelper, +} from "./utils/testHelpers"; + +describe("Advanced Scenario Testing", () => { let testEnv: ReturnType; beforeAll(async () => { - testEnv = TestEnvironmentManager.createTempEnvironment('advanced-scenarios'); + testEnv = + TestEnvironmentManager.createTempEnvironment("advanced-scenarios"); }); afterAll(async () => { testEnv.cleanup(); }); - describe('Multi-Format Workflow Scenarios', () => { - it('should handle complete AAC development workflow', async () => { + describe("Multi-Format Workflow Scenarios", () => { + it("should handle complete AAC development workflow", async () => { // Scenario: Create AAC board in DOT, convert to multiple formats, translate, and verify consistency // Step 1: Create initial communication board in DOT format const initialTree = TreeFactory.createCommunicationBoard(); const dotProcessor = new DotProcessor(); - const dotPath = path.join(testEnv.tempDir, 'initial.dot'); + const dotPath = path.join(testEnv.tempDir, "initial.dot"); await dotProcessor.saveFromTree(initialTree, dotPath); expect(fs.existsSync(dotPath)).toBe(true); // Step 2: Convert to multiple formats const formats = [ - { ext: '.opml', processor: new OpmlProcessor() }, - { ext: '.obf', processor: new ObfProcessor() }, - { ext: '.plist', processor: new ApplePanelsProcessor() }, + { ext: ".opml", processor: new OpmlProcessor() }, + { ext: ".obf", processor: new ObfProcessor() }, + { ext: ".plist", processor: new ApplePanelsProcessor() }, ]; const convertedFiles: Record = {}; @@ -50,28 +55,35 @@ describe('Advanced Scenario Testing', () => { // Step 3: Extract texts from all formats const allTexts: Record = {}; - allTexts['.dot'] = await dotProcessor.extractTexts(dotPath); + allTexts[".dot"] = await dotProcessor.extractTexts(dotPath); for (const { ext, processor } of formats) { allTexts[ext] = await processor.extractTexts(convertedFiles[ext]); } // Step 4: Create translations - const originalTexts = allTexts['.dot']; - const translations = TestDataUtils.createTranslationMap(originalTexts, 'es'); + const originalTexts = allTexts[".dot"]; + const translations = TestDataUtils.createTranslationMap( + originalTexts, + "es", + ); // Step 5: Apply translations to all formats const translatedFiles: Record = {}; // Translate DOT - const translatedDotPath = path.join(testEnv.tempDir, 'translated.dot'); + const translatedDotPath = path.join(testEnv.tempDir, "translated.dot"); await dotProcessor.processTexts(dotPath, translations, translatedDotPath); - translatedFiles['.dot'] = translatedDotPath; + translatedFiles[".dot"] = translatedDotPath; // Translate other formats for (const { ext, processor } of formats) { const translatedPath = path.join(testEnv.tempDir, `translated${ext}`); - await processor.processTexts(convertedFiles[ext], translations, translatedPath); + await processor.processTexts( + convertedFiles[ext], + translations, + translatedPath, + ); translatedFiles[ext] = translatedPath; } @@ -80,11 +92,11 @@ describe('Advanced Scenario Testing', () => { expect(fs.existsSync(filePath)).toBe(true); const processor = - ext === '.dot' + ext === ".dot" ? dotProcessor - : ext === '.opml' + : ext === ".opml" ? new OpmlProcessor() - : ext === '.obf' + : ext === ".obf" ? new ObfProcessor() : new ApplePanelsProcessor(); @@ -92,14 +104,19 @@ describe('Advanced Scenario Testing', () => { // Should have some Spanish translations const hasSpanishContent = translatedTexts.some( - (text) => text.includes('Hola') || text.includes('Comida') || text.includes('Casa') + (text) => + text.includes("Hola") || + text.includes("Comida") || + text.includes("Casa"), ); if (translatedTexts.length > 0) { - if (ext === '.opml') { + if (ext === ".opml") { // OPML is lossy for SPEAK buttons (like Hello -> Hola), so we only check for page names // Home -> Casa should be present as it's the root page - const hasCasa = translatedTexts.some((text) => text.includes('Casa')); + const hasCasa = translatedTexts.some((text) => + text.includes("Casa"), + ); expect(hasCasa).toBe(true); } else { expect(hasSpanishContent).toBe(true); @@ -114,24 +131,24 @@ describe('Advanced Scenario Testing', () => { } }); - it('should handle collaborative editing scenario', async () => { + it("should handle collaborative editing scenario", async () => { // Scenario: Multiple users editing the same AAC board in different formats const baseTree = TreeFactory.createSimple(); // User 1: Works with DOT format const dotProcessor = new DotProcessor(); - const dotPath = path.join(testEnv.tempDir, 'collaborative.dot'); + const dotPath = path.join(testEnv.tempDir, "collaborative.dot"); await dotProcessor.saveFromTree(baseTree, dotPath); // User 2: Converts to OPML and adds content const opmlProcessor = new OpmlProcessor(); - const opmlPath = path.join(testEnv.tempDir, 'collaborative.opml'); + const opmlPath = path.join(testEnv.tempDir, "collaborative.opml"); await opmlProcessor.saveFromTree(baseTree, opmlPath); // User 3: Converts to OBF and modifies const obfProcessor = new ObfProcessor(); - const obfPath = path.join(testEnv.tempDir, 'collaborative.obf'); + const obfPath = path.join(testEnv.tempDir, "collaborative.obf"); await obfProcessor.saveFromTree(baseTree, obfPath); // Simulate concurrent modifications @@ -141,13 +158,13 @@ describe('Advanced Scenario Testing', () => { // DOT modification const tree = await dotProcessor.loadIntoTree(dotPath); const newPage = PageFactory.create({ - id: 'dot_addition', - name: 'DOT Addition', - buttons: [{ label: 'DOT Button', type: 'SPEAK' }], + id: "dot_addition", + name: "DOT Addition", + buttons: [{ label: "DOT Button", type: "SPEAK" }], }); tree.addPage(newPage); - const modifiedDotPath = path.join(testEnv.tempDir, 'modified.dot'); + const modifiedDotPath = path.join(testEnv.tempDir, "modified.dot"); await dotProcessor.saveFromTree(tree, modifiedDotPath); return modifiedDotPath; }, @@ -155,13 +172,16 @@ describe('Advanced Scenario Testing', () => { // OPML modification const tree = await opmlProcessor.loadIntoTree(opmlPath); const newPage = PageFactory.create({ - id: 'opml_addition', - name: 'OPML Addition', - buttons: [{ label: 'OPML Button', type: 'SPEAK' }], + id: "opml_addition", + name: "OPML Addition", + buttons: [{ label: "OPML Button", type: "SPEAK" }], }); tree.addPage(newPage); - const modifiedOpmlPath = path.join(testEnv.tempDir, 'modified.opml'); + const modifiedOpmlPath = path.join( + testEnv.tempDir, + "modified.opml", + ); await opmlProcessor.saveFromTree(tree, modifiedOpmlPath); return modifiedOpmlPath; }, @@ -169,18 +189,18 @@ describe('Advanced Scenario Testing', () => { // OBF modification const tree = await obfProcessor.loadIntoTree(obfPath); const newPage = PageFactory.create({ - id: 'obf_addition', - name: 'OBF Addition', - buttons: [{ label: 'OBF Button', type: 'SPEAK' }], + id: "obf_addition", + name: "OBF Addition", + buttons: [{ label: "OBF Button", type: "SPEAK" }], }); tree.addPage(newPage); - const modifiedObfPath = path.join(testEnv.tempDir, 'modified.obf'); + const modifiedObfPath = path.join(testEnv.tempDir, "modified.obf"); await obfProcessor.saveFromTree(tree, modifiedObfPath); return modifiedObfPath; }, ], - 3 + 3, ); // Verify all modifications were successful @@ -194,14 +214,16 @@ describe('Advanced Scenario Testing', () => { const opmlTree = await opmlProcessor.loadIntoTree(modifications[1]); const obfTree = await obfProcessor.loadIntoTree(modifications[2]); - expect(Object.keys(dotTree.pages).length).toBeGreaterThan(Object.keys(baseTree.pages).length); + expect(Object.keys(dotTree.pages).length).toBeGreaterThan( + Object.keys(baseTree.pages).length, + ); expect(Object.keys(opmlTree.pages).length).toBeGreaterThan(0); expect(Object.keys(obfTree.pages).length).toBeGreaterThan(0); }); }); - describe('Performance-Critical Scenarios', () => { - it('should handle high-volume batch processing', async () => { + describe("Performance-Critical Scenarios", () => { + it("should handle high-volume batch processing", async () => { // Scenario: Process 50 AAC boards simultaneously const batchSize = 20; // Reduced for CI stability @@ -212,28 +234,34 @@ describe('Advanced Scenario Testing', () => { new ApplePanelsProcessor(), ]; - const { result: batchResults, metrics } = await PerformanceHelper.measureAsync(async () => { - const batchOperations = Array.from({ length: batchSize }, (_, i) => async () => { - const tree = TreeFactory.createLarge(5, 6); // 5 pages, 6 buttons each - const processor = processors[i % processors.length]; - const ext = ['.dot', '.opml', '.obf', '.plist'][i % processors.length]; - - const filePath = path.join(testEnv.tempDir, `batch_${i}${ext}`); - await processor.saveFromTree(tree, filePath); - - const reloadedTree = await processor.loadIntoTree(filePath); - const texts = await processor.extractTexts(filePath); - - return { - index: i, - pageCount: Object.keys(reloadedTree.pages).length, - textCount: texts.length, - fileSize: fs.statSync(filePath).size, - }; - }); + const { result: batchResults, metrics } = + await PerformanceHelper.measureAsync(async () => { + const batchOperations = Array.from( + { length: batchSize }, + (_, i) => async () => { + const tree = TreeFactory.createLarge(5, 6); // 5 pages, 6 buttons each + const processor = processors[i % processors.length]; + const ext = [".dot", ".opml", ".obf", ".plist"][ + i % processors.length + ]; + + const filePath = path.join(testEnv.tempDir, `batch_${i}${ext}`); + await processor.saveFromTree(tree, filePath); + + const reloadedTree = await processor.loadIntoTree(filePath); + const texts = await processor.extractTexts(filePath); + + return { + index: i, + pageCount: Object.keys(reloadedTree.pages).length, + textCount: texts.length, + fileSize: fs.statSync(filePath).size, + }; + }, + ); - return AsyncTestHelper.runConcurrently(batchOperations, 5); - }, 'Batch Processing'); + return AsyncTestHelper.runConcurrently(batchOperations, 5); + }, "Batch Processing"); // Verify all operations completed successfully expect(batchResults).toHaveLength(batchSize); @@ -248,37 +276,46 @@ describe('Advanced Scenario Testing', () => { expect(metrics.memoryDelta.heapUsed / 1024 / 1024).toBeLessThan(200); // 200MB max }); - it('should handle streaming large file processing', async () => { + it("should handle streaming large file processing", async () => { // Scenario: Process very large AAC board (1000+ buttons) const largeTree = TreeFactory.createLarge(50, 20); // 50 pages, 20 buttons each = 1000 buttons const processor = new DotProcessor(); - const { result, metrics } = await PerformanceHelper.measureAsync(async () => { - const largePath = path.join(testEnv.tempDir, 'large_board.dot'); + const { result, metrics } = await PerformanceHelper.measureAsync( + async () => { + const largePath = path.join(testEnv.tempDir, "large_board.dot"); - // Save large tree - await processor.saveFromTree(largeTree, largePath); + // Save large tree + await processor.saveFromTree(largeTree, largePath); - // Load it back - const reloadedTree = await processor.loadIntoTree(largePath); + // Load it back + const reloadedTree = await processor.loadIntoTree(largePath); - // Extract texts - const texts = await processor.extractTexts(largePath); + // Extract texts + const texts = await processor.extractTexts(largePath); - // Apply translations - const translations = TestDataUtils.createTranslationMap(texts.slice(0, 100), 'fr'); - const translatedPath = path.join(testEnv.tempDir, 'large_translated.dot'); - await processor.processTexts(largePath, translations, translatedPath); + // Apply translations + const translations = TestDataUtils.createTranslationMap( + texts.slice(0, 100), + "fr", + ); + const translatedPath = path.join( + testEnv.tempDir, + "large_translated.dot", + ); + await processor.processTexts(largePath, translations, translatedPath); - return { - originalPages: Object.keys(largeTree.pages).length, - reloadedPages: Object.keys(reloadedTree.pages).length, - textCount: texts.length, - translationCount: translations.size, - fileSize: fs.statSync(largePath).size, - }; - }, 'Large File Processing'); + return { + originalPages: Object.keys(largeTree.pages).length, + reloadedPages: Object.keys(reloadedTree.pages).length, + textCount: texts.length, + translationCount: translations.size, + fileSize: fs.statSync(largePath).size, + }; + }, + "Large File Processing", + ); expect(result.originalPages).toBe(50); expect(result.reloadedPages).toBeGreaterThan(0); @@ -291,35 +328,35 @@ describe('Advanced Scenario Testing', () => { }); }); - describe('Error Recovery Scenarios', () => { - it('should handle partial file corruption gracefully', async () => { + describe("Error Recovery Scenarios", () => { + it("should handle partial file corruption gracefully", async () => { // Scenario: Process files with various types of corruption const validTree = TreeFactory.createSimple(); const processor = new DotProcessor(); // Create valid file first - const validPath = path.join(testEnv.tempDir, 'valid.dot'); + const validPath = path.join(testEnv.tempDir, "valid.dot"); await processor.saveFromTree(validTree, validPath); - const validContent = fs.readFileSync(validPath, 'utf8'); + const validContent = fs.readFileSync(validPath, "utf8"); // Test various corruption scenarios const corruptionTests = [ { - name: 'Truncated file', + name: "Truncated file", content: validContent.slice(0, validContent.length / 2), }, { - name: 'Invalid characters', - content: validContent.replace(/digraph/g, 'invalid\0\xFF'), + name: "Invalid characters", + content: validContent.replace(/digraph/g, "invalid\0\xFF"), }, { - name: 'Malformed structure', - content: validContent.replace(/}/g, '').replace(/{/g, ''), + name: "Malformed structure", + content: validContent.replace(/}/g, "").replace(/{/g, ""), }, { - name: 'Mixed encoding', - content: validContent + '\xFF\xFE\x00\x00', + name: "Mixed encoding", + content: validContent + "\xFF\xFE\x00\x00", }, ]; @@ -327,7 +364,7 @@ describe('Advanced Scenario Testing', () => { corruptionTests.map((test) => async () => { const corruptedPath = path.join( testEnv.tempDir, - `corrupted_${test.name.replace(/\s+/g, '_')}.dot` + `corrupted_${test.name.replace(/\s+/g, "_")}.dot`, ); fs.writeFileSync(corruptedPath, test.content); @@ -345,11 +382,11 @@ describe('Advanced Scenario Testing', () => { return { name: test.name, success: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: error instanceof Error ? error.message : "Unknown error", }; } }), - 2 + 2, ); // Should handle corruption gracefully (either succeed with partial data or fail cleanly) @@ -362,43 +399,49 @@ describe('Advanced Scenario Testing', () => { } else { // If it fails, should have meaningful error expect(result.error).toBeDefined(); - expect(typeof result.error).toBe('string'); + expect(typeof result.error).toBe("string"); } }); }); - it('should handle resource exhaustion scenarios', async () => { + it("should handle resource exhaustion scenarios", async () => { // Scenario: Test behavior under resource constraints const processor = new DotProcessor(); // Test with many small operations (simulating memory pressure) - const smallOperations = Array.from({ length: 100 }, (_, i) => async () => { - const tree = TreeFactory.createMinimal(); - const tempPath = path.join(testEnv.tempDir, `small_${i}.dot`); + const smallOperations = Array.from( + { length: 100 }, + (_, i) => async () => { + const tree = TreeFactory.createMinimal(); + const tempPath = path.join(testEnv.tempDir, `small_${i}.dot`); - try { - await processor.saveFromTree(tree, tempPath); - const reloadedTree = await processor.loadIntoTree(tempPath); + try { + await processor.saveFromTree(tree, tempPath); + const reloadedTree = await processor.loadIntoTree(tempPath); - // Clean up immediately to simulate resource pressure - fs.unlinkSync(tempPath); + // Clean up immediately to simulate resource pressure + fs.unlinkSync(tempPath); - return { - index: i, - success: true, - pageCount: Object.keys(reloadedTree.pages).length, - }; - } catch (error) { - return { - index: i, - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - }); + return { + index: i, + success: true, + pageCount: Object.keys(reloadedTree.pages).length, + }; + } catch (error) { + return { + index: i, + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, + ); - const results = await AsyncTestHelper.runConcurrently(smallOperations, 10); + const results = await AsyncTestHelper.runConcurrently( + smallOperations, + 10, + ); // Most operations should succeed const successCount = results.filter((r) => r.success).length; @@ -416,20 +459,21 @@ describe('Advanced Scenario Testing', () => { }); }); - describe('Integration with External Systems', () => { - it('should handle processor factory with dynamic format detection', async () => { + describe("Integration with External Systems", () => { + it("should handle processor factory with dynamic format detection", async () => { // Scenario: Dynamically process files based on extension const testFiles = [ - { name: 'test.dot', content: 'digraph G { test [label="Test"]; }' }, + { name: "test.dot", content: 'digraph G { test [label="Test"]; }' }, { - name: 'test.opml', + name: "test.opml", content: '', }, { - name: 'test.obf', - content: '{"id": "test", "buttons": [{"id": "btn1", "label": "Test"}]}', + name: "test.obf", + content: + '{"id": "test", "buttons": [{"id": "btn1", "label": "Test"}]}', }, ]; @@ -454,7 +498,7 @@ describe('Advanced Scenario Testing', () => { results.push({ file: file.name, success: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: error instanceof Error ? error.message : "Unknown error", }); } } @@ -470,13 +514,13 @@ describe('Advanced Scenario Testing', () => { }); // Verify correct processor types - const dotResult = results.find((r) => r.file === 'test.dot'); - const opmlResult = results.find((r) => r.file === 'test.opml'); - const obfResult = results.find((r) => r.file === 'test.obf'); + const dotResult = results.find((r) => r.file === "test.dot"); + const opmlResult = results.find((r) => r.file === "test.opml"); + const obfResult = results.find((r) => r.file === "test.obf"); - expect(dotResult?.processorType).toBe('DotProcessor'); - expect(opmlResult?.processorType).toBe('OpmlProcessor'); - expect(obfResult?.processorType).toBe('ObfProcessor'); + expect(dotResult?.processorType).toBe("DotProcessor"); + expect(opmlResult?.processorType).toBe("OpmlProcessor"); + expect(obfResult?.processorType).toBe("ObfProcessor"); }); }); }); diff --git a/test/aliasMethodsIntegration.test.ts b/test/aliasMethodsIntegration.test.ts index d3e7315..9bf9962 100644 --- a/test/aliasMethodsIntegration.test.ts +++ b/test/aliasMethodsIntegration.test.ts @@ -1,20 +1,24 @@ // Integration tests for alias methods across all processors -import fs from 'fs'; -import path from 'path'; -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; -import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; -import { AstericsGridProcessor } from '../src/processors/astericsGridProcessor'; -import { ExcelProcessor } from '../src/processors/excelProcessor'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { StringCasing } from '../src/core/stringCasing'; -import { ExtractStringsResult, TranslatedString, SourceString } from '../src/core/baseProcessor'; - -describe('Alias Methods Integration', () => { - const tempDir = path.join(__dirname, 'temp_alias_tests'); +import fs from "fs"; +import path from "path"; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; +import { AstericsGridProcessor } from "../src/processors/astericsGridProcessor"; +import { ExcelProcessor } from "../src/processors/excelProcessor"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { StringCasing } from "../src/core/stringCasing"; +import { + ExtractStringsResult, + TranslatedString, + SourceString, +} from "../src/core/baseProcessor"; + +describe("Alias Methods Integration", () => { + const tempDir = path.join(__dirname, "temp_alias_tests"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -28,65 +32,68 @@ describe('Alias Methods Integration', () => { } }); - describe('TouchChatProcessor Alias Methods', () => { + describe("TouchChatProcessor Alias Methods", () => { const processor = new TouchChatProcessor(); - const exampleFile = path.join(__dirname, 'assets/excel/example.ce'); + const exampleFile = path.join(__dirname, "assets/excel/example.ce"); - it('should extract strings with metadata in expected format', async () => { + it("should extract strings with metadata in expected format", async () => { if (!fs.existsSync(exampleFile)) { - console.log('Skipping TouchChat test - example file not found'); + console.log("Skipping TouchChat test - example file not found"); return; } - const result: ExtractStringsResult = await processor.extractStringsWithMetadata(exampleFile); + const result: ExtractStringsResult = + await processor.extractStringsWithMetadata(exampleFile); - expect(result).toHaveProperty('errors'); - expect(result).toHaveProperty('extractedStrings'); + expect(result).toHaveProperty("errors"); + expect(result).toHaveProperty("extractedStrings"); expect(Array.isArray(result.errors)).toBe(true); expect(Array.isArray(result.extractedStrings)).toBe(true); if (result.extractedStrings.length > 0) { const firstString = result.extractedStrings[0]; - expect(firstString).toHaveProperty('string'); - expect(firstString).toHaveProperty('vocabPlacementMeta'); - expect(firstString.vocabPlacementMeta).toHaveProperty('vocabLocations'); - expect(Array.isArray(firstString.vocabPlacementMeta.vocabLocations)).toBe(true); + expect(firstString).toHaveProperty("string"); + expect(firstString).toHaveProperty("vocabPlacementMeta"); + expect(firstString.vocabPlacementMeta).toHaveProperty("vocabLocations"); + expect( + Array.isArray(firstString.vocabPlacementMeta.vocabLocations), + ).toBe(true); if (firstString.vocabPlacementMeta.vocabLocations.length > 0) { const location = firstString.vocabPlacementMeta.vocabLocations[0]; - expect(location).toHaveProperty('table'); - expect(location).toHaveProperty('id'); - expect(location).toHaveProperty('column'); - expect(location).toHaveProperty('casing'); + expect(location).toHaveProperty("table"); + expect(location).toHaveProperty("id"); + expect(location).toHaveProperty("column"); + expect(location).toHaveProperty("casing"); expect(Object.values(StringCasing)).toContain(location.casing); } } }); - it('should generate translated downloads', async () => { + it("should generate translated downloads", async () => { if (!fs.existsSync(exampleFile)) { - console.log('Skipping TouchChat test - example file not found'); + console.log("Skipping TouchChat test - example file not found"); return; } const mockTranslatedStrings: TranslatedString[] = [ { sourcestringid: 1, - overridestring: '', - translatedstring: 'Translated Text', + overridestring: "", + translatedstring: "Translated Text", }, ]; const mockSourceStrings: SourceString[] = [ { id: 1, - sourcestring: 'Original Text', + sourcestring: "Original Text", vocabplacementmetadata: { vocabLocations: [ { - table: 'buttons', + table: "buttons", id: 1, - column: 'LABEL', + column: "LABEL", casing: StringCasing.LOWER, }, ], @@ -97,7 +104,7 @@ describe('Alias Methods Integration', () => { const outputPath = await processor.generateTranslatedDownload( exampleFile, mockTranslatedStrings, - mockSourceStrings + mockSourceStrings, ); expect(outputPath).toMatch(/_translated\.ce$/); @@ -109,96 +116,99 @@ describe('Alias Methods Integration', () => { } }); - it('should handle errors gracefully', async () => { - const nonExistentFile = path.join(tempDir, 'nonexistent.ce'); + it("should handle errors gracefully", async () => { + const nonExistentFile = path.join(tempDir, "nonexistent.ce"); - const result = await processor.extractStringsWithMetadata(nonExistentFile); + const result = + await processor.extractStringsWithMetadata(nonExistentFile); expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors[0]).toHaveProperty('message'); - expect(result.errors[0]).toHaveProperty('step'); - expect(result.errors[0].step).toBe('EXTRACT'); + expect(result.errors[0]).toHaveProperty("message"); + expect(result.errors[0]).toHaveProperty("step"); + expect(result.errors[0].step).toBe("EXTRACT"); expect(result.extractedStrings).toEqual([]); }); }); - describe('ObfProcessor Alias Methods', () => { + describe("ObfProcessor Alias Methods", () => { const processor = new ObfProcessor(); - const exampleFile = path.join(__dirname, 'assets/obf/example.obf'); + const exampleFile = path.join(__dirname, "assets/obf/example.obf"); - it('should have alias methods available', async () => { - expect(typeof processor.extractStringsWithMetadata).toBe('function'); - expect(typeof processor.generateTranslatedDownload).toBe('function'); + it("should have alias methods available", async () => { + expect(typeof processor.extractStringsWithMetadata).toBe("function"); + expect(typeof processor.generateTranslatedDownload).toBe("function"); }); - it('should extract strings with metadata using generic implementation', async () => { + it("should extract strings with metadata using generic implementation", async () => { if (!fs.existsSync(exampleFile)) { - console.log('Skipping OBF test - example file not found'); + console.log("Skipping OBF test - example file not found"); return; } const result = await processor.extractStringsWithMetadata(exampleFile); - expect(result).toHaveProperty('errors'); - expect(result).toHaveProperty('extractedStrings'); + expect(result).toHaveProperty("errors"); + expect(result).toHaveProperty("extractedStrings"); expect(Array.isArray(result.errors)).toBe(true); expect(Array.isArray(result.extractedStrings)).toBe(true); }); }); - describe('SnapProcessor Alias Methods', () => { + describe("SnapProcessor Alias Methods", () => { const processor = new SnapProcessor(); - const exampleFile = path.join(__dirname, 'assets/snap/example.spb'); + const exampleFile = path.join(__dirname, "assets/snap/example.spb"); - it('should have alias methods available', async () => { - expect(typeof processor.extractStringsWithMetadata).toBe('function'); - expect(typeof processor.generateTranslatedDownload).toBe('function'); + it("should have alias methods available", async () => { + expect(typeof processor.extractStringsWithMetadata).toBe("function"); + expect(typeof processor.generateTranslatedDownload).toBe("function"); }); - it('should extract strings with metadata using generic implementation', async () => { + it("should extract strings with metadata using generic implementation", async () => { if (!fs.existsSync(exampleFile)) { - console.log('Skipping Snap test - example file not found'); + console.log("Skipping Snap test - example file not found"); return; } const result = await processor.extractStringsWithMetadata(exampleFile); - expect(result).toHaveProperty('errors'); - expect(result).toHaveProperty('extractedStrings'); + expect(result).toHaveProperty("errors"); + expect(result).toHaveProperty("extractedStrings"); expect(Array.isArray(result.errors)).toBe(true); expect(Array.isArray(result.extractedStrings)).toBe(true); }); }); - describe('Backward Compatibility', () => { - it('should maintain existing API methods', async () => { + describe("Backward Compatibility", () => { + it("should maintain existing API methods", async () => { const touchChatProcessor = new TouchChatProcessor(); const obfProcessor = new ObfProcessor(); const snapProcessor = new SnapProcessor(); // Verify existing methods still exist - expect(typeof touchChatProcessor.extractTexts).toBe('function'); - expect(typeof touchChatProcessor.loadIntoTree).toBe('function'); - expect(typeof touchChatProcessor.processTexts).toBe('function'); - expect(typeof touchChatProcessor.saveFromTree).toBe('function'); - - expect(typeof obfProcessor.extractTexts).toBe('function'); - expect(typeof obfProcessor.loadIntoTree).toBe('function'); - expect(typeof obfProcessor.processTexts).toBe('function'); - expect(typeof obfProcessor.saveFromTree).toBe('function'); - - expect(typeof snapProcessor.extractTexts).toBe('function'); - expect(typeof snapProcessor.loadIntoTree).toBe('function'); - expect(typeof snapProcessor.processTexts).toBe('function'); - expect(typeof snapProcessor.saveFromTree).toBe('function'); + expect(typeof touchChatProcessor.extractTexts).toBe("function"); + expect(typeof touchChatProcessor.loadIntoTree).toBe("function"); + expect(typeof touchChatProcessor.processTexts).toBe("function"); + expect(typeof touchChatProcessor.saveFromTree).toBe("function"); + + expect(typeof obfProcessor.extractTexts).toBe("function"); + expect(typeof obfProcessor.loadIntoTree).toBe("function"); + expect(typeof obfProcessor.processTexts).toBe("function"); + expect(typeof obfProcessor.saveFromTree).toBe("function"); + + expect(typeof snapProcessor.extractTexts).toBe("function"); + expect(typeof snapProcessor.loadIntoTree).toBe("function"); + expect(typeof snapProcessor.processTexts).toBe("function"); + expect(typeof snapProcessor.saveFromTree).toBe("function"); }); - it('should not break existing functionality', async () => { + it("should not break existing functionality", async () => { const processor = new TouchChatProcessor(); - const exampleFile = path.join(__dirname, 'assets/excel/example.ce'); + const exampleFile = path.join(__dirname, "assets/excel/example.ce"); if (!fs.existsSync(exampleFile)) { - console.log('Skipping backward compatibility test - example file not found'); + console.log( + "Skipping backward compatibility test - example file not found", + ); return; } @@ -211,8 +221,8 @@ describe('Alias Methods Integration', () => { }); }); - describe('Cross-Format Consistency', () => { - it('should provide consistent interface across all processors', async () => { + describe("Cross-Format Consistency", () => { + it("should provide consistent interface across all processors", async () => { const processors = [ new TouchChatProcessor(), new ObfProcessor(), @@ -227,14 +237,14 @@ describe('Alias Methods Integration', () => { processors.forEach((processor) => { // All processors should have the alias methods - expect(typeof processor.extractStringsWithMetadata).toBe('function'); - expect(typeof processor.generateTranslatedDownload).toBe('function'); + expect(typeof processor.extractStringsWithMetadata).toBe("function"); + expect(typeof processor.generateTranslatedDownload).toBe("function"); // All processors should have the standard methods - expect(typeof processor.extractTexts).toBe('function'); - expect(typeof processor.loadIntoTree).toBe('function'); - expect(typeof processor.processTexts).toBe('function'); - expect(typeof processor.saveFromTree).toBe('function'); + expect(typeof processor.extractTexts).toBe("function"); + expect(typeof processor.loadIntoTree).toBe("function"); + expect(typeof processor.processTexts).toBe("function"); + expect(typeof processor.saveFromTree).toBe("function"); }); }); }); diff --git a/test/applePanelsProcessor.roundtrip.test.ts b/test/applePanelsProcessor.roundtrip.test.ts index 02e4a5d..98b5b75 100644 --- a/test/applePanelsProcessor.roundtrip.test.ts +++ b/test/applePanelsProcessor.roundtrip.test.ts @@ -1,12 +1,12 @@ // Round-trip test for ApplePanelsProcessor: load, save, reload, and compare structure -import fs from 'fs'; -import path from 'path'; -import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; -import { ValidationFailureError } from '../src/validation'; +import fs from "fs"; +import path from "path"; +import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; +import { ValidationFailureError } from "../src/validation"; -describe('ApplePanelsProcessor round-trip', () => { - const outPath: string = path.join(__dirname, 'out.applepanels'); +describe("ApplePanelsProcessor round-trip", () => { + const outPath: string = path.join(__dirname, "out.applepanels"); afterAll(async () => { const asconfigPath = `${outPath}.ascconfig`; @@ -15,7 +15,7 @@ describe('ApplePanelsProcessor round-trip', () => { } }); - it('can save and load a constructed tree', async () => { + it("can save and load a constructed tree", async () => { const processor = new ApplePanelsProcessor(); // Create a simple tree programmatically @@ -23,24 +23,24 @@ describe('ApplePanelsProcessor round-trip', () => { // Create first panel const page1 = new AACPage({ - id: 'panel1', - name: 'Main Panel', + id: "panel1", + name: "Main Panel", buttons: [], }); const button1 = new AACButton({ - id: 'btn1', - label: 'Hello', - message: 'Hello World', - type: 'SPEAK', + id: "btn1", + label: "Hello", + message: "Hello World", + type: "SPEAK", }); const button2 = new AACButton({ - id: 'btn2', - label: 'Go to Panel 2', - message: 'Navigate', - type: 'NAVIGATE', - targetPageId: 'panel2', + id: "btn2", + label: "Go to Panel 2", + message: "Navigate", + type: "NAVIGATE", + targetPageId: "panel2", }); page1.addButton(button1); @@ -49,17 +49,17 @@ describe('ApplePanelsProcessor round-trip', () => { // Create second panel const page2 = new AACPage({ - id: 'panel2', - name: 'Second Panel', + id: "panel2", + name: "Second Panel", buttons: [], }); const button3 = new AACButton({ - id: 'btn3', - label: 'Back', - message: 'Go back', - type: 'NAVIGATE', - targetPageId: 'panel1', + id: "btn3", + label: "Back", + message: "Go back", + type: "NAVIGATE", + targetPageId: "panel1", }); page2.addButton(button3); @@ -75,25 +75,25 @@ describe('ApplePanelsProcessor round-trip', () => { // Verify structure expect(Object.keys(tree2.pages)).toHaveLength(2); - const reloadedPage1 = tree2.pages['panel1']; + const reloadedPage1 = tree2.pages["panel1"]; expect(reloadedPage1).toBeDefined(); - expect(reloadedPage1.name).toBe('Main Panel'); + expect(reloadedPage1.name).toBe("Main Panel"); expect(reloadedPage1.buttons).toHaveLength(2); - const reloadedPage2 = tree2.pages['panel2']; + const reloadedPage2 = tree2.pages["panel2"]; expect(reloadedPage2).toBeDefined(); - expect(reloadedPage2.name).toBe('Second Panel'); + expect(reloadedPage2.name).toBe("Second Panel"); expect(reloadedPage2.buttons).toHaveLength(1); // Check navigation - const navButton = reloadedPage1.buttons.find((b) => b.type === 'NAVIGATE'); + const navButton = reloadedPage1.buttons.find((b) => b.type === "NAVIGATE"); expect(navButton).toBeDefined(); if (navButton) { - expect(navButton.targetPageId).toBe('panel2'); + expect(navButton.targetPageId).toBe("panel2"); } }); - it('handles empty tree gracefully', async () => { + it("handles empty tree gracefully", async () => { const processor = new ApplePanelsProcessor(); const emptyTree = new AACTree(); @@ -101,6 +101,8 @@ describe('ApplePanelsProcessor round-trip', () => { const asconfigPath = `${outPath}.ascconfig`; expect(fs.existsSync(asconfigPath)).toBe(true); - await expect(processor.loadIntoTree(asconfigPath)).rejects.toThrow(ValidationFailureError); + await expect(processor.loadIntoTree(asconfigPath)).rejects.toThrow( + ValidationFailureError, + ); }); }); diff --git a/test/astericsColors.test.ts b/test/astericsColors.test.ts index aa0ece9..6515bdd 100644 --- a/test/astericsColors.test.ts +++ b/test/astericsColors.test.ts @@ -2,51 +2,51 @@ import { normalizeHexColor, adjustHexColor, getContrastingTextColor, -} from '../src/processors/astericsGridProcessor'; +} from "../src/processors/astericsGridProcessor"; -describe('AstericsGrid Color Helpers', () => { - describe('normalizeHexColor', () => { - it('should normalize hex formats with # prefix', async () => { - expect(normalizeHexColor('#abc')).toBe('#aabbcc'); - expect(normalizeHexColor('#aabbcc')).toBe('#aabbcc'); +describe("AstericsGrid Color Helpers", () => { + describe("normalizeHexColor", () => { + it("should normalize hex formats with # prefix", async () => { + expect(normalizeHexColor("#abc")).toBe("#aabbcc"); + expect(normalizeHexColor("#aabbcc")).toBe("#aabbcc"); }); - it('should return null for hex without # prefix (strict)', async () => { - expect(normalizeHexColor('abc')).toBeNull(); - expect(normalizeHexColor('aabbcc')).toBeNull(); + it("should return null for hex without # prefix (strict)", async () => { + expect(normalizeHexColor("abc")).toBeNull(); + expect(normalizeHexColor("aabbcc")).toBeNull(); }); - it('should return null for invalid colors', async () => { - expect(normalizeHexColor('#zzzzzz')).toBeNull(); - expect(normalizeHexColor('')).toBeNull(); + it("should return null for invalid colors", async () => { + expect(normalizeHexColor("#zzzzzz")).toBeNull(); + expect(normalizeHexColor("")).toBeNull(); }); }); - describe('adjustHexColor', () => { - it('should lighten a color', async () => { + describe("adjustHexColor", () => { + it("should lighten a color", async () => { // #101010 + 10 -> #1a1a1a (16+10=26 -> 0x1a) - expect(adjustHexColor('#101010', 10)).toBe('#1a1a1a'); + expect(adjustHexColor("#101010", 10)).toBe("#1a1a1a"); }); - it('should darken a color', async () => { - expect(adjustHexColor('#aabbcc', -10)).toBe('#a0b1c2'); + it("should darken a color", async () => { + expect(adjustHexColor("#aabbcc", -10)).toBe("#a0b1c2"); }); - it('should clamp to 0 and 255', async () => { - expect(adjustHexColor('#000000', -100)).toBe('#000000'); - expect(adjustHexColor('#ffffff', 100)).toBe('#ffffff'); + it("should clamp to 0 and 255", async () => { + expect(adjustHexColor("#000000", -100)).toBe("#000000"); + expect(adjustHexColor("#ffffff", 100)).toBe("#ffffff"); }); }); - describe('getContrastingTextColor', () => { - it('should return white for dark backgrounds', async () => { - expect(getContrastingTextColor('#000000')).toBe('#FFFFFF'); - expect(getContrastingTextColor('#333333')).toBe('#FFFFFF'); + describe("getContrastingTextColor", () => { + it("should return white for dark backgrounds", async () => { + expect(getContrastingTextColor("#000000")).toBe("#FFFFFF"); + expect(getContrastingTextColor("#333333")).toBe("#FFFFFF"); }); - it('should return black for light backgrounds', async () => { - expect(getContrastingTextColor('#FFFFFF')).toBe('#000000'); - expect(getContrastingTextColor('#DDDDDD')).toBe('#000000'); + it("should return black for light backgrounds", async () => { + expect(getContrastingTextColor("#FFFFFF")).toBe("#000000"); + expect(getContrastingTextColor("#DDDDDD")).toBe("#000000"); }); }); }); diff --git a/test/astericsGridProcessor.test.ts b/test/astericsGridProcessor.test.ts index 952aff1..70ce46d 100644 --- a/test/astericsGridProcessor.test.ts +++ b/test/astericsGridProcessor.test.ts @@ -1,11 +1,15 @@ -import { AstericsGridProcessor } from '../src/processors/astericsGridProcessor'; -import { AACTree, AACButton, AACSemanticCategory } from '../src/core/treeStructure'; -import path from 'path'; -import fs from 'fs'; - -describe('AstericsGridProcessor', () => { - const exampleGrdFile = path.join(__dirname, 'assets/asterics/example2.grd'); - const tempOutputPath = path.join(__dirname, 'temp_test.grd'); +import { AstericsGridProcessor } from "../src/processors/astericsGridProcessor"; +import { + AACTree, + AACButton, + AACSemanticCategory, +} from "../src/core/treeStructure"; +import path from "path"; +import fs from "fs"; + +describe("AstericsGridProcessor", () => { + const exampleGrdFile = path.join(__dirname, "assets/asterics/example2.grd"); + const tempOutputPath = path.join(__dirname, "temp_test.grd"); afterEach(async () => { if (fs.existsSync(tempOutputPath)) { @@ -13,44 +17,50 @@ describe('AstericsGridProcessor', () => { } }); - it('should load an Asterics Grid file into an AACTree', async () => { + it("should load an Asterics Grid file into an AACTree", async () => { const processor = new AstericsGridProcessor(); const tree = await processor.loadIntoTree(exampleGrdFile); expect(tree).toBeInstanceOf(AACTree); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it('should extract texts from an Asterics Grid file', async () => { + it("should extract texts from an Asterics Grid file", async () => { const processor = new AstericsGridProcessor(); const texts = await processor.extractTexts(exampleGrdFile); expect(Array.isArray(texts)).toBe(true); expect(texts.length).toBeGreaterThan(0); - expect(texts).toContain('Change in element'); + expect(texts).toContain("Change in element"); }); - it('should process texts and save the changes', async () => { + it("should process texts and save the changes", async () => { const processor = new AstericsGridProcessor(); const translations = new Map(); - translations.set('Change in element', 'Changed Element'); + translations.set("Change in element", "Changed Element"); - const buffer = await processor.processTexts(exampleGrdFile, translations, tempOutputPath); + const buffer = await processor.processTexts( + exampleGrdFile, + translations, + tempOutputPath, + ); expect(Buffer.isBuffer(buffer)).toBe(true); const newTexts = await processor.extractTexts(tempOutputPath); - expect(newTexts).toContain('Changed Element'); + expect(newTexts).toContain("Changed Element"); }); - it('should perform a roundtrip (load -> save -> load)', async () => { + it("should perform a roundtrip (load -> save -> load)", async () => { const processor = new AstericsGridProcessor(); const initialTree = await processor.loadIntoTree(exampleGrdFile); await processor.saveFromTree(initialTree, tempOutputPath); const finalTree = await processor.loadIntoTree(tempOutputPath); - expect(Object.keys(finalTree.pages).length).toEqual(Object.keys(initialTree.pages).length); + expect(Object.keys(finalTree.pages).length).toEqual( + Object.keys(initialTree.pages).length, + ); // More detailed checks could be added here }); - it('should handle audio when the loadAudio option is true', async () => { + it("should handle audio when the loadAudio option is true", async () => { const processor = new AstericsGridProcessor({ loadAudio: true }); const tree = await processor.loadIntoTree(exampleGrdFile); @@ -67,24 +77,28 @@ describe('AstericsGridProcessor', () => { // This depends on the content of example2.grd having audio actions. // Based on the docs, GridActionAudio exists. We'll assume the example might have it. // If not, this test might need a dedicated test file with audio. - let content = fs.readFileSync(exampleGrdFile, 'utf-8'); + let content = fs.readFileSync(exampleGrdFile, "utf-8"); // Remove BOM if present if (content.charCodeAt(0) === 0xfeff) { content = content.slice(1); } const fileContent = JSON.parse(content); const hasAudioAction = fileContent.grids.some((g: any) => - g.gridElements.some((e: any) => e.actions.some((a: any) => a.modelName === 'GridActionAudio')) + g.gridElements.some((e: any) => + e.actions.some((a: any) => a.modelName === "GridActionAudio"), + ), ); if (hasAudioAction) { expect(foundAudioButton).toBe(true); } else { - console.warn('Test file does not contain audio actions, skipping audio assertion'); + console.warn( + "Test file does not contain audio actions, skipping audio assertion", + ); } }); - it('should extract comprehensive texts including multilingual labels', async () => { + it("should extract comprehensive texts including multilingual labels", async () => { const processor = new AstericsGridProcessor(); const texts = await processor.extractTexts(exampleGrdFile); @@ -92,13 +106,13 @@ describe('AstericsGridProcessor', () => { expect(texts.length).toBeGreaterThan(0); // Should contain various text elements from the example file - expect(texts).toContain('Change in element'); - expect(texts).toContain('Global grid'); - expect(texts).toContain('Next wordform'); - expect(texts).toContain('Home'); + expect(texts).toContain("Change in element"); + expect(texts).toContain("Global grid"); + expect(texts).toContain("Next wordform"); + expect(texts).toContain("Home"); }); - it('should handle multilingual content correctly', async () => { + it("should handle multilingual content correctly", async () => { const processor = new AstericsGridProcessor(); const tree = await processor.loadIntoTree(exampleGrdFile); @@ -111,7 +125,7 @@ describe('AstericsGridProcessor', () => { expect(pageNames.some((name) => name && name.length > 0)).toBe(true); }); - it('should handle navigation relationships correctly', async () => { + it("should handle navigation relationships correctly", async () => { const processor = new AstericsGridProcessor(); const tree = await processor.loadIntoTree(exampleGrdFile); @@ -135,7 +149,7 @@ describe('AstericsGridProcessor', () => { expect(foundNavigationButton).toBe(true); }); - it('should support audio enhancement methods', async () => { + it("should support audio enhancement methods", async () => { const processor = new AstericsGridProcessor(); // Test getElementIds method @@ -145,21 +159,24 @@ describe('AstericsGridProcessor', () => { // Test hasAudioRecording method const firstElementId = elementIds[0]; - const hasAudio = await processor.hasAudioRecording(exampleGrdFile, firstElementId); - expect(typeof hasAudio).toBe('boolean'); + const hasAudio = await processor.hasAudioRecording( + exampleGrdFile, + firstElementId, + ); + expect(typeof hasAudio).toBe("boolean"); }); - it('should handle word forms and advanced features', async () => { + it("should handle word forms and advanced features", async () => { const processor = new AstericsGridProcessor(); const texts = await processor.extractTexts(exampleGrdFile); // The example file contains word forms like "sein", "bin", "bist", etc. - expect(texts).toContain('sein'); - expect(texts).toContain('bin'); - expect(texts).toContain('am'); + expect(texts).toContain("sein"); + expect(texts).toContain("bin"); + expect(texts).toContain("am"); }); - it('should create proper AACButton objects with correct properties', async () => { + it("should create proper AACButton objects with correct properties", async () => { const processor = new AstericsGridProcessor(); const tree = await processor.loadIntoTree(exampleGrdFile); @@ -168,9 +185,9 @@ describe('AstericsGridProcessor', () => { page.buttons.forEach((button) => { foundButtons = true; expect(button).toBeInstanceOf(AACButton); - expect(typeof button.id).toBe('string'); - expect(typeof button.label).toBe('string'); - expect(typeof button.message).toBe('string'); + expect(typeof button.id).toBe("string"); + expect(typeof button.label).toBe("string"); + expect(typeof button.message).toBe("string"); // Check semantic action is present (modern approach, not button.type) expect(button.semanticAction).toBeDefined(); expect(button.semanticAction?.category).toBeDefined(); @@ -181,7 +198,7 @@ describe('AstericsGridProcessor', () => { expect(foundButtons).toBe(true); }); - it('should handle buffer input correctly', async () => { + it("should handle buffer input correctly", async () => { const processor = new AstericsGridProcessor(); const fileBuffer = fs.readFileSync(exampleGrdFile); @@ -194,31 +211,35 @@ describe('AstericsGridProcessor', () => { expect(texts.length).toBeGreaterThan(0); }); - it('should handle comprehensive translation processing', async () => { + it("should handle comprehensive translation processing", async () => { const processor = new AstericsGridProcessor(); const translations = new Map(); - translations.set('Change in element', 'Elemento Cambiado'); - translations.set('Global grid', 'Cuadrícula Global'); - translations.set('Home', 'Inicio'); - - const buffer = await processor.processTexts(exampleGrdFile, translations, tempOutputPath); + translations.set("Change in element", "Elemento Cambiado"); + translations.set("Global grid", "Cuadrícula Global"); + translations.set("Home", "Inicio"); + + const buffer = await processor.processTexts( + exampleGrdFile, + translations, + tempOutputPath, + ); expect(Buffer.isBuffer(buffer)).toBe(true); // Verify translations were applied const translatedTexts = await processor.extractTexts(tempOutputPath); - expect(translatedTexts).toContain('Elemento Cambiado'); - expect(translatedTexts).toContain('Cuadrícula Global'); - expect(translatedTexts).toContain('Inicio'); + expect(translatedTexts).toContain("Elemento Cambiado"); + expect(translatedTexts).toContain("Cuadrícula Global"); + expect(translatedTexts).toContain("Inicio"); }); - it('should preserve home page (tree.rootId) through roundtrip', async () => { + it("should preserve home page (tree.rootId) through roundtrip", async () => { const processor = new AstericsGridProcessor(); // Load the file and check if it has a rootId const initialTree = await processor.loadIntoTree(exampleGrdFile); // Read the original file to check if it has homeGridId in metadata - let content = fs.readFileSync(exampleGrdFile, 'utf-8'); + let content = fs.readFileSync(exampleGrdFile, "utf-8"); if (content.charCodeAt(0) === 0xfeff) { content = content.slice(1); } @@ -247,7 +268,7 @@ describe('AstericsGridProcessor', () => { expect(finalTree.rootId).toBe(initialTree.rootId); // Verify the saved file has homeGridId in metadata - let savedContent = fs.readFileSync(tempOutputPath, 'utf-8'); + let savedContent = fs.readFileSync(tempOutputPath, "utf-8"); if (savedContent.charCodeAt(0) === 0xfeff) { savedContent = savedContent.slice(1); } @@ -261,7 +282,7 @@ describe('AstericsGridProcessor', () => { } }); - it('should extract locale and supported languages into metadata', async () => { + it("should extract locale and supported languages into metadata", async () => { const processor = new AstericsGridProcessor(); const tree = await processor.loadIntoTree(exampleGrdFile); @@ -269,7 +290,7 @@ describe('AstericsGridProcessor', () => { expect(Array.isArray(tree.metadata.languages)).toBe(true); expect(tree.metadata.languages?.length).toBeGreaterThan(0); // At least English should be present in our example file - expect(tree.metadata.languages).toContain('en'); + expect(tree.metadata.languages).toContain("en"); // locale should be one of the languages expect(tree.metadata.languages).toContain(tree.metadata.locale); }); diff --git a/test/audit-images.test.ts b/test/audit-images.test.ts index 7e989db..81350c9 100644 --- a/test/audit-images.test.ts +++ b/test/audit-images.test.ts @@ -5,8 +5,8 @@ * are being resolved correctly by the processor. */ -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; -import path from 'node:path'; +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import path from "node:path"; interface AuditResult { totalCells: number; @@ -28,9 +28,17 @@ interface AuditResult { function countImageFilesInZip(entries: string[]): string[] { // Count all image files in the ZIP (excluding XML files) - const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg', '.webp']; + const imageExtensions = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".svg", + ".webp", + ]; return entries.filter((entry) => { - const ext = entry.toLowerCase().split('.').pop(); + const ext = entry.toLowerCase().split(".").pop(); return ext && imageExtensions.includes(`.${ext}`); }); } @@ -43,14 +51,14 @@ async function auditGridsetImages(gridsetPath: string): Promise { // Get all entries from the ZIP for manual inspection // We need to access the internal ZIP entries - const AdmZip = (await import('adm-zip')).default; + const AdmZip = (await import("adm-zip")).default; const zip = new AdmZip(gridsetPath); const allEntries = zip.getEntries().map((e: any) => e.entryName); const imageFilesInZip = countImageFilesInZip(allEntries); const resolvedImagePaths = new Set(); - const unresolvedCells: AuditResult['unresolvedCells'] = []; + const unresolvedCells: AuditResult["unresolvedCells"] = []; let totalCells = 0; let cellsWithDeclaredImages = 0; @@ -85,14 +93,17 @@ async function auditGridsetImages(gridsetPath: string): Promise { // Check for resolved images that aren't actually in the ZIP const resolvedImagesNotInZip = Array.from(resolvedImagePaths).filter( - (img) => !allEntries.includes(img) && !allEntries.includes(img.replace(/^Grids\//, '')) + (img) => + !allEntries.includes(img) && + !allEntries.includes(img.replace(/^Grids\//, "")), ); return { totalCells, cellsWithDeclaredImages, cellsWithResolvedImages, - cellsWithoutResolvedImages: cellsWithDeclaredImages - cellsWithResolvedImages, + cellsWithoutResolvedImages: + cellsWithDeclaredImages - cellsWithResolvedImages, actualImageFilesInZip: imageFilesInZip.length, resolvedImagePaths: Array.from(resolvedImagePaths), unresolvedCells, @@ -101,36 +112,49 @@ async function auditGridsetImages(gridsetPath: string): Promise { }; } -describe('Gridset Image Audit', () => { - const exampleGridset = path.join(process.cwd(), 'examples/example-images.gridset'); +describe("Gridset Image Audit", () => { + const exampleGridset = path.join( + process.cwd(), + "examples/example-images.gridset", + ); - test('should resolve all images that exist in the ZIP', async () => { + test("should resolve all images that exist in the ZIP", async () => { const audit = await auditGridsetImages(exampleGridset); - console.log('\n=== Gridset Image Audit ==='); + console.log("\n=== Gridset Image Audit ==="); console.log(`Total cells: ${audit.totalCells}`); console.log(`Cells with declared images: ${audit.cellsWithDeclaredImages}`); console.log(`Cells with resolved images: ${audit.cellsWithResolvedImages}`); - console.log(`Cells without resolved images: ${audit.cellsWithoutResolvedImages}`); + console.log( + `Cells without resolved images: ${audit.cellsWithoutResolvedImages}`, + ); console.log(`Actual image files in ZIP: ${audit.actualImageFilesInZip}`); - console.log(`Unique resolved image paths: ${audit.resolvedImagePaths.length}`); - console.log(`Resolved images not found in ZIP: ${audit.resolvedImagesNotInZip.length}`); + console.log( + `Unique resolved image paths: ${audit.resolvedImagePaths.length}`, + ); + console.log( + `Resolved images not found in ZIP: ${audit.resolvedImagesNotInZip.length}`, + ); if (audit.unresolvedCells.length > 0) { console.log(`\nUnresolved cells (${audit.unresolvedCells.length}):`); audit.unresolvedCells.forEach((cell) => { - console.log(` - "${cell.label}" at (${cell.x}, ${cell.y}): ${cell.imageName}`); + console.log( + ` - "${cell.label}" at (${cell.x}, ${cell.y}): ${cell.imageName}`, + ); }); } if (audit.resolvedImagesNotInZip.length > 0) { - console.log(`\nResolved images not in ZIP (${audit.resolvedImagesNotInZip.length}):`); + console.log( + `\nResolved images not in ZIP (${audit.resolvedImagesNotInZip.length}):`, + ); audit.resolvedImagesNotInZip.forEach((img) => { console.log(` - ${img}`); }); } - console.log('\n=== Sample resolved images ==='); + console.log("\n=== Sample resolved images ==="); audit.resolvedImagePaths.slice(0, 10).forEach((img) => { console.log(` - ${img}`); }); @@ -138,7 +162,7 @@ describe('Gridset Image Audit', () => { console.log(` ... and ${audit.resolvedImagePaths.length - 10} more`); } - console.log('\n=== Sample image files in ZIP ==='); + console.log("\n=== Sample image files in ZIP ==="); audit.imageFilesInZip.slice(0, 10).forEach((img) => { console.log(` - ${img}`); }); @@ -150,13 +174,15 @@ describe('Gridset Image Audit', () => { expect(audit.resolvedImagesNotInZip.length).toBe(0); // Log the summary - console.log('\n=== Summary ==='); - console.log(`✓ All ${audit.cellsWithResolvedImages} resolved images exist in the ZIP`); + console.log("\n=== Summary ==="); + console.log( + `✓ All ${audit.cellsWithResolvedImages} resolved images exist in the ZIP`, + ); console.log( - `✓ ${audit.cellsWithDeclaredImages - audit.cellsWithResolvedImages} cells could not be resolved` + `✓ ${audit.cellsWithDeclaredImages - audit.cellsWithResolvedImages} cells could not be resolved`, ); console.log( - `✓ ${audit.actualImageFilesInZip - audit.resolvedImagePaths.length} images in ZIP are not referenced by cells` + `✓ ${audit.actualImageFilesInZip - audit.resolvedImagePaths.length} images in ZIP are not referenced by cells`, ); }, 30000); }); diff --git a/test/browserBundle.output.test.ts b/test/browserBundle.output.test.ts index aac27d3..c590dc5 100644 --- a/test/browserBundle.output.test.ts +++ b/test/browserBundle.output.test.ts @@ -1,30 +1,30 @@ -import fs from 'fs'; -import path from 'path'; +import fs from "fs"; +import path from "path"; type PatternCheck = { pattern: RegExp; label: string }; -describe('Browser bundle output', () => { - it('should not include Node.js module references', () => { - const distDir = path.join(__dirname, '..', 'dist', 'browser'); +describe("Browser bundle output", () => { + it("should not include Node.js module references", () => { + const distDir = path.join(__dirname, "..", "dist", "browser"); expect(fs.existsSync(distDir)).toBe(true); const patterns: PatternCheck[] = [ - { pattern: /__vite-browser-external/, label: '__vite-browser-external' }, + { pattern: /__vite-browser-external/, label: "__vite-browser-external" }, { pattern: /require\(['"]fs['"]\)/, label: 'require("fs")' }, - { pattern: /from ['"]fs['"]/, label: 'import fs' }, + { pattern: /from ['"]fs['"]/, label: "import fs" }, { pattern: /require\(['"]path['"]\)/, label: 'require("path")' }, - { pattern: /from ['"]path['"]/, label: 'import path' }, + { pattern: /from ['"]path['"]/, label: "import path" }, ]; const targetFiles = [ - path.join(distDir, 'processors/gridset/symbols.js'), - path.join(distDir, 'processors/gridset/password.js'), - path.join(distDir, 'validation/gridsetValidator.js'), + path.join(distDir, "processors/gridset/symbols.js"), + path.join(distDir, "processors/gridset/password.js"), + path.join(distDir, "validation/gridsetValidator.js"), ].filter((filePath) => fs.existsSync(filePath)); const offenders: string[] = []; for (const file of targetFiles) { - const content = fs.readFileSync(file, 'utf8'); + const content = fs.readFileSync(file, "utf8"); for (const { pattern, label } of patterns) { if (pattern.test(content)) { offenders.push(`${file}: ${label}`); diff --git a/test/browserCompatibility.test.ts b/test/browserCompatibility.test.ts index af7e824..6ca04ca 100644 --- a/test/browserCompatibility.test.ts +++ b/test/browserCompatibility.test.ts @@ -5,22 +5,22 @@ * as they would be used in a browser environment (no file paths, only buffers). */ -import { readFileSync } from 'fs'; -import path from 'path'; +import { readFileSync } from "fs"; +import path from "path"; import { DotProcessor, OpmlProcessor, ObfProcessor, GridsetProcessor, AstericsGridProcessor, -} from '../src/index'; -import { AACTree } from '../src/core/treeStructure'; +} from "../src/index"; +import { AACTree } from "../src/core/treeStructure"; -describe('Browser Compatibility', () => { - describe('DotProcessor with buffers', () => { - const examplePath = path.join(__dirname, 'assets/dot/example.dot'); +describe("Browser Compatibility", () => { + describe("DotProcessor with buffers", () => { + const examplePath = path.join(__dirname, "assets/dot/example.dot"); - it('should load from Buffer', async () => { + it("should load from Buffer", async () => { const processor = new DotProcessor(); const buffer = readFileSync(examplePath); const tree: AACTree = await processor.loadIntoTree(buffer); @@ -29,7 +29,7 @@ describe('Browser Compatibility', () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it('should load from Uint8Array', async () => { + it("should load from Uint8Array", async () => { const processor = new DotProcessor(); const buffer = readFileSync(examplePath); const uint8Array = new Uint8Array(buffer); @@ -39,7 +39,7 @@ describe('Browser Compatibility', () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it('should extract texts from Buffer', async () => { + it("should extract texts from Buffer", async () => { const processor = new DotProcessor(); const buffer = readFileSync(examplePath); const texts = await processor.extractTexts(buffer); @@ -49,10 +49,10 @@ describe('Browser Compatibility', () => { }); }); - describe('OpmlProcessor with buffers', () => { - const examplePath = path.join(__dirname, 'assets/opml/example.opml'); + describe("OpmlProcessor with buffers", () => { + const examplePath = path.join(__dirname, "assets/opml/example.opml"); - it('should load from Buffer', async () => { + it("should load from Buffer", async () => { const processor = new OpmlProcessor(); const buffer = readFileSync(examplePath); const tree: AACTree = await processor.loadIntoTree(buffer); @@ -61,7 +61,7 @@ describe('Browser Compatibility', () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it('should load from Uint8Array', async () => { + it("should load from Uint8Array", async () => { const processor = new OpmlProcessor(); const buffer = readFileSync(examplePath); const uint8Array = new Uint8Array(buffer); @@ -72,10 +72,10 @@ describe('Browser Compatibility', () => { }); }); - describe('ObfProcessor with buffers', () => { - const examplePath = path.join(__dirname, 'assets/obf/simple.obf'); + describe("ObfProcessor with buffers", () => { + const examplePath = path.join(__dirname, "assets/obf/simple.obf"); - it('should load OBF from Buffer', async () => { + it("should load OBF from Buffer", async () => { const processor = new ObfProcessor(); const buffer = readFileSync(examplePath); const tree: AACTree = await processor.loadIntoTree(buffer); @@ -84,12 +84,12 @@ describe('Browser Compatibility', () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it('should load OBF from ArrayBuffer', async () => { + it("should load OBF from ArrayBuffer", async () => { const processor = new ObfProcessor(); const buffer = readFileSync(examplePath); const arrayBuffer = buffer.buffer.slice( buffer.byteOffset, - buffer.byteOffset + buffer.byteLength + buffer.byteOffset + buffer.byteLength, ); const tree: AACTree = await processor.loadIntoTree(arrayBuffer); @@ -98,10 +98,10 @@ describe('Browser Compatibility', () => { }); }); - describe('GridsetProcessor with buffers', () => { - const examplePath = path.join(__dirname, 'assets/gridset/example.gridset'); + describe("GridsetProcessor with buffers", () => { + const examplePath = path.join(__dirname, "assets/gridset/example.gridset"); - it('should load Gridset from Buffer', async () => { + it("should load Gridset from Buffer", async () => { const processor = new GridsetProcessor(); const buffer = readFileSync(examplePath); const tree: AACTree = await processor.loadIntoTree(buffer); @@ -110,7 +110,7 @@ describe('Browser Compatibility', () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it('should load Gridset from Uint8Array', async () => { + it("should load Gridset from Uint8Array", async () => { const processor = new GridsetProcessor(); const buffer = readFileSync(examplePath); const uint8Array = new Uint8Array(buffer); @@ -121,18 +121,18 @@ describe('Browser Compatibility', () => { }); }); - describe('ApplePanelsProcessor with buffers', () => { - it('should load from Buffer - skipped (no test asset)', async () => { + describe("ApplePanelsProcessor with buffers", () => { + it("should load from Buffer - skipped (no test asset)", async () => { // ApplePanels tests create data programmatically // See test/applePanelsProcessor.roundtrip.test.ts for ApplePanels tests expect(true).toBe(true); }); }); - describe('AstericsGridProcessor with buffers', () => { - const examplePath = path.join(__dirname, 'assets/asterics/example.grd'); + describe("AstericsGridProcessor with buffers", () => { + const examplePath = path.join(__dirname, "assets/asterics/example.grd"); - it('should load from Buffer', async () => { + it("should load from Buffer", async () => { const processor = new AstericsGridProcessor(); const buffer = readFileSync(examplePath); const tree: AACTree = await processor.loadIntoTree(buffer); @@ -141,34 +141,34 @@ describe('Browser Compatibility', () => { }); }); - describe('Browser factory function', () => { - it('getProcessor should work with extensions', async () => { - const { getProcessor } = await import('../src/index.browser'); + describe("Browser factory function", () => { + it("getProcessor should work with extensions", async () => { + const { getProcessor } = await import("../src/index.browser"); - const dotProcessor = getProcessor('.dot'); + const dotProcessor = getProcessor(".dot"); expect(dotProcessor).toBeInstanceOf(DotProcessor); - const opmlProcessor = getProcessor('.opml'); + const opmlProcessor = getProcessor(".opml"); expect(opmlProcessor).toBeInstanceOf(OpmlProcessor); - const obfProcessor = getProcessor('.obf'); + const obfProcessor = getProcessor(".obf"); expect(obfProcessor).toBeInstanceOf(ObfProcessor); - const gridsetProcessor = getProcessor('.gridset'); + const gridsetProcessor = getProcessor(".gridset"); expect(gridsetProcessor).toBeInstanceOf(GridsetProcessor); }); - it('getSupportedExtensions should return browser-supported extensions', async () => { - const { getSupportedExtensions } = await import('../src/index.browser'); + it("getSupportedExtensions should return browser-supported extensions", async () => { + const { getSupportedExtensions } = await import("../src/index.browser"); const extensions = getSupportedExtensions(); - expect(extensions).toContain('.dot'); - expect(extensions).toContain('.opml'); - expect(extensions).toContain('.obf'); - expect(extensions).toContain('.obz'); - expect(extensions).toContain('.gridset'); - expect(extensions).toContain('.plist'); - expect(extensions).toContain('.grd'); + expect(extensions).toContain(".dot"); + expect(extensions).toContain(".opml"); + expect(extensions).toContain(".obf"); + expect(extensions).toContain(".obz"); + expect(extensions).toContain(".gridset"); + expect(extensions).toContain(".plist"); + expect(extensions).toContain(".grd"); }); }); }); diff --git a/test/cli.comprehensive.test.ts b/test/cli.comprehensive.test.ts index 7560574..8f1c4a0 100644 --- a/test/cli.comprehensive.test.ts +++ b/test/cli.comprehensive.test.ts @@ -1,14 +1,14 @@ // Comprehensive CLI tests to achieve 90%+ coverage -import { execSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; -import { TreeFactory } from './utils/testFactories'; -import { DotProcessor } from '../src/processors/dotProcessor'; +import { execSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import { TreeFactory } from "./utils/testFactories"; +import { DotProcessor } from "../src/processors/dotProcessor"; -describe('CLI Comprehensive Tests', () => { - const tempDir = path.join(__dirname, 'temp_cli'); - const cliPath = path.join(__dirname, '../dist/cli/index.js'); - const examplesDir = path.join(__dirname, '../examples'); +describe("CLI Comprehensive Tests", () => { + const tempDir = path.join(__dirname, "temp_cli"); + const cliPath = path.join(__dirname, "../dist/cli/index.js"); + const examplesDir = path.join(__dirname, "../examples"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -17,7 +17,7 @@ describe('CLI Comprehensive Tests', () => { if (!fs.existsSync(cliPath)) { throw new Error( - 'dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.' + "dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.", ); } }); @@ -28,76 +28,79 @@ describe('CLI Comprehensive Tests', () => { } }); - describe('Command Parsing Tests', () => { - it('should parse extract command correctly', async () => { + describe("Command Parsing Tests", () => { + it("should parse extract command correctly", async () => { // Create a test DOT file const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const testFile = path.join(tempDir, 'test.dot'); + const testFile = path.join(tempDir, "test.dot"); await processor.saveFromTree(tree, testFile); const result = execSync(`node ${cliPath} extract ${testFile}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); // DOT processor only extracts navigation relationships and page names - expect(result).toContain('Home'); - expect(result).toContain('More'); // Navigation button label - expect(result.trim().split('\n').length).toBeGreaterThan(0); + expect(result).toContain("Home"); + expect(result).toContain("More"); // Navigation button label + expect(result.trim().split("\n").length).toBeGreaterThan(0); }); - it('should parse convert command with all options', async () => { + it("should parse convert command with all options", async () => { const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const inputFile = path.join(tempDir, 'input.dot'); - const outputFile = path.join(tempDir, 'output.opml'); + const inputFile = path.join(tempDir, "input.dot"); + const outputFile = path.join(tempDir, "output.opml"); await processor.saveFromTree(tree, inputFile); - const result = execSync(`node ${cliPath} convert ${inputFile} ${outputFile} --format opml`, { - encoding: 'utf8', - cwd: tempDir, - }); + const result = execSync( + `node ${cliPath} convert ${inputFile} ${outputFile} --format opml`, + { + encoding: "utf8", + cwd: tempDir, + }, + ); expect(fs.existsSync(outputFile)).toBe(true); - expect(result).toContain('converted'); + expect(result).toContain("converted"); }); - it('should handle invalid command arguments gracefully', async () => { + it("should handle invalid command arguments gracefully", async () => { expect(() => { execSync(`node ${cliPath} invalidcommand`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, - stdio: 'pipe', + stdio: "pipe", }); }).toThrow(); }); - it('should show help when no arguments provided', async () => { + it("should show help when no arguments provided", async () => { const result = execSync(`node ${cliPath}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); - expect(result).toContain('Usage:'); - expect(result).toContain('extract'); - expect(result).toContain('convert'); + expect(result).toContain("Usage:"); + expect(result).toContain("extract"); + expect(result).toContain("convert"); }); - it('should show help with --help flag', async () => { + it("should show help with --help flag", async () => { const result = execSync(`node ${cliPath} --help`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); - expect(result).toContain('Usage:'); - expect(result).toContain('Commands:'); + expect(result).toContain("Usage:"); + expect(result).toContain("Commands:"); }); - it('should show version with --version flag', async () => { + it("should show version with --version flag", async () => { const result = execSync(`node ${cliPath} --version`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); @@ -105,273 +108,285 @@ describe('CLI Comprehensive Tests', () => { }); }); - describe('File Processing Tests', () => { - it('should extract text from DOT format via CLI', async () => { + describe("File Processing Tests", () => { + it("should extract text from DOT format via CLI", async () => { const tree = TreeFactory.createCommunicationBoard(); const processor = new DotProcessor(); - const testFile = path.join(tempDir, 'communication.dot'); + const testFile = path.join(tempDir, "communication.dot"); await processor.saveFromTree(tree, testFile); const result = execSync(`node ${cliPath} extract ${testFile}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); // DOT processor extracts page names and navigation button labels - expect(result).toContain('Home'); - expect(result).toContain('Food'); // Page name, not button label - expect(result).toContain('Activities'); // Page name + expect(result).toContain("Home"); + expect(result).toContain("Food"); // Page name, not button label + expect(result).toContain("Activities"); // Page name }); - it('should extract text from OPML format via CLI', async () => { + it("should extract text from OPML format via CLI", async () => { // Create an OPML file first const tree = TreeFactory.createSimple(); const dotProcessor = new DotProcessor(); - const dotFile = path.join(tempDir, 'temp.dot'); + const dotFile = path.join(tempDir, "temp.dot"); await dotProcessor.saveFromTree(tree, dotFile); // Convert to OPML - const opmlFile = path.join(tempDir, 'test.opml'); + const opmlFile = path.join(tempDir, "test.opml"); execSync(`node ${cliPath} convert ${dotFile} ${opmlFile} --format opml`, { cwd: tempDir, - stdio: 'pipe', + stdio: "pipe", }); // Extract from OPML const result = execSync(`node ${cliPath} extract ${opmlFile}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); - expect(result).toContain('Home'); + expect(result).toContain("Home"); }); - it('should convert DOT to OPML format', async () => { + it("should convert DOT to OPML format", async () => { const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const inputFile = path.join(tempDir, 'dot_to_opml.dot'); - const outputFile = path.join(tempDir, 'dot_to_opml.opml'); + const inputFile = path.join(tempDir, "dot_to_opml.dot"); + const outputFile = path.join(tempDir, "dot_to_opml.opml"); await processor.saveFromTree(tree, inputFile); - execSync(`node ${cliPath} convert ${inputFile} ${outputFile} --format opml`, { - cwd: tempDir, - stdio: 'pipe', - }); + execSync( + `node ${cliPath} convert ${inputFile} ${outputFile} --format opml`, + { + cwd: tempDir, + stdio: "pipe", + }, + ); expect(fs.existsSync(outputFile)).toBe(true); - const content = fs.readFileSync(outputFile, 'utf8'); - expect(content).toContain(' { + it("should convert OPML to DOT format", async () => { // First create an OPML file const tree = TreeFactory.createSimple(); const dotProcessor = new DotProcessor(); - const tempDotFile = path.join(tempDir, 'temp_for_opml.dot'); - const opmlFile = path.join(tempDir, 'opml_to_dot.opml'); - const finalDotFile = path.join(tempDir, 'opml_to_dot.dot'); + const tempDotFile = path.join(tempDir, "temp_for_opml.dot"); + const opmlFile = path.join(tempDir, "opml_to_dot.opml"); + const finalDotFile = path.join(tempDir, "opml_to_dot.dot"); await dotProcessor.saveFromTree(tree, tempDotFile); // Convert to OPML first - execSync(`node ${cliPath} convert ${tempDotFile} ${opmlFile} --format opml`, { - cwd: tempDir, - stdio: 'pipe', - }); + execSync( + `node ${cliPath} convert ${tempDotFile} ${opmlFile} --format opml`, + { + cwd: tempDir, + stdio: "pipe", + }, + ); // Convert back to DOT - execSync(`node ${cliPath} convert ${opmlFile} ${finalDotFile} --format dot`, { - cwd: tempDir, - stdio: 'pipe', - }); + execSync( + `node ${cliPath} convert ${opmlFile} ${finalDotFile} --format dot`, + { + cwd: tempDir, + stdio: "pipe", + }, + ); expect(fs.existsSync(finalDotFile)).toBe(true); - const content = fs.readFileSync(finalDotFile, 'utf8'); - expect(content).toContain('digraph'); + const content = fs.readFileSync(finalDotFile, "utf8"); + expect(content).toContain("digraph"); }); - it('should handle file not found errors', async () => { - const nonExistentFile = path.join(tempDir, 'does_not_exist.dot'); + it("should handle file not found errors", async () => { + const nonExistentFile = path.join(tempDir, "does_not_exist.dot"); expect(() => { execSync(`node ${cliPath} extract ${nonExistentFile}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, - stdio: 'pipe', + stdio: "pipe", }); }).toThrow(); }); - it('should handle unsupported file formats', async () => { - const unsupportedFile = path.join(tempDir, 'unsupported.xyz'); - fs.writeFileSync(unsupportedFile, 'unsupported content'); + it("should handle unsupported file formats", async () => { + const unsupportedFile = path.join(tempDir, "unsupported.xyz"); + fs.writeFileSync(unsupportedFile, "unsupported content"); expect(() => { execSync(`node ${cliPath} extract ${unsupportedFile}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, - stdio: 'pipe', + stdio: "pipe", }); }).toThrow(); }); }); - describe('Output Formatting Tests', () => { - it('should format output correctly for different formats', async () => { + describe("Output Formatting Tests", () => { + it("should format output correctly for different formats", async () => { const tree = TreeFactory.createCommunicationBoard(); const processor = new DotProcessor(); - const testFile = path.join(tempDir, 'format_test.dot'); + const testFile = path.join(tempDir, "format_test.dot"); await processor.saveFromTree(tree, testFile); // Test default format const defaultResult = execSync(`node ${cliPath} extract ${testFile}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); - expect(defaultResult).toContain('Home'); - expect(typeof defaultResult).toBe('string'); + expect(defaultResult).toContain("Home"); + expect(typeof defaultResult).toBe("string"); }); - it('should handle verbose output mode', async () => { + it("should handle verbose output mode", async () => { const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const testFile = path.join(tempDir, 'verbose_test.dot'); + const testFile = path.join(tempDir, "verbose_test.dot"); await processor.saveFromTree(tree, testFile); const result = execSync(`node ${cliPath} extract ${testFile} --verbose`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); - expect(result).toContain('Home'); + expect(result).toContain("Home"); // Verbose mode might include additional information }); - it('should handle quiet output mode', async () => { + it("should handle quiet output mode", async () => { const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const testFile = path.join(tempDir, 'quiet_test.dot'); + const testFile = path.join(tempDir, "quiet_test.dot"); await processor.saveFromTree(tree, testFile); const result = execSync(`node ${cliPath} extract ${testFile} --quiet`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); // Quiet mode should still return the extracted text - expect(result).toContain('Home'); + expect(result).toContain("Home"); }); - it('should display help information correctly', async () => { + it("should display help information correctly", async () => { const helpResult = execSync(`node ${cliPath} help`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); - expect(helpResult).toContain('Usage:'); - expect(helpResult).toContain('extract'); - expect(helpResult).toContain('convert'); - expect(helpResult).toContain('Options:'); + expect(helpResult).toContain("Usage:"); + expect(helpResult).toContain("extract"); + expect(helpResult).toContain("convert"); + expect(helpResult).toContain("Options:"); }); - it('should display command-specific help', async () => { + it("should display command-specific help", async () => { const extractHelp = execSync(`node ${cliPath} help extract`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); - expect(extractHelp).toContain('extract'); - expect(extractHelp).toContain('file'); + expect(extractHelp).toContain("extract"); + expect(extractHelp).toContain("file"); }); }); - describe('Integration Tests', () => { - it('should process example.dot file correctly', async () => { - const exampleDotFile = path.join(examplesDir, 'example.dot'); + describe("Integration Tests", () => { + it("should process example.dot file correctly", async () => { + const exampleDotFile = path.join(examplesDir, "example.dot"); if (fs.existsSync(exampleDotFile)) { const result = execSync(`node ${cliPath} extract ${exampleDotFile}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); expect(result).toBeDefined(); expect(result.length).toBeGreaterThan(0); } else { - console.log('Skipping test - example.dot not found'); + console.log("Skipping test - example.dot not found"); } }); - it('should convert example.obf to dot format', async () => { - const exampleObfFile = path.join(examplesDir, 'example.obf'); + it("should convert example.obf to dot format", async () => { + const exampleObfFile = path.join(examplesDir, "example.obf"); if (fs.existsSync(exampleObfFile)) { - const outputFile = path.join(tempDir, 'converted_example.dot'); + const outputFile = path.join(tempDir, "converted_example.dot"); - execSync(`node ${cliPath} convert ${exampleObfFile} ${outputFile} --format dot`, { - cwd: tempDir, - stdio: 'pipe', - }); + execSync( + `node ${cliPath} convert ${exampleObfFile} ${outputFile} --format dot`, + { + cwd: tempDir, + stdio: "pipe", + }, + ); expect(fs.existsSync(outputFile)).toBe(true); } else { - console.log('Skipping test - example.obf not found'); + console.log("Skipping test - example.obf not found"); } }); - it('should handle batch processing of multiple files', async () => { + it("should handle batch processing of multiple files", async () => { // Create multiple test files const tree1 = TreeFactory.createSimple(); const tree2 = TreeFactory.createCommunicationBoard(); const processor = new DotProcessor(); - const file1 = path.join(tempDir, 'batch1.dot'); - const file2 = path.join(tempDir, 'batch2.dot'); + const file1 = path.join(tempDir, "batch1.dot"); + const file2 = path.join(tempDir, "batch2.dot"); await processor.saveFromTree(tree1, file1); await processor.saveFromTree(tree2, file2); // Process each file const result1 = execSync(`node ${cliPath} extract ${file1}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); const result2 = execSync(`node ${cliPath} extract ${file2}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, }); - expect(result1).toContain('Home'); - expect(result2).toContain('Home'); - expect(result2).toContain('Food'); + expect(result1).toContain("Home"); + expect(result2).toContain("Home"); + expect(result2).toContain("Food"); }); }); - describe('Error Handling Tests', () => { - it('should display helpful error messages for invalid files', async () => { - const invalidFile = path.join(tempDir, 'invalid.dot'); - fs.writeFileSync(invalidFile, 'invalid dot content'); + describe("Error Handling Tests", () => { + it("should display helpful error messages for invalid files", async () => { + const invalidFile = path.join(tempDir, "invalid.dot"); + fs.writeFileSync(invalidFile, "invalid dot content"); try { execSync(`node ${cliPath} extract ${invalidFile}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, - stdio: 'pipe', + stdio: "pipe", }); } catch (error: any) { - expect(error.message).toContain('Command failed'); + expect(error.message).toContain("Command failed"); } }); - it('should handle permission errors gracefully', async () => { + it("should handle permission errors gracefully", async () => { // Create a file and remove read permissions (on Unix systems) - const restrictedFile = path.join(tempDir, 'restricted.dot'); + const restrictedFile = path.join(tempDir, "restricted.dot"); const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); await processor.saveFromTree(tree, restrictedFile); @@ -382,16 +397,18 @@ describe('CLI Comprehensive Tests', () => { try { execSync(`node ${cliPath} extract ${restrictedFile}`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, - stdio: 'pipe', + stdio: "pipe", }); } catch (error: any) { - expect(error.message).toContain('Command failed'); + expect(error.message).toContain("Command failed"); } } catch (_permissionError) { // If we can't change permissions, skip this test - console.log('Skipping permission test - unable to change file permissions'); + console.log( + "Skipping permission test - unable to change file permissions", + ); } finally { // Restore permissions for cleanup try { @@ -402,47 +419,50 @@ describe('CLI Comprehensive Tests', () => { } }); - it('should provide usage help for incorrect commands', async () => { + it("should provide usage help for incorrect commands", async () => { try { execSync(`node ${cliPath} wrongcommand`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, - stdio: 'pipe', + stdio: "pipe", }); } catch (error: any) { - expect(error.message).toContain('Command failed'); + expect(error.message).toContain("Command failed"); } }); - it('should handle missing required arguments', async () => { + it("should handle missing required arguments", async () => { try { execSync(`node ${cliPath} extract`, { - encoding: 'utf8', + encoding: "utf8", cwd: tempDir, - stdio: 'pipe', + stdio: "pipe", }); } catch (error: any) { - expect(error.message).toContain('Command failed'); + expect(error.message).toContain("Command failed"); } }); - it('should handle invalid output paths for convert command', async () => { + it("should handle invalid output paths for convert command", async () => { const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const inputFile = path.join(tempDir, 'valid_input.dot'); + const inputFile = path.join(tempDir, "valid_input.dot"); await processor.saveFromTree(tree, inputFile); // Try to write to an invalid path - const invalidOutputPath = '/invalid/path/output.opml'; + const invalidOutputPath = "/invalid/path/output.opml"; try { - execSync(`node ${cliPath} convert ${inputFile} ${invalidOutputPath} --format opml`, { - encoding: 'utf8', - cwd: tempDir, - stdio: 'pipe', - }); + execSync( + `node ${cliPath} convert ${inputFile} ${invalidOutputPath} --format opml`, + { + encoding: "utf8", + cwd: tempDir, + stdio: "pipe", + }, + ); } catch (error: any) { - expect(error.message).toContain('Command failed'); + expect(error.message).toContain("Command failed"); } }); }); diff --git a/test/colorUtils.test.ts b/test/colorUtils.test.ts index 58b931f..f0bd027 100644 --- a/test/colorUtils.test.ts +++ b/test/colorUtils.test.ts @@ -8,42 +8,42 @@ import { darkenColor, normalizeColor, ensureAlphaChannel, -} from '../src/processors/gridset/colorUtils'; +} from "../src/processors/gridset/colorUtils"; -describe('Color Utilities', () => { - describe('getNamedColor', () => { - it('returns RGB values for valid CSS color names', async () => { - expect(getNamedColor('red')).toEqual([255, 0, 0]); - expect(getNamedColor('blue')).toEqual([0, 0, 255]); - expect(getNamedColor('green')).toEqual([0, 128, 0]); - expect(getNamedColor('white')).toEqual([255, 255, 255]); - expect(getNamedColor('black')).toEqual([0, 0, 0]); +describe("Color Utilities", () => { + describe("getNamedColor", () => { + it("returns RGB values for valid CSS color names", async () => { + expect(getNamedColor("red")).toEqual([255, 0, 0]); + expect(getNamedColor("blue")).toEqual([0, 0, 255]); + expect(getNamedColor("green")).toEqual([0, 128, 0]); + expect(getNamedColor("white")).toEqual([255, 255, 255]); + expect(getNamedColor("black")).toEqual([0, 0, 0]); }); - it('is case-insensitive', async () => { - expect(getNamedColor('RED')).toEqual([255, 0, 0]); - expect(getNamedColor('Red')).toEqual([255, 0, 0]); - expect(getNamedColor('cornflowerblue')).toEqual([100, 149, 237]); - expect(getNamedColor('CORNFLOWERBLUE')).toEqual([100, 149, 237]); + it("is case-insensitive", async () => { + expect(getNamedColor("RED")).toEqual([255, 0, 0]); + expect(getNamedColor("Red")).toEqual([255, 0, 0]); + expect(getNamedColor("cornflowerblue")).toEqual([100, 149, 237]); + expect(getNamedColor("CORNFLOWERBLUE")).toEqual([100, 149, 237]); }); - it('returns undefined for invalid color names', async () => { - expect(getNamedColor('notacolor')).toBeUndefined(); - expect(getNamedColor('xyz')).toBeUndefined(); + it("returns undefined for invalid color names", async () => { + expect(getNamedColor("notacolor")).toBeUndefined(); + expect(getNamedColor("xyz")).toBeUndefined(); }); - it('supports all 147 CSS color names', async () => { + it("supports all 147 CSS color names", async () => { const colors = [ - 'aliceblue', - 'antiquewhite', - 'aqua', - 'aquamarine', - 'azure', - 'rebeccapurple', - 'yellowgreen', - 'whitesmoke', - 'wheat', - 'white', + "aliceblue", + "antiquewhite", + "aqua", + "aquamarine", + "azure", + "rebeccapurple", + "yellowgreen", + "whitesmoke", + "wheat", + "white", ]; colors.forEach((color) => { expect(getNamedColor(color)).toBeDefined(); @@ -51,27 +51,27 @@ describe('Color Utilities', () => { }); }); - describe('channelToHex', () => { - it('converts channel values to hex', async () => { - expect(channelToHex(0)).toBe('00'); - expect(channelToHex(255)).toBe('FF'); - expect(channelToHex(128)).toBe('80'); - expect(channelToHex(16)).toBe('10'); + describe("channelToHex", () => { + it("converts channel values to hex", async () => { + expect(channelToHex(0)).toBe("00"); + expect(channelToHex(255)).toBe("FF"); + expect(channelToHex(128)).toBe("80"); + expect(channelToHex(16)).toBe("10"); }); - it('clamps values to 0-255 range', async () => { - expect(channelToHex(-10)).toBe('00'); - expect(channelToHex(300)).toBe('FF'); + it("clamps values to 0-255 range", async () => { + expect(channelToHex(-10)).toBe("00"); + expect(channelToHex(300)).toBe("FF"); }); - it('rounds decimal values', async () => { - expect(channelToHex(127.5)).toBe('80'); - expect(channelToHex(127.4)).toBe('7F'); + it("rounds decimal values", async () => { + expect(channelToHex(127.5)).toBe("80"); + expect(channelToHex(127.4)).toBe("7F"); }); }); - describe('clampColorChannel', () => { - it('clamps values to 0-255 range', async () => { + describe("clampColorChannel", () => { + it("clamps values to 0-255 range", async () => { expect(clampColorChannel(0)).toBe(0); expect(clampColorChannel(255)).toBe(255); expect(clampColorChannel(128)).toBe(128); @@ -79,13 +79,13 @@ describe('Color Utilities', () => { expect(clampColorChannel(300)).toBe(255); }); - it('returns 0 for NaN', async () => { + it("returns 0 for NaN", async () => { expect(clampColorChannel(NaN)).toBe(0); }); }); - describe('clampAlpha', () => { - it('clamps values to 0-1 range', async () => { + describe("clampAlpha", () => { + it("clamps values to 0-1 range", async () => { expect(clampAlpha(0)).toBe(0); expect(clampAlpha(1)).toBe(1); expect(clampAlpha(0.5)).toBe(0.5); @@ -93,138 +93,138 @@ describe('Color Utilities', () => { expect(clampAlpha(1.5)).toBe(1); }); - it('returns 1 for NaN', async () => { + it("returns 1 for NaN", async () => { expect(clampAlpha(NaN)).toBe(1); }); }); - describe('rgbaToHex', () => { - it('converts RGBA to hex format', async () => { - expect(rgbaToHex(255, 0, 0, 1)).toBe('#FF0000FF'); - expect(rgbaToHex(0, 255, 0, 1)).toBe('#00FF00FF'); - expect(rgbaToHex(0, 0, 255, 1)).toBe('#0000FFFF'); + describe("rgbaToHex", () => { + it("converts RGBA to hex format", async () => { + expect(rgbaToHex(255, 0, 0, 1)).toBe("#FF0000FF"); + expect(rgbaToHex(0, 255, 0, 1)).toBe("#00FF00FF"); + expect(rgbaToHex(0, 0, 255, 1)).toBe("#0000FFFF"); }); - it('handles alpha channel correctly', async () => { - expect(rgbaToHex(255, 0, 0, 0.5)).toBe('#FF000080'); - expect(rgbaToHex(255, 0, 0, 0)).toBe('#FF000000'); - expect(rgbaToHex(255, 0, 0, 1)).toBe('#FF0000FF'); + it("handles alpha channel correctly", async () => { + expect(rgbaToHex(255, 0, 0, 0.5)).toBe("#FF000080"); + expect(rgbaToHex(255, 0, 0, 0)).toBe("#FF000000"); + expect(rgbaToHex(255, 0, 0, 1)).toBe("#FF0000FF"); }); - it('clamps values to valid ranges', async () => { - expect(rgbaToHex(300, -10, 128, 1.5)).toBe('#FF0080FF'); + it("clamps values to valid ranges", async () => { + expect(rgbaToHex(300, -10, 128, 1.5)).toBe("#FF0080FF"); }); }); - describe('toHexColor', () => { - it('converts hex colors', async () => { - expect(toHexColor('#FF0000')).toBe('#FF0000'); - expect(toHexColor('#F00')).toBe('#FF0000'); - expect(toHexColor('#FF0000FF')).toBe('#FF0000FF'); + describe("toHexColor", () => { + it("converts hex colors", async () => { + expect(toHexColor("#FF0000")).toBe("#FF0000"); + expect(toHexColor("#F00")).toBe("#FF0000"); + expect(toHexColor("#FF0000FF")).toBe("#FF0000FF"); }); - it('converts RGB colors', async () => { - expect(toHexColor('rgb(255, 0, 0)')).toBe('#FF0000FF'); - expect(toHexColor('rgb(0, 255, 0)')).toBe('#00FF00FF'); + it("converts RGB colors", async () => { + expect(toHexColor("rgb(255, 0, 0)")).toBe("#FF0000FF"); + expect(toHexColor("rgb(0, 255, 0)")).toBe("#00FF00FF"); }); - it('converts RGBA colors', async () => { - expect(toHexColor('rgba(255, 0, 0, 1)')).toBe('#FF0000FF'); - expect(toHexColor('rgba(255, 0, 0, 0.5)')).toBe('#FF000080'); + it("converts RGBA colors", async () => { + expect(toHexColor("rgba(255, 0, 0, 1)")).toBe("#FF0000FF"); + expect(toHexColor("rgba(255, 0, 0, 0.5)")).toBe("#FF000080"); }); - it('converts CSS color names', async () => { - expect(toHexColor('red')).toBe('#FF0000FF'); - expect(toHexColor('blue')).toBe('#0000FFFF'); - expect(toHexColor('cornflowerblue')).toBe('#6495EDFF'); + it("converts CSS color names", async () => { + expect(toHexColor("red")).toBe("#FF0000FF"); + expect(toHexColor("blue")).toBe("#0000FFFF"); + expect(toHexColor("cornflowerblue")).toBe("#6495EDFF"); }); - it('returns undefined for invalid colors', async () => { - expect(toHexColor('notacolor')).toBeUndefined(); - expect(toHexColor('rgb(999, 999, 999)')).toBeDefined(); // Clamped + it("returns undefined for invalid colors", async () => { + expect(toHexColor("notacolor")).toBeUndefined(); + expect(toHexColor("rgb(999, 999, 999)")).toBeDefined(); // Clamped }); - it('is case-insensitive for hex and named colors', async () => { - expect(toHexColor('#ff0000')).toBe('#ff0000'); - expect(toHexColor('RED')).toBe('#FF0000FF'); + it("is case-insensitive for hex and named colors", async () => { + expect(toHexColor("#ff0000")).toBe("#ff0000"); + expect(toHexColor("RED")).toBe("#FF0000FF"); }); }); - describe('darkenColor', () => { - it('darkens colors by specified amount', async () => { - const result = darkenColor('#FF0000FF', 50); - expect(result).toBe('#CD0000FF'); + describe("darkenColor", () => { + it("darkens colors by specified amount", async () => { + const result = darkenColor("#FF0000FF", 50); + expect(result).toBe("#CD0000FF"); }); - it('clamps darkened values to 0', async () => { - const result = darkenColor('#0F0F0FFF', 50); - expect(result).toBe('#000000FF'); + it("clamps darkened values to 0", async () => { + const result = darkenColor("#0F0F0FFF", 50); + expect(result).toBe("#000000FF"); }); - it('preserves alpha channel', async () => { - const result = darkenColor('#FF000080', 50); - expect(result).toBe('#CD000080'); + it("preserves alpha channel", async () => { + const result = darkenColor("#FF000080", 50); + expect(result).toBe("#CD000080"); }); - it('handles colors without alpha channel', async () => { - const result = darkenColor('#FF0000', 50); - expect(result).toBe('#CD0000FF'); + it("handles colors without alpha channel", async () => { + const result = darkenColor("#FF0000", 50); + expect(result).toBe("#CD0000FF"); }); }); - describe('normalizeColor', () => { - it('normalizes hex colors to 8-digit format', async () => { - expect(normalizeColor('#FF0000')).toBe('#FF0000FF'); - expect(normalizeColor('#F00')).toBe('#FF0000FF'); + describe("normalizeColor", () => { + it("normalizes hex colors to 8-digit format", async () => { + expect(normalizeColor("#FF0000")).toBe("#FF0000FF"); + expect(normalizeColor("#F00")).toBe("#FF0000FF"); }); - it('normalizes RGB colors', async () => { - expect(normalizeColor('rgb(255, 0, 0)')).toBe('#FF0000FF'); + it("normalizes RGB colors", async () => { + expect(normalizeColor("rgb(255, 0, 0)")).toBe("#FF0000FF"); }); - it('normalizes CSS color names', async () => { - expect(normalizeColor('red')).toBe('#FF0000FF'); + it("normalizes CSS color names", async () => { + expect(normalizeColor("red")).toBe("#FF0000FF"); }); - it('returns fallback for invalid colors', async () => { - expect(normalizeColor('notacolor')).toBe('#FFFFFFFF'); - expect(normalizeColor('notacolor', '#000000FF')).toBe('#000000FF'); + it("returns fallback for invalid colors", async () => { + expect(normalizeColor("notacolor")).toBe("#FFFFFFFF"); + expect(normalizeColor("notacolor", "#000000FF")).toBe("#000000FF"); }); - it('returns fallback for empty strings', async () => { - expect(normalizeColor('')).toBe('#FFFFFFFF'); - expect(normalizeColor(' ')).toBe('#FFFFFFFF'); + it("returns fallback for empty strings", async () => { + expect(normalizeColor("")).toBe("#FFFFFFFF"); + expect(normalizeColor(" ")).toBe("#FFFFFFFF"); }); - it('is case-insensitive', async () => { - expect(normalizeColor('RED')).toBe('#FF0000FF'); - expect(normalizeColor('#ff0000')).toBe('#FF0000FF'); + it("is case-insensitive", async () => { + expect(normalizeColor("RED")).toBe("#FF0000FF"); + expect(normalizeColor("#ff0000")).toBe("#FF0000FF"); }); }); - describe('ensureAlphaChannel', () => { - it('adds alpha channel to 6-digit hex', async () => { - expect(ensureAlphaChannel('#FF0000')).toBe('#FF0000FF'); + describe("ensureAlphaChannel", () => { + it("adds alpha channel to 6-digit hex", async () => { + expect(ensureAlphaChannel("#FF0000")).toBe("#FF0000FF"); }); - it('expands 3-digit hex to 8-digit', async () => { - expect(ensureAlphaChannel('#F00')).toBe('#FF0000FF'); + it("expands 3-digit hex to 8-digit", async () => { + expect(ensureAlphaChannel("#F00")).toBe("#FF0000FF"); }); - it('preserves 8-digit hex', async () => { - expect(ensureAlphaChannel('#FF0000FF')).toBe('#FF0000FF'); + it("preserves 8-digit hex", async () => { + expect(ensureAlphaChannel("#FF0000FF")).toBe("#FF0000FF"); }); - it('returns white for undefined', async () => { - expect(ensureAlphaChannel(undefined)).toBe('#FFFFFFFF'); + it("returns white for undefined", async () => { + expect(ensureAlphaChannel(undefined)).toBe("#FFFFFFFF"); }); - it('returns white for invalid format', async () => { - expect(ensureAlphaChannel('notahex')).toBe('#FFFFFFFF'); + it("returns white for invalid format", async () => { + expect(ensureAlphaChannel("notahex")).toBe("#FFFFFFFF"); }); - it('is case-insensitive', async () => { - expect(ensureAlphaChannel('#ff0000')).toBe('#ff0000FF'); + it("is case-insensitive", async () => { + expect(ensureAlphaChannel("#ff0000")).toBe("#ff0000FF"); }); }); }); diff --git a/test/concurrency.test.ts b/test/concurrency.test.ts index e3623b4..b89659a 100644 --- a/test/concurrency.test.ts +++ b/test/concurrency.test.ts @@ -1,10 +1,10 @@ // Concurrent access and thread safety tests -import fs from 'fs'; -import path from 'path'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; +import fs from "fs"; +import path from "path"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; const runDelayed = (delayMs: number, task: () => Promise): Promise => new Promise((resolve, reject) => { @@ -13,8 +13,8 @@ const runDelayed = (delayMs: number, task: () => Promise): Promise => }, delayMs); }); -describe('Concurrency and Thread Safety Tests', () => { - const tempDir = path.join(__dirname, 'temp_concurrency'); +describe("Concurrency and Thread Safety Tests", () => { + const tempDir = path.join(__dirname, "temp_concurrency"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -28,8 +28,8 @@ describe('Concurrency and Thread Safety Tests', () => { } }); - describe('Concurrent File Access', () => { - it('should handle multiple processors reading the same file simultaneously', async () => { + describe("Concurrent File Access", () => { + it("should handle multiple processors reading the same file simultaneously", async () => { const testContent = ` digraph G { home [label="Home"]; @@ -40,7 +40,7 @@ describe('Concurrency and Thread Safety Tests', () => { } `; - const testFile = path.join(tempDir, 'concurrent_read.dot'); + const testFile = path.join(tempDir, "concurrent_read.dot"); fs.writeFileSync(testFile, testContent); // Create multiple processors @@ -58,7 +58,7 @@ describe('Concurrency and Thread Safety Tests', () => { pageCount: Object.keys(tree.pages).length, textCount: texts.length, }; - }) + }), ); const results = await Promise.all(readPromises); @@ -78,7 +78,7 @@ describe('Concurrency and Thread Safety Tests', () => { }); }); - it('should handle concurrent write operations safely', async () => { + it("should handle concurrent write operations safely", async () => { const processor = new DotProcessor(); // Create test trees @@ -96,7 +96,7 @@ describe('Concurrency and Thread Safety Tests', () => { id: `btn_${index}`, label: `Button ${index}`, message: `Message ${index}`, - type: 'SPEAK', + type: "SPEAK", }); page.addButton(button); @@ -128,15 +128,15 @@ describe('Concurrency and Thread Safety Tests', () => { }); }); - describe('Database Concurrency', () => { - it('should handle concurrent SQLite database access', async () => { + describe("Database Concurrency", () => { + it("should handle concurrent SQLite database access", async () => { const processor = new SnapProcessor(); // Create a test database const tree = new AACTree(); const page = new AACPage({ - id: 'test_page', - name: 'Test Page', + id: "test_page", + name: "Test Page", buttons: [], }); @@ -145,14 +145,14 @@ describe('Concurrency and Thread Safety Tests', () => { id: `btn_${i}`, label: `Button ${i}`, message: `Message ${i}`, - type: 'SPEAK', + type: "SPEAK", }); page.addButton(button); } tree.addPage(page); - const dbPath = path.join(tempDir, 'concurrent_test.spb'); + const dbPath = path.join(tempDir, "concurrent_test.spb"); await processor.saveFromTree(tree, dbPath); // Read from the same database concurrently @@ -169,7 +169,7 @@ describe('Concurrency and Thread Safety Tests', () => { pageCount: Object.keys(loadedTree.pages).length, textCount: texts.length, }; - }) + }), ); const results = await Promise.all(readPromises); @@ -182,7 +182,7 @@ describe('Concurrency and Thread Safety Tests', () => { }); }); - it('should handle database creation race conditions', async () => { + it("should handle database creation race conditions", async () => { const createPromises = Array(3) .fill(0) .map((_, index) => @@ -199,7 +199,7 @@ describe('Concurrency and Thread Safety Tests', () => { id: `race_btn_${index}`, label: `Race Button ${index}`, message: `Race Message ${index}`, - type: 'SPEAK', + type: "SPEAK", }); page.addButton(button); @@ -213,7 +213,7 @@ describe('Concurrency and Thread Safety Tests', () => { dbPath, exists: fs.existsSync(dbPath), }; - }) + }), ); const results = await Promise.all(createPromises); @@ -226,8 +226,8 @@ describe('Concurrency and Thread Safety Tests', () => { }); }); - describe('Resource Contention', () => { - it('should handle high-frequency operations without resource exhaustion', async () => { + describe("Resource Contention", () => { + it("should handle high-frequency operations without resource exhaustion", async () => { const processor = new DotProcessor(); const testContent = 'digraph G { test [label="High Frequency Test"]; }'; @@ -236,7 +236,9 @@ describe('Concurrency and Thread Safety Tests', () => { .map((_, index) => runDelayed(index * 10, async () => { const tree = await processor.loadIntoTree(Buffer.from(testContent)); - const texts = await processor.extractTexts(Buffer.from(testContent)); + const texts = await processor.extractTexts( + Buffer.from(testContent), + ); const outputPath = path.join(tempDir, `high_freq_${index}.dot`); await processor.saveFromTree(tree, outputPath); @@ -247,7 +249,7 @@ describe('Concurrency and Thread Safety Tests', () => { pageCount: Object.keys(tree.pages).length, textCount: texts.length, }; - }) + }), ); const results = await Promise.all(operations); @@ -259,10 +261,10 @@ describe('Concurrency and Thread Safety Tests', () => { }); }); - it('should handle mixed read/write operations', async () => { + it("should handle mixed read/write operations", async () => { const processor = new DotProcessor(); const baseContent = 'digraph G { base [label="Base Content"]; }'; - const baseFile = path.join(tempDir, 'mixed_base.dot'); + const baseFile = path.join(tempDir, "mixed_base.dot"); fs.writeFileSync(baseFile, baseContent); @@ -276,7 +278,7 @@ describe('Concurrency and Thread Safety Tests', () => { return { index, - operation: 'read', + operation: "read", pageCount: Object.keys(tree.pages).length, textCount: texts.length, }; @@ -293,7 +295,7 @@ describe('Concurrency and Thread Safety Tests', () => { id: `mixed_btn_${index}`, label: `Mixed Button ${index}`, message: `Mixed Message ${index}`, - type: 'SPEAK', + type: "SPEAK", }); page.addButton(button); @@ -304,19 +306,19 @@ describe('Concurrency and Thread Safety Tests', () => { return { index, - operation: 'write', + operation: "write", outputPath, exists: fs.existsSync(outputPath), }; - }) + }), ); const results = await Promise.all(mixedOperations); expect(results).toHaveLength(10); - const readResults = results.filter((r: any) => r.operation === 'read'); - const writeResults = results.filter((r: any) => r.operation === 'write'); + const readResults = results.filter((r: any) => r.operation === "read"); + const writeResults = results.filter((r: any) => r.operation === "write"); expect(readResults.length).toBe(5); expect(writeResults.length).toBe(5); @@ -332,8 +334,8 @@ describe('Concurrency and Thread Safety Tests', () => { }); }); - describe('Error Handling Under Concurrency', () => { - it('should handle concurrent errors gracefully', async () => { + describe("Error Handling Under Concurrency", () => { + it("should handle concurrent errors gracefully", async () => { const processor = new ObfProcessor(); // Mix of valid and invalid operations @@ -344,7 +346,9 @@ describe('Concurrency and Thread Safety Tests', () => { try { if (index % 2 === 0) { const validContent = '{"id": "test", "buttons": []}'; - const tree = await processor.loadIntoTree(Buffer.from(validContent)); + const tree = await processor.loadIntoTree( + Buffer.from(validContent), + ); return { index, success: true, @@ -363,10 +367,10 @@ describe('Concurrency and Thread Safety Tests', () => { return { index, success: false, - error: error instanceof Error ? error.message : 'Unknown error', + error: error instanceof Error ? error.message : "Unknown error", }; } - }) + }), ); const results = await Promise.all(operations); @@ -382,11 +386,11 @@ describe('Concurrency and Thread Safety Tests', () => { // Errors should be handled gracefully errorResults.forEach((result: any) => { expect(result.error).toBeDefined(); - expect(typeof result.error).toBe('string'); + expect(typeof result.error).toBe("string"); }); }); - it('should maintain data integrity under concurrent stress', async () => { + it("should maintain data integrity under concurrent stress", async () => { const processor = new DotProcessor(); // Create a reference file @@ -400,7 +404,7 @@ describe('Concurrency and Thread Safety Tests', () => { } `; - const referenceFile = path.join(tempDir, 'integrity_reference.dot'); + const referenceFile = path.join(tempDir, "integrity_reference.dot"); fs.writeFileSync(referenceFile, referenceContent); // Get reference data @@ -416,7 +420,8 @@ describe('Concurrency and Thread Safety Tests', () => { const texts = await processor.extractTexts(referenceFile); const pageCountMatch = - Object.keys(tree.pages).length === Object.keys(referenceTree.pages).length; + Object.keys(tree.pages).length === + Object.keys(referenceTree.pages).length; const textCountMatch = texts.length === referenceTexts.length; return { @@ -425,7 +430,7 @@ describe('Concurrency and Thread Safety Tests', () => { textCountMatch, integrity: pageCountMatch && textCountMatch, }; - }) + }), ); const results = await Promise.all(integrityChecks); diff --git a/test/core/analyze.test.ts b/test/core/analyze.test.ts index 7ab3bef..790da9f 100644 --- a/test/core/analyze.test.ts +++ b/test/core/analyze.test.ts @@ -1,4 +1,4 @@ -import { getProcessor, analyze } from '../../src/core/analyze'; +import { getProcessor, analyze } from "../../src/core/analyze"; import { DotProcessor, OpmlProcessor, @@ -8,92 +8,92 @@ import { AstericsGridProcessor, TouchChatProcessor, ApplePanelsProcessor, -} from '../../src/index'; -import { TreeFactory } from '../utils/testFactories'; -import path from 'path'; -import fs from 'fs'; -import os from 'os'; - -describe('analyze', () => { - describe('getProcessor', () => { +} from "../../src/index"; +import { TreeFactory } from "../utils/testFactories"; +import path from "path"; +import fs from "fs"; +import os from "os"; + +describe("analyze", () => { + describe("getProcessor", () => { it('should return a DotProcessor for "dot"', async () => { - expect(getProcessor('dot')).toBeInstanceOf(DotProcessor); + expect(getProcessor("dot")).toBeInstanceOf(DotProcessor); }); it('should return a OpmlProcessor for "opml"', async () => { - expect(getProcessor('opml')).toBeInstanceOf(OpmlProcessor); + expect(getProcessor("opml")).toBeInstanceOf(OpmlProcessor); }); it('should return a ObfProcessor for "obf"', async () => { - expect(getProcessor('obf')).toBeInstanceOf(ObfProcessor); + expect(getProcessor("obf")).toBeInstanceOf(ObfProcessor); }); it('should return a SnapProcessor for "snap"', async () => { - expect(getProcessor('snap')).toBeInstanceOf(SnapProcessor); + expect(getProcessor("snap")).toBeInstanceOf(SnapProcessor); }); it('should return a SnapProcessor for "sps" extension', async () => { - expect(getProcessor('sps')).toBeInstanceOf(SnapProcessor); + expect(getProcessor("sps")).toBeInstanceOf(SnapProcessor); }); it('should return a SnapProcessor for "spb" extension', async () => { - expect(getProcessor('spb')).toBeInstanceOf(SnapProcessor); + expect(getProcessor("spb")).toBeInstanceOf(SnapProcessor); }); it('should return a GridsetProcessor for "gridset"', async () => { - expect(getProcessor('gridset')).toBeInstanceOf(GridsetProcessor); + expect(getProcessor("gridset")).toBeInstanceOf(GridsetProcessor); }); it('should return a GridsetProcessor for "gridsetx"', async () => { - expect(getProcessor('gridsetx')).toBeInstanceOf(GridsetProcessor); + expect(getProcessor("gridsetx")).toBeInstanceOf(GridsetProcessor); }); it('should return an AstericsGridProcessor for "grd" extension', async () => { - expect(getProcessor('grd')).toBeInstanceOf(AstericsGridProcessor); + expect(getProcessor("grd")).toBeInstanceOf(AstericsGridProcessor); }); it('should return a TouchChatProcessor for "touchchat"', async () => { - expect(getProcessor('touchchat')).toBeInstanceOf(TouchChatProcessor); + expect(getProcessor("touchchat")).toBeInstanceOf(TouchChatProcessor); }); it('should return a TouchChatProcessor for "ce" extension', async () => { - expect(getProcessor('ce')).toBeInstanceOf(TouchChatProcessor); + expect(getProcessor("ce")).toBeInstanceOf(TouchChatProcessor); }); it('should return a ApplePanelsProcessor for "applepanels"', async () => { - expect(getProcessor('applepanels')).toBeInstanceOf(ApplePanelsProcessor); + expect(getProcessor("applepanels")).toBeInstanceOf(ApplePanelsProcessor); }); it('should return a ApplePanelsProcessor for "panels"', async () => { - expect(getProcessor('panels')).toBeInstanceOf(ApplePanelsProcessor); + expect(getProcessor("panels")).toBeInstanceOf(ApplePanelsProcessor); }); - it('should be case-insensitive', async () => { - expect(getProcessor('DOT')).toBeInstanceOf(DotProcessor); - expect(getProcessor('OPML')).toBeInstanceOf(OpmlProcessor); - expect(getProcessor('SNAP')).toBeInstanceOf(SnapProcessor); + it("should be case-insensitive", async () => { + expect(getProcessor("DOT")).toBeInstanceOf(DotProcessor); + expect(getProcessor("OPML")).toBeInstanceOf(OpmlProcessor); + expect(getProcessor("SNAP")).toBeInstanceOf(SnapProcessor); }); - it('should handle empty string format', async () => { - expect(() => getProcessor('')).toThrow('Unknown format: '); + it("should handle empty string format", async () => { + expect(() => getProcessor("")).toThrow("Unknown format: "); }); - it('should handle null/undefined format', async () => { - expect(() => getProcessor(null as any)).toThrow('Unknown format: '); - expect(() => getProcessor(undefined as any)).toThrow('Unknown format: '); + it("should handle null/undefined format", async () => { + expect(() => getProcessor(null as any)).toThrow("Unknown format: "); + expect(() => getProcessor(undefined as any)).toThrow("Unknown format: "); }); - it('should throw an error for an unknown format', async () => { - expect(() => getProcessor('unknown')).toThrow('Unknown format: unknown'); - expect(() => getProcessor('xyz')).toThrow('Unknown format: xyz'); + it("should throw an error for an unknown format", async () => { + expect(() => getProcessor("unknown")).toThrow("Unknown format: unknown"); + expect(() => getProcessor("xyz")).toThrow("Unknown format: xyz"); }); }); - describe('analyze', () => { + describe("analyze", () => { let tempDir: string; beforeEach(async () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'analyze-test-')); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "analyze-test-")); }); afterEach(async () => { @@ -102,72 +102,74 @@ describe('analyze', () => { } }); - it('should analyze a DOT file and return a tree', async () => { - const tempFile = path.join(tempDir, 'test.dot'); + it("should analyze a DOT file and return a tree", async () => { + const tempFile = path.join(tempDir, "test.dot"); fs.writeFileSync(tempFile, 'digraph G { "Home" -> "Food"; }'); - const { tree } = await analyze(tempFile, 'dot'); + const { tree } = await analyze(tempFile, "dot"); expect(tree).toBeDefined(); expect(tree.pages).toBeDefined(); }); - it('should analyze an OPML file and return a tree', async () => { + it("should analyze an OPML file and return a tree", async () => { // Create a test OPML file using TreeFactory const tree = TreeFactory.createSimple(); const processor = new OpmlProcessor(); - const tempFile = path.join(tempDir, 'test.opml'); + const tempFile = path.join(tempDir, "test.opml"); await processor.saveFromTree(tree, tempFile); - const { tree: analyzedTree } = await analyze(tempFile, 'opml'); + const { tree: analyzedTree } = await analyze(tempFile, "opml"); expect(analyzedTree).toBeDefined(); expect(analyzedTree.pages).toBeDefined(); // OPML processor may create additional pages for circular references expect(Object.keys(analyzedTree.pages).length).toBeGreaterThanOrEqual(2); }); - it('should handle file reading errors', async () => { - const nonExistentFile = path.join(tempDir, 'nonexistent.opml'); + it("should handle file reading errors", async () => { + const nonExistentFile = path.join(tempDir, "nonexistent.opml"); - await expect(analyze(nonExistentFile, 'opml')).rejects.toThrow(); + await expect(analyze(nonExistentFile, "opml")).rejects.toThrow(); }); - it('should handle invalid format in analyze', async () => { + it("should handle invalid format in analyze", async () => { // Create a dummy file - const tempFile = path.join(tempDir, 'test.txt'); - fs.writeFileSync(tempFile, 'dummy content'); + const tempFile = path.join(tempDir, "test.txt"); + fs.writeFileSync(tempFile, "dummy content"); - await expect(analyze(tempFile, 'invalid')).rejects.toThrow('Unknown format: invalid'); + await expect(analyze(tempFile, "invalid")).rejects.toThrow( + "Unknown format: invalid", + ); }); - it('should work with different file formats', async () => { + it("should work with different file formats", async () => { const tree = TreeFactory.createSimple(); // Test DOT format const dotProcessor = new DotProcessor(); - const dotFile = path.join(tempDir, 'test.dot'); + const dotFile = path.join(tempDir, "test.dot"); await dotProcessor.saveFromTree(tree, dotFile); - const dotResult = await analyze(dotFile, 'dot'); - expect(dotResult).toHaveProperty('tree'); + const dotResult = await analyze(dotFile, "dot"); + expect(dotResult).toHaveProperty("tree"); expect(dotResult.tree).toBeDefined(); // Test OPML format const opmlProcessor = new OpmlProcessor(); - const opmlFile = path.join(tempDir, 'test.opml'); + const opmlFile = path.join(tempDir, "test.opml"); await opmlProcessor.saveFromTree(tree, opmlFile); - const opmlResult = await analyze(opmlFile, 'opml'); - expect(opmlResult).toHaveProperty('tree'); + const opmlResult = await analyze(opmlFile, "opml"); + expect(opmlResult).toHaveProperty("tree"); expect(opmlResult.tree).toBeDefined(); }); - it('should return tree with correct structure', async () => { + it("should return tree with correct structure", async () => { const tree = TreeFactory.createCommunicationBoard(); const processor = new OpmlProcessor(); - const tempFile = path.join(tempDir, 'communication.opml'); + const tempFile = path.join(tempDir, "communication.opml"); await processor.saveFromTree(tree, tempFile); - const { tree: analyzedTree } = await analyze(tempFile, 'opml'); + const { tree: analyzedTree } = await analyze(tempFile, "opml"); expect(analyzedTree).toBeDefined(); expect(analyzedTree.pages).toBeDefined(); expect(Object.keys(analyzedTree.pages).length).toBeGreaterThan(0); diff --git a/test/core/baseConfig.test.ts b/test/core/baseConfig.test.ts index 85df7d4..e547a43 100644 --- a/test/core/baseConfig.test.ts +++ b/test/core/baseConfig.test.ts @@ -1,7 +1,7 @@ -import AdmZip from 'adm-zip'; -import { BaseProcessor } from '../../src/core/baseProcessor'; -import { BaseValidator } from '../../src/validation/baseValidator'; -import { ValidationResult } from '../../src/validation/validationTypes'; +import AdmZip from "adm-zip"; +import { BaseProcessor } from "../../src/core/baseProcessor"; +import { BaseValidator } from "../../src/validation/baseValidator"; +import { ValidationResult } from "../../src/validation/validationTypes"; class TestProcessor extends BaseProcessor { async extractTexts(): Promise { @@ -22,31 +22,37 @@ class TestProcessor extends BaseProcessor { } class TestValidator extends BaseValidator { - async validate(_content: any, _filename: string, _filesize: number): Promise { - return this.buildResult('file', 0, 'test'); + async validate( + _content: any, + _filename: string, + _filesize: number, + ): Promise { + return this.buildResult("file", 0, "test"); } } -describe('base defaults', () => { - it('BaseProcessor provides a default zipAdapter', async () => { +describe("base defaults", () => { + it("BaseProcessor provides a default zipAdapter", async () => { const processor = new TestProcessor(); const zip = new AdmZip(); - zip.addFile('hello.txt', Buffer.from('hello', 'utf8')); + zip.addFile("hello.txt", Buffer.from("hello", "utf8")); const adapter = await (processor as any).options.zipAdapter(zip.toBuffer()); - expect(adapter.listFiles()).toContain('hello.txt'); - const contents = await adapter.readFile('hello.txt'); - expect(Buffer.from(contents).toString('utf8')).toBe('hello'); + expect(adapter.listFiles()).toContain("hello.txt"); + const contents = await adapter.readFile("hello.txt"); + expect(Buffer.from(contents).toString("utf8")).toBe("hello"); }); - it('BaseValidator provides a default zipAdapter', async () => { + it("BaseValidator provides a default zipAdapter", async () => { const validator = new TestValidator(); const zip = new AdmZip(); - zip.addFile('world.txt', Buffer.from('world', 'utf8')); - const adapter = await (validator as any)._options.zipAdapter(zip.toBuffer()); - - expect(adapter.listFiles()).toContain('world.txt'); - const contents = await adapter.readFile('world.txt'); - expect(Buffer.from(contents).toString('utf8')).toBe('world'); + zip.addFile("world.txt", Buffer.from("world", "utf8")); + const adapter = await (validator as any)._options.zipAdapter( + zip.toBuffer(), + ); + + expect(adapter.listFiles()).toContain("world.txt"); + const contents = await adapter.readFile("world.txt"); + expect(Buffer.from(contents).toString("utf8")).toBe("world"); }); }); diff --git a/test/core/baseProcessor.generic.test.ts b/test/core/baseProcessor.generic.test.ts index 5d332e7..1e81ca2 100644 --- a/test/core/baseProcessor.generic.test.ts +++ b/test/core/baseProcessor.generic.test.ts @@ -4,14 +4,14 @@ import { type TranslatedString, type SourceString, type ProcessorOptions, -} from '../../src/core/baseProcessor'; +} from "../../src/core/baseProcessor"; import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent, -} from '../../src/core/treeStructure'; +} from "../../src/core/treeStructure"; class DummyProcessor extends BaseProcessor { private tree: AACTree; @@ -34,7 +34,7 @@ class DummyProcessor extends BaseProcessor { async processTexts( _filePathOrBuffer: string, translations: Map, - outputPath: string + outputPath: string, ): Promise { this.lastTranslations = translations; this.lastOutputPath = outputPath; @@ -49,16 +49,22 @@ class DummyProcessor extends BaseProcessor { return this.filterPageButtons(buttons); } - public extractStringsGeneric(filePath: string): Promise { + public extractStringsGeneric( + filePath: string, + ): Promise { return this.extractStringsWithMetadataGeneric(filePath); } public generateTranslatedGeneric( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[] + sourceStrings: SourceString[], ): Promise { - return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); + return this.generateTranslatedDownloadGeneric( + filePath, + translatedStrings, + sourceStrings, + ); } public outputPathFor(filePath: string): string { @@ -68,53 +74,57 @@ class DummyProcessor extends BaseProcessor { function createTree(): AACTree { const tree = new AACTree(); - const page = new AACPage({ id: 'page-1', name: 'Home' }); - const yesButton = new AACButton({ id: 'btn-1', label: 'Yes', message: 'Yes' }); - const noButton = new AACButton({ id: 'btn-2', label: 'No', message: 'Nope' }); + const page = new AACPage({ id: "page-1", name: "Home" }); + const yesButton = new AACButton({ + id: "btn-1", + label: "Yes", + message: "Yes", + }); + const noButton = new AACButton({ id: "btn-2", label: "No", message: "Nope" }); page.buttons.push(yesButton, noButton); tree.addPage(page); tree.rootId = page.id; return tree; } -describe('BaseProcessor generic helpers', () => { - it('filters navigation/system buttons by default', () => { +describe("BaseProcessor generic helpers", () => { + it("filters navigation/system buttons by default", () => { const tree = createTree(); const processor = new DummyProcessor(tree); const buttons = [ new AACButton({ - id: 'nav', - label: 'Back', - message: '', + id: "nav", + label: "Back", + message: "", semanticAction: { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.GO_BACK, }, }), new AACButton({ - id: 'sys', - label: 'Clear', - message: '', + id: "sys", + label: "Clear", + message: "", semanticAction: { category: AACSemanticCategory.TEXT_EDITING, intent: AACSemanticIntent.CLEAR_TEXT, }, }), - new AACButton({ id: 'keep', label: 'Hello', message: 'Hello' }), + new AACButton({ id: "keep", label: "Hello", message: "Hello" }), ]; const filtered = processor.filterButtons(buttons); - expect(filtered.map((b) => b.id)).toEqual(['keep']); + expect(filtered.map((b) => b.id)).toEqual(["keep"]); }); - it('preserves all buttons when preserveAllButtons is set', () => { + it("preserves all buttons when preserveAllButtons is set", () => { const tree = createTree(); const processor = new DummyProcessor(tree, { preserveAllButtons: true }); const buttons = [ new AACButton({ - id: 'nav', - label: 'Back', - message: '', + id: "nav", + label: "Back", + message: "", semanticAction: { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.GO_BACK, @@ -125,54 +135,67 @@ describe('BaseProcessor generic helpers', () => { expect(processor.filterButtons(buttons).length).toBe(1); }); - it('applies a custom button filter', () => { + it("applies a custom button filter", () => { const tree = createTree(); const processor = new DummyProcessor(tree, { - customButtonFilter: (button) => !button.label?.toLowerCase().includes('skip'), + customButtonFilter: (button) => + !button.label?.toLowerCase().includes("skip"), }); const buttons = [ - new AACButton({ id: 'skip', label: 'Skip Me', message: '' }), - new AACButton({ id: 'keep', label: 'Keep', message: '' }), + new AACButton({ id: "skip", label: "Skip Me", message: "" }), + new AACButton({ id: "keep", label: "Keep", message: "" }), ]; - expect(processor.filterButtons(buttons).map((b) => b.id)).toEqual(['keep']); + expect(processor.filterButtons(buttons).map((b) => b.id)).toEqual(["keep"]); }); - it('extracts strings with metadata and deduplicates', async () => { + it("extracts strings with metadata and deduplicates", async () => { const tree = createTree(); const processor = new DummyProcessor(tree); - const result = await processor.extractStringsGeneric('dummy.path'); + const result = await processor.extractStringsGeneric("dummy.path"); const labels = result.extractedStrings.map((entry) => entry.string).sort(); - expect(labels).toEqual(['Home', 'No', 'Nope', 'Yes']); + expect(labels).toEqual(["Home", "No", "Nope", "Yes"]); - const yesEntry = result.extractedStrings.find((entry) => entry.string === 'Yes'); + const yesEntry = result.extractedStrings.find( + (entry) => entry.string === "Yes", + ); expect(yesEntry?.vocabPlacementMeta.vocabLocations.length).toBe(1); }); - it('builds translations and output paths for generic downloads', async () => { + it("builds translations and output paths for generic downloads", async () => { const tree = createTree(); const processor = new DummyProcessor(tree); const sourceStrings: SourceString[] = [ - { id: 1, sourcestring: 'Hello', vocabplacementmetadata: { vocabLocations: [] } }, + { + id: 1, + sourcestring: "Hello", + vocabplacementmetadata: { vocabLocations: [] }, + }, ]; const translatedStrings: TranslatedString[] = [ - { sourcestringid: 1, overridestring: 'Hola', translatedstring: 'Bonjour' }, + { + sourcestringid: 1, + overridestring: "Hola", + translatedstring: "Bonjour", + }, ]; const outputPath = await processor.generateTranslatedGeneric( - '/tmp/example.obf', + "/tmp/example.obf", translatedStrings, - sourceStrings + sourceStrings, ); - expect(outputPath).toBe('/tmp/example_translated.obf'); - expect(processor.lastTranslations?.get('Hello')).toBe('Hola'); + expect(outputPath).toBe("/tmp/example_translated.obf"); + expect(processor.lastTranslations?.get("Hello")).toBe("Hola"); }); - it('generates translated output paths without extensions', () => { + it("generates translated output paths without extensions", () => { const tree = createTree(); const processor = new DummyProcessor(tree); - expect(processor.outputPathFor('/tmp/example')).toBe('/tmp/example_translated'); + expect(processor.outputPathFor("/tmp/example")).toBe( + "/tmp/example_translated", + ); }); }); diff --git a/test/core/coverageBoost.test.ts b/test/core/coverageBoost.test.ts index 6c14764..e179be6 100644 --- a/test/core/coverageBoost.test.ts +++ b/test/core/coverageBoost.test.ts @@ -4,113 +4,115 @@ import { AACButton, AACSemanticCategory, AACSemanticIntent, -} from '../../src/core/treeStructure'; -import { BaseProcessor } from '../../src/core/baseProcessor'; +} from "../../src/core/treeStructure"; +import { BaseProcessor } from "../../src/core/baseProcessor"; -describe('src/core Coverage Boost', () => { - describe('AACButton constructor legacy mappings', () => { - it('should map legacy NAVIGATE type', async () => { +describe("src/core Coverage Boost", () => { + describe("AACButton constructor legacy mappings", () => { + it("should map legacy NAVIGATE type", async () => { const button = new AACButton({ - id: 'btn1', - type: 'NAVIGATE', - targetPageId: 'page2', + id: "btn1", + type: "NAVIGATE", + targetPageId: "page2", }); expect(button.semanticAction?.intent).toBe(AACSemanticIntent.NAVIGATE_TO); - expect(button.type).toBe('NAVIGATE'); + expect(button.type).toBe("NAVIGATE"); }); - it('should map legacy SPEAK type', async () => { + it("should map legacy SPEAK type", async () => { const button = new AACButton({ - id: 'btn1', - type: 'SPEAK', - message: 'hello', + id: "btn1", + type: "SPEAK", + message: "hello", }); expect(button.semanticAction?.intent).toBe(AACSemanticIntent.SPEAK_TEXT); - expect(button.type).toBe('SPEAK'); + expect(button.type).toBe("SPEAK"); }); - it('should map legacy ACTION type', async () => { + it("should map legacy ACTION type", async () => { const button = new AACButton({ - id: 'btn1', - type: 'ACTION', + id: "btn1", + type: "ACTION", }); - expect(button.semanticAction?.intent).toBe(AACSemanticIntent.PLATFORM_SPECIFIC); - expect(button.type).toBe('ACTION'); + expect(button.semanticAction?.intent).toBe( + AACSemanticIntent.PLATFORM_SPECIFIC, + ); + expect(button.type).toBe("ACTION"); }); - it('should map legacy action object (NAVIGATE)', async () => { + it("should map legacy action object (NAVIGATE)", async () => { const button = new AACButton({ - id: 'btn1', - action: { type: 'NAVIGATE', targetPageId: 'page2' }, + id: "btn1", + action: { type: "NAVIGATE", targetPageId: "page2" }, }); - expect(button.type).toBe('NAVIGATE'); + expect(button.type).toBe("NAVIGATE"); }); - it('should map legacy action object (SPEAK)', async () => { + it("should map legacy action object (SPEAK)", async () => { const button = new AACButton({ - id: 'btn1', - action: { type: 'SPEAK', message: 'test' }, + id: "btn1", + action: { type: "SPEAK", message: "test" }, }); - expect(button.type).toBe('SPEAK'); - expect(button.message).toBe('test'); + expect(button.type).toBe("SPEAK"); + expect(button.message).toBe("test"); }); - it('should map legacy action object (ACTION)', async () => { + it("should map legacy action object (ACTION)", async () => { const button = new AACButton({ - id: 'btn1', - action: { type: 'ACTION' }, + id: "btn1", + action: { type: "ACTION" }, }); - expect(button.type).toBe('ACTION'); + expect(button.type).toBe("ACTION"); }); }); - describe('AACButton getters', () => { - it('should return SPEAK for SPEAK_IMMEDIATE intent', async () => { + describe("AACButton getters", () => { + it("should return SPEAK for SPEAK_IMMEDIATE intent", async () => { const button = new AACButton({ - id: '1', + id: "1", semanticAction: { intent: AACSemanticIntent.SPEAK_IMMEDIATE }, }); - expect(button.type).toBe('SPEAK'); + expect(button.type).toBe("SPEAK"); }); - it('should return null for empty SPEAK button action', async () => { - const button = new AACButton({ id: '1' }); + it("should return null for empty SPEAK button action", async () => { + const button = new AACButton({ id: "1" }); // In constructor, default type is SPEAK, but message/label are empty expect(button.action).toBeNull(); }); - it('should handle NAVIGATE type from targetPageId fallback', async () => { - const button = new AACButton({ id: '1', targetPageId: 'p2' }); - expect(button.type).toBe('NAVIGATE'); - expect(button.action?.type).toBe('NAVIGATE'); + it("should handle NAVIGATE type from targetPageId fallback", async () => { + const button = new AACButton({ id: "1", targetPageId: "p2" }); + expect(button.type).toBe("NAVIGATE"); + expect(button.action?.type).toBe("NAVIGATE"); }); - it('should handle SPEAK type from message fallback', async () => { - const button = new AACButton({ id: '1', message: 'hello' }); - expect(button.type).toBe('SPEAK'); - expect(button.action?.type).toBe('SPEAK'); + it("should handle SPEAK type from message fallback", async () => { + const button = new AACButton({ id: "1", message: "hello" }); + expect(button.type).toBe("SPEAK"); + expect(button.action?.type).toBe("SPEAK"); }); }); - describe('AACTree extra properties', () => { - it('should handle rootId getter/setter', async () => { + describe("AACTree extra properties", () => { + it("should handle rootId getter/setter", async () => { const tree = new AACTree(); - tree.rootId = 'root1'; - expect(tree.rootId).toBe('root1'); - expect(tree.metadata.defaultHomePageId).toBe('root1'); + tree.rootId = "root1"; + expect(tree.rootId).toBe("root1"); + expect(tree.metadata.defaultHomePageId).toBe("root1"); tree.rootId = null; expect(tree.rootId).toBeNull(); expect(tree.metadata.defaultHomePageId).toBeUndefined(); }); - it('should handle toolbarId and dashboardId', async () => { + it("should handle toolbarId and dashboardId", async () => { const tree = new AACTree(); - tree.toolbarId = 'tb1'; - tree.dashboardId = 'db1'; - expect(tree.toolbarId).toBe('tb1'); - expect(tree.dashboardId).toBe('db1'); - expect(tree.metadata.toolbarId).toBe('tb1'); - expect(tree.metadata.dashboardId).toBe('db1'); + tree.toolbarId = "tb1"; + tree.dashboardId = "db1"; + expect(tree.toolbarId).toBe("tb1"); + expect(tree.dashboardId).toBe("db1"); + expect(tree.metadata.toolbarId).toBe("tb1"); + expect(tree.metadata.dashboardId).toBe("db1"); tree.toolbarId = null; tree.dashboardId = null; @@ -119,10 +121,10 @@ describe('src/core Coverage Boost', () => { }); }); - describe('AACPage grid constructor', () => { - it('should create empty grid for columns/rows object', async () => { + describe("AACPage grid constructor", () => { + it("should create empty grid for columns/rows object", async () => { const page = new AACPage({ - id: 'p1', + id: "p1", grid: { columns: 2, rows: 3 }, }); expect(page.grid).toHaveLength(3); @@ -130,13 +132,13 @@ describe('src/core Coverage Boost', () => { expect(page.grid[0][0]).toBeNull(); }); - it('should default to empty grid if no grid provided', async () => { - const page = new AACPage({ id: 'p1' }); + it("should default to empty grid if no grid provided", async () => { + const page = new AACPage({ id: "p1" }); expect(page.grid).toEqual([]); }); }); - describe('BaseProcessor features', () => { + describe("BaseProcessor features", () => { class MockProcessor extends BaseProcessor { async extractTexts() { return []; @@ -160,10 +162,10 @@ describe('src/core Coverage Boost', () => { } } - it('should filter GO_BACK / GO_HOME navigation buttons', async () => { + it("should filter GO_BACK / GO_HOME navigation buttons", async () => { const processor = new MockProcessor({ excludeNavigationButtons: true }); const backBtn = new AACButton({ - id: 'back', + id: "back", semanticAction: { intent: AACSemanticIntent.GO_BACK, category: AACSemanticCategory.NAVIGATION, @@ -172,19 +174,22 @@ describe('src/core Coverage Boost', () => { expect(processor.callShouldFilter(backBtn)).toBe(true); }); - it('should filter text editing category', async () => { + it("should filter text editing category", async () => { const processor = new MockProcessor({ excludeSystemButtons: true }); const editBtn = new AACButton({ - id: 'edit', - semanticAction: { intent: 'ANY', category: AACSemanticCategory.TEXT_EDITING }, + id: "edit", + semanticAction: { + intent: "ANY", + category: AACSemanticCategory.TEXT_EDITING, + }, }); expect(processor.callShouldFilter(editBtn)).toBe(true); }); - it('should filter specific system intents', async () => { + it("should filter specific system intents", async () => { const processor = new MockProcessor({ excludeSystemButtons: true }); const deleteBtn = new AACButton({ - id: 'del', + id: "del", semanticAction: { intent: AACSemanticIntent.DELETE_WORD, category: AACSemanticCategory.SYSTEM_CONTROL, @@ -193,38 +198,40 @@ describe('src/core Coverage Boost', () => { expect(processor.callShouldFilter(deleteBtn)).toBe(true); }); - it('should handle output path without extension', async () => { + it("should handle output path without extension", async () => { const processor = new MockProcessor(); - expect(processor.callGenerateOutputPath('myfile')).toBe('myfile_translated'); + expect(processor.callGenerateOutputPath("myfile")).toBe( + "myfile_translated", + ); }); - it('should handle custom button filter', async () => { + it("should handle custom button filter", async () => { const processor = new MockProcessor({ - customButtonFilter: (btn) => btn.label !== 'Secret', + customButtonFilter: (btn) => btn.label !== "Secret", }); - const secretBtn = new AACButton({ id: '1', label: 'Secret' }); - const normalBtn = new AACButton({ id: '2', label: 'Normal' }); + const secretBtn = new AACButton({ id: "1", label: "Secret" }); + const normalBtn = new AACButton({ id: "2", label: "Normal" }); expect(processor.callShouldFilter(secretBtn)).toBe(true); expect(processor.callShouldFilter(normalBtn)).toBe(false); }); - it('should handle addToExtractedMap with existing key', async () => { + it("should handle addToExtractedMap with existing key", async () => { const processor = new MockProcessor(); const extractedMap = new Map(); - (processor as any).addToExtractedMap(extractedMap, 'test', 'Test', { - table: 'b', + (processor as any).addToExtractedMap(extractedMap, "test", "Test", { + table: "b", id: 1, - column: 'L', - casing: 'capitalized', + column: "L", + casing: "capitalized", }); - (processor as any).addToExtractedMap(extractedMap, 'test', 'Test2', { - table: 'b', + (processor as any).addToExtractedMap(extractedMap, "test", "Test2", { + table: "b", id: 2, - column: 'L', - casing: 'capitalized', + column: "L", + casing: "capitalized", }); - const item = extractedMap.get('test'); + const item = extractedMap.get("test"); expect(item.vocabPlacementMeta.vocabLocations).toHaveLength(2); }); }); diff --git a/test/core/treeStructure.test.ts b/test/core/treeStructure.test.ts index 3f5a0af..96c9bc3 100644 --- a/test/core/treeStructure.test.ts +++ b/test/core/treeStructure.test.ts @@ -1,74 +1,74 @@ -import { AACTree, AACPage, AACButton } from '../../src/index'; - -describe('AACButton', () => { - it('should create a button with default values', async () => { - const button = new AACButton({ id: 'btn1' }); - expect(button.id).toBe('btn1'); - expect(button.label).toBe(''); - expect(button.message).toBe(''); - expect(button.type).toBe('SPEAK'); +import { AACTree, AACPage, AACButton } from "../../src/index"; + +describe("AACButton", () => { + it("should create a button with default values", async () => { + const button = new AACButton({ id: "btn1" }); + expect(button.id).toBe("btn1"); + expect(button.label).toBe(""); + expect(button.message).toBe(""); + expect(button.type).toBe("SPEAK"); expect(button.action).toBeNull(); expect(button.targetPageId).toBeUndefined(); }); - it('should create a navigation button', async () => { + it("should create a navigation button", async () => { const button = new AACButton({ - id: 'nav1', - label: 'Go to Page 2', - type: 'NAVIGATE', - targetPageId: 'page2', - action: { type: 'NAVIGATE', targetPageId: 'page2' }, + id: "nav1", + label: "Go to Page 2", + type: "NAVIGATE", + targetPageId: "page2", + action: { type: "NAVIGATE", targetPageId: "page2" }, }); - expect(button.type).toBe('NAVIGATE'); - expect(button.targetPageId).toBe('page2'); - expect(button.action?.type).toBe('NAVIGATE'); - expect(button.action?.targetPageId).toBe('page2'); + expect(button.type).toBe("NAVIGATE"); + expect(button.targetPageId).toBe("page2"); + expect(button.action?.type).toBe("NAVIGATE"); + expect(button.action?.targetPageId).toBe("page2"); }); - it('should create a button with audio recording', async () => { - const audioData = Buffer.from('audio data'); + it("should create a button with audio recording", async () => { + const audioData = Buffer.from("audio data"); const button = new AACButton({ - id: 'audio1', - label: 'Hello', + id: "audio1", + label: "Hello", audioRecording: { id: 123, data: audioData, - identifier: 'SND:hello', - metadata: 'test metadata', + identifier: "SND:hello", + metadata: "test metadata", }, }); expect(button.audioRecording?.id).toBe(123); expect(button.audioRecording?.data).toBe(audioData); - expect(button.audioRecording?.identifier).toBe('SND:hello'); - expect(button.audioRecording?.metadata).toBe('test metadata'); + expect(button.audioRecording?.identifier).toBe("SND:hello"); + expect(button.audioRecording?.metadata).toBe("test metadata"); }); }); -describe('AACPage', () => { - it('should create a page with default values', async () => { - const page = new AACPage({ id: 'page1' }); - expect(page.id).toBe('page1'); - expect(page.name).toBe(''); +describe("AACPage", () => { + it("should create a page with default values", async () => { + const page = new AACPage({ id: "page1" }); + expect(page.id).toBe("page1"); + expect(page.name).toBe(""); expect(page.grid).toEqual([]); expect(page.buttons).toEqual([]); expect(page.parentId).toBeNull(); }); - it('should create a page with custom values', async () => { + it("should create a page with custom values", async () => { const page = new AACPage({ - id: 'page2', - name: 'Main Page', - parentId: 'parent1', + id: "page2", + name: "Main Page", + parentId: "parent1", }); - expect(page.id).toBe('page2'); - expect(page.name).toBe('Main Page'); - expect(page.parentId).toBe('parent1'); + expect(page.id).toBe("page2"); + expect(page.name).toBe("Main Page"); + expect(page.parentId).toBe("parent1"); }); - it('should add buttons to a page', async () => { - const page = new AACPage({ id: 'page1' }); - const button1 = new AACButton({ id: 'btn1', label: 'Button 1' }); - const button2 = new AACButton({ id: 'btn2', label: 'Button 2' }); + it("should add buttons to a page", async () => { + const page = new AACPage({ id: "page1" }); + const button1 = new AACButton({ id: "btn1", label: "Button 1" }); + const button2 = new AACButton({ id: "btn2", label: "Button 2" }); page.addButton(button1); page.addButton(button2); @@ -79,60 +79,60 @@ describe('AACPage', () => { }); }); -describe('AACTree', () => { - it('should create an empty tree', async () => { +describe("AACTree", () => { + it("should create an empty tree", async () => { const tree = new AACTree(); expect(tree.pages).toEqual({}); expect(tree.rootId).toBeNull(); }); - it('should add pages to the tree', async () => { + it("should add pages to the tree", async () => { const tree = new AACTree(); - const page1 = new AACPage({ id: 'page1', name: 'First Page' }); - const page2 = new AACPage({ id: 'page2', name: 'Second Page' }); + const page1 = new AACPage({ id: "page1", name: "First Page" }); + const page2 = new AACPage({ id: "page2", name: "Second Page" }); tree.addPage(page1); tree.addPage(page2); expect(Object.keys(tree.pages)).toHaveLength(2); - expect(tree.pages['page1']).toBe(page1); - expect(tree.pages['page2']).toBe(page2); - expect(tree.rootId).toBe('page1'); // First page becomes root + expect(tree.pages["page1"]).toBe(page1); + expect(tree.pages["page2"]).toBe(page2); + expect(tree.rootId).toBe("page1"); // First page becomes root }); - it('should get pages by id', async () => { + it("should get pages by id", async () => { const tree = new AACTree(); - const page = new AACPage({ id: 'test-page', name: 'Test Page' }); + const page = new AACPage({ id: "test-page", name: "Test Page" }); tree.addPage(page); - const retrievedPage = tree.getPage('test-page'); + const retrievedPage = tree.getPage("test-page"); expect(retrievedPage).toBe(page); }); - it('should return undefined for non-existent page', async () => { + it("should return undefined for non-existent page", async () => { const tree = new AACTree(); - const retrievedPage = tree.getPage('non-existent'); + const retrievedPage = tree.getPage("non-existent"); expect(retrievedPage).toBeUndefined(); }); - it('should traverse all pages', async () => { + it("should traverse all pages", async () => { const tree = new AACTree(); - const page1 = new AACPage({ id: 'page1', name: 'Page 1' }); - const page2 = new AACPage({ id: 'page2', name: 'Page 2' }); - const page3 = new AACPage({ id: 'page3', name: 'Page 3' }); + const page1 = new AACPage({ id: "page1", name: "Page 1" }); + const page2 = new AACPage({ id: "page2", name: "Page 2" }); + const page3 = new AACPage({ id: "page3", name: "Page 3" }); // Add navigation buttons const navButton = new AACButton({ - id: 'nav1', - type: 'NAVIGATE', - targetPageId: 'page2', + id: "nav1", + type: "NAVIGATE", + targetPageId: "page2", }); page1.addButton(navButton); const navButton2 = new AACButton({ - id: 'nav2', - type: 'NAVIGATE', - targetPageId: 'page3', + id: "nav2", + type: "NAVIGATE", + targetPageId: "page3", }); page2.addButton(navButton2); @@ -145,27 +145,27 @@ describe('AACTree', () => { visitedPages.push(page.id); }); - expect(visitedPages).toContain('page1'); - expect(visitedPages).toContain('page2'); - expect(visitedPages).toContain('page3'); + expect(visitedPages).toContain("page1"); + expect(visitedPages).toContain("page2"); + expect(visitedPages).toContain("page3"); expect(visitedPages).toHaveLength(3); }); - it('should handle circular navigation in traverse', async () => { + it("should handle circular navigation in traverse", async () => { const tree = new AACTree(); - const page1 = new AACPage({ id: 'page1' }); - const page2 = new AACPage({ id: 'page2' }); + const page1 = new AACPage({ id: "page1" }); + const page2 = new AACPage({ id: "page2" }); // Create circular navigation const nav1 = new AACButton({ - id: 'nav1', - type: 'NAVIGATE', - targetPageId: 'page2', + id: "nav1", + type: "NAVIGATE", + targetPageId: "page2", }); const nav2 = new AACButton({ - id: 'nav2', - type: 'NAVIGATE', - targetPageId: 'page1', + id: "nav2", + type: "NAVIGATE", + targetPageId: "page1", }); page1.addButton(nav1); @@ -181,7 +181,7 @@ describe('AACTree', () => { // Should visit each page only once despite circular references expect(visitedPages).toHaveLength(2); - expect(visitedPages).toContain('page1'); - expect(visitedPages).toContain('page2'); + expect(visitedPages).toContain("page1"); + expect(visitedPages).toContain("page2"); }); }); diff --git a/test/dotProcessor.roundtrip.test.ts b/test/dotProcessor.roundtrip.test.ts index f6de0ba..c489070 100644 --- a/test/dotProcessor.roundtrip.test.ts +++ b/test/dotProcessor.roundtrip.test.ts @@ -1,19 +1,21 @@ -import fs from 'fs'; -import path from 'path'; -import { DotProcessor } from '../src/processors/dotProcessor'; -describe('DotProcessor round-trip', () => { - const dotPath = path.join(__dirname, 'assets/dot/example.dot'); - const outPath = path.join(__dirname, 'out.dot'); +import fs from "fs"; +import path from "path"; +import { DotProcessor } from "../src/processors/dotProcessor"; +describe("DotProcessor round-trip", () => { + const dotPath = path.join(__dirname, "assets/dot/example.dot"); + const outPath = path.join(__dirname, "out.dot"); afterAll(async () => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it('round-trips DOT file without losing pages or navigation', async () => { + it("round-trips DOT file without losing pages or navigation", async () => { const processor = new DotProcessor(); const tree1 = await processor.loadIntoTree(dotPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); // Compare page IDs and navigation - expect(Object.keys(tree1.pages).sort()).toEqual(Object.keys(tree2.pages).sort()); + expect(Object.keys(tree1.pages).sort()).toEqual( + Object.keys(tree2.pages).sort(), + ); for (const pid in tree1.pages) { expect(tree2.pages).toHaveProperty(pid); const btnLabels1 = tree1.pages[pid].buttons.map((b) => b.label).sort(); diff --git a/test/dotProcessor.test.ts b/test/dotProcessor.test.ts index da063ff..c1521a3 100644 --- a/test/dotProcessor.test.ts +++ b/test/dotProcessor.test.ts @@ -1,12 +1,12 @@ // Unit test for DotProcessor -import path from 'path'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { AACTree } from '../src/core/treeStructure'; +import path from "path"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { AACTree } from "../src/core/treeStructure"; -describe('DotProcessor', () => { - const dotPath: string = path.join(__dirname, 'assets/dot/example.dot'); +describe("DotProcessor", () => { + const dotPath: string = path.join(__dirname, "assets/dot/example.dot"); - it('can process .dot files and build a navigation tree', async () => { + it("can process .dot files and build a navigation tree", async () => { const processor = new DotProcessor(); const tree: AACTree = await processor.loadIntoTree(dotPath); expect(tree).toBeInstanceOf(AACTree); @@ -25,38 +25,42 @@ describe('DotProcessor', () => { } expect(rootPage.buttons.length).toBeGreaterThan(0); // Should have navigation buttons - const navButtons = rootPage.buttons.filter((b) => b.type === 'NAVIGATE'); + const navButtons = rootPage.buttons.filter((b) => b.type === "NAVIGATE"); expect(navButtons.length).toBeGreaterThan(0); navButtons.forEach((btn) => { - expect(btn.type).toBe('NAVIGATE'); + expect(btn.type).toBe("NAVIGATE"); expect(btn.targetPageId).toBeTruthy(); }); }); - describe('Error Handling', () => { - it('should throw error for non-existent file', async () => { + describe("Error Handling", () => { + it("should throw error for non-existent file", async () => { const processor = new DotProcessor(); - await expect(processor.loadIntoTree('/non/existent/file.dot')).rejects.toThrow(); + await expect( + processor.loadIntoTree("/non/existent/file.dot"), + ).rejects.toThrow(); }); - it('should handle malformed dot content gracefully', async () => { + it("should handle malformed dot content gracefully", async () => { const processor = new DotProcessor(); - const malformedContent = Buffer.from('invalid dot content'); + const malformedContent = Buffer.from("invalid dot content"); const tree = await processor.loadIntoTree(malformedContent); expect(tree).toBeInstanceOf(AACTree); expect(Object.keys(tree.pages)).toHaveLength(0); }); - it('should handle empty file gracefully', async () => { + it("should handle empty file gracefully", async () => { const processor = new DotProcessor(); - const emptyContent = Buffer.from(''); + const emptyContent = Buffer.from(""); await expect(processor.loadIntoTree(emptyContent)).rejects.toThrow(); }); - it('should handle content with only comments', async () => { + it("should handle content with only comments", async () => { const processor = new DotProcessor(); - const commentContent = Buffer.from('// This is a comment\n// Another comment'); + const commentContent = Buffer.from( + "// This is a comment\n// Another comment", + ); const tree = await processor.loadIntoTree(commentContent); expect(tree).toBeInstanceOf(AACTree); expect(Object.keys(tree.pages)).toHaveLength(0); diff --git a/test/edgeCases.test.ts b/test/edgeCases.test.ts index c1b05be..3031ba2 100644 --- a/test/edgeCases.test.ts +++ b/test/edgeCases.test.ts @@ -1,15 +1,15 @@ // Edge case tests for all processors -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { AACTree } from '../src/core/treeStructure'; - -describe('Edge Case Tests', () => { - const tempDir = path.join(__dirname, 'temp_edge_cases'); +import fs from "fs"; +import path from "path"; +import os from "os"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { AACTree } from "../src/core/treeStructure"; + +describe("Edge Case Tests", () => { + const tempDir = path.join(__dirname, "temp_edge_cases"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -23,12 +23,12 @@ describe('Edge Case Tests', () => { } }); - describe('Empty and Minimal Content', () => { - it('should handle completely empty files', async () => { + describe("Empty and Minimal Content", () => { + it("should handle completely empty files", async () => { const processors = [ - { name: 'DOT', processor: new DotProcessor(), testBuffer: true }, - { name: 'OPML', processor: new OpmlProcessor(), testBuffer: true }, - { name: 'OBF', processor: new ObfProcessor(), testBuffer: true }, + { name: "DOT", processor: new DotProcessor(), testBuffer: true }, + { name: "OPML", processor: new OpmlProcessor(), testBuffer: true }, + { name: "OBF", processor: new ObfProcessor(), testBuffer: true }, ]; for (const { processor, testBuffer } of processors) { @@ -38,21 +38,21 @@ describe('Edge Case Tests', () => { } }); - it('should handle minimal valid content', async () => { + it("should handle minimal valid content", async () => { const testCases = [ { - name: 'DOT', + name: "DOT", processor: new DotProcessor(), - content: 'digraph G { }', + content: "digraph G { }", }, { - name: 'OPML', + name: "OPML", processor: new OpmlProcessor(), content: '', }, { - name: 'OBF', + name: "OBF", processor: new ObfProcessor(), content: '{"id": "test", "buttons": []}', }, @@ -61,57 +61,61 @@ describe('Edge Case Tests', () => { for (const { name, processor, content } of testCases) { const tree = await processor.loadIntoTree(Buffer.from(content)); expect(tree).toBeInstanceOf(AACTree); - console.log(`${name} minimal content: ${Object.keys(tree.pages).length} pages`); + console.log( + `${name} minimal content: ${Object.keys(tree.pages).length} pages`, + ); } }); - it('should handle single-element content', async () => { + it("should handle single-element content", async () => { const dotProcessor = new DotProcessor(); const singleNodeContent = 'digraph G { single [label="Only Node"]; }'; - const tree = await dotProcessor.loadIntoTree(Buffer.from(singleNodeContent)); + const tree = await dotProcessor.loadIntoTree( + Buffer.from(singleNodeContent), + ); expect(Object.keys(tree.pages)).toHaveLength(1); const page = Object.values(tree.pages)[0]; expect(page.buttons).toHaveLength(1); - expect(page.buttons[0].label).toBe('Only Node'); + expect(page.buttons[0].label).toBe("Only Node"); }); }); - describe('Unusual Characters and Encoding', () => { - it('should handle Unicode characters correctly', async () => { + describe("Unusual Characters and Encoding", () => { + it("should handle Unicode characters correctly", async () => { const unicodeTestCases = [ { - name: 'Emoji', + name: "Emoji", content: 'digraph G { emoji [label="😀🎉🌟"]; }', - expectedLabel: '😀🎉🌟', + expectedLabel: "😀🎉🌟", }, { - name: 'Chinese', + name: "Chinese", content: 'digraph G { chinese [label="你好世界"]; }', - expectedLabel: '你好世界', + expectedLabel: "你好世界", }, { - name: 'Arabic', + name: "Arabic", content: 'digraph G { arabic [label="مرحبا بالعالم"]; }', - expectedLabel: 'مرحبا بالعالم', + expectedLabel: "مرحبا بالعالم", }, { - name: 'Accented', + name: "Accented", content: 'digraph G { accented [label="Café, naïve, résumé"]; }', - expectedLabel: 'Café, naïve, résumé', + expectedLabel: "Café, naïve, résumé", }, { - name: 'Mathematical', + name: "Mathematical", content: 'digraph G { math [label="∑∞≠≤≥±"]; }', - expectedLabel: '∑∞≠≤≥±', + expectedLabel: "∑∞≠≤≥±", }, ]; const processor = new DotProcessor(); for (const { name, content, expectedLabel } of unicodeTestCases) { - const tree = await processor.loadIntoTree(Buffer.from(content, 'utf8')); + const tree = await processor.loadIntoTree(Buffer.from(content, "utf8")); const page = Object.values(tree.pages)[0]; expect(page.buttons).toHaveLength(1); @@ -120,7 +124,7 @@ describe('Edge Case Tests', () => { } }); - it('should handle special characters in file paths and content', async () => { + it("should handle special characters in file paths and content", async () => { const processor = new DotProcessor(); const specialContent = ` digraph G { @@ -136,16 +140,18 @@ describe('Edge Case Tests', () => { const tree = await processor.loadIntoTree(Buffer.from(specialContent)); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); - const allButtons = Object.values(tree.pages).flatMap((page) => page.buttons); + const allButtons = Object.values(tree.pages).flatMap( + (page) => page.buttons, + ); expect(allButtons.length).toBe(6); const labels = allButtons.map((btn) => btn.label); - expect(labels).toContain('Label with spaces'); - expect(labels).toContain('Label-with-dashes'); - expect(labels).toContain('Label@with@symbols'); + expect(labels).toContain("Label with spaces"); + expect(labels).toContain("Label-with-dashes"); + expect(labels).toContain("Label@with@symbols"); }); - it('should handle escaped characters correctly', async () => { + it("should handle escaped characters correctly", async () => { const processor = new DotProcessor(); const escapedContent = ` digraph G { @@ -156,21 +162,25 @@ describe('Edge Case Tests', () => { `; const tree = await processor.loadIntoTree(Buffer.from(escapedContent)); - const allButtons = Object.values(tree.pages).flatMap((page) => page.buttons); + const allButtons = Object.values(tree.pages).flatMap( + (page) => page.buttons, + ); expect(allButtons.length).toBe(3); - const escapedButton = allButtons.find((btn) => btn.label.includes('Line 1')); + const escapedButton = allButtons.find((btn) => + btn.label.includes("Line 1"), + ); expect(escapedButton).toBeDefined(); }); }); - describe('Boundary Conditions', () => { - it('should handle maximum reasonable content sizes', async () => { + describe("Boundary Conditions", () => { + it("should handle maximum reasonable content sizes", async () => { const processor = new DotProcessor(); // Test very long labels - const longLabel = 'A'.repeat(1000); + const longLabel = "A".repeat(1000); const longLabelContent = `digraph G { long [label="${longLabel}"]; }`; const tree = await processor.loadIntoTree(Buffer.from(longLabelContent)); @@ -178,28 +188,30 @@ describe('Edge Case Tests', () => { expect(page.buttons[0].label).toBe(longLabel); // Test many nodes - const manyNodesLines = ['digraph G {']; + const manyNodesLines = ["digraph G {"]; for (let i = 0; i < 100; i++) { manyNodesLines.push(` node${i} [label="Node ${i}"];`); } - manyNodesLines.push('}'); + manyNodesLines.push("}"); - const manyNodesContent = manyNodesLines.join('\n'); - const manyNodesTree = await processor.loadIntoTree(Buffer.from(manyNodesContent)); + const manyNodesContent = manyNodesLines.join("\n"); + const manyNodesTree = await processor.loadIntoTree( + Buffer.from(manyNodesContent), + ); const totalButtons = Object.values(manyNodesTree.pages).reduce( (sum, page) => sum + page.buttons.length, - 0 + 0, ); expect(totalButtons).toBe(100); }); - it('should handle deeply nested structures', async () => { + it("should handle deeply nested structures", async () => { const processor = new OpmlProcessor(); // Create deeply nested OPML let nestedContent = ''; - let currentLevel = ''; + let currentLevel = ""; for (let i = 0; i < 10; i++) { currentLevel += ''; @@ -208,16 +220,16 @@ describe('Edge Case Tests', () => { nestedContent += currentLevel; for (let i = 9; i >= 0; i--) { - nestedContent += ''; + nestedContent += ""; } - nestedContent += ''; + nestedContent += ""; const tree = await processor.loadIntoTree(Buffer.from(nestedContent)); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it('should handle circular references gracefully', async () => { + it("should handle circular references gracefully", async () => { const processor = new DotProcessor(); const circularContent = ` digraph G { @@ -244,8 +256,8 @@ describe('Edge Case Tests', () => { }); }); - describe('Corrupted and Malformed Content', () => { - it('should handle partially corrupted JSON', async () => { + describe("Corrupted and Malformed Content", () => { + it("should handle partially corrupted JSON", async () => { const processor = new ObfProcessor(); const corruptedJsonCases = [ @@ -257,12 +269,14 @@ describe('Edge Case Tests', () => { ]; for (const [index, corruptedJson] of corruptedJsonCases.entries()) { - await expect(processor.loadIntoTree(Buffer.from(corruptedJson))).rejects.toThrow(); + await expect( + processor.loadIntoTree(Buffer.from(corruptedJson)), + ).rejects.toThrow(); console.log(`Corrupted JSON case ${index + 1} handled correctly`); } }); - it('should handle malformed XML', async () => { + it("should handle malformed XML", async () => { const processor = new OpmlProcessor(); const malformedXmlCases = [ @@ -282,18 +296,20 @@ describe('Edge Case Tests', () => { } }); - it('should handle binary data as text input', async () => { + it("should handle binary data as text input", async () => { const processor = new DotProcessor(); // Create some binary data - const binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd]); + const binaryData = Buffer.from([ + 0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd, + ]); await expect(processor.loadIntoTree(binaryData)).rejects.toThrow(); }); }); - describe('Resource Limits and Cleanup', () => { - it('should clean up temporary files on errors', async () => { + describe("Resource Limits and Cleanup", () => { + it("should clean up temporary files on errors", async () => { const processor = new SnapProcessor(); const tempFilesBefore = fs.readdirSync(os.tmpdir()).length; @@ -311,10 +327,10 @@ describe('Edge Case Tests', () => { }, 100); }); - it('should handle concurrent access to same file', async () => { + it("should handle concurrent access to same file", async () => { const processor = new DotProcessor(); const testContent = 'digraph G { test [label="Concurrent Test"]; }'; - const testFile = path.join(tempDir, 'concurrent_test.dot'); + const testFile = path.join(tempDir, "concurrent_test.dot"); fs.writeFileSync(testFile, testContent); @@ -334,14 +350,14 @@ describe('Edge Case Tests', () => { }); }); - it('should handle very long file paths', async () => { + it("should handle very long file paths", async () => { const processor = new DotProcessor(); // Create a very long but valid path - const longDir = path.join(tempDir, 'a'.repeat(100), 'b'.repeat(100)); + const longDir = path.join(tempDir, "a".repeat(100), "b".repeat(100)); fs.mkdirSync(longDir, { recursive: true }); - const longFilePath = path.join(longDir, 'test.dot'); + const longFilePath = path.join(longDir, "test.dot"); const testContent = 'digraph G { test [label="Long Path Test"]; }'; fs.writeFileSync(longFilePath, testContent); @@ -352,44 +368,54 @@ describe('Edge Case Tests', () => { }); }); - describe('Translation Edge Cases', () => { - it('should handle empty translation maps', async () => { + describe("Translation Edge Cases", () => { + it("should handle empty translation maps", async () => { const processor = new DotProcessor(); const content = 'digraph G { test [label="Test"]; }'; - const outputPath = path.join(tempDir, 'empty_translation.dot'); + const outputPath = path.join(tempDir, "empty_translation.dot"); const emptyTranslations = new Map(); await expect( - processor.processTexts(Buffer.from(content), emptyTranslations, outputPath) + processor.processTexts( + Buffer.from(content), + emptyTranslations, + outputPath, + ), ).resolves.not.toThrow(); expect(fs.existsSync(outputPath)).toBe(true); }); - it('should handle translations with special regex characters', async () => { + it("should handle translations with special regex characters", async () => { const processor = new DotProcessor(); const content = 'digraph G { test [label="$pecial [chars] (here)"]; }'; - const outputPath = path.join(tempDir, 'special_chars_translation.dot'); + const outputPath = path.join(tempDir, "special_chars_translation.dot"); - const translations = new Map([['$pecial [chars] (here)', 'Caracteres especiales aquí']]); + const translations = new Map([ + ["$pecial [chars] (here)", "Caracteres especiales aquí"], + ]); - const result = await processor.processTexts(Buffer.from(content), translations, outputPath); - const translatedContent = Buffer.from(result).toString('utf8'); + const result = await processor.processTexts( + Buffer.from(content), + translations, + outputPath, + ); + const translatedContent = Buffer.from(result).toString("utf8"); - expect(translatedContent).toContain('Caracteres especiales aquí'); + expect(translatedContent).toContain("Caracteres especiales aquí"); }); - it('should handle very large translation maps', async () => { + it("should handle very large translation maps", async () => { const processor = new DotProcessor(); // Create content with many translatable items - const lines = ['digraph G {']; + const lines = ["digraph G {"]; for (let i = 0; i < 100; i++) { lines.push(` node${i} [label="Text ${i}"];`); } - lines.push('}'); - const content = lines.join('\n'); + lines.push("}"); + const content = lines.join("\n"); // Create large translation map const translations = new Map(); @@ -397,15 +423,19 @@ describe('Edge Case Tests', () => { translations.set(`Text ${i}`, `Texto ${i}`); } - const outputPath = path.join(tempDir, 'large_translation.dot'); - const result = await processor.processTexts(Buffer.from(content), translations, outputPath); + const outputPath = path.join(tempDir, "large_translation.dot"); + const result = await processor.processTexts( + Buffer.from(content), + translations, + outputPath, + ); expect(Buffer.from(result)).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); - const translatedContent = Buffer.from(result).toString('utf8'); - expect(translatedContent).toContain('Texto 0'); - expect(translatedContent).toContain('Texto 99'); + const translatedContent = Buffer.from(result).toString("utf8"); + expect(translatedContent).toContain("Texto 0"); + expect(translatedContent).toContain("Texto 99"); }); }); }); diff --git a/test/errorHandling.test.ts b/test/errorHandling.test.ts index c94ed53..72706fa 100644 --- a/test/errorHandling.test.ts +++ b/test/errorHandling.test.ts @@ -1,16 +1,16 @@ // Comprehensive error handling tests for all processors -import fs from 'fs'; -import path from 'path'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; -import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; - -describe('Error Handling', () => { - const tempDir = path.join(__dirname, 'temp_error'); +import fs from "fs"; +import path from "path"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; +import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; + +describe("Error Handling", () => { + const tempDir = path.join(__dirname, "temp_error"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -24,8 +24,8 @@ describe('Error Handling', () => { } }); - describe('File I/O Error Handling', () => { - it('should handle non-existent files gracefully', async () => { + describe("File I/O Error Handling", () => { + it("should handle non-existent files gracefully", async () => { const processors = [ new SnapProcessor(), new TouchChatProcessor(), @@ -35,14 +35,16 @@ describe('Error Handling', () => { ]; for (const processor of processors) { - await expect(processor.loadIntoTree('/non/existent/file.ext')).rejects.toThrow(); + await expect( + processor.loadIntoTree("/non/existent/file.ext"), + ).rejects.toThrow(); } }); - it('should handle permission denied errors', async () => { + it("should handle permission denied errors", async () => { // Create a file with no read permissions (if possible on this system) - const restrictedFile = path.join(tempDir, 'restricted.txt'); - fs.writeFileSync(restrictedFile, 'test content'); + const restrictedFile = path.join(tempDir, "restricted.txt"); + fs.writeFileSync(restrictedFile, "test content"); try { fs.chmodSync(restrictedFile, 0o000); // No permissions @@ -51,7 +53,7 @@ describe('Error Handling', () => { await expect(processor.loadIntoTree(restrictedFile)).rejects.toThrow(); } catch (_e) { // chmod might not work on all systems, skip this test - console.log('Skipping permission test - chmod not supported'); + console.log("Skipping permission test - chmod not supported"); } finally { try { fs.chmodSync(restrictedFile, 0o644); // Restore permissions for cleanup @@ -63,38 +65,38 @@ describe('Error Handling', () => { }); }); - describe('Malformed Content Error Handling', () => { - it('should handle invalid JSON in OBF files', async () => { + describe("Malformed Content Error Handling", () => { + it("should handle invalid JSON in OBF files", async () => { const processor = new ObfProcessor(); - const invalidJson = Buffer.from('{ invalid json content }'); + const invalidJson = Buffer.from("{ invalid json content }"); await expect(processor.loadIntoTree(invalidJson)).rejects.toThrow(); }); - it('should handle invalid XML in OPML files', async () => { + it("should handle invalid XML in OPML files", async () => { const processor = new OpmlProcessor(); - const invalidXml = Buffer.from('xml'); + const invalidXml = Buffer.from("xml"); await expect(processor.loadIntoTree(invalidXml)).rejects.toThrow(); }); - it('should handle invalid XML in GridSet files', async () => { + it("should handle invalid XML in GridSet files", async () => { const processor = new GridsetProcessor(); - const invalidZip = Buffer.from('not a zip file'); + const invalidZip = Buffer.from("not a zip file"); await expect(processor.loadIntoTree(invalidZip)).rejects.toThrow(); }); - it('should handle corrupted SQLite databases', async () => { + it("should handle corrupted SQLite databases", async () => { const processor = new SnapProcessor(); - const corruptedDb = Buffer.from('SQLite format 3\x00but corrupted data'); + const corruptedDb = Buffer.from("SQLite format 3\x00but corrupted data"); await expect(processor.loadIntoTree(corruptedDb)).rejects.toThrow(); }); }); - describe('Empty Content Error Handling', () => { - it('should handle empty files gracefully', async () => { + describe("Empty Content Error Handling", () => { + it("should handle empty files gracefully", async () => { const emptyBuffer = Buffer.alloc(0); // Processors should throw meaningful errors @@ -105,86 +107,92 @@ describe('Error Handling', () => { await expect(snapProcessor.loadIntoTree(emptyBuffer)).rejects.toThrow(); }); - it('should handle files with only whitespace', async () => { - const whitespaceBuffer = Buffer.from(' \n\t \n '); + it("should handle files with only whitespace", async () => { + const whitespaceBuffer = Buffer.from(" \n\t \n "); const dotProcessor = new DotProcessor(); - await expect(dotProcessor.loadIntoTree(whitespaceBuffer)).rejects.toThrow(); + await expect( + dotProcessor.loadIntoTree(whitespaceBuffer), + ).rejects.toThrow(); }); }); - describe('Memory and Resource Error Handling', () => { - it('should handle very large files gracefully', async () => { + describe("Memory and Resource Error Handling", () => { + it("should handle very large files gracefully", async () => { // Create a large but valid DOT file const largeDotContent = - 'digraph G {\n' + + "digraph G {\n" + Array(1000) .fill(0) .map((_, i) => ` node${i} [label="Node ${i}"];`) - .join('\n') + - '\n}'; + .join("\n") + + "\n}"; const processor = new DotProcessor(); const result = await processor.loadIntoTree(Buffer.from(largeDotContent)); expect(Object.keys(result.pages).length).toBeGreaterThan(0); }); - it('should clean up temporary files on error', async () => { + it("should clean up temporary files on error", async () => { const processor = new SnapProcessor(); - const invalidData = Buffer.from('invalid sqlite data'); + const invalidData = Buffer.from("invalid sqlite data"); // eslint-disable-next-line @typescript-eslint/no-var-requires - const tempFilesBefore = fs.readdirSync(require('os').tmpdir()).length; + const tempFilesBefore = fs.readdirSync(require("os").tmpdir()).length; await expect(processor.loadIntoTree(invalidData)).rejects.toThrow(); // Give some time for cleanup setTimeout(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires - const tempFilesAfter = fs.readdirSync(require('os').tmpdir()).length; + const tempFilesAfter = fs.readdirSync(require("os").tmpdir()).length; const allowedDelta = 5; // Allow a handful of transient temp files created by other processes - expect(tempFilesAfter).toBeLessThanOrEqual(tempFilesBefore + allowedDelta); + expect(tempFilesAfter).toBeLessThanOrEqual( + tempFilesBefore + allowedDelta, + ); }, 100); }); }); - describe('Translation Error Handling', () => { - it('should handle invalid translation maps', async () => { + describe("Translation Error Handling", () => { + it("should handle invalid translation maps", async () => { const processor = new DotProcessor(); const validContent = Buffer.from('digraph G { node1 [label="test"]; }'); - const outputPath = path.join(tempDir, 'output.dot'); + const outputPath = path.join(tempDir, "output.dot"); // Test with null/undefined values in translation map const invalidTranslations = new Map([ - ['test', null as any], - [undefined as any, 'replacement'], - ['valid', 'válido'], + ["test", null as any], + [undefined as any, "replacement"], + ["valid", "válido"], ]); await expect( - processor.processTexts(validContent, invalidTranslations, outputPath) + processor.processTexts(validContent, invalidTranslations, outputPath), ).resolves.not.toThrow(); }); - it('should handle circular references in translation maps', async () => { + it("should handle circular references in translation maps", async () => { const processor = new DotProcessor(); - const validContent = Buffer.from('digraph G { node1 [label="A"]; node2 [label="B"]; }'); - const outputPath = path.join(tempDir, 'circular.dot'); + const validContent = Buffer.from( + 'digraph G { node1 [label="A"]; node2 [label="B"]; }', + ); + const outputPath = path.join(tempDir, "circular.dot"); const circularTranslations = new Map([ - ['A', 'B'], - ['B', 'A'], + ["A", "B"], + ["B", "A"], ]); await expect( - processor.processTexts(validContent, circularTranslations, outputPath) + processor.processTexts(validContent, circularTranslations, outputPath), ).resolves.not.toThrow(); }); }); - describe('Save Operation Error Handling', () => { - it('should handle read-only output directories', async () => { - const readOnlyDir = path.join(tempDir, 'readonly'); + describe("Save Operation Error Handling", () => { + it("should handle read-only output directories", async () => { + const readOnlyDir = path.join(tempDir, "readonly"); fs.mkdirSync(readOnlyDir, { recursive: true }); try { @@ -192,14 +200,16 @@ describe('Error Handling', () => { const processor = new DotProcessor(); const tree = await processor.loadIntoTree( - Buffer.from('digraph G { node1 [label="test"]; }') + Buffer.from('digraph G { node1 [label="test"]; }'), ); - const outputPath = path.join(readOnlyDir, 'output.dot'); + const outputPath = path.join(readOnlyDir, "output.dot"); - await expect(processor.saveFromTree(tree, outputPath)).rejects.toThrow(); + await expect( + processor.saveFromTree(tree, outputPath), + ).rejects.toThrow(); } catch (_e) { // chmod might not work on all systems - console.log('Skipping read-only directory test - chmod not supported'); + console.log("Skipping read-only directory test - chmod not supported"); } finally { try { fs.chmodSync(readOnlyDir, 0o755); // Restore permissions @@ -210,15 +220,20 @@ describe('Error Handling', () => { } }); - it('should handle disk space errors gracefully', async () => { + it("should handle disk space errors gracefully", async () => { // This is hard to test reliably, but we can at least ensure // the error handling code paths exist const processor = new DotProcessor(); - const tree = await processor.loadIntoTree(Buffer.from('digraph G { node1 [label="test"]; }')); + const tree = await processor.loadIntoTree( + Buffer.from('digraph G { node1 [label="test"]; }'), + ); // Try to save to an invalid path await expect( - processor.saveFromTree(tree, '/invalid/path/that/does/not/exist/output.dot') + processor.saveFromTree( + tree, + "/invalid/path/that/does/not/exist/output.dot", + ), ).rejects.toThrow(); }); }); diff --git a/test/grid3VerbsParser.test.ts b/test/grid3VerbsParser.test.ts index b4d9d72..635a2aa 100644 --- a/test/grid3VerbsParser.test.ts +++ b/test/grid3VerbsParser.test.ts @@ -1,22 +1,24 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { Grid3VerbsParser } from '../src/utilities/analytics/morphology/grid3VerbsParser'; -import { join } from 'path'; +import { Grid3VerbsParser } from "../src/utilities/analytics/morphology/grid3VerbsParser"; +import { join } from "path"; -const SYNTHETIC_XML = join(__dirname, 'assets', 'grid3', 'synthetic-verbs.xml'); +const SYNTHETIC_XML = join(__dirname, "assets", "grid3", "synthetic-verbs.xml"); // Users can set GRID3_MORPHOLOGY_DIR to point to their own copy of // Grid 3's Locale directory (e.g. copied from another machine). // Example: GRID3_MORPHOLOGY_DIR=/path/to/Grid3/Locale npm test const MORPHOLOGY_DIR = process.env.GRID3_MORPHOLOGY_DIR || - (process.platform === 'win32' ? 'C:\\Program Files (x86)\\Smartbox\\Grid 3\\Locale' : ''); + (process.platform === "win32" + ? "C:\\Program Files (x86)\\Smartbox\\Grid 3\\Locale" + : ""); const parser = new Grid3VerbsParser(); function fileExists(p: string): boolean { try { // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require('fs'); + const fs = require("fs"); fs.accessSync(p, fs.constants.R_OK); return true; } catch { @@ -25,137 +27,140 @@ function fileExists(p: string): boolean { } function getMorphZip(locale: string): string { - return join(MORPHOLOGY_DIR, locale, 'verbs', 'verbs.zip'); + return join(MORPHOLOGY_DIR, locale, "verbs", "verbs.zip"); } // eslint-disable-next-line @typescript-eslint/no-var-requires -const fs = require('fs'); +const fs = require("fs"); -describe('Grid3VerbsParser - Synthetic XML (always runs)', () => { - test('synthetic fixture exists', () => { +describe("Grid3VerbsParser - Synthetic XML (always runs)", () => { + test("synthetic fixture exists", () => { expect(fileExists(SYNTHETIC_XML)).toBe(true); }); - describe('parseXml (flat forms)', () => { + describe("parseXml (flat forms)", () => { let forms: Map; let locale: string; beforeAll(() => { - const xml = fs.readFileSync(SYNTHETIC_XML, 'utf-8'); + const xml = fs.readFileSync(SYNTHETIC_XML, "utf-8"); const result = parser.parseXml(xml); locale = result.locale; forms = result.verbs; }); - test('detects locale test-XX', () => { - expect(locale).toBe('test-XX'); + test("detects locale test-XX", () => { + expect(locale).toBe("test-XX"); }); - test('parses 3 verbs', () => { + test("parses 3 verbs", () => { expect(forms.size).toBe(3); }); - test('regular verb walk -> walks, walked, walking', () => { - const walkForms = forms.get('walk'); + test("regular verb walk -> walks, walked, walking", () => { + const walkForms = forms.get("walk"); expect(walkForms).toBeDefined(); - expect(walkForms).toContain('walks'); - expect(walkForms).toContain('walked'); - expect(walkForms).toContain('walking'); + expect(walkForms).toContain("walks"); + expect(walkForms).toContain("walked"); + expect(walkForms).toContain("walking"); }); - test('irregular verb go -> goes, went, going, gone', () => { - const goForms = forms.get('go'); + test("irregular verb go -> goes, went, going, gone", () => { + const goForms = forms.get("go"); expect(goForms).toBeDefined(); - expect(goForms).toContain('goes'); - expect(goForms).toContain('went'); - expect(goForms).toContain('going'); - expect(goForms).toContain('gone'); + expect(goForms).toContain("goes"); + expect(goForms).toContain("went"); + expect(goForms).toContain("going"); + expect(goForms).toContain("gone"); }); - test('default-rule verb jump -> jumps, jumped, jumping', () => { - const jumpForms = forms.get('jump'); + test("default-rule verb jump -> jumps, jumped, jumping", () => { + const jumpForms = forms.get("jump"); expect(jumpForms).toBeDefined(); - expect(jumpForms).toContain('jumps'); - expect(jumpForms).toContain('jumped'); - expect(jumpForms).toContain('jumping'); + expect(jumpForms).toContain("jumps"); + expect(jumpForms).toContain("jumped"); + expect(jumpForms).toContain("jumping"); }); - test('no compound forms', () => { + test("no compound forms", () => { for (const [, wordForms] of forms) { for (const f of wordForms) { - expect(f).not.toContain(' '); + expect(f).not.toContain(" "); } } }); }); - describe('parseXmlDetailed (forms with conditions)', () => { - let detailed: Map }>>; + describe("parseXmlDetailed (forms with conditions)", () => { + let detailed: Map< + string, + Array<{ value: string; conditions: Map }> + >; beforeAll(() => { const result = parser.parseXmlFileDetailed(SYNTHETIC_XML); detailed = result.verbs; }); - test('parses 3 verbs with conditions', () => { + test("parses 3 verbs with conditions", () => { expect(detailed.size).toBe(3); }); test('walk has "walks" with person=third, time=present', () => { - const walkForms = detailed.get('walk'); + const walkForms = detailed.get("walk"); expect(walkForms).toBeDefined(); - const walksForm = walkForms!.find((f) => f.value === 'walks'); + const walksForm = walkForms!.find((f) => f.value === "walks"); expect(walksForm).toBeDefined(); - expect(walksForm!.conditions.get('person')).toBe('third'); - expect(walksForm!.conditions.get('time')).toBe('present'); + expect(walksForm!.conditions.get("person")).toBe("third"); + expect(walksForm!.conditions.get("time")).toBe("present"); }); test('walk has "walked" with time=past', () => { - const walkForms = detailed.get('walk'); - const walkedForm = walkForms!.find((f) => f.value === 'walked'); + const walkForms = detailed.get("walk"); + const walkedForm = walkForms!.find((f) => f.value === "walked"); expect(walkedForm).toBeDefined(); - expect(walkedForm!.conditions.get('time')).toBe('past'); + expect(walkedForm!.conditions.get("time")).toBe("past"); }); test('go has "went" with time=past', () => { - const goForms = detailed.get('go'); - const wentForm = goForms!.find((f) => f.value === 'went'); + const goForms = detailed.get("go"); + const wentForm = goForms!.find((f) => f.value === "went"); expect(wentForm).toBeDefined(); - expect(wentForm!.conditions.get('time')).toBe('past'); + expect(wentForm!.conditions.get("time")).toBe("past"); }); test('go has "gone" with participleType=pastparticiple', () => { - const goForms = detailed.get('go'); - const goneForm = goForms!.find((f) => f.value === 'gone'); + const goForms = detailed.get("go"); + const goneForm = goForms!.find((f) => f.value === "gone"); expect(goneForm).toBeDefined(); - expect(goneForm!.conditions.get('participleType')).toBe('pastparticiple'); + expect(goneForm!.conditions.get("participleType")).toBe("pastparticiple"); }); test('go has "goes" with person=third', () => { - const goForms = detailed.get('go'); - const goesForm = goForms!.find((f) => f.value === 'goes'); + const goForms = detailed.get("go"); + const goesForm = goForms!.find((f) => f.value === "goes"); expect(goesForm).toBeDefined(); - expect(goesForm!.conditions.get('person')).toBe('third'); + expect(goesForm!.conditions.get("person")).toBe("third"); }); }); - describe('parseZip with synthetic data', () => { - test('can parse a zip file containing verbs.xml', () => { + describe("parseZip with synthetic data", () => { + test("can parse a zip file containing verbs.xml", () => { // Create a temporary zip with our synthetic XML // eslint-disable-next-line @typescript-eslint/no-var-requires - const AdmZip = require('adm-zip'); - const tmpDir = join(__dirname, 'assets', 'grid3', '_tmp'); - const zipPath = join(tmpDir, 'verbs.zip'); + const AdmZip = require("adm-zip"); + const tmpDir = join(__dirname, "assets", "grid3", "_tmp"); + const zipPath = join(tmpDir, "verbs.zip"); try { fs.mkdirSync(tmpDir, { recursive: true }); const zip = new AdmZip(); - zip.addFile('verbs.xml', fs.readFileSync(SYNTHETIC_XML)); + zip.addFile("verbs.xml", fs.readFileSync(SYNTHETIC_XML)); zip.writeZip(zipPath); const result = parser.parseZip(zipPath); expect(result.verbs.size).toBe(3); - expect(result.verbs.get('go')).toContain('went'); + expect(result.verbs.get("go")).toContain("went"); } finally { try { fs.unlinkSync(zipPath); @@ -168,42 +173,42 @@ describe('Grid3VerbsParser - Synthetic XML (always runs)', () => { }); }); -describe('Grid3VerbsParser - External morphology data', () => { +describe("Grid3VerbsParser - External morphology data", () => { // These tests run when GRID3_MORPHOLOGY_DIR points to a valid // Grid 3 Locale directory (or a directory someone has copied // the verbs.zip files into). On CI without Grid 3, these skip. - test('parses en-GB verbs.zip', () => { - const zipPath = getMorphZip('en-GB'); + test("parses en-GB verbs.zip", () => { + const zipPath = getMorphZip("en-GB"); if (!fileExists(zipPath)) return; const result = parser.parseZip(zipPath); expect(result.verbs.size).toBeGreaterThan(100); }); - test('en-GB go -> goes, going, gone, went', () => { - const zipPath = getMorphZip('en-GB'); + test("en-GB go -> goes, going, gone, went", () => { + const zipPath = getMorphZip("en-GB"); if (!fileExists(zipPath)) return; const result = parser.parseZip(zipPath); - const goForms = result.verbs.get('go'); + const goForms = result.verbs.get("go"); expect(goForms).toBeDefined(); - expect(goForms).toContain('goes'); - expect(goForms).toContain('going'); - expect(goForms).toContain('gone'); - expect(goForms).toContain('went'); + expect(goForms).toContain("goes"); + expect(goForms).toContain("going"); + expect(goForms).toContain("gone"); + expect(goForms).toContain("went"); }); - test('en-GB detailed has conditions', () => { - const zipPath = getMorphZip('en-GB'); + test("en-GB detailed has conditions", () => { + const zipPath = getMorphZip("en-GB"); if (!fileExists(zipPath)) return; const result = parser.parseZipDetailed(zipPath); - const goForms = result.verbs.get('go'); + const goForms = result.verbs.get("go"); expect(goForms).toBeDefined(); expect(goForms!.length).toBeGreaterThan(0); - const went = goForms!.find((f) => f.value === 'went'); + const went = goForms!.find((f) => f.value === "went"); expect(went).toBeDefined(); }); - test('parses nb-NO verbs.zip', () => { - const zipPath = getMorphZip('nb-NO'); + test("parses nb-NO verbs.zip", () => { + const zipPath = getMorphZip("nb-NO"); if (!fileExists(zipPath)) return; const result = parser.parseZip(zipPath); expect(result.verbs.size).toBeGreaterThan(100); diff --git a/test/gridsetHelpers.misc.test.ts b/test/gridsetHelpers.misc.test.ts index 9b571d7..a85e364 100644 --- a/test/gridsetHelpers.misc.test.ts +++ b/test/gridsetHelpers.misc.test.ts @@ -1,35 +1,37 @@ -import { describe, expect, it } from '@jest/globals'; +import { describe, expect, it } from "@jest/globals"; import { createFileMapXml, createSettingsXml, generateGrid3Guid, -} from '../src/processors/gridset/helpers'; +} from "../src/processors/gridset/helpers"; -describe('Gridset helper misc utilities', () => { - it('generates a GUID-like value', async () => { +describe("Gridset helper misc utilities", () => { + it("generates a GUID-like value", async () => { const guid = generateGrid3Guid(); - expect(guid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(guid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); }); - it('builds settings XML with overrides', async () => { - const xml = createSettingsXml('Home', { + it("builds settings XML with overrides", async () => { + const xml = createSettingsXml("Home", { scanEnabled: true, hoverTimeoutMs: 1500, - language: 'en-GB', + language: "en-GB", }); - expect(xml).toContain('Home'); - expect(xml).toContain('true'); - expect(xml).toContain('1500'); - expect(xml).toContain('en-GB'); + expect(xml).toContain("Home"); + expect(xml).toContain("true"); + expect(xml).toContain("1500"); + expect(xml).toContain("en-GB"); }); - it('builds file map XML for multiple grids', async () => { + it("builds file map XML for multiple grids", async () => { const xml = createFileMapXml([ - { name: 'Main', path: 'main.gridset' }, - { name: 'Alt', path: 'alt.gridset', dynamicFiles: ['dyn1'] }, + { name: "Main", path: "main.gridset" }, + { name: "Alt", path: "alt.gridset", dynamicFiles: ["dyn1"] }, ]); - expect(xml).toContain('main.gridset'); - expect(xml).toContain('alt.gridset'); - expect(xml).toContain(''); + expect(xml).toContain("main.gridset"); + expect(xml).toContain("alt.gridset"); + expect(xml).toContain(""); }); }); diff --git a/test/gridsetHelpers.test.ts b/test/gridsetHelpers.test.ts index 372f38c..d497107 100644 --- a/test/gridsetHelpers.test.ts +++ b/test/gridsetHelpers.test.ts @@ -1,12 +1,12 @@ -import AdmZip from 'adm-zip'; -import { AACTree, AACPage, AACButton, Gridset } from '../src/index'; +import AdmZip from "adm-zip"; +import { AACTree, AACPage, AACButton, Gridset } from "../src/index"; -describe('Gridset helper APIs', () => { - it('getPageTokenImageMap returns button.id to resolvedImageEntry map for a page', async () => { +describe("Gridset helper APIs", () => { + it("getPageTokenImageMap returns button.id to resolvedImageEntry map for a page", async () => { const tree = new AACTree(); const page = new AACPage({ - id: 'p1', - name: 'Page 1', + id: "p1", + name: "Page 1", grid: { columns: 2, rows: 2 }, buttons: [], }); @@ -14,38 +14,38 @@ describe('Gridset helper APIs', () => { page.addButton( new AACButton({ - id: 'b1', - label: 'A', - message: 'A', - resolvedImageEntry: 'Grids/Home/Images/a.png', - }) + id: "b1", + label: "A", + message: "A", + resolvedImageEntry: "Grids/Home/Images/a.png", + }), ); page.addButton( new AACButton({ - id: 'b2', - label: 'B', - message: 'B', - resolvedImageEntry: 'Grids/Home/1-1.jpeg', - }) + id: "b2", + label: "B", + message: "B", + resolvedImageEntry: "Grids/Home/1-1.jpeg", + }), ); - const map = Gridset.getPageTokenImageMap(tree, 'p1'); - expect(map.get('b1')).toBe('Grids/Home/Images/a.png'); - expect(map.get('b2')).toBe('Grids/Home/1-1.jpeg'); + const map = Gridset.getPageTokenImageMap(tree, "p1"); + expect(map.get("b1")).toBe("Grids/Home/Images/a.png"); + expect(map.get("b2")).toBe("Grids/Home/1-1.jpeg"); expect(map.size).toBe(2); }); - it('getAllowedImageEntries aggregates unique image entries across pages', async () => { + it("getAllowedImageEntries aggregates unique image entries across pages", async () => { const tree = new AACTree(); const p1 = new AACPage({ - id: 'p1', - name: 'P1', + id: "p1", + name: "P1", grid: { columns: 1, rows: 1 }, buttons: [], }); const p2 = new AACPage({ - id: 'p2', - name: 'P2', + id: "p2", + name: "P2", grid: { columns: 1, rows: 1 }, buttons: [], }); @@ -54,57 +54,58 @@ describe('Gridset helper APIs', () => { p1.addButton( new AACButton({ - id: 'b1', - label: 'A', - message: 'A', - resolvedImageEntry: 'X/Y/a.png', - }) + id: "b1", + label: "A", + message: "A", + resolvedImageEntry: "X/Y/a.png", + }), ); p1.addButton( new AACButton({ - id: 'b2', - label: 'B', - message: 'B', - resolvedImageEntry: 'X/Y/a.png', - }) + id: "b2", + label: "B", + message: "B", + resolvedImageEntry: "X/Y/a.png", + }), ); p2.addButton( new AACButton({ - id: 'b3', - label: 'C', - message: 'C', - resolvedImageEntry: 'X/Z/c.png', - }) + id: "b3", + label: "C", + message: "C", + resolvedImageEntry: "X/Z/c.png", + }), ); const set = Gridset.getAllowedImageEntries(tree); - expect(set.has('X/Y/a.png')).toBe(true); - expect(set.has('X/Z/c.png')).toBe(true); + expect(set.has("X/Y/a.png")).toBe(true); + expect(set.has("X/Z/c.png")).toBe(true); expect(set.size).toBe(2); }); - it('openImage reads a specific entry from a gridset buffer', async () => { + it("openImage reads a specific entry from a gridset buffer", async () => { const zip = new AdmZip(); - zip.addFile('Grids/Home/Images/dog.png', Buffer.from('DOGDATA')); + zip.addFile("Grids/Home/Images/dog.png", Buffer.from("DOGDATA")); const buf = zip.toBuffer(); - const data = await Gridset.openImage(buf, 'Grids/Home/Images/dog.png'); - expect(Buffer.from(data || []).toString('utf8')).toBe('DOGDATA'); + const data = await Gridset.openImage(buf, "Grids/Home/Images/dog.png"); + expect(Buffer.from(data || []).toString("utf8")).toBe("DOGDATA"); - const missing = await Gridset.openImage(buf, 'Grids/Home/Images/cat.png'); + const missing = await Gridset.openImage(buf, "Grids/Home/Images/cat.png"); expect(missing).toBeNull(); }); }); -describe('Grid3 GUID Generation', () => { - it('generateGrid3Guid generates a valid GUID format', async () => { +describe("Grid3 GUID Generation", () => { + it("generateGrid3Guid generates a valid GUID format", async () => { const guid = Gridset.generateGrid3Guid(); // Check format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx - const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + const guidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; expect(guid).toMatch(guidRegex); }); - it('generateGrid3Guid generates unique GUIDs', async () => { + it("generateGrid3Guid generates unique GUIDs", async () => { const guid1 = Gridset.generateGrid3Guid(); const guid2 = Gridset.generateGrid3Guid(); const guid3 = Gridset.generateGrid3Guid(); @@ -113,122 +114,130 @@ describe('Grid3 GUID Generation', () => { expect(guid1).not.toBe(guid3); }); - it('generateGrid3Guid generates GUIDs with correct version and variant', async () => { + it("generateGrid3Guid generates GUIDs with correct version and variant", async () => { // Generate multiple GUIDs and check they all have version 4 and variant 1 for (let i = 0; i < 10; i++) { const guid = Gridset.generateGrid3Guid(); - const parts = guid.split('-'); + const parts = guid.split("-"); // Version 4 is in the first character of the 3rd group - expect(parts[2][0]).toBe('4'); + expect(parts[2][0]).toBe("4"); // Variant 1 is in the first character of the 4th group (should be 8, 9, a, or b) - expect(['8', '9', 'a', 'b']).toContain(parts[3][0].toLowerCase()); + expect(["8", "9", "a", "b"]).toContain(parts[3][0].toLowerCase()); } }); }); -describe('Grid3 Settings XML Builder', () => { - it('createSettingsXml creates valid XML with default options', async () => { - const xml = Gridset.createSettingsXml('Home'); - expect(xml).toContain('Home'); - expect(xml).toContain('false'); - expect(xml).toContain('false'); - expect(xml).toContain('true'); - expect(xml).toContain('en-US'); +describe("Grid3 Settings XML Builder", () => { + it("createSettingsXml creates valid XML with default options", async () => { + const xml = Gridset.createSettingsXml("Home"); + expect(xml).toContain("Home"); + expect(xml).toContain("false"); + expect(xml).toContain("false"); + expect(xml).toContain("true"); + expect(xml).toContain("en-US"); }); - it('createSettingsXml respects custom options', async () => { - const xml = Gridset.createSettingsXml('MainMenu', { + it("createSettingsXml respects custom options", async () => { + const xml = Gridset.createSettingsXml("MainMenu", { scanEnabled: true, scanTimeoutMs: 3000, hoverEnabled: true, hoverTimeoutMs: 1500, mouseclickEnabled: false, - language: 'fr-FR', + language: "fr-FR", }); - expect(xml).toContain('MainMenu'); - expect(xml).toContain('true'); - expect(xml).toContain('3000'); - expect(xml).toContain('true'); - expect(xml).toContain('1500'); - expect(xml).toContain('false'); - expect(xml).toContain('fr-FR'); - }); - - it('createSettingsXml includes XML namespace', async () => { - const xml = Gridset.createSettingsXml('Home'); - expect(xml).toContain('xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'); + expect(xml).toContain("MainMenu"); + expect(xml).toContain("true"); + expect(xml).toContain("3000"); + expect(xml).toContain("true"); + expect(xml).toContain("1500"); + expect(xml).toContain("false"); + expect(xml).toContain("fr-FR"); + }); + + it("createSettingsXml includes XML namespace", async () => { + const xml = Gridset.createSettingsXml("Home"); + expect(xml).toContain( + 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + ); }); - it('createSettingsXml handles partial options', async () => { - const xml = Gridset.createSettingsXml('Home', { + it("createSettingsXml handles partial options", async () => { + const xml = Gridset.createSettingsXml("Home", { scanEnabled: true, - language: 'de-DE', + language: "de-DE", }); - expect(xml).toContain('true'); - expect(xml).toContain('de-DE'); + expect(xml).toContain("true"); + expect(xml).toContain("de-DE"); // Should still have defaults for unspecified options - expect(xml).toContain('false'); - expect(xml).toContain('true'); + expect(xml).toContain("false"); + expect(xml).toContain("true"); }); }); -describe('Grid3 FileMap XML Builder', () => { - it('createFileMapXml creates valid XML with single grid', async () => { - const xml = Gridset.createFileMapXml([{ name: 'Home', path: 'Grids\\Home\\grid.xml' }]); - expect(xml).toContain(''); - expect(xml).toContain(' { + it("createFileMapXml creates valid XML with single grid", async () => { + const xml = Gridset.createFileMapXml([ + { name: "Home", path: "Grids\\Home\\grid.xml" }, + ]); + expect(xml).toContain(""); + expect(xml).toContain(" { + it("createFileMapXml creates valid XML with multiple grids", async () => { const xml = Gridset.createFileMapXml([ - { name: 'Home', path: 'Grids\\Home\\grid.xml' }, - { name: 'Menu', path: 'Grids\\Menu\\grid.xml' }, - { name: 'Settings', path: 'Grids\\Settings\\grid.xml' }, + { name: "Home", path: "Grids\\Home\\grid.xml" }, + { name: "Menu", path: "Grids\\Menu\\grid.xml" }, + { name: "Settings", path: "Grids\\Settings\\grid.xml" }, ]); expect(xml).toContain('StaticFile="Grids\\Home\\grid.xml"'); expect(xml).toContain('StaticFile="Grids\\Menu\\grid.xml"'); expect(xml).toContain('StaticFile="Grids\\Settings\\grid.xml"'); }); - it('createFileMapXml includes dynamic files when provided', async () => { + it("createFileMapXml includes dynamic files when provided", async () => { const xml = Gridset.createFileMapXml([ { - name: 'Home', - path: 'Grids\\Home\\grid.xml', - dynamicFiles: ['dynamic1.xml', 'dynamic2.xml'], + name: "Home", + path: "Grids\\Home\\grid.xml", + dynamicFiles: ["dynamic1.xml", "dynamic2.xml"], }, ]); - expect(xml).toContain(''); - expect(xml).toContain('dynamic1.xml'); - expect(xml).toContain('dynamic2.xml'); + expect(xml).toContain(""); + expect(xml).toContain("dynamic1.xml"); + expect(xml).toContain("dynamic2.xml"); }); - it('createFileMapXml omits DynamicFiles when empty', async () => { + it("createFileMapXml omits DynamicFiles when empty", async () => { const xml = Gridset.createFileMapXml([ - { name: 'Home', path: 'Grids\\Home\\grid.xml', dynamicFiles: [] }, + { name: "Home", path: "Grids\\Home\\grid.xml", dynamicFiles: [] }, ]); - expect(xml).not.toContain(''); + expect(xml).not.toContain(""); }); - it('createFileMapXml includes XML namespace', async () => { - const xml = Gridset.createFileMapXml([{ name: 'Home', path: 'Grids\\Home\\grid.xml' }]); - expect(xml).toContain('xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'); + it("createFileMapXml includes XML namespace", async () => { + const xml = Gridset.createFileMapXml([ + { name: "Home", path: "Grids\\Home\\grid.xml" }, + ]); + expect(xml).toContain( + 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + ); }); - it('createFileMapXml handles mixed grids with and without dynamic files', async () => { + it("createFileMapXml handles mixed grids with and without dynamic files", async () => { const xml = Gridset.createFileMapXml([ - { name: 'Home', path: 'Grids\\Home\\grid.xml' }, + { name: "Home", path: "Grids\\Home\\grid.xml" }, { - name: 'Menu', - path: 'Grids\\Menu\\grid.xml', - dynamicFiles: ['menu_dynamic.xml'], + name: "Menu", + path: "Grids\\Menu\\grid.xml", + dynamicFiles: ["menu_dynamic.xml"], }, ]); expect(xml).toContain('StaticFile="Grids\\Home\\grid.xml"'); expect(xml).toContain('StaticFile="Grids\\Menu\\grid.xml"'); - expect(xml).toContain('menu_dynamic.xml'); + expect(xml).toContain("menu_dynamic.xml"); }); }); diff --git a/test/gridsetImageDebug.test.ts b/test/gridsetImageDebug.test.ts index aa6c8ce..21d326e 100644 --- a/test/gridsetImageDebug.test.ts +++ b/test/gridsetImageDebug.test.ts @@ -1,22 +1,25 @@ -import AdmZip from 'adm-zip'; -import { auditGridsetImages, formatImageAuditSummary } from '../src/processors/gridset/imageDebug'; +import AdmZip from "adm-zip"; +import { + auditGridsetImages, + formatImageAuditSummary, +} from "../src/processors/gridset/imageDebug"; -describe('Image Debugging Utilities', () => { +describe("Image Debugging Utilities", () => { function createMinimalGridset( options: { includeImages?: boolean; includeBrokenImages?: boolean; includeSymbolLibrary?: boolean; - } = {} + } = {}, ): Buffer { const zip = new AdmZip(); // Add Settings zip.addFile( - 'Settings0/settings.xml', + "Settings0/settings.xml", Buffer.from( - '' - ) + '', + ), ); // Create a simple grid with images @@ -65,24 +68,24 @@ describe('Image Debugging Utilities', () => { `; - zip.addFile('Grids/Test Grid/grid.xml', Buffer.from(gridXml)); + zip.addFile("Grids/Test Grid/grid.xml", Buffer.from(gridXml)); // Add image files if (options.includeImages) { - zip.addFile('Grids/Test Grid/test-image.png', Buffer.from('PNG-DATA')); - zip.addFile('Grids/Test Grid/another-image.png', Buffer.from('PNG-DATA')); + zip.addFile("Grids/Test Grid/test-image.png", Buffer.from("PNG-DATA")); + zip.addFile("Grids/Test Grid/another-image.png", Buffer.from("PNG-DATA")); } if (options.includeBrokenImages) { // Add image with coordinate prefix - zip.addFile('Grids/Test Grid/1-1-0-text-0.png', Buffer.from('PNG-DATA')); + zip.addFile("Grids/Test Grid/1-1-0-text-0.png", Buffer.from("PNG-DATA")); } return zip.toBuffer(); } - describe('auditGridsetImages', () => { - it('should audit gridset with all images resolved', async () => { + describe("auditGridsetImages", () => { + it("should audit gridset with all images resolved", async () => { const buffer = createMinimalGridset({ includeImages: true }); const audit = await auditGridsetImages(buffer); @@ -94,7 +97,7 @@ describe('Image Debugging Utilities', () => { expect(audit.availableImages.length).toBeGreaterThan(0); }); - it('should detect broken image references', async () => { + it("should detect broken image references", async () => { const buffer = createMinimalGridset({ includeBrokenImages: true }); const audit = await auditGridsetImages(buffer); @@ -102,60 +105,64 @@ describe('Image Debugging Utilities', () => { expect(audit.issues.length).toBeGreaterThan(0); const issue = audit.issues[0]; - expect(issue.issue).toBe('not_found'); - expect(issue.gridName).toBe('Test Grid'); + expect(issue.issue).toBe("not_found"); + expect(issue.gridName).toBe("Test Grid"); expect(issue.cellX).toBe(1); expect(issue.cellY).toBe(1); }); - it('should identify symbol library references', async () => { + it("should identify symbol library references", async () => { const buffer = createMinimalGridset({ includeSymbolLibrary: true }); const audit = await auditGridsetImages(buffer); expect(audit.unresolvedImages).toBeGreaterThan(0); - const issue = audit.issues.find((i: { issue: string }) => i.issue === 'symbol_library'); + const issue = audit.issues.find( + (i: { issue: string }) => i.issue === "symbol_library", + ); expect(issue).toBeDefined(); - expect(issue?.declaredImage).toContain('widgit'); - expect(issue?.suggestion).toContain('symbol library'); + expect(issue?.declaredImage).toContain("widgit"); + expect(issue?.suggestion).toContain("symbol library"); }); - it('should provide available images list', async () => { + it("should provide available images list", async () => { const buffer = createMinimalGridset({ includeImages: true }); const audit = await auditGridsetImages(buffer); - expect(audit.availableImages).toContain('Grids/Test Grid/test-image.png'); - expect(audit.availableImages).toContain('Grids/Test Grid/another-image.png'); + expect(audit.availableImages).toContain("Grids/Test Grid/test-image.png"); + expect(audit.availableImages).toContain( + "Grids/Test Grid/another-image.png", + ); }); }); - describe('formatImageAuditSummary', () => { - it('should format audit results as readable text', async () => { + describe("formatImageAuditSummary", () => { + it("should format audit results as readable text", async () => { const buffer = createMinimalGridset({ includeImages: true }); const audit = await auditGridsetImages(buffer); const summary = formatImageAuditSummary(audit); - expect(summary).toContain('Grid3 Image Audit Summary'); - expect(summary).toContain('Total cells: 2'); - expect(summary).toContain('Resolved images: 2'); - expect(summary).toContain('Unresolved images: 0'); + expect(summary).toContain("Grid3 Image Audit Summary"); + expect(summary).toContain("Total cells: 2"); + expect(summary).toContain("Resolved images: 2"); + expect(summary).toContain("Unresolved images: 0"); }); - it('should include issue details when problems exist', async () => { + it("should include issue details when problems exist", async () => { const buffer = createMinimalGridset({ includeBrokenImages: true }); const audit = await auditGridsetImages(buffer); const summary = formatImageAuditSummary(audit); - expect(summary).toContain('Image Issues'); - expect(summary).toContain('NOT_FOUND'); + expect(summary).toContain("Image Issues"); + expect(summary).toContain("NOT_FOUND"); }); - it('should group issues by type', async () => { + it("should group issues by type", async () => { const buffer = createMinimalGridset({ includeSymbolLibrary: true }); const audit = await auditGridsetImages(buffer); const summary = formatImageAuditSummary(audit); - expect(summary).toContain('SYMBOL_LIBRARY'); + expect(summary).toContain("SYMBOL_LIBRARY"); }); }); }); diff --git a/test/gridsetPluginTypes.test.ts b/test/gridsetPluginTypes.test.ts index b8937fc..dd447f5 100644 --- a/test/gridsetPluginTypes.test.ts +++ b/test/gridsetPluginTypes.test.ts @@ -8,47 +8,47 @@ import { isLiveCell, isAutoContentCell, isRegularCell, -} from '../src/processors/gridset/pluginTypes'; +} from "../src/processors/gridset/pluginTypes"; -describe('Grid 3 Plugin Type Detection', () => { - describe('Workspace Detection', () => { - it('should detect Workspace cell from ContentType', async () => { +describe("Grid 3 Plugin Type Detection", () => { + describe("Workspace Detection", () => { + it("should detect Workspace cell from ContentType", async () => { const content = { - ContentType: 'Workspace', - ContentSubType: 'Chat', + ContentType: "Workspace", + ContentSubType: "Chat", }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.Workspace); - expect(metadata.subType).toBe('Chat'); - expect(metadata.pluginId).toBe('Grid3.Chat'); + expect(metadata.subType).toBe("Chat"); + expect(metadata.pluginId).toBe("Grid3.Chat"); }); - it('should detect Workspace cell from Style', async () => { + it("should detect Workspace cell from Style", async () => { const content = { Style: { - BasedOnStyle: 'Workspace', + BasedOnStyle: "Workspace", }, - ContentSubType: 'Email', + ContentSubType: "Email", }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.Workspace); - expect(metadata.subType).toBe('Email'); + expect(metadata.subType).toBe("Email"); }); - it('should infer correct plugin IDs for various workspaces', async () => { + it("should infer correct plugin IDs for various workspaces", async () => { const workspaces = [ - { sub: WORKSPACE_TYPES.EMAIL, expected: 'Grid3.Email' }, + { sub: WORKSPACE_TYPES.EMAIL, expected: "Grid3.Email" }, { sub: WORKSPACE_TYPES.WORD_PROCESSOR, - expected: 'Grid3.WordProcessor', + expected: "Grid3.WordProcessor", }, - { sub: WORKSPACE_TYPES.WEB_BROWSER, expected: 'Grid3.WebBrowser' }, - { sub: WORKSPACE_TYPES.SETTINGS, expected: 'Grid3.Settings' }, + { sub: WORKSPACE_TYPES.WEB_BROWSER, expected: "Grid3.WebBrowser" }, + { sub: WORKSPACE_TYPES.SETTINGS, expected: "Grid3.Settings" }, ]; workspaces.forEach(({ sub, expected }) => { const metadata = detectPluginCellType({ - ContentType: 'Workspace', + ContentType: "Workspace", ContentSubType: sub, }); expect(metadata.pluginId).toBe(expected); @@ -56,71 +56,71 @@ describe('Grid 3 Plugin Type Detection', () => { }); }); - describe('LiveCell Detection', () => { - it('should detect LiveCell from ContentType', async () => { + describe("LiveCell Detection", () => { + it("should detect LiveCell from ContentType", async () => { const content = { - ContentType: 'LiveCell', - ContentSubType: 'DigitalClock', + ContentType: "LiveCell", + ContentSubType: "DigitalClock", }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.LiveCell); - expect(metadata.liveCellType).toBe('DigitalClock'); - expect(metadata.pluginId).toBe('Grid3.Clock'); + expect(metadata.liveCellType).toBe("DigitalClock"); + expect(metadata.pluginId).toBe("Grid3.Clock"); }); - it('should infer correct plugin IDs for live cells', async () => { + it("should infer correct plugin IDs for live cells", async () => { expect( detectPluginCellType({ - ContentType: 'LiveCell', + ContentType: "LiveCell", ContentSubType: LIVECELL_TYPES.BATTERY, - }).pluginId - ).toBe('Grid3.Battery'); + }).pluginId, + ).toBe("Grid3.Battery"); expect( detectPluginCellType({ - ContentType: 'LiveCell', + ContentType: "LiveCell", ContentSubType: LIVECELL_TYPES.WIFI_STRENGTH, - }).pluginId - ).toBe('Grid3.Wifi'); + }).pluginId, + ).toBe("Grid3.Wifi"); }); }); - describe('AutoContent Detection', () => { - it('should detect AutoContent from ContentType', async () => { + describe("AutoContent Detection", () => { + it("should detect AutoContent from ContentType", async () => { const content = { - ContentType: 'AutoContent', + ContentType: "AutoContent", Commands: { Command: [ { - '@_ID': 'AutoContent.Activate', - Parameter: { '@_Key': 'autocontenttype', '#text': 'Prediction' }, + "@_ID": "AutoContent.Activate", + Parameter: { "@_Key": "autocontenttype", "#text": "Prediction" }, }, ], }, }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.AutoContent); - expect(metadata.autoContentType).toBe('Prediction'); - expect(metadata.pluginId).toBe('Grid3.Prediction'); + expect(metadata.autoContentType).toBe("Prediction"); + expect(metadata.pluginId).toBe("Grid3.Prediction"); }); - it('should detect AutoContent from Style', async () => { + it("should detect AutoContent from Style", async () => { const content = { - Style: { BasedOnStyle: 'AutoContent' }, + Style: { BasedOnStyle: "AutoContent" }, Commands: { Command: { - '@_ID': 'AutoContent.Activate', - Parameter: { '@_Key': 'autocontenttype', '#text': 'Grammar' }, + "@_ID": "AutoContent.Activate", + Parameter: { "@_Key": "autocontenttype", "#text": "Grammar" }, }, }, }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.AutoContent); - expect(metadata.autoContentType).toBe('Grammar'); + expect(metadata.autoContentType).toBe("Grammar"); }); - it('should return undefined pluginId for unknown types', async () => { + it("should return undefined pluginId for unknown types", async () => { const metadata = detectPluginCellType({ - ContentType: 'AutoContent', + ContentType: "AutoContent", Commands: {}, }); expect(metadata.cellType).toBe(Grid3CellType.AutoContent); @@ -128,23 +128,25 @@ describe('Grid 3 Plugin Type Detection', () => { }); }); - describe('Regular Cell Detection', () => { - it('should detect regular cells', async () => { - const content = { Label: 'Hello' }; + describe("Regular Cell Detection", () => { + it("should detect regular cells", async () => { + const content = { Label: "Hello" }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.Regular); }); }); - describe('Utility Functions', () => { - it('getCellTypeDisplayName should return correct names', async () => { - expect(getCellTypeDisplayName(Grid3CellType.Workspace)).toBe('Workspace'); - expect(getCellTypeDisplayName(Grid3CellType.LiveCell)).toBe('Live Cell'); - expect(getCellTypeDisplayName(Grid3CellType.AutoContent)).toBe('Auto Content'); - expect(getCellTypeDisplayName(Grid3CellType.Regular)).toBe('Regular'); + describe("Utility Functions", () => { + it("getCellTypeDisplayName should return correct names", async () => { + expect(getCellTypeDisplayName(Grid3CellType.Workspace)).toBe("Workspace"); + expect(getCellTypeDisplayName(Grid3CellType.LiveCell)).toBe("Live Cell"); + expect(getCellTypeDisplayName(Grid3CellType.AutoContent)).toBe( + "Auto Content", + ); + expect(getCellTypeDisplayName(Grid3CellType.Regular)).toBe("Regular"); }); - it('type checking functions should work', async () => { + it("type checking functions should work", async () => { const workspace = { cellType: Grid3CellType.Workspace }; const live = { cellType: Grid3CellType.LiveCell }; const auto = { cellType: Grid3CellType.AutoContent }; diff --git a/test/gridsetProcessor.coverage.test.ts b/test/gridsetProcessor.coverage.test.ts index df7d5ae..d1aa042 100644 --- a/test/gridsetProcessor.coverage.test.ts +++ b/test/gridsetProcessor.coverage.test.ts @@ -1,35 +1,35 @@ -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; -import AdmZip from 'adm-zip'; -import { XMLBuilder } from 'fast-xml-parser'; +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import AdmZip from "adm-zip"; +import { XMLBuilder } from "fast-xml-parser"; -describe('GridsetProcessor Coverage Tests', () => { - describe('Metadata Extraction', () => { - it('should extract metadata from settings.xml', async () => { +describe("GridsetProcessor Coverage Tests", () => { + describe("Metadata Extraction", () => { + it("should extract metadata from settings.xml", async () => { const zip = new AdmZip(); // Create settings.xml with full metadata const settingsData = { - '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, + "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, GridSetSettings: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - Name: 'Test Gridset', - Description: 'Test Description', - Author: 'Test Author', - PrimaryLanguage: 'en-US', - StartGrid: 'home', - KeyboardGrid: 'keyboard', - DocumentationUrl: 'https://example.com/docs', - DocumentationSlug: 'test-gridset', - Thumbnail: '[grid3x]thumbnail.wmf', - ThumbnailBackground: '#FF0000FF', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + Name: "Test Gridset", + Description: "Test Description", + Author: "Test Author", + PrimaryLanguage: "en-US", + StartGrid: "home", + KeyboardGrid: "keyboard", + DocumentationUrl: "https://example.com/docs", + DocumentationSlug: "test-gridset", + Thumbnail: "[grid3x]thumbnail.wmf", + ThumbnailBackground: "#FF0000FF", PictureSearch: { PictureSearchKeys: { - PictureSearchKey: ['widgit', 'sstix'], + PictureSearchKey: ["widgit", "sstix"], }, }, Appearance: { - TextAtTop: '1', - ComputerControlCellSize: '0.4', + TextAtTop: "1", + ComputerControlCellSize: "0.4", }, }, }; @@ -40,24 +40,24 @@ describe('GridsetProcessor Coverage Tests', () => { suppressEmptyNode: true, }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); + zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); // Create a minimal grid const gridData = { Grid: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - GridGuid: 'home-guid', - Name: 'home', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + GridGuid: "home-guid", + Name: "home", ColumnDefinitions: { ColumnDefinition: [{}, {}, {}] }, RowDefinitions: { RowDefinition: [{}, {}] }, Cells: { Cell: [ { - '@_X': 1, - '@_Y': 1, + "@_X": 1, + "@_Y": 1, Content: { CaptionAndImage: { - Caption: 'Test', + Caption: "Test", }, }, }, @@ -71,7 +71,7 @@ describe('GridsetProcessor Coverage Tests', () => { format: true, }); const gridXml = gridBuilder.build(gridData); - zip.addFile('Grids\\home\\grid.xml', Buffer.from(gridXml, 'utf8')); + zip.addFile("Grids\\home\\grid.xml", Buffer.from(gridXml, "utf8")); const buffer = zip.toBuffer(); @@ -79,41 +79,41 @@ describe('GridsetProcessor Coverage Tests', () => { const tree = await processor.loadIntoTree(buffer); expect(tree.metadata).toBeDefined(); - expect(tree.metadata.format).toBe('gridset'); - expect(tree.metadata.name).toBe('Test Gridset'); - expect(tree.metadata.description).toBe('Test Description'); - expect(tree.metadata.author).toBe('Test Author'); - expect(tree.metadata.locale).toBe('en-US'); - expect(tree.metadata.homepageUrl).toBe('https://example.com/docs'); - expect(tree.metadata.documentationUrl).toBe('https://example.com/docs'); - expect(tree.metadata.documentationSlug).toBe('test-gridset'); - expect(tree.metadata.pictureSearchKeys).toEqual(['widgit', 'sstix']); + expect(tree.metadata.format).toBe("gridset"); + expect(tree.metadata.name).toBe("Test Gridset"); + expect(tree.metadata.description).toBe("Test Description"); + expect(tree.metadata.author).toBe("Test Author"); + expect(tree.metadata.locale).toBe("en-US"); + expect(tree.metadata.homepageUrl).toBe("https://example.com/docs"); + expect(tree.metadata.documentationUrl).toBe("https://example.com/docs"); + expect(tree.metadata.documentationSlug).toBe("test-gridset"); + expect(tree.metadata.pictureSearchKeys).toEqual(["widgit", "sstix"]); expect(tree.metadata.appearance).toBeDefined(); expect(tree.metadata.appearance?.textAtTop).toBe(true); expect(tree.metadata.appearance?.computerControlCellSize).toBe(0.4); - expect(tree.metadata.thumbnail).toBe('[grid3x]thumbnail.wmf'); - expect(tree.metadata.thumbnailBackground).toBe('#FF0000FF'); + expect(tree.metadata.thumbnail).toBe("[grid3x]thumbnail.wmf"); + expect(tree.metadata.thumbnailBackground).toBe("#FF0000FF"); }); - it('should handle missing optional metadata fields', async () => { + it("should handle missing optional metadata fields", async () => { const zip = new AdmZip(); // Minimal settings.xml const settingsData = { GridSetSettings: { - Name: 'Minimal Gridset', + Name: "Minimal Gridset", }, }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); + zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); // Create a minimal grid const gridData = { Grid: { - GridGuid: 'grid-guid', - Name: 'grid1', + GridGuid: "grid-guid", + Name: "grid1", ColumnDefinitions: { ColumnDefinition: [{}, {}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [] }, @@ -122,7 +122,7 @@ describe('GridsetProcessor Coverage Tests', () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile('Grids\\grid1\\grid.xml', Buffer.from(gridXml, 'utf8')); + zip.addFile("Grids\\grid1\\grid.xml", Buffer.from(gridXml, "utf8")); const buffer = zip.toBuffer(); @@ -130,54 +130,54 @@ describe('GridsetProcessor Coverage Tests', () => { const tree = await processor.loadIntoTree(buffer); expect(tree.metadata).toBeDefined(); - expect(tree.metadata.format).toBe('gridset'); - expect(tree.metadata.name).toBe('Minimal Gridset'); + expect(tree.metadata.format).toBe("gridset"); + expect(tree.metadata.name).toBe("Minimal Gridset"); // Optional fields should be undefined expect(tree.metadata.description).toBeUndefined(); expect(tree.metadata.author).toBeUndefined(); }); }); - describe('Grid Cell Parsing', () => { - it('should parse cell with all attributes', async () => { + describe("Grid Cell Parsing", () => { + it("should parse cell with all attributes", async () => { const zip = new AdmZip(); const gridData = { Grid: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - GridGuid: 'test-guid', - Name: 'test', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + GridGuid: "test-guid", + Name: "test", ColumnDefinitions: { ColumnDefinition: [{}, {}, {}] }, RowDefinitions: { RowDefinition: [{}, {}] }, Cells: { Cell: [ { - '@_X': 1, - '@_Y': 1, - '@_ColumnSpan': 2, - '@_RowSpan': 2, - '@_ScanBlock': 3, - '@_StyleID': 'style1', - '@_BackColour': '#FF0000FF', - '@_FontColour': '#000000FF', - Visibility: 'Visible', + "@_X": 1, + "@_Y": 1, + "@_ColumnSpan": 2, + "@_RowSpan": 2, + "@_ScanBlock": 3, + "@_StyleID": "style1", + "@_BackColour": "#FF0000FF", + "@_FontColour": "#000000FF", + Visibility: "Visible", Content: { CaptionAndImage: { - Caption: 'Test Button', - Image: 'test.png', + Caption: "Test Button", + Image: "test.png", }, - ContentType: 'Normal', + ContentType: "Normal", Style: { - BasedOnStyle: 'style1', - FontName: 'Arial', - FontSize: '16', + BasedOnStyle: "style1", + FontName: "Arial", + FontSize: "16", }, Commands: { Command: { - '@_ID': 'Action.InsertText', + "@_ID": "Action.InsertText", Parameter: { - '@_Key': 'text', - '#text': 'Hello', + "@_Key": "text", + "#text": "Hello", }, }, }, @@ -190,13 +190,13 @@ describe('GridsetProcessor Coverage Tests', () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile('Grids\\test\\grid.xml', Buffer.from(gridXml, 'utf8')); + zip.addFile("Grids\\test\\grid.xml", Buffer.from(gridXml, "utf8")); // Add minimal settings - const settingsData = { GridSetSettings: { Name: 'Test' } }; + const settingsData = { GridSetSettings: { Name: "Test" } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); + zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); const buffer = zip.toBuffer(); @@ -211,48 +211,48 @@ describe('GridsetProcessor Coverage Tests', () => { const button = page.buttons[0]; expect(button).toBeDefined(); - expect(button.label).toBe('Test Button'); + expect(button.label).toBe("Test Button"); expect(button.x).toBe(1); // Grid 3 XML coordinates are already 0-based expect(button.y).toBe(1); expect(button.columnSpan).toBe(2); expect(button.rowSpan).toBe(2); expect(button.scanBlock).toBe(3); - expect(button.visibility).toBe('Visible'); - expect(button.style?.backgroundColor).toBe('#FF0000FF'); - expect(button.style?.fontColor).toBe('#000000FF'); - expect(button.style?.fontFamily).toBe('Arial'); + expect(button.visibility).toBe("Visible"); + expect(button.style?.backgroundColor).toBe("#FF0000FF"); + expect(button.style?.fontColor).toBe("#000000FF"); + expect(button.style?.fontFamily).toBe("Arial"); expect(button.style?.fontSize).toBe(16); - expect(button.image).toBe('test.png'); + expect(button.image).toBe("test.png"); }); - it('should parse cell with prediction wordlist', async () => { + it("should parse cell with prediction wordlist", async () => { const zip = new AdmZip(); const gridData = { Grid: { - GridGuid: 'test-guid', - Name: 'test', + GridGuid: "test-guid", + Name: "test", ColumnDefinitions: { ColumnDefinition: [{}, {}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [ { - '@_X': 1, - '@_Y': 1, + "@_X": 1, + "@_Y": 1, Content: { - CaptionAndImage: { Caption: 'Predict' }, + CaptionAndImage: { Caption: "Predict" }, Commands: { Command: { - '@_ID': 'Prediction.PredictThis', + "@_ID": "Prediction.PredictThis", Parameter: [ { - '@_Key': 'wordlist', + "@_Key": "wordlist", WordList: { Items: { WordListItem: [ - { Text: 'word1' }, - { Text: 'word2' }, - { Text: 'word3' }, + { Text: "word1" }, + { Text: "word2" }, + { Text: "word3" }, ], }, }, @@ -269,12 +269,12 @@ describe('GridsetProcessor Coverage Tests', () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile('Grids\\test\\grid.xml', Buffer.from(gridXml, 'utf8')); + zip.addFile("Grids\\test\\grid.xml", Buffer.from(gridXml, "utf8")); - const settingsData = { GridSetSettings: { Name: 'Test' } }; + const settingsData = { GridSetSettings: { Name: "Test" } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); + zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); const buffer = zip.toBuffer(); @@ -286,37 +286,35 @@ describe('GridsetProcessor Coverage Tests', () => { // Should have 1 button plus virtual buttons for predicted words expect(page.buttons.length).toBeGreaterThanOrEqual(1); const button = page.buttons[0]; - expect(button.label).toBe('Predict'); + expect(button.label).toBe("Predict"); expect(button.semanticAction).toBeDefined(); - expect(button.semanticAction?.platformData?.grid3?.parameters?.wordlist).toEqual([ - 'word1', - 'word2', - 'word3', - ]); + expect( + button.semanticAction?.platformData?.grid3?.parameters?.wordlist, + ).toEqual(["word1", "word2", "word3"]); }); - it('should parse navigation commands', async () => { + it("should parse navigation commands", async () => { const zip = new AdmZip(); const gridData = { Grid: { - GridGuid: 'home-guid', - Name: 'home', + GridGuid: "home-guid", + Name: "home", ColumnDefinitions: { ColumnDefinition: [{}, {}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [ { - '@_X': 1, - '@_Y': 1, + "@_X": 1, + "@_Y": 1, Content: { - CaptionAndImage: { Caption: 'Go to page' }, + CaptionAndImage: { Caption: "Go to page" }, Commands: { Command: { - '@_ID': 'Jump.To', + "@_ID": "Jump.To", Parameter: { - '@_Key': 'grid', - '#text': 'other', + "@_Key": "grid", + "#text": "other", }, }, }, @@ -329,80 +327,80 @@ describe('GridsetProcessor Coverage Tests', () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile('Grids\\home\\grid.xml', Buffer.from(gridXml, 'utf8')); + zip.addFile("Grids\\home\\grid.xml", Buffer.from(gridXml, "utf8")); // Add target page const targetGridData = { Grid: { - GridGuid: 'other-guid', - Name: 'other', + GridGuid: "other-guid", + Name: "other", ColumnDefinitions: { ColumnDefinition: [{}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [] }, }, }; const targetGridXml = gridBuilder.build(targetGridData); - zip.addFile('Grids\\other\\grid.xml', Buffer.from(targetGridXml, 'utf8')); + zip.addFile("Grids\\other\\grid.xml", Buffer.from(targetGridXml, "utf8")); - const settingsData = { GridSetSettings: { Name: 'Test' } }; + const settingsData = { GridSetSettings: { Name: "Test" } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); + zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); const buffer = zip.toBuffer(); const processor = new GridsetProcessor(); const tree = await processor.loadIntoTree(buffer); - const homePage = tree.pages['home-guid']; + const homePage = tree.pages["home-guid"]; expect(homePage).toBeDefined(); const navButton = homePage.buttons[0]; - expect(navButton.targetPageId).toBe('other-guid'); + expect(navButton.targetPageId).toBe("other-guid"); - const otherPage = tree.pages['other-guid']; - expect(otherPage.parentId).toBe('home-guid'); + const otherPage = tree.pages["other-guid"]; + expect(otherPage.parentId).toBe("home-guid"); }); - it('should handle different visibility values', async () => { + it("should handle different visibility values", async () => { const zip = new AdmZip(); const gridData = { Grid: { - GridGuid: 'test-guid', - Name: 'test', + GridGuid: "test-guid", + Name: "test", ColumnDefinitions: { ColumnDefinition: [{}, {}, {}, {}, {}] }, RowDefinitions: { RowDefinition: [{}, {}, {}, {}, {}] }, Cells: { Cell: [ { - '@_X': 1, - '@_Y': 1, - Visibility: 'Hidden', - Content: { CaptionAndImage: { Caption: 'Hidden' } }, + "@_X": 1, + "@_Y": 1, + Visibility: "Hidden", + Content: { CaptionAndImage: { Caption: "Hidden" } }, }, { - '@_X': 2, - '@_Y': 1, - Visibility: 'Disabled', - Content: { CaptionAndImage: { Caption: 'Disabled' } }, + "@_X": 2, + "@_Y": 1, + Visibility: "Disabled", + Content: { CaptionAndImage: { Caption: "Disabled" } }, }, { - '@_X': 3, - '@_Y': 1, - Visibility: 'PointerAndTouchOnly', - Content: { CaptionAndImage: { Caption: 'TouchOnly' } }, + "@_X": 3, + "@_Y": 1, + Visibility: "PointerAndTouchOnly", + Content: { CaptionAndImage: { Caption: "TouchOnly" } }, }, { - '@_X': 4, - '@_Y': 1, - Visibility: 'TouchOnly', - Content: { CaptionAndImage: { Caption: 'RealTouchOnly' } }, + "@_X": 4, + "@_Y": 1, + Visibility: "TouchOnly", + Content: { CaptionAndImage: { Caption: "RealTouchOnly" } }, }, { - '@_X': 5, - '@_Y': 1, - Visibility: 'PointerOnly', - Content: { CaptionAndImage: { Caption: 'PointerOnly' } }, + "@_X": 5, + "@_Y": 1, + Visibility: "PointerOnly", + Content: { CaptionAndImage: { Caption: "PointerOnly" } }, }, ], }, @@ -411,12 +409,12 @@ describe('GridsetProcessor Coverage Tests', () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile('Grids\\test\\grid.xml', Buffer.from(gridXml, 'utf8')); + zip.addFile("Grids\\test\\grid.xml", Buffer.from(gridXml, "utf8")); - const settingsData = { GridSetSettings: { Name: 'Test' } }; + const settingsData = { GridSetSettings: { Name: "Test" } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); + zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); const buffer = zip.toBuffer(); @@ -426,28 +424,31 @@ describe('GridsetProcessor Coverage Tests', () => { const page = Object.values(tree.pages)[0]; expect(page.buttons.length).toBe(5); - expect(page.buttons[0].visibility).toBe('Hidden'); - expect(page.buttons[1].visibility).toBe('Disabled'); - expect(page.buttons[2].visibility).toBe('PointerAndTouchOnly'); - expect(page.buttons[3].visibility).toBe('PointerAndTouchOnly'); // TouchOnly maps to this - expect(page.buttons[4].visibility).toBe('PointerAndTouchOnly'); // PointerOnly maps to this + expect(page.buttons[0].visibility).toBe("Hidden"); + expect(page.buttons[1].visibility).toBe("Disabled"); + expect(page.buttons[2].visibility).toBe("PointerAndTouchOnly"); + expect(page.buttons[3].visibility).toBe("PointerAndTouchOnly"); // TouchOnly maps to this + expect(page.buttons[4].visibility).toBe("PointerAndTouchOnly"); // PointerOnly maps to this }); }); - describe('FileMap Support', () => { - it('should parse FileMap.xml', async () => { + describe("FileMap Support", () => { + it("should parse FileMap.xml", async () => { const zip = new AdmZip(); const fileMapData = { - '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, + "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, FileMap: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", Entries: { Entry: [ { - '@_StaticFile': 'Grids\\page1\\grid.xml', + "@_StaticFile": "Grids\\page1\\grid.xml", DynamicFiles: { - File: ['Grids\\page1\\1-1-0-text-0.png', 'Grids\\page1\\1-2-0-text-0.png'], + File: [ + "Grids\\page1\\1-1-0-text-0.png", + "Grids\\page1\\1-2-0-text-0.png", + ], }, }, ], @@ -457,13 +458,13 @@ describe('GridsetProcessor Coverage Tests', () => { const fileMapBuilder = new XMLBuilder({ ignoreAttributes: false }); const fileMapXml = fileMapBuilder.build(fileMapData); - zip.addFile('FileMap.xml', Buffer.from(fileMapXml, 'utf8')); + zip.addFile("FileMap.xml", Buffer.from(fileMapXml, "utf8")); // Add minimal grid const gridData = { Grid: { - GridGuid: 'page1-guid', - Name: 'page1', + GridGuid: "page1-guid", + Name: "page1", ColumnDefinitions: { ColumnDefinition: [{}, {}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [] }, @@ -472,12 +473,12 @@ describe('GridsetProcessor Coverage Tests', () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile('Grids\\page1\\grid.xml', Buffer.from(gridXml, 'utf8')); + zip.addFile("Grids\\page1\\grid.xml", Buffer.from(gridXml, "utf8")); - const settingsData = { GridSetSettings: { Name: 'Test' } }; + const settingsData = { GridSetSettings: { Name: "Test" } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); + zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); const buffer = zip.toBuffer(); @@ -490,28 +491,28 @@ describe('GridsetProcessor Coverage Tests', () => { }); }); - describe('Styles Support', () => { - it('should parse styles.xml', async () => { + describe("Styles Support", () => { + it("should parse styles.xml", async () => { const zip = new AdmZip(); const stylesData = { - '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, + "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, StyleData: { - '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", Styles: { Style: [ { - '@_Key': 'style1', - BackColour: '#FF0000FF', - BorderColour: '#000000FF', - FontColour: '#FFFFFFFF', - FontName: 'Arial', - FontSize: '16', + "@_Key": "style1", + BackColour: "#FF0000FF", + BorderColour: "#000000FF", + FontColour: "#FFFFFFFF", + FontName: "Arial", + FontSize: "16", }, { - '@_Key': 'style2', - BackColour: '#00FF00FF', - FontColour: '#000000FF', + "@_Key": "style2", + BackColour: "#00FF00FF", + FontColour: "#000000FF", }, ], }, @@ -520,22 +521,25 @@ describe('GridsetProcessor Coverage Tests', () => { const stylesBuilder = new XMLBuilder({ ignoreAttributes: false }); const stylesXml = stylesBuilder.build(stylesData); - zip.addFile('Settings0/Styles/styles.xml', Buffer.from(stylesXml, 'utf8')); + zip.addFile( + "Settings0/Styles/styles.xml", + Buffer.from(stylesXml, "utf8"), + ); // Add grid with styled cell const gridData = { Grid: { - GridGuid: 'test-guid', - Name: 'test', + GridGuid: "test-guid", + Name: "test", ColumnDefinitions: { ColumnDefinition: [{}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [ { - '@_X': 1, - '@_Y': 1, - '@_StyleID': 'style1', - Content: { CaptionAndImage: { Caption: 'Styled' } }, + "@_X": 1, + "@_Y": 1, + "@_StyleID": "style1", + Content: { CaptionAndImage: { Caption: "Styled" } }, }, ], }, @@ -544,12 +548,12 @@ describe('GridsetProcessor Coverage Tests', () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile('Grids\\test\\grid.xml', Buffer.from(gridXml, 'utf8')); + zip.addFile("Grids\\test\\grid.xml", Buffer.from(gridXml, "utf8")); - const settingsData = { GridSetSettings: { Name: 'Test' } }; + const settingsData = { GridSetSettings: { Name: "Test" } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); + zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); const buffer = zip.toBuffer(); @@ -558,10 +562,10 @@ describe('GridsetProcessor Coverage Tests', () => { const page = Object.values(tree.pages)[0]; const button = page.buttons[0]; - expect(button.style?.backgroundColor).toBe('#FF0000FF'); - expect(button.style?.borderColor).toBe('#000000FF'); - expect(button.style?.fontColor).toBe('#FFFFFFFF'); - expect(button.style?.fontFamily).toBe('Arial'); + expect(button.style?.backgroundColor).toBe("#FF0000FF"); + expect(button.style?.borderColor).toBe("#000000FF"); + expect(button.style?.fontColor).toBe("#FFFFFFFF"); + expect(button.style?.fontFamily).toBe("Arial"); expect(button.style?.fontSize).toBe(16); }); }); diff --git a/test/gridsetProcessor.roundtrip.test.legacy.ts b/test/gridsetProcessor.roundtrip.test.legacy.ts index 83fbde7..3d47ae9 100644 --- a/test/gridsetProcessor.roundtrip.test.legacy.ts +++ b/test/gridsetProcessor.roundtrip.test.legacy.ts @@ -1,21 +1,23 @@ -import fs from 'fs'; -import path from 'path'; -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import fs from "fs"; +import path from "path"; +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; // import { AACTree } from '../src/core/treeStructure'; // Unused import -describe('GridsetProcessor round-trip', () => { - const gsPath = path.join(__dirname, 'assets/gridset/example.gridset.json'); - const outPath = path.join(__dirname, 'out.gridset.json'); +describe("GridsetProcessor round-trip", () => { + const gsPath = path.join(__dirname, "assets/gridset/example.gridset.json"); + const outPath = path.join(__dirname, "out.gridset.json"); afterAll(() => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it('round-trips Gridset JSON without losing pages or navigation', async () => { + it("round-trips Gridset JSON without losing pages or navigation", async () => { if (!fs.existsSync(gsPath)) return; const processor = new GridsetProcessor(); const tree1 = await processor.loadIntoTree(gsPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); - expect(Object.keys(tree1.pages).sort()).toEqual(Object.keys(tree2.pages).sort()); + expect(Object.keys(tree1.pages).sort()).toEqual( + Object.keys(tree2.pages).sort(), + ); for (const pid in tree1.pages) { expect(tree2.pages).toHaveProperty(pid); const btnLabels1 = tree1.pages[pid].buttons.map((b) => b.label).sort(); diff --git a/test/gridsetProcessor.roundtrip.test.ts b/test/gridsetProcessor.roundtrip.test.ts index 7c6f36b..07836b6 100644 --- a/test/gridsetProcessor.roundtrip.test.ts +++ b/test/gridsetProcessor.roundtrip.test.ts @@ -1,20 +1,23 @@ // Round-trip test for GridsetProcessor: load, save, reload, and compare structure -import fs from 'fs'; -import path from 'path'; -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; - -describe('GridsetProcessor round-trip', () => { - const exampleFile: string = path.join(__dirname, 'assets/gridset/example.gridset'); - const outPath: string = path.join(__dirname, 'out.gridset'); +import fs from "fs"; +import path from "path"; +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; + +describe("GridsetProcessor round-trip", () => { + const exampleFile: string = path.join( + __dirname, + "assets/gridset/example.gridset", + ); + const outPath: string = path.join(__dirname, "out.gridset"); afterAll(async () => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it('round-trips gridset files without losing structure', async () => { + it("round-trips gridset files without losing structure", async () => { if (!fs.existsSync(exampleFile)) { - console.log('Skipping gridset round-trip test - example file not found'); + console.log("Skipping gridset round-trip test - example file not found"); return; } @@ -30,11 +33,15 @@ describe('GridsetProcessor round-trip', () => { // Compare basic structure expect(Object.keys(tree2.pages).length).toBeGreaterThan(0); - expect(Object.keys(tree1.pages).length).toBe(Object.keys(tree2.pages).length); + expect(Object.keys(tree1.pages).length).toBe( + Object.keys(tree2.pages).length, + ); // Compare metadata expect(tree2.metadata.name).toBe(tree1.metadata.name); - expect(tree2.metadata.description?.trim()).toBe(tree1.metadata.description?.trim()); + expect(tree2.metadata.description?.trim()).toBe( + tree1.metadata.description?.trim(), + ); if (tree1.metadata.locale) { expect(tree2.metadata.locale).toBe(tree1.metadata.locale); } @@ -62,31 +69,31 @@ describe('GridsetProcessor round-trip', () => { } }); - it('can save and load a constructed tree', async () => { + it("can save and load a constructed tree", async () => { const processor = new GridsetProcessor({ preserveAllButtons: true }); // Create a simple tree programmatically const tree1 = new AACTree(); const page = new AACPage({ - id: 'grid1', - name: 'Test Grid', + id: "grid1", + name: "Test Grid", buttons: [], }); const speakButton = new AACButton({ - id: 'cell1', - label: 'Hello', - message: 'Hello World', - type: 'SPEAK', + id: "cell1", + label: "Hello", + message: "Hello World", + type: "SPEAK", }); const navButton = new AACButton({ - id: 'cell2', - label: 'Next Grid', - message: 'Navigate', - type: 'NAVIGATE', - targetPageId: 'grid2', + id: "cell2", + label: "Next Grid", + message: "Navigate", + type: "NAVIGATE", + targetPageId: "grid2", }); page.addButton(speakButton); @@ -102,23 +109,23 @@ describe('GridsetProcessor round-trip', () => { // Verify structure expect(Object.keys(tree2.pages)).toHaveLength(1); - const reloadedPage = tree2.pages['grid1']; + const reloadedPage = tree2.pages["grid1"]; expect(reloadedPage).toBeDefined(); - expect(reloadedPage.name).toBe('Test Grid'); + expect(reloadedPage.name).toBe("Test Grid"); // We expect exactly 2 buttons - zero injection of mandatory workspace cells expect(reloadedPage.buttons).toHaveLength(2); // Check that we have buttons with the expected labels const buttonLabels = reloadedPage.buttons.map((b) => b.label).sort(); - expect(buttonLabels).toContain('Hello'); - expect(buttonLabels).toContain('Next Grid'); + expect(buttonLabels).toContain("Hello"); + expect(buttonLabels).toContain("Next Grid"); // Check that at least one button has the expected properties - const helloBtn = reloadedPage.buttons.find((b) => b.label === 'Hello'); + const helloBtn = reloadedPage.buttons.find((b) => b.label === "Hello"); expect(helloBtn).toBeDefined(); }); - it('handles empty tree gracefully', async () => { + it("handles empty tree gracefully", async () => { const processor = new GridsetProcessor(); const emptyTree = new AACTree(); diff --git a/test/gridsetProcessor.test.ts b/test/gridsetProcessor.test.ts index e7c3b50..c27b86b 100644 --- a/test/gridsetProcessor.test.ts +++ b/test/gridsetProcessor.test.ts @@ -1,13 +1,16 @@ // Unit tests for GridsetProcessor -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; -import { AACTree } from '../src/core/treeStructure'; -import path from 'path'; -import fs from 'fs'; - -describe('GridsetProcessor', () => { - const exampleFile: string = path.join(__dirname, 'assets/gridset/example.gridset'); - - it('should load a .gridset file into a tree', async () => { +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import { AACTree } from "../src/core/treeStructure"; +import path from "path"; +import fs from "fs"; + +describe("GridsetProcessor", () => { + const exampleFile: string = path.join( + __dirname, + "assets/gridset/example.gridset", + ); + + it("should load a .gridset file into a tree", async () => { const processor = new GridsetProcessor(); const fileBuffer = fs.readFileSync(exampleFile); const tree: AACTree = await processor.loadIntoTree(fileBuffer); @@ -15,7 +18,7 @@ describe('GridsetProcessor', () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it('should extract all texts from a .gridset file', async () => { + it("should extract all texts from a .gridset file", async () => { const processor = new GridsetProcessor(); const fileBuffer = fs.readFileSync(exampleFile); const texts: string[] = await processor.extractTexts(fileBuffer); @@ -23,28 +26,28 @@ describe('GridsetProcessor', () => { expect(texts.length).toBeGreaterThan(0); }); - describe('Error Handling', () => { - it('should throw error for non-existent file', async () => { + describe("Error Handling", () => { + it("should throw error for non-existent file", async () => { expect(() => { - fs.readFileSync('/non/existent/file.gridset'); + fs.readFileSync("/non/existent/file.gridset"); }).toThrow(); }); - it('should handle invalid zip content', async () => { + it("should handle invalid zip content", async () => { const processor = new GridsetProcessor(); - const invalidBuffer = Buffer.from('not a zip file'); + const invalidBuffer = Buffer.from("not a zip file"); await expect(processor.loadIntoTree(invalidBuffer)).rejects.toThrow(); }); - it('should handle empty buffer', async () => { + it("should handle empty buffer", async () => { const processor = new GridsetProcessor(); const emptyBuffer = Buffer.alloc(0); await expect(processor.loadIntoTree(emptyBuffer)).rejects.toThrow(); }); }); - describe('Home Page Preservation', () => { - const tempOutputPath = path.join(__dirname, 'temp_gridset_test.gridset'); + describe("Home Page Preservation", () => { + const tempOutputPath = path.join(__dirname, "temp_gridset_test.gridset"); afterEach(async () => { if (fs.existsSync(tempOutputPath)) { @@ -52,7 +55,7 @@ describe('GridsetProcessor', () => { } }); - it('should preserve home page (tree.rootId) through roundtrip', async () => { + it("should preserve home page (tree.rootId) through roundtrip", async () => { const processor = new GridsetProcessor(); // Load the original file @@ -84,9 +87,15 @@ describe('GridsetProcessor', () => { }); }); - describe('saveModifiedTree', () => { - const tempOutputPath = path.join(__dirname, 'temp_gridset_modified.gridset'); - const tempSaveFromTreePath = path.join(__dirname, 'temp_gridset_saveFromTree.gridset'); + describe("saveModifiedTree", () => { + const tempOutputPath = path.join( + __dirname, + "temp_gridset_modified.gridset", + ); + const tempSaveFromTreePath = path.join( + __dirname, + "temp_gridset_saveFromTree.gridset", + ); afterEach(async () => { if (fs.existsSync(tempOutputPath)) { @@ -97,7 +106,7 @@ describe('GridsetProcessor', () => { } }); - it('should preserve original file size better than saveFromTree', async () => { + it("should preserve original file size better than saveFromTree", async () => { const processor = new GridsetProcessor(); // Load the original file @@ -120,7 +129,7 @@ describe('GridsetProcessor', () => { expect(modifiedSize / originalSize).toBeGreaterThan(0.8); }); - it('should produce a valid loadable gridset', async () => { + it("should produce a valid loadable gridset", async () => { const processor = new GridsetProcessor(); // Load the original file @@ -140,7 +149,7 @@ describe('GridsetProcessor', () => { expect(savedTree.rootId).toBe(tree.rootId); }); - it('should handle empty tree by copying original', async () => { + it("should handle empty tree by copying original", async () => { const processor = new GridsetProcessor(); // Create an empty tree @@ -151,7 +160,7 @@ describe('GridsetProcessor', () => { dashboardId: null, metadata: {}, addPage() { - throw new Error('Not implemented'); + throw new Error("Not implemented"); }, getPage() { return undefined; diff --git a/test/gridsetResolver.test.ts b/test/gridsetResolver.test.ts index 70dd835..7096ff9 100644 --- a/test/gridsetResolver.test.ts +++ b/test/gridsetResolver.test.ts @@ -1,7 +1,7 @@ -import AdmZip from 'adm-zip'; -import { resolveGrid3CellImage } from '../src/processors/gridset/resolver'; +import AdmZip from "adm-zip"; +import { resolveGrid3CellImage } from "../src/processors/gridset/resolver"; -describe('resolveGrid3CellImage', () => { +describe("resolveGrid3CellImage", () => { function mkZip(entries: Record): AdmZip { const zip = new AdmZip(); for (const [name, data] of Object.entries(entries)) { @@ -10,99 +10,99 @@ describe('resolveGrid3CellImage', () => { return zip; } - it('resolves declared image in Images/ subfolder', async () => { + it("resolves declared image in Images/ subfolder", async () => { const zip = mkZip({ - 'Grids/Home/Images/dog.png': 'PNGDATA', + "Grids/Home/Images/dog.png": "PNGDATA", }); const p = resolveGrid3CellImage(zip, { - baseDir: 'Grids/Home/', - imageName: 'dog.png', + baseDir: "Grids/Home/", + imageName: "dog.png", }); - expect(p).toBe('Grids/Home/Images/dog.png'); + expect(p).toBe("Grids/Home/Images/dog.png"); }); - it('uses FileMap dynamic files with coordinate prefix', async () => { + it("uses FileMap dynamic files with coordinate prefix", async () => { const zip = mkZip({ - 'Grids/Home/1-5-0-text-0.jpeg': 'IMG', - 'Grids/Home/1-5.jpeg': 'ALT', + "Grids/Home/1-5-0-text-0.jpeg": "IMG", + "Grids/Home/1-5.jpeg": "ALT", }); const p = resolveGrid3CellImage(zip, { - baseDir: 'Grids/Home/', + baseDir: "Grids/Home/", x: 1, y: 5, - dynamicFiles: ['Grids/Home/1-5-0-text-0.jpeg'], + dynamicFiles: ["Grids/Home/1-5-0-text-0.jpeg"], }); - expect(p).toBe('Grids/Home/1-5-0-text-0.jpeg'); + expect(p).toBe("Grids/Home/1-5-0-text-0.jpeg"); }); - it('falls back to coordinate guesses when no name or map', async () => { + it("falls back to coordinate guesses when no name or map", async () => { const zip = mkZip({ - 'Grids/Home/1-1.jpeg': 'IMG', + "Grids/Home/1-1.jpeg": "IMG", }); const p = resolveGrid3CellImage(zip, { - baseDir: 'Grids/Home/', + baseDir: "Grids/Home/", x: 1, y: 1, }); - expect(p).toBe('Grids/Home/1-1.jpeg'); + expect(p).toBe("Grids/Home/1-1.jpeg"); }); - it('treats built-in [grid3x] names as non-zip assets unless mapped', async () => { + it("treats built-in [grid3x] names as non-zip assets unless mapped", async () => { const zip = mkZip({}); const p1 = resolveGrid3CellImage(zip, { - baseDir: 'Grids/Home/', - imageName: '[grid3x]Home', + baseDir: "Grids/Home/", + imageName: "[grid3x]Home", }); expect(p1).toBeNull(); const p2 = resolveGrid3CellImage(zip, { - baseDir: 'Grids/Home/', - imageName: '[grid3x]Home', - builtinHandler: () => 'builtin://home', + baseDir: "Grids/Home/", + imageName: "[grid3x]Home", + builtinHandler: () => "builtin://home", }); - expect(p2).toBe('builtin://home'); + expect(p2).toBe("builtin://home"); }); it('resolves coordinate-prefixed image names starting with "-"', async () => { const zip = mkZip({ - 'Grids/Home/1-4-0-text-0.jpeg': 'IMG', - 'Grids/Home/2-3-0-text-0.png': 'PNG', + "Grids/Home/1-4-0-text-0.jpeg": "IMG", + "Grids/Home/2-3-0-text-0.png": "PNG", }); const p1 = resolveGrid3CellImage(zip, { - baseDir: 'Grids/Home/', - imageName: '-0-text-0.jpeg', + baseDir: "Grids/Home/", + imageName: "-0-text-0.jpeg", x: 1, y: 4, }); - expect(p1).toBe('Grids/Home/1-4-0-text-0.jpeg'); + expect(p1).toBe("Grids/Home/1-4-0-text-0.jpeg"); const p2 = resolveGrid3CellImage(zip, { - baseDir: 'Grids/Home/', - imageName: '-0-text-0.png', + baseDir: "Grids/Home/", + imageName: "-0-text-0.png", x: 2, y: 3, }); - expect(p2).toBe('Grids/Home/2-3-0-text-0.png'); + expect(p2).toBe("Grids/Home/2-3-0-text-0.png"); }); - it('returns null for coordinate-prefixed names when file does not exist', async () => { + it("returns null for coordinate-prefixed names when file does not exist", async () => { const zip = mkZip({}); const p = resolveGrid3CellImage(zip, { - baseDir: 'Grids/Home/', - imageName: '-0-text-0.png', + baseDir: "Grids/Home/", + imageName: "-0-text-0.png", x: 1, y: 4, }); expect(p).toBeNull(); }); - it('returns null for coordinate-prefixed names when coordinates are missing', async () => { + it("returns null for coordinate-prefixed names when coordinates are missing", async () => { const zip = mkZip({ - 'Grids/Home/1-4-0-text-0.jpeg': 'IMG', + "Grids/Home/1-4-0-text-0.jpeg": "IMG", }); const p = resolveGrid3CellImage(zip, { - baseDir: 'Grids/Home/', - imageName: '-0-text-0.jpeg', + baseDir: "Grids/Home/", + imageName: "-0-text-0.jpeg", // No x, y provided }); expect(p).toBeNull(); diff --git a/test/gridsetWordlistHelpers.test.ts b/test/gridsetWordlistHelpers.test.ts index 8b81dba..d5939bd 100644 --- a/test/gridsetWordlistHelpers.test.ts +++ b/test/gridsetWordlistHelpers.test.ts @@ -1,4 +1,4 @@ -import AdmZip from 'adm-zip'; +import AdmZip from "adm-zip"; import { createWordlist, extractWordlists, @@ -6,120 +6,120 @@ import { wordlistToXml, WordList, WordListItem, -} from '../src/processors/gridset/wordlistHelpers'; +} from "../src/processors/gridset/wordlistHelpers"; -describe('Grid3 Wordlist Helpers', () => { - describe('createWordlist', () => { - it('creates wordlist from simple string array', async () => { - const input = ['hello', 'goodbye', 'thank you']; +describe("Grid3 Wordlist Helpers", () => { + describe("createWordlist", () => { + it("creates wordlist from simple string array", async () => { + const input = ["hello", "goodbye", "thank you"]; const wordlist = createWordlist(input); expect(wordlist.items).toHaveLength(3); - expect(wordlist.items[0].text).toBe('hello'); - expect(wordlist.items[1].text).toBe('goodbye'); - expect(wordlist.items[2].text).toBe('thank you'); + expect(wordlist.items[0].text).toBe("hello"); + expect(wordlist.items[1].text).toBe("goodbye"); + expect(wordlist.items[2].text).toBe("thank you"); }); - it('creates wordlist from array of WordListItem objects', async () => { + it("creates wordlist from array of WordListItem objects", async () => { const input: WordListItem[] = [ { - text: 'hello', - image: '[WIDGIT]greetings/hello.emf', - partOfSpeech: 'Interjection', + text: "hello", + image: "[WIDGIT]greetings/hello.emf", + partOfSpeech: "Interjection", }, { - text: 'goodbye', - image: '[WIDGIT]greetings/goodbye.emf', - partOfSpeech: 'Interjection', + text: "goodbye", + image: "[WIDGIT]greetings/goodbye.emf", + partOfSpeech: "Interjection", }, ]; const wordlist = createWordlist(input); expect(wordlist.items).toHaveLength(2); - expect(wordlist.items[0].text).toBe('hello'); - expect(wordlist.items[0].image).toBe('[WIDGIT]greetings/hello.emf'); - expect(wordlist.items[0].partOfSpeech).toBe('Interjection'); + expect(wordlist.items[0].text).toBe("hello"); + expect(wordlist.items[0].image).toBe("[WIDGIT]greetings/hello.emf"); + expect(wordlist.items[0].partOfSpeech).toBe("Interjection"); }); - it('creates wordlist from dictionary of strings', async () => { + it("creates wordlist from dictionary of strings", async () => { const input = { - greeting: 'hello', - farewell: 'goodbye', - gratitude: 'thank you', + greeting: "hello", + farewell: "goodbye", + gratitude: "thank you", }; const wordlist = createWordlist(input); expect(wordlist.items).toHaveLength(3); - expect(wordlist.items.map((i) => i.text)).toContain('hello'); - expect(wordlist.items.map((i) => i.text)).toContain('goodbye'); + expect(wordlist.items.map((i) => i.text)).toContain("hello"); + expect(wordlist.items.map((i) => i.text)).toContain("goodbye"); }); - it('creates wordlist from dictionary of objects', async () => { + it("creates wordlist from dictionary of objects", async () => { const input: Record = { - greeting: { text: 'hello', partOfSpeech: 'Interjection' }, - farewell: { text: 'goodbye', partOfSpeech: 'Interjection' }, + greeting: { text: "hello", partOfSpeech: "Interjection" }, + farewell: { text: "goodbye", partOfSpeech: "Interjection" }, }; const wordlist = createWordlist(input); expect(wordlist.items).toHaveLength(2); - expect(wordlist.items[0].partOfSpeech).toBe('Interjection'); + expect(wordlist.items[0].partOfSpeech).toBe("Interjection"); }); - it('handles empty array', async () => { + it("handles empty array", async () => { const wordlist = createWordlist([]); expect(wordlist.items).toHaveLength(0); }); - it('handles empty object', async () => { + it("handles empty object", async () => { const wordlist = createWordlist({}); expect(wordlist.items).toHaveLength(0); }); }); - describe('wordlistToXml', () => { - it('converts wordlist to valid XML', async () => { + describe("wordlistToXml", () => { + it("converts wordlist to valid XML", async () => { const wordlist: WordList = { items: [ { - text: 'hello', - image: '[WIDGIT]hello.emf', - partOfSpeech: 'Interjection', + text: "hello", + image: "[WIDGIT]hello.emf", + partOfSpeech: "Interjection", }, - { text: 'goodbye', partOfSpeech: 'Interjection' }, + { text: "goodbye", partOfSpeech: "Interjection" }, ], }; const xml = wordlistToXml(wordlist); - expect(xml).toContain(''); - expect(xml).toContain(''); - expect(xml).toContain(''); - expect(xml).toContain('hello'); - expect(xml).toContain('goodbye'); - expect(xml).toContain('[WIDGIT]hello.emf'); + expect(xml).toContain(""); + expect(xml).toContain(""); + expect(xml).toContain(""); + expect(xml).toContain("hello"); + expect(xml).toContain("goodbye"); + expect(xml).toContain("[WIDGIT]hello.emf"); }); - it('handles single item wordlist', async () => { + it("handles single item wordlist", async () => { const wordlist: WordList = { - items: [{ text: 'hello' }], + items: [{ text: "hello" }], }; const xml = wordlistToXml(wordlist); - expect(xml).toContain('hello'); - expect(xml).toContain(''); + expect(xml).toContain("hello"); + expect(xml).toContain(""); }); - it('includes PartOfSpeech as Unknown when not specified', async () => { + it("includes PartOfSpeech as Unknown when not specified", async () => { const wordlist: WordList = { - items: [{ text: 'hello' }], + items: [{ text: "hello" }], }; const xml = wordlistToXml(wordlist); - expect(xml).toContain('Unknown'); + expect(xml).toContain("Unknown"); }); }); - describe('extractWordlists', () => { + describe("extractWordlists", () => { function createTestGridset(gridName: string, wordlistXml: string): Buffer { const zip = new AdmZip(); @@ -145,11 +145,11 @@ describe('Grid3 Wordlist Helpers', () => { ${wordlistXml} `; - zip.addFile(`Grids/${gridName}/grid.xml`, Buffer.from(gridXml, 'utf8')); + zip.addFile(`Grids/${gridName}/grid.xml`, Buffer.from(gridXml, "utf8")); return zip.toBuffer(); } - it('extracts wordlist from gridset', async () => { + it("extracts wordlist from gridset", async () => { const wordlistXml = ` @@ -165,24 +165,24 @@ describe('Grid3 Wordlist Helpers', () => { `; - const gridset = createTestGridset('Greetings', wordlistXml); + const gridset = createTestGridset("Greetings", wordlistXml); const wordlists = await extractWordlists(gridset); expect(wordlists.size).toBe(1); - expect(wordlists.has('Greetings')).toBe(true); + expect(wordlists.has("Greetings")).toBe(true); - const wordlist = wordlists.get('Greetings'); + const wordlist = wordlists.get("Greetings"); expect(wordlist).toBeDefined(); if (!wordlist) { return; } expect(wordlist.items).toHaveLength(2); - expect(wordlist.items[0].text).toBe('hello'); - expect(wordlist.items[0].image).toBe('[WIDGIT]hello.emf'); - expect(wordlist.items[1].text).toBe('goodbye'); + expect(wordlist.items[0].text).toBe("hello"); + expect(wordlist.items[0].image).toBe("[WIDGIT]hello.emf"); + expect(wordlist.items[1].text).toBe("goodbye"); }); - it('returns empty map for gridset without wordlists', async () => { + it("returns empty map for gridset without wordlists", async () => { const zip = new AdmZip(); const gridXml = ` @@ -190,13 +190,13 @@ describe('Grid3 Wordlist Helpers', () => { `; - zip.addFile('Grids/Home/grid.xml', Buffer.from(gridXml, 'utf8')); + zip.addFile("Grids/Home/grid.xml", Buffer.from(gridXml, "utf8")); const wordlists = await extractWordlists(zip.toBuffer()); expect(wordlists.size).toBe(0); }); - it('handles multiple grids with wordlists', async () => { + it("handles multiple grids with wordlists", async () => { const zip = new AdmZip(); const createGrid = (name: string, items: string[]) => { @@ -206,9 +206,9 @@ describe('Grid3 Wordlist Helpers', () => { ${item} Unknown - ` + `, ) - .join(''); + .join(""); return ` @@ -222,29 +222,29 @@ describe('Grid3 Wordlist Helpers', () => { }; zip.addFile( - 'Grids/Greetings/grid.xml', - Buffer.from(createGrid('Greetings', ['hello', 'hi']), 'utf8') + "Grids/Greetings/grid.xml", + Buffer.from(createGrid("Greetings", ["hello", "hi"]), "utf8"), ); zip.addFile( - 'Grids/Farewells/grid.xml', - Buffer.from(createGrid('Farewells', ['goodbye', 'bye']), 'utf8') + "Grids/Farewells/grid.xml", + Buffer.from(createGrid("Farewells", ["goodbye", "bye"]), "utf8"), ); const wordlists = await extractWordlists(zip.toBuffer()); expect(wordlists.size).toBe(2); - expect(wordlists.get('Greetings')?.items).toHaveLength(2); - expect(wordlists.get('Farewells')?.items).toHaveLength(2); + expect(wordlists.get("Greetings")?.items).toHaveLength(2); + expect(wordlists.get("Farewells")?.items).toHaveLength(2); }); - it('throws error for invalid gridset buffer', async () => { - const invalidBuffer = Buffer.from('not a zip file'); + it("throws error for invalid gridset buffer", async () => { + const invalidBuffer = Buffer.from("not a zip file"); await expect(async () => { await extractWordlists(invalidBuffer); }).rejects.toThrow(); }); - it('skips grids with malformed wordlist XML', async () => { + it("skips grids with malformed wordlist XML", async () => { const zip = new AdmZip(); const gridXml = ` @@ -255,7 +255,7 @@ describe('Grid3 Wordlist Helpers', () => { `; - zip.addFile('Grids/Test/grid.xml', Buffer.from(gridXml, 'utf8')); + zip.addFile("Grids/Test/grid.xml", Buffer.from(gridXml, "utf8")); const wordlists = await extractWordlists(zip.toBuffer()); // Should not throw, just skip the malformed grid @@ -263,8 +263,11 @@ describe('Grid3 Wordlist Helpers', () => { }); }); - describe('updateWordlist', () => { - function createTestGridset(gridName: string, initialWordlistXml?: string): Buffer { + describe("updateWordlist", () => { + function createTestGridset( + gridName: string, + initialWordlistXml?: string, + ): Buffer { const zip = new AdmZip(); const wordlistSection = @@ -299,89 +302,91 @@ describe('Grid3 Wordlist Helpers', () => { ${wordlistSection} `; - zip.addFile(`Grids/${gridName}/grid.xml`, Buffer.from(gridXml, 'utf8')); + zip.addFile(`Grids/${gridName}/grid.xml`, Buffer.from(gridXml, "utf8")); return zip.toBuffer(); } - it('updates wordlist in existing grid', async () => { - const gridset = createTestGridset('Greetings'); - const newWordlist = createWordlist(['hello', 'hi', 'hey']); + it("updates wordlist in existing grid", async () => { + const gridset = createTestGridset("Greetings"); + const newWordlist = createWordlist(["hello", "hi", "hey"]); - const updated = await updateWordlist(gridset, 'Greetings', newWordlist); + const updated = await updateWordlist(gridset, "Greetings", newWordlist); const wordlists = await extractWordlists(updated); - expect(wordlists.has('Greetings')).toBe(true); - const wordlist = wordlists.get('Greetings'); + expect(wordlists.has("Greetings")).toBe(true); + const wordlist = wordlists.get("Greetings"); expect(wordlist).toBeDefined(); if (!wordlist) { return; } expect(wordlist.items).toHaveLength(3); - expect(wordlist.items.map((i) => i.text)).toEqual(['hello', 'hi', 'hey']); + expect(wordlist.items.map((i) => i.text)).toEqual(["hello", "hi", "hey"]); }); - it('updates wordlist with metadata', async () => { - const gridset = createTestGridset('Greetings'); + it("updates wordlist with metadata", async () => { + const gridset = createTestGridset("Greetings"); const newWordlist = createWordlist([ { - text: 'hello', - image: '[WIDGIT]hello.emf', - partOfSpeech: 'Interjection', + text: "hello", + image: "[WIDGIT]hello.emf", + partOfSpeech: "Interjection", }, { - text: 'goodbye', - image: '[WIDGIT]goodbye.emf', - partOfSpeech: 'Interjection', + text: "goodbye", + image: "[WIDGIT]goodbye.emf", + partOfSpeech: "Interjection", }, ]); - const updated = await updateWordlist(gridset, 'Greetings', newWordlist); + const updated = await updateWordlist(gridset, "Greetings", newWordlist); const wordlists = await extractWordlists(updated); - const wordlist = wordlists.get('Greetings'); + const wordlist = wordlists.get("Greetings"); expect(wordlist).toBeDefined(); if (!wordlist) { return; } - expect(wordlist.items[0].image).toBe('[WIDGIT]hello.emf'); - expect(wordlist.items[0].partOfSpeech).toBe('Interjection'); + expect(wordlist.items[0].image).toBe("[WIDGIT]hello.emf"); + expect(wordlist.items[0].partOfSpeech).toBe("Interjection"); }); - it('replaces existing wordlist completely', async () => { - const gridset = createTestGridset('Greetings'); + it("replaces existing wordlist completely", async () => { + const gridset = createTestGridset("Greetings"); const extracted1 = await extractWordlists(gridset); - expect(extracted1.get('Greetings')?.items[0].text).toBe('old'); + expect(extracted1.get("Greetings")?.items[0].text).toBe("old"); - const newWordlist = createWordlist(['new1', 'new2']); - const updated = await updateWordlist(gridset, 'Greetings', newWordlist); + const newWordlist = createWordlist(["new1", "new2"]); + const updated = await updateWordlist(gridset, "Greetings", newWordlist); const extracted2 = await extractWordlists(updated); - expect(extracted2.get('Greetings')?.items).toHaveLength(2); - expect(extracted2.get('Greetings')?.items[0].text).toBe('new1'); + expect(extracted2.get("Greetings")?.items).toHaveLength(2); + expect(extracted2.get("Greetings")?.items[0].text).toBe("new1"); }); - it('throws error for non-existent grid', async () => { - const gridset = createTestGridset('Greetings'); - const newWordlist = createWordlist(['hello']); + it("throws error for non-existent grid", async () => { + const gridset = createTestGridset("Greetings"); + const newWordlist = createWordlist(["hello"]); await expect(async () => { - await updateWordlist(gridset, 'NonExistent', newWordlist); + await updateWordlist(gridset, "NonExistent", newWordlist); }).rejects.toThrow('Grid "NonExistent" not found in gridset'); }); - it('throws error for invalid gridset buffer', async () => { - const invalidBuffer = Buffer.from('not a zip file'); - const newWordlist = createWordlist(['hello']); + it("throws error for invalid gridset buffer", async () => { + const invalidBuffer = Buffer.from("not a zip file"); + const newWordlist = createWordlist(["hello"]); await expect(async () => { - await updateWordlist(invalidBuffer, 'Greetings', newWordlist); + await updateWordlist(invalidBuffer, "Greetings", newWordlist); }).rejects.toThrow(); }); - it('preserves other grids when updating one', async () => { + it("preserves other grids when updating one", async () => { const zip = new AdmZip(); - const createGrid = (name: string) => ` + const createGrid = ( + name: string, + ) => ` ${name}-id @@ -395,15 +400,25 @@ describe('Grid3 Wordlist Helpers', () => { `; - zip.addFile('Grids/Greetings/grid.xml', Buffer.from(createGrid('Greetings'), 'utf8')); - zip.addFile('Grids/Farewells/grid.xml', Buffer.from(createGrid('Farewells'), 'utf8')); + zip.addFile( + "Grids/Greetings/grid.xml", + Buffer.from(createGrid("Greetings"), "utf8"), + ); + zip.addFile( + "Grids/Farewells/grid.xml", + Buffer.from(createGrid("Farewells"), "utf8"), + ); - const newWordlist = createWordlist(['updated']); - const updated = await updateWordlist(zip.toBuffer(), 'Greetings', newWordlist); + const newWordlist = createWordlist(["updated"]); + const updated = await updateWordlist( + zip.toBuffer(), + "Greetings", + newWordlist, + ); const wordlists = await extractWordlists(updated); - expect(wordlists.get('Greetings')?.items[0].text).toBe('updated'); - expect(wordlists.get('Farewells')?.items[0].text).toBe('Farewells-item'); + expect(wordlists.get("Greetings")?.items[0].text).toBe("updated"); + expect(wordlists.get("Farewells")?.items[0].text).toBe("Farewells-item"); }); }); }); diff --git a/test/history.analytics.test.ts b/test/history.analytics.test.ts index c2cf95a..cdea6d8 100644 --- a/test/history.analytics.test.ts +++ b/test/history.analytics.test.ts @@ -1,83 +1,88 @@ -import { describe, expect, it, jest } from '@jest/globals'; +import { describe, expect, it, jest } from "@jest/globals"; -describe('History analytics wrappers (mocked)', () => { +describe("History analytics wrappers (mocked)", () => { afterEach(async () => { jest.resetModules(); jest.clearAllMocks(); }); - it('wraps platform helpers and unifies histories', async () => { + it("wraps platform helpers and unifies histories", async () => { jest.isolateModules(async () => { - jest.doMock('../src/processors/gridset/helpers', () => ({ + jest.doMock("../src/processors/gridset/helpers", () => ({ readGrid3History: jest.fn(() => [ { - id: 'g1', - content: 'grid single', + id: "g1", + content: "grid single", occurrences: [{ timestamp: new Date() }], }, ]), readGrid3HistoryForUser: jest.fn(() => [ { - id: 'g-user', - content: 'grid user', + id: "g-user", + content: "grid user", occurrences: [{ timestamp: new Date() }], }, ]), readAllGrid3History: jest.fn(() => [ { - id: 'g-all', - content: 'grid all', + id: "g-all", + content: "grid all", occurrences: [{ timestamp: new Date() }], }, ]), findGrid3Users: jest.fn(() => [ { - userName: 'alice', - langCode: 'en', - basePath: 'p', - historyDbPath: 'p/db', + userName: "alice", + langCode: "en", + basePath: "p", + historyDbPath: "p/db", }, ]), })); - jest.doMock('../src/processors/snap/helpers', () => ({ + jest.doMock("../src/processors/snap/helpers", () => ({ readSnapUsage: jest.fn(() => [ { - id: 's1', - content: 'snap single', + id: "s1", + content: "snap single", occurrences: [{ timestamp: new Date() }], - platform: { buttonId: 'b1' }, + platform: { buttonId: "b1" }, }, ]), readSnapUsageForUser: jest.fn(() => [ { - id: 's-user', - content: 'snap user', + id: "s-user", + content: "snap user", occurrences: [{ timestamp: new Date() }], }, ]), - findSnapUsers: jest.fn(() => [{ userId: 'u1', userPath: 'p', vocabPaths: [] }]), + findSnapUsers: jest.fn(() => [ + { userId: "u1", userPath: "p", vocabPaths: [] }, + ]), })); // Import after mocks are in place // eslint-disable-next-line @typescript-eslint/no-var-requires - const history = require('../src/utilities/analytics/history'); // eslint-disable-line @typescript-eslint/no-var-requires + const history = require("../src/utilities/analytics/history"); // eslint-disable-line @typescript-eslint/no-var-requires - const gridUserEntries = await history.readGrid3HistoryForUser('alice'); - expect(gridUserEntries[0].source).toBe('Grid'); - expect(gridUserEntries[0].content).toBe('grid user'); + const gridUserEntries = await history.readGrid3HistoryForUser("alice"); + expect(gridUserEntries[0].source).toBe("Grid"); + expect(gridUserEntries[0].content).toBe("grid user"); const gridAllEntries = await history.readAllGrid3History(); - expect(gridAllEntries[0].source).toBe('Grid'); + expect(gridAllEntries[0].source).toBe("Grid"); - const snapEntries = await history.readSnapUsageForUser('u1'); - expect(snapEntries[0].source).toBe('Snap'); + const snapEntries = await history.readSnapUsageForUser("u1"); + expect(snapEntries[0].source).toBe("Snap"); expect(await history.listGrid3Users()).toHaveLength(1); expect(await history.listSnapUsers()).toHaveLength(1); const unified = await history.collectUnifiedHistory(); - expect(unified.map((e: any) => e.source).sort()).toEqual(['Grid', 'Snap']); + expect(unified.map((e: any) => e.source).sort()).toEqual([ + "Grid", + "Snap", + ]); }); }); }); diff --git a/test/history.test.ts b/test/history.test.ts index 5a97942..61421b9 100644 --- a/test/history.test.ts +++ b/test/history.test.ts @@ -1,8 +1,8 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import Database from 'better-sqlite3'; -import { Analytics } from '../src/index'; +import fs from "fs"; +import os from "os"; +import path from "path"; +import Database from "better-sqlite3"; +import { Analytics } from "../src/index"; const EPOCH_TICKS = 621355968000000000n; const TICKS_PER_MS = 10000n; @@ -11,8 +11,8 @@ function dateToTicks(date: Date): bigint { return BigInt(date.getTime()) * TICKS_PER_MS + EPOCH_TICKS; } -describe('History analytics', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'history-test-')); +describe("History analytics", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "history-test-")); afterAll(async () => { try { @@ -22,15 +22,15 @@ describe('History analytics', () => { } }); - it('converts .NET ticks to Date', async () => { - const now = new Date('2024-01-01T00:00:00Z'); + it("converts .NET ticks to Date", async () => { + const now = new Date("2024-01-01T00:00:00Z"); const ticks = dateToTicks(now); const converted = Analytics.dotNetTicksToDate(ticks); expect(converted.toISOString()).toBe(now.toISOString()); }); - it('reads Grid 3 history from sqlite', async () => { - const dbPath = path.join(tempDir, 'grid3-history.sqlite'); + it("reads Grid 3 history from sqlite", async () => { + const dbPath = path.join(tempDir, "grid3-history.sqlite"); const db = new Database(dbPath); db.exec(` CREATE TABLE Phrases (Id INTEGER PRIMARY KEY AUTOINCREMENT, Text TEXT NOT NULL, Content TEXT NOT NULL); @@ -45,29 +45,29 @@ describe('History analytics', () => { `); const phraseId = db - .prepare('INSERT INTO Phrases (Text, Content) VALUES (?, ?)') + .prepare("INSERT INTO Phrases (Text, Content) VALUES (?, ?)") .run( - 'hello world', - '

Helloworld

' + "hello world", + "

Helloworld

", ).lastInsertRowid as number; - const ts = dateToTicks(new Date('2024-02-02T10:00:00Z')); + const ts = dateToTicks(new Date("2024-02-02T10:00:00Z")); db.prepare( - 'INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)' + "INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)", ).run(phraseId, ts, 51.5, -1.2); const history = await Analytics.readGrid3History(dbPath); expect(history).toHaveLength(1); const entry = history[0] as Analytics.HistoryEntry; - expect(entry.source).toBe('Grid'); - expect(entry.content).toBe('Hello world'); + expect(entry.source).toBe("Grid"); + expect(entry.content).toBe("Hello world"); expect(entry.occurrences).toHaveLength(1); expect(entry.occurrences[0].latitude).toBeCloseTo(51.5); expect(entry.occurrences[0].longitude).toBeCloseTo(-1.2); }); - it('skips Grid 3 history rows without text and falls back to plain text when XML is missing', async () => { - const dbPath = path.join(tempDir, 'grid3-history-missing.sqlite'); + it("skips Grid 3 history rows without text and falls back to plain text when XML is missing", async () => { + const dbPath = path.join(tempDir, "grid3-history-missing.sqlite"); const db = new Database(dbPath); db.exec(` CREATE TABLE Phrases (Id INTEGER PRIMARY KEY AUTOINCREMENT, Text TEXT, Content TEXT); @@ -82,30 +82,30 @@ describe('History analytics', () => { `); const missingId = db - .prepare('INSERT INTO Phrases (Text, Content) VALUES (?, ?)') + .prepare("INSERT INTO Phrases (Text, Content) VALUES (?, ?)") .run(null, null).lastInsertRowid as number; const fallbackId = db - .prepare('INSERT INTO Phrases (Text, Content) VALUES (?, ?)') - .run('plain text only', '').lastInsertRowid as number; + .prepare("INSERT INTO Phrases (Text, Content) VALUES (?, ?)") + .run("plain text only", "").lastInsertRowid as number; - const ts1 = dateToTicks(new Date('2024-04-04T00:00:00Z')); - const ts2 = dateToTicks(new Date('2024-04-04T00:01:00Z')); + const ts1 = dateToTicks(new Date("2024-04-04T00:00:00Z")); + const ts2 = dateToTicks(new Date("2024-04-04T00:01:00Z")); db.prepare( - 'INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)' + "INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)", ).run(missingId, ts1, null, null); db.prepare( - 'INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)' + "INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)", ).run(fallbackId, ts2, null, null); const history = await Analytics.readGrid3History(dbPath); expect(history).toHaveLength(1); - expect(history[0].content).toBe('plain text only'); + expect(history[0].content).toBe("plain text only"); expect(history[0].occurrences).toHaveLength(1); }); - it('reads Snap usage from pageset sqlite', async () => { - const pagesetPath = path.join(tempDir, 'snap.sps'); + it("reads Snap usage from pageset sqlite", async () => { + const pagesetPath = path.join(tempDir, "snap.sps"); const db = new Database(pagesetPath); db.exec(` CREATE TABLE Button ( @@ -124,24 +124,22 @@ describe('History analytics', () => { ); `); - const buttonId = 'btn-1'; - db.prepare('INSERT INTO Button (Label, Message, UniqueId) VALUES (?, ?, ?)').run( - 'Hello', - 'Hello there', - buttonId - ); + const buttonId = "btn-1"; + db.prepare( + "INSERT INTO Button (Label, Message, UniqueId) VALUES (?, ?, ?)", + ).run("Hello", "Hello there", buttonId); - const ts = dateToTicks(new Date('2024-03-03T12:00:00Z')); + const ts = dateToTicks(new Date("2024-03-03T12:00:00Z")); db.prepare( - 'INSERT INTO ButtonUsage (Timestamp, ButtonUniqueId, Modeling, AccessMethod, BlockId) VALUES (?, ?, ?, ?, ?)' + "INSERT INTO ButtonUsage (Timestamp, ButtonUniqueId, Modeling, AccessMethod, BlockId) VALUES (?, ?, ?, ?, ?)", ).run(ts, buttonId, 0, 2, 1); const history = await Analytics.readSnapUsage(pagesetPath); expect(history).toHaveLength(1); const entry = history[0] as Analytics.HistoryEntry; - expect(entry.source).toBe('Snap'); + expect(entry.source).toBe("Snap"); expect(entry.platform?.buttonId).toBe(buttonId); - expect(entry.content).toContain('Hello'); + expect(entry.content).toContain("Hello"); expect(entry.occurrences[0].modeling).toBe(false); expect(entry.occurrences[0].accessMethod).toBe(2); }); diff --git a/test/index.entrypoints.test.ts b/test/index.entrypoints.test.ts index fc3cc90..1f03ed4 100644 --- a/test/index.entrypoints.test.ts +++ b/test/index.entrypoints.test.ts @@ -1,16 +1,16 @@ -import * as browserEntry from '../src/index.browser'; -import * as nodeEntry from '../src/index.node'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import * as browserEntry from "../src/index.browser"; +import * as nodeEntry from "../src/index.node"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; -describe('entrypoint exports', () => { - it('browser entry resolves supported processors', () => { - const dot = browserEntry.getProcessor('.dot'); - const grid = browserEntry.getProcessor('.gridset'); - const snap = browserEntry.getProcessor('.sps'); - const touch = browserEntry.getProcessor('.ce'); +describe("entrypoint exports", () => { + it("browser entry resolves supported processors", () => { + const dot = browserEntry.getProcessor(".dot"); + const grid = browserEntry.getProcessor(".gridset"); + const snap = browserEntry.getProcessor(".sps"); + const touch = browserEntry.getProcessor(".ce"); expect(dot).toBeInstanceOf(DotProcessor); expect(grid).toBeInstanceOf(GridsetProcessor); @@ -18,28 +18,43 @@ describe('entrypoint exports', () => { expect(touch).toBeInstanceOf(TouchChatProcessor); const extensions = browserEntry.getSupportedExtensions(); - expect(browserEntry.isExtensionSupported('.dot')).toBe(true); - expect(browserEntry.isExtensionSupported('.ce')).toBe(true); + expect(browserEntry.isExtensionSupported(".dot")).toBe(true); + expect(browserEntry.isExtensionSupported(".ce")).toBe(true); expect(extensions).toEqual( - expect.arrayContaining(['.dot', '.gridset', '.sps', '.spb', '.ce', '.plist', '.grd']) + expect.arrayContaining([ + ".dot", + ".gridset", + ".sps", + ".spb", + ".ce", + ".plist", + ".grd", + ]), ); - expect(() => browserEntry.getProcessor('.unknown')).toThrow(); + expect(() => browserEntry.getProcessor(".unknown")).toThrow(); }); - it('node entry resolves supported processors', () => { - const snap = nodeEntry.getProcessor('.sps'); - const touch = nodeEntry.getProcessor('.ce'); - const grid = nodeEntry.getProcessor('.gridsetx'); + it("node entry resolves supported processors", () => { + const snap = nodeEntry.getProcessor(".sps"); + const touch = nodeEntry.getProcessor(".ce"); + const grid = nodeEntry.getProcessor(".gridsetx"); expect(snap).toBeInstanceOf(SnapProcessor); expect(touch).toBeInstanceOf(TouchChatProcessor); expect(grid).toBeInstanceOf(GridsetProcessor); const extensions = nodeEntry.getSupportedExtensions(); - expect(nodeEntry.isExtensionSupported('.gridsetx')).toBe(true); + expect(nodeEntry.isExtensionSupported(".gridsetx")).toBe(true); expect(extensions).toEqual( - expect.arrayContaining(['.gridsetx', '.sps', '.spb', '.ce', '.obf', '.obz']) + expect.arrayContaining([ + ".gridsetx", + ".sps", + ".spb", + ".ce", + ".obf", + ".obz", + ]), ); - expect(() => nodeEntry.getProcessor('.unknown')).toThrow(); + expect(() => nodeEntry.getProcessor(".unknown")).toThrow(); }); }); diff --git a/test/integration.test.ts b/test/integration.test.ts index 096c477..652eab8 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,21 +1,21 @@ // Integration tests for CLI, processor factory, and cross-format compatibility -import fs from 'fs'; -import path from 'path'; -import { execSync } from 'child_process'; -import { getProcessor } from '../src/index'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { ExcelProcessor } from '../src/processors/excelProcessor'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; -import { AstericsGridProcessor } from '../src/processors/astericsGridProcessor'; - -describe('Integration Tests', () => { - const tempDir = path.join(__dirname, 'temp_integration'); - const examplesDir = path.join(__dirname, '../examples'); +import fs from "fs"; +import path from "path"; +import { execSync } from "child_process"; +import { getProcessor } from "../src/index"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { ExcelProcessor } from "../src/processors/excelProcessor"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; +import { AstericsGridProcessor } from "../src/processors/astericsGridProcessor"; + +describe("Integration Tests", () => { + const tempDir = path.join(__dirname, "temp_integration"); + const examplesDir = path.join(__dirname, "../examples"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -29,98 +29,101 @@ describe('Integration Tests', () => { } }); - describe('CLI Integration', () => { - const cliPath = path.join(__dirname, '../dist/cli.js'); + describe("CLI Integration", () => { + const cliPath = path.join(__dirname, "../dist/cli.js"); let cliAvailable = false; beforeAll(async () => { // Check if CLI is available cliAvailable = fs.existsSync(cliPath); if (!cliAvailable) { - console.log('CLI not available, skipping CLI tests'); + console.log("CLI not available, skipping CLI tests"); } }); - it('should display help when no arguments provided', async () => { + it("should display help when no arguments provided", async () => { if (!cliAvailable) { - console.log('Skipping CLI test - CLI not available'); + console.log("Skipping CLI test - CLI not available"); return; } try { const result = execSync(`node ${cliPath}`, { - encoding: 'utf8', - stdio: 'pipe', + encoding: "utf8", + stdio: "pipe", }); - expect(result).toContain('Usage:'); + expect(result).toContain("Usage:"); } catch (error: any) { // CLI might exit with non-zero code when showing help - expect(error.stdout || error.stderr).toContain('Usage:'); + expect(error.stdout || error.stderr).toContain("Usage:"); } }); - it('should process DOT files via CLI', async () => { - const dotFile = path.join(examplesDir, 'example.dot'); + it("should process DOT files via CLI", async () => { + const dotFile = path.join(examplesDir, "example.dot"); if (!cliAvailable || !fs.existsSync(dotFile)) { - console.log('Skipping CLI DOT test - files not available'); + console.log("Skipping CLI DOT test - files not available"); return; } - const outputFile = path.join(tempDir, 'cli_output.json'); + const outputFile = path.join(tempDir, "cli_output.json"); try { - const _result = execSync(`node ${cliPath} extract-texts ${dotFile} ${outputFile}`, { - encoding: 'utf8', - stdio: 'pipe', - }); + const _result = execSync( + `node ${cliPath} extract-texts ${dotFile} ${outputFile}`, + { + encoding: "utf8", + stdio: "pipe", + }, + ); expect(fs.existsSync(outputFile)).toBe(true); - const outputContent = JSON.parse(fs.readFileSync(outputFile, 'utf8')); + const outputContent = JSON.parse(fs.readFileSync(outputFile, "utf8")); expect(Array.isArray(outputContent)).toBe(true); expect(outputContent.length).toBeGreaterThan(0); } catch (error: any) { - console.log('CLI test failed:', error.message); + console.log("CLI test failed:", error.message); // CLI might not be fully implemented yet } }); - it('should handle invalid file formats gracefully via CLI', async () => { + it("should handle invalid file formats gracefully via CLI", async () => { if (!cliAvailable) { - console.log('Skipping CLI error test - CLI not available'); + console.log("Skipping CLI error test - CLI not available"); return; } - const invalidFile = path.join(tempDir, 'invalid.xyz'); - fs.writeFileSync(invalidFile, 'invalid content'); + const invalidFile = path.join(tempDir, "invalid.xyz"); + fs.writeFileSync(invalidFile, "invalid content"); try { execSync(`node ${cliPath} extract-texts ${invalidFile}`, { - encoding: 'utf8', - stdio: 'pipe', + encoding: "utf8", + stdio: "pipe", }); } catch (error: any) { // Should fail gracefully with meaningful error expect(error.status).not.toBe(0); - expect(error.stderr || error.stdout).toContain('error'); + expect(error.stderr || error.stdout).toContain("error"); } }); }); - describe('Processor Factory Integration', () => { - it('should return correct processor for each file extension', async () => { + describe("Processor Factory Integration", () => { + it("should return correct processor for each file extension", async () => { const testCases = [ - { ext: '.dot', expectedType: DotProcessor }, - { ext: '.xlsx', expectedType: ExcelProcessor }, - { ext: '.opml', expectedType: OpmlProcessor }, - { ext: '.obf', expectedType: ObfProcessor }, - { ext: '.obz', expectedType: ObfProcessor }, - { ext: '.gridset', expectedType: GridsetProcessor }, - { ext: '.gridsetx', expectedType: GridsetProcessor }, - { ext: '.spb', expectedType: SnapProcessor }, - { ext: '.sps', expectedType: SnapProcessor }, - { ext: '.ce', expectedType: TouchChatProcessor }, - { ext: '.plist', expectedType: ApplePanelsProcessor }, - { ext: '.grd', expectedType: AstericsGridProcessor }, + { ext: ".dot", expectedType: DotProcessor }, + { ext: ".xlsx", expectedType: ExcelProcessor }, + { ext: ".opml", expectedType: OpmlProcessor }, + { ext: ".obf", expectedType: ObfProcessor }, + { ext: ".obz", expectedType: ObfProcessor }, + { ext: ".gridset", expectedType: GridsetProcessor }, + { ext: ".gridsetx", expectedType: GridsetProcessor }, + { ext: ".spb", expectedType: SnapProcessor }, + { ext: ".sps", expectedType: SnapProcessor }, + { ext: ".ce", expectedType: TouchChatProcessor }, + { ext: ".plist", expectedType: ApplePanelsProcessor }, + { ext: ".grd", expectedType: AstericsGridProcessor }, ]; for (const { ext, expectedType } of testCases) { @@ -129,22 +132,22 @@ describe('Integration Tests', () => { } }); - it('should handle unknown file extensions', async () => { + it("should handle unknown file extensions", async () => { expect(() => { - getProcessor('.unknown'); + getProcessor(".unknown"); }).toThrow(); expect(() => { - getProcessor('.xyz'); + getProcessor(".xyz"); }).toThrow(); }); - it('should work with full file paths', async () => { + it("should work with full file paths", async () => { const testPaths = [ - '/path/to/file.dot', - 'relative/path/file.opml', - 'file.gridset', - '/complex/path/with.multiple.dots.obf', + "/path/to/file.dot", + "relative/path/file.opml", + "file.gridset", + "/complex/path/with.multiple.dots.obf", ]; for (const filePath of testPaths) { @@ -156,8 +159,8 @@ describe('Integration Tests', () => { }); }); - describe('Cross-Format Compatibility', () => { - it('should convert between compatible formats', async () => { + describe("Cross-Format Compatibility", () => { + it("should convert between compatible formats", async () => { // Create a simple tree structure const dotProcessor = new DotProcessor(); const opmlProcessor = new OpmlProcessor(); @@ -175,26 +178,31 @@ describe('Integration Tests', () => { // Load from DOT const tree = await dotProcessor.loadIntoTree(Buffer.from(dotContent)); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); - console.log('Original DOT tree pages:', Object.keys(tree.pages).length); + console.log("Original DOT tree pages:", Object.keys(tree.pages).length); // Save as OPML - const opmlPath = path.join(tempDir, 'converted.opml'); + const opmlPath = path.join(tempDir, "converted.opml"); await opmlProcessor.saveFromTree(tree, opmlPath); expect(fs.existsSync(opmlPath)).toBe(true); // Load back from OPML const reloadedTree = await opmlProcessor.loadIntoTree(opmlPath); - console.log('Reloaded OPML tree pages:', Object.keys(reloadedTree.pages).length); + console.log( + "Reloaded OPML tree pages:", + Object.keys(reloadedTree.pages).length, + ); // The page count might differ due to format differences, but should have at least some pages expect(Object.keys(reloadedTree.pages).length).toBeGreaterThan(0); // Verify content preservation - const originalTexts = await dotProcessor.extractTexts(Buffer.from(dotContent)); + const originalTexts = await dotProcessor.extractTexts( + Buffer.from(dotContent), + ); const convertedTexts = await opmlProcessor.extractTexts(opmlPath); - console.log('Original texts:', originalTexts); - console.log('Converted texts:', convertedTexts); + console.log("Original texts:", originalTexts); + console.log("Converted texts:", convertedTexts); // Should have some text content expect(originalTexts.length).toBeGreaterThan(0); @@ -205,42 +213,42 @@ describe('Integration Tests', () => { convertedTexts.some( (convertedText) => originalText.toLowerCase().includes(convertedText.toLowerCase()) || - convertedText.toLowerCase().includes(originalText.toLowerCase()) - ) + convertedText.toLowerCase().includes(originalText.toLowerCase()), + ), ); expect(hasCommonContent).toBe(true); }); - it('should preserve navigation structure across formats', async () => { + it("should preserve navigation structure across formats", async () => { const obfProcessor = new ObfProcessor(); const applePanelsProcessor = new ApplePanelsProcessor(); // Create OBF content with navigation const obfContent = { - id: 'main', - name: 'Main Board', + id: "main", + name: "Main Board", buttons: [ { - id: 'btn1', - label: 'Hello', - vocalization: 'Hello World', + id: "btn1", + label: "Hello", + vocalization: "Hello World", }, { - id: 'btn2', - label: 'Go Home', - load_board: { path: 'home' }, + id: "btn2", + label: "Go Home", + load_board: { path: "home" }, }, ], }; - const obfPath = path.join(tempDir, 'nav_test.obf'); + const obfPath = path.join(tempDir, "nav_test.obf"); fs.writeFileSync(obfPath, JSON.stringify(obfContent, null, 2)); // Load from OBF const tree = await obfProcessor.loadIntoTree(obfPath); // Convert to Apple Panels - const applePath = path.join(tempDir, 'nav_test.plist'); + const applePath = path.join(tempDir, "nav_test.plist"); await applePanelsProcessor.saveFromTree(tree, applePath); // Load back and verify navigation is preserved @@ -251,12 +259,12 @@ describe('Integration Tests', () => { expect(mainPage.buttons.length).toBe(2); const navButton = mainPage.buttons.find( - (btn) => btn.semanticAction?.intent === 'NAVIGATE_TO' + (btn) => btn.semanticAction?.intent === "NAVIGATE_TO", ); expect(navButton).toBeDefined(); }); - it('should handle translation workflows across formats', async () => { + it("should handle translation workflows across formats", async () => { const dotProcessor = new DotProcessor(); const gridsetProcessor = new GridsetProcessor(); @@ -269,34 +277,37 @@ describe('Integration Tests', () => { `; // Extract texts from DOT - const originalTexts = await dotProcessor.extractTexts(Buffer.from(dotContent)); + const originalTexts = await dotProcessor.extractTexts( + Buffer.from(dotContent), + ); expect(originalTexts.length).toBeGreaterThan(0); // Create translations const translations = new Map(); for (const text of originalTexts) { - if (text.toLowerCase().includes('hello')) { - translations.set(text, text.replace(/hello/gi, 'hola')); + if (text.toLowerCase().includes("hello")) { + translations.set(text, text.replace(/hello/gi, "hola")); } - if (text.toLowerCase().includes('world')) { - translations.set(text, text.replace(/world/gi, 'mundo')); + if (text.toLowerCase().includes("world")) { + translations.set(text, text.replace(/world/gi, "mundo")); } } if (translations.size > 0) { // Apply translations in DOT format - const translatedDotPath = path.join(tempDir, 'translated.dot'); + const translatedDotPath = path.join(tempDir, "translated.dot"); const _translatedDotResult = await dotProcessor.processTexts( Buffer.from(dotContent), translations, - translatedDotPath + translatedDotPath, ); expect(fs.existsSync(translatedDotPath)).toBe(true); // Load translated DOT and convert to GridSet - const translatedTree = await dotProcessor.loadIntoTree(translatedDotPath); - const gridsetPath = path.join(tempDir, 'translated.gridset'); + const translatedTree = + await dotProcessor.loadIntoTree(translatedDotPath); + const gridsetPath = path.join(tempDir, "translated.gridset"); try { await gridsetProcessor.saveFromTree(translatedTree, gridsetPath); @@ -304,21 +315,22 @@ describe('Integration Tests', () => { // Verify translations are preserved in GridSet format const gridsetBuffer = fs.readFileSync(gridsetPath); - const gridsetTexts = await gridsetProcessor.extractTexts(gridsetBuffer); + const gridsetTexts = + await gridsetProcessor.extractTexts(gridsetBuffer); const hasTranslations = gridsetTexts.some( - (text) => text.includes('hola') || text.includes('mundo') + (text) => text.includes("hola") || text.includes("mundo"), ); expect(hasTranslations).toBe(true); } catch (error) { - console.log('GridSet conversion test skipped due to:', error); + console.log("GridSet conversion test skipped due to:", error); } } }); }); - describe('End-to-End Workflows', () => { - it('should support complete AAC workflow: load -> extract -> translate -> save', async () => { + describe("End-to-End Workflows", () => { + it("should support complete AAC workflow: load -> extract -> translate -> save", async () => { const processor = new DotProcessor(); const originalContent = ` @@ -344,41 +356,51 @@ describe('Integration Tests', () => { // Step 3: Create translations (simulate translation service) const translations = new Map(); for (const text of texts) { - if (text.includes('Home')) translations.set(text, text.replace('Home', 'Casa')); - if (text.includes('Food')) translations.set(text, text.replace('Food', 'Comida')); - if (text.includes('Drink')) translations.set(text, text.replace('Drink', 'Bebida')); - if (text.includes('More')) translations.set(text, text.replace('More', 'Más')); - if (text.includes('want')) translations.set(text, text.replace('want', 'quiero')); + if (text.includes("Home")) + translations.set(text, text.replace("Home", "Casa")); + if (text.includes("Food")) + translations.set(text, text.replace("Food", "Comida")); + if (text.includes("Drink")) + translations.set(text, text.replace("Drink", "Bebida")); + if (text.includes("More")) + translations.set(text, text.replace("More", "Más")); + if (text.includes("want")) + translations.set(text, text.replace("want", "quiero")); } // Step 4: Apply translations - const translatedPath = path.join(tempDir, 'workflow_translated.dot'); + const translatedPath = path.join(tempDir, "workflow_translated.dot"); const _translatedResult = await processor.processTexts( Buffer.from(originalContent), translations, - translatedPath + translatedPath, ); expect(fs.existsSync(translatedPath)).toBe(true); // Step 5: Verify final result const finalTree = await processor.loadIntoTree(translatedPath); - expect(Object.keys(finalTree.pages).length).toBe(Object.keys(tree.pages).length); + expect(Object.keys(finalTree.pages).length).toBe( + Object.keys(tree.pages).length, + ); const finalTexts = await processor.extractTexts(translatedPath); const hasSpanishContent = finalTexts.some( - (text) => text.includes('Casa') || text.includes('Comida') || text.includes('quiero') + (text) => + text.includes("Casa") || + text.includes("Comida") || + text.includes("quiero"), ); expect(hasSpanishContent).toBe(true); }); - it('should handle batch processing of multiple files', async () => { + it("should handle batch processing of multiple files", async () => { const processor = new DotProcessor(); const testFiles = [ - { name: 'test1.dot', content: 'digraph G { a [label="Test 1"]; }' }, - { name: 'test2.dot', content: 'digraph G { b [label="Test 2"]; }' }, - { name: 'test3.dot', content: 'digraph G { c [label="Test 3"]; }' }, + { name: "test1.dot", content: 'digraph G { a [label="Test 1"]; }' }, + { name: "test2.dot", content: 'digraph G { b [label="Test 2"]; }' }, + { name: "test3.dot", content: 'digraph G { c [label="Test 3"]; }' }, ]; const results: any[] = []; diff --git a/test/memoryLeaks.test.ts b/test/memoryLeaks.test.ts index aec1df7..060cd21 100644 --- a/test/memoryLeaks.test.ts +++ b/test/memoryLeaks.test.ts @@ -1,17 +1,17 @@ // Memory leak detection tests -import fs from 'fs'; -import path from 'path'; -import { performance } from 'perf_hooks'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; - -describe('Memory Leak Detection Tests', () => { - const tempDir = path.join(__dirname, 'temp_memory'); +import fs from "fs"; +import path from "path"; +import { performance } from "perf_hooks"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; + +describe("Memory Leak Detection Tests", () => { + const tempDir = path.join(__dirname, "temp_memory"); let warnSpy: jest.SpyInstance; beforeAll(async () => { - warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } @@ -43,7 +43,10 @@ describe('Memory Leak Detection Tests', () => { } // Helper function to create test data - function createTestTree(pageCount: number = 5, buttonsPerPage: number = 10): AACTree { + function createTestTree( + pageCount: number = 5, + buttonsPerPage: number = 10, + ): AACTree { const tree = new AACTree(); for (let p = 0; p < pageCount; p++) { @@ -58,9 +61,11 @@ describe('Memory Leak Detection Tests', () => { id: `btn_${p}_${b}`, label: `Button ${b} on Page ${p}`, message: `Message for button ${b} on page ${p}`, - type: Math.random() > 0.5 ? 'SPEAK' : 'NAVIGATE', + type: Math.random() > 0.5 ? "SPEAK" : "NAVIGATE", targetPageId: - Math.random() > 0.7 ? `page_${Math.floor(Math.random() * pageCount)}` : undefined, + Math.random() > 0.7 + ? `page_${Math.floor(Math.random() * pageCount)}` + : undefined, }); page.addButton(button); } @@ -71,8 +76,8 @@ describe('Memory Leak Detection Tests', () => { return tree; } - describe('Repeated Operations Memory Tests', () => { - it('should not leak memory during repeated loadIntoTree operations', async () => { + describe("Repeated Operations Memory Tests", () => { + it("should not leak memory during repeated loadIntoTree operations", async () => { const processor = new DotProcessor(); const testContent = ` digraph G { @@ -85,7 +90,7 @@ describe('Memory Leak Detection Tests', () => { `; const memBefore = getMemoryUsage(); - console.log('Memory before repeated loads:', memBefore); + console.log("Memory before repeated loads:", memBefore); // Perform many load operations for (let i = 0; i < 50; i++) { @@ -100,7 +105,7 @@ describe('Memory Leak Detection Tests', () => { forceGC(); const memAfter = getMemoryUsage(); - console.log('Memory after repeated loads:', memAfter); + console.log("Memory after repeated loads:", memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Memory increase: ${memoryIncrease}MB`); @@ -109,12 +114,12 @@ describe('Memory Leak Detection Tests', () => { expect(memoryIncrease).toBeLessThan(20); // Less than 20MB increase }); - it('should not leak memory during repeated saveFromTree operations', async () => { + it("should not leak memory during repeated saveFromTree operations", async () => { const processor = new DotProcessor(); const testTree = createTestTree(3, 5); const memBefore = getMemoryUsage(); - console.log('Memory before repeated saves:', memBefore); + console.log("Memory before repeated saves:", memBefore); // Perform many save operations for (let i = 0; i < 30; i++) { @@ -132,7 +137,7 @@ describe('Memory Leak Detection Tests', () => { forceGC(); const memAfter = getMemoryUsage(); - console.log('Memory after repeated saves:', memAfter); + console.log("Memory after repeated saves:", memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Memory increase: ${memoryIncrease}MB`); @@ -140,7 +145,7 @@ describe('Memory Leak Detection Tests', () => { expect(memoryIncrease).toBeLessThan(15); // Less than 15MB increase }); - it('should not leak memory during repeated translation operations', async () => { + it("should not leak memory during repeated translation operations", async () => { const processor = new DotProcessor(); const testContent = ` digraph G { @@ -152,14 +157,14 @@ describe('Memory Leak Detection Tests', () => { `; const translations = new Map([ - ['Hello', 'Hola'], - ['World', 'Mundo'], - ['Test', 'Prueba'], - ['Go', 'Ir'], + ["Hello", "Hola"], + ["World", "Mundo"], + ["Test", "Prueba"], + ["Go", "Ir"], ]); const memBefore = getMemoryUsage(); - console.log('Memory before repeated translations:', memBefore); + console.log("Memory before repeated translations:", memBefore); // Perform many translation operations for (let i = 0; i < 25; i++) { @@ -167,7 +172,7 @@ describe('Memory Leak Detection Tests', () => { const result = await processor.processTexts( Buffer.from(testContent), translations, - outputPath + outputPath, ); expect(result).toBeInstanceOf(Buffer); @@ -183,7 +188,7 @@ describe('Memory Leak Detection Tests', () => { forceGC(); const memAfter = getMemoryUsage(); - console.log('Memory after repeated translations:', memAfter); + console.log("Memory after repeated translations:", memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Memory increase: ${memoryIncrease}MB`); @@ -192,13 +197,13 @@ describe('Memory Leak Detection Tests', () => { }); }); - describe('Database Connection Memory Tests', () => { - it('should not leak memory with repeated database operations', async () => { + describe("Database Connection Memory Tests", () => { + it("should not leak memory with repeated database operations", async () => { const processor = new SnapProcessor(); const testTree = createTestTree(2, 8); const memBefore = getMemoryUsage(); - console.log('Memory before repeated DB operations:', memBefore); + console.log("Memory before repeated DB operations:", memBefore); // Perform many database operations for (let i = 0; i < 20; i++) { @@ -210,7 +215,9 @@ describe('Memory Leak Detection Tests', () => { // Load from database const loadedTree = await processor.loadIntoTree(dbPath); - expect(Object.keys(loadedTree.pages).length).toBe(Object.keys(testTree.pages).length); + expect(Object.keys(loadedTree.pages).length).toBe( + Object.keys(testTree.pages).length, + ); // Extract texts const texts = await processor.extractTexts(dbPath); @@ -226,7 +233,7 @@ describe('Memory Leak Detection Tests', () => { forceGC(); const memAfter = getMemoryUsage(); - console.log('Memory after repeated DB operations:', memAfter); + console.log("Memory after repeated DB operations:", memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Memory increase: ${memoryIncrease}MB`); @@ -234,7 +241,7 @@ describe('Memory Leak Detection Tests', () => { expect(memoryIncrease).toBeLessThan(25); // Less than 25MB increase }); - it('should properly close database connections', async () => { + it("should properly close database connections", async () => { const processor = new SnapProcessor(); const testTree = createTestTree(1, 5); @@ -270,12 +277,12 @@ describe('Memory Leak Detection Tests', () => { }); }); - describe('Large Data Memory Tests', () => { - it('should handle large trees without excessive memory retention', async () => { + describe("Large Data Memory Tests", () => { + it("should handle large trees without excessive memory retention", async () => { const processor = new DotProcessor(); const memBefore = getMemoryUsage(); - console.log('Memory before large tree test:', memBefore); + console.log("Memory before large tree test:", memBefore); // Create and process large trees for (let i = 0; i < 5; i++) { @@ -295,7 +302,7 @@ describe('Memory Leak Detection Tests', () => { } const memAfter = getMemoryUsage(); - console.log('Memory after large tree test:', memAfter); + console.log("Memory after large tree test:", memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Large tree memory increase: ${memoryIncrease}MB`); @@ -303,16 +310,16 @@ describe('Memory Leak Detection Tests', () => { expect(memoryIncrease).toBeLessThan(30); // Less than 30MB increase }); - it('should handle large translation maps without memory leaks', async () => { + it("should handle large translation maps without memory leaks", async () => { const processor = new DotProcessor(); // Create content with many nodes - const lines = ['digraph G {']; + const lines = ["digraph G {"]; for (let i = 0; i < 200; i++) { lines.push(` node${i} [label="Text ${i}"];`); } - lines.push('}'); - const largeContent = lines.join('\n'); + lines.push("}"); + const largeContent = lines.join("\n"); // Create large translation map const largeTranslations = new Map(); @@ -321,7 +328,7 @@ describe('Memory Leak Detection Tests', () => { } const memBefore = getMemoryUsage(); - console.log('Memory before large translation test:', memBefore); + console.log("Memory before large translation test:", memBefore); // Perform translation multiple times for (let i = 0; i < 5; i++) { @@ -329,16 +336,16 @@ describe('Memory Leak Detection Tests', () => { const result = await processor.processTexts( Buffer.from(largeContent), largeTranslations, - outputPath + outputPath, ); expect(Buffer.from(result)).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); // Verify some translations - const translatedContent = Buffer.from(result).toString('utf8'); - expect(translatedContent).toContain('Texto 0'); - expect(translatedContent).toContain('Texto 199'); + const translatedContent = Buffer.from(result).toString("utf8"); + expect(translatedContent).toContain("Texto 0"); + expect(translatedContent).toContain("Texto 199"); // Clean up fs.unlinkSync(outputPath); @@ -347,7 +354,7 @@ describe('Memory Leak Detection Tests', () => { } const memAfter = getMemoryUsage(); - console.log('Memory after large translation test:', memAfter); + console.log("Memory after large translation test:", memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Large translation memory increase: ${memoryIncrease}MB`); @@ -356,8 +363,8 @@ describe('Memory Leak Detection Tests', () => { }); }); - describe('Long-Running Operation Memory Tests', () => { - it('should maintain stable memory during extended operations', async () => { + describe("Long-Running Operation Memory Tests", () => { + it("should maintain stable memory during extended operations", async () => { const processor = new DotProcessor(); const testContent = 'digraph G { test [label="Extended Test"]; }'; @@ -389,26 +396,30 @@ describe('Memory Leak Detection Tests', () => { const endTime = performance.now(); const totalTime = endTime - startTime; - console.log(`Completed ${operationCount} operations in ${totalTime.toFixed(2)}ms`); - console.log('Memory snapshots:', memorySnapshots); + console.log( + `Completed ${operationCount} operations in ${totalTime.toFixed(2)}ms`, + ); + console.log("Memory snapshots:", memorySnapshots); // Memory should remain relatively stable const maxMemory = Math.max(...memorySnapshots); const minMemory = Math.min(...memorySnapshots); const memoryVariation = maxMemory - minMemory; - console.log(`Memory variation: ${memoryVariation}MB (${minMemory}MB - ${maxMemory}MB)`); + console.log( + `Memory variation: ${memoryVariation}MB (${minMemory}MB - ${maxMemory}MB)`, + ); // Memory variation should be reasonable expect(memoryVariation).toBeLessThan(50); // Allow variance on CI }); - it('should clean up temporary resources properly', async () => { + it("should clean up temporary resources properly", async () => { const processor = new SnapProcessor(); const memBefore = getMemoryUsage(); // eslint-disable-next-line @typescript-eslint/no-var-requires - const tempFilesBefore = fs.readdirSync(require('os').tmpdir()).length; + const tempFilesBefore = fs.readdirSync(require("os").tmpdir()).length; // Perform operations that create temporary files for (let i = 0; i < 10; i++) { @@ -437,13 +448,13 @@ describe('Memory Leak Detection Tests', () => { setTimeout(() => { const memAfter = getMemoryUsage(); // eslint-disable-next-line @typescript-eslint/no-var-requires - const tempFilesAfter = fs.readdirSync(require('os').tmpdir()).length; + const tempFilesAfter = fs.readdirSync(require("os").tmpdir()).length; const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; const tempFileIncrease = tempFilesAfter - tempFilesBefore; console.log( - `Temp cleanup - Memory: +${memoryIncrease}MB, Temp files: +${tempFileIncrease}` + `Temp cleanup - Memory: +${memoryIncrease}MB, Temp files: +${tempFileIncrease}`, ); expect(memoryIncrease).toBeLessThan(20); diff --git a/test/morphology.test.ts b/test/morphology.test.ts index 5641604..2d7df53 100644 --- a/test/morphology.test.ts +++ b/test/morphology.test.ts @@ -1,302 +1,302 @@ -import { MorphologyEngine } from '../src/utilities/analytics/morphology/engine'; -import { MorphRuleSet } from '../src/utilities/analytics/morphology/types'; +import { MorphologyEngine } from "../src/utilities/analytics/morphology/engine"; +import { MorphRuleSet } from "../src/utilities/analytics/morphology/types"; -describe('MorphologyEngine', () => { - describe('built-in English rules', () => { +describe("MorphologyEngine", () => { + describe("built-in English rules", () => { let engine: MorphologyEngine; beforeEach(() => { - engine = new MorphologyEngine('en-gb'); + engine = new MorphologyEngine("en-gb"); }); - describe('irregular verbs', () => { - test('go -> goes, going, gone, went', () => { - const forms = engine.inflect('go', 'Verb'); - expect(forms).toContain('goes'); - expect(forms).toContain('going'); - expect(forms).toContain('gone'); - expect(forms).toContain('went'); - expect(forms).not.toContain('goed'); + describe("irregular verbs", () => { + test("go -> goes, going, gone, went", () => { + const forms = engine.inflect("go", "Verb"); + expect(forms).toContain("goes"); + expect(forms).toContain("going"); + expect(forms).toContain("gone"); + expect(forms).toContain("went"); + expect(forms).not.toContain("goed"); }); - test('be -> is, am, are, was, were, been, being', () => { - const forms = engine.inflect('be', 'Verb'); - expect(forms).toContain('is'); - expect(forms).toContain('am'); - expect(forms).toContain('are'); - expect(forms).toContain('was'); - expect(forms).toContain('were'); - expect(forms).toContain('been'); - expect(forms).toContain('being'); + test("be -> is, am, are, was, were, been, being", () => { + const forms = engine.inflect("be", "Verb"); + expect(forms).toContain("is"); + expect(forms).toContain("am"); + expect(forms).toContain("are"); + expect(forms).toContain("was"); + expect(forms).toContain("were"); + expect(forms).toContain("been"); + expect(forms).toContain("being"); }); - test('have -> has, had, having', () => { - const forms = engine.inflect('have', 'Verb'); - expect(forms).toContain('has'); - expect(forms).toContain('had'); - expect(forms).toContain('having'); + test("have -> has, had, having", () => { + const forms = engine.inflect("have", "Verb"); + expect(forms).toContain("has"); + expect(forms).toContain("had"); + expect(forms).toContain("having"); }); - test('do -> does, did, done, doing', () => { - const forms = engine.inflect('do', 'Verb'); - expect(forms).toContain('does'); - expect(forms).toContain('did'); - expect(forms).toContain('done'); - expect(forms).toContain('doing'); + test("do -> does, did, done, doing", () => { + const forms = engine.inflect("do", "Verb"); + expect(forms).toContain("does"); + expect(forms).toContain("did"); + expect(forms).toContain("done"); + expect(forms).toContain("doing"); }); - test('say -> says, said, saying', () => { - const forms = engine.inflect('say', 'Verb'); - expect(forms).toContain('says'); - expect(forms).toContain('said'); - expect(forms).toContain('saying'); + test("say -> says, said, saying", () => { + const forms = engine.inflect("say", "Verb"); + expect(forms).toContain("says"); + expect(forms).toContain("said"); + expect(forms).toContain("saying"); }); - test('get -> gets, got, getting', () => { - const forms = engine.inflect('get', 'Verb'); - expect(forms).toContain('gets'); - expect(forms).toContain('got'); - expect(forms).toContain('getting'); + test("get -> gets, got, getting", () => { + const forms = engine.inflect("get", "Verb"); + expect(forms).toContain("gets"); + expect(forms).toContain("got"); + expect(forms).toContain("getting"); }); - test('take -> takes, took, taken, taking', () => { - const forms = engine.inflect('take', 'Verb'); - expect(forms).toContain('takes'); - expect(forms).toContain('took'); - expect(forms).toContain('taken'); - expect(forms).toContain('taking'); + test("take -> takes, took, taken, taking", () => { + const forms = engine.inflect("take", "Verb"); + expect(forms).toContain("takes"); + expect(forms).toContain("took"); + expect(forms).toContain("taken"); + expect(forms).toContain("taking"); }); - test('come -> comes, came, coming', () => { - const forms = engine.inflect('come', 'Verb'); - expect(forms).toContain('comes'); - expect(forms).toContain('came'); - expect(forms).toContain('coming'); + test("come -> comes, came, coming", () => { + const forms = engine.inflect("come", "Verb"); + expect(forms).toContain("comes"); + expect(forms).toContain("came"); + expect(forms).toContain("coming"); }); }); - describe('regular verbs', () => { - test('walk -> walks, walked, walking', () => { - const forms = engine.inflect('walk', 'Verb'); - expect(forms).toContain('walks'); - expect(forms).toContain('walked'); - expect(forms).toContain('walking'); + describe("regular verbs", () => { + test("walk -> walks, walked, walking", () => { + const forms = engine.inflect("walk", "Verb"); + expect(forms).toContain("walks"); + expect(forms).toContain("walked"); + expect(forms).toContain("walking"); }); - test('watch -> watches, watched, watching', () => { - const forms = engine.inflect('watch', 'Verb'); - expect(forms).toContain('watches'); - expect(forms).toContain('watched'); - expect(forms).toContain('watching'); + test("watch -> watches, watched, watching", () => { + const forms = engine.inflect("watch", "Verb"); + expect(forms).toContain("watches"); + expect(forms).toContain("watched"); + expect(forms).toContain("watching"); }); - test('carry -> carries, carried, carrying', () => { - const forms = engine.inflect('carry', 'Verb'); - expect(forms).toContain('carries'); - expect(forms).toContain('carried'); - expect(forms).toContain('carrying'); + test("carry -> carries, carried, carrying", () => { + const forms = engine.inflect("carry", "Verb"); + expect(forms).toContain("carries"); + expect(forms).toContain("carried"); + expect(forms).toContain("carrying"); }); - test('like -> likes, liked, liking', () => { - const forms = engine.inflect('like', 'Verb'); - expect(forms).toContain('likes'); - expect(forms).toContain('liked'); - expect(forms).toContain('liking'); + test("like -> likes, liked, liking", () => { + const forms = engine.inflect("like", "Verb"); + expect(forms).toContain("likes"); + expect(forms).toContain("liked"); + expect(forms).toContain("liking"); }); }); - describe('irregular nouns', () => { - test('child -> children', () => { - const forms = engine.inflect('child', 'Noun'); - expect(forms).toContain('children'); + describe("irregular nouns", () => { + test("child -> children", () => { + const forms = engine.inflect("child", "Noun"); + expect(forms).toContain("children"); }); - test('person -> people', () => { - const forms = engine.inflect('person', 'Noun'); - expect(forms).toContain('people'); + test("person -> people", () => { + const forms = engine.inflect("person", "Noun"); + expect(forms).toContain("people"); }); - test('mouse -> mice', () => { - const forms = engine.inflect('mouse', 'Noun'); - expect(forms).toContain('mice'); + test("mouse -> mice", () => { + const forms = engine.inflect("mouse", "Noun"); + expect(forms).toContain("mice"); }); - test('foot -> feet', () => { - const forms = engine.inflect('foot', 'Noun'); - expect(forms).toContain('feet'); + test("foot -> feet", () => { + const forms = engine.inflect("foot", "Noun"); + expect(forms).toContain("feet"); }); - test('sheep -> sheep (no change)', () => { - const forms = engine.inflect('sheep', 'Noun'); - expect(forms).toContain('sheep'); + test("sheep -> sheep (no change)", () => { + const forms = engine.inflect("sheep", "Noun"); + expect(forms).toContain("sheep"); expect(forms.length).toBe(1); }); }); - describe('regular nouns', () => { - test('book -> books', () => { - const forms = engine.inflect('book', 'Noun'); - expect(forms).toContain('books'); + describe("regular nouns", () => { + test("book -> books", () => { + const forms = engine.inflect("book", "Noun"); + expect(forms).toContain("books"); }); - test('thing -> things', () => { - const forms = engine.inflect('thing', 'Noun'); - expect(forms).toContain('things'); + test("thing -> things", () => { + const forms = engine.inflect("thing", "Noun"); + expect(forms).toContain("things"); }); - test('story -> stories', () => { - const forms = engine.inflect('story', 'Noun'); - expect(forms).toContain('stories'); + test("story -> stories", () => { + const forms = engine.inflect("story", "Noun"); + expect(forms).toContain("stories"); }); - test('bus -> buses', () => { - const forms = engine.inflect('bus', 'Noun'); - expect(forms).toContain('buses'); + test("bus -> buses", () => { + const forms = engine.inflect("bus", "Noun"); + expect(forms).toContain("buses"); }); }); - describe('adjectives', () => { - test('good -> better, best', () => { - const forms = engine.inflect('good', 'Adjective'); - expect(forms).toContain('better'); - expect(forms).toContain('best'); + describe("adjectives", () => { + test("good -> better, best", () => { + const forms = engine.inflect("good", "Adjective"); + expect(forms).toContain("better"); + expect(forms).toContain("best"); }); - test('bad -> worse, worst', () => { - const forms = engine.inflect('bad', 'Adjective'); - expect(forms).toContain('worse'); - expect(forms).toContain('worst'); + test("bad -> worse, worst", () => { + const forms = engine.inflect("bad", "Adjective"); + expect(forms).toContain("worse"); + expect(forms).toContain("worst"); }); - test('big -> bigger, biggest', () => { - const forms = engine.inflect('big', 'Adjective'); - expect(forms).toContain('bigger'); - expect(forms).toContain('biggest'); + test("big -> bigger, biggest", () => { + const forms = engine.inflect("big", "Adjective"); + expect(forms).toContain("bigger"); + expect(forms).toContain("biggest"); }); - test('happy -> happier, happiest', () => { - const forms = engine.inflect('happy', 'Adjective'); - expect(forms).toContain('happier'); - expect(forms).toContain('happiest'); + test("happy -> happier, happiest", () => { + const forms = engine.inflect("happy", "Adjective"); + expect(forms).toContain("happier"); + expect(forms).toContain("happiest"); }); }); - describe('pronouns', () => { - test('I -> me, my, mine', () => { - const forms = engine.inflect('I', 'Pronoun'); - expect(forms).toContain('me'); - expect(forms).toContain('my'); - expect(forms).toContain('mine'); + describe("pronouns", () => { + test("I -> me, my, mine", () => { + const forms = engine.inflect("I", "Pronoun"); + expect(forms).toContain("me"); + expect(forms).toContain("my"); + expect(forms).toContain("mine"); }); - test('they -> them, their, theirs', () => { - const forms = engine.inflect('they', 'Pronoun'); - expect(forms).toContain('them'); - expect(forms).toContain('their'); - expect(forms).toContain('theirs'); + test("they -> them, their, theirs", () => { + const forms = engine.inflect("they", "Pronoun"); + expect(forms).toContain("them"); + expect(forms).toContain("their"); + expect(forms).toContain("theirs"); }); }); }); - describe('isFormOf', () => { + describe("isFormOf", () => { let engine: MorphologyEngine; beforeEach(() => { - engine = new MorphologyEngine('en-gb'); + engine = new MorphologyEngine("en-gb"); }); test('detects "went" as form of "go"', () => { - expect(engine.isFormOf('went', 'go', 'Verb')).toBe(true); + expect(engine.isFormOf("went", "go", "Verb")).toBe(true); }); test('detects "going" as form of "go"', () => { - expect(engine.isFormOf('going', 'go', 'Verb')).toBe(true); + expect(engine.isFormOf("going", "go", "Verb")).toBe(true); }); test('detects "children" as form of "child"', () => { - expect(engine.isFormOf('children', 'child', 'Noun')).toBe(true); + expect(engine.isFormOf("children", "child", "Noun")).toBe(true); }); - test('does not match unrelated words', () => { - expect(engine.isFormOf('running', 'go', 'Verb')).toBe(false); + test("does not match unrelated words", () => { + expect(engine.isFormOf("running", "go", "Verb")).toBe(false); }); - test('case insensitive', () => { - expect(engine.isFormOf('Went', 'Go', 'Verb')).toBe(true); - expect(engine.isFormOf('WENT', 'go', 'Verb')).toBe(true); + test("case insensitive", () => { + expect(engine.isFormOf("Went", "Go", "Verb")).toBe(true); + expect(engine.isFormOf("WENT", "go", "Verb")).toBe(true); }); }); - describe('expandVocabulary', () => { + describe("expandVocabulary", () => { let engine: MorphologyEngine; beforeEach(() => { - engine = new MorphologyEngine('en-gb'); + engine = new MorphologyEngine("en-gb"); }); - test('expands verb buttons', () => { + test("expands verb buttons", () => { const buttons = [ - { label: 'go', pos: 'Verb' }, - { label: 'book', pos: 'Noun' }, + { label: "go", pos: "Verb" }, + { label: "book", pos: "Noun" }, ]; const result = engine.expandVocabulary(buttons); - expect(result.get('go')).toContain('goes'); - expect(result.get('go')).toContain('going'); - expect(result.get('go')).toContain('went'); - expect(result.get('go')).toContain('gone'); - expect(result.get('book')).toContain('books'); + expect(result.get("go")).toContain("goes"); + expect(result.get("go")).toContain("going"); + expect(result.get("go")).toContain("went"); + expect(result.get("go")).toContain("gone"); + expect(result.get("book")).toContain("books"); }); - test('skips Unknown and Ignore POS', () => { + test("skips Unknown and Ignore POS", () => { const buttons = [ - { label: 'hello', pos: 'Unknown' }, - { label: 'world', pos: 'Ignore' }, + { label: "hello", pos: "Unknown" }, + { label: "world", pos: "Ignore" }, ]; const result = engine.expandVocabulary(buttons); - expect(result.has('hello')).toBe(false); - expect(result.has('world')).toBe(false); + expect(result.has("hello")).toBe(false); + expect(result.has("world")).toBe(false); }); - test('skips buttons without POS', () => { - const buttons = [{ label: 'hello' }]; + test("skips buttons without POS", () => { + const buttons = [{ label: "hello" }]; const result = engine.expandVocabulary(buttons); - expect(result.has('hello')).toBe(false); + expect(result.has("hello")).toBe(false); }); }); - describe('custom rule set', () => { - test('accepts custom MorphRuleSet', () => { + describe("custom rule set", () => { + test("accepts custom MorphRuleSet", () => { const customRules: MorphRuleSet = { - locale: 'test', + locale: "test", version: 1, irregular: {}, regular: { Verb: { - past: [{ match: '$', replace: 'ed' }], + past: [{ match: "$", replace: "ed" }], }, }, }; const engine = new MorphologyEngine(customRules); - const forms = engine.inflect('walk', 'Verb'); - expect(forms).toContain('walked'); + const forms = engine.inflect("walk", "Verb"); + expect(forms).toContain("walked"); }); }); - describe('unknown locale', () => { - test('returns empty for unsupported locale', () => { - const engine = new MorphologyEngine('xx-xx'); - const forms = engine.inflect('go', 'Verb'); + describe("unknown locale", () => { + test("returns empty for unsupported locale", () => { + const engine = new MorphologyEngine("xx-xx"); + const forms = engine.inflect("go", "Verb"); expect(forms).toEqual([]); }); }); - describe('caching', () => { - test('caches results for same base+pos', () => { - const engine = new MorphologyEngine('en-gb'); - const first = engine.inflect('go', 'Verb'); - const second = engine.inflect('go', 'Verb'); + describe("caching", () => { + test("caches results for same base+pos", () => { + const engine = new MorphologyEngine("en-gb"); + const first = engine.inflect("go", "Verb"); + const second = engine.inflect("go", "Verb"); expect(first).toBe(second); }); }); diff --git a/test/obfProcessor.roundtrip.test.ts b/test/obfProcessor.roundtrip.test.ts index 7350d29..b63aa01 100644 --- a/test/obfProcessor.roundtrip.test.ts +++ b/test/obfProcessor.roundtrip.test.ts @@ -1,16 +1,16 @@ // Round-trip test for OBFProcessor: load, save, reload, and compare structure -import fs from 'fs'; -import path from 'path'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; +import fs from "fs"; +import path from "path"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; -jest.setTimeout(process.platform === 'win32' ? 60000 : 30000); +jest.setTimeout(process.platform === "win32" ? 60000 : 30000); -describe('OBFProcessor round-trip', () => { - const obfPath: string = path.join(__dirname, 'assets/obf/example.obf'); - const obzPath: string = path.join(__dirname, 'assets/obz/example.obz'); - const outObfPath: string = path.join(__dirname, 'out.obf'); - const outObzPath: string = path.join(__dirname, 'out.obz'); +describe("OBFProcessor round-trip", () => { + const obfPath: string = path.join(__dirname, "assets/obf/example.obf"); + const obzPath: string = path.join(__dirname, "assets/obz/example.obz"); + const outObfPath: string = path.join(__dirname, "out.obf"); + const outObzPath: string = path.join(__dirname, "out.obz"); afterAll(async () => { [outObfPath, outObzPath].forEach((file) => { @@ -18,9 +18,9 @@ describe('OBFProcessor round-trip', () => { }); }); - it('round-trips OBF JSON without losing pages or navigation', async () => { + it("round-trips OBF JSON without losing pages or navigation", async () => { if (!fs.existsSync(obfPath)) { - console.log('Skipping OBF test - example file not found'); + console.log("Skipping OBF test - example file not found"); return; } @@ -33,7 +33,9 @@ describe('OBFProcessor round-trip', () => { const tree2: AACTree = await processor.loadIntoTree(outObfPath); // Compare basic structure - expect(Object.keys(tree1.pages).length).toBe(Object.keys(tree2.pages).length); + expect(Object.keys(tree1.pages).length).toBe( + Object.keys(tree2.pages).length, + ); // Compare metadata expect(tree2.metadata.name).toBe(tree1.metadata.name); @@ -57,9 +59,9 @@ describe('OBFProcessor round-trip', () => { } }); - it('round-trips OBZ (zip) format without losing data', async () => { + it("round-trips OBZ (zip) format without losing data", async () => { if (!fs.existsSync(obzPath)) { - console.log('Skipping OBZ test - example file not found'); + console.log("Skipping OBZ test - example file not found"); return; } @@ -73,29 +75,31 @@ describe('OBFProcessor round-trip', () => { // Compare structure expect(Object.keys(tree2.pages).length).toBeGreaterThan(0); - expect(Object.keys(tree1.pages).length).toBe(Object.keys(tree2.pages).length); + expect(Object.keys(tree1.pages).length).toBe( + Object.keys(tree2.pages).length, + ); // Compare metadata from root board expect(tree2.metadata.name).toBe(tree1.metadata.name); expect(tree2.metadata.locale).toBe(tree1.metadata.locale); }); - it('can save and load a simple constructed tree', async () => { + it("can save and load a simple constructed tree", async () => { const processor = new ObfProcessor(); // Create a simple tree programmatically const tree1 = new AACTree(); const page = new AACPage({ - id: 'test-page', - name: 'Test Page', + id: "test-page", + name: "Test Page", buttons: [], }); const button = new AACButton({ - id: 'test-button', - label: 'Test Button', - message: 'Hello World', - type: 'SPEAK', + id: "test-button", + label: "Test Button", + message: "Hello World", + type: "SPEAK", }); page.addButton(button); @@ -107,37 +111,37 @@ describe('OBFProcessor round-trip', () => { // Verify structure expect(Object.keys(tree2.pages)).toHaveLength(1); - const reloadedPage = tree2.pages['test-page']; + const reloadedPage = tree2.pages["test-page"]; expect(reloadedPage).toBeDefined(); - expect(reloadedPage.name).toBe('Test Page'); + expect(reloadedPage.name).toBe("Test Page"); expect(reloadedPage.buttons).toHaveLength(1); - expect(reloadedPage.buttons[0].label).toBe('Test Button'); + expect(reloadedPage.buttons[0].label).toBe("Test Button"); }); - it('includes required OBF metadata fields when saving a tree', async () => { + it("includes required OBF metadata fields when saving a tree", async () => { const processor = new ObfProcessor(); const tree = new AACTree(); const page = new AACPage({ - id: 'meta-page', - name: 'Meta Page', + id: "meta-page", + name: "Meta Page", grid: [ [null, null], [null, null], ], - locale: 'en', + locale: "en", }); const AACButtonCtor = AACButton; const buttonA = new AACButtonCtor({ - id: 'btn-a', - label: 'A', - message: 'A', + id: "btn-a", + label: "A", + message: "A", }); const buttonB = new AACButtonCtor({ - id: 'btn-b', - label: 'B', - message: 'B', + id: "btn-b", + label: "B", + message: "B", }); page.addButton(buttonA); @@ -151,16 +155,16 @@ describe('OBFProcessor round-trip', () => { tree.rootId = page.id; await processor.saveFromTree(tree, outObfPath); - const savedObf = JSON.parse(fs.readFileSync(outObfPath, 'utf8')); + const savedObf = JSON.parse(fs.readFileSync(outObfPath, "utf8")); - expect(savedObf.format).toBe('open-board-0.1'); - expect(savedObf.description_html).toBe('Meta Page'); - expect(savedObf.locale).toBe('en'); + expect(savedObf.format).toBe("open-board-0.1"); + expect(savedObf.description_html).toBe("Meta Page"); + expect(savedObf.locale).toBe("en"); expect(savedObf.grid).toEqual({ rows: 2, columns: 2, order: [ - ['btn-a', 'btn-b'], + ["btn-a", "btn-b"], [null, null], ], }); diff --git a/test/obfProcessor.test.ts b/test/obfProcessor.test.ts index 7a25646..6ae8e53 100644 --- a/test/obfProcessor.test.ts +++ b/test/obfProcessor.test.ts @@ -1,15 +1,15 @@ // Test for OBFProcessor (Open Board Format/Zip) // Test for OBFProcessor (Open Board Format/Zip) -import path from 'path'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { AACTree } from '../src/core/treeStructure'; +import path from "path"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { AACTree } from "../src/core/treeStructure"; jest.setTimeout(30000); -describe('OBFProcessor', () => { - const obzPath: string = path.join(__dirname, 'assets/obz/example.obz'); +describe("OBFProcessor", () => { + const obzPath: string = path.join(__dirname, "assets/obz/example.obz"); - it('can process .obz (zip) files with manifest', async () => { + it("can process .obz (zip) files with manifest", async () => { const processor = new ObfProcessor(); const tree: AACTree = await processor.loadIntoTree(obzPath); expect(tree).toBeInstanceOf(AACTree); @@ -19,7 +19,7 @@ describe('OBFProcessor', () => { let navFound = false; tree.traverse((page) => { page.buttons.forEach((btn) => { - if (btn.type === 'NAVIGATE' && btn.targetPageId) navFound = true; + if (btn.type === "NAVIGATE" && btn.targetPageId) navFound = true; }); }); expect(navFound).toBe(true); @@ -30,7 +30,7 @@ describe('OBFProcessor', () => { const imgBtn = rootPage.buttons.find((b: any) => b.image); if (imgBtn) { // Image should now be a data URL string (from embedded OBZ images) - expect(typeof imgBtn.image).toBe('string'); + expect(typeof imgBtn.image).toBe("string"); expect(imgBtn.image).toMatch(/^data:image\//); // resolvedImageEntry should also be set expect(imgBtn.resolvedImageEntry).toBe(imgBtn.image); @@ -38,12 +38,15 @@ describe('OBFProcessor', () => { } }); - describe('saveModifiedTree', () => { - const tempOutputPath = path.join(__dirname, 'temp_obz_modified.obz'); - const tempSaveFromTreePath = path.join(__dirname, 'temp_obz_saveFromTree.obz'); + describe("saveModifiedTree", () => { + const tempOutputPath = path.join(__dirname, "temp_obz_modified.obz"); + const tempSaveFromTreePath = path.join( + __dirname, + "temp_obz_saveFromTree.obz", + ); afterEach(async () => { - const fs = await import('fs'); + const fs = await import("fs"); if (fs.existsSync(tempOutputPath)) { fs.unlinkSync(tempOutputPath); } @@ -52,9 +55,9 @@ describe('OBFProcessor', () => { } }); - it('should preserve original file size better than saveFromTree for OBZ files', async () => { + it("should preserve original file size better than saveFromTree for OBZ files", async () => { const processor = new ObfProcessor(); - const fs = await import('fs'); + const fs = await import("fs"); // Load the original file const tree = await processor.loadIntoTree(obzPath); @@ -75,7 +78,7 @@ describe('OBFProcessor', () => { expect(modifiedSize / originalSize).toBeGreaterThan(0.8); }); - it('should produce a valid loadable OBZ file', async () => { + it("should produce a valid loadable OBZ file", async () => { const processor = new ObfProcessor(); // Load the original file @@ -93,9 +96,9 @@ describe('OBFProcessor', () => { expect(savedTree.rootId).toBe(tree.rootId); }); - it('should handle empty tree by copying original', async () => { + it("should handle empty tree by copying original", async () => { const processor = new ObfProcessor(); - const fs = await import('fs'); + const fs = await import("fs"); // Create an empty tree const emptyTree: AACTree = { @@ -105,7 +108,7 @@ describe('OBFProcessor', () => { dashboardId: null, metadata: {}, addPage() { - throw new Error('Not implemented'); + throw new Error("Not implemented"); }, getPage() { return undefined; diff --git a/test/obfsetProcessor.test.ts b/test/obfsetProcessor.test.ts index b35c0f7..6b87f4e 100644 --- a/test/obfsetProcessor.test.ts +++ b/test/obfsetProcessor.test.ts @@ -1,70 +1,70 @@ -import path from 'path'; -import fs from 'fs'; -import { ObfsetProcessor } from '../src/processors/obfsetProcessor'; -import { AACTree } from '../src/core/treeStructure'; +import path from "path"; +import fs from "fs"; +import { ObfsetProcessor } from "../src/processors/obfsetProcessor"; +import { AACTree } from "../src/core/treeStructure"; -describe('ObfsetProcessor', () => { - const obfsetPath = path.join(__dirname, 'fixtures/example.obfset'); +describe("ObfsetProcessor", () => { + const obfsetPath = path.join(__dirname, "fixtures/example.obfset"); - it('can load .obfset files into a tree', async () => { + it("can load .obfset files into a tree", async () => { const processor = new ObfsetProcessor(); const tree = await processor.loadIntoTree(obfsetPath); expect(tree).toBeInstanceOf(AACTree); - expect(tree.rootId).toBe('root'); + expect(tree.rootId).toBe("root"); expect(Object.keys(tree.pages).length).toBe(2); - const rootPage = tree.getPage('root'); + const rootPage = tree.getPage("root"); if (!rootPage) { - throw new Error('Expected root page to exist'); + throw new Error("Expected root page to exist"); } - expect(rootPage.name).toBe('Home'); - expect(rootPage.grid[0][0]?.label).toBe('Hello'); - expect(rootPage.grid[0][0]?.semantic_id).toBe('greeting-1'); + expect(rootPage.name).toBe("Home"); + expect(rootPage.grid[0][0]?.label).toBe("Hello"); + expect(rootPage.grid[0][0]?.semantic_id).toBe("greeting-1"); expect(rootPage.buttons.length).toBe(2); - const page2 = tree.getPage('page2'); + const page2 = tree.getPage("page2"); if (!page2) { - throw new Error('Expected page2 to exist'); + throw new Error("Expected page2 to exist"); } - expect(page2.parentId).toBe('root'); - expect(page2.grid[0][0]?.label).toBe('World'); - expect(page2.grid[0][0]?.clone_id).toBe('world-1'); + expect(page2.parentId).toBe("root"); + expect(page2.grid[0][0]?.label).toBe("World"); + expect(page2.grid[0][0]?.clone_id).toBe("world-1"); }); - it('can load .obfset from a Buffer', async () => { + it("can load .obfset from a Buffer", async () => { const processor = new ObfsetProcessor(); const buffer = fs.readFileSync(obfsetPath); const tree = await processor.loadIntoTree(buffer); expect(tree).toBeInstanceOf(AACTree); - expect(tree.rootId).toBe('root'); + expect(tree.rootId).toBe("root"); }); - it('can extract texts from .obfset', async () => { + it("can extract texts from .obfset", async () => { const processor = new ObfsetProcessor(); const texts = await processor.extractTexts(obfsetPath); - expect(texts).toContain('Hello'); - expect(texts).toContain('Go To Page 2'); - expect(texts).toContain('World'); - expect(texts).toContain('Home'); - expect(texts).toContain('Page 2'); + expect(texts).toContain("Hello"); + expect(texts).toContain("Go To Page 2"); + expect(texts).toContain("World"); + expect(texts).toContain("Home"); + expect(texts).toContain("Page 2"); }); - it('throws error for unsupported operations', async () => { + it("throws error for unsupported operations", async () => { const processor = new ObfsetProcessor(); await expect(async () => { - await processor.processTexts(obfsetPath, new Map(), 'out.obfset'); + await processor.processTexts(obfsetPath, new Map(), "out.obfset"); }).rejects.toThrow(); await expect(async () => { - await processor.saveFromTree(new AACTree(), 'out.obfset'); + await processor.saveFromTree(new AACTree(), "out.obfset"); }).rejects.toThrow(); }); - it('correctly reports supported extension', async () => { + it("correctly reports supported extension", async () => { const processor = new ObfsetProcessor(); - expect(processor.supportsExtension('.obfset')).toBe(true); - expect(processor.supportsExtension('.obf')).toBe(false); + expect(processor.supportsExtension(".obfset")).toBe(true); + expect(processor.supportsExtension(".obf")).toBe(false); }); }); diff --git a/test/obl.test.ts b/test/obl.test.ts index 7f513a3..8de8a31 100644 --- a/test/obl.test.ts +++ b/test/obl.test.ts @@ -1,128 +1,139 @@ -import { OblUtil, OblAnonymizer } from '../src/utilities/analytics/index'; -import * as fs from 'fs'; -import * as path from 'path'; -import { AACSemanticIntent, AACSemanticCategory } from '../src/core/treeStructure'; - -describe('OBL Support', () => { +import { OblUtil, OblAnonymizer } from "../src/utilities/analytics/index"; +import * as fs from "fs"; +import * as path from "path"; +import { + AACSemanticIntent, + AACSemanticCategory, +} from "../src/core/treeStructure"; + +describe("OBL Support", () => { const sampleOBL = { - format: 'open-board-log-0.1', - user_id: 'test-user', - source: 'test-source', + format: "open-board-log-0.1", + user_id: "test-user", + source: "test-source", sessions: [ { - id: 'session-1', - type: 'log' as const, - started: '2023-01-01T10:00:00.000Z', - ended: '2023-01-01T10:05:00.000Z', + id: "session-1", + type: "log" as const, + started: "2023-01-01T10:00:00.000Z", + ended: "2023-01-01T10:05:00.000Z", events: [ { - id: 'event-1', - type: 'button' as const, - timestamp: '2023-01-01T10:01:00.000Z', - label: 'Hello', - vocalization: 'Hello there', - board_id: 'board-main', + id: "event-1", + type: "button" as const, + timestamp: "2023-01-01T10:01:00.000Z", + label: "Hello", + vocalization: "Hello there", + board_id: "board-main", }, { - id: 'event-2', - type: 'action' as const, - timestamp: '2023-01-01T10:02:00.000Z', - action: ':open_board', - destination_board_id: 'board-food', + id: "event-2", + type: "action" as const, + timestamp: "2023-01-01T10:02:00.000Z", + action: ":open_board", + destination_board_id: "board-food", }, { - id: 'event-3', - type: 'utterance' as const, - timestamp: '2023-01-01T10:03:00.000Z', - text: 'I want apple', + id: "event-3", + type: "utterance" as const, + timestamp: "2023-01-01T10:03:00.000Z", + text: "I want apple", }, ], }, ], }; - test('should parse OBL JSON with notice comment', () => { + test("should parse OBL JSON with notice comment", () => { const json = `/* This is a notice */\n${JSON.stringify(sampleOBL)}`; const parsed = OblUtil.parse(json); - expect(parsed.user_id).toBe('test-user'); + expect(parsed.user_id).toBe("test-user"); expect(parsed.sessions[0].events).toHaveLength(3); }); - test('should stringify OBL with notice comment', () => { + test("should stringify OBL with notice comment", () => { const json = OblUtil.stringify(sampleOBL as any); - expect(json).toContain('/* NOTICE:'); + expect(json).toContain("/* NOTICE:"); expect(json).toContain('"user_id": "test-user"'); }); - test('should convert OBL to HistoryEntries', () => { + test("should convert OBL to HistoryEntries", () => { const entries = OblUtil.toHistoryEntries(sampleOBL as any); // Should have entries for 'Hello there', ':open_board', and 'I want apple' expect(entries).toHaveLength(3); - const helloEntry = entries.find((e) => e.content === 'Hello there'); + const helloEntry = entries.find((e) => e.content === "Hello there"); expect(helloEntry).toBeDefined(); - expect(helloEntry?.occurrences[0].type).toBe('button'); - expect(helloEntry?.occurrences[0].pageId).toBe('board-main'); + expect(helloEntry?.occurrences[0].type).toBe("button"); + expect(helloEntry?.occurrences[0].pageId).toBe("board-main"); - const uttEntry = entries.find((e) => e.content === 'I want apple'); + const uttEntry = entries.find((e) => e.content === "I want apple"); expect(uttEntry).toBeDefined(); - expect(uttEntry?.occurrences[0].type).toBe('utterance'); + expect(uttEntry?.occurrences[0].type).toBe("utterance"); }); - test('should maintain bidirectional mapping (OBL -> History -> OBL)', () => { + test("should maintain bidirectional mapping (OBL -> History -> OBL)", () => { const originalOBL = sampleOBL as any; const entries = OblUtil.toHistoryEntries(originalOBL); - const roundTripOBL = OblUtil.fromHistoryEntries(entries, 'test-user', 'test-source'); + const roundTripOBL = OblUtil.fromHistoryEntries( + entries, + "test-user", + "test-source", + ); - expect(roundTripOBL.user_id).toBe('test-user'); + expect(roundTripOBL.user_id).toBe("test-user"); expect(roundTripOBL.sessions[0].events).toHaveLength(3); // Check if the utterance was preserved - const uttEvent = roundTripOBL.sessions[0].events.find((e) => e.type === 'utterance'); + const uttEvent = roundTripOBL.sessions[0].events.find( + (e) => e.type === "utterance", + ); expect(uttEvent).toBeDefined(); - expect((uttEvent as any).text).toBe('I want apple'); + expect((uttEvent as any).text).toBe("I want apple"); // Check if the action was preserved and mapped back to :open_board // (HistoryEntry stores ':open_board' as content, fromHistoryEntries should see it) - const actionEvent = roundTripOBL.sessions[0].events.find((e) => e.type === 'action'); + const actionEvent = roundTripOBL.sessions[0].events.find( + (e) => e.type === "action", + ); expect(actionEvent).toBeDefined(); - expect((actionEvent as any).action).toBe(':open_board'); + expect((actionEvent as any).action).toBe(":open_board"); }); - test('should use semantic intents for mapping', () => { + test("should use semantic intents for mapping", () => { const entries = [ { - id: '1', - source: 'Grid', - content: 'Home', + id: "1", + source: "Grid", + content: "Home", occurrences: [ { - timestamp: new Date('2023-01-01T12:00:00Z'), + timestamp: new Date("2023-01-01T12:00:00Z"), intent: AACSemanticIntent.GO_HOME, category: AACSemanticCategory.NAVIGATION, - type: 'button' as const, + type: "button" as const, }, ], }, ]; - const obl = OblUtil.fromHistoryEntries(entries as any, 'user1'); + const obl = OblUtil.fromHistoryEntries(entries as any, "user1"); const event = obl.sessions[0].events[0] as any; - expect(event.type).toBe('action'); - expect(event.action).toBe(':home'); + expect(event.type).toBe("action"); + expect(event.action).toBe(":home"); }); - test('should anonymize data correctly', () => { + test("should anonymize data correctly", () => { const obl = JSON.parse(JSON.stringify(sampleOBL)) as any; - obl.user_name = 'Will Wade'; + obl.user_name = "Will Wade"; obl.sessions[0].events[0].geo = [51.5, -0.1]; const anonymized = OblAnonymizer.anonymize(obl, [ - 'timestamp_shift', - 'geolocation_masking', - 'name_masking', + "timestamp_shift", + "geolocation_masking", + "name_masking", ]); expect(anonymized.anonymized).toBe(true); @@ -133,35 +144,37 @@ describe('OBL Support', () => { const originalDate = new Date(obl.sessions[0].started).getTime(); const shiftedDate = new Date(anonymized.sessions[0].started).getTime(); expect(shiftedDate).not.toBe(originalDate); - expect(anonymized.sessions[0].started).toBe('2000-01-01T00:00:00.000Z'); + expect(anonymized.sessions[0].started).toBe("2000-01-01T00:00:00.000Z"); }); - test('should parse real OBLA data from dataset', () => { + test("should parse real OBLA data from dataset", () => { // Inlined sample data to ensure tests pass in CI even without extra files const oblaSample = { - format: 'open-board-log-0.1', - user_id: 'test-real-user', - source: 'coughdrop', + format: "open-board-log-0.1", + user_id: "test-real-user", + source: "coughdrop", sessions: [ { - id: 'session-1', - type: 'log', - started: '2000-01-24T01:15:27Z', - ended: '2000-01-24T01:15:32Z', + id: "session-1", + type: "log", + started: "2000-01-24T01:15:27Z", + ended: "2000-01-24T01:15:32Z", events: [ { - id: 'event-1', - timestamp: '2000-01-24T01:15:26Z', - type: 'action', - action: ':clear', + id: "event-1", + timestamp: "2000-01-24T01:15:26Z", + type: "action", + action: ":clear", }, { - id: 'event-2', - timestamp: '2000-05-15T00:30:52Z', - type: 'button', - label: 'Hello', - vocalization: 'Hello', - actions: [{ action: ':open_board', destination_board_id: 'board-2' }], + id: "event-2", + timestamp: "2000-05-15T00:30:52Z", + type: "button", + label: "Hello", + vocalization: "Hello", + actions: [ + { action: ":open_board", destination_board_id: "board-2" }, + ], }, ], anonymized: true, @@ -173,29 +186,38 @@ describe('OBL Support', () => { const content = JSON.stringify(oblaSample); const parsed = OblUtil.parse(content); - expect(parsed.format).toContain('open-board-log'); + expect(parsed.format).toContain("open-board-log"); expect(parsed.sessions.length).toBeGreaterThan(0); const history = OblUtil.toHistoryEntries(parsed); - const totalOriginalEvents = parsed.sessions.reduce((acc, s) => acc + s.events.length, 0); - const roundTrip = OblUtil.fromHistoryEntries(history, parsed.user_id, parsed.source); + const totalOriginalEvents = parsed.sessions.reduce( + (acc, s) => acc + s.events.length, + 0, + ); + const roundTrip = OblUtil.fromHistoryEntries( + history, + parsed.user_id, + parsed.source, + ); expect(roundTrip.sessions[0].events.length).toBe(totalOriginalEvents); }); - test('bulk test real OBLA files (first 10)', () => { - const oblaDir = path.join(__dirname, 'assets/obla'); + test("bulk test real OBLA files (first 10)", () => { + const oblaDir = path.join(__dirname, "assets/obla"); if (!fs.existsSync(oblaDir)) { - console.warn('Skipping bulk OBLA test - test/assets/obla directory not found'); + console.warn( + "Skipping bulk OBLA test - test/assets/obla directory not found", + ); return; } const files = fs .readdirSync(oblaDir) - .filter((f) => f.endsWith('.obla')) + .filter((f) => f.endsWith(".obla")) .slice(0, 10); for (const file of files) { - const content = fs.readFileSync(path.join(oblaDir, file), 'utf8'); + const content = fs.readFileSync(path.join(oblaDir, file), "utf8"); const parsed = OblUtil.parse(content); expect(parsed.sessions).toBeDefined(); diff --git a/test/opmlProcessor.roundtrip.test.ts b/test/opmlProcessor.roundtrip.test.ts index beecc83..510a74d 100644 --- a/test/opmlProcessor.roundtrip.test.ts +++ b/test/opmlProcessor.roundtrip.test.ts @@ -1,28 +1,34 @@ -import fs from 'fs'; -import path from 'path'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import fs from "fs"; +import path from "path"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; // import { AACTree } from '../src/core/treeStructure'; // Unused import -const outPath = path.join(__dirname, 'out.opml'); +const outPath = path.join(__dirname, "out.opml"); -describe('OpmlProcessor round-trip', () => { - const opmlPath = path.join(__dirname, 'assets/opml/example.opml'); +describe("OpmlProcessor round-trip", () => { + const opmlPath = path.join(__dirname, "assets/opml/example.opml"); afterAll(async () => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it('round-trips OPML file without losing pages', async () => { + it("round-trips OPML file without losing pages", async () => { const processor = new OpmlProcessor(); const tree1 = await processor.loadIntoTree(opmlPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); // Compare set of page names (labels) const filterArtificial = (arr: any[]) => - arr.filter((n: any) => n !== 'Super Root' && n !== 'Root').sort(); - const names1 = filterArtificial(Object.values(tree1.pages).map((p) => p.name)); - const names2 = filterArtificial(Object.values(tree2.pages).map((p) => p.name)); + arr.filter((n: any) => n !== "Super Root" && n !== "Root").sort(); + const names1 = filterArtificial( + Object.values(tree1.pages).map((p) => p.name), + ); + const names2 = filterArtificial( + Object.values(tree2.pages).map((p) => p.name), + ); expect(names2).toEqual(names1); // Compare root names if (tree2.rootId && tree1.rootId) { - expect(tree2.getPage(tree2.rootId)?.name).toEqual(tree1.getPage(tree1.rootId)?.name); + expect(tree2.getPage(tree2.rootId)?.name).toEqual( + tree1.getPage(tree1.rootId)?.name, + ); } }); }); diff --git a/test/opmlProcessor.test.ts b/test/opmlProcessor.test.ts index 5d6f0c5..4afe13a 100644 --- a/test/opmlProcessor.test.ts +++ b/test/opmlProcessor.test.ts @@ -1,12 +1,12 @@ // Unit test for OPMLProcessor -import path from 'path'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; -import { AACTree } from '../src/core/treeStructure'; +import path from "path"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; +import { AACTree } from "../src/core/treeStructure"; -describe('OPMLProcessor', () => { - const opmlPath: string = path.join(__dirname, 'assets/opml/example.opml'); +describe("OPMLProcessor", () => { + const opmlPath: string = path.join(__dirname, "assets/opml/example.opml"); - it('can process .opml files and build a navigation tree', async () => { + it("can process .opml files and build a navigation tree", async () => { const processor = new OpmlProcessor(); const tree: AACTree = await processor.loadIntoTree(opmlPath); expect(tree).toBeInstanceOf(AACTree); @@ -21,7 +21,7 @@ describe('OPMLProcessor', () => { let navFound = false; tree.traverse((page) => { page.buttons.forEach((btn) => { - if (btn.type === 'NAVIGATE' && btn.targetPageId) navFound = true; + if (btn.type === "NAVIGATE" && btn.targetPageId) navFound = true; }); }); expect(navFound).toBe(true); diff --git a/test/performance.memory.test.ts b/test/performance.memory.test.ts index d9c3178..44dc93a 100644 --- a/test/performance.memory.test.ts +++ b/test/performance.memory.test.ts @@ -1,16 +1,16 @@ // Memory performance tests for large communication boards -import fs from 'fs'; -import path from 'path'; -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { TreeFactory } from './utils/testFactories'; +import fs from "fs"; +import path from "path"; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { TreeFactory } from "./utils/testFactories"; // Skip memory intensive tests in CI environment const describeIfLocal = process.env.CI ? describe.skip : describe; -describeIfLocal('Memory Performance Tests', () => { - const tempDir = path.join(__dirname, 'temp_performance_memory'); +describeIfLocal("Memory Performance Tests", () => { + const tempDir = path.join(__dirname, "temp_performance_memory"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -32,7 +32,7 @@ describeIfLocal('Memory Performance Tests', () => { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (e) { - console.warn('Failed to clean up temp dir:', e); + console.warn("Failed to clean up temp dir:", e); } } resolve(); @@ -77,7 +77,9 @@ describeIfLocal('Memory Performance Tests', () => { }; } - async function measureMemoryUsageAsync(operation: () => Promise): Promise<{ + async function measureMemoryUsageAsync( + operation: () => Promise, + ): Promise<{ result: T; memoryUsedMB: number; peakMemoryMB: number; @@ -114,8 +116,8 @@ describeIfLocal('Memory Performance Tests', () => { }; } - describe('TouchChatProcessor Memory Tests', () => { - it('should process 1000+ button boards under 50MB memory', async () => { + describe("TouchChatProcessor Memory Tests", () => { + it("should process 1000+ button boards under 50MB memory", async () => { const processor = new TouchChatProcessor(); const { @@ -126,34 +128,37 @@ describeIfLocal('Memory Performance Tests', () => { return TreeFactory.createLarge(10, 100); // 10 pages, 100 buttons each = 1000 buttons }); - const outputPath = path.join(tempDir, 'large_touchchat.ce'); + const outputPath = path.join(tempDir, "large_touchchat.ce"); const { memoryUsedMB: saveMemoryMB } = await measureMemoryUsageAsync(() => - processor.saveFromTree(tree, outputPath) + processor.saveFromTree(tree, outputPath), ); - const { result: loadedTree, memoryUsedMB: loadMemoryMB } = await measureMemoryUsageAsync(() => - processor.loadIntoTree(outputPath) - ); + const { result: loadedTree, memoryUsedMB: loadMemoryMB } = + await measureMemoryUsageAsync(() => processor.loadIntoTree(outputPath)); expect(loadedTree).toBeDefined(); expect(Object.keys(loadedTree.pages)).toHaveLength(10); // Memory usage should be under 50MB for the entire operation - const totalMemoryUsed = Math.max(memoryUsedMB, saveMemoryMB, loadMemoryMB); + const totalMemoryUsed = Math.max( + memoryUsedMB, + saveMemoryMB, + loadMemoryMB, + ); expect(totalMemoryUsed).toBeLessThan(50); expect(peakMemoryMB).toBeLessThan(50); console.log( - `TouchChat 1000+ buttons - Memory used: ${totalMemoryUsed.toFixed(2)}MB, Peak: ${peakMemoryMB.toFixed(2)}MB` + `TouchChat 1000+ buttons - Memory used: ${totalMemoryUsed.toFixed(2)}MB, Peak: ${peakMemoryMB.toFixed(2)}MB`, ); }); - it('should handle streaming large files efficiently', async () => { + it("should handle streaming large files efficiently", async () => { const processor = new TouchChatProcessor(); const tree = TreeFactory.createLarge(50, 50); // 2500 buttons - const outputPath = path.join(tempDir, 'streaming_touchchat.ce'); + const outputPath = path.join(tempDir, "streaming_touchchat.ce"); const { memoryUsedMB } = await measureMemoryUsageAsync(async () => { await processor.saveFromTree(tree, outputPath); @@ -161,10 +166,12 @@ describeIfLocal('Memory Performance Tests', () => { }); expect(memoryUsedMB).toBeLessThan(75); // Slightly higher limit for larger dataset - console.log(`TouchChat streaming - Memory used: ${memoryUsedMB.toFixed(2)}MB`); + console.log( + `TouchChat streaming - Memory used: ${memoryUsedMB.toFixed(2)}MB`, + ); }); - it('should garbage collect properly after processing', async () => { + it("should garbage collect properly after processing", async () => { const processor = new TouchChatProcessor(); // Force garbage collection if available @@ -197,12 +204,14 @@ describeIfLocal('Memory Performance Tests', () => { // Memory increase should be minimal after garbage collection // Without --expose-gc, we can't guarantee cleanup, so we use a higher threshold expect(memoryIncrease).toBeLessThan(100); - console.log(`TouchChat GC test - Memory increase: ${memoryIncrease.toFixed(2)}MB`); + console.log( + `TouchChat GC test - Memory increase: ${memoryIncrease.toFixed(2)}MB`, + ); }); }); - describe('SnapProcessor Memory Tests', () => { - it('should process 1000+ button boards under 50MB memory', async () => { + describe("SnapProcessor Memory Tests", () => { + it("should process 1000+ button boards under 50MB memory", async () => { const processor = new SnapProcessor(); const { @@ -213,29 +222,32 @@ describeIfLocal('Memory Performance Tests', () => { return TreeFactory.createLarge(10, 100); // 1000 buttons }); - const outputPath = path.join(tempDir, 'large_snap.sps'); + const outputPath = path.join(tempDir, "large_snap.sps"); const { memoryUsedMB: saveMemoryMB } = await measureMemoryUsageAsync(() => - processor.saveFromTree(tree, outputPath) + processor.saveFromTree(tree, outputPath), ); - const { result: loadedTree, memoryUsedMB: loadMemoryMB } = await measureMemoryUsageAsync(() => - processor.loadIntoTree(outputPath) - ); + const { result: loadedTree, memoryUsedMB: loadMemoryMB } = + await measureMemoryUsageAsync(() => processor.loadIntoTree(outputPath)); expect(loadedTree).toBeDefined(); expect(Object.keys(loadedTree.pages)).toHaveLength(10); - const totalMemoryUsed = Math.max(memoryUsedMB, saveMemoryMB, loadMemoryMB); + const totalMemoryUsed = Math.max( + memoryUsedMB, + saveMemoryMB, + loadMemoryMB, + ); expect(totalMemoryUsed).toBeLessThan(50); expect(peakMemoryMB).toBeLessThan(50); console.log( - `Snap 1000+ buttons - Memory used: ${totalMemoryUsed.toFixed(2)}MB, Peak: ${peakMemoryMB.toFixed(2)}MB` + `Snap 1000+ buttons - Memory used: ${totalMemoryUsed.toFixed(2)}MB, Peak: ${peakMemoryMB.toFixed(2)}MB`, ); }); - it('should handle large audio content efficiently', async () => { + it("should handle large audio content efficiently", async () => { const processor = new SnapProcessor(); const { result: tree, memoryUsedMB } = measureMemoryUsage(() => { @@ -248,7 +260,7 @@ describeIfLocal('Memory Performance Tests', () => { id: pageIndex * 100 + buttonIndex, data: Buffer.alloc(8192, 0x41), // 8KB audio per button identifier: `audio_${pageIndex}_${buttonIndex}`, - metadata: 'Performance test audio', + metadata: "Performance test audio", }; }); }); @@ -256,117 +268,142 @@ describeIfLocal('Memory Performance Tests', () => { return tree; }); - const outputPath = path.join(tempDir, 'audio_heavy_snap.sps'); + const outputPath = path.join(tempDir, "audio_heavy_snap.sps"); const { memoryUsedMB: saveMemoryMB } = await measureMemoryUsageAsync(() => - processor.saveFromTree(tree, outputPath) + processor.saveFromTree(tree, outputPath), ); - const { result: loadedTree, memoryUsedMB: loadMemoryMB } = await measureMemoryUsageAsync(() => - processor.loadIntoTree(outputPath) - ); + const { result: loadedTree, memoryUsedMB: loadMemoryMB } = + await measureMemoryUsageAsync(() => processor.loadIntoTree(outputPath)); expect(loadedTree).toBeDefined(); // With audio content, allow slightly higher memory usage - const totalMemoryUsed = Math.max(memoryUsedMB, saveMemoryMB, loadMemoryMB); + const totalMemoryUsed = Math.max( + memoryUsedMB, + saveMemoryMB, + loadMemoryMB, + ); expect(totalMemoryUsed).toBeLessThan(100); - console.log(`Snap with audio - Memory used: ${totalMemoryUsed.toFixed(2)}MB`); + console.log( + `Snap with audio - Memory used: ${totalMemoryUsed.toFixed(2)}MB`, + ); }); - it('should maintain memory usage under 100MB for large files', async () => { + it("should maintain memory usage under 100MB for large files", async () => { const processor = new SnapProcessor(); - const { result: tree, memoryUsedMB: _memoryUsedMB } = measureMemoryUsage(() => { - const tree = TreeFactory.createLarge(100, 20); // 2000 buttons - - // Add moderate audio content - Object.values(tree.pages).forEach((page, pageIndex) => { - page.buttons.forEach((button, buttonIndex) => { - if (buttonIndex % 3 === 0) { - // Every 3rd button has audio - button.audioRecording = { - id: pageIndex * 100 + buttonIndex, - data: Buffer.alloc(4096, 0x42), // 4KB audio - identifier: `audio_${pageIndex}_${buttonIndex}`, - metadata: 'Large file test audio', - }; - } + const { result: tree, memoryUsedMB: _memoryUsedMB } = measureMemoryUsage( + () => { + const tree = TreeFactory.createLarge(100, 20); // 2000 buttons + + // Add moderate audio content + Object.values(tree.pages).forEach((page, pageIndex) => { + page.buttons.forEach((button, buttonIndex) => { + if (buttonIndex % 3 === 0) { + // Every 3rd button has audio + button.audioRecording = { + id: pageIndex * 100 + buttonIndex, + data: Buffer.alloc(4096, 0x42), // 4KB audio + identifier: `audio_${pageIndex}_${buttonIndex}`, + metadata: "Large file test audio", + }; + } + }); }); - }); - return tree; - }); + return tree; + }, + ); - const outputPath = path.join(tempDir, 'very_large_snap.sps'); + const outputPath = path.join(tempDir, "very_large_snap.sps"); - const { memoryUsedMB: totalMemoryMB } = await measureMemoryUsageAsync(async () => { - await processor.saveFromTree(tree, outputPath); - return await processor.loadIntoTree(outputPath); - }); + const { memoryUsedMB: totalMemoryMB } = await measureMemoryUsageAsync( + async () => { + await processor.saveFromTree(tree, outputPath); + return await processor.loadIntoTree(outputPath); + }, + ); expect(totalMemoryMB).toBeLessThan(100); - console.log(`Snap very large file - Memory used: ${totalMemoryMB.toFixed(2)}MB`); + console.log( + `Snap very large file - Memory used: ${totalMemoryMB.toFixed(2)}MB`, + ); }); }); - describe('DotProcessor Memory Tests', () => { - it('should handle very large hierarchies efficiently', async () => { + describe("DotProcessor Memory Tests", () => { + it("should handle very large hierarchies efficiently", async () => { const processor = new DotProcessor(); - const { result: tree, memoryUsedMB: _memoryUsedMB } = measureMemoryUsage(() => { - return TreeFactory.createLarge(200, 10); // 200 pages, 10 buttons each - }); + const { result: tree, memoryUsedMB: _memoryUsedMB } = measureMemoryUsage( + () => { + return TreeFactory.createLarge(200, 10); // 200 pages, 10 buttons each + }, + ); - const outputPath = path.join(tempDir, 'large_hierarchy.dot'); + const outputPath = path.join(tempDir, "large_hierarchy.dot"); - const { memoryUsedMB: totalMemoryMB } = await measureMemoryUsageAsync(async () => { - await processor.saveFromTree(tree, outputPath); - return await processor.loadIntoTree(outputPath); - }); + const { memoryUsedMB: totalMemoryMB } = await measureMemoryUsageAsync( + async () => { + await processor.saveFromTree(tree, outputPath); + return await processor.loadIntoTree(outputPath); + }, + ); expect(totalMemoryMB).toBeLessThan(40); // DOT format should be very efficient - console.log(`DOT large hierarchy - Memory used: ${totalMemoryMB.toFixed(2)}MB`); + console.log( + `DOT large hierarchy - Memory used: ${totalMemoryMB.toFixed(2)}MB`, + ); }); }); - describe('Cross-Processor Memory Comparison', () => { - it('should compare memory usage across all processors', async () => { + describe("Cross-Processor Memory Comparison", () => { + it("should compare memory usage across all processors", async () => { const tree = TreeFactory.createLarge(50, 20); // 1000 buttons const results: { [key: string]: number } = {}; // Test TouchChatProcessor const touchChatProcessor = new TouchChatProcessor(); - const touchChatPath = path.join(tempDir, 'comparison_touchchat.ce'); - const { memoryUsedMB: touchChatMemory } = await measureMemoryUsageAsync(async () => { - await touchChatProcessor.saveFromTree(tree, touchChatPath); - return await touchChatProcessor.loadIntoTree(touchChatPath); - }); - results['TouchChat'] = touchChatMemory; + const touchChatPath = path.join(tempDir, "comparison_touchchat.ce"); + const { memoryUsedMB: touchChatMemory } = await measureMemoryUsageAsync( + async () => { + await touchChatProcessor.saveFromTree(tree, touchChatPath); + return await touchChatProcessor.loadIntoTree(touchChatPath); + }, + ); + results["TouchChat"] = touchChatMemory; // Test SnapProcessor const snapProcessor = new SnapProcessor(); - const snapPath = path.join(tempDir, 'comparison_snap.sps'); - const { memoryUsedMB: snapMemory } = await measureMemoryUsageAsync(async () => { - await snapProcessor.saveFromTree(tree, snapPath); - return await snapProcessor.loadIntoTree(snapPath); - }); - results['Snap'] = snapMemory; + const snapPath = path.join(tempDir, "comparison_snap.sps"); + const { memoryUsedMB: snapMemory } = await measureMemoryUsageAsync( + async () => { + await snapProcessor.saveFromTree(tree, snapPath); + return await snapProcessor.loadIntoTree(snapPath); + }, + ); + results["Snap"] = snapMemory; // Test DotProcessor const dotProcessor = new DotProcessor(); - const dotPath = path.join(tempDir, 'comparison_dot.dot'); - const { memoryUsedMB: dotMemory } = await measureMemoryUsageAsync(async () => { - await dotProcessor.saveFromTree(tree, dotPath); - return await dotProcessor.loadIntoTree(dotPath); - }); - results['DOT'] = dotMemory; + const dotPath = path.join(tempDir, "comparison_dot.dot"); + const { memoryUsedMB: dotMemory } = await measureMemoryUsageAsync( + async () => { + await dotProcessor.saveFromTree(tree, dotPath); + return await dotProcessor.loadIntoTree(dotPath); + }, + ); + results["DOT"] = dotMemory; // All should be under reasonable limits Object.entries(results).forEach(([processor, memory]) => { expect(memory).toBeLessThan(50); - console.log(`${processor} processor - Memory used: ${memory.toFixed(2)}MB`); + console.log( + `${processor} processor - Memory used: ${memory.toFixed(2)}MB`, + ); }); // DOT should be efficient, but relative comparisons are flaky without --expose-gc @@ -375,8 +412,8 @@ describeIfLocal('Memory Performance Tests', () => { }); }); - describe('Memory Leak Detection', () => { - it('should not leak memory during repeated operations', async () => { + describe("Memory Leak Detection", () => { + it("should not leak memory during repeated operations", async () => { const processor = new DotProcessor(); if (global.gc) { @@ -408,15 +445,21 @@ describeIfLocal('Memory Performance Tests', () => { const firstHalf = memoryReadings.slice(0, 5); const secondHalf = memoryReadings.slice(5); - const firstHalfAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length; - const secondHalfAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length; + const firstHalfAvg = + firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length; + const secondHalfAvg = + secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length; // Second half should not be significantly higher than first half const memoryIncrease = secondHalfAvg - firstHalfAvg; expect(memoryIncrease).toBeLessThan(5); // Less than 5MB increase - console.log(`Memory leak test - Average increase: ${memoryIncrease.toFixed(2)}MB`); - console.log(`Memory readings: ${memoryReadings.map((m) => m.toFixed(1)).join(', ')}MB`); + console.log( + `Memory leak test - Average increase: ${memoryIncrease.toFixed(2)}MB`, + ); + console.log( + `Memory readings: ${memoryReadings.map((m) => m.toFixed(1)).join(", ")}MB`, + ); }); }); }); diff --git a/test/performance.test.ts b/test/performance.test.ts index 592a85d..7435099 100644 --- a/test/performance.test.ts +++ b/test/performance.test.ts @@ -1,17 +1,17 @@ // Performance tests for all processors -import fs from 'fs'; -import path from 'path'; -import { performance } from 'perf_hooks'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; - -describe('Performance Tests', () => { - const tempDir = path.join(__dirname, 'temp_performance'); +import fs from "fs"; +import path from "path"; +import { performance } from "perf_hooks"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; + +describe("Performance Tests", () => { + const tempDir = path.join(__dirname, "temp_performance"); let warnSpy: jest.SpyInstance; beforeAll(async () => { - warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } @@ -37,11 +37,13 @@ describe('Performance Tests', () => { // Helper function to create large test data function createLargeDotFile(nodeCount: number): string { - const lines = ['digraph G {']; + const lines = ["digraph G {"]; // Add nodes for (let i = 0; i < nodeCount; i++) { - lines.push(` node${i} [label="Node ${i} with some longer text content"];`); + lines.push( + ` node${i} [label="Node ${i} with some longer text content"];`, + ); } // Add edges (create a connected graph) @@ -58,8 +60,8 @@ describe('Performance Tests', () => { } } - lines.push('}'); - return lines.join('\n'); + lines.push("}"); + return lines.join("\n"); } function createLargeTree(pageCount: number, buttonsPerPage: number): AACTree { @@ -77,9 +79,11 @@ describe('Performance Tests', () => { id: `btn_${p}_${b}`, label: `Button ${b} on Page ${p}`, message: `This is button ${b} on page ${p} with some longer message content`, - type: Math.random() > 0.7 ? 'NAVIGATE' : 'SPEAK', + type: Math.random() > 0.7 ? "NAVIGATE" : "SPEAK", targetPageId: - Math.random() > 0.7 ? `page_${Math.floor(Math.random() * pageCount)}` : undefined, + Math.random() > 0.7 + ? `page_${Math.floor(Math.random() * pageCount)}` + : undefined, }); page.addButton(button); } @@ -90,8 +94,8 @@ describe('Performance Tests', () => { return tree; } - describe('Large File Processing', () => { - it('should handle large DOT files efficiently', async () => { + describe("Large File Processing", () => { + it("should handle large DOT files efficiently", async () => { const processor = new DotProcessor(); const largeContent = createLargeDotFile(1000); // 1000 nodes @@ -106,7 +110,9 @@ describe('Performance Tests', () => { const processingTime = endTime - startTime; const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; - console.log(`DOT Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`); + console.log( + `DOT Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`, + ); expect(tree).toBeDefined(); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); @@ -114,11 +120,11 @@ describe('Performance Tests', () => { expect(memoryIncrease).toBeLessThan(100); // Should not use more than 100MB extra }); - it('should handle large trees in saveFromTree operations', async () => { + it("should handle large trees in saveFromTree operations", async () => { const processor = new DotProcessor(); const largeTree = createLargeTree(50, 20); // 50 pages, 20 buttons each - const outputPath = path.join(tempDir, 'large_output.dot'); + const outputPath = path.join(tempDir, "large_output.dot"); const memBefore = getMemoryUsage(); const startTime = performance.now(); @@ -131,7 +137,7 @@ describe('Performance Tests', () => { const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log( - `DOT Save Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB` + `DOT Save Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`, ); expect(fs.existsSync(outputPath)).toBe(true); @@ -139,7 +145,7 @@ describe('Performance Tests', () => { expect(memoryIncrease).toBeLessThan(50); // Should not use more than 50MB extra }); - it('should handle large translation operations efficiently', async () => { + it("should handle large translation operations efficiently", async () => { const processor = new DotProcessor(); const largeContent = createLargeDotFile(500); @@ -150,14 +156,14 @@ describe('Performance Tests', () => { translations.set(`Edge ${i}`, `Borde ${i}`); } - const outputPath = path.join(tempDir, 'large_translated.dot'); + const outputPath = path.join(tempDir, "large_translated.dot"); const memBefore = getMemoryUsage(); const startTime = performance.now(); const result = await processor.processTexts( Buffer.from(largeContent), translations, - outputPath + outputPath, ); const endTime = performance.now(); @@ -167,7 +173,7 @@ describe('Performance Tests', () => { const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log( - `DOT Translation Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB` + `DOT Translation Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`, ); expect(result).toBeInstanceOf(Buffer); @@ -176,8 +182,8 @@ describe('Performance Tests', () => { }); }); - describe('Memory Usage Patterns', () => { - it('should not leak memory during repeated operations', async () => { + describe("Memory Usage Patterns", () => { + it("should not leak memory during repeated operations", async () => { const processor = new DotProcessor(); const testContent = createLargeDotFile(100); @@ -203,7 +209,7 @@ describe('Performance Tests', () => { expect(memoryIncrease).toBeLessThan(30); // Allow small variance on CI }); - it('should handle concurrent processing efficiently', async () => { + it("should handle concurrent processing efficiently", async () => { const processor = new DotProcessor(); const testContent = createLargeDotFile(200); @@ -229,7 +235,7 @@ describe('Performance Tests', () => { const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log( - `Concurrent Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB` + `Concurrent Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`, ); expect(results).toHaveLength(5); @@ -242,12 +248,12 @@ describe('Performance Tests', () => { }); }); - describe('Database Performance', () => { - it('should handle large Snap databases efficiently', async () => { + describe("Database Performance", () => { + it("should handle large Snap databases efficiently", async () => { const processor = new SnapProcessor(); const largeTree = createLargeTree(20, 15); // 20 pages, 15 buttons each - const outputPath = path.join(tempDir, 'large_snap.spb'); + const outputPath = path.join(tempDir, "large_snap.spb"); const memBefore = getMemoryUsage(); const startTime = performance.now(); @@ -266,19 +272,21 @@ describe('Performance Tests', () => { const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log( - `Snap DB Performance: Save ${saveProcessingTime.toFixed(2)}ms, Load ${loadProcessingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB` + `Snap DB Performance: Save ${saveProcessingTime.toFixed(2)}ms, Load ${loadProcessingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`, ); expect(loadedTree).toBeDefined(); - expect(Object.keys(loadedTree.pages).length).toBe(Object.keys(largeTree.pages).length); + expect(Object.keys(loadedTree.pages).length).toBe( + Object.keys(largeTree.pages).length, + ); expect(saveProcessingTime).toBeLessThan(25000); // Save should complete in under 25 seconds on slower disks expect(loadProcessingTime).toBeLessThan(15000); // Load should complete in under 15 seconds expect(memoryIncrease).toBeLessThan(100); // Should not use excessive memory }); }); - describe('Timeout Handling', () => { - it('should handle slow operations gracefully', async () => { + describe("Timeout Handling", () => { + it("should handle slow operations gracefully", async () => { const processor = new DotProcessor(); // Create a very large file that might be slow to process @@ -287,17 +295,21 @@ describe('Performance Tests', () => { const startTime = performance.now(); try { - const tree = await processor.loadIntoTree(Buffer.from(veryLargeContent)); + const tree = await processor.loadIntoTree( + Buffer.from(veryLargeContent), + ); const endTime = performance.now(); const processingTime = endTime - startTime; - console.log(`Very large file processing: ${processingTime.toFixed(2)}ms`); + console.log( + `Very large file processing: ${processingTime.toFixed(2)}ms`, + ); expect(tree).toBeDefined(); expect(processingTime).toBeLessThan(30000); // Should complete within 30 seconds } catch (error) { // If it fails due to memory or timeout, that's acceptable for very large files - console.log('Very large file processing failed (acceptable):', error); + console.log("Very large file processing failed (acceptable):", error); } }); }); diff --git a/test/platformPaths.test.ts b/test/platformPaths.test.ts index bda54ce..2f2ddd0 100644 --- a/test/platformPaths.test.ts +++ b/test/platformPaths.test.ts @@ -1,123 +1,139 @@ -import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; -import * as fs from 'fs'; -import * as path from 'path'; -import { execSync } from 'child_process'; +import { + describe, + it, + expect, + beforeEach, + afterEach, + jest, +} from "@jest/globals"; +import * as fs from "fs"; +import * as path from "path"; +import { execSync } from "child_process"; import { getCommonDocumentsPath, findGrid3UserPaths, findGrid3HistoryDatabases, findGrid3Vocabularies, findGrid3UserHistory, -} from '../src/processors/gridset/helpers'; +} from "../src/processors/gridset/helpers"; import { findSnapPackages as findSnapPackagesFromSnap, findSnapPackagePath as findSnapPackagePathFromSnap, findSnapUsers, findSnapUserVocabularies, findSnapUserHistory, -} from '../src/processors/snap/helpers'; -import { defaultFileAdapter } from '../src/utils/io'; +} from "../src/processors/snap/helpers"; +import { defaultFileAdapter } from "../src/utils/io"; // Mock modules -jest.mock('fs'); -jest.mock('child_process'); +jest.mock("fs"); +jest.mock("child_process"); const mockFs = fs as jest.Mocked; const mockExecSync = execSync as jest.MockedFunction; -describe('Grid3 Path Discovery', () => { +describe("Grid3 Path Discovery", () => { const originalPlatform = process.platform; beforeEach(async () => { jest.clearAllMocks(); // Mock Windows platform - Object.defineProperty(process, 'platform', { - value: 'win32', + Object.defineProperty(process, "platform", { + value: "win32", configurable: true, }); }); afterEach(async () => { // Restore original platform - Object.defineProperty(process, 'platform', { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true, }); }); - describe('getCommonDocumentsPath', () => { - it('should return path from registry on Windows', async () => { - const expectedPath = 'C:\\Users\\Public\\Documents'; - mockExecSync.mockReturnValue(`Common Documents REG_SZ ${expectedPath}\r\n` as any); + describe("getCommonDocumentsPath", () => { + it("should return path from registry on Windows", async () => { + const expectedPath = "C:\\Users\\Public\\Documents"; + mockExecSync.mockReturnValue( + `Common Documents REG_SZ ${expectedPath}\r\n` as any, + ); const result = getCommonDocumentsPath(); expect(result).toBe(expectedPath); expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining('REG.EXE QUERY'), - expect.objectContaining({ encoding: 'utf-8', windowsHide: true }) + expect.stringContaining("REG.EXE QUERY"), + expect.objectContaining({ encoding: "utf-8", windowsHide: true }), ); }); - it('should return default path if registry access fails', async () => { + it("should return default path if registry access fails", async () => { mockExecSync.mockImplementation(() => { - throw new Error('Registry access failed'); + throw new Error("Registry access failed"); }); const result = getCommonDocumentsPath(); - expect(result).toBe('C:\\Users\\Public\\Documents'); + expect(result).toBe("C:\\Users\\Public\\Documents"); }); - it('should return empty string on non-Windows platforms', async () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', + it("should return empty string on non-Windows platforms", async () => { + Object.defineProperty(process, "platform", { + value: "darwin", configurable: true, }); const result = getCommonDocumentsPath(); - expect(result).toBe(''); + expect(result).toBe(""); expect(mockExecSync).not.toHaveBeenCalled(); }); }); - describe('findGrid3UserPaths', () => { - it('should find Grid3 user paths with history databases', async () => { - const mockCommonDocs = 'C:\\Users\\Public\\Documents'; - mockExecSync.mockReturnValue(`Common Documents REG_SZ ${mockCommonDocs}\r\n` as any); + describe("findGrid3UserPaths", () => { + it("should find Grid3 user paths with history databases", async () => { + const mockCommonDocs = "C:\\Users\\Public\\Documents"; + mockExecSync.mockReturnValue( + `Common Documents REG_SZ ${mockCommonDocs}\r\n` as any, + ); - const grid3BasePath = path.win32.join(mockCommonDocs, 'Smartbox', 'Grid 3', 'Users'); + const grid3BasePath = path.win32.join( + mockCommonDocs, + "Smartbox", + "Grid 3", + "Users", + ); const result = await findGrid3UserPaths({ ...defaultFileAdapter, listDir: async (pathStr) => { - if (pathStr === grid3BasePath) return ['TestUser']; - if (pathStr.includes('TestUser')) return ['en-gb']; + if (pathStr === grid3BasePath) return ["TestUser"]; + if (pathStr.includes("TestUser")) return ["en-gb"]; return []; }, isDirectory: async (pathStr) => { - return pathStr.endsWith('TestUser') || pathStr.endsWith('en-gb'); + return pathStr.endsWith("TestUser") || pathStr.endsWith("en-gb"); }, pathExists: async (pathStr) => { if (pathStr === grid3BasePath) return true; - if (pathStr.includes('history.sqlite')) return true; + if (pathStr.includes("history.sqlite")) return true; return false; }, }); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ - userName: 'TestUser', - langCode: 'en-gb', - basePath: expect.stringContaining('TestUser\\en-gb'), - historyDbPath: expect.stringContaining('history.sqlite'), + userName: "TestUser", + langCode: "en-gb", + basePath: expect.stringContaining("TestUser\\en-gb"), + historyDbPath: expect.stringContaining("history.sqlite"), }); }); - it('should return empty array if Grid3 directory does not exist', async () => { + it("should return empty array if Grid3 directory does not exist", async () => { mockExecSync.mockReturnValue( - 'Common Documents REG_SZ C:\\Users\\Public\\Documents\r\n' as any + "Common Documents REG_SZ C:\\Users\\Public\\Documents\r\n" as any, ); mockFs.existsSync.mockReturnValue(false); @@ -126,9 +142,9 @@ describe('Grid3 Path Discovery', () => { expect(result).toEqual([]); }); - it('should return empty array on non-Windows platforms', async () => { - Object.defineProperty(process, 'platform', { - value: 'linux', + it("should return empty array on non-Windows platforms", async () => { + Object.defineProperty(process, "platform", { + value: "linux", configurable: true, }); @@ -139,145 +155,174 @@ describe('Grid3 Path Discovery', () => { }); }); - describe('findGrid3HistoryDatabases', () => { - it('should return array of history database paths', async () => { - const mockCommonDocs = 'C:\\Users\\Public\\Documents'; - mockExecSync.mockReturnValue(`Common Documents REG_SZ ${mockCommonDocs}\r\n` as any); + describe("findGrid3HistoryDatabases", () => { + it("should return array of history database paths", async () => { + const mockCommonDocs = "C:\\Users\\Public\\Documents"; + mockExecSync.mockReturnValue( + `Common Documents REG_SZ ${mockCommonDocs}\r\n` as any, + ); - const grid3BasePath = path.win32.join(mockCommonDocs, 'Smartbox', 'Grid 3', 'Users'); + const grid3BasePath = path.win32.join( + mockCommonDocs, + "Smartbox", + "Grid 3", + "Users", + ); const result = await findGrid3HistoryDatabases({ ...defaultFileAdapter, pathExists: async () => true, listDir: async (pathStr) => { - if (pathStr === grid3BasePath) return ['User1']; - return ['en-us']; + if (pathStr === grid3BasePath) return ["User1"]; + return ["en-us"]; }, isDirectory: async (pathStr) => { - return pathStr.endsWith('User1') || pathStr.endsWith('en-us'); + return pathStr.endsWith("User1") || pathStr.endsWith("en-us"); }, }); expect(result).toHaveLength(1); - expect(result[0]).toContain('history.sqlite'); + expect(result[0]).toContain("history.sqlite"); }); }); - describe('findGrid3Vocabularies', () => { - it('should list gridset files per user', async () => { - const mockCommonDocs = 'C:\\Users\\Public\\Documents'; - const grid3BasePath = path.win32.join(mockCommonDocs, 'Smartbox', 'Grid 3', 'Users'); - const gridSetsDir = path.win32.join(grid3BasePath, 'User1', 'Grid Sets'); + describe("findGrid3Vocabularies", () => { + it("should list gridset files per user", async () => { + const mockCommonDocs = "C:\\Users\\Public\\Documents"; + const grid3BasePath = path.win32.join( + mockCommonDocs, + "Smartbox", + "Grid 3", + "Users", + ); + const gridSetsDir = path.win32.join(grid3BasePath, "User1", "Grid Sets"); - mockExecSync.mockReturnValue(`Common Documents REG_SZ ${mockCommonDocs}\r\n` as any); + mockExecSync.mockReturnValue( + `Common Documents REG_SZ ${mockCommonDocs}\r\n` as any, + ); const result = await findGrid3Vocabularies(undefined, { ...defaultFileAdapter, pathExists: async (pathStr) => - pathStr === grid3BasePath || pathStr === gridSetsDir || pathStr.endsWith('Test.gridset'), + pathStr === grid3BasePath || + pathStr === gridSetsDir || + pathStr.endsWith("Test.gridset"), listDir: async (pathStr) => { - if (pathStr === grid3BasePath) return ['User1']; - if (pathStr === gridSetsDir) return ['Test.gridset']; + if (pathStr === grid3BasePath) return ["User1"]; + if (pathStr === gridSetsDir) return ["Test.gridset"]; return []; }, isDirectory: async (pathStr) => - pathStr === grid3BasePath || pathStr === gridSetsDir || pathStr.endsWith('User1'), + pathStr === grid3BasePath || + pathStr === gridSetsDir || + pathStr.endsWith("User1"), }); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ - userName: 'User1', - gridsetPath: path.win32.join(gridSetsDir, 'Test.gridset'), + userName: "User1", + gridsetPath: path.win32.join(gridSetsDir, "Test.gridset"), }); }); }); - describe('findGrid3UserHistory', () => { - it('should return history path for specific user', async () => { - const mockCommonDocs = 'C:\\Users\\Public\\Documents'; - mockExecSync.mockReturnValue(`Common Documents REG_SZ ${mockCommonDocs}\r\n` as any); + describe("findGrid3UserHistory", () => { + it("should return history path for specific user", async () => { + const mockCommonDocs = "C:\\Users\\Public\\Documents"; + mockExecSync.mockReturnValue( + `Common Documents REG_SZ ${mockCommonDocs}\r\n` as any, + ); - const grid3BasePath = path.win32.join(mockCommonDocs, 'Smartbox', 'Grid 3', 'Users'); + const grid3BasePath = path.win32.join( + mockCommonDocs, + "Smartbox", + "Grid 3", + "Users", + ); - const result = await findGrid3UserHistory('User1', 'en-gb', { + const result = await findGrid3UserHistory("User1", "en-gb", { ...defaultFileAdapter, pathExists: async (pathStr) => { if (pathStr === grid3BasePath) return true; - if (pathStr.includes('history.sqlite')) return true; + if (pathStr.includes("history.sqlite")) return true; return false; }, listDir: async (pathStr) => { - if (pathStr === grid3BasePath) return ['User1']; - if (pathStr.includes('User1')) return ['en-gb']; + if (pathStr === grid3BasePath) return ["User1"]; + if (pathStr.includes("User1")) return ["en-gb"]; return []; }, - isDirectory: async (pathStr) => pathStr.endsWith('User1') || pathStr.endsWith('en-gb'), + isDirectory: async (pathStr) => + pathStr.endsWith("User1") || pathStr.endsWith("en-gb"), }); - expect(result).toContain('history.sqlite'); + expect(result).toContain("history.sqlite"); }); }); }); -describe('Snap Path Discovery', () => { +describe("Snap Path Discovery", () => { const originalPlatform = process.platform; const originalEnv = process.env; beforeEach(async () => { jest.clearAllMocks(); // Mock Windows platform - Object.defineProperty(process, 'platform', { - value: 'win32', + Object.defineProperty(process, "platform", { + value: "win32", configurable: true, }); // Mock environment process.env = { ...originalEnv, - LOCALAPPDATA: 'C:\\Users\\TestUser\\AppData\\Local', + LOCALAPPDATA: "C:\\Users\\TestUser\\AppData\\Local", }; }); afterEach(async () => { // Restore original platform and environment - Object.defineProperty(process, 'platform', { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true, }); process.env = originalEnv; }); - describe('findSnapPackages', () => { - it('should find Snap packages matching pattern', async () => { + describe("findSnapPackages", () => { + it("should find Snap packages matching pattern", async () => { const result = await findSnapPackagesFromSnap(undefined, { ...defaultFileAdapter, pathExists: async () => true, listDir: async () => [ - 'TobiiDynavox.Snap_abc123', - 'TobiiDynavox.Communicator_def456', - 'Microsoft.WindowsStore_xyz789', + "TobiiDynavox.Snap_abc123", + "TobiiDynavox.Communicator_def456", + "Microsoft.WindowsStore_xyz789", ], isDirectory: async () => true, }); expect(result).toHaveLength(2); - expect(result[0].packageName).toBe('TobiiDynavox.Snap_abc123'); - expect(result[0].packagePath).toContain('TobiiDynavox.Snap_abc123'); - expect(result[1].packageName).toBe('TobiiDynavox.Communicator_def456'); + expect(result[0].packageName).toBe("TobiiDynavox.Snap_abc123"); + expect(result[0].packagePath).toContain("TobiiDynavox.Snap_abc123"); + expect(result[1].packageName).toBe("TobiiDynavox.Communicator_def456"); }); - it('should filter by custom pattern', async () => { - const result = await findSnapPackagesFromSnap('CustomApp', { + it("should filter by custom pattern", async () => { + const result = await findSnapPackagesFromSnap("CustomApp", { ...defaultFileAdapter, pathExists: async () => true, - listDir: async () => ['TobiiDynavox.Snap_abc123', 'CustomApp.Package_xyz'], + listDir: async () => [ + "TobiiDynavox.Snap_abc123", + "CustomApp.Package_xyz", + ], isDirectory: async () => true, }); expect(result).toHaveLength(1); - expect(result[0].packageName).toBe('CustomApp.Package_xyz'); + expect(result[0].packageName).toBe("CustomApp.Package_xyz"); }); - it('should return empty array if Packages directory does not exist', async () => { + it("should return empty array if Packages directory does not exist", async () => { mockFs.existsSync.mockReturnValue(false); const result = await findSnapPackagesFromSnap(); @@ -285,7 +330,7 @@ describe('Snap Path Discovery', () => { expect(result).toEqual([]); }); - it('should return empty array if LOCALAPPDATA is not set', async () => { + it("should return empty array if LOCALAPPDATA is not set", async () => { delete process.env.LOCALAPPDATA; const result = await findSnapPackagesFromSnap(); @@ -294,9 +339,9 @@ describe('Snap Path Discovery', () => { expect(mockFs.existsSync).not.toHaveBeenCalled(); }); - it('should return empty array on non-Windows platforms', async () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', + it("should return empty array on non-Windows platforms", async () => { + Object.defineProperty(process, "platform", { + value: "darwin", configurable: true, }); @@ -307,19 +352,19 @@ describe('Snap Path Discovery', () => { }); }); - describe('findSnapPackagePath', () => { - it('should return first matching package path', async () => { + describe("findSnapPackagePath", () => { + it("should return first matching package path", async () => { const result = await findSnapPackagePathFromSnap(undefined, { ...defaultFileAdapter, pathExists: async () => true, - listDir: async () => ['TobiiDynavox.Snap_abc123'], + listDir: async () => ["TobiiDynavox.Snap_abc123"], isDirectory: async () => true, }); - expect(result).toContain('TobiiDynavox.Snap_abc123'); + expect(result).toContain("TobiiDynavox.Snap_abc123"); }); - it('should return null if no packages found', async () => { + it("should return null if no packages found", async () => { mockFs.existsSync.mockReturnValue(true); mockFs.readdirSync.mockReturnValue([] as any); @@ -329,85 +374,93 @@ describe('Snap Path Discovery', () => { }); }); - describe('findSnapUsers', () => { - it('should list Snap users and vocab files', async () => { - const localAppData = process.env.LOCALAPPDATA ?? ''; - const packagesPath = path.join(localAppData, 'Packages'); - const packagePath = path.join(packagesPath, 'TobiiDynavox.Snap_abc123'); - const usersRoot = path.join(packagePath, 'LocalState', 'Users'); - const userPath = path.join(usersRoot, 'user1'); - const vocabPath = path.join(userPath, 'board.sps'); + describe("findSnapUsers", () => { + it("should list Snap users and vocab files", async () => { + const localAppData = process.env.LOCALAPPDATA ?? ""; + const packagesPath = path.join(localAppData, "Packages"); + const packagePath = path.join(packagesPath, "TobiiDynavox.Snap_abc123"); + const usersRoot = path.join(packagePath, "LocalState", "Users"); + const userPath = path.join(usersRoot, "user1"); + const vocabPath = path.join(userPath, "board.sps"); - const users = await findSnapUsers('TobiiDynavox', { + const users = await findSnapUsers("TobiiDynavox", { ...defaultFileAdapter, pathExists: async (pathStr) => - pathStr === packagesPath || pathStr === usersRoot || pathStr === userPath, + pathStr === packagesPath || + pathStr === usersRoot || + pathStr === userPath, listDir: async (pathStr) => { - if (pathStr === packagesPath) return ['TobiiDynavox.Snap_abc123']; - if (pathStr === usersRoot) return ['user1', 'SwiftKeyStaticModels']; - if (pathStr === userPath) return ['board.sps', 'notes.txt']; + if (pathStr === packagesPath) return ["TobiiDynavox.Snap_abc123"]; + if (pathStr === usersRoot) return ["user1", "SwiftKeyStaticModels"]; + if (pathStr === userPath) return ["board.sps", "notes.txt"]; return []; }, isDirectory: async (pathStr) => - pathStr.endsWith('TobiiDynavox.Snap_abc123') || - pathStr.endsWith('user1') || - pathStr.endsWith('SwiftKeyStaticModels'), + pathStr.endsWith("TobiiDynavox.Snap_abc123") || + pathStr.endsWith("user1") || + pathStr.endsWith("SwiftKeyStaticModels"), }); expect(users).toHaveLength(1); - expect(users[0]).toMatchObject({ userId: 'user1' }); + expect(users[0]).toMatchObject({ userId: "user1" }); expect(users[0].vocabPaths).toContain(vocabPath); }); }); - describe('findSnapUserVocabularies', () => { - it('should return vocab paths for a specific user', async () => { - const localAppData = process.env.LOCALAPPDATA ?? ''; - const packagesPath = path.join(localAppData, 'Packages'); - const packagePath = path.join(packagesPath, 'TobiiDynavox.Snap_abc123'); - const usersRoot = path.join(packagePath, 'LocalState', 'Users'); - const userPath = path.join(usersRoot, 'user1'); - const vocabPath = path.join(userPath, 'board.sps'); + describe("findSnapUserVocabularies", () => { + it("should return vocab paths for a specific user", async () => { + const localAppData = process.env.LOCALAPPDATA ?? ""; + const packagesPath = path.join(localAppData, "Packages"); + const packagePath = path.join(packagesPath, "TobiiDynavox.Snap_abc123"); + const usersRoot = path.join(packagePath, "LocalState", "Users"); + const userPath = path.join(usersRoot, "user1"); + const vocabPath = path.join(userPath, "board.sps"); - const result = await findSnapUserVocabularies('user1', 'TobiiDynavox', { + const result = await findSnapUserVocabularies("user1", "TobiiDynavox", { ...defaultFileAdapter, pathExists: async (pathStr) => - pathStr === packagesPath || pathStr === usersRoot || pathStr === userPath, + pathStr === packagesPath || + pathStr === usersRoot || + pathStr === userPath, listDir: async (pathStr) => { - if (pathStr === packagesPath) return ['TobiiDynavox.Snap_abc123']; - if (pathStr === usersRoot) return ['user1']; - if (pathStr === userPath) return ['board.sps']; + if (pathStr === packagesPath) return ["TobiiDynavox.Snap_abc123"]; + if (pathStr === usersRoot) return ["user1"]; + if (pathStr === userPath) return ["board.sps"]; return []; }, isDirectory: async (pathStr) => - pathStr.endsWith('TobiiDynavox.Snap_abc123') || pathStr.endsWith('user1'), + pathStr.endsWith("TobiiDynavox.Snap_abc123") || + pathStr.endsWith("user1"), }); expect(result).toContain(vocabPath); }); }); - describe('findSnapUserHistory', () => { - it('should find history-like files for a user', async () => { - const localAppData = process.env.LOCALAPPDATA ?? ''; - const packagesPath = path.join(localAppData, 'Packages'); - const packagePath = path.join(packagesPath, 'TobiiDynavox.Snap_abc123'); - const usersRoot = path.join(packagePath, 'LocalState', 'Users'); - const userPath = path.join(usersRoot, 'user1'); - const historyPath = path.join(userPath, 'history.db'); + describe("findSnapUserHistory", () => { + it("should find history-like files for a user", async () => { + const localAppData = process.env.LOCALAPPDATA ?? ""; + const packagesPath = path.join(localAppData, "Packages"); + const packagePath = path.join(packagesPath, "TobiiDynavox.Snap_abc123"); + const usersRoot = path.join(packagePath, "LocalState", "Users"); + const userPath = path.join(usersRoot, "user1"); + const historyPath = path.join(userPath, "history.db"); - const result = await findSnapUserHistory('user1', 'TobiiDynavox', { + const result = await findSnapUserHistory("user1", "TobiiDynavox", { ...defaultFileAdapter, pathExists: async (pathStr) => - pathStr === packagesPath || pathStr === usersRoot || pathStr === userPath, + pathStr === packagesPath || + pathStr === usersRoot || + pathStr === userPath, listDir: async (pathStr) => { - if (pathStr === packagesPath) return ['TobiiDynavox.Snap_abc123']; - if (pathStr === usersRoot) return ['user1']; - if (pathStr === userPath) return ['history.db']; + if (pathStr === packagesPath) return ["TobiiDynavox.Snap_abc123"]; + if (pathStr === usersRoot) return ["user1"]; + if (pathStr === userPath) return ["history.db"]; return []; }, isDirectory: async (pathStr) => - pathStr.endsWith('TobiiDynavox.Snap_abc123') || pathStr.endsWith('user1'), + pathStr.endsWith("TobiiDynavox.Snap_abc123") || + pathStr.endsWith("user1"), }); expect(result).toContain(historyPath); diff --git a/test/processTexts.realworld.test.ts b/test/processTexts.realworld.test.ts index 1727dc8..44a9d05 100644 --- a/test/processTexts.realworld.test.ts +++ b/test/processTexts.realworld.test.ts @@ -1,18 +1,18 @@ // Real-world processTexts tests using actual example files -import fs from 'fs'; -import path from 'path'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import fs from "fs"; +import path from "path"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; -jest.setTimeout(process.platform === 'win32' ? 60000 : 30000); +jest.setTimeout(process.platform === "win32" ? 60000 : 30000); -describe('ProcessTexts with Real-World Data', () => { - const examplesDir = path.join(__dirname, '../examples'); - const tempDir = path.join(__dirname, 'temp_realworld'); +describe("ProcessTexts with Real-World Data", () => { + const examplesDir = path.join(__dirname, "../examples"); + const tempDir = path.join(__dirname, "temp_realworld"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -30,13 +30,13 @@ describe('ProcessTexts with Real-World Data', () => { } }); - describe('DOT Processor with Real Data', () => { - const dotFile = path.join(examplesDir, 'example.dot'); - const communikateDotFile = path.join(examplesDir, 'communikate.dot'); + describe("DOT Processor with Real Data", () => { + const dotFile = path.join(examplesDir, "example.dot"); + const communikateDotFile = path.join(examplesDir, "communikate.dot"); - it('should extract and translate texts from example.dot', async () => { + it("should extract and translate texts from example.dot", async () => { if (!fs.existsSync(dotFile)) { - console.log('Skipping DOT test - example.dot not found'); + console.log("Skipping DOT test - example.dot not found"); return; } @@ -45,31 +45,35 @@ describe('ProcessTexts with Real-World Data', () => { // First extract all texts to see what we're working with const originalTexts = await processor.extractTexts(dotFile); expect(originalTexts.length).toBeGreaterThan(0); - console.log('DOT original texts:', originalTexts.slice(0, 5)); // Show first 5 + console.log("DOT original texts:", originalTexts.slice(0, 5)); // Show first 5 // Create translations for some common words const translations = new Map(); originalTexts.forEach((text) => { - if (text.toLowerCase().includes('hello')) { - translations.set(text, text.replace(/hello/gi, 'hola')); + if (text.toLowerCase().includes("hello")) { + translations.set(text, text.replace(/hello/gi, "hola")); } - if (text.toLowerCase().includes('home')) { - translations.set(text, text.replace(/home/gi, 'casa')); + if (text.toLowerCase().includes("home")) { + translations.set(text, text.replace(/home/gi, "casa")); } - if (text.toLowerCase().includes('food')) { - translations.set(text, text.replace(/food/gi, 'comida')); + if (text.toLowerCase().includes("food")) { + translations.set(text, text.replace(/food/gi, "comida")); } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, 'translated_example.dot'); - const result = await processor.processTexts(dotFile, translations, outputPath); + const outputPath = path.join(tempDir, "translated_example.dot"); + const result = await processor.processTexts( + dotFile, + translations, + outputPath, + ); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); // Verify translations were applied - const translatedContent = Buffer.from(result).toString('utf8'); + const translatedContent = Buffer.from(result).toString("utf8"); translations.forEach((translation, original) => { if (original !== translation) { expect(translatedContent).toContain(translation); @@ -78,9 +82,9 @@ describe('ProcessTexts with Real-World Data', () => { } }); - it('should handle communikate.dot file', async () => { + it("should handle communikate.dot file", async () => { if (!fs.existsSync(communikateDotFile)) { - console.log('Skipping communikate DOT test - file not found'); + console.log("Skipping communikate DOT test - file not found"); return; } @@ -89,21 +93,21 @@ describe('ProcessTexts with Real-World Data', () => { expect(texts.length).toBeGreaterThan(0); // Test with a simple translation - const translations = new Map([['Core', 'Núcleo']]); - const outputPath = path.join(tempDir, 'translated_communikate.dot'); + const translations = new Map([["Core", "Núcleo"]]); + const outputPath = path.join(tempDir, "translated_communikate.dot"); await expect( - processor.processTexts(communikateDotFile, translations, outputPath) + processor.processTexts(communikateDotFile, translations, outputPath), ).resolves.toBeInstanceOf(Uint8Array); }); }); - describe('OPML Processor with Real Data', () => { - const opmlFile = path.join(examplesDir, 'example.opml'); + describe("OPML Processor with Real Data", () => { + const opmlFile = path.join(examplesDir, "example.opml"); - it('should extract and translate texts from example.opml', async () => { + it("should extract and translate texts from example.opml", async () => { if (!fs.existsSync(opmlFile)) { - console.log('Skipping OPML test - example.opml not found'); + console.log("Skipping OPML test - example.opml not found"); return; } @@ -112,32 +116,36 @@ describe('ProcessTexts with Real-World Data', () => { // Extract texts to see the structure const originalTexts = await processor.extractTexts(opmlFile); expect(originalTexts.length).toBeGreaterThan(0); - console.log('OPML original texts:', originalTexts.slice(0, 5)); + console.log("OPML original texts:", originalTexts.slice(0, 5)); // Create translations based on actual content const translations = new Map(); originalTexts.forEach((text) => { - if (text.toLowerCase().includes('home')) { - translations.set(text, text.replace(/home/gi, 'casa')); + if (text.toLowerCase().includes("home")) { + translations.set(text, text.replace(/home/gi, "casa")); } - if (text.toLowerCase().includes('food')) { - translations.set(text, text.replace(/food/gi, 'comida')); + if (text.toLowerCase().includes("food")) { + translations.set(text, text.replace(/food/gi, "comida")); } - if (text.toLowerCase().includes('drink')) { - translations.set(text, text.replace(/drink/gi, 'bebida')); + if (text.toLowerCase().includes("drink")) { + translations.set(text, text.replace(/drink/gi, "bebida")); } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, 'translated_example.opml'); - const result = await processor.processTexts(opmlFile, translations, outputPath); + const outputPath = path.join(tempDir, "translated_example.opml"); + const result = await processor.processTexts( + opmlFile, + translations, + outputPath, + ); expect(result).toBeInstanceOf(Buffer); // Verify the XML structure is maintained and translations applied - const translatedContent = Buffer.from(result).toString('utf8'); - expect(translatedContent).toContain(' { if (original !== translation) { @@ -148,13 +156,13 @@ describe('ProcessTexts with Real-World Data', () => { }); }); - describe('OBF Processor with Real Data', () => { - const obfFile = path.join(examplesDir, 'example.obf'); - const obzFile = path.join(examplesDir, 'example.obz'); + describe("OBF Processor with Real Data", () => { + const obfFile = path.join(examplesDir, "example.obf"); + const obzFile = path.join(examplesDir, "example.obz"); - it('should extract and translate texts from example.obf', async () => { + it("should extract and translate texts from example.obf", async () => { if (!fs.existsSync(obfFile)) { - console.log('Skipping OBF test - example.obf not found'); + console.log("Skipping OBF test - example.obf not found"); return; } @@ -163,27 +171,31 @@ describe('ProcessTexts with Real-World Data', () => { // Extract texts to understand the content const originalTexts = await processor.extractTexts(obfFile); expect(originalTexts.length).toBeGreaterThan(0); - console.log('OBF original texts:', originalTexts.slice(0, 5)); + console.log("OBF original texts:", originalTexts.slice(0, 5)); // Create meaningful translations const translations = new Map(); originalTexts.forEach((text) => { - if (text && typeof text === 'string') { - if (text.toLowerCase().includes('hello')) { - translations.set(text, text.replace(/hello/gi, 'hola')); + if (text && typeof text === "string") { + if (text.toLowerCase().includes("hello")) { + translations.set(text, text.replace(/hello/gi, "hola")); } - if (text.toLowerCase().includes('yes')) { - translations.set(text, text.replace(/yes/gi, 'sí')); + if (text.toLowerCase().includes("yes")) { + translations.set(text, text.replace(/yes/gi, "sí")); } - if (text.toLowerCase().includes('no')) { - translations.set(text, text.replace(/no/gi, 'no')); + if (text.toLowerCase().includes("no")) { + translations.set(text, text.replace(/no/gi, "no")); } } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, 'translated_example.obf'); - const result = await processor.processTexts(obfFile, translations, outputPath); + const outputPath = path.join(tempDir, "translated_example.obf"); + const result = await processor.processTexts( + obfFile, + translations, + outputPath, + ); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); @@ -194,9 +206,9 @@ describe('ProcessTexts with Real-World Data', () => { } }); - it('should handle OBZ (zip) files', async () => { + it("should handle OBZ (zip) files", async () => { if (!fs.existsSync(obzFile)) { - console.log('Skipping OBZ test - example.obz not found'); + console.log("Skipping OBZ test - example.obz not found"); return; } @@ -205,21 +217,21 @@ describe('ProcessTexts with Real-World Data', () => { expect(texts.length).toBeGreaterThan(0); // Test with simple translation - const translations = new Map([['home', 'casa']]); - const outputPath = path.join(tempDir, 'translated_example.obz'); + const translations = new Map([["home", "casa"]]); + const outputPath = path.join(tempDir, "translated_example.obz"); await expect( - processor.processTexts(obzFile, translations, outputPath) + processor.processTexts(obzFile, translations, outputPath), ).resolves.toBeInstanceOf(Uint8Array); }); }); - describe('GridSet Processor with Real Data', () => { - const gridsetFile = path.join(examplesDir, 'example.gridset'); + describe("GridSet Processor with Real Data", () => { + const gridsetFile = path.join(examplesDir, "example.gridset"); - it('should extract and translate texts from example.gridset', async () => { + it("should extract and translate texts from example.gridset", async () => { if (!fs.existsSync(gridsetFile)) { - console.log('Skipping GridSet test - example.gridset not found'); + console.log("Skipping GridSet test - example.gridset not found"); return; } @@ -229,28 +241,32 @@ describe('ProcessTexts with Real-World Data', () => { const fileBuffer = fs.readFileSync(gridsetFile); const originalTexts = await processor.extractTexts(fileBuffer); expect(originalTexts.length).toBeGreaterThan(0); - console.log('GridSet original texts:', originalTexts.slice(0, 5)); + console.log("GridSet original texts:", originalTexts.slice(0, 5)); // Create translations based on Grid3 format expectations const translations = new Map(); originalTexts.forEach((text) => { - if (text && typeof text === 'string') { + if (text && typeof text === "string") { // Common AAC words that might be in a gridset - if (text.toLowerCase().includes('i')) { - translations.set(text, text.replace(/\bi\b/gi, 'yo')); + if (text.toLowerCase().includes("i")) { + translations.set(text, text.replace(/\bi\b/gi, "yo")); } - if (text.toLowerCase().includes('want')) { - translations.set(text, text.replace(/want/gi, 'quiero')); + if (text.toLowerCase().includes("want")) { + translations.set(text, text.replace(/want/gi, "quiero")); } - if (text.toLowerCase().includes('more')) { - translations.set(text, text.replace(/more/gi, 'más')); + if (text.toLowerCase().includes("more")) { + translations.set(text, text.replace(/more/gi, "más")); } } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, 'translated_example.gridset'); - const result = await processor.processTexts(fileBuffer, translations, outputPath); + const outputPath = path.join(tempDir, "translated_example.gridset"); + const result = await processor.processTexts( + fileBuffer, + translations, + outputPath, + ); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); @@ -263,13 +279,13 @@ describe('ProcessTexts with Real-World Data', () => { }); }); - describe('Snap Processor with Real Data', () => { - const spbFile = path.join(examplesDir, 'example.spb'); - const spsFile = path.join(examplesDir, 'example.sps'); + describe("Snap Processor with Real Data", () => { + const spbFile = path.join(examplesDir, "example.spb"); + const spsFile = path.join(examplesDir, "example.sps"); - it('should extract and translate texts from example.spb', async () => { + it("should extract and translate texts from example.spb", async () => { if (!fs.existsSync(spbFile)) { - console.log('Skipping SPB test - example.spb not found'); + console.log("Skipping SPB test - example.spb not found"); return; } @@ -278,33 +294,37 @@ describe('ProcessTexts with Real-World Data', () => { // Extract texts from real Snap database const originalTexts = await processor.extractTexts(spbFile); expect(originalTexts.length).toBeGreaterThan(0); - console.log('Snap SPB original texts:', originalTexts.slice(0, 5)); + console.log("Snap SPB original texts:", originalTexts.slice(0, 5)); // Create translations for common AAC vocabulary const translations = new Map(); originalTexts.forEach((text) => { - if (text && typeof text === 'string') { - if (text.toLowerCase().includes('hello')) { - translations.set(text, text.replace(/hello/gi, 'hola')); + if (text && typeof text === "string") { + if (text.toLowerCase().includes("hello")) { + translations.set(text, text.replace(/hello/gi, "hola")); } - if (text.toLowerCase().includes('thank')) { - translations.set(text, text.replace(/thank/gi, 'gracias')); + if (text.toLowerCase().includes("thank")) { + translations.set(text, text.replace(/thank/gi, "gracias")); } } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, 'translated_example.spb'); - const result = await processor.processTexts(spbFile, translations, outputPath); + const outputPath = path.join(tempDir, "translated_example.spb"); + const result = await processor.processTexts( + spbFile, + translations, + outputPath, + ); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); } }); - it('should handle SPS files', async () => { + it("should handle SPS files", async () => { if (!fs.existsSync(spsFile)) { - console.log('Skipping SPS test - example.sps not found'); + console.log("Skipping SPS test - example.sps not found"); return; } @@ -313,21 +333,21 @@ describe('ProcessTexts with Real-World Data', () => { expect(texts.length).toBeGreaterThan(0); // Test basic translation functionality - const translations = new Map([['home', 'casa']]); - const outputPath = path.join(tempDir, 'translated_example.sps'); + const translations = new Map([["home", "casa"]]); + const outputPath = path.join(tempDir, "translated_example.sps"); await expect( - processor.processTexts(spsFile, translations, outputPath) + processor.processTexts(spsFile, translations, outputPath), ).resolves.toBeInstanceOf(Uint8Array); }); }); - describe('TouchChat Processor with Real Data', () => { - const ceFile = path.join(examplesDir, 'example.ce'); + describe("TouchChat Processor with Real Data", () => { + const ceFile = path.join(examplesDir, "example.ce"); - it('should extract and translate texts from example.ce', async () => { + it("should extract and translate texts from example.ce", async () => { if (!fs.existsSync(ceFile)) { - console.log('Skipping TouchChat test - example.ce not found'); + console.log("Skipping TouchChat test - example.ce not found"); return; } @@ -336,24 +356,28 @@ describe('ProcessTexts with Real-World Data', () => { // Extract texts from real TouchChat file const originalTexts = await processor.extractTexts(ceFile); expect(originalTexts.length).toBeGreaterThan(0); - console.log('TouchChat original texts:', originalTexts.slice(0, 5)); + console.log("TouchChat original texts:", originalTexts.slice(0, 5)); // Create translations for TouchChat vocabulary const translations = new Map(); originalTexts.forEach((text) => { - if (text && typeof text === 'string') { - if (text.toLowerCase().includes('hello')) { - translations.set(text, text.replace(/hello/gi, 'hola')); + if (text && typeof text === "string") { + if (text.toLowerCase().includes("hello")) { + translations.set(text, text.replace(/hello/gi, "hola")); } - if (text.toLowerCase().includes('goodbye')) { - translations.set(text, text.replace(/goodbye/gi, 'adiós')); + if (text.toLowerCase().includes("goodbye")) { + translations.set(text, text.replace(/goodbye/gi, "adiós")); } } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, 'translated_example.ce'); - const result = await processor.processTexts(ceFile, translations, outputPath); + const outputPath = path.join(tempDir, "translated_example.ce"); + const result = await processor.processTexts( + ceFile, + translations, + outputPath, + ); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); diff --git a/test/processTexts.test.ts b/test/processTexts.test.ts index dd5afad..f4176d7 100644 --- a/test/processTexts.test.ts +++ b/test/processTexts.test.ts @@ -1,17 +1,17 @@ // Tests for processTexts functionality across all processors -import fs from 'fs'; -import path from 'path'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; -import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; -import { ObfProcessor } from '../src/processors/obfProcessor'; +import fs from "fs"; +import path from "path"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; +import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; +import { ObfProcessor } from "../src/processors/obfProcessor"; // import { GridsetProcessor } from '../src/processors/gridsetProcessor'; // import { SnapProcessor } from '../src/processors/snapProcessor'; // import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; -describe('ProcessTexts functionality', () => { - const tempDir = path.join(__dirname, 'temp'); +describe("ProcessTexts functionality", () => { + const tempDir = path.join(__dirname, "temp"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -25,8 +25,8 @@ describe('ProcessTexts functionality', () => { } }); - describe('DotProcessor processTexts', () => { - it('should apply translations to dot file content', async () => { + describe("DotProcessor processTexts", () => { + it("should apply translations to dot file content", async () => { const processor = new DotProcessor(); const dotContent = ` digraph G { @@ -37,27 +37,27 @@ describe('ProcessTexts functionality', () => { `; const translations = new Map([ - ['Hello', 'Hola'], - ['World', 'Mundo'], - ['Go', 'Ir'], + ["Hello", "Hola"], + ["World", "Mundo"], + ["Go", "Ir"], ]); - const outputPath = path.join(tempDir, 'translated.dot'); + const outputPath = path.join(tempDir, "translated.dot"); const result = await processor.processTexts( Buffer.from(dotContent), translations, - outputPath + outputPath, ); - const translatedContent = Buffer.from(result).toString('utf8'); + const translatedContent = Buffer.from(result).toString("utf8"); expect(translatedContent).toContain('label="Hola"'); expect(translatedContent).toContain('label="Mundo"'); expect(translatedContent).toContain('label="Ir"'); }); }); - describe('OpmlProcessor processTexts', () => { - it('should apply translations to OPML text attributes', async () => { + describe("OpmlProcessor processTexts", () => { + it("should apply translations to OPML text attributes", async () => { const processor = new OpmlProcessor(); const opmlContent = ` @@ -71,26 +71,26 @@ describe('ProcessTexts functionality', () => { `; const translations = new Map([ - ['Home', 'Casa'], - ['Food', 'Comida'], - ['Drinks', 'Bebidas'], + ["Home", "Casa"], + ["Food", "Comida"], + ["Drinks", "Bebidas"], ]); - const outputPath = path.join(tempDir, 'translated.opml'); + const outputPath = path.join(tempDir, "translated.opml"); const result = await processor.processTexts( Buffer.from(opmlContent), translations, - outputPath + outputPath, ); - const translatedContent = Buffer.from(result).toString('utf8'); + const translatedContent = Buffer.from(result).toString("utf8"); expect(translatedContent).toContain('text="Casa"'); expect(translatedContent).toContain('text="Comida"'); expect(translatedContent).toContain('text="Bebidas"'); }); }); - describe('Tree-based processors processTexts', () => { + describe("Tree-based processors processTexts", () => { let testTree: AACTree; beforeEach(async () => { @@ -98,24 +98,24 @@ describe('ProcessTexts functionality', () => { testTree = new AACTree(); const page1 = new AACPage({ - id: 'page1', - name: 'Main Page', + id: "page1", + name: "Main Page", buttons: [], }); const button1 = new AACButton({ - id: 'btn1', - label: 'Hello', - message: 'Hello World', - type: 'SPEAK', + id: "btn1", + label: "Hello", + message: "Hello World", + type: "SPEAK", }); const button2 = new AACButton({ - id: 'btn2', - label: 'Go Home', - message: 'Navigate to home', - type: 'NAVIGATE', - targetPageId: 'page2', + id: "btn2", + label: "Go Home", + message: "Navigate to home", + type: "NAVIGATE", + targetPageId: "page2", }); page1.addButton(button1); @@ -123,31 +123,35 @@ describe('ProcessTexts functionality', () => { testTree.addPage(page1); const page2 = new AACPage({ - id: 'page2', - name: 'Home Page', + id: "page2", + name: "Home Page", buttons: [], }); testTree.addPage(page2); }); - it('should translate ApplePanels content', async () => { + it("should translate ApplePanels content", async () => { const processor = new ApplePanelsProcessor(); - const outputPath = path.join(tempDir, 'test.applepanels.plist'); + const outputPath = path.join(tempDir, "test.applepanels.plist"); // First save the test tree await processor.saveFromTree(testTree, outputPath); const translations = new Map([ - ['Main Page', 'Página Principal'], - ['Hello', 'Hola'], - ['Hello World', 'Hola Mundo'], - ['Go Home', 'Ir a Casa'], - ['Home Page', 'Página de Inicio'], + ["Main Page", "Página Principal"], + ["Hello", "Hola"], + ["Hello World", "Hola Mundo"], + ["Go Home", "Ir a Casa"], + ["Home Page", "Página de Inicio"], ]); - const translatedPath = path.join(tempDir, 'translated.applepanels.plist'); - const result = await processor.processTexts(outputPath, translations, translatedPath); + const translatedPath = path.join(tempDir, "translated.applepanels.plist"); + const result = await processor.processTexts( + outputPath, + translations, + translatedPath, + ); expect(result).toBeInstanceOf(Uint8Array); expect(fs.existsSync(translatedPath)).toBe(true); @@ -158,54 +162,61 @@ describe('ProcessTexts functionality', () => { expect(pages.length).toBeGreaterThan(0); // Find the main page (might have different ID after round-trip) - const mainPage = pages.find((p) => p.name === 'Página Principal'); + const mainPage = pages.find((p) => p.name === "Página Principal"); expect(mainPage).toBeDefined(); if (!mainPage) { return; } - expect(mainPage.name).toBe('Página Principal'); + expect(mainPage.name).toBe("Página Principal"); // Find the hello button by label - const helloButton = mainPage.buttons.find((b) => b.label === 'Hola'); + const helloButton = mainPage.buttons.find((b) => b.label === "Hola"); expect(helloButton).toBeDefined(); if (!helloButton) { return; } - expect(helloButton.label).toBe('Hola'); - expect(helloButton.message).toBe('Hola Mundo'); + expect(helloButton.label).toBe("Hola"); + expect(helloButton.message).toBe("Hola Mundo"); }); - it('should translate OBF content', async () => { + it("should translate OBF content", async () => { const processor = new ObfProcessor(); - const outputPath = path.join(tempDir, 'test.obf'); + const outputPath = path.join(tempDir, "test.obf"); // First save the test tree await processor.saveFromTree(testTree, outputPath); const translations = new Map([ - ['Main Page', 'Página Principal'], - ['Hello', 'Hola'], - ['Hello World', 'Hola Mundo'], + ["Main Page", "Página Principal"], + ["Hello", "Hola"], + ["Hello World", "Hola Mundo"], ]); - const translatedPath = path.join(tempDir, 'translated.obf'); - const result = await processor.processTexts(outputPath, translations, translatedPath); + const translatedPath = path.join(tempDir, "translated.obf"); + const result = await processor.processTexts( + outputPath, + translations, + translatedPath, + ); expect(result).toBeInstanceOf(Uint8Array); expect(fs.existsSync(translatedPath)).toBe(true); }); - it('should handle empty translations gracefully', async () => { + it("should handle empty translations gracefully", async () => { const processor = new ApplePanelsProcessor(); - const outputPath = path.join(tempDir, 'test_empty.applepanels.plist'); + const outputPath = path.join(tempDir, "test_empty.applepanels.plist"); await processor.saveFromTree(testTree, outputPath); const emptyTranslations = new Map(); - const translatedPath = path.join(tempDir, 'empty_translated.applepanels.plist'); + const translatedPath = path.join( + tempDir, + "empty_translated.applepanels.plist", + ); await expect( - processor.processTexts(outputPath, emptyTranslations, translatedPath) + processor.processTexts(outputPath, emptyTranslations, translatedPath), ).resolves.not.toThrow(); expect(fs.existsSync(translatedPath)).toBe(true); diff --git a/test/processors/excelProcessor.test.ts b/test/processors/excelProcessor.test.ts index 6894a96..8f585e3 100644 --- a/test/processors/excelProcessor.test.ts +++ b/test/processors/excelProcessor.test.ts @@ -1,21 +1,21 @@ -import fs from 'fs'; -import path from 'path'; -import { ExcelProcessor } from '../../src/index'; -import { AACTree, AACPage, AACButton } from '../../src/index'; -import { AACSemanticIntent } from '../../src/index'; +import fs from "fs"; +import path from "path"; +import { ExcelProcessor } from "../../src/index"; +import { AACTree, AACPage, AACButton } from "../../src/index"; +import { AACSemanticIntent } from "../../src/index"; -describe('ExcelProcessor', () => { +describe("ExcelProcessor", () => { let processor: ExcelProcessor; let tempDir: string; let warnSpy: jest.SpyInstance; beforeAll(async () => { - warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); }); beforeEach(async () => { processor = new ExcelProcessor(); - tempDir = fs.mkdtempSync(path.join(__dirname, 'temp-excel-')); + tempDir = fs.mkdtempSync(path.join(__dirname, "temp-excel-")); }); afterEach(async () => { @@ -29,56 +29,56 @@ describe('ExcelProcessor', () => { warnSpy.mockRestore(); }); - describe('Basic Functionality', () => { - it('should create an instance', async () => { + describe("Basic Functionality", () => { + it("should create an instance", async () => { expect(processor).toBeInstanceOf(ExcelProcessor); }); - it('should handle empty tree', async () => { + it("should handle empty tree", async () => { const tree = new AACTree(); - const outputPath = path.join(tempDir, 'empty.xlsx'); + const outputPath = path.join(tempDir, "empty.xlsx"); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); }); - it('should extract texts from non-existent file', async () => { - const texts = await processor.extractTexts('non-existent.xlsx'); + it("should extract texts from non-existent file", async () => { + const texts = await processor.extractTexts("non-existent.xlsx"); expect(texts).toEqual([]); }); - it('should return empty tree for loadIntoTree', async () => { - const tree = await processor.loadIntoTree('any-file.xlsx'); + it("should return empty tree for loadIntoTree", async () => { + const tree = await processor.loadIntoTree("any-file.xlsx"); expect(tree).toBeInstanceOf(AACTree); expect(Object.keys(tree.pages)).toHaveLength(0); }); }); - describe('Tree to Excel Conversion', () => { - it('should convert simple AAC tree to Excel', async () => { + describe("Tree to Excel Conversion", () => { + it("should convert simple AAC tree to Excel", async () => { const tree = new AACTree(); // Create a simple page with buttons const page = new AACPage({ - id: 'home', - name: 'Home Page', + id: "home", + name: "Home Page", buttons: [ new AACButton({ - id: 'btn1', - label: 'Hello', - message: 'Hello there!', + id: "btn1", + label: "Hello", + message: "Hello there!", }), new AACButton({ - id: 'btn2', - label: 'Goodbye', - message: 'See you later!', + id: "btn2", + label: "Goodbye", + message: "See you later!", }), ], }); tree.addPage(page); - const outputPath = path.join(tempDir, 'simple.xlsx'); + const outputPath = path.join(tempDir, "simple.xlsx"); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); @@ -87,58 +87,58 @@ describe('ExcelProcessor', () => { // In a real test, we'd need to wait for the async operation }); - it('should handle buttons with styling', async () => { + it("should handle buttons with styling", async () => { const tree = new AACTree(); const styledButton = new AACButton({ - id: 'styled', - label: 'Styled Button', - message: 'I have style!', + id: "styled", + label: "Styled Button", + message: "I have style!", style: { - backgroundColor: '#FF0000', - fontColor: '#FFFFFF', + backgroundColor: "#FF0000", + fontColor: "#FFFFFF", fontSize: 16, - fontWeight: 'bold', + fontWeight: "bold", }, }); const page = new AACPage({ - id: 'styled-page', - name: 'Styled Page', + id: "styled-page", + name: "Styled Page", buttons: [styledButton], }); tree.addPage(page); - const outputPath = path.join(tempDir, 'styled.xlsx'); + const outputPath = path.join(tempDir, "styled.xlsx"); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); }); - it('should handle navigation buttons', async () => { + it("should handle navigation buttons", async () => { const tree = new AACTree(); // Create home page const homePage = new AACPage({ - id: 'home', - name: 'Home', + id: "home", + name: "Home", buttons: [], }); // Create food page with navigation back to home const foodPage = new AACPage({ - id: 'food', - name: 'Food', + id: "food", + name: "Food", buttons: [ new AACButton({ - id: 'nav-home', - label: 'Home', - message: '', + id: "nav-home", + label: "Home", + message: "", semanticAction: { intent: AACSemanticIntent.NAVIGATE_TO, parameters: {}, }, - targetPageId: 'home', + targetPageId: "home", }), ], }); @@ -146,49 +146,49 @@ describe('ExcelProcessor', () => { // Add navigation button from home to food homePage.addButton( new AACButton({ - id: 'nav-food', - label: 'Food', - message: '', + id: "nav-food", + label: "Food", + message: "", semanticAction: { intent: AACSemanticIntent.NAVIGATE_TO, parameters: {}, }, - targetPageId: 'food', - }) + targetPageId: "food", + }), ); tree.addPage(homePage); tree.addPage(foodPage); - tree.rootId = 'home'; + tree.rootId = "home"; - const outputPath = path.join(tempDir, 'navigation.xlsx'); + const outputPath = path.join(tempDir, "navigation.xlsx"); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); }); - it('should handle grid layout', async () => { + it("should handle grid layout", async () => { const tree = new AACTree(); // Create buttons for grid const btn1 = new AACButton({ - id: '1', - label: 'Button 1', - message: 'One', + id: "1", + label: "Button 1", + message: "One", }); const btn2 = new AACButton({ - id: '2', - label: 'Button 2', - message: 'Two', + id: "2", + label: "Button 2", + message: "Two", }); const btn3 = new AACButton({ - id: '3', - label: 'Button 3', - message: 'Three', + id: "3", + label: "Button 3", + message: "Three", }); const btn4 = new AACButton({ - id: '4', - label: 'Button 4', - message: 'Four', + id: "4", + label: "Button 4", + message: "Four", }); // Create 2x2 grid @@ -198,50 +198,52 @@ describe('ExcelProcessor', () => { ]; const page = new AACPage({ - id: 'grid-page', - name: 'Grid Layout', + id: "grid-page", + name: "Grid Layout", grid: grid, buttons: [btn1, btn2, btn3, btn4], }); tree.addPage(page); - const outputPath = path.join(tempDir, 'grid.xlsx'); + const outputPath = path.join(tempDir, "grid.xlsx"); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); }); }); - describe('Utility Methods', () => { - it('should sanitize worksheet names', async () => { + describe("Utility Methods", () => { + it("should sanitize worksheet names", async () => { // Access private method through any cast for testing const sanitize = (processor as any).sanitizeWorksheetName; - expect(sanitize('Normal Name')).toBe('Normal Name'); - expect(sanitize('Name/With\\Invalid:Chars')).toBe('Name_With_Invalid_Chars'); - expect(sanitize('')).toBe('Sheet1'); - expect(sanitize('Very Long Name That Exceeds Thirty One Characters')).toBe( - 'Very Long Name That Exceeds Thi' + expect(sanitize("Normal Name")).toBe("Normal Name"); + expect(sanitize("Name/With\\Invalid:Chars")).toBe( + "Name_With_Invalid_Chars", ); + expect(sanitize("")).toBe("Sheet1"); + expect( + sanitize("Very Long Name That Exceeds Thirty One Characters"), + ).toBe("Very Long Name That Exceeds Thi"); }); - it('should convert colors to ARGB', async () => { + it("should convert colors to ARGB", async () => { const convert = (processor as any).convertColorToArgb; - expect(convert('#FF0000')).toBe('FFFF0000'); - expect(convert('rgb(255, 0, 0)')).toBe('FFFF0000'); - expect(convert('rgba(255, 0, 0, 0.5)')).toBe('80FF0000'); - expect(convert('')).toBe('FFFFFFFF'); - expect(convert('invalid')).toBe('FFFFFFFF'); + expect(convert("#FF0000")).toBe("FFFF0000"); + expect(convert("rgb(255, 0, 0)")).toBe("FFFF0000"); + expect(convert("rgba(255, 0, 0, 0.5)")).toBe("80FF0000"); + expect(convert("")).toBe("FFFFFFFF"); + expect(convert("invalid")).toBe("FFFFFFFF"); }); }); - describe('Error Handling', () => { - it('should handle processTexts gracefully', async () => { - const translations = new Map([['Hello', 'Hola']]); + describe("Error Handling", () => { + it("should handle processTexts gracefully", async () => { + const translations = new Map([["Hello", "Hola"]]); await expect( - processor.processTexts('test.xlsx', translations, 'output.xlsx') + processor.processTexts("test.xlsx", translations, "output.xlsx"), ).resolves.not.toThrow(); }); }); diff --git a/test/processors/gridset/symbols.test.ts b/test/processors/gridset/symbols.test.ts index a9f388f..f5ecc5a 100644 --- a/test/processors/gridset/symbols.test.ts +++ b/test/processors/gridset/symbols.test.ts @@ -9,60 +9,67 @@ import { isSymbolReference, parseSymbolReference, symbolReferenceToFilename, -} from '../../../src/processors/gridset/symbols'; +} from "../../../src/processors/gridset/symbols"; -describe('gridset symbols utilities', () => { - it('parses and formats symbol references', () => { - const parsed = parseSymbolReference('[Widgit]/food/apple.png'); +describe("gridset symbols utilities", () => { + it("parses and formats symbol references", () => { + const parsed = parseSymbolReference("[Widgit]/food/apple.png"); expect(parsed.isValid).toBe(true); - expect(parsed.library).toBe('widgit'); - expect(parsed.path).toBe('/food/apple.png'); + expect(parsed.library).toBe("widgit"); + expect(parsed.path).toBe("/food/apple.png"); - expect(isSymbolReference('[widgit]/food/apple.png')).toBe(true); - expect(isSymbolReference('plain-text')).toBe(false); + expect(isSymbolReference("[widgit]/food/apple.png")).toBe(true); + expect(isSymbolReference("plain-text")).toBe(false); - const created = createSymbolReference('Widgit', '/food/apple.png'); - expect(created).toBe('[widgit]/food/apple.png'); + const created = createSymbolReference("Widgit", "/food/apple.png"); + expect(created).toBe("[widgit]/food/apple.png"); - expect(getSymbolLibraryName(created)).toBe('widgit'); - expect(getSymbolPath(created)).toBe('/food/apple.png'); + expect(getSymbolLibraryName(created)).toBe("widgit"); + expect(getSymbolPath(created)).toBe("/food/apple.png"); }); - it('detects known libraries and display names', () => { - expect(isKnownSymbolLibrary('[grid3x]')).toBe(true); - expect(isKnownSymbolLibrary('unknownlib')).toBe(false); + it("detects known libraries and display names", () => { + expect(isKnownSymbolLibrary("[grid3x]")).toBe(true); + expect(isKnownSymbolLibrary("unknownlib")).toBe(false); - expect(getSymbolLibraryDisplayName('widgit')).toBe('Widgit Symbols'); - expect(getSymbolLibraryDisplayName('custom')).toBe('Custom'); + expect(getSymbolLibraryDisplayName("widgit")).toBe("Widgit Symbols"); + expect(getSymbolLibraryDisplayName("custom")).toBe("Custom"); }); - it('extracts and analyzes symbol usage from a tree', () => { + it("extracts and analyzes symbol usage from a tree", () => { const tree = { pages: { one: { buttons: [ - { image: '[widgit]/food/apple.png' }, - { symbolLibrary: 'tawasl', symbolPath: '/animals/cat.png' }, + { image: "[widgit]/food/apple.png" }, + { symbolLibrary: "tawasl", symbolPath: "/animals/cat.png" }, ], }, two: { - buttons: [{ image: '[widgit]/food/apple.png' }], + buttons: [{ image: "[widgit]/food/apple.png" }], }, }, }; const refs = extractSymbolReferences(tree); - expect(refs).toEqual(['[tawasl]/animals/cat.png', '[widgit]/food/apple.png']); + expect(refs).toEqual([ + "[tawasl]/animals/cat.png", + "[widgit]/food/apple.png", + ]); const usage = analyzeSymbolUsage(tree); expect(usage.totalSymbols).toBe(2); expect(usage.byLibrary.widgit).toBe(1); expect(usage.byLibrary.tawasl).toBe(1); - expect(usage.librariesUsed).toEqual(['tawasl', 'widgit']); + expect(usage.librariesUsed).toEqual(["tawasl", "widgit"]); }); - it('creates embedded filenames for symbol references', () => { - expect(symbolReferenceToFilename('[widgit]/food/apple.png', 2, 3)).toBe('2-3-0-text-0.png'); - expect(symbolReferenceToFilename('[widgit]/food/apple', 1, 1)).toBe('1-1-0-text-0.png'); + it("creates embedded filenames for symbol references", () => { + expect(symbolReferenceToFilename("[widgit]/food/apple.png", 2, 3)).toBe( + "2-3-0-text-0.png", + ); + expect(symbolReferenceToFilename("[widgit]/food/apple", 1, 1)).toBe( + "1-1-0-text-0.png", + ); }); }); diff --git a/test/propertyBased.test.ts b/test/propertyBased.test.ts index fcf5fdd..e03dd21 100644 --- a/test/propertyBased.test.ts +++ b/test/propertyBased.test.ts @@ -1,15 +1,15 @@ // Property-based testing using fast-check -import fc from 'fast-check'; -import fs from 'fs'; -import path from 'path'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; - -describe('Property-Based Testing', () => { - const tempDir = path.join(__dirname, 'temp_property'); +import fc from "fast-check"; +import fs from "fs"; +import path from "path"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; + +describe("Property-Based Testing", () => { + const tempDir = path.join(__dirname, "temp_property"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -28,10 +28,10 @@ describe('Property-Based Testing', () => { const validLabelGenerator = fc .string({ minLength: 1, maxLength: 100 }) .filter((s) => s.trim().length > 0) - .map((s) => s.trim() || 'DefaultLabel'); + .map((s) => s.trim() || "DefaultLabel"); const validMessageGenerator = fc.string({ maxLength: 500 }); - const buttonTypeGenerator = fc.constantFrom('SPEAK', 'NAVIGATE'); + const buttonTypeGenerator = fc.constantFrom("SPEAK", "NAVIGATE"); const aacButtonGenerator = fc .record({ @@ -88,7 +88,7 @@ describe('Property-Based Testing', () => { if (allPageIds.length > 1) { Object.values(tree.pages).forEach((page) => { page.buttons.forEach((button) => { - if (button.type === 'NAVIGATE') { + if (button.type === "NAVIGATE") { const randomIndex = Math.floor(Math.random() * allPageIds.length); button.targetPageId = allPageIds[randomIndex]; } @@ -99,15 +99,18 @@ describe('Property-Based Testing', () => { return tree; }); - describe('Round-Trip Property Tests', () => { - it('DOT processor should preserve tree structure through round-trip', async () => { + describe("Round-Trip Property Tests", () => { + it("DOT processor should preserve tree structure through round-trip", async () => { await fc.assert( fc.asyncProperty(aacTreeGenerator, async (originalTree) => { const processor = new DotProcessor(); try { // Save tree to DOT format - const outputPath = path.join(tempDir, `roundtrip_${Date.now()}_${Math.random()}.dot`); + const outputPath = path.join( + tempDir, + `roundtrip_${Date.now()}_${Math.random()}.dot`, + ); await processor.saveFromTree(originalTree, outputPath); // Load it back @@ -134,22 +137,26 @@ describe('Property-Based Testing', () => { // At least some page names should be preserved const commonNames = originalPageNames.filter((name) => reloadedPageNames.some( - (reloadedName) => reloadedName.includes(name) || name.includes(reloadedName) - ) + (reloadedName) => + reloadedName.includes(name) || name.includes(reloadedName), + ), ); return commonNames.length > 0; } catch (error) { // If the test fails due to invalid data, that's acceptable - console.log('Round-trip test failed (acceptable for some data):', error); + console.log( + "Round-trip test failed (acceptable for some data):", + error, + ); return true; } }), - { numRuns: 20 } + { numRuns: 20 }, ); }); - it('OPML processor should preserve hierarchical structure', async () => { + it("OPML processor should preserve hierarchical structure", async () => { await fc.assert( fc.asyncProperty(aacTreeGenerator, async (originalTree) => { const processor = new OpmlProcessor(); @@ -157,7 +164,7 @@ describe('Property-Based Testing', () => { try { const outputPath = path.join( tempDir, - `opml_roundtrip_${Date.now()}_${Math.random()}.opml` + `opml_roundtrip_${Date.now()}_${Math.random()}.opml`, ); await processor.saveFromTree(originalTree, outputPath); @@ -172,23 +179,27 @@ describe('Property-Based Testing', () => { return reloadedPageCount > 0; } catch (error) { - console.log('OPML round-trip test failed (acceptable):', error); + console.log("OPML round-trip test failed (acceptable):", error); return true; } }), - { numRuns: 15 } + { numRuns: 15 }, ); }); - it('OBF processor should preserve button structure', async () => { + it("OBF processor should preserve button structure", async () => { await fc.assert( fc.asyncProperty(aacTreeGenerator, async (originalTree) => { const processor = new ObfProcessor(); try { // Skip trees with invalid button configurations - const hasInvalidButtons = Object.values(originalTree.pages).some((page) => - page.buttons.some((button) => button.type === 'NAVIGATE' && !button.targetPageId) + const hasInvalidButtons = Object.values(originalTree.pages).some( + (page) => + page.buttons.some( + (button) => + button.type === "NAVIGATE" && !button.targetPageId, + ), ); if (hasInvalidButtons) { @@ -197,7 +208,7 @@ describe('Property-Based Testing', () => { const outputPath = path.join( tempDir, - `obf_roundtrip_${Date.now()}_${Math.random()}.obz` + `obf_roundtrip_${Date.now()}_${Math.random()}.obz`, ); await processor.saveFromTree(originalTree, outputPath); @@ -207,33 +218,31 @@ describe('Property-Based Testing', () => { fs.unlinkSync(outputPath); // Should preserve button information - const originalButtonCount = Object.values(originalTree.pages).reduce( - (sum, page) => sum + page.buttons.length, - 0 - ); - const reloadedButtonCount = Object.values(reloadedTree.pages).reduce( - (sum, page) => sum + page.buttons.length, - 0 - ); + const originalButtonCount = Object.values( + originalTree.pages, + ).reduce((sum, page) => sum + page.buttons.length, 0); + const reloadedButtonCount = Object.values( + reloadedTree.pages, + ).reduce((sum, page) => sum + page.buttons.length, 0); // Should have some buttons if original had buttons return originalButtonCount === 0 || reloadedButtonCount > 0; } catch (error) { - console.log('OBF round-trip test failed (acceptable):', error); + console.log("OBF round-trip test failed (acceptable):", error); return true; } }), - { numRuns: 15 } + { numRuns: 15 }, ); }); }); - describe('Translation Invariant Tests', () => { + describe("Translation Invariant Tests", () => { const translationMapGenerator = fc .dictionary(validLabelGenerator, validLabelGenerator, { maxKeys: 10 }) .map((dict) => new Map(Object.entries(dict))); - it('Translation should preserve text count invariant', async () => { + it("Translation should preserve text count invariant", async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 10, maxLength: 1000 }), @@ -244,19 +253,19 @@ describe('Property-Based Testing', () => { try { // Create DOT-like content const dotContent = `digraph G {\n${content - .split(' ') + .split(" ") .slice(0, 5) .map((word, i) => ` node${i} [label="${word}"];`) - .join('\n')}\n}`; + .join("\n")}\n}`; const outputPath = path.join( tempDir, - `translation_test_${Date.now()}_${Math.random()}.dot` + `translation_test_${Date.now()}_${Math.random()}.dot`, ); const result = await processor.processTexts( Buffer.from(dotContent), translations, - outputPath + outputPath, ); // Clean up @@ -264,63 +273,69 @@ describe('Property-Based Testing', () => { fs.unlinkSync(outputPath); } - const translatedContent = Buffer.from(result).toString('utf8'); + const translatedContent = Buffer.from(result).toString("utf8"); // Should still be valid content expect(translatedContent.length).toBeGreaterThan(0); - expect(translatedContent).toContain('digraph'); + expect(translatedContent).toContain("digraph"); return true; } catch (error) { - console.log('Translation test failed (acceptable):', error); + console.log("Translation test failed (acceptable):", error); return true; } - } + }, ), - { numRuns: 20 } + { numRuns: 20 }, ); }); - it('Empty translation map should not change content', async () => { + it("Empty translation map should not change content", async () => { await fc.assert( - fc.asyncProperty(fc.string({ minLength: 10, maxLength: 200 }), async (content) => { - const processor = new DotProcessor(); - const emptyTranslations = new Map(); + fc.asyncProperty( + fc.string({ minLength: 10, maxLength: 200 }), + async (content) => { + const processor = new DotProcessor(); + const emptyTranslations = new Map(); - try { - const dotContent = `digraph G {\n test [label="${content.slice(0, 50)}"];\n}`; - const outputPath = path.join( - tempDir, - `empty_translation_${Date.now()}_${Math.random()}.dot` - ); + try { + const dotContent = `digraph G {\n test [label="${content.slice(0, 50)}"];\n}`; + const outputPath = path.join( + tempDir, + `empty_translation_${Date.now()}_${Math.random()}.dot`, + ); - const result = await processor.processTexts( - Buffer.from(dotContent), - emptyTranslations, - outputPath - ); + const result = await processor.processTexts( + Buffer.from(dotContent), + emptyTranslations, + outputPath, + ); - // Clean up - if (fs.existsSync(outputPath)) { - fs.unlinkSync(outputPath); - } + // Clean up + if (fs.existsSync(outputPath)) { + fs.unlinkSync(outputPath); + } - const translatedContent = Buffer.from(result).toString('utf8'); + const translatedContent = Buffer.from(result).toString("utf8"); - // Content should be essentially unchanged - return translatedContent.includes(content.slice(0, 50)) || translatedContent.length > 0; - } catch (error) { - console.log('Empty translation test failed (acceptable):', error); - return true; - } - }), - { numRuns: 15 } + // Content should be essentially unchanged + return ( + translatedContent.includes(content.slice(0, 50)) || + translatedContent.length > 0 + ); + } catch (error) { + console.log("Empty translation test failed (acceptable):", error); + return true; + } + }, + ), + { numRuns: 15 }, ); }); }); - describe('Data Structure Invariants', () => { - it('AACTree should maintain page uniqueness', async () => { + describe("Data Structure Invariants", () => { + it("AACTree should maintain page uniqueness", async () => { fc.assert( fc.property(aacTreeGenerator, (tree) => { const pageIds = Object.keys(tree.pages); @@ -329,11 +344,11 @@ describe('Property-Based Testing', () => { // All page IDs should be unique return pageIds.length === uniqueIds.size; }), - { numRuns: 50 } + { numRuns: 50 }, ); }); - it('AACPage should maintain button ID uniqueness within page', async () => { + it("AACPage should maintain button ID uniqueness within page", async () => { fc.assert( fc.property(aacPageGenerator, (page) => { const buttonIds = page.buttons.map((b) => b.id); @@ -342,18 +357,18 @@ describe('Property-Based Testing', () => { // All button IDs within a page should be unique return buttonIds.length === uniqueIds.size; }), - { numRuns: 50 } + { numRuns: 50 }, ); }); - it('Navigation buttons should have valid target page IDs', async () => { + it("Navigation buttons should have valid target page IDs", async () => { fc.assert( fc.property(aacTreeGenerator, (tree) => { const pageIds = new Set(Object.keys(tree.pages)); for (const page of Object.values(tree.pages)) { for (const button of page.buttons) { - if (button.type === 'NAVIGATE' && button.targetPageId) { + if (button.type === "NAVIGATE" && button.targetPageId) { // Navigation buttons should either have valid targets or be acceptable as invalid // (since we're testing with generated data, some invalid references are expected) if (!pageIds.has(button.targetPageId)) { @@ -366,13 +381,13 @@ describe('Property-Based Testing', () => { return true; // Always pass as we're testing the structure, not the validity }), - { numRuns: 30 } + { numRuns: 30 }, ); }); }); - describe('Text Extraction Properties', () => { - it('Extracted texts should be non-empty strings', async () => { + describe("Text Extraction Properties", () => { + it("Extracted texts should be non-empty strings", async () => { await fc.assert( fc.asyncProperty(aacTreeGenerator, async (tree) => { const processor = new DotProcessor(); @@ -383,8 +398,10 @@ describe('Property-Based Testing', () => { (page) => page.name.trim().length > 0 || page.buttons.some( - (button) => button.label.trim().length > 0 || button.message.trim().length > 0 - ) + (button) => + button.label.trim().length > 0 || + button.message.trim().length > 0, + ), ); if (!hasContent) { @@ -393,7 +410,7 @@ describe('Property-Based Testing', () => { const outputPath = path.join( tempDir, - `text_extraction_${Date.now()}_${Math.random()}.dot` + `text_extraction_${Date.now()}_${Math.random()}.dot`, ); await processor.saveFromTree(tree, outputPath); @@ -403,23 +420,27 @@ describe('Property-Based Testing', () => { fs.unlinkSync(outputPath); // All extracted texts should be strings - const allStrings = extractedTexts.every((text) => typeof text === 'string'); + const allStrings = extractedTexts.every( + (text) => typeof text === "string", + ); // If we have content, we should extract some non-empty texts - const nonEmptyTexts = extractedTexts.filter((text) => text.trim().length > 0); + const nonEmptyTexts = extractedTexts.filter( + (text) => text.trim().length > 0, + ); const hasNonEmptyTexts = nonEmptyTexts.length > 0; return allStrings && hasNonEmptyTexts; } catch (error) { - console.log('Text extraction test failed (acceptable):', error); + console.log("Text extraction test failed (acceptable):", error); return true; } }), - { numRuns: 20 } + { numRuns: 20 }, ); }); - it('Text extraction should be deterministic', async () => { + it("Text extraction should be deterministic", async () => { await fc.assert( fc.asyncProperty(aacTreeGenerator, async (tree) => { const processor = new DotProcessor(); @@ -427,7 +448,7 @@ describe('Property-Based Testing', () => { try { const outputPath = path.join( tempDir, - `deterministic_${Date.now()}_${Math.random()}.dot` + `deterministic_${Date.now()}_${Math.random()}.dot`, ); await processor.saveFromTree(tree, outputPath); @@ -441,71 +462,84 @@ describe('Property-Based Testing', () => { // Results should be identical return JSON.stringify(texts1) === JSON.stringify(texts2); } catch (error) { - console.log('Deterministic test failed (acceptable):', error); + console.log("Deterministic test failed (acceptable):", error); return true; } }), - { numRuns: 15 } + { numRuns: 15 }, ); }); }); - describe('Error Handling Properties', () => { - it('Invalid input should not crash processors', async () => { + describe("Error Handling Properties", () => { + it("Invalid input should not crash processors", async () => { await fc.assert( - fc.asyncProperty(fc.uint8Array({ minLength: 0, maxLength: 1000 }), async (randomBytes) => { - const processors = [ - new DotProcessor(), - new OpmlProcessor(), - new ObfProcessor(), - new ApplePanelsProcessor(), - ]; - - for (const processor of processors) { - try { - const result = await processor.loadIntoTree(Buffer.from(randomBytes)); - // Should return a valid AACTree (might be empty) - expect(result).toBeInstanceOf(AACTree); - } catch (error) { - // Throwing an error is also acceptable - expect(error).toBeInstanceOf(Error); + fc.asyncProperty( + fc.uint8Array({ minLength: 0, maxLength: 1000 }), + async (randomBytes) => { + const processors = [ + new DotProcessor(), + new OpmlProcessor(), + new ObfProcessor(), + new ApplePanelsProcessor(), + ]; + + for (const processor of processors) { + try { + const result = await processor.loadIntoTree( + Buffer.from(randomBytes), + ); + // Should return a valid AACTree (might be empty) + expect(result).toBeInstanceOf(AACTree); + } catch (error) { + // Throwing an error is also acceptable + expect(error).toBeInstanceOf(Error); + } } - } - return true; - }), - { numRuns: 30 } + return true; + }, + ), + { numRuns: 30 }, ); }); - it('Processors should handle extremely large valid inputs gracefully', async () => { + it("Processors should handle extremely large valid inputs gracefully", async () => { await fc.assert( - fc.asyncProperty(fc.integer({ min: 100, max: 1000 }), async (nodeCount) => { - const processor = new DotProcessor(); + fc.asyncProperty( + fc.integer({ min: 100, max: 1000 }), + async (nodeCount) => { + const processor = new DotProcessor(); - try { - // Generate large but valid DOT content - const lines = ['digraph G {']; - for (let i = 0; i < nodeCount; i++) { - lines.push(` node${i} [label="Node ${i}"];`); - } - lines.push('}'); + try { + // Generate large but valid DOT content + const lines = ["digraph G {"]; + for (let i = 0; i < nodeCount; i++) { + lines.push(` node${i} [label="Node ${i}"];`); + } + lines.push("}"); - const largeContent = lines.join('\n'); - const tree = await processor.loadIntoTree(Buffer.from(largeContent)); + const largeContent = lines.join("\n"); + const tree = await processor.loadIntoTree( + Buffer.from(largeContent), + ); - // Should handle large input without crashing - expect(tree).toBeInstanceOf(AACTree); - expect(Object.keys(tree.pages).length).toBeGreaterThan(0); + // Should handle large input without crashing + expect(tree).toBeInstanceOf(AACTree); + expect(Object.keys(tree.pages).length).toBeGreaterThan(0); - return true; - } catch (error) { - // If it fails due to memory/performance limits, that's acceptable - console.log(`Large input test failed for ${nodeCount} nodes (acceptable):`, error); - return true; - } - }), - { numRuns: 10 } + return true; + } catch (error) { + // If it fails due to memory/performance limits, that's acceptable + console.log( + `Large input test failed for ${nodeCount} nodes (acceptable):`, + error, + ); + return true; + } + }, + ), + { numRuns: 10 }, ); }); }); diff --git a/test/scanningMetrics.test.ts b/test/scanningMetrics.test.ts index b6bc7c8..33ad55a 100644 --- a/test/scanningMetrics.test.ts +++ b/test/scanningMetrics.test.ts @@ -1,21 +1,44 @@ -import { describe, expect, it } from '@jest/globals'; -import { AACTree, AACPage, AACButton, AACScanType } from '../src/core/treeStructure'; -import { MetricsCalculator } from '../src/utilities/analytics/metrics/core'; - -describe('Scanning Metrics', () => { - it('calculates linear scanning effort correctly', async () => { +import { describe, expect, it } from "@jest/globals"; +import { + AACTree, + AACPage, + AACButton, + AACScanType, +} from "../src/core/treeStructure"; +import { MetricsCalculator } from "../src/utilities/analytics/metrics/core"; + +describe("Scanning Metrics", () => { + it("calculates linear scanning effort correctly", async () => { const tree = new AACTree(); const page = new AACPage({ - id: 'root', - name: 'Home', + id: "root", + name: "Home", grid: { columns: 2, rows: 2 }, scanType: AACScanType.LINEAR, }); // Target button is #3 (linear index 2) - const btn1 = new AACButton({ id: 'btn1', label: '1', type: 'SPEAK', x: 0, y: 0 }); - const btn2 = new AACButton({ id: 'btn2', label: '2', type: 'SPEAK', x: 1, y: 0 }); - const btn3 = new AACButton({ id: 'btn3', label: '3', type: 'SPEAK', x: 0, y: 1 }); // Target + const btn1 = new AACButton({ + id: "btn1", + label: "1", + type: "SPEAK", + x: 0, + y: 0, + }); + const btn2 = new AACButton({ + id: "btn2", + label: "2", + type: "SPEAK", + x: 1, + y: 0, + }); + const btn3 = new AACButton({ + id: "btn3", + label: "3", + type: "SPEAK", + x: 0, + y: 1, + }); // Target page.grid[0][0] = btn1; page.grid[0][1] = btn2; @@ -26,12 +49,12 @@ describe('Scanning Metrics', () => { page.addButton(btn3); tree.addPage(page); - tree.rootId = 'root'; + tree.rootId = "root"; const calculator = new MetricsCalculator(); const result = calculator.analyze(tree); - const btn3Metrics = result.buttons.find((b) => b.label === '3'); + const btn3Metrics = result.buttons.find((b) => b.label === "3"); // Steps = 3 (buttons 1, 2, 3), Selections = 1 // Scan Effort = 3 * 0.015 + 1 * 0.1 = 0.045 + 0.1 = 0.145 // baseBoardEffort(2, 2, 4): @@ -42,17 +65,23 @@ describe('Scanning Metrics', () => { expect(btn3Metrics?.effort).toBeCloseTo(0.345, 4); }); - it('calculates row-column scanning effort correctly', async () => { + it("calculates row-column scanning effort correctly", async () => { const tree = new AACTree(); const page = new AACPage({ - id: 'root', - name: 'Home', + id: "root", + name: "Home", grid: { columns: 5, rows: 5 }, scanType: AACScanType.ROW_COLUMN, }); // Target button at row 3 (index 2), col 4 (index 3) - const btn = new AACButton({ id: 'target', label: 'Target', type: 'SPEAK', x: 3, y: 2 }); + const btn = new AACButton({ + id: "target", + label: "Target", + type: "SPEAK", + x: 3, + y: 2, + }); page.grid[2][3] = btn; page.addButton(btn); @@ -61,7 +90,7 @@ describe('Scanning Metrics', () => { const calculator = new MetricsCalculator(); const result = calculator.analyze(tree); - const metrics = result.buttons.find((b) => b.label === 'Target'); + const metrics = result.buttons.find((b) => b.label === "Target"); // Steps = (rowIndex + 1) + (colIndex + 1) = (2 + 1) + (3 + 1) = 7 steps // Selections = 2 // Scan Effort = 7 * 0.015 + 2 * 0.1 = 0.105 + 0.2 = 0.305 @@ -73,37 +102,37 @@ describe('Scanning Metrics', () => { expect(metrics?.effort).toBeCloseTo(0.88, 4); }); - it('calculates block scanning effort correctly', async () => { + it("calculates block scanning effort correctly", async () => { const tree = new AACTree(); const page = new AACPage({ - id: 'root', - name: 'Home', + id: "root", + name: "Home", grid: { columns: 4, rows: 4 }, scanType: AACScanType.BLOCK_ROW_COLUMN, scanBlocksConfig: [ - { id: 1, name: 'Block A', order: 1 }, - { id: 2, name: 'Block B', order: 2 }, + { id: 1, name: "Block A", order: 1 }, + { id: 2, name: "Block B", order: 2 }, ], }); // Target button in Block B, at some position const btnA = new AACButton({ - id: 'a', - label: 'A', + id: "a", + label: "A", scanBlocks: [1], - type: 'SPEAK', + type: "SPEAK", }); const btnB1 = new AACButton({ - id: 'b1', - label: 'B1', + id: "b1", + label: "B1", scanBlocks: [2], - type: 'SPEAK', + type: "SPEAK", }); const btnB2 = new AACButton({ - id: 'b2', - label: 'B2', + id: "b2", + label: "B2", scanBlocks: [2], - type: 'SPEAK', + type: "SPEAK", }); // Target page.grid[0][0] = btnA; @@ -119,7 +148,7 @@ describe('Scanning Metrics', () => { const calculator = new MetricsCalculator(); const result = calculator.analyze(tree); - const metrics = result.buttons.find((b) => b.label === 'B2'); + const metrics = result.buttons.find((b) => b.label === "B2"); // Steps = blockOrder (2) + btnInBlockIndex (1) + 1 = 4 steps // Selections = 2 // Scan Effort = 4 * 0.015 + 2 * 0.1 = 0.06 + 0.2 = 0.26 @@ -130,11 +159,11 @@ describe('Scanning Metrics', () => { expect(metrics?.effort).toBeCloseTo(0.7, 4); }); - it('calculates error correction effort correctly', async () => { + it("calculates error correction effort correctly", async () => { const tree = new AACTree(); const page = new AACPage({ - id: 'root', - name: 'Home', + id: "root", + name: "Home", grid: { columns: 10, rows: 1 }, scanningConfig: { errorCorrectionEnabled: true, @@ -142,7 +171,7 @@ describe('Scanning Metrics', () => { }, }); - const btn1 = new AACButton({ id: 'btn1', label: 'B1', type: 'SPEAK' }); + const btn1 = new AACButton({ id: "btn1", label: "B1", type: "SPEAK" }); page.grid[0][0] = btn1; page.addButton(btn1); tree.addPage(page); @@ -150,7 +179,7 @@ describe('Scanning Metrics', () => { const calculator = new MetricsCalculator(); const result = calculator.analyze(tree); - const metrics = result.buttons.find((b) => b.label === 'B1'); + const metrics = result.buttons.find((b) => b.label === "B1"); // B1 is at root, index 0. // Steps = 1, Selections = 1 // Ideal Scan Effort = 1 * 0.015 + 1 * 0.1 = 0.115 diff --git a/test/snapProcessor.audio.comprehensive.test.ts b/test/snapProcessor.audio.comprehensive.test.ts index ff16661..5b4ae87 100644 --- a/test/snapProcessor.audio.comprehensive.test.ts +++ b/test/snapProcessor.audio.comprehensive.test.ts @@ -1,18 +1,18 @@ // Comprehensive tests for SnapProcessor to improve coverage from 67.11% to 85%+ -import fs from 'fs'; -import path from 'path'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { AACTree } from '../src/core/treeStructure'; -import { PageFactory, ButtonFactory } from './utils/testFactories'; +import fs from "fs"; +import path from "path"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { AACTree } from "../src/core/treeStructure"; +import { PageFactory, ButtonFactory } from "./utils/testFactories"; -describe('SnapProcessor - Comprehensive Coverage Tests', () => { +describe("SnapProcessor - Comprehensive Coverage Tests", () => { let processor: SnapProcessor; - const tempDir = path.join(__dirname, 'temp_snap'); - const _exampleFile = path.join(__dirname, 'assets/snap/example.sps'); + const tempDir = path.join(__dirname, "temp_snap"); + const _exampleFile = path.join(__dirname, "assets/snap/example.sps"); let warnSpy: jest.SpyInstance; beforeAll(async () => { - warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } @@ -29,86 +29,88 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { warnSpy.mockRestore(); }); - describe('Audio Handling Tests', () => { - it('should load audio recordings from SPS database', async () => { + describe("Audio Handling Tests", () => { + it("should load audio recordings from SPS database", async () => { // Create a button with audio recording const button = ButtonFactory.create({ - label: 'Audio Button', - message: 'I have audio', - type: 'SPEAK', + label: "Audio Button", + message: "I have audio", + type: "SPEAK", }); // Add audio recording button.audioRecording = { id: 1, - data: Buffer.from('fake audio data for testing'), - identifier: 'audio_1', - metadata: 'Test audio recording', + data: Buffer.from("fake audio data for testing"), + identifier: "audio_1", + metadata: "Test audio recording", }; const page = PageFactory.create({ - id: 'audio_page', - name: 'Audio Test Page', + id: "audio_page", + name: "Audio Test Page", }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, 'audio_test.sps'); + const outputPath = path.join(tempDir, "audio_test.sps"); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); // Load and verify audio is preserved const loadedTree = await processor.loadIntoTree(outputPath); - const loadedPage = loadedTree.getPage('audio_page'); + const loadedPage = loadedTree.getPage("audio_page"); expect(loadedPage).toBeDefined(); if (!loadedPage) { return; } expect(loadedPage.buttons).toHaveLength(1); - expect(loadedPage.buttons[0].label).toBe('Audio Button'); + expect(loadedPage.buttons[0].label).toBe("Audio Button"); }); - it('should handle missing audio files gracefully', async () => { + it("should handle missing audio files gracefully", async () => { // Create a button that references non-existent audio const button = ButtonFactory.create({ - label: 'Missing Audio Button', - message: 'No audio here', - type: 'SPEAK', + label: "Missing Audio Button", + message: "No audio here", + type: "SPEAK", }); // Set audio recording with invalid data button.audioRecording = { id: 999, data: Buffer.alloc(0), // Empty buffer - identifier: 'missing_audio', - metadata: 'Non-existent audio', + identifier: "missing_audio", + metadata: "Non-existent audio", }; const page = PageFactory.create({ - id: 'missing_audio_page', - name: 'Missing Audio Page', + id: "missing_audio_page", + name: "Missing Audio Page", }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, 'missing_audio.sps'); + const outputPath = path.join(tempDir, "missing_audio.sps"); - await expect(processor.saveFromTree(tree, outputPath)).resolves.not.toThrow(); + await expect( + processor.saveFromTree(tree, outputPath), + ).resolves.not.toThrow(); const loadedTree = await processor.loadIntoTree(outputPath); expect(loadedTree).toBeDefined(); }); - it('should process different audio formats (WAV, MP3, AAC)', async () => { + it("should process different audio formats (WAV, MP3, AAC)", async () => { const audioFormats = [ - { format: 'WAV', data: Buffer.from('RIFF....WAVE'), extension: '.wav' }, - { format: 'MP3', data: Buffer.from('ID3....'), extension: '.mp3' }, - { format: 'AAC', data: Buffer.from('ADTS....'), extension: '.aac' }, + { format: "WAV", data: Buffer.from("RIFF....WAVE"), extension: ".wav" }, + { format: "MP3", data: Buffer.from("ID3...."), extension: ".mp3" }, + { format: "AAC", data: Buffer.from("ADTS...."), extension: ".aac" }, ]; const tree = new AACTree(); @@ -117,7 +119,7 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { const button = ButtonFactory.create({ label: `${format.format} Button`, message: `Audio in ${format.format}`, - type: 'SPEAK', + type: "SPEAK", }); button.audioRecording = { @@ -135,24 +137,24 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { tree.addPage(page); }); - const outputPath = path.join(tempDir, 'multi_format_audio.sps'); + const outputPath = path.join(tempDir, "multi_format_audio.sps"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); expect(Object.keys(loadedTree.pages)).toHaveLength(3); }); - it('should add new audio recordings to buttons', async () => { + it("should add new audio recordings to buttons", async () => { // Start with a button without audio const button = ButtonFactory.create({ - label: 'No Audio Button', - message: 'Initially no audio', - type: 'SPEAK', + label: "No Audio Button", + message: "Initially no audio", + type: "SPEAK", }); const page = PageFactory.create({ - id: 'add_audio_page', - name: 'Add Audio Page', + id: "add_audio_page", + name: "Add Audio Page", }); page.addButton(button); @@ -160,12 +162,12 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { tree.addPage(page); // Save initial version - const outputPath = path.join(tempDir, 'add_audio.sps'); + const outputPath = path.join(tempDir, "add_audio.sps"); await processor.saveFromTree(tree, outputPath); // Load and add audio const loadedTree = await processor.loadIntoTree(outputPath); - const loadedPage = loadedTree.getPage('add_audio_page'); + const loadedPage = loadedTree.getPage("add_audio_page"); expect(loadedPage).toBeDefined(); if (!loadedPage) { return; @@ -175,18 +177,18 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { // Add audio recording loadedButton.audioRecording = { id: 1, - data: Buffer.from('newly added audio data'), - identifier: 'new_audio', - metadata: 'Newly added audio', + data: Buffer.from("newly added audio data"), + identifier: "new_audio", + metadata: "Newly added audio", }; // Save with audio - const updatedPath = path.join(tempDir, 'add_audio_updated.sps'); + const updatedPath = path.join(tempDir, "add_audio_updated.sps"); await processor.saveFromTree(loadedTree, updatedPath); // Verify audio was added const finalTree = await processor.loadIntoTree(updatedPath); - const finalPage = finalTree.getPage('add_audio_page'); + const finalPage = finalTree.getPage("add_audio_page"); expect(finalPage).toBeDefined(); if (!finalPage) { return; @@ -194,39 +196,39 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { const finalButton = finalPage.buttons[0]; expect(finalButton.audioRecording).toBeDefined(); - expect(finalButton.audioRecording?.identifier).toBe('new_audio'); + expect(finalButton.audioRecording?.identifier).toBe("new_audio"); }); - it('should update existing audio recordings', async () => { + it("should update existing audio recordings", async () => { // Create button with initial audio const button = ButtonFactory.create({ - label: 'Update Audio Button', - message: 'Audio will be updated', - type: 'SPEAK', + label: "Update Audio Button", + message: "Audio will be updated", + type: "SPEAK", }); button.audioRecording = { id: 1, - data: Buffer.from('original audio data'), - identifier: 'original_audio', - metadata: 'Original audio', + data: Buffer.from("original audio data"), + identifier: "original_audio", + metadata: "Original audio", }; const page = PageFactory.create({ - id: 'update_audio_page', - name: 'Update Audio Page', + id: "update_audio_page", + name: "Update Audio Page", }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, 'update_audio.sps'); + const outputPath = path.join(tempDir, "update_audio.sps"); await processor.saveFromTree(tree, outputPath); // Load and update audio const loadedTree = await processor.loadIntoTree(outputPath); - const updatePage = loadedTree.getPage('update_audio_page'); + const updatePage = loadedTree.getPage("update_audio_page"); expect(updatePage).toBeDefined(); if (!updatePage) { return; @@ -236,57 +238,57 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { // Update audio recording loadedButton.audioRecording = { id: 1, - data: Buffer.from('updated audio data'), - identifier: 'updated_audio', - metadata: 'Updated audio', + data: Buffer.from("updated audio data"), + identifier: "updated_audio", + metadata: "Updated audio", }; - const updatedPath = path.join(tempDir, 'update_audio_final.sps'); + const updatedPath = path.join(tempDir, "update_audio_final.sps"); await processor.saveFromTree(loadedTree, updatedPath); // Verify audio was updated const finalTree = await processor.loadIntoTree(updatedPath); - const finalPage = finalTree.getPage('update_audio_page'); + const finalPage = finalTree.getPage("update_audio_page"); expect(finalPage).toBeDefined(); if (!finalPage) { return; } const finalButton = finalPage.buttons[0]; - expect(finalButton.audioRecording?.identifier).toBe('updated_audio'); - expect(finalButton.audioRecording?.metadata).toBe('Updated audio'); + expect(finalButton.audioRecording?.identifier).toBe("updated_audio"); + expect(finalButton.audioRecording?.metadata).toBe("Updated audio"); }); - it('should remove audio recordings from buttons', async () => { + it("should remove audio recordings from buttons", async () => { // Create button with audio const button = ButtonFactory.create({ - label: 'Remove Audio Button', - message: 'Audio will be removed', - type: 'SPEAK', + label: "Remove Audio Button", + message: "Audio will be removed", + type: "SPEAK", }); button.audioRecording = { id: 1, - data: Buffer.from('audio to be removed'), - identifier: 'removable_audio', - metadata: 'Audio to be removed', + data: Buffer.from("audio to be removed"), + identifier: "removable_audio", + metadata: "Audio to be removed", }; const page = PageFactory.create({ - id: 'remove_audio_page', - name: 'Remove Audio Page', + id: "remove_audio_page", + name: "Remove Audio Page", }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, 'remove_audio.sps'); + const outputPath = path.join(tempDir, "remove_audio.sps"); await processor.saveFromTree(tree, outputPath); // Load and remove audio const loadedTree = await processor.loadIntoTree(outputPath); - const removePage = loadedTree.getPage('remove_audio_page'); + const removePage = loadedTree.getPage("remove_audio_page"); expect(removePage).toBeDefined(); if (!removePage) { return; @@ -296,12 +298,12 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { // Remove audio recording loadedButton.audioRecording = undefined; - const updatedPath = path.join(tempDir, 'remove_audio_final.sps'); + const updatedPath = path.join(tempDir, "remove_audio_final.sps"); await processor.saveFromTree(loadedTree, updatedPath); // Verify audio was removed const finalTree = await processor.loadIntoTree(updatedPath); - const finalPage = finalTree.getPage('remove_audio_page'); + const finalPage = finalTree.getPage("remove_audio_page"); expect(finalPage).toBeDefined(); if (!finalPage) { return; @@ -310,11 +312,11 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { expect(finalButton.audioRecording).toBeUndefined(); }); - it('should preserve audio metadata during processing', async () => { + it("should preserve audio metadata during processing", async () => { const button = ButtonFactory.create({ - label: 'Metadata Button', - message: 'Audio with metadata', - type: 'SPEAK', + label: "Metadata Button", + message: "Audio with metadata", + type: "SPEAK", }); const complexMetadata = JSON.stringify({ @@ -322,31 +324,31 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { bitDepth: 16, channels: 2, duration: 2.5, - format: 'WAV', + format: "WAV", created: new Date().toISOString(), }); button.audioRecording = { id: 1, - data: Buffer.from('audio with complex metadata'), - identifier: 'metadata_audio', + data: Buffer.from("audio with complex metadata"), + identifier: "metadata_audio", metadata: complexMetadata, }; const page = PageFactory.create({ - id: 'metadata_page', - name: 'Metadata Page', + id: "metadata_page", + name: "Metadata Page", }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, 'metadata_test.sps'); + const outputPath = path.join(tempDir, "metadata_test.sps"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); - const loadedPage = loadedTree.getPage('metadata_page'); + const loadedPage = loadedTree.getPage("metadata_page"); expect(loadedPage).toBeDefined(); if (!loadedPage) { return; @@ -357,12 +359,14 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { expect(loadedButton.audioRecording?.metadata).toBe(complexMetadata); // Verify metadata can be parsed back - const parsedMetadata = JSON.parse(loadedButton.audioRecording?.metadata || '{}'); + const parsedMetadata = JSON.parse( + loadedButton.audioRecording?.metadata || "{}", + ); expect(parsedMetadata.sampleRate).toBe(44100); - expect(parsedMetadata.format).toBe('WAV'); + expect(parsedMetadata.format).toBe("WAV"); }); - it('should handle audio with different sample rates', async () => { + it("should handle audio with different sample rates", async () => { const sampleRates = [8000, 16000, 22050, 44100, 48000, 96000]; const tree = new AACTree(); @@ -370,7 +374,7 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { const button = ButtonFactory.create({ label: `${rate}Hz Button`, message: `Audio at ${rate}Hz`, - type: 'SPEAK', + type: "SPEAK", }); button.audioRecording = { @@ -388,7 +392,7 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { tree.addPage(page); }); - const outputPath = path.join(tempDir, 'sample_rates.sps'); + const outputPath = path.join(tempDir, "sample_rates.sps"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -405,12 +409,14 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { expect(page.buttons.length).toBeGreaterThan(0); expect(page.buttons[0].audioRecording).toBeDefined(); - const metadata = JSON.parse(page.buttons[0].audioRecording?.metadata || '{}'); + const metadata = JSON.parse( + page.buttons[0].audioRecording?.metadata || "{}", + ); expect(metadata.sampleRate).toBe(rate); }); }); - it('should process audio with various bit depths', async () => { + it("should process audio with various bit depths", async () => { const bitDepths = [8, 16, 24, 32]; const tree = new AACTree(); @@ -418,7 +424,7 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { const button = ButtonFactory.create({ label: `${depth}-bit Button`, message: `Audio at ${depth}-bit`, - type: 'SPEAK', + type: "SPEAK", }); button.audioRecording = { @@ -436,7 +442,7 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { tree.addPage(page); }); - const outputPath = path.join(tempDir, 'bit_depths.sps'); + const outputPath = path.join(tempDir, "bit_depths.sps"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -453,7 +459,9 @@ describe('SnapProcessor - Comprehensive Coverage Tests', () => { expect(page.buttons.length).toBeGreaterThan(0); expect(page.buttons[0].audioRecording).toBeDefined(); - const metadata = JSON.parse(page.buttons[0].audioRecording?.metadata || '{}'); + const metadata = JSON.parse( + page.buttons[0].audioRecording?.metadata || "{}", + ); expect(metadata.bitDepth).toBe(depth); }); }); diff --git a/test/snapProcessor.audio.test.ts b/test/snapProcessor.audio.test.ts index b6eed5d..e3a67be 100644 --- a/test/snapProcessor.audio.test.ts +++ b/test/snapProcessor.audio.test.ts @@ -1,21 +1,21 @@ -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { AACTree, AACPage } from '../src/core/treeStructure'; -import path from 'path'; -import fs from 'fs'; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { AACTree, AACPage } from "../src/core/treeStructure"; +import path from "path"; +import fs from "fs"; -describe('SnapProcessor Audio Support', () => { +describe("SnapProcessor Audio Support", () => { const exampleSPSFile: string = path.join( __dirname, - 'assets/snap/Aphasia Page Set With Sound.sps' + "assets/snap/Aphasia Page Set With Sound.sps", ); const enhancedSPSFile: string = path.join( __dirname, - 'assets/snap/Aphasia_Page_Set_With_Punjabi_Audio.sps' + "assets/snap/Aphasia_Page_Set_With_Punjabi_Audio.sps", ); - it('should load pageset without audio by default', async () => { + it("should load pageset without audio by default", async () => { if (!fs.existsSync(exampleSPSFile)) { - console.log('Skipping test - audio example file not found'); + console.log("Skipping test - audio example file not found"); return; } @@ -36,9 +36,9 @@ describe('SnapProcessor Audio Support', () => { } }); - it('should load pageset with audio when requested', async () => { + it("should load pageset with audio when requested", async () => { if (!fs.existsSync(exampleSPSFile)) { - console.log('Skipping test - audio example file not found'); + console.log("Skipping test - audio example file not found"); return; } @@ -65,9 +65,9 @@ describe('SnapProcessor Audio Support', () => { expect(foundAudioButton).toBe(true); }); - it('should extract buttons for audio processing', async () => { + it("should extract buttons for audio processing", async () => { if (!fs.existsSync(exampleSPSFile)) { - console.log('Skipping test - audio example file not found'); + console.log("Skipping test - audio example file not found"); return; } @@ -84,53 +84,55 @@ describe('SnapProcessor Audio Support', () => { if (pageWithButtons) { const buttons = (processor as any).extractButtonsForAudio( exampleSPSFile, - pageWithButtons.id + pageWithButtons.id, ); expect(Array.isArray(buttons)).toBe(true); if (buttons.length > 0) { const firstButton = buttons[0]; - expect(firstButton).toHaveProperty('id'); - expect(firstButton).toHaveProperty('label'); - expect(firstButton).toHaveProperty('message'); - expect(firstButton).toHaveProperty('hasAudio'); - expect(typeof firstButton.hasAudio).toBe('boolean'); + expect(firstButton).toHaveProperty("id"); + expect(firstButton).toHaveProperty("label"); + expect(firstButton).toHaveProperty("message"); + expect(firstButton).toHaveProperty("hasAudio"); + expect(typeof firstButton.hasAudio).toBe("boolean"); } } } } catch (error: any) { - console.log('Could not test button extraction:', error.message); + console.log("Could not test button extraction:", error.message); } }); - it('should add audio to buttons', async () => { + it("should add audio to buttons", async () => { if (!fs.existsSync(exampleSPSFile)) { - console.log('Skipping test - audio example file not found'); + console.log("Skipping test - audio example file not found"); return; } const processor = new SnapProcessor(); - const testDbPath: string = path.join(__dirname, 'test_audio_temp.sps'); + const testDbPath: string = path.join(__dirname, "test_audio_temp.sps"); try { // Copy the example file for testing fs.copyFileSync(exampleSPSFile, testDbPath); // Create some test audio data - const testAudioData: Uint8Array = new Uint8Array(Buffer.from('RIFF....WAVE....', 'ascii')); // Minimal WAV-like data + const testAudioData: Uint8Array = new Uint8Array( + Buffer.from("RIFF....WAVE....", "ascii"), + ); // Minimal WAV-like data // Add audio to a button (using button ID 1 as a test) const audioId: number = await processor.addAudioToButton( testDbPath, 1, testAudioData, - 'Test Audio' + "Test Audio", ); - expect(typeof audioId).toBe('number'); + expect(typeof audioId).toBe("number"); expect(audioId).toBeGreaterThan(0); } catch (error: any) { - console.log('Could not test audio addition:', error.message); + console.log("Could not test audio addition:", error.message); } finally { // Clean up if (fs.existsSync(testDbPath)) { @@ -139,9 +141,9 @@ describe('SnapProcessor Audio Support', () => { } }); - it('should load enhanced pageset with Punjabi audio', async () => { + it("should load enhanced pageset with Punjabi audio", async () => { if (!fs.existsSync(enhancedSPSFile)) { - console.log('Skipping test - enhanced pageset not found'); + console.log("Skipping test - enhanced pageset not found"); return; } @@ -153,15 +155,17 @@ describe('SnapProcessor Audio Support', () => { // Look for the QuickFires page const quickFiresPage = Object.values(tree.pages).find( - (page) => page.name && page.name.includes('QuickFires') + (page) => page.name && page.name.includes("QuickFires"), ); if (quickFiresPage) { - console.log(`Found QuickFires page with ${quickFiresPage.buttons.length} buttons`); + console.log( + `Found QuickFires page with ${quickFiresPage.buttons.length} buttons`, + ); // Count buttons with audio const buttonsWithAudio = quickFiresPage.buttons.filter( - (button) => button.audioRecording && button.audioRecording.data + (button) => button.audioRecording && button.audioRecording.data, ); console.log(`Buttons with audio: ${buttonsWithAudio.length}`); @@ -184,37 +188,47 @@ describe('SnapProcessor Audio Support', () => { } }); } else { - console.log('QuickFires page not found in enhanced pageset'); + console.log("QuickFires page not found in enhanced pageset"); } }); }); -describe('SnapProcessor Audio Integration', () => { - it('should demonstrate complete audio workflow', async () => { - console.log('\n=== SnapProcessor Audio Integration Demo ==='); - console.log('1. Basic usage (no audio):'); - console.log(' const processor = new SnapProcessor();'); +describe("SnapProcessor Audio Integration", () => { + it("should demonstrate complete audio workflow", async () => { + console.log("\n=== SnapProcessor Audio Integration Demo ==="); + console.log("1. Basic usage (no audio):"); + console.log(" const processor = new SnapProcessor();"); console.log(' const tree = await processor.loadIntoTree("pageset.sps");'); - console.log('\n2. With audio support:'); - console.log(' const processor = new SnapProcessor(null, { loadAudio: true });'); + console.log("\n2. With audio support:"); + console.log( + " const processor = new SnapProcessor(null, { loadAudio: true });", + ); console.log(' const tree = await processor.loadIntoTree("pageset.sps");'); - console.log(' // Buttons will have audioRecording property if available'); + console.log(" // Buttons will have audioRecording property if available"); - console.log('\n3. Adding audio to buttons:'); + console.log("\n3. Adding audio to buttons:"); console.log(' const audioData = fs.readFileSync("audio.wav");'); console.log( - ' const audioId = processor.addAudioToButton(dbPath, buttonId, audioData, "metadata");' + ' const audioId = processor.addAudioToButton(dbPath, buttonId, audioData, "metadata");', ); - console.log('\n4. Creating enhanced pageset:'); - console.log(' const audioMappings = new Map();'); - console.log(' audioMappings.set(buttonId, { audioData, metadata: "Punjabi audio" });'); - console.log(' processor.createAudioEnhancedPageset(source, target, audioMappings);'); + console.log("\n4. Creating enhanced pageset:"); + console.log(" const audioMappings = new Map();"); + console.log( + ' audioMappings.set(buttonId, { audioData, metadata: "Punjabi audio" });', + ); + console.log( + " processor.createAudioEnhancedPageset(source, target, audioMappings);", + ); - console.log('\n5. Extracting buttons for processing:'); - console.log(' const buttons = processor.extractButtonsForAudio(dbPath, pageUniqueId);'); - console.log(' // Returns array with id, label, message, hasAudio properties'); + console.log("\n5. Extracting buttons for processing:"); + console.log( + " const buttons = processor.extractButtonsForAudio(dbPath, pageUniqueId);", + ); + console.log( + " // Returns array with id, label, message, hasAudio properties", + ); expect(true).toBe(true); // This is just a demo test }); diff --git a/test/snapProcessor.corruption.performance.test.ts b/test/snapProcessor.corruption.performance.test.ts index 322991c..58158ea 100644 --- a/test/snapProcessor.corruption.performance.test.ts +++ b/test/snapProcessor.corruption.performance.test.ts @@ -1,13 +1,13 @@ // Database corruption and performance tests for SnapProcessor -import fs from 'fs'; -import path from 'path'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { AACTree } from '../src/core/treeStructure'; -import { TreeFactory, PageFactory, ButtonFactory } from './utils/testFactories'; +import fs from "fs"; +import path from "path"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { AACTree } from "../src/core/treeStructure"; +import { TreeFactory, PageFactory, ButtonFactory } from "./utils/testFactories"; -describe('SnapProcessor - Database Corruption & Performance Tests', () => { +describe("SnapProcessor - Database Corruption & Performance Tests", () => { let processor: SnapProcessor; - const tempDir = path.join(__dirname, 'temp_snap_corruption'); + const tempDir = path.join(__dirname, "temp_snap_corruption"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -25,11 +25,11 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { } }); - describe('Database Corruption Handling', () => { - it('should handle partially corrupted SPS files', async () => { + describe("Database Corruption Handling", () => { + it("should handle partially corrupted SPS files", async () => { // Create a valid SPS file first const tree = TreeFactory.createSimple(); - const validPath = path.join(tempDir, 'valid.sps'); + const validPath = path.join(tempDir, "valid.sps"); await processor.saveFromTree(tree, validPath); // Read the valid file and corrupt part of it @@ -43,38 +43,38 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { corruptedData[i] = Math.floor(Math.random() * 256); } - const corruptedPath = path.join(tempDir, 'partially_corrupted.sps'); + const corruptedPath = path.join(tempDir, "partially_corrupted.sps"); fs.writeFileSync(corruptedPath, corruptedData); // Should handle corruption gracefully await expect(processor.loadIntoTree(corruptedPath)).rejects.toThrow(); }); - it('should recover from corrupted audio blob data', async () => { + it("should recover from corrupted audio blob data", async () => { // Create a file with audio data const button = ButtonFactory.create({ - label: 'Audio Button', - message: 'Has audio', - type: 'SPEAK', + label: "Audio Button", + message: "Has audio", + type: "SPEAK", }); button.audioRecording = { id: 1, - data: Buffer.from('valid audio data'), - identifier: 'audio_1', - metadata: 'Valid audio', + data: Buffer.from("valid audio data"), + identifier: "audio_1", + metadata: "Valid audio", }; const page = PageFactory.create({ - id: 'audio_page', - name: 'Audio Page', + id: "audio_page", + name: "Audio Page", }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, 'audio_corruption.sps'); + const outputPath = path.join(tempDir, "audio_corruption.sps"); await processor.saveFromTree(tree, outputPath); // Verify the file was created successfully @@ -85,81 +85,88 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { expect(loadedTree).toBeDefined(); }); - it('should handle missing database tables gracefully', async () => { + it("should handle missing database tables gracefully", async () => { // Create a zip file with missing required tables // eslint-disable-next-line @typescript-eslint/no-var-requires - const AdmZip = require('adm-zip'); + const AdmZip = require("adm-zip"); const zip = new AdmZip(); // Add some files but not the required database structure - zip.addFile('readme.txt', Buffer.from('This is not a proper SPS file')); - zip.addFile('config.json', Buffer.from('{"version": "1.0"}')); + zip.addFile("readme.txt", Buffer.from("This is not a proper SPS file")); + zip.addFile("config.json", Buffer.from('{"version": "1.0"}')); - const invalidPath = path.join(tempDir, 'missing_tables.sps'); + const invalidPath = path.join(tempDir, "missing_tables.sps"); zip.writeZip(invalidPath); await expect(processor.loadIntoTree(invalidPath)).rejects.toThrow(); }); - it('should process files with invalid foreign keys', async () => { + it("should process files with invalid foreign keys", async () => { // Create a valid tree first const tree = TreeFactory.createCommunicationBoard(); - const outputPath = path.join(tempDir, 'foreign_keys.sps'); + const outputPath = path.join(tempDir, "foreign_keys.sps"); // This should work with proper relationships - await expect(processor.saveFromTree(tree, outputPath)).resolves.not.toThrow(); + await expect( + processor.saveFromTree(tree, outputPath), + ).resolves.not.toThrow(); const loadedTree = await processor.loadIntoTree(outputPath); expect(loadedTree).toBeDefined(); }); - it('should handle truncated database files', async () => { + it("should handle truncated database files", async () => { // Create a valid file const tree = TreeFactory.createSimple(); - const validPath = path.join(tempDir, 'valid_for_truncation.sps'); + const validPath = path.join(tempDir, "valid_for_truncation.sps"); await processor.saveFromTree(tree, validPath); // Read and truncate the file const validData = fs.readFileSync(validPath); - const truncatedData = validData.slice(0, Math.floor(validData.length / 2)); + const truncatedData = validData.slice( + 0, + Math.floor(validData.length / 2), + ); - const truncatedPath = path.join(tempDir, 'truncated.sps'); + const truncatedPath = path.join(tempDir, "truncated.sps"); fs.writeFileSync(truncatedPath, truncatedData); await expect(processor.loadIntoTree(truncatedPath)).rejects.toThrow(); }); - it('should handle completely invalid file formats', async () => { - const invalidPath = path.join(tempDir, 'not_a_zip.sps'); - fs.writeFileSync(invalidPath, 'This is just plain text, not a zip file'); + it("should handle completely invalid file formats", async () => { + const invalidPath = path.join(tempDir, "not_a_zip.sps"); + fs.writeFileSync(invalidPath, "This is just plain text, not a zip file"); await expect(processor.loadIntoTree(invalidPath)).rejects.toThrow(); }); - it('should handle empty files', async () => { - const emptyPath = path.join(tempDir, 'empty.sps'); - fs.writeFileSync(emptyPath, ''); + it("should handle empty files", async () => { + const emptyPath = path.join(tempDir, "empty.sps"); + fs.writeFileSync(emptyPath, ""); await expect(processor.loadIntoTree(emptyPath)).rejects.toThrow(); }); - it('should handle files with invalid zip structure', async () => { - const invalidZipPath = path.join(tempDir, 'invalid_zip.sps'); + it("should handle files with invalid zip structure", async () => { + const invalidZipPath = path.join(tempDir, "invalid_zip.sps"); // Write some bytes that look like they might be a zip but aren't - const fakeZipData = Buffer.from('PK\x03\x04\x14\x00\x00\x00invalid zip data'); + const fakeZipData = Buffer.from( + "PK\x03\x04\x14\x00\x00\x00invalid zip data", + ); fs.writeFileSync(invalidZipPath, fakeZipData); await expect(processor.loadIntoTree(invalidZipPath)).rejects.toThrow(); }); }); - describe('Performance Tests', () => { - it('should process large pagesets (500+ pages) efficiently', async () => { + describe("Performance Tests", () => { + it("should process large pagesets (500+ pages) efficiently", async () => { const startTime = Date.now(); // Create a very large tree const tree = TreeFactory.createLarge(500, 5); // 500 pages, 5 buttons each - const outputPath = path.join(tempDir, 'large_pageset.sps'); + const outputPath = path.join(tempDir, "large_pageset.sps"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -174,7 +181,7 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { console.log(`Large pageset processing time: ${processingTime}ms`); }); - it('should handle pagesets with extensive audio content', async () => { + it("should handle pagesets with extensive audio content", async () => { const startTime = Date.now(); // Create tree with many audio recordings @@ -192,7 +199,7 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { const button = ButtonFactory.create({ label: `Audio Button ${buttonIndex}`, message: `Audio message ${buttonIndex}`, - type: 'SPEAK', + type: "SPEAK", }); const audioSize = audioSizes[buttonIndex % audioSizes.length]; @@ -213,7 +220,7 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { tree.addPage(page); } - const outputPath = path.join(tempDir, 'extensive_audio.sps'); + const outputPath = path.join(tempDir, "extensive_audio.sps"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -226,7 +233,7 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { expect(processingTime).toBeLessThan(80000); // Allow headroom on slower machines // Verify audio data integrity - const firstPage = loadedTree.getPage('audio_page_0'); + const firstPage = loadedTree.getPage("audio_page_0"); expect(firstPage).toBeDefined(); if (!firstPage) { return; @@ -237,7 +244,7 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { console.log(`Extensive audio processing time: ${processingTime}ms`); }); - it('should maintain memory usage under 100MB for large files', async () => { + it("should maintain memory usage under 100MB for large files", async () => { // Monitor memory usage during processing const initialMemory = process.memoryUsage(); @@ -253,13 +260,13 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { id: pageIndex * 100 + buttonIndex, data: Buffer.alloc(4096, 0x42), // 4KB audio data identifier: `audio_${pageIndex}_${buttonIndex}`, - metadata: 'Performance test audio', + metadata: "Performance test audio", }; } }); }); - const outputPath = path.join(tempDir, 'memory_test.sps'); + const outputPath = path.join(tempDir, "memory_test.sps"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -274,7 +281,7 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { console.log(`Memory increase: ${memoryIncreaseMB.toFixed(2)}MB`); }); - it('should handle concurrent processing efficiently', async () => { + it("should handle concurrent processing efficiently", async () => { // Test processing multiple files concurrently const trees = [ TreeFactory.createSimple(), @@ -305,11 +312,11 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { console.log(`Concurrent processing time: ${processingTime}ms`); }); - it('should handle streaming large files efficiently', async () => { + it("should handle streaming large files efficiently", async () => { // Test with a very large tree that would benefit from streaming const tree = TreeFactory.createLarge(200, 10); // 200 pages, 10 buttons each - const outputPath = path.join(tempDir, 'streaming_test.sps'); + const outputPath = path.join(tempDir, "streaming_test.sps"); const startTime = Date.now(); await processor.saveFromTree(tree, outputPath); @@ -329,15 +336,15 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { expect(processingTime).toBeLessThan(80000); // Should complete in under ~80 seconds console.log( - `Streaming test - File size: ${fileSizeMB.toFixed(2)}MB, Processing time: ${processingTime}ms` + `Streaming test - File size: ${fileSizeMB.toFixed(2)}MB, Processing time: ${processingTime}ms`, ); }); }); - describe('Text Processing Methods', () => { - it('should extract all texts from large databases', async () => { + describe("Text Processing Methods", () => { + it("should extract all texts from large databases", async () => { const tree = TreeFactory.createLarge(50, 10); - const outputPath = path.join(tempDir, 'text_extraction.sps'); + const outputPath = path.join(tempDir, "text_extraction.sps"); await processor.saveFromTree(tree, outputPath); const texts = await processor.extractTexts(outputPath); @@ -349,10 +356,10 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { expect(texts.length).toBeGreaterThanOrEqual(expectedTextCount); }); - it('should process texts with translations efficiently', async () => { + it("should process texts with translations efficiently", async () => { const tree = TreeFactory.createCommunicationBoard(); - const inputPath = path.join(tempDir, 'input_for_translation.sps'); - const outputPath = path.join(tempDir, 'translation_performance.sps'); + const inputPath = path.join(tempDir, "input_for_translation.sps"); + const outputPath = path.join(tempDir, "translation_performance.sps"); // Save the tree first await processor.saveFromTree(tree, inputPath); @@ -364,7 +371,11 @@ describe('SnapProcessor - Database Corruption & Performance Tests', () => { } const startTime = Date.now(); - const result = await processor.processTexts(inputPath, translations, outputPath); + const result = await processor.processTexts( + inputPath, + translations, + outputPath, + ); const endTime = Date.now(); expect(result).toBeInstanceOf(Buffer); diff --git a/test/snapProcessor.coverage.test.ts b/test/snapProcessor.coverage.test.ts index 0f5e53c..a569ded 100644 --- a/test/snapProcessor.coverage.test.ts +++ b/test/snapProcessor.coverage.test.ts @@ -1,12 +1,12 @@ -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { TreeFactory } from './utils/testFactories'; -import path from 'path'; -import fs from 'fs'; -import Database from 'better-sqlite3'; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { TreeFactory } from "./utils/testFactories"; +import path from "path"; +import fs from "fs"; +import Database from "better-sqlite3"; -describe('SnapProcessor Coverage', () => { - const exampleFile: string = path.join(__dirname, 'assets/snap/example.sps'); - const tempDbPath = path.join(__dirname, 'temp_snap.db'); +describe("SnapProcessor Coverage", () => { + const exampleFile: string = path.join(__dirname, "assets/snap/example.sps"); + const tempDbPath = path.join(__dirname, "temp_snap.db"); beforeEach(async () => { if (fs.existsSync(tempDbPath)) { @@ -20,83 +20,103 @@ describe('SnapProcessor Coverage', () => { } }); - describe('Audio Handling', () => { - it('should load audio data when loadAudio is true', async () => { + describe("Audio Handling", () => { + it("should load audio data when loadAudio is true", async () => { const saveProcessor = new SnapProcessor(); const tree = TreeFactory.createSimple(); await saveProcessor.saveFromTree(tree, tempDbPath); const db = new Database(tempDbPath); - const firstButton = db.prepare('SELECT Id FROM Button ORDER BY Id LIMIT 1').get() as { + const firstButton = db + .prepare("SELECT Id FROM Button ORDER BY Id LIMIT 1") + .get() as { Id: number; }; db.close(); - const audioData = new Uint8Array(Buffer.from('audio data')); - await saveProcessor.addAudioToButton(tempDbPath, firstButton.Id, audioData, 'test.wav'); + const audioData = new Uint8Array(Buffer.from("audio data")); + await saveProcessor.addAudioToButton( + tempDbPath, + firstButton.Id, + audioData, + "test.wav", + ); const processor = new SnapProcessor(null, { loadAudio: true }); const loadedTree = await processor.loadIntoTree(tempDbPath); const page = Object.values(loadedTree.pages)[0]; expect(page).toBeDefined(); - const buttonWithAudio = page?.buttons.find((button) => button.audioRecording); + const buttonWithAudio = page?.buttons.find( + (button) => button.audioRecording, + ); expect(buttonWithAudio).toBeDefined(); expect(Buffer.from(buttonWithAudio?.audioRecording?.data || [])).toEqual( - Buffer.from(audioData) + Buffer.from(audioData), ); }); - it('should add audio to a button', async () => { + it("should add audio to a button", async () => { // Use a real file to test against fs.copyFileSync(exampleFile, tempDbPath); const processor = new SnapProcessor(); - const audioData = new Uint8Array(Buffer.from('new audio data')); - await processor.addAudioToButton(tempDbPath, 1, audioData, 'test.wav'); + const audioData = new Uint8Array(Buffer.from("new audio data")); + await processor.addAudioToButton(tempDbPath, 1, audioData, "test.wav"); const db = new Database(tempDbPath); - const row = db.prepare('SELECT * FROM Button WHERE Id = ?').get(1) as any; + const row = db.prepare("SELECT * FROM Button WHERE Id = ?").get(1) as any; expect(row.MessageRecordingId).toBeGreaterThan(0); const audioRow = db - .prepare('SELECT * FROM PageSetData WHERE Id = ?') + .prepare("SELECT * FROM PageSetData WHERE Id = ?") .get(row.MessageRecordingId) as any; expect(Buffer.from(audioRow.Data)).toEqual(Buffer.from(audioData)); db.close(); }); - it('should create an audio-enhanced pageset', async () => { - const enhancedDbPath = path.join(__dirname, 'enhanced.db'); + it("should create an audio-enhanced pageset", async () => { + const enhancedDbPath = path.join(__dirname, "enhanced.db"); if (fs.existsSync(enhancedDbPath)) { fs.unlinkSync(enhancedDbPath); } const processor = new SnapProcessor(); - const audioMappings = new Map(); - audioMappings.set(1, { audioData: new Uint8Array(Buffer.from('new audio')) }); - - await processor.createAudioEnhancedPageset(exampleFile, enhancedDbPath, audioMappings); + const audioMappings = new Map< + number, + { audioData: Uint8Array; metadata?: string } + >(); + audioMappings.set(1, { + audioData: new Uint8Array(Buffer.from("new audio")), + }); + + await processor.createAudioEnhancedPageset( + exampleFile, + enhancedDbPath, + audioMappings, + ); expect(fs.existsSync(enhancedDbPath)).toBe(true); const db = new Database(enhancedDbPath); - const row = db.prepare('SELECT * FROM Button WHERE Id = ?').get(1) as any; + const row = db.prepare("SELECT * FROM Button WHERE Id = ?").get(1) as any; expect(row.MessageRecordingId).toBeGreaterThan(0); db.close(); fs.unlinkSync(enhancedDbPath); }); }); - describe('Database Corruption and Schema', () => { - it('should throw an error for a corrupted database file', async () => { - fs.writeFileSync(tempDbPath, 'not a database'); + describe("Database Corruption and Schema", () => { + it("should throw an error for a corrupted database file", async () => { + fs.writeFileSync(tempDbPath, "not a database"); const processor = new SnapProcessor(); await expect(processor.loadIntoTree(tempDbPath)).rejects.toThrow( - 'Invalid SQLite database file' + "Invalid SQLite database file", ); }); - it('should handle missing tables gracefully', async () => { + it("should handle missing tables gracefully", async () => { const db = new Database(tempDbPath); - db.exec('CREATE TABLE Page (Id INTEGER PRIMARY KEY, UniqueId TEXT, Name TEXT);'); + db.exec( + "CREATE TABLE Page (Id INTEGER PRIMARY KEY, UniqueId TEXT, Name TEXT);", + ); db.close(); const processor = new SnapProcessor(); diff --git a/test/snapProcessor.roundtrip.test.ts b/test/snapProcessor.roundtrip.test.ts index de353ac..7a84f43 100644 --- a/test/snapProcessor.roundtrip.test.ts +++ b/test/snapProcessor.roundtrip.test.ts @@ -1,21 +1,23 @@ -import fs from 'fs'; -import path from 'path'; -import { SnapProcessor } from '../src/processors/snapProcessor'; +import fs from "fs"; +import path from "path"; +import { SnapProcessor } from "../src/processors/snapProcessor"; // import { AACTree } from '../src/core/treeStructure'; // Unused import -describe('SnapProcessor round-trip', () => { - const snapPath = path.join(__dirname, 'assets/snap/example.snap.json'); - const spsPath = path.join(__dirname, 'assets/snap/example.sps'); - const outPath = path.join(__dirname, 'out.snap.json'); +describe("SnapProcessor round-trip", () => { + const snapPath = path.join(__dirname, "assets/snap/example.snap.json"); + const spsPath = path.join(__dirname, "assets/snap/example.sps"); + const outPath = path.join(__dirname, "out.snap.json"); afterAll(async () => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it('round-trips Snap JSON without losing pages or navigation', async () => { + it("round-trips Snap JSON without losing pages or navigation", async () => { if (!fs.existsSync(snapPath)) return; const processor = new SnapProcessor(); const tree1 = await processor.loadIntoTree(snapPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); - expect(Object.keys(tree1.pages).sort()).toEqual(Object.keys(tree2.pages).sort()); + expect(Object.keys(tree1.pages).sort()).toEqual( + Object.keys(tree2.pages).sort(), + ); for (const pid in tree1.pages) { expect(tree2.pages).toHaveProperty(pid); const btnLabels1 = tree1.pages[pid].buttons.map((b) => b.label).sort(); @@ -28,12 +30,14 @@ describe('SnapProcessor round-trip', () => { expect(tree2.metadata.locale).toBe(tree1.metadata.locale); }); - it.skip('round-trips .sps file without losing pages (saveFromTree not implemented)', async () => { + it.skip("round-trips .sps file without losing pages (saveFromTree not implemented)", async () => { if (!fs.existsSync(spsPath)) return; const processor = new SnapProcessor(); const tree1 = await processor.loadIntoTree(spsPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); - expect(Object.keys(tree1.pages).sort()).toEqual(Object.keys(tree2.pages).sort()); + expect(Object.keys(tree1.pages).sort()).toEqual( + Object.keys(tree2.pages).sort(), + ); }); }); diff --git a/test/snapProcessor.test.ts b/test/snapProcessor.test.ts index f792035..7e7f628 100644 --- a/test/snapProcessor.test.ts +++ b/test/snapProcessor.test.ts @@ -1,29 +1,35 @@ -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { AACTree } from '../src/core/treeStructure'; -import path from 'path'; -import fs from 'fs'; -import AdmZip from 'adm-zip'; - -describe('SnapProcessor', () => { - const exampleFile: string = path.join(__dirname, 'assets/snap/example.spb'); - const exampleSPSFile: string = path.join(__dirname, 'assets/snap/example.sps'); - const exampleSubZipFile: string = path.join(__dirname, 'assets/snap/example.sub.zip'); - - it('should extract all texts from a .spb file', async () => { +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { AACTree } from "../src/core/treeStructure"; +import path from "path"; +import fs from "fs"; +import AdmZip from "adm-zip"; + +describe("SnapProcessor", () => { + const exampleFile: string = path.join(__dirname, "assets/snap/example.spb"); + const exampleSPSFile: string = path.join( + __dirname, + "assets/snap/example.sps", + ); + const exampleSubZipFile: string = path.join( + __dirname, + "assets/snap/example.sub.zip", + ); + + it("should extract all texts from a .spb file", async () => { const processor = new SnapProcessor(); const texts: string[] = await processor.extractTexts(exampleFile); expect(Array.isArray(texts)).toBe(true); expect(texts.length).toBeGreaterThan(0); }); - it('should extract all texts from a .sps file', async () => { + it("should extract all texts from a .sps file", async () => { const processor = new SnapProcessor(); const texts: string[] = await processor.extractTexts(exampleSPSFile); expect(Array.isArray(texts)).toBe(true); expect(texts.length).toBeGreaterThan(0); }); - it('should load the tree structure from a .spb file and use UniqueId for page ids', async () => { + it("should load the tree structure from a .spb file and use UniqueId for page ids", async () => { const processor = new SnapProcessor(); const tree: AACTree = await processor.loadIntoTree(exampleFile); expect(tree).toBeTruthy(); @@ -35,7 +41,7 @@ describe('SnapProcessor', () => { }); }); - it('should load the tree structure from a .sps file and use UniqueId for page ids', async () => { + it("should load the tree structure from a .sps file and use UniqueId for page ids", async () => { const processor = new SnapProcessor(); const tree: AACTree = await processor.loadIntoTree(exampleSPSFile); expect(tree).toBeTruthy(); @@ -44,7 +50,7 @@ describe('SnapProcessor', () => { // All page ids should be UUID-like (contain hyphens) pageIds.forEach((id) => { - expect(typeof id).toBe('string'); + expect(typeof id).toBe("string"); expect(id.length).toBeGreaterThan(10); expect(id).toMatch(/-/); }); @@ -53,15 +59,15 @@ describe('SnapProcessor', () => { for (const pageId of pageIds) { const page = tree.pages[pageId]; for (const btn of page.buttons) { - if (btn.type === 'NAVIGATE') { - expect(typeof btn.targetPageId).toBe('string'); + if (btn.type === "NAVIGATE") { + expect(typeof btn.targetPageId).toBe("string"); expect(btn.targetPageId).toMatch(/-/); } } } }); - it('should handle .sub.zip files by extracting and processing the embedded .sps file', async () => { + it("should handle .sub.zip files by extracting and processing the embedded .sps file", async () => { const processor = new SnapProcessor(); const tree: AACTree = await processor.loadIntoTree(exampleSubZipFile); expect(tree).toBeTruthy(); @@ -69,57 +75,61 @@ describe('SnapProcessor', () => { expect(pageIds.length).toBeGreaterThan(0); }); - describe('Error Handling', () => { - it('should throw error for non-existent file', async () => { + describe("Error Handling", () => { + it("should throw error for non-existent file", async () => { const processor = new SnapProcessor(); - await expect(processor.loadIntoTree('/non/existent/file.spb')).rejects.toThrow(); + await expect( + processor.loadIntoTree("/non/existent/file.spb"), + ).rejects.toThrow(); }); - it('should handle invalid buffer input', async () => { + it("should handle invalid buffer input", async () => { const processor = new SnapProcessor(); - const invalidBuffer = Buffer.from('not a database file'); + const invalidBuffer = Buffer.from("not a database file"); await expect(processor.loadIntoTree(invalidBuffer)).rejects.toThrow(); }); - it('should handle empty file path', async () => { + it("should handle empty file path", async () => { const processor = new SnapProcessor(); - await expect(processor.loadIntoTree('')).rejects.toThrow(); + await expect(processor.loadIntoTree("")).rejects.toThrow(); }); - it('should throw error for .sub.zip file without .sps file inside', async () => { + it("should throw error for .sub.zip file without .sps file inside", async () => { const processor = new SnapProcessor(); const zip = new AdmZip(); - zip.addFile('not-an-sps.txt', Buffer.from('Not a pageset')); - const invalidSubZipPath = path.join(__dirname, 'invalid.sub.zip'); + zip.addFile("not-an-sps.txt", Buffer.from("Not a pageset")); + const invalidSubZipPath = path.join(__dirname, "invalid.sub.zip"); zip.writeZip(invalidSubZipPath); await expect(processor.loadIntoTree(invalidSubZipPath)).rejects.toThrow( - 'No .sps file found in .sub.zip archive' + "No .sps file found in .sub.zip archive", ); // Cleanup fs.unlinkSync(invalidSubZipPath); }); - it('should throw error for .sub.zip file that does not exist', async () => { + it("should throw error for .sub.zip file that does not exist", async () => { const processor = new SnapProcessor(); - await expect(processor.loadIntoTree('non-existent.sub.zip')).rejects.toThrow(); + await expect( + processor.loadIntoTree("non-existent.sub.zip"), + ).rejects.toThrow(); }); }); - describe('Audio Options', () => { - it('should create processor with audio loading disabled by default', async () => { + describe("Audio Options", () => { + it("should create processor with audio loading disabled by default", async () => { const processor = new SnapProcessor(); expect(processor).toBeDefined(); // Audio loading is private, but we can test the behavior }); - it('should create processor with audio loading enabled', async () => { + it("should create processor with audio loading enabled", async () => { const processor = new SnapProcessor(null, { loadAudio: true }); expect(processor).toBeDefined(); }); - it('should create processor with symbol resolver', async () => { + it("should create processor with symbol resolver", async () => { const mockResolver = { resolve: jest.fn() }; const processor = new SnapProcessor(mockResolver); expect(processor).toBeDefined(); diff --git a/test/stringCasing.test.ts b/test/stringCasing.test.ts index 262b089..546c7ee 100644 --- a/test/stringCasing.test.ts +++ b/test/stringCasing.test.ts @@ -4,139 +4,159 @@ import { detectCasing, convertCasing, isNumericOrEmpty, -} from '../src/core/stringCasing'; +} from "../src/core/stringCasing"; -describe('StringCasing', () => { - describe('detectCasing', () => { - it('should detect lowercase', async () => { - expect(detectCasing('hello world')).toBe(StringCasing.LOWER); - expect(detectCasing('test')).toBe(StringCasing.LOWER); +describe("StringCasing", () => { + describe("detectCasing", () => { + it("should detect lowercase", async () => { + expect(detectCasing("hello world")).toBe(StringCasing.LOWER); + expect(detectCasing("test")).toBe(StringCasing.LOWER); }); - it('should detect uppercase', async () => { - expect(detectCasing('HELLO WORLD')).toBe(StringCasing.UPPER); - expect(detectCasing('TEST')).toBe(StringCasing.UPPER); + it("should detect uppercase", async () => { + expect(detectCasing("HELLO WORLD")).toBe(StringCasing.UPPER); + expect(detectCasing("TEST")).toBe(StringCasing.UPPER); }); - it('should detect sentence case', async () => { - expect(detectCasing('Hello world')).toBe(StringCasing.SENTENCE); - expect(detectCasing('Test sentence')).toBe(StringCasing.SENTENCE); + it("should detect sentence case", async () => { + expect(detectCasing("Hello world")).toBe(StringCasing.SENTENCE); + expect(detectCasing("Test sentence")).toBe(StringCasing.SENTENCE); }); - it('should detect title case', async () => { - expect(detectCasing('Hello World')).toBe(StringCasing.TITLE); - expect(detectCasing('Test Title Case')).toBe(StringCasing.TITLE); + it("should detect title case", async () => { + expect(detectCasing("Hello World")).toBe(StringCasing.TITLE); + expect(detectCasing("Test Title Case")).toBe(StringCasing.TITLE); }); - it('should detect camelCase', async () => { - expect(detectCasing('helloWorld')).toBe(StringCasing.CAMEL); - expect(detectCasing('testCamelCase')).toBe(StringCasing.CAMEL); + it("should detect camelCase", async () => { + expect(detectCasing("helloWorld")).toBe(StringCasing.CAMEL); + expect(detectCasing("testCamelCase")).toBe(StringCasing.CAMEL); }); - it('should detect PascalCase', async () => { - expect(detectCasing('HelloWorld')).toBe(StringCasing.PASCAL); - expect(detectCasing('TestPascalCase')).toBe(StringCasing.PASCAL); + it("should detect PascalCase", async () => { + expect(detectCasing("HelloWorld")).toBe(StringCasing.PASCAL); + expect(detectCasing("TestPascalCase")).toBe(StringCasing.PASCAL); }); - it('should detect snake_case', async () => { - expect(detectCasing('hello_world')).toBe(StringCasing.SNAKE); - expect(detectCasing('test_snake_case')).toBe(StringCasing.SNAKE); + it("should detect snake_case", async () => { + expect(detectCasing("hello_world")).toBe(StringCasing.SNAKE); + expect(detectCasing("test_snake_case")).toBe(StringCasing.SNAKE); }); - it('should detect CONSTANT_CASE', async () => { - expect(detectCasing('HELLO_WORLD')).toBe(StringCasing.CONSTANT); - expect(detectCasing('TEST_CONSTANT_CASE')).toBe(StringCasing.CONSTANT); + it("should detect CONSTANT_CASE", async () => { + expect(detectCasing("HELLO_WORLD")).toBe(StringCasing.CONSTANT); + expect(detectCasing("TEST_CONSTANT_CASE")).toBe(StringCasing.CONSTANT); }); - it('should detect kebab-case', async () => { - expect(detectCasing('hello-world')).toBe(StringCasing.KEBAB); - expect(detectCasing('test-kebab-case')).toBe(StringCasing.KEBAB); + it("should detect kebab-case", async () => { + expect(detectCasing("hello-world")).toBe(StringCasing.KEBAB); + expect(detectCasing("test-kebab-case")).toBe(StringCasing.KEBAB); }); - it('should detect Header-Case', async () => { - expect(detectCasing('Hello-World')).toBe(StringCasing.HEADER); - expect(detectCasing('Test-Header-Case')).toBe(StringCasing.HEADER); + it("should detect Header-Case", async () => { + expect(detectCasing("Hello-World")).toBe(StringCasing.HEADER); + expect(detectCasing("Test-Header-Case")).toBe(StringCasing.HEADER); }); - it('should handle edge cases', async () => { - expect(detectCasing('')).toBe(StringCasing.LOWER); - expect(detectCasing(' ')).toBe(StringCasing.LOWER); - expect(detectCasing('A')).toBe(StringCasing.CAPITAL); - expect(detectCasing('a')).toBe(StringCasing.LOWER); + it("should handle edge cases", async () => { + expect(detectCasing("")).toBe(StringCasing.LOWER); + expect(detectCasing(" ")).toBe(StringCasing.LOWER); + expect(detectCasing("A")).toBe(StringCasing.CAPITAL); + expect(detectCasing("a")).toBe(StringCasing.LOWER); }); }); - describe('convertCasing', () => { - const testText = 'Hello World Test'; + describe("convertCasing", () => { + const testText = "Hello World Test"; - it('should convert to lowercase', async () => { - expect(convertCasing(testText, StringCasing.LOWER)).toBe('hello world test'); + it("should convert to lowercase", async () => { + expect(convertCasing(testText, StringCasing.LOWER)).toBe( + "hello world test", + ); }); - it('should convert to uppercase', async () => { - expect(convertCasing(testText, StringCasing.UPPER)).toBe('HELLO WORLD TEST'); + it("should convert to uppercase", async () => { + expect(convertCasing(testText, StringCasing.UPPER)).toBe( + "HELLO WORLD TEST", + ); }); - it('should convert to sentence case', async () => { - expect(convertCasing(testText, StringCasing.SENTENCE)).toBe('Hello world test'); + it("should convert to sentence case", async () => { + expect(convertCasing(testText, StringCasing.SENTENCE)).toBe( + "Hello world test", + ); }); - it('should convert to title case', async () => { - expect(convertCasing(testText, StringCasing.TITLE)).toBe('Hello World Test'); + it("should convert to title case", async () => { + expect(convertCasing(testText, StringCasing.TITLE)).toBe( + "Hello World Test", + ); }); - it('should convert to camelCase', async () => { - expect(convertCasing(testText, StringCasing.CAMEL)).toBe('helloWorldTest'); + it("should convert to camelCase", async () => { + expect(convertCasing(testText, StringCasing.CAMEL)).toBe( + "helloWorldTest", + ); }); - it('should convert to PascalCase', async () => { - expect(convertCasing(testText, StringCasing.PASCAL)).toBe('HelloWorldTest'); + it("should convert to PascalCase", async () => { + expect(convertCasing(testText, StringCasing.PASCAL)).toBe( + "HelloWorldTest", + ); }); - it('should convert to snake_case', async () => { - expect(convertCasing(testText, StringCasing.SNAKE)).toBe('hello_world_test'); + it("should convert to snake_case", async () => { + expect(convertCasing(testText, StringCasing.SNAKE)).toBe( + "hello_world_test", + ); }); - it('should convert to CONSTANT_CASE', async () => { - expect(convertCasing(testText, StringCasing.CONSTANT)).toBe('HELLO_WORLD_TEST'); + it("should convert to CONSTANT_CASE", async () => { + expect(convertCasing(testText, StringCasing.CONSTANT)).toBe( + "HELLO_WORLD_TEST", + ); }); - it('should convert to kebab-case', async () => { - expect(convertCasing(testText, StringCasing.KEBAB)).toBe('hello-world-test'); + it("should convert to kebab-case", async () => { + expect(convertCasing(testText, StringCasing.KEBAB)).toBe( + "hello-world-test", + ); }); - it('should convert to Header-Case', async () => { - expect(convertCasing(testText, StringCasing.HEADER)).toBe('Hello-World-Test'); + it("should convert to Header-Case", async () => { + expect(convertCasing(testText, StringCasing.HEADER)).toBe( + "Hello-World-Test", + ); }); - it('should handle empty strings', async () => { - expect(convertCasing('', StringCasing.UPPER)).toBe(''); - expect(convertCasing(' ', StringCasing.LOWER)).toBe(' '); + it("should handle empty strings", async () => { + expect(convertCasing("", StringCasing.UPPER)).toBe(""); + expect(convertCasing(" ", StringCasing.LOWER)).toBe(" "); }); }); - describe('isNumericOrEmpty', () => { - it('should identify numeric strings', async () => { - expect(isNumericOrEmpty('123')).toBe(true); - expect(isNumericOrEmpty('0')).toBe(true); - expect(isNumericOrEmpty('-5')).toBe(true); + describe("isNumericOrEmpty", () => { + it("should identify numeric strings", async () => { + expect(isNumericOrEmpty("123")).toBe(true); + expect(isNumericOrEmpty("0")).toBe(true); + expect(isNumericOrEmpty("-5")).toBe(true); }); - it('should identify empty or short strings', async () => { - expect(isNumericOrEmpty('')).toBe(true); - expect(isNumericOrEmpty(' ')).toBe(true); - expect(isNumericOrEmpty('a')).toBe(true); + it("should identify empty or short strings", async () => { + expect(isNumericOrEmpty("")).toBe(true); + expect(isNumericOrEmpty(" ")).toBe(true); + expect(isNumericOrEmpty("a")).toBe(true); }); - it('should identify meaningful text', async () => { - expect(isNumericOrEmpty('hello')).toBe(false); - expect(isNumericOrEmpty('test word')).toBe(false); - expect(isNumericOrEmpty('abc')).toBe(false); + it("should identify meaningful text", async () => { + expect(isNumericOrEmpty("hello")).toBe(false); + expect(isNumericOrEmpty("test word")).toBe(false); + expect(isNumericOrEmpty("abc")).toBe(false); }); - it('should handle mixed content', async () => { - expect(isNumericOrEmpty('123abc')).toBe(false); - expect(isNumericOrEmpty('hello123')).toBe(false); + it("should handle mixed content", async () => { + expect(isNumericOrEmpty("123abc")).toBe(false); + expect(isNumericOrEmpty("hello123")).toBe(false); }); }); }); diff --git a/test/styling.test.ts b/test/styling.test.ts index a83bdcb..6abd683 100644 --- a/test/styling.test.ts +++ b/test/styling.test.ts @@ -1,20 +1,20 @@ -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import { ObfProcessor } from '../src/processors/obfProcessor'; -import { SnapProcessor } from '../src/processors/snapProcessor'; -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -import { AstericsGridProcessor } from '../src/processors/astericsGridProcessor'; -import { GridsetProcessor } from '../src/processors/gridsetProcessor'; -import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; - -describe('Styling Support Tests', () => { +import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { ObfProcessor } from "../src/processors/obfProcessor"; +import { SnapProcessor } from "../src/processors/snapProcessor"; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import { AstericsGridProcessor } from "../src/processors/astericsGridProcessor"; +import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; + +describe("Styling Support Tests", () => { let tempDir: string; beforeEach(async () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'styling-test-')); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "styling-test-")); }); afterEach(async () => { @@ -28,34 +28,34 @@ describe('Styling Support Tests', () => { const tree = new AACTree(); const page = new AACPage({ - id: 'test-page-1', - name: 'Test Page', + id: "test-page-1", + name: "Test Page", grid: [], buttons: [], parentId: null, style: { - backgroundColor: '#f0f0f0', - borderColor: '#cccccc', - fontFamily: 'Arial', + backgroundColor: "#f0f0f0", + borderColor: "#cccccc", + fontFamily: "Arial", fontSize: 16, }, }); const button1 = new AACButton({ - id: 'btn-1', - label: 'Hello', - message: 'Hello World', - type: 'SPEAK', + id: "btn-1", + label: "Hello", + message: "Hello World", + type: "SPEAK", action: null, style: { - backgroundColor: '#ff0000', - fontColor: '#ffffff', - borderColor: '#990000', + backgroundColor: "#ff0000", + fontColor: "#ffffff", + borderColor: "#990000", borderWidth: 2, fontSize: 18, - fontFamily: 'Helvetica', - fontWeight: 'bold', - fontStyle: 'normal', + fontFamily: "Helvetica", + fontWeight: "bold", + fontStyle: "normal", textUnderline: false, labelOnTop: true, transparent: false, @@ -63,24 +63,24 @@ describe('Styling Support Tests', () => { }); const button2 = new AACButton({ - id: 'btn-2', - label: 'Navigate', - message: 'Go to page 2', - type: 'NAVIGATE', - targetPageId: 'test-page-2', + id: "btn-2", + label: "Navigate", + message: "Go to page 2", + type: "NAVIGATE", + targetPageId: "test-page-2", action: { - type: 'NAVIGATE', - targetPageId: 'test-page-2', + type: "NAVIGATE", + targetPageId: "test-page-2", }, style: { - backgroundColor: '#00ff00', - fontColor: '#000000', - borderColor: '#009900', + backgroundColor: "#00ff00", + fontColor: "#000000", + borderColor: "#009900", borderWidth: 1, fontSize: 14, - fontFamily: 'Times', - fontWeight: 'normal', - fontStyle: 'italic', + fontFamily: "Times", + fontWeight: "normal", + fontStyle: "italic", textUnderline: true, labelOnTop: false, transparent: true, @@ -94,11 +94,11 @@ describe('Styling Support Tests', () => { return tree; }; - describe('OBF Processor Styling', () => { - it('should preserve background and border colors in round-trip', async () => { + describe("OBF Processor Styling", () => { + it("should preserve background and border colors in round-trip", async () => { const processor = new ObfProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, 'test.obf'); + const outputPath = path.join(tempDir, "test.obf"); // Save tree to OBF await processor.saveFromTree(tree, outputPath); @@ -110,16 +110,16 @@ describe('Styling Support Tests', () => { const loadedButton = loadedPage.buttons[0]; // Verify styling is preserved - expect(loadedButton.style?.backgroundColor).toBe('#ff0000'); - expect(loadedButton.style?.borderColor).toBe('#990000'); + expect(loadedButton.style?.backgroundColor).toBe("#ff0000"); + expect(loadedButton.style?.borderColor).toBe("#990000"); }); }); - describe('Snap Processor Styling', () => { - it('should preserve comprehensive styling in round-trip', async () => { + describe("Snap Processor Styling", () => { + it("should preserve comprehensive styling in round-trip", async () => { const processor = new SnapProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, 'test.spb'); + const outputPath = path.join(tempDir, "test.spb"); // Save tree to Snap await processor.saveFromTree(tree, outputPath); @@ -131,21 +131,21 @@ describe('Styling Support Tests', () => { const loadedButton = loadedPage.buttons[0]; // Verify comprehensive styling is preserved - expect(loadedButton.style?.backgroundColor).toBe('#ff0000'); - expect(loadedButton.style?.fontColor).toBe('#ffffff'); - expect(loadedButton.style?.borderColor).toBe('#990000'); + expect(loadedButton.style?.backgroundColor).toBe("#ff0000"); + expect(loadedButton.style?.fontColor).toBe("#ffffff"); + expect(loadedButton.style?.borderColor).toBe("#990000"); expect(loadedButton.style?.borderWidth).toBe(2); expect(loadedButton.style?.fontSize).toBe(18); - expect(loadedButton.style?.fontFamily).toBe('Helvetica'); - expect(loadedPage.style?.backgroundColor).toBe('#f0f0f0'); + expect(loadedButton.style?.fontFamily).toBe("Helvetica"); + expect(loadedPage.style?.backgroundColor).toBe("#f0f0f0"); }); }); - describe('TouchChat Processor Styling', () => { - it('should preserve button and page styles in round-trip', async () => { + describe("TouchChat Processor Styling", () => { + it("should preserve button and page styles in round-trip", async () => { const processor = new TouchChatProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, 'test.ce'); + const outputPath = path.join(tempDir, "test.ce"); // Save tree to TouchChat await processor.saveFromTree(tree, outputPath); @@ -166,11 +166,11 @@ describe('Styling Support Tests', () => { }); }); - describe('Asterics Grid Processor Styling', () => { - it('should preserve background colors and metadata styling', async () => { + describe("Asterics Grid Processor Styling", () => { + it("should preserve background colors and metadata styling", async () => { const processor = new AstericsGridProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, 'test.grd'); + const outputPath = path.join(tempDir, "test.grd"); // Save tree to Asterics Grid await processor.saveFromTree(tree, outputPath); @@ -187,11 +187,11 @@ describe('Styling Support Tests', () => { }); }); - describe('Grid 3 Processor Styling', () => { - it('should create and reference styles correctly', async () => { + describe("Grid 3 Processor Styling", () => { + it("should create and reference styles correctly", async () => { const processor = new GridsetProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, 'test.gridset'); + const outputPath = path.join(tempDir, "test.gridset"); // Save tree to Grid 3 await processor.saveFromTree(tree, outputPath); @@ -199,22 +199,23 @@ describe('Styling Support Tests', () => { // Verify the zip contains style.xml // eslint-disable-next-line @typescript-eslint/no-var-requires - const AdmZip = require('adm-zip'); + const AdmZip = require("adm-zip"); const zip = new AdmZip(outputPath); const entries = zip.getEntries(); const hasStyleXml = entries.some( (entry: any) => - entry.entryName.endsWith('styles.xml') || entry.entryName.endsWith('style.xml') + entry.entryName.endsWith("styles.xml") || + entry.entryName.endsWith("style.xml"), ); expect(hasStyleXml).toBe(true); }); }); - describe('Apple Panels Processor Styling', () => { - it('should preserve DisplayColor, FontSize, and DisplayImageWeight', async () => { + describe("Apple Panels Processor Styling", () => { + it("should preserve DisplayColor, FontSize, and DisplayImageWeight", async () => { const processor = new ApplePanelsProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, 'test.ascconfig'); + const outputPath = path.join(tempDir, "test.ascconfig"); // Save tree to Apple Panels await processor.saveFromTree(tree, outputPath); @@ -232,19 +233,19 @@ describe('Styling Support Tests', () => { }); }); - describe('Cross-Format Styling Compatibility', () => { - it('should maintain basic styling when converting between formats', async () => { + describe("Cross-Format Styling Compatibility", () => { + it("should maintain basic styling when converting between formats", async () => { const obfProcessor = new ObfProcessor(); const snapProcessor = new SnapProcessor(); const tree = createStyledTestTree(); // Save as OBF - const obfPath = path.join(tempDir, 'test.obf'); + const obfPath = path.join(tempDir, "test.obf"); await obfProcessor.saveFromTree(tree, obfPath); // Load from OBF and save as Snap const loadedFromObf = await obfProcessor.loadIntoTree(obfPath); - const snapPath = path.join(tempDir, 'test.spb'); + const snapPath = path.join(tempDir, "test.spb"); await snapProcessor.saveFromTree(loadedFromObf, snapPath); // Load from Snap and verify styling is maintained diff --git a/test/suggestWordsEffort.test.ts b/test/suggestWordsEffort.test.ts index f3a8934..dad0f4b 100644 --- a/test/suggestWordsEffort.test.ts +++ b/test/suggestWordsEffort.test.ts @@ -1,121 +1,147 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { describe, expect, it } from '@jest/globals'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; -import { MetricsCalculator } from '../src/utilities/analytics/metrics/core'; -import { EFFORT_CONSTANTS, visualScanEffort } from '../src/utilities/analytics/metrics/effort'; +import { describe, expect, it } from "@jest/globals"; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; +import { MetricsCalculator } from "../src/utilities/analytics/metrics/core"; +import { + EFFORT_CONSTANTS, + visualScanEffort, +} from "../src/utilities/analytics/metrics/effort"; function buildTreeWithPredictions( predictions: string[], parametersPredictions?: string[], - pos?: string + pos?: string, ): { tree: AACTree; btnId: string } { const tree = new AACTree(); const page = new AACPage({ - id: 'root', - name: 'Home', + id: "root", + name: "Home", grid: { columns: 2, rows: 2 }, }); const btn = new AACButton({ - id: 'some_btn', - label: 'some', - type: 'SPEAK', + id: "some_btn", + label: "some", + type: "SPEAK", x: 0, y: 0, predictions, pos, - parameters: parametersPredictions ? { predictions: parametersPredictions } : undefined, + parameters: parametersPredictions + ? { predictions: parametersPredictions } + : undefined, }); page.grid[0][0] = btn; page.addButton(btn); tree.addPage(page); - tree.rootId = 'root'; + tree.rootId = "root"; return { tree, btnId: btn.id }; } -describe('Suggest Words effort cost', () => { - it('adds confirmation cost to Suggest Words word forms', () => { - const suggestWords = ['something', 'someone', 'somewhere']; +describe("Suggest Words effort cost", () => { + it("adds confirmation cost to Suggest Words word forms", () => { + const suggestWords = ["something", "someone", "somewhere"]; const { tree } = buildTreeWithPredictions(suggestWords, suggestWords); const calculator = new MetricsCalculator(); const result = calculator.analyze(tree, { useSmartGrammar: true }); - const parentBtn = result.buttons.find((b) => b.label === 'some'); + const parentBtn = result.buttons.find((b) => b.label === "some"); expect(parentBtn).toBeDefined(); - const something = result.buttons.find((b) => b.label === 'something'); + const something = result.buttons.find((b) => b.label === "something"); expect(something).toBeDefined(); expect(something!.is_word_form).toBe(true); expect(something!.is_suggest_words).toBe(true); const expectedEffort = - parentBtn!.effort + visualScanEffort(0) + EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT; + parentBtn!.effort + + visualScanEffort(0) + + EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT; expect(something!.effort).toBeCloseTo(expectedEffort, 4); }); - it('does not add confirmation cost to morphology word forms', () => { - const predictions = ['goes', 'going', 'went']; - const { tree } = buildTreeWithPredictions(predictions, undefined, 'Verb'); + it("does not add confirmation cost to morphology word forms", () => { + const predictions = ["goes", "going", "went"]; + const { tree } = buildTreeWithPredictions(predictions, undefined, "Verb"); const calculator = new MetricsCalculator(); const result = calculator.analyze(tree, { useSmartGrammar: true }); - const parentBtn = result.buttons.find((b) => b.label === 'some'); + const parentBtn = result.buttons.find((b) => b.label === "some"); expect(parentBtn).toBeDefined(); - const goes = result.buttons.find((b) => b.label === 'goes'); + const goes = result.buttons.find((b) => b.label === "goes"); expect(goes).toBeDefined(); expect(goes!.is_word_form).toBe(true); expect(goes!.is_suggest_words).toBeUndefined(); - expect(goes!.effort).toBeCloseTo(parentBtn!.effort + visualScanEffort(0), 4); + expect(goes!.effort).toBeCloseTo( + parentBtn!.effort + visualScanEffort(0), + 4, + ); }); - it('only adds confirmation to Suggest Words forms when predictions are mixed', () => { - const suggestWordsOriginals = ['something', 'someone']; - const allPredictions = ['something', 'someone', 'somes']; - const { tree } = buildTreeWithPredictions(allPredictions, suggestWordsOriginals, 'Noun'); + it("only adds confirmation to Suggest Words forms when predictions are mixed", () => { + const suggestWordsOriginals = ["something", "someone"]; + const allPredictions = ["something", "someone", "somes"]; + const { tree } = buildTreeWithPredictions( + allPredictions, + suggestWordsOriginals, + "Noun", + ); const calculator = new MetricsCalculator(); const result = calculator.analyze(tree, { useSmartGrammar: true }); - const parentBtn = result.buttons.find((b) => b.label === 'some'); + const parentBtn = result.buttons.find((b) => b.label === "some"); - const something = result.buttons.find((b) => b.label === 'something'); + const something = result.buttons.find((b) => b.label === "something"); expect(something).toBeDefined(); expect(something!.is_suggest_words).toBe(true); expect(something!.effort).toBeCloseTo( - parentBtn!.effort + visualScanEffort(0) + EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT, - 4 + parentBtn!.effort + + visualScanEffort(0) + + EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT, + 4, ); // "somes" is at index 2 → predictionPriorItems = 2 - const somes = result.buttons.find((b) => b.label === 'somes'); + const somes = result.buttons.find((b) => b.label === "somes"); expect(somes).toBeDefined(); expect(somes!.is_suggest_words).toBeUndefined(); - expect(somes!.effort).toBeCloseTo(parentBtn!.effort + visualScanEffort(2), 4); + expect(somes!.effort).toBeCloseTo( + parentBtn!.effort + visualScanEffort(2), + 4, + ); }); - it('has no confirmation when parameters.predictions is absent', () => { - const predictions = ['something', 'someone']; - const { tree } = buildTreeWithPredictions(predictions, undefined, 'Noun'); + it("has no confirmation when parameters.predictions is absent", () => { + const predictions = ["something", "someone"]; + const { tree } = buildTreeWithPredictions(predictions, undefined, "Noun"); const calculator = new MetricsCalculator(); const result = calculator.analyze(tree, { useSmartGrammar: true }); - const parentBtn = result.buttons.find((b) => b.label === 'some'); + const parentBtn = result.buttons.find((b) => b.label === "some"); - const something = result.buttons.find((b) => b.label === 'something'); + const something = result.buttons.find((b) => b.label === "something"); expect(something).toBeDefined(); expect(something!.is_suggest_words).toBeUndefined(); - expect(something!.effort).toBeCloseTo(parentBtn!.effort + visualScanEffort(0), 4); + expect(something!.effort).toBeCloseTo( + parentBtn!.effort + visualScanEffort(0), + 4, + ); }); - it('SUGGEST_WORDS_SELECTION_EFFORT is between 0.5 and 1.0', () => { - expect(EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT).toBeGreaterThanOrEqual(0.5); - expect(EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT).toBeLessThanOrEqual(1.0); + it("SUGGEST_WORDS_SELECTION_EFFORT is between 0.5 and 1.0", () => { + expect( + EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT, + ).toBeGreaterThanOrEqual(0.5); + expect(EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT).toBeLessThanOrEqual( + 1.0, + ); }); }); diff --git a/test/symbolAlignment.test.ts b/test/symbolAlignment.test.ts index 87a584c..c79898a 100644 --- a/test/symbolAlignment.test.ts +++ b/test/symbolAlignment.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from '@jest/globals'; +import { describe, it, expect } from "@jest/globals"; import { parseMessageWithSymbols, alignWords, @@ -6,137 +6,148 @@ import { translateWithSymbols, extractSymbolsFromButton, type ParsedMessage, -} from '../src/processors/gridset/symbolAlignment'; +} from "../src/processors/gridset/symbolAlignment"; -describe('Symbol Alignment Utilities', () => { - describe('parseMessageWithSymbols', () => { - it('should parse plain text without symbols', async () => { - const result = parseMessageWithSymbols('Hello world'); +describe("Symbol Alignment Utilities", () => { + describe("parseMessageWithSymbols", () => { + it("should parse plain text without symbols", async () => { + const result = parseMessageWithSymbols("Hello world"); - expect(result.text).toBe('Hello world'); - expect(result.words).toEqual(['Hello', 'world']); + expect(result.text).toBe("Hello world"); + expect(result.words).toEqual(["Hello", "world"]); expect(result.symbols).toEqual([]); }); - it('should parse text with richText symbols attached', async () => { + it("should parse text with richText symbols attached", async () => { const richTextSymbols = [ - { text: 'apple', image: '[widgit]/food/apple.png' }, - { text: 'juice', image: '[widgit]/food/juice.png' }, + { text: "apple", image: "[widgit]/food/apple.png" }, + { text: "juice", image: "[widgit]/food/juice.png" }, ]; - const result = parseMessageWithSymbols('I want apple juice', richTextSymbols); + const result = parseMessageWithSymbols( + "I want apple juice", + richTextSymbols, + ); - expect(result.text).toBe('I want apple juice'); - expect(result.words).toEqual(['I', 'want', 'apple', 'juice']); + expect(result.text).toBe("I want apple juice"); + expect(result.words).toEqual(["I", "want", "apple", "juice"]); expect(result.symbols).toHaveLength(2); // Check apple symbol - const appleSymbol = result.symbols.find((s) => s.originalWord === 'apple'); + const appleSymbol = result.symbols.find( + (s) => s.originalWord === "apple", + ); expect(appleSymbol).toBeDefined(); expect(appleSymbol?.wordIndex).toBe(2); - expect(appleSymbol?.symbolRef).toBe('[widgit]/food/apple.png'); + expect(appleSymbol?.symbolRef).toBe("[widgit]/food/apple.png"); // Check juice symbol - const juiceSymbol = result.symbols.find((s) => s.originalWord === 'juice'); + const juiceSymbol = result.symbols.find( + (s) => s.originalWord === "juice", + ); expect(juiceSymbol).toBeDefined(); expect(juiceSymbol?.wordIndex).toBe(3); - expect(juiceSymbol?.symbolRef).toBe('[widgit]/food/juice.png'); + expect(juiceSymbol?.symbolRef).toBe("[widgit]/food/juice.png"); }); - it('should handle fuzzy matching for case differences', async () => { - const richTextSymbols = [{ text: 'Apple', image: '[widgit]/food/apple.png' }]; + it("should handle fuzzy matching for case differences", async () => { + const richTextSymbols = [ + { text: "Apple", image: "[widgit]/food/apple.png" }, + ]; - const result = parseMessageWithSymbols('I want apple', richTextSymbols); + const result = parseMessageWithSymbols("I want apple", richTextSymbols); expect(result.symbols).toHaveLength(1); - expect(result.symbols[0].originalWord).toBe('apple'); + expect(result.symbols[0].originalWord).toBe("apple"); expect(result.symbols[0].wordIndex).toBe(2); }); - it('should normalize whitespace', async () => { - const result = parseMessageWithSymbols('I want apple'); + it("should normalize whitespace", async () => { + const result = parseMessageWithSymbols("I want apple"); - expect(result.text).toBe('I want apple'); - expect(result.words).toEqual(['I', 'want', 'apple']); + expect(result.text).toBe("I want apple"); + expect(result.words).toEqual(["I", "want", "apple"]); }); - it('should handle empty message', async () => { - const result = parseMessageWithSymbols(''); + it("should handle empty message", async () => { + const result = parseMessageWithSymbols(""); - expect(result.text).toBe(''); + expect(result.text).toBe(""); expect(result.words).toEqual([]); expect(result.symbols).toEqual([]); }); }); - describe('alignWords', () => { - it('should align identical words (cognates)', async () => { - const originalWords = ['I', 'want', 'apple', 'juice']; - const translatedWords = ['Yo', 'quiero', 'apple', 'jugo']; + describe("alignWords", () => { + it("should align identical words (cognates)", async () => { + const originalWords = ["I", "want", "apple", "juice"]; + const translatedWords = ["Yo", "quiero", "apple", "jugo"]; const alignment = alignWords(originalWords, translatedWords); expect(alignment).toHaveLength(4); // Check apple alignment (identical word) - const appleAlignment = alignment.find((a) => a.originalWord === 'apple'); + const appleAlignment = alignment.find((a) => a.originalWord === "apple"); expect(appleAlignment).toBeDefined(); - expect(appleAlignment?.translatedWord).toBe('apple'); + expect(appleAlignment?.translatedWord).toBe("apple"); expect(appleAlignment?.originalIndex).toBe(2); expect(appleAlignment?.translatedIndex).toBe(2); }); - it('should use positional alignment for non-matching words', async () => { - const originalWords = ['I', 'want', 'apple']; - const translatedWords = ['Yo', 'quiero', 'manzana']; + it("should use positional alignment for non-matching words", async () => { + const originalWords = ["I", "want", "apple"]; + const translatedWords = ["Yo", "quiero", "manzana"]; const alignment = alignWords(originalWords, translatedWords); expect(alignment).toHaveLength(3); // First word should align with first word - expect(alignment[0].originalWord).toBe('I'); - expect(alignment[0].translatedWord).toBe('Yo'); + expect(alignment[0].originalWord).toBe("I"); + expect(alignment[0].translatedWord).toBe("Yo"); // Last word should align with last word - expect(alignment[2].originalWord).toBe('apple'); - expect(alignment[2].translatedWord).toBe('manzana'); + expect(alignment[2].originalWord).toBe("apple"); + expect(alignment[2].translatedWord).toBe("manzana"); }); - it('should handle different length sentences', async () => { - const originalWords = ['Hello', 'world']; - const translatedWords = ['Hola', 'mundo', 'amigo']; + it("should handle different length sentences", async () => { + const originalWords = ["Hello", "world"]; + const translatedWords = ["Hola", "mundo", "amigo"]; const alignment = alignWords(originalWords, translatedWords); expect(alignment.length).toBeGreaterThan(0); // All original words should be aligned - expect(alignment.filter((a) => a.originalIndex !== undefined)).toHaveLength(2); + expect( + alignment.filter((a) => a.originalIndex !== undefined), + ).toHaveLength(2); }); - it('should handle numbers and punctuation', async () => { - const originalWords = ['I', 'want', '2', 'apples']; - const translatedWords = ['Quiero', '2', 'manzanas']; + it("should handle numbers and punctuation", async () => { + const originalWords = ["I", "want", "2", "apples"]; + const translatedWords = ["Quiero", "2", "manzanas"]; const alignment = alignWords(originalWords, translatedWords); // Number '2' should align exactly - const numberAlignment = alignment.find((a) => a.originalWord === '2'); + const numberAlignment = alignment.find((a) => a.originalWord === "2"); expect(numberAlignment).toBeDefined(); - expect(numberAlignment?.translatedWord).toBe('2'); + expect(numberAlignment?.translatedWord).toBe("2"); }); }); - describe('reattachSymbols', () => { - it('should reattach symbols to translated words based on alignment', async () => { + describe("reattachSymbols", () => { + it("should reattach symbols to translated words based on alignment", async () => { const originalParsed: ParsedMessage = { - text: 'I want apple', - words: ['I', 'want', 'apple'], + text: "I want apple", + words: ["I", "want", "apple"], symbols: [ { - symbolRef: '[widgit]/food/apple.png', + symbolRef: "[widgit]/food/apple.png", wordIndex: 2, - originalWord: 'apple', + originalWord: "apple", startPos: 7, endPos: 12, }, @@ -145,49 +156,53 @@ describe('Symbol Alignment Utilities', () => { const alignment = [ { - originalWord: 'I', - translatedWord: 'Yo', + originalWord: "I", + translatedWord: "Yo", originalIndex: 0, translatedIndex: 0, }, { - originalWord: 'want', - translatedWord: 'quiero', + originalWord: "want", + translatedWord: "quiero", originalIndex: 1, translatedIndex: 1, }, { - originalWord: 'apple', - translatedWord: 'manzana', + originalWord: "apple", + translatedWord: "manzana", originalIndex: 2, translatedIndex: 2, }, ]; - const result = reattachSymbols('Yo quiero manzana', originalParsed, alignment); + const result = reattachSymbols( + "Yo quiero manzana", + originalParsed, + alignment, + ); - expect(result.text).toBe('Yo quiero manzana'); + expect(result.text).toBe("Yo quiero manzana"); expect(result.richTextSymbols).toHaveLength(1); - expect(result.richTextSymbols[0].text).toBe('manzana'); - expect(result.richTextSymbols[0].image).toBe('[widgit]/food/apple.png'); + expect(result.richTextSymbols[0].text).toBe("manzana"); + expect(result.richTextSymbols[0].image).toBe("[widgit]/food/apple.png"); }); - it('should handle multiple symbols', async () => { + it("should handle multiple symbols", async () => { const originalParsed: ParsedMessage = { - text: 'I want apple juice', - words: ['I', 'want', 'apple', 'juice'], + text: "I want apple juice", + words: ["I", "want", "apple", "juice"], symbols: [ { - symbolRef: '[widgit]/food/apple.png', + symbolRef: "[widgit]/food/apple.png", wordIndex: 2, - originalWord: 'apple', + originalWord: "apple", startPos: 7, endPos: 12, }, { - symbolRef: '[widgit]/food/juice.png', + symbolRef: "[widgit]/food/juice.png", wordIndex: 3, - originalWord: 'juice', + originalWord: "juice", startPos: 13, endPos: 18, }, @@ -196,47 +211,51 @@ describe('Symbol Alignment Utilities', () => { const alignment = [ { - originalWord: 'I', - translatedWord: 'Yo', + originalWord: "I", + translatedWord: "Yo", originalIndex: 0, translatedIndex: 0, }, { - originalWord: 'want', - translatedWord: 'quiero', + originalWord: "want", + translatedWord: "quiero", originalIndex: 1, translatedIndex: 1, }, { - originalWord: 'apple', - translatedWord: 'manzana', + originalWord: "apple", + translatedWord: "manzana", originalIndex: 2, translatedIndex: 2, }, { - originalWord: 'juice', - translatedWord: 'jugo', + originalWord: "juice", + translatedWord: "jugo", originalIndex: 3, translatedIndex: 3, }, ]; - const result = reattachSymbols('Yo quiero manzana jugo', originalParsed, alignment); + const result = reattachSymbols( + "Yo quiero manzana jugo", + originalParsed, + alignment, + ); expect(result.richTextSymbols).toHaveLength(2); - expect(result.richTextSymbols[0].text).toBe('manzana'); - expect(result.richTextSymbols[1].text).toBe('jugo'); + expect(result.richTextSymbols[0].text).toBe("manzana"); + expect(result.richTextSymbols[1].text).toBe("jugo"); }); - it('should fallback to original word if alignment not found', async () => { + it("should fallback to original word if alignment not found", async () => { const originalParsed: ParsedMessage = { - text: 'I want apple', - words: ['I', 'want', 'apple'], + text: "I want apple", + words: ["I", "want", "apple"], symbols: [ { - symbolRef: '[widgit]/food/apple.png', + symbolRef: "[widgit]/food/apple.png", wordIndex: 2, - originalWord: 'apple', + originalWord: "apple", startPos: 7, endPos: 12, }, @@ -245,137 +264,163 @@ describe('Symbol Alignment Utilities', () => { const alignment = [ { - originalWord: 'I', - translatedWord: 'Yo', + originalWord: "I", + translatedWord: "Yo", originalIndex: 0, translatedIndex: 0, }, { - originalWord: 'want', - translatedWord: 'quiero', + originalWord: "want", + translatedWord: "quiero", originalIndex: 1, translatedIndex: 1, }, // No alignment for 'apple' ]; - const result = reattachSymbols('Yo quiero fruta', originalParsed, alignment); + const result = reattachSymbols( + "Yo quiero fruta", + originalParsed, + alignment, + ); expect(result.richTextSymbols).toHaveLength(1); - expect(result.richTextSymbols[0].text).toBe('apple'); // Fallback to original - expect(result.richTextSymbols[0].image).toBe('[widgit]/food/apple.png'); + expect(result.richTextSymbols[0].text).toBe("apple"); // Fallback to original + expect(result.richTextSymbols[0].image).toBe("[widgit]/food/apple.png"); }); }); - describe('translateWithSymbols (integration)', () => { - it('should complete the full pipeline', async () => { - const originalMessage = 'I want apple juice'; - const translatedText = 'Yo quiero jugo de manzana'; + describe("translateWithSymbols (integration)", () => { + it("should complete the full pipeline", async () => { + const originalMessage = "I want apple juice"; + const translatedText = "Yo quiero jugo de manzana"; const richTextSymbols = [ - { text: 'apple', image: '[widgit]/food/apple.png' }, - { text: 'juice', image: '[widgit]/food/juice.png' }, + { text: "apple", image: "[widgit]/food/apple.png" }, + { text: "juice", image: "[widgit]/food/juice.png" }, ]; - const result = translateWithSymbols(originalMessage, translatedText, richTextSymbols); + const result = translateWithSymbols( + originalMessage, + translatedText, + richTextSymbols, + ); - expect(result.text).toBe('Yo quiero jugo de manzana'); + expect(result.text).toBe("Yo quiero jugo de manzana"); expect(result.richTextSymbols).toHaveLength(2); // Symbols should be reattached to translated words - const appleSymbol = result.richTextSymbols.find((s) => s.image?.includes('apple')); + const appleSymbol = result.richTextSymbols.find((s) => + s.image?.includes("apple"), + ); expect(appleSymbol).toBeDefined(); - expect(appleSymbol?.text).not.toBe('apple'); // Should be a translated word + expect(appleSymbol?.text).not.toBe("apple"); // Should be a translated word - const juiceSymbol = result.richTextSymbols.find((s) => s.image?.includes('juice')); + const juiceSymbol = result.richTextSymbols.find((s) => + s.image?.includes("juice"), + ); expect(juiceSymbol).toBeDefined(); }); - it('should handle messages without symbols', async () => { - const result = translateWithSymbols('Hello', 'Hola'); + it("should handle messages without symbols", async () => { + const result = translateWithSymbols("Hello", "Hola"); - expect(result.text).toBe('Hola'); + expect(result.text).toBe("Hola"); expect(result.richTextSymbols).toEqual([]); }); - it('should handle English to Spanish translation', async () => { - const originalMessage = 'I want water'; - const translatedText = 'Yo quiero agua'; - const richTextSymbols = [{ text: 'water', image: '[widgit]/food/water.png' }]; + it("should handle English to Spanish translation", async () => { + const originalMessage = "I want water"; + const translatedText = "Yo quiero agua"; + const richTextSymbols = [ + { text: "water", image: "[widgit]/food/water.png" }, + ]; - const result = translateWithSymbols(originalMessage, translatedText, richTextSymbols); + const result = translateWithSymbols( + originalMessage, + translatedText, + richTextSymbols, + ); - expect(result.text).toBe('Yo quiero agua'); + expect(result.text).toBe("Yo quiero agua"); expect(result.richTextSymbols).toHaveLength(1); - expect(result.richTextSymbols[0].image).toBe('[widgit]/food/water.png'); + expect(result.richTextSymbols[0].image).toBe("[widgit]/food/water.png"); // The symbol should be attached to 'agua' (the translation of 'water') expect(result.richTextSymbols[0].text).toBeTruthy(); }); - it('should handle symbol library references', async () => { - const originalMessage = 'home'; - const translatedText = 'casa'; - const richTextSymbols = [{ text: 'home', image: '[widgit]/places/home.png' }]; + it("should handle symbol library references", async () => { + const originalMessage = "home"; + const translatedText = "casa"; + const richTextSymbols = [ + { text: "home", image: "[widgit]/places/home.png" }, + ]; - const result = translateWithSymbols(originalMessage, translatedText, richTextSymbols); + const result = translateWithSymbols( + originalMessage, + translatedText, + richTextSymbols, + ); - expect(result.richTextSymbols[0].image).toBe('[widgit]/places/home.png'); + expect(result.richTextSymbols[0].image).toBe("[widgit]/places/home.png"); }); }); - describe('extractSymbolsFromButton', () => { - it('should extract symbols from semanticAction.richText.symbols', async () => { + describe("extractSymbolsFromButton", () => { + it("should extract symbols from semanticAction.richText.symbols", async () => { const button = { - label: 'apple', - message: 'I want apple', + label: "apple", + message: "I want apple", semanticAction: { richText: { - text: 'I want apple', - symbols: [{ text: 'apple', image: '[widgit]/food/apple.png' }], + text: "I want apple", + symbols: [{ text: "apple", image: "[widgit]/food/apple.png" }], }, }, }; const symbols = extractSymbolsFromButton(button); - expect(symbols).toEqual([{ text: 'apple', image: '[widgit]/food/apple.png' }]); + expect(symbols).toEqual([ + { text: "apple", image: "[widgit]/food/apple.png" }, + ]); }); - it('should extract symbols from symbolLibrary and symbolPath', async () => { + it("should extract symbols from symbolLibrary and symbolPath", async () => { const button = { - label: 'apple', - message: 'apple', - symbolLibrary: 'widgit', - symbolPath: '/food/apple.png', + label: "apple", + message: "apple", + symbolLibrary: "widgit", + symbolPath: "/food/apple.png", }; const symbols = extractSymbolsFromButton(button); expect(symbols).toBeDefined(); expect(symbols).toHaveLength(1); - expect(symbols?.[0].text).toBe('apple'); - expect(symbols?.[0].image).toBe('[widgit]/food/apple.png'); + expect(symbols?.[0].text).toBe("apple"); + expect(symbols?.[0].image).toBe("[widgit]/food/apple.png"); }); - it('should extract symbols from image field if it is a symbol reference', async () => { + it("should extract symbols from image field if it is a symbol reference", async () => { const button = { - label: 'home', - message: 'home', - image: '[widgit]/places/home.png', + label: "home", + message: "home", + image: "[widgit]/places/home.png", }; const symbols = extractSymbolsFromButton(button); expect(symbols).toBeDefined(); expect(symbols).toHaveLength(1); - expect(symbols?.[0].text).toBe('home'); - expect(symbols?.[0].image).toBe('[widgit]/places/home.png'); + expect(symbols?.[0].text).toBe("home"); + expect(symbols?.[0].image).toBe("[widgit]/places/home.png"); }); - it('should return undefined for regular image paths (not symbol references)', async () => { + it("should return undefined for regular image paths (not symbol references)", async () => { const button = { - label: 'photo', - message: 'photo', - image: 'images/photo.png', + label: "photo", + message: "photo", + image: "images/photo.png", }; const symbols = extractSymbolsFromButton(button); @@ -383,10 +428,10 @@ describe('Symbol Alignment Utilities', () => { expect(symbols).toBeUndefined(); }); - it('should return undefined for buttons without symbols', async () => { + it("should return undefined for buttons without symbols", async () => { const button = { - label: 'hello', - message: 'hello', + label: "hello", + message: "hello", }; const symbols = extractSymbolsFromButton(button); @@ -394,10 +439,10 @@ describe('Symbol Alignment Utilities', () => { expect(symbols).toBeUndefined(); }); - it('should handle empty label and message', async () => { + it("should handle empty label and message", async () => { const button = { - symbolLibrary: 'widgit', - symbolPath: '/food/apple.png', + symbolLibrary: "widgit", + symbolPath: "/food/apple.png", }; const symbols = extractSymbolsFromButton(button); @@ -406,58 +451,76 @@ describe('Symbol Alignment Utilities', () => { }); }); - describe('Real-world scenarios', () => { - it('should handle AAC gridset button translation', async () => { + describe("Real-world scenarios", () => { + it("should handle AAC gridset button translation", async () => { // Simulate a real AAC button with a symbol const button = { - id: 'btn1', - label: 'apple', - message: 'I want apple', + id: "btn1", + label: "apple", + message: "I want apple", semanticAction: { richText: { - text: 'I want apple', - symbols: [{ text: 'apple', image: '[widgit]/food/apple.png' }], + text: "I want apple", + symbols: [{ text: "apple", image: "[widgit]/food/apple.png" }], }, }, }; const originalMessage = button.message; - const translatedText = 'Yo quiero manzana'; + const translatedText = "Yo quiero manzana"; const symbols = extractSymbolsFromButton(button); - const result = translateWithSymbols(originalMessage, translatedText, symbols); + const result = translateWithSymbols( + originalMessage, + translatedText, + symbols, + ); - expect(result.text).toBe('Yo quiero manzana'); + expect(result.text).toBe("Yo quiero manzana"); expect(result.richTextSymbols).toHaveLength(1); - expect(result.richTextSymbols[0].image).toBe('[widgit]/food/apple.png'); + expect(result.richTextSymbols[0].image).toBe("[widgit]/food/apple.png"); // Symbol should be attached to the translation of 'apple' (which is 'manzana') - expect(['manzana', 'quiero']).toContain(result.richTextSymbols[0].text); + expect(["manzana", "quiero"]).toContain(result.richTextSymbols[0].text); }); - it('should handle multi-word phrases with symbols', async () => { - const originalMessage = 'I want to go home'; - const translatedText = 'Quiero ir a casa'; - const richTextSymbols = [{ text: 'home', image: '[widgit]/places/home.png' }]; + it("should handle multi-word phrases with symbols", async () => { + const originalMessage = "I want to go home"; + const translatedText = "Quiero ir a casa"; + const richTextSymbols = [ + { text: "home", image: "[widgit]/places/home.png" }, + ]; - const result = translateWithSymbols(originalMessage, translatedText, richTextSymbols); + const result = translateWithSymbols( + originalMessage, + translatedText, + richTextSymbols, + ); expect(result.richTextSymbols).toHaveLength(1); - expect(result.richTextSymbols[0].image).toBe('[widgit]/places/home.png'); + expect(result.richTextSymbols[0].image).toBe("[widgit]/places/home.png"); }); - it('should preserve all symbols in a sentence with multiple symbols', async () => { - const originalMessage = 'I eat apple and banana'; - const translatedText = 'Como manzana y plátano'; + it("should preserve all symbols in a sentence with multiple symbols", async () => { + const originalMessage = "I eat apple and banana"; + const translatedText = "Como manzana y plátano"; const richTextSymbols = [ - { text: 'apple', image: '[widgit]/food/apple.png' }, - { text: 'banana', image: '[widgit]/food/banana.png' }, + { text: "apple", image: "[widgit]/food/apple.png" }, + { text: "banana", image: "[widgit]/food/banana.png" }, ]; - const result = translateWithSymbols(originalMessage, translatedText, richTextSymbols); + const result = translateWithSymbols( + originalMessage, + translatedText, + richTextSymbols, + ); expect(result.richTextSymbols).toHaveLength(2); - expect(result.richTextSymbols.some((s) => s.image?.includes('apple'))).toBe(true); - expect(result.richTextSymbols.some((s) => s.image?.includes('banana'))).toBe(true); + expect( + result.richTextSymbols.some((s) => s.image?.includes("apple")), + ).toBe(true); + expect( + result.richTextSymbols.some((s) => s.image?.includes("banana")), + ).toBe(true); }); }); }); diff --git a/test/touchchatHelpers.test.ts b/test/touchchatHelpers.test.ts index 512a414..0422ed1 100644 --- a/test/touchchatHelpers.test.ts +++ b/test/touchchatHelpers.test.ts @@ -1,24 +1,24 @@ -import { AACTree, AACPage, TouchChat } from '../src/index'; +import { AACTree, AACPage, TouchChat } from "../src/index"; -describe('TouchChat helpers', () => { - it('maps page buttons with resolved images', async () => { +describe("TouchChat helpers", () => { + it("maps page buttons with resolved images", async () => { const tree = new AACTree(); const page = new AACPage({ - id: 'page1', - buttons: [{ id: 'btn1', resolvedImageEntry: 'img.png' } as any], + id: "page1", + buttons: [{ id: "btn1", resolvedImageEntry: "img.png" } as any], }); tree.addPage(page); - const map = TouchChat.getPageTokenImageMap(tree, 'page1'); - expect(map.get('btn1')).toBe('img.png'); + const map = TouchChat.getPageTokenImageMap(tree, "page1"); + expect(map.get("btn1")).toBe("img.png"); - const empty = TouchChat.getPageTokenImageMap(tree, 'missing'); + const empty = TouchChat.getPageTokenImageMap(tree, "missing"); expect(empty.size).toBe(0); }); - it('returns empty image sets/placeholders', async () => { + it("returns empty image sets/placeholders", async () => { const tree = new AACTree(); expect(TouchChat.getAllowedImageEntries(tree).size).toBe(0); - expect(TouchChat.openImage('ce', 'entry')).toBeNull(); + expect(TouchChat.openImage("ce", "entry")).toBeNull(); }); }); diff --git a/test/touchchatProcessor.comprehensive.test.ts b/test/touchchatProcessor.comprehensive.test.ts index 3ed854e..f643fb4 100644 --- a/test/touchchatProcessor.comprehensive.test.ts +++ b/test/touchchatProcessor.comprehensive.test.ts @@ -1,14 +1,14 @@ // Comprehensive tests for TouchChatProcessor to improve coverage from 57.62% to 85%+ -import fs from 'fs'; -import path from 'path'; -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -import { AACTree } from '../src/core/treeStructure'; -import { TreeFactory, PageFactory, ButtonFactory } from './utils/testFactories'; +import fs from "fs"; +import path from "path"; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import { AACTree } from "../src/core/treeStructure"; +import { TreeFactory, PageFactory, ButtonFactory } from "./utils/testFactories"; -describe('TouchChatProcessor - Comprehensive Coverage Tests', () => { +describe("TouchChatProcessor - Comprehensive Coverage Tests", () => { let processor: TouchChatProcessor; - const tempDir = path.join(__dirname, 'temp_touchchat'); - const exampleFile = path.join(__dirname, 'assets/excel/example.ce'); + const tempDir = path.join(__dirname, "temp_touchchat"); + const exampleFile = path.join(__dirname, "assets/excel/example.ce"); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -26,13 +26,15 @@ describe('TouchChatProcessor - Comprehensive Coverage Tests', () => { } }); - describe('SQLite Schema Tests', () => { - it('should handle TouchChat v1.x database schema', async () => { + describe("SQLite Schema Tests", () => { + it("should handle TouchChat v1.x database schema", async () => { // Test with minimal valid TouchChat database structure const tree = TreeFactory.createSimple(); - const outputPath = path.join(tempDir, 'v1_test.ce'); + const outputPath = path.join(tempDir, "v1_test.ce"); - await expect(processor.saveFromTree(tree, outputPath)).resolves.not.toThrow(); + await expect( + processor.saveFromTree(tree, outputPath), + ).resolves.not.toThrow(); expect(fs.existsSync(outputPath)).toBe(true); @@ -42,22 +44,24 @@ describe('TouchChatProcessor - Comprehensive Coverage Tests', () => { expect(Object.keys(loadedTree.pages).length).toBeGreaterThan(0); }); - it('should handle TouchChat v2.x database schema', async () => { + it("should handle TouchChat v2.x database schema", async () => { // Test with more complex button configurations const tree = TreeFactory.createCommunicationBoard(); - const outputPath = path.join(tempDir, 'v2_test.ce'); + const outputPath = path.join(tempDir, "v2_test.ce"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); expect(loadedTree).toBeDefined(); - expect(Object.keys(loadedTree.pages).length).toBe(Object.keys(tree.pages).length); + expect(Object.keys(loadedTree.pages).length).toBe( + Object.keys(tree.pages).length, + ); }); - it('should handle TouchChat v3.x database schema', async () => { + it("should handle TouchChat v3.x database schema", async () => { // Test with large dataset const tree = TreeFactory.createLarge(5, 10); - const outputPath = path.join(tempDir, 'v3_test.ce'); + const outputPath = path.join(tempDir, "v3_test.ce"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -66,189 +70,191 @@ describe('TouchChatProcessor - Comprehensive Coverage Tests', () => { expect(Object.keys(loadedTree.pages).length).toBe(5); }); - it('should process buttons with custom actions', async () => { + it("should process buttons with custom actions", async () => { const page = PageFactory.create({ - id: 'custom_actions', - name: 'Custom Actions Page', + id: "custom_actions", + name: "Custom Actions Page", buttons: [ - { label: 'Speak Button', message: 'Hello World', type: 'SPEAK' }, + { label: "Speak Button", message: "Hello World", type: "SPEAK" }, { - label: 'Nav Button', - message: 'Navigate', - type: 'NAVIGATE', - targetPageId: 'target', + label: "Nav Button", + message: "Navigate", + type: "NAVIGATE", + targetPageId: "target", }, ], }); const tree = new AACTree(); tree.addPage(page); - tree.addPage(PageFactory.create({ id: 'target', name: 'Target Page' })); + tree.addPage(PageFactory.create({ id: "target", name: "Target Page" })); - const outputPath = path.join(tempDir, 'custom_actions.ce'); + const outputPath = path.join(tempDir, "custom_actions.ce"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); - const loadedPage = loadedTree.getPage('custom_actions'); + const loadedPage = loadedTree.getPage("custom_actions"); expect(loadedPage).toBeDefined(); if (!loadedPage) { return; } expect(loadedPage.buttons).toHaveLength(2); - expect(loadedPage.buttons[0].type).toBe('SPEAK'); - expect(loadedPage.buttons[1].type).toBe('NAVIGATE'); - expect(loadedPage.buttons[1].targetPageId).toBe('target'); + expect(loadedPage.buttons[0].type).toBe("SPEAK"); + expect(loadedPage.buttons[1].type).toBe("NAVIGATE"); + expect(loadedPage.buttons[1].targetPageId).toBe("target"); }); - it('should handle buttons with multiple audio recordings', async () => { + it("should handle buttons with multiple audio recordings", async () => { const button = ButtonFactory.create({ - label: 'Audio Button', - message: 'I have audio', - type: 'SPEAK', + label: "Audio Button", + message: "I have audio", + type: "SPEAK", }); // Add audio recording button.audioRecording = { id: 1, - data: Buffer.from('fake audio data'), - identifier: 'audio_1', - metadata: 'Test audio recording', + data: Buffer.from("fake audio data"), + identifier: "audio_1", + metadata: "Test audio recording", }; const page = PageFactory.create({ - id: 'audio_page', - name: 'Audio Page', + id: "audio_page", + name: "Audio Page", }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, 'audio_test.ce'); + const outputPath = path.join(tempDir, "audio_test.ce"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); - const loadedPage = loadedTree.getPage('audio_page'); + const loadedPage = loadedTree.getPage("audio_page"); expect(loadedPage).toBeDefined(); if (!loadedPage) { return; } - expect(loadedPage.buttons[0].label).toBe('Audio Button'); + expect(loadedPage.buttons[0].label).toBe("Audio Button"); }); - it('should process navigation buttons with complex targets', async () => { + it("should process navigation buttons with complex targets", async () => { // Create a complex navigation hierarchy - const homePage = PageFactory.create({ id: 'home', name: 'Home' }); + const homePage = PageFactory.create({ id: "home", name: "Home" }); const categoryPage = PageFactory.create({ - id: 'category', - name: 'Category', - parentId: 'home', + id: "category", + name: "Category", + parentId: "home", }); const subPage = PageFactory.create({ - id: 'sub', - name: 'Sub Page', - parentId: 'category', + id: "sub", + name: "Sub Page", + parentId: "category", }); // Add navigation buttons homePage.addButton( ButtonFactory.create({ - label: 'Go to Category', - type: 'NAVIGATE', - targetPageId: 'category', - }) + label: "Go to Category", + type: "NAVIGATE", + targetPageId: "category", + }), ); categoryPage.addButton( ButtonFactory.create({ - label: 'Go to Sub', - type: 'NAVIGATE', - targetPageId: 'sub', - }) + label: "Go to Sub", + type: "NAVIGATE", + targetPageId: "sub", + }), ); categoryPage.addButton( ButtonFactory.create({ - label: 'Back to Home', - type: 'NAVIGATE', - targetPageId: 'home', - }) + label: "Back to Home", + type: "NAVIGATE", + targetPageId: "home", + }), ); const tree = new AACTree(); tree.addPage(homePage); tree.addPage(categoryPage); tree.addPage(subPage); - tree.rootId = 'home'; + tree.rootId = "home"; - const outputPath = path.join(tempDir, 'navigation_test.ce'); + const outputPath = path.join(tempDir, "navigation_test.ce"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); - expect(loadedTree.rootId).toBe('home'); + expect(loadedTree.rootId).toBe("home"); expect(Object.keys(loadedTree.pages)).toHaveLength(3); - const loadedHome = loadedTree.getPage('home'); + const loadedHome = loadedTree.getPage("home"); expect(loadedHome).toBeDefined(); if (!loadedHome) { return; } - expect(loadedHome.buttons[0].targetPageId).toBe('category'); + expect(loadedHome.buttons[0].targetPageId).toBe("category"); - const loadedCategory = loadedTree.getPage('category'); + const loadedCategory = loadedTree.getPage("category"); expect(loadedCategory).toBeDefined(); if (!loadedCategory) { return; } expect(loadedCategory.buttons).toHaveLength(2); - expect(loadedCategory.buttons[0].targetPageId).toBe('sub'); - expect(loadedCategory.buttons[1].targetPageId).toBe('home'); + expect(loadedCategory.buttons[0].targetPageId).toBe("sub"); + expect(loadedCategory.buttons[1].targetPageId).toBe("home"); }); }); - describe('Database Connection Edge Cases', () => { - it('should handle corrupted SQLite databases gracefully', async () => { - const corruptedPath = path.join(tempDir, 'corrupted.ce'); - fs.writeFileSync(corruptedPath, 'This is not a valid zip file'); + describe("Database Connection Edge Cases", () => { + it("should handle corrupted SQLite databases gracefully", async () => { + const corruptedPath = path.join(tempDir, "corrupted.ce"); + fs.writeFileSync(corruptedPath, "This is not a valid zip file"); await expect(processor.loadIntoTree(corruptedPath)).rejects.toThrow(); }); - it('should process databases with missing required tables', async () => { + it("should process databases with missing required tables", async () => { // Create a minimal zip file without proper database structure // eslint-disable-next-line @typescript-eslint/no-var-requires - const AdmZip = require('adm-zip'); + const AdmZip = require("adm-zip"); const zip = new AdmZip(); - zip.addFile('empty.txt', Buffer.from('empty')); + zip.addFile("empty.txt", Buffer.from("empty")); - const invalidPath = path.join(tempDir, 'invalid.ce'); + const invalidPath = path.join(tempDir, "invalid.ce"); zip.writeZip(invalidPath); await expect(processor.loadIntoTree(invalidPath)).rejects.toThrow(); }); - it('should handle databases with foreign key constraints', async () => { + it("should handle databases with foreign key constraints", async () => { // Test with a valid tree that has proper relationships const tree = TreeFactory.createCommunicationBoard(); - const outputPath = path.join(tempDir, 'fk_test.ce'); + const outputPath = path.join(tempDir, "fk_test.ce"); - await expect(processor.saveFromTree(tree, outputPath)).resolves.not.toThrow(); + await expect( + processor.saveFromTree(tree, outputPath), + ).resolves.not.toThrow(); const loadedTree = await processor.loadIntoTree(outputPath); expect(loadedTree).toBeDefined(); }); }); - describe('Large Dataset Performance', () => { - it('should process databases with 1000+ buttons efficiently', async () => { + describe("Large Dataset Performance", () => { + it("should process databases with 1000+ buttons efficiently", async () => { const startTime = Date.now(); // Create a large tree with many buttons const tree = TreeFactory.createLarge(10, 100); // 10 pages, 100 buttons each = 1000 buttons - const outputPath = path.join(tempDir, 'large_test.ce'); + const outputPath = path.join(tempDir, "large_test.ce"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -268,10 +274,10 @@ describe('TouchChatProcessor - Comprehensive Coverage Tests', () => { expect(totalButtons).toBe(1000); }); - it('should handle databases with complex page hierarchies', async () => { + it("should handle databases with complex page hierarchies", async () => { // Create a deep hierarchy const tree = new AACTree(); - let currentParent = 'root'; + let currentParent = "root"; // Create 5 levels deep for (let level = 0; level < 5; level++) { @@ -290,9 +296,9 @@ describe('TouchChatProcessor - Comprehensive Coverage Tests', () => { page.addButton( ButtonFactory.create({ label: `Go to ${targetId}`, - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId: targetId, - }) + }), ); } } @@ -305,7 +311,7 @@ describe('TouchChatProcessor - Comprehensive Coverage Tests', () => { } } - const outputPath = path.join(tempDir, 'hierarchy_test.ce'); + const outputPath = path.join(tempDir, "hierarchy_test.ce"); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -314,10 +320,10 @@ describe('TouchChatProcessor - Comprehensive Coverage Tests', () => { }); }); - describe('Text Processing Methods', () => { - it('should extract all texts from complex database', async () => { + describe("Text Processing Methods", () => { + it("should extract all texts from complex database", async () => { if (!fs.existsSync(exampleFile)) { - console.log('Skipping test - example file not found'); + console.log("Skipping test - example file not found"); return; } @@ -327,34 +333,38 @@ describe('TouchChatProcessor - Comprehensive Coverage Tests', () => { // Verify texts are non-empty strings texts.forEach((text) => { - expect(typeof text).toBe('string'); + expect(typeof text).toBe("string"); expect(text.length).toBeGreaterThan(0); }); }); - it('should process texts with translations', async () => { + it("should process texts with translations", async () => { const tree = TreeFactory.createSimple(); - const inputPath = path.join(tempDir, 'input_for_translation.ce'); - const outputPath = path.join(tempDir, 'translation_test.ce'); + const inputPath = path.join(tempDir, "input_for_translation.ce"); + const outputPath = path.join(tempDir, "translation_test.ce"); // Save the tree first await processor.saveFromTree(tree, inputPath); // Create translation map const translations = new Map(); - translations.set('Hello', 'Hola'); - translations.set('Food', 'Comida'); - translations.set('Home', 'Casa'); - - const result = await processor.processTexts(inputPath, translations, outputPath); + translations.set("Hello", "Hola"); + translations.set("Food", "Comida"); + translations.set("Home", "Casa"); + + const result = await processor.processTexts( + inputPath, + translations, + outputPath, + ); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); // Load and verify translations were applied const translatedTree = await processor.loadIntoTree(outputPath); - const homePage = translatedTree.getPage('home'); + const homePage = translatedTree.getPage("home"); expect(homePage).toBeDefined(); - expect(homePage?.name).toBe('Casa'); + expect(homePage?.name).toBe("Casa"); }); }); }); diff --git a/test/touchchatProcessor.coverage.test.ts b/test/touchchatProcessor.coverage.test.ts index 9236924..a0fa199 100644 --- a/test/touchchatProcessor.coverage.test.ts +++ b/test/touchchatProcessor.coverage.test.ts @@ -1,16 +1,16 @@ -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; -import path from 'path'; -import fs from 'fs'; -import AdmZip from 'adm-zip'; -import os from 'os'; -import Database from 'better-sqlite3'; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; +import path from "path"; +import fs from "fs"; +import AdmZip from "adm-zip"; +import os from "os"; +import Database from "better-sqlite3"; -describe('TouchChatProcessor Coverage', () => { - const _exampleFile: string = path.join(__dirname, 'assets/excel/example.ce'); - const tempDir = path.join(os.tmpdir(), 'touchchat-test'); - const tempDbPath = path.join(tempDir, 'vocab.c4v'); - const tempZipPath = path.join(__dirname, 'temp.ce'); +describe("TouchChatProcessor Coverage", () => { + const _exampleFile: string = path.join(__dirname, "assets/excel/example.ce"); + const tempDir = path.join(os.tmpdir(), "touchchat-test"); + const tempDbPath = path.join(tempDir, "vocab.c4v"); + const tempZipPath = path.join(__dirname, "temp.ce"); beforeEach(async () => { if (fs.existsSync(tempDir)) { @@ -31,38 +31,38 @@ describe('TouchChatProcessor Coverage', () => { } }); - describe('File Handling', () => { - it('should throw an error if no .c4v file is found in the archive', async () => { + describe("File Handling", () => { + it("should throw an error if no .c4v file is found in the archive", async () => { const zip = new AdmZip(); - zip.addFile('test.txt', Buffer.from('hello')); + zip.addFile("test.txt", Buffer.from("hello")); zip.writeZip(tempZipPath); const processor = new TouchChatProcessor(); await expect(processor.loadIntoTree(tempZipPath)).rejects.toThrow( - 'No .c4v vocab DB found in TouchChat export' + "No .c4v vocab DB found in TouchChat export", ); }); }); - describe('Save and Load with UNIQUE constraints', () => { - it('should save and reload a tree without UNIQUE constraint violations', async () => { + describe("Save and Load with UNIQUE constraints", () => { + it("should save and reload a tree without UNIQUE constraint violations", async () => { const processor = new TouchChatProcessor(); const tree = new AACTree(); const originalPage1 = new AACPage({ - id: '1', - name: 'Page 1', + id: "1", + name: "Page 1", buttons: [], }); - const button1 = new AACButton({ id: '101', label: 'Button 1' }); + const button1 = new AACButton({ id: "101", label: "Button 1" }); originalPage1.addButton(button1); tree.addPage(originalPage1); const originalPage2 = new AACPage({ - id: '2', - name: 'Page 2', + id: "2", + name: "Page 2", buttons: [], }); - const button2 = new AACButton({ id: '102', label: 'Button 2' }); + const button2 = new AACButton({ id: "102", label: "Button 2" }); originalPage2.addButton(button2); tree.addPage(originalPage2); @@ -72,8 +72,8 @@ describe('TouchChatProcessor Coverage', () => { const newTree = await newProcessor.loadIntoTree(tempZipPath); expect(Object.keys(newTree.pages).length).toBe(2); - const loadedPage1 = newTree.getPage('1'); - const loadedPage2 = newTree.getPage('2'); + const loadedPage1 = newTree.getPage("1"); + const loadedPage2 = newTree.getPage("2"); expect(loadedPage1).toBeDefined(); expect(loadedPage2).toBeDefined(); if (loadedPage1) { @@ -85,15 +85,18 @@ describe('TouchChatProcessor Coverage', () => { }); }); - describe('Schema Variations', () => { - it('should handle different table schemas gracefully', async () => { + describe("Schema Variations", () => { + it("should handle different table schemas gracefully", async () => { const db = new Database(tempDbPath); db.exec(` CREATE TABLE resources (id INTEGER PRIMARY KEY, name TEXT); CREATE TABLE pages (id INTEGER PRIMARY KEY, resource_id INTEGER); `); - db.prepare('INSERT INTO resources (id, name) VALUES (?, ?)').run(1, 'Page 1'); - db.prepare('INSERT INTO pages (id, resource_id) VALUES (?, ?)').run(1, 1); + db.prepare("INSERT INTO resources (id, name) VALUES (?, ?)").run( + 1, + "Page 1", + ); + db.prepare("INSERT INTO pages (id, resource_id) VALUES (?, ?)").run(1, 1); db.close(); const zip = new AdmZip(); @@ -103,7 +106,7 @@ describe('TouchChatProcessor Coverage', () => { const processor = new TouchChatProcessor(); const tree = await processor.loadIntoTree(tempZipPath); expect(Object.keys(tree.pages).length).toBe(1); - const testPage = tree.getPage('1'); + const testPage = tree.getPage("1"); expect(testPage).toBeDefined(); expect(testPage?.buttons.length).toBe(0); // No buttons table }); diff --git a/test/touchchatProcessor.roundtrip.test.ts b/test/touchchatProcessor.roundtrip.test.ts index 7fe7dfb..6f29b45 100644 --- a/test/touchchatProcessor.roundtrip.test.ts +++ b/test/touchchatProcessor.roundtrip.test.ts @@ -1,20 +1,22 @@ -import fs from 'fs'; -import path from 'path'; -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import fs from "fs"; +import path from "path"; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; // import { AACTree } from '../src/core/treeStructure'; // Unused import -describe('TouchChatProcessor round-trip', () => { - const tcPath = path.join(__dirname, 'assets/excel/example.touchchat.json'); - const outPath = path.join(__dirname, 'out.touchchat.json'); +describe("TouchChatProcessor round-trip", () => { + const tcPath = path.join(__dirname, "assets/excel/example.touchchat.json"); + const outPath = path.join(__dirname, "out.touchchat.json"); afterAll(async () => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it('round-trips TouchChat JSON without losing pages or navigation', async () => { + it("round-trips TouchChat JSON without losing pages or navigation", async () => { if (!fs.existsSync(tcPath)) return; const processor = new TouchChatProcessor(); const tree1 = await processor.loadIntoTree(tcPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); - expect(Object.keys(tree1.pages).sort()).toEqual(Object.keys(tree2.pages).sort()); + expect(Object.keys(tree1.pages).sort()).toEqual( + Object.keys(tree2.pages).sort(), + ); for (const pid in tree1.pages) { expect(tree2.pages).toHaveProperty(pid); const btnLabels1 = tree1.pages[pid].buttons.map((b) => b.label).sort(); diff --git a/test/touchchatProcessor.test.ts b/test/touchchatProcessor.test.ts index 8021f3c..c3b0764 100644 --- a/test/touchchatProcessor.test.ts +++ b/test/touchchatProcessor.test.ts @@ -1,19 +1,19 @@ // Unit tests for TouchChatProcessor -import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -import { AACTree } from '../src/core/treeStructure'; -import path from 'path'; +import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import { AACTree } from "../src/core/treeStructure"; +import path from "path"; -describe('TouchChatProcessor', () => { - const exampleFile: string = path.join(__dirname, 'assets/excel/example.ce'); +describe("TouchChatProcessor", () => { + const exampleFile: string = path.join(__dirname, "assets/excel/example.ce"); - it('should load a .ce file into a tree', async () => { + it("should load a .ce file into a tree", async () => { const processor = new TouchChatProcessor(); const tree: AACTree = await processor.loadIntoTree(exampleFile); expect(tree).toBeDefined(); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it('should extract all texts from a .ce file', async () => { + it("should extract all texts from a .ce file", async () => { const processor = new TouchChatProcessor(); const texts: string[] = await processor.extractTexts(exampleFile); expect(Array.isArray(texts)).toBe(true); diff --git a/test/utils/ioHelpers.test.ts b/test/utils/ioHelpers.test.ts index d612fcb..1d5bb31 100644 --- a/test/utils/ioHelpers.test.ts +++ b/test/utils/ioHelpers.test.ts @@ -1,29 +1,33 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; +import fs from "fs"; +import os from "os"; +import path from "path"; import { decodeText, defaultFileAdapter, encodeBase64, encodeText, getBasename, -} from '../../src/utils/io'; +} from "../../src/utils/io"; function createTempDir(prefix: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } -describe('io helpers', () => { - it('reads and writes text and binary files', async () => { - const tempDir = createTempDir('aac-io-test-'); - const textPath = path.join(tempDir, 'note.txt'); - const binPath = path.join(tempDir, 'data.bin'); - const { writeTextToPath, readTextFromInput, writeBinaryToPath, readBinaryFromInput } = - defaultFileAdapter; +describe("io helpers", () => { + it("reads and writes text and binary files", async () => { + const tempDir = createTempDir("aac-io-test-"); + const textPath = path.join(tempDir, "note.txt"); + const binPath = path.join(tempDir, "data.bin"); + const { + writeTextToPath, + readTextFromInput, + writeBinaryToPath, + readBinaryFromInput, + } = defaultFileAdapter; try { - await writeTextToPath(textPath, 'hello'); - expect(await readTextFromInput(textPath)).toBe('hello'); + await writeTextToPath(textPath, "hello"); + expect(await readTextFromInput(textPath)).toBe("hello"); const bin = await readBinaryFromInput(textPath); expect(Buffer.isBuffer(bin)).toBe(true); @@ -36,19 +40,19 @@ describe('io helpers', () => { } }); - it('encodes and decodes text helpers', () => { - const encoded = encodeText('abc'); - expect(Buffer.from(encoded).toString('utf8')).toBe('abc'); + it("encodes and decodes text helpers", () => { + const encoded = encodeText("abc"); + expect(Buffer.from(encoded).toString("utf8")).toBe("abc"); - const base64 = encodeBase64(Buffer.from('xyz', 'utf8')); - expect(base64).toBe('eHl6'); + const base64 = encodeBase64(Buffer.from("xyz", "utf8")); + expect(base64).toBe("eHl6"); const decoded = decodeText(new Uint8Array([104, 105])); - expect(decoded).toBe('hi'); + expect(decoded).toBe("hi"); }); - it('extracts basenames from paths', () => { - expect(getBasename('/tmp/example.txt')).toBe('example.txt'); - expect(getBasename('C:\\\\temp\\\\example.txt')).toBe('example.txt'); + it("extracts basenames from paths", () => { + expect(getBasename("/tmp/example.txt")).toBe("example.txt"); + expect(getBasename("C:\\\\temp\\\\example.txt")).toBe("example.txt"); }); }); diff --git a/test/utils/testFactories.ts b/test/utils/testFactories.ts index 2ff62b9..6501c2b 100644 --- a/test/utils/testFactories.ts +++ b/test/utils/testFactories.ts @@ -1,11 +1,16 @@ // Test data factories and utilities for consistent test object creation -import { AACTree, AACPage, AACButton, AACSemanticIntent } from '../../src/index'; +import { + AACTree, + AACPage, + AACButton, + AACSemanticIntent, +} from "../../src/index"; export interface ButtonConfig { id?: string; label?: string; message?: string; - type?: 'SPEAK' | 'NAVIGATE'; + type?: "SPEAK" | "NAVIGATE"; targetPageId?: string; } @@ -34,7 +39,7 @@ export class ButtonFactory { id, label: config.label || `Button ${id}`, message: config.message || `Message for ${id}`, - type: config.type || 'SPEAK', + type: config.type || "SPEAK", targetPageId: config.targetPageId, }); } @@ -43,7 +48,7 @@ export class ButtonFactory { return this.create({ label, message: message || label, - type: 'SPEAK', + type: "SPEAK", }); } @@ -51,7 +56,7 @@ export class ButtonFactory { return this.create({ label, message: `Navigate to ${targetPageId}`, - type: 'NAVIGATE', + type: "NAVIGATE", targetPageId, }); } @@ -60,16 +65,19 @@ export class ButtonFactory { return this.create({ label, message: message || `Action: ${label}`, - type: 'SPEAK', // Use SPEAK instead of ACTION since ACTION is not supported + type: "SPEAK", // Use SPEAK instead of ACTION since ACTION is not supported }); } - static createBatch(count: number, type: 'SPEAK' | 'NAVIGATE' = 'SPEAK'): AACButton[] { + static createBatch( + count: number, + type: "SPEAK" | "NAVIGATE" = "SPEAK", + ): AACButton[] { return Array.from({ length: count }, (_, i) => this.create({ label: `${type} Button ${i + 1}`, type, - }) + }), ); } } @@ -101,7 +109,10 @@ export class PageFactory { return page; } - static createWithButtons(name: string, buttonConfigs: ButtonConfig[]): AACPage { + static createWithButtons( + name: string, + buttonConfigs: ButtonConfig[], + ): AACPage { return this.create({ name, buttons: buttonConfigs, @@ -110,13 +121,13 @@ export class PageFactory { static createHome(): AACPage { return this.create({ - id: 'home', - name: 'Home', + id: "home", + name: "Home", buttons: [ - { label: 'Hello', message: 'Hello!', type: 'SPEAK' }, - { label: 'Food', message: 'I want food', type: 'SPEAK' }, - { label: 'Drink', message: 'I want a drink', type: 'SPEAK' }, - { label: 'More', targetPageId: 'more', type: 'NAVIGATE' }, + { label: "Hello", message: "Hello!", type: "SPEAK" }, + { label: "Food", message: "I want food", type: "SPEAK" }, + { label: "Drink", message: "I want a drink", type: "SPEAK" }, + { label: "More", targetPageId: "more", type: "NAVIGATE" }, ], }); } @@ -125,11 +136,11 @@ export class PageFactory { const buttons = items.map((item) => ({ label: item, message: `I want ${item.toLowerCase()}`, - type: 'SPEAK' as const, + type: "SPEAK" as const, })); return this.create({ - id: categoryName.toLowerCase().replace(/\s+/g, '_'), + id: categoryName.toLowerCase().replace(/\s+/g, "_"), name: categoryName, buttons, }); @@ -138,12 +149,12 @@ export class PageFactory { static createNavigation(pageName: string, destinations: string[]): AACPage { const buttons = destinations.map((dest) => ({ label: `Go to ${dest}`, - targetPageId: dest.toLowerCase().replace(/\s+/g, '_'), - type: 'NAVIGATE' as const, + targetPageId: dest.toLowerCase().replace(/\s+/g, "_"), + type: "NAVIGATE" as const, })); return this.create({ - id: pageName.toLowerCase().replace(/\s+/g, '_'), + id: pageName.toLowerCase().replace(/\s+/g, "_"), name: pageName, buttons, }); @@ -178,12 +189,12 @@ export class TreeFactory { static createSimple(): AACTree { const homePage = PageFactory.createHome(); const morePage = PageFactory.create({ - id: 'more', - name: 'More Options', + id: "more", + name: "More Options", buttons: [ - { label: 'Please', message: 'Please', type: 'SPEAK' }, - { label: 'Thank you', message: 'Thank you', type: 'SPEAK' }, - { label: 'Home', targetPageId: 'home', type: 'NAVIGATE' }, + { label: "Please", message: "Please", type: "SPEAK" }, + { label: "Thank you", message: "Thank you", type: "SPEAK" }, + { label: "Home", targetPageId: "home", type: "NAVIGATE" }, ], }); @@ -214,17 +225,40 @@ export class TreeFactory { })), }, ], - rootId: 'home', + rootId: "home", }); } static createCommunicationBoard(): AACTree { const pages = [ PageFactory.createHome(), - PageFactory.createCategory('Food', ['Apple', 'Banana', 'Bread', 'Water', 'Milk']), - PageFactory.createCategory('Activities', ['Play', 'Read', 'Music', 'TV', 'Walk']), - PageFactory.createCategory('People', ['Mom', 'Dad', 'Friend', 'Teacher', 'Doctor']), - PageFactory.createNavigation('Navigation', ['Home', 'Food', 'Activities', 'People']), + PageFactory.createCategory("Food", [ + "Apple", + "Banana", + "Bread", + "Water", + "Milk", + ]), + PageFactory.createCategory("Activities", [ + "Play", + "Read", + "Music", + "TV", + "Walk", + ]), + PageFactory.createCategory("People", [ + "Mom", + "Dad", + "Friend", + "Teacher", + "Doctor", + ]), + PageFactory.createNavigation("Navigation", [ + "Home", + "Food", + "Activities", + "People", + ]), ]; return this.create({ @@ -239,11 +273,14 @@ export class TreeFactory { targetPageId: b.targetPageId, })), })), - rootId: 'home', + rootId: "home", }); } - static createLarge(pageCount: number = 10, buttonsPerPage: number = 8): AACTree { + static createLarge( + pageCount: number = 10, + buttonsPerPage: number = 8, + ): AACTree { const pages: PageConfig[] = []; for (let i = 0; i < pageCount; i++) { @@ -253,8 +290,9 @@ export class TreeFactory { buttons.push({ label: `Button ${j + 1}`, message: `Message ${j + 1} on page ${i + 1}`, - type: j % 3 === 0 ? 'NAVIGATE' : 'SPEAK', - targetPageId: j % 3 === 0 ? `page_${((i + 1) % pageCount) + 1}` : undefined, + type: j % 3 === 0 ? "NAVIGATE" : "SPEAK", + targetPageId: + j % 3 === 0 ? `page_${((i + 1) % pageCount) + 1}` : undefined, }); } @@ -267,7 +305,7 @@ export class TreeFactory { return this.create({ pages, - rootId: 'page_1', + rootId: "page_1", }); } @@ -275,18 +313,18 @@ export class TreeFactory { return this.create({ pages: [ { - id: 'single', - name: 'Single Page', + id: "single", + name: "Single Page", buttons: [ { - label: 'Hello', - message: 'Hello World', - type: 'SPEAK', + label: "Hello", + message: "Hello World", + type: "SPEAK", }, ], }, ], - rootId: 'single', + rootId: "single", }); } @@ -300,8 +338,9 @@ export class TreeFactory { */ export class TestDataUtils { static generateRandomString(length: number = 10): string { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - let result = ''; + const chars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let result = ""; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } @@ -313,44 +352,46 @@ export class TestDataUtils { } static generateUnicodeString(): string { - const unicodeChars = ['😀', '🎉', '🌟', '你好', 'مرحبا', 'Café', '∑∞≠']; + const unicodeChars = ["😀", "🎉", "🌟", "你好", "مرحبا", "Café", "∑∞≠"]; return ( - unicodeChars[Math.floor(Math.random() * unicodeChars.length)] + this.generateRandomString(5) + unicodeChars[Math.floor(Math.random() * unicodeChars.length)] + + this.generateRandomString(5) ); } static createTranslationMap( originalTexts: string[], - targetLanguage: string = 'es' + targetLanguage: string = "es", ): Map { const translations = new Map(); const commonTranslations: Record> = { es: { - Hello: 'Hola', - Food: 'Comida', - Drink: 'Bebida', - Home: 'Casa', - More: 'Más', - Please: 'Por favor', - 'Thank you': 'Gracias', - Yes: 'Sí', - No: 'No', + Hello: "Hola", + Food: "Comida", + Drink: "Bebida", + Home: "Casa", + More: "Más", + Please: "Por favor", + "Thank you": "Gracias", + Yes: "Sí", + No: "No", }, fr: { - Hello: 'Bonjour', - Food: 'Nourriture', - Drink: 'Boisson', - Home: 'Maison', - More: 'Plus', + Hello: "Bonjour", + Food: "Nourriture", + Drink: "Boisson", + Home: "Maison", + More: "Plus", Please: "S'il vous plaît", - 'Thank you': 'Merci', - Yes: 'Oui', - No: 'Non', + "Thank you": "Merci", + Yes: "Oui", + No: "Non", }, }; - const targetTranslations = commonTranslations[targetLanguage] || commonTranslations.es; + const targetTranslations = + commonTranslations[targetLanguage] || commonTranslations.es; originalTexts.forEach((text) => { if (targetTranslations[text]) { @@ -388,7 +429,7 @@ export class TestDataUtils { return true; } catch (error) { - console.error('Tree validation error:', error); + console.error("Tree validation error:", error); return false; } } diff --git a/test/utils/testHelpers.ts b/test/utils/testHelpers.ts index 50f933d..909689c 100644 --- a/test/utils/testHelpers.ts +++ b/test/utils/testHelpers.ts @@ -1,8 +1,8 @@ // Test helper utilities for setup, teardown, and common operations -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import { performance } from 'perf_hooks'; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { performance } from "perf_hooks"; export interface TestEnvironment { tempDir: string; @@ -28,7 +28,12 @@ export class TestEnvironmentManager { private static environments: TestEnvironment[] = []; static createTempEnvironment(testName: string): TestEnvironment { - const tempDir = path.join(os.tmpdir(), 'aac-processors-test', testName, Date.now().toString()); + const tempDir = path.join( + os.tmpdir(), + "aac-processors-test", + testName, + Date.now().toString(), + ); // Ensure directory exists fs.mkdirSync(tempDir, { recursive: true }); @@ -54,13 +59,17 @@ export class TestEnvironmentManager { try { env.cleanup(); } catch (error) { - console.warn('Failed to cleanup environment:', error); + console.warn("Failed to cleanup environment:", error); } }); this.environments.length = 0; } - static createTestFile(tempDir: string, filename: string, content: string | Buffer): string { + static createTestFile( + tempDir: string, + filename: string, + content: string | Buffer, + ): string { const filePath = path.join(tempDir, filename); fs.writeFileSync(filePath, content); return filePath; @@ -68,7 +77,7 @@ export class TestEnvironmentManager { static createTestFiles( tempDir: string, - files: Record + files: Record, ): Record { const filePaths: Record = {}; @@ -86,7 +95,7 @@ export class TestEnvironmentManager { export class PerformanceHelper { static async measureAsync( operation: () => Promise, - description?: string + description?: string, ): Promise<{ result: T; metrics: PerformanceMetrics }> { // Force garbage collection if available if (global.gc) { @@ -125,7 +134,7 @@ export class PerformanceHelper { static measure( operation: () => T, - description?: string + description?: string, ): { result: T; metrics: PerformanceMetrics } { // Force garbage collection if available if (global.gc) { @@ -167,7 +176,7 @@ export class PerformanceHelper { expectations: { maxTime?: number; maxMemoryMB?: number; - } + }, ): void { if (expectations.maxTime !== undefined) { expect(metrics.executionTime).toBeLessThan(expectations.maxTime); @@ -186,7 +195,7 @@ export class PerformanceHelper { export class FileSystemHelper { static createLargeFile(filePath: string, sizeInMB: number): void { const chunkSize = 1024 * 1024; // 1MB chunks - const chunk = Buffer.alloc(chunkSize, 'A'); + const chunk = Buffer.alloc(chunkSize, "A"); const writeStream = fs.createWriteStream(filePath); @@ -199,12 +208,13 @@ export class FileSystemHelper { static createCorruptedFile(filePath: string, originalContent: string): void { // Create a file with corrupted content (truncated, invalid characters, etc.) - const corruptedContent = originalContent.slice(0, originalContent.length / 2) + '\0\xFF\xFE'; - fs.writeFileSync(filePath, corruptedContent, 'binary'); + const corruptedContent = + originalContent.slice(0, originalContent.length / 2) + "\0\xFF\xFE"; + fs.writeFileSync(filePath, corruptedContent, "binary"); } static createEmptyFile(filePath: string): void { - fs.writeFileSync(filePath, ''); + fs.writeFileSync(filePath, ""); } static createBinaryFile(filePath: string, size: number = 1024): void { @@ -241,7 +251,7 @@ export class AsyncTestHelper { static async waitFor( condition: () => boolean | Promise, timeoutMs: number = 5000, - intervalMs: number = 100 + intervalMs: number = 100, ): Promise { const startTime = Date.now(); @@ -263,11 +273,13 @@ export class AsyncTestHelper { static async withTimeout( promise: Promise, timeoutMs: number, - errorMessage?: string + errorMessage?: string, ): Promise { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { - reject(new Error(errorMessage || `Operation timed out after ${timeoutMs}ms`)); + reject( + new Error(errorMessage || `Operation timed out after ${timeoutMs}ms`), + ); }, timeoutMs); }); @@ -276,7 +288,7 @@ export class AsyncTestHelper { static async runConcurrently( operations: (() => Promise)[], - maxConcurrency: number = 5 + maxConcurrency: number = 5, ): Promise { const results: T[] = []; const executing: Promise[] = []; @@ -292,7 +304,12 @@ export class AsyncTestHelper { await Promise.race(executing); // Remove completed promises for (let i = executing.length - 1; i >= 0; i--) { - if (await Promise.race([executing[i].then(() => true), Promise.resolve(false)])) { + if ( + await Promise.race([ + executing[i].then(() => true), + Promise.resolve(false), + ]) + ) { executing.splice(i, 1); } } @@ -311,20 +328,20 @@ export class ErrorTestHelper { static expectError( operation: () => T, expectedErrorType?: new (...args: any[]) => Error, - expectedMessage?: string | RegExp + expectedMessage?: string | RegExp, ): Error { let thrownError: Error | null = null; try { operation(); - fail('Expected operation to throw an error, but it did not'); + fail("Expected operation to throw an error, but it did not"); } catch (error) { thrownError = error as Error; } expect(thrownError).toBeDefined(); if (!thrownError) { - throw new Error('Expected an error to be thrown.'); + throw new Error("Expected an error to be thrown."); } if (expectedErrorType) { @@ -332,7 +349,7 @@ export class ErrorTestHelper { } if (expectedMessage) { - if (typeof expectedMessage === 'string') { + if (typeof expectedMessage === "string") { expect(thrownError.message).toContain(expectedMessage); } else { expect(thrownError.message).toMatch(expectedMessage); @@ -345,20 +362,20 @@ export class ErrorTestHelper { static async expectAsyncError( operation: () => Promise, expectedErrorType?: new (...args: any[]) => Error, - expectedMessage?: string | RegExp + expectedMessage?: string | RegExp, ): Promise { let thrownError: Error | null = null; try { await operation(); - fail('Expected async operation to throw an error, but it did not'); + fail("Expected async operation to throw an error, but it did not"); } catch (error) { thrownError = error as Error; } expect(thrownError).toBeDefined(); if (!thrownError) { - throw new Error('Expected an error to be thrown.'); + throw new Error("Expected an error to be thrown."); } if (expectedErrorType) { @@ -366,7 +383,7 @@ export class ErrorTestHelper { } if (expectedMessage) { - if (typeof expectedMessage === 'string') { + if (typeof expectedMessage === "string") { expect(thrownError.message).toContain(expectedMessage); } else { expect(thrownError.message).toMatch(expectedMessage); @@ -401,7 +418,7 @@ export class TestPatterns { createData: () => T, serialize: (data: T) => string | Buffer, deserialize: (serialized: string | Buffer) => T, - compare: (original: T, deserialized: T) => boolean + compare: (original: T, deserialized: T) => boolean, ): void { const original = createData(); const serialized = serialize(original); @@ -413,7 +430,7 @@ export class TestPatterns { static async testConcurrentAccess( operation: () => Promise, concurrency: number = 5, - iterations: number = 10 + iterations: number = 10, ): Promise { const operations = Array(iterations) .fill(0) @@ -423,7 +440,7 @@ export class TestPatterns { static testMemoryUsage( operation: () => T, - maxMemoryMB: number = 50 + maxMemoryMB: number = 50, ): { result: T; metrics: PerformanceMetrics } { const { result, metrics } = PerformanceHelper.measure(operation); diff --git a/test/utils/zipAdapter.browser.test.ts b/test/utils/zipAdapter.browser.test.ts index 6c460f5..1fb3a2f 100644 --- a/test/utils/zipAdapter.browser.test.ts +++ b/test/utils/zipAdapter.browser.test.ts @@ -1,31 +1,31 @@ -import JSZip from 'jszip'; +import JSZip from "jszip"; async function getBrowserZipAdapter() { jest.resetModules(); - jest.doMock('../../src/utils/io', () => { - const actual = jest.requireActual('../../src/utils/io'); + jest.doMock("../../src/utils/io", () => { + const actual = jest.requireActual("../../src/utils/io"); return { ...actual, isNodeRuntime: () => false, }; }); - const module = await import('../../src/utils/zip'); + const module = await import("../../src/utils/zip"); return module.getZipAdapter; } -describe('zip adapter (browser)', () => { - it('does not include directories in listFiles', async () => { +describe("zip adapter (browser)", () => { + it("does not include directories in listFiles", async () => { const zip = new JSZip(); - zip.folder('dir'); - zip.file('dir/nested.txt', 'nested'); - const buffer = await zip.generateAsync({ type: 'uint8array' }); + zip.folder("dir"); + zip.file("dir/nested.txt", "nested"); + const buffer = await zip.generateAsync({ type: "uint8array" }); const getZipAdapter = await getBrowserZipAdapter(); const adapter = await getZipAdapter(buffer); const entries = adapter.listFiles(); - expect(entries).toContain('dir/nested.txt'); - expect(entries).not.toContain('dir/'); + expect(entries).toContain("dir/nested.txt"); + expect(entries).not.toContain("dir/"); }); }); diff --git a/test/utils/zipAdapter.test.ts b/test/utils/zipAdapter.test.ts index 1ad6f50..d98b059 100644 --- a/test/utils/zipAdapter.test.ts +++ b/test/utils/zipAdapter.test.ts @@ -1,38 +1,38 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import AdmZip from 'adm-zip'; -import { getZipAdapter } from '../../src/utils/zip'; +import fs from "fs"; +import os from "os"; +import path from "path"; +import AdmZip from "adm-zip"; +import { getZipAdapter } from "../../src/utils/zip"; function createTempDir(prefix: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } -describe('zip adapter (Node)', () => { - it('reads entries from a zip buffer', async () => { +describe("zip adapter (Node)", () => { + it("reads entries from a zip buffer", async () => { const zip = new AdmZip(); - zip.addFile('foo.txt', Buffer.from('hello', 'utf8')); + zip.addFile("foo.txt", Buffer.from("hello", "utf8")); const buffer = zip.toBuffer(); const adapter = await getZipAdapter(new Uint8Array(buffer)); - expect(adapter.listFiles()).toContain('foo.txt'); - const contents = await adapter.readFile('foo.txt'); - expect(Buffer.from(contents).toString('utf8')).toBe('hello'); + expect(adapter.listFiles()).toContain("foo.txt"); + const contents = await adapter.readFile("foo.txt"); + expect(Buffer.from(contents).toString("utf8")).toBe("hello"); }); - it('reads entries from a zip file path', async () => { - const tempDir = createTempDir('aac-zip-test-'); - const zipPath = path.join(tempDir, 'sample.zip'); + it("reads entries from a zip file path", async () => { + const tempDir = createTempDir("aac-zip-test-"); + const zipPath = path.join(tempDir, "sample.zip"); try { const zip = new AdmZip(); - zip.addFile('bar.txt', Buffer.from('world', 'utf8')); + zip.addFile("bar.txt", Buffer.from("world", "utf8")); zip.writeZip(zipPath); const adapter = await getZipAdapter(zipPath); - expect(adapter.listFiles()).toContain('bar.txt'); - const contents = await adapter.readFile('bar.txt'); - expect(Buffer.from(contents).toString('utf8')).toBe('world'); + expect(adapter.listFiles()).toContain("bar.txt"); + const contents = await adapter.readFile("bar.txt"); + expect(Buffer.from(contents).toString("utf8")).toBe("world"); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } diff --git a/test/validation.coverage.test.ts b/test/validation.coverage.test.ts index a0c8deb..79c8b5d 100644 --- a/test/validation.coverage.test.ts +++ b/test/validation.coverage.test.ts @@ -3,17 +3,17 @@ import { GridsetValidator, SnapValidator, TouchChatValidator, -} from '../src/validation'; -import JSZip from 'jszip'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import AdmZip from 'adm-zip'; -import Database from 'better-sqlite3'; +} from "../src/validation"; +import JSZip from "jszip"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import AdmZip from "adm-zip"; +import Database from "better-sqlite3"; function createSqliteDbBuffer(schemaSql: string, insertSql?: string): Buffer { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aac-test-sqlite-')); - const dbPath = path.join(dir, 'db.sqlite'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aac-test-sqlite-")); + const dbPath = path.join(dir, "db.sqlite"); const db = new Database(dbPath); try { db.exec(schemaSql); @@ -28,28 +28,28 @@ function createSqliteDbBuffer(schemaSql: string, insertSql?: string): Buffer { return buffer; } -describe('Validation Coverage Tests', () => { - describe('ObfValidator - Extended Coverage', () => { - it('should validate button with all valid attributes', async () => { +describe("Validation Coverage Tests", () => { + describe("ObfValidator - Extended Coverage", () => { + it("should validate button with all valid attributes", async () => { const validObf = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", buttons: [ { id: 1, - label: 'Test Button', - vocalization: 'test', - image_id: 'img1', - sound_id: 'snd1', + label: "Test Button", + vocalization: "test", + image_id: "img1", + sound_id: "snd1", hidden: false, - background_color: 'rgb(255, 0, 0)', - border_color: 'rgba(255, 0, 0, 0.5)', - action: ':speak', - actions: [':speak', ':back'], + background_color: "rgb(255, 0, 0)", + border_color: "rgba(255, 0, 0, 0.5)", + action: ":speak", + actions: [":speak", ":back"], load_board: { - path: 'other.obf', + path: "other.obf", }, top: 0, left: 0, @@ -64,38 +64,42 @@ describe('Validation Coverage Tests', () => { }, images: [ { - id: 'img1', + id: "img1", width: 100, height: 100, - content_type: 'image/png', - url: 'http://example.com/img.png', + content_type: "image/png", + url: "http://example.com/img.png", }, ], sounds: [ { - id: 'snd1', + id: "snd1", duration: 1.5, - content_type: 'audio/wav', - path: '/sounds/test.wav', + content_type: "audio/wav", + path: "/sounds/test.wav", }, ], }; const content = Buffer.from(JSON.stringify(validObf)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.valid).toBe(true); expect(result.errors).toBe(0); }); - it('should validate background attribute', async () => { + it("should validate background attribute", async () => { const obfWithBackground = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", background: { - color: 'rgb(255, 255, 255)', + color: "rgb(255, 255, 255)", }, buttons: [], grid: { @@ -108,18 +112,22 @@ describe('Validation Coverage Tests', () => { }; const content = Buffer.from(JSON.stringify(obfWithBackground)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.valid).toBe(true); }); - it('should reject invalid background attribute', async () => { + it("should reject invalid background attribute", async () => { const obfWithBadBackground = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', - background: 'not-an-object', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", + background: "not-an-object", buttons: [], grid: { rows: 1, @@ -131,23 +139,27 @@ describe('Validation Coverage Tests', () => { }; const content = Buffer.from(JSON.stringify(obfWithBadBackground)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.valid).toBe(false); expect(result.errors).toBeGreaterThan(0); }); - it('should validate button with ext_ prefix attributes', async () => { + it("should validate button with ext_ prefix attributes", async () => { const obfWithExt = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", buttons: [ { id: 1, - label: 'Test', - ext_custom_field: 'allowed', + label: "Test", + ext_custom_field: "allowed", }, ], grid: { @@ -160,23 +172,27 @@ describe('Validation Coverage Tests', () => { }; const content = Buffer.from(JSON.stringify(obfWithExt)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); // Should have warning but still be valid expect(result.errors).toBe(0); }); - it('should reject button without action prefix', async () => { + it("should reject button without action prefix", async () => { const obfWithBadAction = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", buttons: [ { id: 1, - label: 'Test', - action: 'invalid-action', // Missing : or + prefix + label: "Test", + action: "invalid-action", // Missing : or + prefix }, ], grid: { @@ -189,17 +205,21 @@ describe('Validation Coverage Tests', () => { }; const content = Buffer.from(JSON.stringify(obfWithBadAction)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.valid).toBe(false); }); - it('should validate image with all attributes', async () => { + it("should validate image with all attributes", async () => { const obfWithFullImage = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", buttons: [], grid: { rows: 1, @@ -208,31 +228,35 @@ describe('Validation Coverage Tests', () => { }, images: [ { - id: 'img1', + id: "img1", width: 100, height: 100, - content_type: 'image/png', - data: 'base64data', - url: 'http://example.com', - path: '/path/to/image.png', - data_url: 'data:image/png;base64,iVBORw0KG...', + content_type: "image/png", + data: "base64data", + url: "http://example.com", + path: "/path/to/image.png", + data_url: "data:image/png;base64,iVBORw0KG...", }, ], sounds: [], }; const content = Buffer.from(JSON.stringify(obfWithFullImage)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.valid).toBe(true); }); - it('should validate sound with all attributes', async () => { + it("should validate sound with all attributes", async () => { const obfWithFullSound = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", buttons: [], grid: { rows: 1, @@ -242,28 +266,32 @@ describe('Validation Coverage Tests', () => { images: [], sounds: [ { - id: 'snd1', + id: "snd1", duration: 2.5, - content_type: 'audio/mp3', - data: 'base64data', - url: 'http://example.com/sound.mp3', - path: '/sounds/test.mp3', + content_type: "audio/mp3", + data: "base64data", + url: "http://example.com/sound.mp3", + path: "/sounds/test.mp3", }, ], }; const content = Buffer.from(JSON.stringify(obfWithFullSound)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.valid).toBe(true); }); - it('should warn about old format version', async () => { + it("should warn about old format version", async () => { const obfOldVersion = { - format: 'open-board-0.0', // Old version - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.0", // Old version + id: "test-board", + locale: "en", + name: "Test Board", buttons: [], grid: { rows: 1, @@ -275,21 +303,26 @@ describe('Validation Coverage Tests', () => { }; const content = Buffer.from(JSON.stringify(obfOldVersion)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.warnings).toBeGreaterThan(0); const hasVersionWarning = result.results.some( - (r) => r.type === 'format_version' && r.warnings && r.warnings.length > 0 + (r) => + r.type === "format_version" && r.warnings && r.warnings.length > 0, ); expect(hasVersionWarning).toBe(true); }); - it('should reject future format version', async () => { + it("should reject future format version", async () => { const obfFutureVersion = { - format: 'open-board-99.9', // Future version - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-99.9", // Future version + id: "test-board", + locale: "en", + name: "Test Board", buttons: [], grid: { rows: 1, @@ -301,18 +334,22 @@ describe('Validation Coverage Tests', () => { }; const content = Buffer.from(JSON.stringify(obfFutureVersion)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.valid).toBe(false); }); - it('should validate ext_ prefixed board attributes', async () => { + it("should validate ext_ prefixed board attributes", async () => { const obfWithExt = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', - ext_custom_data: 'allowed', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", + ext_custom_data: "allowed", ext_another_field: { valid: true }, buttons: [], grid: { @@ -325,17 +362,21 @@ describe('Validation Coverage Tests', () => { }; const content = Buffer.from(JSON.stringify(obfWithExt)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.errors).toBe(0); }); - it('should reject invalid sound duration', async () => { + it("should reject invalid sound duration", async () => { const obfWithBadDuration = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", buttons: [], grid: { rows: 1, @@ -345,49 +386,53 @@ describe('Validation Coverage Tests', () => { images: [], sounds: [ { - id: 'snd1', + id: "snd1", duration: -1.5, // Negative duration - content_type: 'audio/wav', - path: '/sounds/test.wav', + content_type: "audio/wav", + path: "/sounds/test.wav", }, ], }; const content = Buffer.from(JSON.stringify(obfWithBadDuration)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.valid).toBe(false); }); }); - describe('ObfValidator - OBZ Coverage', () => { - it('should validate complete OBZ structure', async () => { + describe("ObfValidator - OBZ Coverage", () => { + it("should validate complete OBZ structure", async () => { const zip = new JSZip(); // Create manifest.json const manifest = { - format: 'open-board-0.1', - root: 'root.obf', + format: "open-board-0.1", + root: "root.obf", paths: { boards: { - board1: 'boards/board1.obf', + board1: "boards/board1.obf", }, images: { - img1: 'images/img1.png', + img1: "images/img1.png", }, sounds: { - snd1: 'sounds/snd1.wav', + snd1: "sounds/snd1.wav", }, }, }; - zip.file('manifest.json', JSON.stringify(manifest)); + zip.file("manifest.json", JSON.stringify(manifest)); // Create root board const rootBoard = { - format: 'open-board-0.1', - id: 'board1', - locale: 'en', - name: 'Root Board', + format: "open-board-0.1", + id: "board1", + locale: "en", + name: "Root Board", buttons: [], grid: { rows: 1, @@ -397,73 +442,87 @@ describe('Validation Coverage Tests', () => { images: [], sounds: [], }; - zip.file('boards/board1.obf', JSON.stringify(rootBoard)); + zip.file("boards/board1.obf", JSON.stringify(rootBoard)); // Create placeholder image and sound files - zip.file('images/img1.png', 'fake-image-data'); - zip.file('sounds/snd1.wav', 'fake-sound-data'); - - const content = await zip.generateAsync({ type: 'nodebuffer' }); - const result = await new ObfValidator().validate(content, 'test.obz', content.length); + zip.file("images/img1.png", "fake-image-data"); + zip.file("sounds/snd1.wav", "fake-sound-data"); + + const content = await zip.generateAsync({ type: "nodebuffer" }); + const result = await new ObfValidator().validate( + content, + "test.obz", + content.length, + ); - expect(result.format).toBe('obz'); - expect(result.filename).toBe('test.obz'); + expect(result.format).toBe("obz"); + expect(result.filename).toBe("test.obz"); }); - it('should detect missing manifest in OBZ', async () => { + it("should detect missing manifest in OBZ", async () => { const zip = new JSZip(); - zip.file('somefile.txt', 'content'); + zip.file("somefile.txt", "content"); - const content = await zip.generateAsync({ type: 'nodebuffer' }); - const result = await new ObfValidator().validate(content, 'test.obz', content.length); + const content = await zip.generateAsync({ type: "nodebuffer" }); + const result = await new ObfValidator().validate( + content, + "test.obz", + content.length, + ); expect(result.valid).toBe(false); - const hasManifestError = result.results.some((r) => r.type === 'manifest' && r.error); + const hasManifestError = result.results.some( + (r) => r.type === "manifest" && r.error, + ); expect(hasManifestError).toBe(true); }); - it('should detect manifest root file not in zip', async () => { + it("should detect manifest root file not in zip", async () => { const zip = new JSZip(); const manifest = { - format: 'open-board-0.1', - root: 'missing.obf', // File doesn't exist + format: "open-board-0.1", + root: "missing.obf", // File doesn't exist paths: { boards: {}, images: {}, sounds: {}, }, }; - zip.file('manifest.json', JSON.stringify(manifest)); + zip.file("manifest.json", JSON.stringify(manifest)); - const content = await zip.generateAsync({ type: 'nodebuffer' }); - const result = await new ObfValidator().validate(content, 'test.obz', content.length); + const content = await zip.generateAsync({ type: "nodebuffer" }); + const result = await new ObfValidator().validate( + content, + "test.obz", + content.length, + ); expect(result.valid).toBe(false); }); - it('should detect board ID mismatch in manifest', async () => { + it("should detect board ID mismatch in manifest", async () => { const zip = new JSZip(); const manifest = { - format: 'open-board-0.1', - root: 'boards/board1.obf', + format: "open-board-0.1", + root: "boards/board1.obf", paths: { boards: { - board1: 'boards/board1.obf', + board1: "boards/board1.obf", }, images: {}, sounds: {}, }, }; - zip.file('manifest.json', JSON.stringify(manifest)); + zip.file("manifest.json", JSON.stringify(manifest)); // Board has different ID than manifest claims const board = { - format: 'open-board-0.1', - id: 'different-id', // Mismatch! - locale: 'en', - name: 'Board', + format: "open-board-0.1", + id: "different-id", // Mismatch! + locale: "en", + name: "Board", buttons: [], grid: { rows: 1, @@ -473,33 +532,37 @@ describe('Validation Coverage Tests', () => { images: [], sounds: [], }; - zip.file('boards/board1.obf', JSON.stringify(board)); + zip.file("boards/board1.obf", JSON.stringify(board)); - const content = await zip.generateAsync({ type: 'nodebuffer' }); - const result = await new ObfValidator().validate(content, 'test.obz', content.length); + const content = await zip.generateAsync({ type: "nodebuffer" }); + const result = await new ObfValidator().validate( + content, + "test.obz", + content.length, + ); expect(result.valid).toBe(false); }); - it('should validate manifest paths structure', async () => { + it("should validate manifest paths structure", async () => { const zip = new JSZip(); const manifest = { - format: 'open-board-0.1', - root: 'root.obf', + format: "open-board-0.1", + root: "root.obf", paths: { boards: {}, images: {}, sounds: {}, }, }; - zip.file('manifest.json', JSON.stringify(manifest)); + zip.file("manifest.json", JSON.stringify(manifest)); const rootBoard = { - format: 'open-board-0.1', - id: 'root', - locale: 'en', - name: 'Root', + format: "open-board-0.1", + id: "root", + locale: "en", + name: "Root", buttons: [], grid: { rows: 1, @@ -509,17 +572,21 @@ describe('Validation Coverage Tests', () => { images: [], sounds: [], }; - zip.file('root.obf', JSON.stringify(rootBoard)); + zip.file("root.obf", JSON.stringify(rootBoard)); - const content = await zip.generateAsync({ type: 'nodebuffer' }); - const result = await new ObfValidator().validate(content, 'test.obz', content.length); + const content = await zip.generateAsync({ type: "nodebuffer" }); + const result = await new ObfValidator().validate( + content, + "test.obz", + content.length, + ); expect(result.valid).toBe(true); }); }); - describe('GridsetValidator - Extended Coverage', () => { - it('should validate gridset with all required elements', async () => { + describe("GridsetValidator - Extended Coverage", () => { + it("should validate gridset with all required elements", async () => { const fullGridset = ` @@ -536,13 +603,17 @@ describe('Validation Coverage Tests', () => { `; const content = Buffer.from(fullGridset); - const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); + const result = await new GridsetValidator().validate( + content, + "test.gridset", + content.length, + ); expect(result).toBeDefined(); - expect(result.format).toBe('gridset'); + expect(result.format).toBe("gridset"); }); - it('should handle gridset with wordlists', async () => { + it("should handle gridset with wordlists", async () => { const gridsetWithWordlists = ` @@ -559,26 +630,34 @@ describe('Validation Coverage Tests', () => { `; const content = Buffer.from(gridsetWithWordlists); - const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); + const result = await new GridsetValidator().validate( + content, + "test.gridset", + content.length, + ); expect(result).toBeDefined(); - expect(result.format).toBe('gridset'); + expect(result.format).toBe("gridset"); }); - it('should detect missing pages element', async () => { + it("should detect missing pages element", async () => { const gridsetWithoutPages = ` `; const content = Buffer.from(gridsetWithoutPages); - const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); + const result = await new GridsetValidator().validate( + content, + "test.gridset", + content.length, + ); expect(result).toBeDefined(); // Should have warnings about missing pages }); - it('should detect missing fixedCellSize', async () => { + it("should detect missing fixedCellSize", async () => { const gridsetWithoutCellSize = ` @@ -591,30 +670,42 @@ describe('Validation Coverage Tests', () => { `; const content = Buffer.from(gridsetWithoutCellSize); - const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); + const result = await new GridsetValidator().validate( + content, + "test.gridset", + content.length, + ); expect(result).toBeDefined(); // Should have warnings about missing cell size }); }); - describe('SnapValidator - Extended Coverage', () => { - it('should detect invalid zip format', async () => { - const notAZip = Buffer.from('This is not a zip file'); - const result = await new SnapValidator().validate(notAZip, 'test.spb', notAZip.length); + describe("SnapValidator - Extended Coverage", () => { + it("should detect invalid zip format", async () => { + const notAZip = Buffer.from("This is not a zip file"); + const result = await new SnapValidator().validate( + notAZip, + "test.spb", + notAZip.length, + ); expect(result.valid).toBe(false); }); - it('should validate .sps extension', async () => { - const notAZip = Buffer.from('Not a zip'); - const result = await new SnapValidator().validate(notAZip, 'test.sps', notAZip.length); + it("should validate .sps extension", async () => { + const notAZip = Buffer.from("Not a zip"); + const result = await new SnapValidator().validate( + notAZip, + "test.sps", + notAZip.length, + ); expect(result.valid).toBe(false); - expect(result.format).toBe('snap'); + expect(result.format).toBe("snap"); }); - it('should validate sqlite-based Snap files', async () => { + it("should validate sqlite-based Snap files", async () => { const sqliteBuffer = createSqliteDbBuffer(` CREATE TABLE Page (Id INTEGER PRIMARY KEY, UniqueId TEXT, Name TEXT, Title TEXT); CREATE TABLE Button (Id INTEGER PRIMARY KEY, Label TEXT, Message TEXT); @@ -625,17 +716,17 @@ describe('Validation Coverage Tests', () => { const result = await new SnapValidator().validate( sqliteBuffer, - 'test.sps', - sqliteBuffer.length + "test.sps", + sqliteBuffer.length, ); expect(result.valid).toBe(true); - expect(result.format).toBe('snap'); + expect(result.format).toBe("snap"); }); }); - describe('TouchChatValidator - Extended Coverage', () => { - it('should validate TouchChat zip with sqlite db', async () => { + describe("TouchChatValidator - Extended Coverage", () => { + it("should validate TouchChat zip with sqlite db", async () => { const sqliteBuffer = createSqliteDbBuffer( ` CREATE TABLE resources (id INTEGER PRIMARY KEY, name TEXT); @@ -652,21 +743,25 @@ describe('Validation Coverage Tests', () => { INSERT INTO button_boxes (id) VALUES (1); INSERT INTO button_box_cells (id, resource_id, button_box_id) VALUES (1, 1, 1); INSERT INTO button_box_instances (id, page_id, button_box_id) VALUES (1, 1, 1); - ` + `, ); const zip = new AdmZip(); - zip.addFile('vocab.c4v', sqliteBuffer); + zip.addFile("vocab.c4v", sqliteBuffer); const content = zip.toBuffer(); - const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); + const result = await new TouchChatValidator().validate( + content, + "test.ce", + content.length, + ); expect(result).toBeDefined(); - expect(result.format).toBe('touchchat'); + expect(result.format).toBe("touchchat"); expect(result.valid).toBe(true); }); - it('should validate complete TouchChat structure', async () => { + it("should validate complete TouchChat structure", async () => { const completeTouchChat = ` @@ -680,26 +775,34 @@ describe('Validation Coverage Tests', () => { `; const content = Buffer.from(completeTouchChat); - const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); + const result = await new TouchChatValidator().validate( + content, + "test.ce", + content.length, + ); expect(result).toBeDefined(); - expect(result.format).toBe('touchchat'); + expect(result.format).toBe("touchchat"); expect(result.valid).toBe(true); }); - it('should detect missing Pages element', async () => { + it("should detect missing Pages element", async () => { const touchChatWithoutPages = ` `; const content = Buffer.from(touchChatWithoutPages); - const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); + const result = await new TouchChatValidator().validate( + content, + "test.ce", + content.length, + ); expect(result).toBeDefined(); // Should have warnings about missing pages }); - it('should validate Page without Buttons', async () => { + it("should validate Page without Buttons", async () => { const touchChatWithoutButtons = ` @@ -708,13 +811,17 @@ describe('Validation Coverage Tests', () => { `; const content = Buffer.from(touchChatWithoutButtons); - const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); + const result = await new TouchChatValidator().validate( + content, + "test.ce", + content.length, + ); expect(result).toBeDefined(); // Should have warnings about missing buttons }); - it('should validate Button with minimal attributes', async () => { + it("should validate Button with minimal attributes", async () => { const minimalTouchChat = ` @@ -727,10 +834,14 @@ describe('Validation Coverage Tests', () => { `; const content = Buffer.from(minimalTouchChat); - const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); + const result = await new TouchChatValidator().validate( + content, + "test.ce", + content.length, + ); expect(result).toBeDefined(); - expect(result.format).toBe('touchchat'); + expect(result.format).toBe("touchchat"); }); }); }); diff --git a/test/validation.newFormats.test.ts b/test/validation.newFormats.test.ts index cc8d634..9d5e1f1 100644 --- a/test/validation.newFormats.test.ts +++ b/test/validation.newFormats.test.ts @@ -1,73 +1,85 @@ -import path from 'path'; -import plist from 'plist'; -import ExcelJS from 'exceljs'; -import { validateFileOrBuffer, ValidationFailureError } from '../src/validation'; -import { OpmlProcessor } from '../src/processors/opmlProcessor'; -import { DotProcessor } from '../src/processors/dotProcessor'; -import { defaultFileAdapter } from '../src/utils/io'; +import path from "path"; +import plist from "plist"; +import ExcelJS from "exceljs"; +import { + validateFileOrBuffer, + ValidationFailureError, +} from "../src/validation"; +import { OpmlProcessor } from "../src/processors/opmlProcessor"; +import { DotProcessor } from "../src/processors/dotProcessor"; +import { defaultFileAdapter } from "../src/utils/io"; -describe('Validation - additional formats', () => { - const asset = (...parts: string[]): string => path.join(__dirname, 'assets', ...parts); +describe("Validation - additional formats", () => { + const asset = (...parts: string[]): string => + path.join(__dirname, "assets", ...parts); - it('validates Asterics .grd assets', async () => { - const filePath = asset('asterics', 'example.grd'); + it("validates Asterics .grd assets", async () => { + const filePath = asset("asterics", "example.grd"); const result = await validateFileOrBuffer(filePath); expect(result.valid).toBe(true); - expect(result.format).toBe('asterics'); + expect(result.format).toBe("asterics"); }); - it('validates OPML asset', async () => { - const filePath = asset('opml', 'example.opml'); + it("validates OPML asset", async () => { + const filePath = asset("opml", "example.opml"); const result = await validateFileOrBuffer(filePath); expect(result.valid).toBe(true); - expect(result.format).toBe('opml'); + expect(result.format).toBe("opml"); }); - it('validates DOT asset', async () => { - const filePath = asset('dot', 'example.dot'); + it("validates DOT asset", async () => { + const filePath = asset("dot", "example.dot"); const result = await validateFileOrBuffer(filePath); expect(result.valid).toBe(true); - expect(result.format).toBe('dot'); + expect(result.format).toBe("dot"); }); - it('validates Excel workbook buffers', async () => { + it("validates Excel workbook buffers", async () => { const workbook = new ExcelJS.Workbook(); - const sheet = workbook.addWorksheet('Page 1'); - sheet.getCell('A1').value = 'Hello'; - sheet.getCell('B2').value = 'World'; + const sheet = workbook.addWorksheet("Page 1"); + sheet.getCell("A1").value = "Hello"; + sheet.getCell("B2").value = "World"; const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); - const result = await validateFileOrBuffer(buffer, defaultFileAdapter, 'sample.xlsx'); + const result = await validateFileOrBuffer( + buffer, + defaultFileAdapter, + "sample.xlsx", + ); expect(result.valid).toBe(true); - expect(result.format).toBe('excel'); + expect(result.format).toBe("excel"); }); - it('fails legacy .xls with structured result', async () => { + it("fails legacy .xls with structured result", async () => { const workbook = new ExcelJS.Workbook(); - workbook.addWorksheet('Sheet 1').getCell('A1').value = 'legacy'; + workbook.addWorksheet("Sheet 1").getCell("A1").value = "legacy"; const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); - const result = await validateFileOrBuffer(buffer, defaultFileAdapter, 'legacy.xls'); + const result = await validateFileOrBuffer( + buffer, + defaultFileAdapter, + "legacy.xls", + ); expect(result.valid).toBe(false); expect(result.errors).toBeGreaterThan(0); - expect(result.results.some((c) => c.error?.includes('.xls'))).toBe(true); + expect(result.results.some((c) => c.error?.includes(".xls"))).toBe(true); }); - it('validates Apple Panels plist buffers', async () => { + it("validates Apple Panels plist buffers", async () => { const plistContent = plist.build({ Panels: { panel1: { - ID: 'panel1', - Name: 'Panel 1', + ID: "panel1", + Name: "Panel 1", PanelObjects: [ { - PanelObjectType: 'Button', - DisplayText: 'Hi', - Rect: '{{0,0},{100,100}}', + PanelObjectType: "Button", + DisplayText: "Hi", + Rect: "{{0,0},{100,100}}", Actions: [ { - ActionType: 'ActionPressKeyCharSequence', - ActionParam: { CharString: 'Hi' }, + ActionType: "ActionPressKeyCharSequence", + ActionParam: { CharString: "Hi" }, }, ], }, @@ -75,30 +87,50 @@ describe('Validation - additional formats', () => { }, }, }); - const buffer = Buffer.from(plistContent, 'utf8'); - const result = await validateFileOrBuffer(buffer, defaultFileAdapter, 'panel.plist'); + const buffer = Buffer.from(plistContent, "utf8"); + const result = await validateFileOrBuffer( + buffer, + defaultFileAdapter, + "panel.plist", + ); expect(result.valid).toBe(true); - expect(result.format).toBe('applepanels'); + expect(result.format).toBe("applepanels"); }); - it('validates OBFSet bundles', async () => { + it("validates OBFSet bundles", async () => { const obfset = [ - { id: 'board1', buttons: [{ id: 'b1', label: 'Hi' }], grid: { rows: 1, columns: 1 } }, - { id: 'board2', buttons: [{ id: 'b2', label: 'There' }], grid: { rows: 1, columns: 1 } }, + { + id: "board1", + buttons: [{ id: "b1", label: "Hi" }], + grid: { rows: 1, columns: 1 }, + }, + { + id: "board2", + buttons: [{ id: "b2", label: "There" }], + grid: { rows: 1, columns: 1 }, + }, ]; const buffer = Buffer.from(JSON.stringify(obfset)); - const result = await validateFileOrBuffer(buffer, defaultFileAdapter, 'bundle.obfset'); + const result = await validateFileOrBuffer( + buffer, + defaultFileAdapter, + "bundle.obfset", + ); expect(result.valid).toBe(true); - expect(result.format).toBe('obfset'); + expect(result.format).toBe("obfset"); }); - it('exposes ValidationResult on OPML parse failures', async () => { - const invalid = Buffer.from('', 'utf8'); - await expect(new OpmlProcessor().loadIntoTree(invalid)).rejects.toThrow(ValidationFailureError); + it("exposes ValidationResult on OPML parse failures", async () => { + const invalid = Buffer.from("", "utf8"); + await expect(new OpmlProcessor().loadIntoTree(invalid)).rejects.toThrow( + ValidationFailureError, + ); }); - it('exposes ValidationResult on DOT binary content', async () => { + it("exposes ValidationResult on DOT binary content", async () => { const invalid = Buffer.from([0, 1, 2, 3]); - await expect(new DotProcessor().loadIntoTree(invalid)).rejects.toThrow(ValidationFailureError); + await expect(new DotProcessor().loadIntoTree(invalid)).rejects.toThrow( + ValidationFailureError, + ); }); }); diff --git a/test/validation.test.ts b/test/validation.test.ts index bd539e5..bb4016d 100644 --- a/test/validation.test.ts +++ b/test/validation.test.ts @@ -1,26 +1,27 @@ -import { Validation } from '../src/index'; -import path from 'path'; +import { Validation } from "../src/index"; +import path from "path"; // Destructure for convenience -const { ObfValidator, GridsetValidator, SnapValidator, TouchChatValidator } = Validation; +const { ObfValidator, GridsetValidator, SnapValidator, TouchChatValidator } = + Validation; type ValidationResult = Validation.ValidationResult; -const samplesDir = path.join(__dirname, '..', 'examples', 'obf'); +const samplesDir = path.join(__dirname, "..", "examples", "obf"); -describe('Validation System', () => { - describe('ObfValidator - Real File Tests (validation samples from obf-node)', () => { - it('should validate simple.obf successfully', async () => { - const filePath = path.join(samplesDir, 'simple.obf'); +describe("Validation System", () => { + describe("ObfValidator - Real File Tests (validation samples from obf-node)", () => { + it("should validate simple.obf successfully", async () => { + const filePath = path.join(samplesDir, "simple.obf"); const result = await ObfValidator.validateFile(filePath); expect(result.valid).toBe(true); expect(result.errors).toBe(0); - expect(result.format).toBe('obf'); - expect(result.filename).toBe('simple.obf'); + expect(result.format).toBe("obf"); + expect(result.filename).toBe("simple.obf"); }); - it('should identify aboutme.json as invalid OBF (missing locale)', async () => { - const filePath = path.join(samplesDir, 'aboutme.json'); + it("should identify aboutme.json as invalid OBF (missing locale)", async () => { + const filePath = path.join(samplesDir, "aboutme.json"); const result = await ObfValidator.validateFile(filePath); // aboutme.json is missing required fields like locale @@ -28,39 +29,39 @@ describe('Validation System', () => { expect(result.errors).toBeGreaterThan(0); }); - it('should identify hash.json as non-OBF JSON', async () => { - const filePath = path.join(samplesDir, 'hash.json'); + it("should identify hash.json as non-OBF JSON", async () => { + const filePath = path.join(samplesDir, "hash.json"); const result = await ObfValidator.validateFile(filePath); expect(result.valid).toBe(false); expect(result.errors).toBeGreaterThanOrEqual(1); }); - it('should identify array.json as non-object JSON', async () => { - const filePath = path.join(samplesDir, 'array.json'); + it("should identify array.json as non-object JSON", async () => { + const filePath = path.join(samplesDir, "array.json"); const result = await ObfValidator.validateFile(filePath); expect(result.valid).toBe(false); expect(result.errors).toBeGreaterThanOrEqual(1); }); - it('should validate links.obz', async () => { - const filePath = path.join(samplesDir, 'links.obz'); + it("should validate links.obz", async () => { + const filePath = path.join(samplesDir, "links.obz"); const result = await ObfValidator.validateFile(filePath); - expect(result.filename).toBe('links.obz'); - expect(result.format).toBe('obz'); + expect(result.filename).toBe("links.obz"); + expect(result.format).toBe("obz"); // OBZ files may have warnings but should be valid }); }); - describe('ObfValidator - Synthetic Tests', () => { - it('should validate a minimal valid OBF structure', async () => { + describe("ObfValidator - Synthetic Tests", () => { + it("should validate a minimal valid OBF structure", async () => { const validObf = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", buttons: [], grid: { rows: 2, @@ -75,33 +76,41 @@ describe('Validation System', () => { }; const content = Buffer.from(JSON.stringify(validObf)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result).toBeDefined(); expect(result.valid).toBe(true); - expect(result.format).toBe('obf'); + expect(result.format).toBe("obf"); expect(result.errors).toBe(0); }); - it('should detect missing required fields', async () => { + it("should detect missing required fields", async () => { const invalidObf = { - format: 'open-board-0.1', + format: "open-board-0.1", // Missing id, locale, name, buttons, grid, images, sounds }; const content = Buffer.from(JSON.stringify(invalidObf)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.valid).toBe(false); expect(result.errors).toBeGreaterThan(0); }); - it('should validate filename extension', async () => { + it("should validate filename extension", async () => { const validObf = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", buttons: [], grid: { rows: 1, @@ -115,24 +124,24 @@ describe('Validation System', () => { const content = Buffer.from(JSON.stringify(validObf)); const result = await new ObfValidator().validate( content, - 'test.txt', // Wrong extension - content.length + "test.txt", // Wrong extension + content.length, ); // Should have a warning about filename const hasFilenameWarning = result.results.some( - (r) => r.type === 'filename' && r.warnings && r.warnings.length > 0 + (r) => r.type === "filename" && r.warnings && r.warnings.length > 0, ); expect(hasFilenameWarning).toBe(true); }); - it('should validate grid structure', async () => { + it("should validate grid structure", async () => { const obfWithBadGrid = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', - buttons: [{ id: 1, label: 'Test' }], + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", + buttons: [{ id: 1, label: "Test" }], grid: { rows: 2, columns: 2, @@ -143,15 +152,19 @@ describe('Validation System', () => { }; const content = Buffer.from(JSON.stringify(obfWithBadGrid)); - const result = await new ObfValidator().validate(content, 'test.obf', content.length); + const result = await new ObfValidator().validate( + content, + "test.obf", + content.length, + ); expect(result.valid).toBe(false); // Should have error about grid order length }); }); - describe('GridsetValidator', () => { - it('should validate basic Gridset XML structure', async () => { + describe("GridsetValidator", () => { + it("should validate basic Gridset XML structure", async () => { const validGridset = ` @@ -165,44 +178,53 @@ describe('Validation System', () => { `; const content = Buffer.from(validGridset); - const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); + const result = await new GridsetValidator().validate( + content, + "test.gridset", + content.length, + ); expect(result).toBeDefined(); - expect(result.format).toBe('gridset'); + expect(result.format).toBe("gridset"); // May have warnings but should parse successfully }); - it('should detect invalid XML', async () => { + it("should detect invalid XML", async () => { const invalidXml = ` `; // Unclosed tags const content = Buffer.from(invalidXml); - const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); + const result = await new GridsetValidator().validate( + content, + "test.gridset", + content.length, + ); expect(result.valid).toBe(false); }); - it('should handle encrypted .gridsetx files', async () => { + it("should handle encrypted .gridsetx files", async () => { // .gridsetx files are encrypted, so we just validate the extension - const encryptedContent = Buffer.from('encrypted binary data'); + const encryptedContent = Buffer.from("encrypted binary data"); const result = await new GridsetValidator().validate( encryptedContent, - 'test.gridsetx', - encryptedContent.length + "test.gridsetx", + encryptedContent.length, ); expect(result).toBeDefined(); - expect(result.format).toBe('gridset'); + expect(result.format).toBe("gridset"); // Should have warning about encryption const hasEncryptionWarning = result.results.some( - (r) => r.type === 'encrypted_format' && r.warnings && r.warnings.length > 0 + (r) => + r.type === "encrypted_format" && r.warnings && r.warnings.length > 0, ); expect(hasEncryptionWarning).toBe(true); }); - it('should not require wordlists element', async () => { + it("should not require wordlists element", async () => { const gridsetWithoutWordlists = ` @@ -216,33 +238,37 @@ describe('Validation System', () => { `; const content = Buffer.from(gridsetWithoutWordlists); - const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); + const result = await new GridsetValidator().validate( + content, + "test.gridset", + content.length, + ); expect(result).toBeDefined(); - expect(result.format).toBe('gridset'); + expect(result.format).toBe("gridset"); // Should NOT have warning about missing wordlists const hasWordlistsWarning = result.results.some( - (r) => r.type === 'wordlists' && r.warnings && r.warnings.length > 0 + (r) => r.type === "wordlists" && r.warnings && r.warnings.length > 0, ); expect(hasWordlistsWarning).toBe(false); }); }); - describe('SnapValidator', () => { - it('should validate a basic zip package structure', async () => { + describe("SnapValidator", () => { + it("should validate a basic zip package structure", async () => { // Create a minimal valid zip with settings.xml // Note: This test would require creating a real zip file // For now, we'll test with an empty buffer which should fail - const content = Buffer.from(''); - const result = await new SnapValidator().validate(content, 'test.spb', 0); + const content = Buffer.from(""); + const result = await new SnapValidator().validate(content, "test.spb", 0); // Should fail with zip error expect(result.valid).toBe(false); }); }); - describe('TouchChatValidator', () => { - it('should validate basic TouchChat XML structure', async () => { + describe("TouchChatValidator", () => { + it("should validate basic TouchChat XML structure", async () => { const validTouchChat = ` @@ -255,32 +281,40 @@ describe('Validation System', () => { `; const content = Buffer.from(validTouchChat); - const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); + const result = await new TouchChatValidator().validate( + content, + "test.ce", + content.length, + ); expect(result).toBeDefined(); - expect(result.format).toBe('touchchat'); + expect(result.format).toBe("touchchat"); }); - it('should detect missing required elements', async () => { + it("should detect missing required elements", async () => { const invalidXml = ` `; const content = Buffer.from(invalidXml); - const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); + const result = await new TouchChatValidator().validate( + content, + "test.ce", + content.length, + ); // May have warnings about missing content expect(result).toBeDefined(); }); }); - describe('ValidationResult structure', () => { - it('should have all required fields', async () => { + describe("ValidationResult structure", () => { + it("should have all required fields", async () => { const validObf = { - format: 'open-board-0.1', - id: 'test-board', - locale: 'en', - name: 'Test Board', + format: "open-board-0.1", + id: "test-board", + locale: "en", + name: "Test Board", buttons: [], grid: { rows: 1, @@ -294,16 +328,16 @@ describe('Validation System', () => { const content = Buffer.from(JSON.stringify(validObf)); const result: ValidationResult = await new ObfValidator().validate( content, - 'test.obf', - content.length + "test.obf", + content.length, ); - expect(result.filename).toBe('test.obf'); + expect(result.filename).toBe("test.obf"); expect(result.filesize).toBe(content.length); - expect(result.format).toBe('obf'); - expect(typeof result.valid).toBe('boolean'); - expect(typeof result.errors).toBe('number'); - expect(typeof result.warnings).toBe('number'); + expect(result.format).toBe("obf"); + expect(typeof result.valid).toBe("boolean"); + expect(typeof result.errors).toBe("number"); + expect(typeof result.warnings).toBe("number"); expect(Array.isArray(result.results)).toBe(true); }); }); diff --git a/test/wordFormGenerator.test.ts b/test/wordFormGenerator.test.ts index d8ec777..cd04782 100644 --- a/test/wordFormGenerator.test.ts +++ b/test/wordFormGenerator.test.ts @@ -1,213 +1,235 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { WordFormGenerator } from '../src/utilities/analytics/morphology/wordFormGenerator'; -import { MorphologyEngine } from '../src/utilities/analytics/morphology/engine'; -import { Grid3VerbsParser } from '../src/utilities/analytics/morphology/grid3VerbsParser'; -import { join } from 'path'; +import { WordFormGenerator } from "../src/utilities/analytics/morphology/wordFormGenerator"; +import { MorphologyEngine } from "../src/utilities/analytics/morphology/engine"; +import { Grid3VerbsParser } from "../src/utilities/analytics/morphology/grid3VerbsParser"; +import { join } from "path"; -const SYNTHETIC_XML = join(__dirname, 'assets', 'grid3', 'synthetic-verbs.xml'); +const SYNTHETIC_XML = join(__dirname, "assets", "grid3", "synthetic-verbs.xml"); -describe('WordFormGenerator', () => { +describe("WordFormGenerator", () => { let generator: WordFormGenerator; let engine: MorphologyEngine; beforeEach(() => { generator = new WordFormGenerator(); - engine = new MorphologyEngine('en-gb'); + engine = new MorphologyEngine("en-gb"); }); - describe('generateFromEngineSlots', () => { - test('regular verb walk -> BASE + 3.PERS + PAST + GERUND', () => { - const forms = generator.generateFromEngineSlots('walk', 'Verb', engine); - const base = forms.find((f) => f.tags.includes('BASE')); + describe("generateFromEngineSlots", () => { + test("regular verb walk -> BASE + 3.PERS + PAST + GERUND", () => { + const forms = generator.generateFromEngineSlots("walk", "Verb", engine); + const base = forms.find((f) => f.tags.includes("BASE")); expect(base).toBeDefined(); - expect(base!.value).toBe('walk'); + expect(base!.value).toBe("walk"); - const third = forms.find((f) => f.tags.includes('3.PERS')); + const third = forms.find((f) => f.tags.includes("3.PERS")); expect(third).toBeDefined(); - expect(third!.value).toBe('walks'); + expect(third!.value).toBe("walks"); - const past = forms.find((f) => f.tags.includes('PAST') && !f.tags.includes('PARTICIPLE')); + const past = forms.find( + (f) => f.tags.includes("PAST") && !f.tags.includes("PARTICIPLE"), + ); expect(past).toBeDefined(); - expect(past!.value).toBe('walked'); + expect(past!.value).toBe("walked"); - const gerund = forms.find((f) => f.tags.includes('GERUND')); + const gerund = forms.find((f) => f.tags.includes("GERUND")); expect(gerund).toBeDefined(); - expect(gerund!.value).toBe('walking'); + expect(gerund!.value).toBe("walking"); }); - test('irregular verb go -> correct tagged forms', () => { - const forms = generator.generateFromEngineSlots('go', 'Verb', engine); + test("irregular verb go -> correct tagged forms", () => { + const forms = generator.generateFromEngineSlots("go", "Verb", engine); - const went = forms.find((f) => f.value === 'went'); + const went = forms.find((f) => f.value === "went"); expect(went).toBeDefined(); - expect(went!.tags).toContain('PAST'); + expect(went!.tags).toContain("PAST"); - const goes = forms.find((f) => f.value === 'goes'); + const goes = forms.find((f) => f.value === "goes"); expect(goes).toBeDefined(); - expect(goes!.tags).toContain('3.PERS'); + expect(goes!.tags).toContain("3.PERS"); - const gone = forms.find((f) => f.value === 'gone'); + const gone = forms.find((f) => f.value === "gone"); expect(gone).toBeDefined(); - expect(gone!.tags).toContain('PAST'); - expect(gone!.tags).toContain('PARTICIPLE'); + expect(gone!.tags).toContain("PAST"); + expect(gone!.tags).toContain("PARTICIPLE"); - const going = forms.find((f) => f.value === 'going'); + const going = forms.find((f) => f.value === "going"); expect(going).toBeDefined(); - expect(going!.tags).toContain('GERUND'); + expect(going!.tags).toContain("GERUND"); }); - test('noun book -> BASE + PLURAL', () => { - const forms = generator.generateFromEngineSlots('book', 'Noun', engine); + test("noun book -> BASE + PLURAL", () => { + const forms = generator.generateFromEngineSlots("book", "Noun", engine); - const base = forms.find((f) => f.tags.includes('BASE')); + const base = forms.find((f) => f.tags.includes("BASE")); expect(base).toBeDefined(); - expect(base!.value).toBe('book'); + expect(base!.value).toBe("book"); - const plural = forms.find((f) => f.tags.includes('PLURAL')); + const plural = forms.find((f) => f.tags.includes("PLURAL")); expect(plural).toBeDefined(); - expect(plural!.value).toBe('books'); + expect(plural!.value).toBe("books"); }); - test('adjective big -> BASE + COMPARATIVE + SUPERLATIVE', () => { - const forms = generator.generateFromEngineSlots('big', 'Adjective', engine); + test("adjective big -> BASE + COMPARATIVE + SUPERLATIVE", () => { + const forms = generator.generateFromEngineSlots( + "big", + "Adjective", + engine, + ); - const comp = forms.find((f) => f.tags.includes('COMPARATIVE')); + const comp = forms.find((f) => f.tags.includes("COMPARATIVE")); expect(comp).toBeDefined(); - expect(comp!.value).toBe('bigger'); + expect(comp!.value).toBe("bigger"); - const sup = forms.find((f) => f.tags.includes('SUPERLATIVE')); + const sup = forms.find((f) => f.tags.includes("SUPERLATIVE")); expect(sup).toBeDefined(); - expect(sup!.value).toBe('biggest'); + expect(sup!.value).toBe("biggest"); }); - test('all forms have lang', () => { - const forms = generator.generateFromEngineSlots('walk', 'Verb', engine, 'en'); + test("all forms have lang", () => { + const forms = generator.generateFromEngineSlots( + "walk", + "Verb", + engine, + "en", + ); for (const f of forms) { - expect(f.lang).toBe('en'); + expect(f.lang).toBe("en"); } }); - test('inflected forms have base set', () => { - const forms = generator.generateFromEngineSlots('go', 'Verb', engine, 'en'); - const nonBase = forms.filter((f) => !f.tags.includes('BASE')); + test("inflected forms have base set", () => { + const forms = generator.generateFromEngineSlots( + "go", + "Verb", + engine, + "en", + ); + const nonBase = forms.filter((f) => !f.tags.includes("BASE")); for (const f of nonBase) { - expect(f.base).toBe('go'); + expect(f.base).toBe("go"); } }); }); - describe('generateFromGrid3Conditions', () => { - test('maps person/time conditions to AsTeRICS tags', () => { + describe("generateFromGrid3Conditions", () => { + test("maps person/time conditions to AsTeRICS tags", () => { const forms = generator.generateFromGrid3Conditions( - 'walk', + "walk", [ { - value: 'walks', + value: "walks", conditions: new Map([ - ['person', 'third'], - ['time', 'present'], + ["person", "third"], + ["time", "present"], ]), }, { - value: 'walked', - conditions: new Map([['time', 'past']]), + value: "walked", + conditions: new Map([["time", "past"]]), }, ], - 'en' + "en", ); - const base = forms.find((f) => f.tags.includes('BASE')); + const base = forms.find((f) => f.tags.includes("BASE")); expect(base).toBeDefined(); - expect(base!.value).toBe('walk'); + expect(base!.value).toBe("walk"); - const walks = forms.find((f) => f.value === 'walks'); + const walks = forms.find((f) => f.value === "walks"); expect(walks).toBeDefined(); - expect(walks!.tags).toContain('3.PERS'); + expect(walks!.tags).toContain("3.PERS"); - const walked = forms.find((f) => f.value === 'walked'); + const walked = forms.find((f) => f.value === "walked"); expect(walked).toBeDefined(); - expect(walked!.tags).toContain('PAST'); + expect(walked!.tags).toContain("PAST"); }); - test('maps participleType to correct tags', () => { + test("maps participleType to correct tags", () => { const forms = generator.generateFromGrid3Conditions( - 'go', + "go", [ { - value: 'gone', - conditions: new Map([['participleType', 'pastparticiple']]), + value: "gone", + conditions: new Map([["participleType", "pastparticiple"]]), }, { - value: 'going', - conditions: new Map([['participleType', 'presentparticiple']]), + value: "going", + conditions: new Map([["participleType", "presentparticiple"]]), }, ], - 'en' + "en", ); - const gone = forms.find((f) => f.value === 'gone'); + const gone = forms.find((f) => f.value === "gone"); expect(gone).toBeDefined(); - expect(gone!.tags).toContain('PAST'); - expect(gone!.tags).toContain('PARTICIPLE'); + expect(gone!.tags).toContain("PAST"); + expect(gone!.tags).toContain("PARTICIPLE"); - const going = forms.find((f) => f.value === 'going'); + const going = forms.find((f) => f.value === "going"); expect(going).toBeDefined(); - expect(going!.tags).toContain('GERUND'); + expect(going!.tags).toContain("GERUND"); }); - test('deduplicates same value+tags combos', () => { + test("deduplicates same value+tags combos", () => { const forms = generator.generateFromGrid3Conditions( - 'test', + "test", [ - { value: 'tested', conditions: new Map([['time', 'past']]) }, - { value: 'tested', conditions: new Map([['time', 'past']]) }, + { value: "tested", conditions: new Map([["time", "past"]]) }, + { value: "tested", conditions: new Map([["time", "past"]]) }, ], - 'en' + "en", ); - const testedForms = forms.filter((f) => f.value === 'tested'); + const testedForms = forms.filter((f) => f.value === "tested"); expect(testedForms.length).toBe(1); }); }); - describe('conditionsToTags', () => { - test('maps person conditions', () => { - const tags = generator.conditionsToTags(new Map([['person', 'first']])); - expect(tags).toContain('1.PERS'); + describe("conditionsToTags", () => { + test("maps person conditions", () => { + const tags = generator.conditionsToTags(new Map([["person", "first"]])); + expect(tags).toContain("1.PERS"); }); - test('maps number conditions', () => { - const tags = generator.conditionsToTags(new Map([['number', 'plural']])); - expect(tags).toContain('PLURAL'); + test("maps number conditions", () => { + const tags = generator.conditionsToTags(new Map([["number", "plural"]])); + expect(tags).toContain("PLURAL"); }); - test('maps time conditions', () => { - const tags = generator.conditionsToTags(new Map([['time', 'past']])); - expect(tags).toContain('PAST'); + test("maps time conditions", () => { + const tags = generator.conditionsToTags(new Map([["time", "past"]])); + expect(tags).toContain("PAST"); }); - test('returns UNKNOWN for unmapped conditions', () => { - const tags = generator.conditionsToTags(new Map([['unknownDim', 'unknownVal']])); - expect(tags).toContain('UNKNOWN'); + test("returns UNKNOWN for unmapped conditions", () => { + const tags = generator.conditionsToTags( + new Map([["unknownDim", "unknownVal"]]), + ); + expect(tags).toContain("UNKNOWN"); }); }); - describe('end-to-end with synthetic XML', () => { - test('walk via synthetic parser produces correct word forms', () => { + describe("end-to-end with synthetic XML", () => { + test("walk via synthetic parser produces correct word forms", () => { const parser = new Grid3VerbsParser(); // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require('fs'); - const xml = fs.readFileSync(SYNTHETIC_XML, 'utf-8'); + const fs = require("fs"); + const xml = fs.readFileSync(SYNTHETIC_XML, "utf-8"); const detailed = parser.parseXmlDetailed(xml); - const walkForms = detailed.verbs.get('walk'); + const walkForms = detailed.verbs.get("walk"); expect(walkForms).toBeDefined(); - const astericsForms = generator.generateFromGrid3Conditions('walk', walkForms!, 'en'); + const astericsForms = generator.generateFromGrid3Conditions( + "walk", + walkForms!, + "en", + ); expect(astericsForms.length).toBeGreaterThan(1); - const base = astericsForms.find((f) => f.tags.includes('BASE')); - expect(base!.value).toBe('walk'); + const base = astericsForms.find((f) => f.tags.includes("BASE")); + expect(base!.value).toBe("walk"); }); }); }); diff --git a/tsconfig.json b/tsconfig.json index 653b284..7a989d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,5 @@ "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], - "exclude": [ - "node_modules", "dist", "examples", "test"] + "exclude": ["node_modules", "dist", "examples", "test"] } From 6b7a7d29d2d7991bdbeca2311d11c8bdfdb27ba8 Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 28 Apr 2026 21:20:31 +0100 Subject: [PATCH 2/2] fixing prettier with eslint --- .eslintrc.json | 5 +- .prettierrc | 5 +- CHANGELOG.md | 27 +- README.md | 25 +- jest.config.js | 53 +- src/analytics.ts | 2 +- src/applePanels.ts | 2 +- src/astericsGrid.ts | 2 +- src/cli/index.ts | 390 ++--- src/cli/prettyPrint.ts | 12 +- src/core/analyze.ts | 70 +- src/core/baseProcessor.ts | 124 +- src/core/stringCasing.ts | 75 +- src/core/treeStructure.ts | 160 +- src/dot.ts | 2 +- src/excel.ts | 2 +- src/gridset.ts | 24 +- src/index.browser.ts | 83 +- src/index.node.ts | 120 +- src/index.ts | 2 +- src/metrics.ts | 28 +- src/obf.ts | 4 +- src/obfset.ts | 2 +- src/opml.ts | 2 +- src/processors/applePanelsProcessor.ts | 307 ++-- src/processors/astericsGridProcessor.ts | 1265 +++++++--------- src/processors/dotProcessor.ts | 111 +- src/processors/excelProcessor.ts | 191 +-- src/processors/gridset/cellHelpers.ts | 9 +- src/processors/gridset/colorUtils.ts | 33 +- src/processors/gridset/commands.ts | 1104 +++++++------- src/processors/gridset/crypto.ts | 31 +- src/processors/gridset/gridCalculations.ts | 10 +- src/processors/gridset/helpers.ts | 144 +- src/processors/gridset/imageDebug.ts | 117 +- src/processors/gridset/index.ts | 44 +- src/processors/gridset/password.ts | 43 +- src/processors/gridset/pluginTypes.ts | 222 ++- src/processors/gridset/resolver.ts | 33 +- src/processors/gridset/styleHelpers.ts | 208 ++- src/processors/gridset/symbolAlignment.ts | 54 +- src/processors/gridset/symbolExtractor.ts | 150 +- src/processors/gridset/symbolSearch.ts | 52 +- src/processors/gridset/symbols.ts | 201 ++- src/processors/gridset/wordlistHelpers.ts | 78 +- src/processors/gridset/xmlFormatter.ts | 34 +- src/processors/gridsetProcessor.ts | 1302 +++++++---------- src/processors/index.ts | 20 +- src/processors/obfProcessor.ts | 365 ++--- src/processors/obfsetProcessor.ts | 34 +- src/processors/opmlProcessor.ts | 185 +-- src/processors/snap/helpers.ts | 119 +- src/processors/snapProcessor.ts | 580 +++----- src/processors/touchchat/helpers.ts | 12 +- src/processors/touchchatProcessor.ts | 407 ++---- src/snap.ts | 4 +- src/touchchat.ts | 4 +- src/translation.ts | 4 +- src/types/aac.ts | 41 +- src/utilities/analytics/history.ts | 61 +- src/utilities/analytics/index.ts | 40 +- src/utilities/analytics/metrics/comparison.ts | 121 +- src/utilities/analytics/metrics/core.ts | 442 +++--- src/utilities/analytics/metrics/effort.ts | 66 +- src/utilities/analytics/metrics/index.ts | 12 +- src/utilities/analytics/metrics/obl-types.ts | 14 +- src/utilities/analytics/metrics/obl.ts | 150 +- src/utilities/analytics/metrics/sentence.ts | 27 +- src/utilities/analytics/metrics/types.ts | 2 +- src/utilities/analytics/metrics/vocabulary.ts | 33 +- src/utilities/analytics/morphology/engine.ts | 1046 +++++++------ .../analytics/morphology/grid3VerbsParser.ts | 182 +-- src/utilities/analytics/morphology/index.ts | 8 +- .../analytics/morphology/wordFormGenerator.ts | 55 +- src/utilities/analytics/reference/browser.ts | 35 +- src/utilities/analytics/reference/index.ts | 43 +- src/utilities/analytics/utils/idGenerator.ts | 16 +- src/utilities/symbolTools.ts | 47 +- .../translation/translationProcessor.ts | 48 +- src/utils/io.ts | 120 +- src/utils/sqlite.ts | 49 +- src/utils/zip.ts | 18 +- src/validation.ts | 26 +- src/validation/applePanelsValidator.ts | 62 +- src/validation/astericsValidator.ts | 55 +- src/validation/baseValidator.ts | 35 +- src/validation/dotValidator.ts | 65 +- src/validation/excelValidator.ts | 62 +- src/validation/gridsetValidator.ts | 334 ++--- src/validation/index.ts | 135 +- src/validation/obfValidator.ts | 601 ++++---- src/validation/obfsetValidator.ts | 53 +- src/validation/opmlValidator.ts | 62 +- src/validation/snapValidator.ts | 319 ++-- src/validation/touchChatValidator.ts | 336 ++--- src/validation/validationTypes.ts | 18 +- test/advancedScenarios.test.ts | 330 ++--- test/aliasMethodsIntegration.test.ts | 190 ++- test/applePanelsProcessor.roundtrip.test.ts | 70 +- test/astericsColors.test.ts | 54 +- test/astericsGridProcessor.test.ts | 129 +- test/audit-images.test.ts | 72 +- test/browserBundle.output.test.ts | 24 +- test/browserCompatibility.test.ts | 88 +- test/cli.comprehensive.test.ts | 340 ++--- test/cli/cli.dot.integration.test.js | 24 +- test/cli/cli.integration.test.js | 47 +- test/cli/cli.obf.integration.test.js | 34 +- test/cli/cli.opml.integration.test.js | 24 +- test/cli/cli.snap.integration.test.js | 56 +- test/cli/prettyPrint.test.js | 26 +- test/colorUtils.test.ts | 242 +-- test/concurrency.test.ts | 97 +- test/core/analyze.test.ts | 120 +- test/core/baseConfig.test.ts | 44 +- test/core/baseProcessor.generic.test.ts | 99 +- test/core/coverageBoost.test.ts | 178 ++- test/core/treeStructure.test.ts | 160 +- test/dotProcessor.export.test.js | 20 +- test/dotProcessor.roundtrip.test.ts | 18 +- test/dotProcessor.test.ts | 38 +- test/edgeCases.test.ts | 212 ++- test/errorHandling.test.ts | 147 +- test/grid3VerbsParser.test.ts | 153 +- test/gridsetHelpers.misc.test.ts | 38 +- test/gridsetHelpers.test.ts | 237 ++- test/gridsetImageDebug.test.ts | 75 +- test/gridsetPluginTypes.test.ts | 112 +- test/gridsetProcessor.coverage.test.ts | 382 +++-- test/gridsetProcessor.export.test.js | 31 +- .../gridsetProcessor.roundtrip.test.legacy.ts | 18 +- test/gridsetProcessor.roundtrip.test.ts | 67 +- test/gridsetProcessor.test.ts | 61 +- test/gridsetResolver.test.ts | 80 +- test/gridsetWordlistHelpers.test.ts | 253 ++-- test/history.analytics.test.ts | 63 +- test/history.test.ts | 78 +- test/index.entrypoints.test.ts | 61 +- test/integration.test.ts | 252 ++-- test/memoryLeaks.test.ts | 125 +- test/morphology.test.ts | 350 ++--- test/obfProcessor.roundtrip.test.ts | 86 +- test/obfProcessor.test.ts | 39 +- test/obfsetProcessor.test.ts | 64 +- test/obl.test.ts | 194 ++- test/opmlProcessor.export.test.js | 22 +- test/opmlProcessor.roundtrip.test.ts | 28 +- test/opmlProcessor.test.ts | 14 +- test/performance.memory.test.ts | 253 ++-- test/performance.test.ts | 92 +- test/platformPaths.test.ts | 349 ++--- test/processTexts.realworld.test.ts | 248 ++-- test/processTexts.test.ts | 139 +- test/processors/excelProcessor.test.ts | 174 ++- test/processors/gridset/symbols.test.ts | 59 +- test/propertyBased.test.ts | 322 ++-- test/scanningMetrics.test.ts | 97 +- .../snapProcessor.audio.comprehensive.test.ts | 216 ++- test/snapProcessor.audio.test.ts | 114 +- ...apProcessor.corruption.performance.test.ts | 137 +- test/snapProcessor.coverage.test.ts | 82 +- test/snapProcessor.export.test.js | 24 +- test/snapProcessor.roundtrip.test.ts | 26 +- test/snapProcessor.test.ts | 82 +- test/stringCasing.test.ts | 178 +-- test/styling.test.ts | 147 +- test/suggestWordsEffort.test.ts | 112 +- test/symbolAlignment.test.ts | 423 +++--- test/touchchatHelpers.test.ts | 20 +- test/touchchatProcessor.comprehensive.test.ts | 218 ++- test/touchchatProcessor.coverage.test.ts | 65 +- test/touchchatProcessor.export.test.js | 31 +- test/touchchatProcessor.roundtrip.test.ts | 18 +- test/touchchatProcessor.test.ts | 14 +- test/utils/ioHelpers.test.ts | 48 +- test/utils/testFactories.ts | 171 +-- test/utils/testHelpers.ts | 79 +- test/utils/zipAdapter.browser.test.ts | 22 +- test/utils/zipAdapter.test.ts | 36 +- test/validation.coverage.test.ts | 525 +++---- test/validation.newFormats.test.ts | 126 +- test/validation.test.ts | 204 ++- test/wordFormGenerator.test.ts | 226 ++- 183 files changed, 10619 insertions(+), 13761 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 92b12d9..16d5860 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -82,10 +82,7 @@ "parserOptions": { "project": "./tsconfig.test.json" }, - "extends": [ - "plugin:@typescript-eslint/recommended", - "plugin:prettier/recommended" - ], + "extends": ["plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], "rules": { "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-explicit-any": "off", diff --git a/.prettierrc b/.prettierrc index 168d9d2..a1ab6bb 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,6 @@ { - "endOfLine": "auto" + "endOfLine": "auto", + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f89018..7cd7540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,11 +27,7 @@ processor.saveFromTree(tree, output); // After (v3.x) const tree: AACTree = await processor.loadIntoTree(file); const texts: string[] = await processor.extractTexts(file); -const result: Uint8Array = await processor.processTexts( - file, - translations, - output, -); +const result: Uint8Array = await processor.processTexts(file, translations, output); await processor.saveFromTree(tree, output); ``` @@ -43,8 +39,7 @@ const symbols: ButtonForTranslation[] = processor.extractSymbolsForLLM(file); processor.processLLMTranslations(file, translations, output); // After -const symbols: ButtonForTranslation[] = - await processor.extractSymbolsForLLM(file); +const symbols: ButtonForTranslation[] = await processor.extractSymbolsForLLM(file); await processor.processLLMTranslations(file, translations, output); ``` @@ -108,7 +103,7 @@ async function processMultipleFiles(files: string[]) { const processor = getProcessor(file); const tree = await processor.loadIntoTree(file); return tree; - }), + }) ); return results; } @@ -321,10 +316,10 @@ npm install aac-processors@2.x ```javascript // Old (CommonJS) -const { DotProcessor } = require("aac-processors/dist/processors"); +const { DotProcessor } = require('aac-processors/dist/processors'); // New (ES Modules + TypeScript) -import { DotProcessor, getProcessor } from "aac-processors"; +import { DotProcessor, getProcessor } from 'aac-processors'; ``` ### API Changes @@ -332,23 +327,23 @@ import { DotProcessor, getProcessor } from "aac-processors"; ```typescript // Old const processor = new DotProcessor(); -const tree = processor.loadIntoTree("file.dot"); +const tree = processor.loadIntoTree('file.dot'); // New (same API, but with TypeScript support) const processor = new DotProcessor(); -const tree: AACTree = processor.loadIntoTree("file.dot"); +const tree: AACTree = processor.loadIntoTree('file.dot'); // New factory pattern -const processor = getProcessor("file.dot"); // Auto-detects format +const processor = getProcessor('file.dot'); // Auto-detects format ``` ### New Translation Workflow ```typescript // New in 2.x -const texts = processor.extractTexts("file.dot"); -const translations = new Map([["Hello", "Hola"]]); -const result = processor.processTexts("file.dot", translations, "output.dot"); +const texts = processor.extractTexts('file.dot'); +const translations = new Map([['Hello', 'Hola']]); +const result = processor.processTexts('file.dot', translations, 'output.dot'); ``` For detailed migration assistance, see the [Migration Guide](docs/MIGRATION.md). diff --git a/README.md b/README.md index 7398016..38f0ba2 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,13 @@ Full feature set, including filesystem access, SQLite-backed formats, and ZIP/encrypted formats. ```ts -import { getProcessor, SnapProcessor } from "@willwade/aac-processors"; +import { getProcessor, SnapProcessor } from '@willwade/aac-processors'; -const processor = getProcessor("board.sps"); -const tree = await processor.loadIntoTree("board.sps"); +const processor = getProcessor('board.sps'); +const tree = await processor.loadIntoTree('board.sps'); const snap = new SnapProcessor(); -const texts = await snap.extractTexts("board.sps"); +const texts = await snap.extractTexts('board.sps'); ``` ### Browser @@ -38,10 +38,7 @@ and either include `` in your HTML, or `window.initSqlJs = require('sql.js');` in your app. ```ts -import { - configureSqlJs, - SnapProcessor, -} from "@willwade/aac-processors/browser"; +import { configureSqlJs, SnapProcessor } from '@willwade/aac-processors/browser'; configureSqlJs({ locateFile: (file) => new URL(`./${file}`, import.meta.url).toString(), @@ -52,7 +49,7 @@ const tree = await snap.loadIntoTree(snapUint8Array); ``` ```ts -import { GridsetProcessor } from "@willwade/aac-processors/browser"; +import { GridsetProcessor } from '@willwade/aac-processors/browser'; const processor = new GridsetProcessor(); const tree = await processor.loadIntoTree(gridsetUint8Array); @@ -75,17 +72,17 @@ const tree = await processor.loadIntoTree(gridsetUint8Array); All processors implement `processTexts()` to get all strings eg ```ts -import { DotProcessor } from "@willwade/aac-processors"; +import { DotProcessor } from '@willwade/aac-processors'; const processor = new DotProcessor(); -const texts = await processor.extractTexts("board.dot"); +const texts = await processor.extractTexts('board.dot'); const translations = new Map([ - ["Hello", "Hola"], - ["Food", "Comida"], + ['Hello', 'Hola'], + ['Food', 'Comida'], ]); -await processor.processTexts("board.dot", translations, "board-es.dot"); +await processor.processTexts('board.dot', translations, 'board-es.dot'); ``` NB: Please use [https://aactools.co.uk](https://aactools.co.uk) for a far more comphrensive translation logic - where we do far far more than this... diff --git a/jest.config.js b/jest.config.js index e969e51..129cf84 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,42 +1,31 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { rootDir: __dirname, - preset: "ts-jest", - testEnvironment: "node", - roots: ["/src", "/test"], - testMatch: [ - "**/__tests__/**/*.+(ts|tsx|js)", - "**/?(*.)+(spec|test).+(ts|tsx|js)", - ], + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src', '/test'], + testMatch: ['**/__tests__/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'], transform: { - "^.+\\.(ts|tsx)$": [ - "ts-jest", + '^.+\\.(ts|tsx)$': [ + 'ts-jest', { - tsconfig: "/tsconfig.test.json", + tsconfig: '/tsconfig.test.json', }, ], }, - moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], collectCoverage: true, - coverageDirectory: "coverage", - coverageReporters: [ - "text", - "text-summary", - "lcov", - "html", - "json", - "json-summary", - "cobertura", - ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'text-summary', 'lcov', 'html', 'json', 'json-summary', 'cobertura'], collectCoverageFrom: [ - "src/**/*.{js,ts}", - "!src/**/*.d.ts", - "!src/**/*.test.{js,ts}", - "!src/**/__tests__/**", - "!src/cli/**", - "!src/utilities/**", + 'src/**/*.{js,ts}', + '!src/**/*.d.ts', + '!src/**/*.test.{js,ts}', + '!src/**/__tests__/**', + '!src/cli/**', + '!src/utilities/**', ], - coveragePathIgnorePatterns: ["/node_modules/", "/dist/", "/__tests__/"], + coveragePathIgnorePatterns: ['/node_modules/', '/dist/', '/__tests__/'], coverageThreshold: { global: { branches: 58, @@ -45,13 +34,13 @@ module.exports = { statements: 72, }, // Per-file thresholds for critical components - "src/core/": { + 'src/core/': { branches: 80, functions: 88, lines: 88, statements: 88, }, - "src/processors/dotProcessor.ts": { + 'src/processors/dotProcessor.ts': { branches: 70, functions: 80, lines: 80, @@ -60,8 +49,8 @@ module.exports = { }, // Enable module resolution for both src and dist moduleNameMapper: { - "^@/(.*)$": "/src/$1", + '^@/(.*)$': '/src/$1', }, // Ensure Jest can find modules - moduleDirectories: ["node_modules", "/src", "/dist"], + moduleDirectories: ['node_modules', '/src', '/dist'], }; diff --git a/src/analytics.ts b/src/analytics.ts index 8d4f1bc..39fffb8 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -5,4 +5,4 @@ * This is separate from pageset metrics. */ -export * from "./utilities/analytics/history"; +export * from './utilities/analytics/history'; diff --git a/src/applePanels.ts b/src/applePanels.ts index 5208841..dac2070 100644 --- a/src/applePanels.ts +++ b/src/applePanels.ts @@ -5,7 +5,7 @@ */ // Processor class -export { ApplePanelsProcessor } from "./processors/applePanelsProcessor"; +export { ApplePanelsProcessor } from './processors/applePanelsProcessor'; // Note: Apple Panels doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/astericsGrid.ts b/src/astericsGrid.ts index 8a85fb8..121beba 100644 --- a/src/astericsGrid.ts +++ b/src/astericsGrid.ts @@ -5,7 +5,7 @@ */ // Processor class -export { AstericsGridProcessor } from "./processors/astericsGridProcessor"; +export { AstericsGridProcessor } from './processors/astericsGridProcessor'; // Note: Asterics Grid doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/cli/index.ts b/src/cli/index.ts index 2909750..eb8452a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,20 +1,19 @@ #!/usr/bin/env node -import { program } from "commander"; -import { prettyPrintTree } from "./prettyPrint"; -import { getProcessor } from "../core/analyze"; -import { ProcessorOptions } from "../core/baseProcessor"; +import { program } from 'commander'; +import { prettyPrintTree } from './prettyPrint'; +import { getProcessor } from '../core/analyze'; +import { ProcessorOptions } from '../core/baseProcessor'; import { exportHistoryToBaton, readGrid3History, readSnapUsage, -} from "../utilities/analytics/history"; -import { ComparisonAnalyzer, MetricsCalculator } from "../utilities/analytics"; -import { CellScanningOrder, ScanningSelectionMethod } from "../types/aac"; -import { defaultFileAdapter, extname } from "../utils/io"; -import { readFileSync } from "node:fs"; +} from '../utilities/analytics/history'; +import { ComparisonAnalyzer, MetricsCalculator } from '../utilities/analytics'; +import { CellScanningOrder, ScanningSelectionMethod } from '../types/aac'; +import { defaultFileAdapter, extname } from '../utils/io'; +import { readFileSync } from 'node:fs'; -const { pathExists, isDirectory, join, basename, writeTextToPath } = - defaultFileAdapter; +const { pathExists, isDirectory, join, basename, writeTextToPath } = defaultFileAdapter; // Helper function to detect format from file/folder path async function detectFormat(filePath: string): Promise { @@ -22,17 +21,17 @@ async function detectFormat(filePath: string): Promise { if ( (await pathExists(filePath)) && (await isDirectory(filePath)) && - filePath.endsWith(".ascconfig") + filePath.endsWith('.ascconfig') ) { - return "ascconfig"; + return 'ascconfig'; } // Map multi-file formats to their base processor - if (filePath.endsWith(".obfset")) { - return "obf"; // Use ObfProcessor for .obfset files + if (filePath.endsWith('.obfset')) { + return 'obf'; // Use ObfProcessor for .obfset files } - if (filePath.endsWith(".gridset")) { - return "gridset"; + if (filePath.endsWith('.gridset')) { + return 'gridset'; } // Otherwise use file extension @@ -70,19 +69,17 @@ function parseFilteringOptions(options: { // Handle custom button exclusion list if (options.excludeButtons) { const excludeList = options.excludeButtons - .split(",") + .split(',') .map((s) => s.trim().toLowerCase()) .filter((s) => s.length > 0); if (excludeList.length > 0) { processorOptions.customButtonFilter = (button) => { - const label = button.label?.toLowerCase() || ""; - const message = button.message?.toLowerCase() || ""; + const label = button.label?.toLowerCase() || ''; + const message = button.message?.toLowerCase() || ''; // Exclude if button label or message contains any of the excluded terms - return !excludeList.some( - (term) => label.includes(term) || message.includes(term), - ); + return !excludeList.some((term) => label.includes(term) || message.includes(term)); }; } } @@ -91,37 +88,20 @@ function parseFilteringOptions(options: { } // Set version from package.json -const packageJson = JSON.parse( - readFileSync(join(__dirname, "../../package.json"), "utf8"), -) as { +const packageJson = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')) as { version: string; }; program.version(packageJson.version); program - .command("analyze ") - .option("--format ", "Format type (auto-detected if not specified)") - .option("--pretty", "Pretty print output") - .option( - "--preserve-all-buttons", - "Preserve all buttons including navigation/system buttons", - ) - .option( - "--no-exclude-navigation", - "Don't exclude navigation buttons (Home, Back)", - ) - .option( - "--no-exclude-system", - "Don't exclude system buttons (Delete, Clear, etc.)", - ) - .option( - "--exclude-buttons ", - "Comma-separated list of button labels/terms to exclude", - ) - .option( - "--gridset-password ", - "Password for encrypted Grid3 archives (.gridsetx)", - ) + .command('analyze ') + .option('--format ', 'Format type (auto-detected if not specified)') + .option('--pretty', 'Pretty print output') + .option('--preserve-all-buttons', 'Preserve all buttons including navigation/system buttons') + .option('--no-exclude-navigation', "Don't exclude navigation buttons (Home, Back)") + .option('--no-exclude-system', "Don't exclude system buttons (Delete, Clear, etc.)") + .option('--exclude-buttons ', 'Comma-separated list of button labels/terms to exclude') + .option('--gridset-password ', 'Password for encrypted Grid3 archives (.gridsetx)') .action( async ( file: string, @@ -133,7 +113,7 @@ program excludeSystem?: boolean; excludeButtons?: string; gridsetPassword?: string; - }, + } ) => { try { // Parse filtering options @@ -157,39 +137,24 @@ program } } catch (error) { console.error( - "Error analyzing file:", - error instanceof Error ? error.message : String(error), + 'Error analyzing file:', + error instanceof Error ? error.message : String(error) ); process.exit(1); } - }, + } ); program - .command("extract ") - .option("--format ", "Format type (auto-detected if not specified)") - .option("--verbose", "Verbose output") - .option("--quiet", "Quiet output") - .option( - "--preserve-all-buttons", - "Preserve all buttons including navigation/system buttons", - ) - .option( - "--no-exclude-navigation", - "Don't exclude navigation buttons (Home, Back)", - ) - .option( - "--no-exclude-system", - "Don't exclude system buttons (Delete, Clear, etc.)", - ) - .option( - "--exclude-buttons ", - "Comma-separated list of button labels/terms to exclude", - ) - .option( - "--gridset-password ", - "Password for encrypted Grid3 archives (.gridsetx)", - ) + .command('extract ') + .option('--format ', 'Format type (auto-detected if not specified)') + .option('--verbose', 'Verbose output') + .option('--quiet', 'Quiet output') + .option('--preserve-all-buttons', 'Preserve all buttons including navigation/system buttons') + .option('--no-exclude-navigation', "Don't exclude navigation buttons (Home, Back)") + .option('--no-exclude-system', "Don't exclude system buttons (Delete, Clear, etc.)") + .option('--exclude-buttons ', 'Comma-separated list of button labels/terms to exclude') + .option('--gridset-password ', 'Password for encrypted Grid3 archives (.gridsetx)') .action( async ( file: string, @@ -202,7 +167,7 @@ program excludeSystem?: boolean; excludeButtons?: string; gridsetPassword?: string; - }, + } ) => { try { // Parse filtering options @@ -220,18 +185,14 @@ program // Show filtering info in verbose mode if (filteringOptions.preserveAllButtons) { - console.log("Filtering: All buttons preserved"); + console.log('Filtering: All buttons preserved'); } else { const filters = []; - if (filteringOptions.excludeNavigationButtons !== false) - filters.push("navigation"); - if (filteringOptions.excludeSystemButtons !== false) - filters.push("system"); - if (filteringOptions.customButtonFilter) filters.push("custom"); + if (filteringOptions.excludeNavigationButtons !== false) filters.push('navigation'); + if (filteringOptions.excludeSystemButtons !== false) filters.push('system'); + if (filteringOptions.customButtonFilter) filters.push('custom'); if (filters.length > 0) { - console.log( - `Filtering: Excluding ${filters.join(", ")} buttons`, - ); + console.log(`Filtering: Excluding ${filters.join(', ')} buttons`); } } } @@ -241,37 +202,22 @@ program texts.forEach((text) => console.log(text)); } catch (error) { console.error( - "Error extracting texts:", - error instanceof Error ? error.message : String(error), + 'Error extracting texts:', + error instanceof Error ? error.message : String(error) ); process.exit(1); } - }, + } ); program - .command("convert ") - .option("--format ", "Output format (required)") - .option( - "--preserve-all-buttons", - "Preserve all buttons including navigation/system buttons", - ) - .option( - "--no-exclude-navigation", - "Don't exclude navigation buttons (Home, Back)", - ) - .option( - "--no-exclude-system", - "Don't exclude system buttons (Delete, Clear, etc.)", - ) - .option( - "--exclude-buttons ", - "Comma-separated list of button labels/terms to exclude", - ) - .option( - "--gridset-password ", - "Password for encrypted Grid3 archives (.gridsetx)", - ) + .command('convert ') + .option('--format ', 'Output format (required)') + .option('--preserve-all-buttons', 'Preserve all buttons including navigation/system buttons') + .option('--no-exclude-navigation', "Don't exclude navigation buttons (Home, Back)") + .option('--no-exclude-system', "Don't exclude system buttons (Delete, Clear, etc.)") + .option('--exclude-buttons ', 'Comma-separated list of button labels/terms to exclude') + .option('--gridset-password ', 'Password for encrypted Grid3 archives (.gridsetx)') .action( async ( input: string, @@ -283,13 +229,11 @@ program excludeSystem?: boolean; excludeButtons?: string; gridsetPassword?: string; - }, + } ) => { try { if (!options.format) { - console.error( - "Error: --format option is required for convert command", - ); + console.error('Error: --format option is required for convert command'); process.exit(1); } @@ -308,44 +252,39 @@ program await outputProcessor.saveFromTree(tree, output); // Show filtering summary - let filteringSummary = ""; + let filteringSummary = ''; if (filteringOptions.preserveAllButtons) { - filteringSummary = " (all buttons preserved)"; + filteringSummary = ' (all buttons preserved)'; } else { const filters = []; - if (filteringOptions.excludeNavigationButtons !== false) - filters.push("navigation"); - if (filteringOptions.excludeSystemButtons !== false) - filters.push("system"); - if (filteringOptions.customButtonFilter) filters.push("custom"); + if (filteringOptions.excludeNavigationButtons !== false) filters.push('navigation'); + if (filteringOptions.excludeSystemButtons !== false) filters.push('system'); + if (filteringOptions.customButtonFilter) filters.push('custom'); if (filters.length > 0) { - filteringSummary = ` (filtered: ${filters.join(", ")} buttons)`; + filteringSummary = ` (filtered: ${filters.join(', ')} buttons)`; } } console.log( - `Successfully converted ${input} to ${output} (${options.format} format)${filteringSummary}`, + `Successfully converted ${input} to ${output} (${options.format} format)${filteringSummary}` ); } catch (error) { console.error( - "Error converting file:", - error instanceof Error ? error.message : String(error), + 'Error converting file:', + error instanceof Error ? error.message : String(error) ); process.exit(1); } - }, + } ); program - .command("validate ") - .description("Validate an AAC file format") - .option("--format ", "Format type (auto-detected if not specified)") - .option("--json", "Output results as JSON") - .option("--quiet", "Only output validation result (valid/invalid)") - .option( - "--gridset-password ", - "Password for encrypted Grid3 archives (.gridsetx)", - ) + .command('validate ') + .description('Validate an AAC file format') + .option('--format ', 'Format type (auto-detected if not specified)') + .option('--json', 'Output results as JSON') + .option('--quiet', 'Only output validation result (valid/invalid)') + .option('--gridset-password ', 'Password for encrypted Grid3 archives (.gridsetx)') .action( async ( file: string, @@ -354,7 +293,7 @@ program json?: boolean; quiet?: boolean; gridsetPassword?: string; - }, + } ) => { try { // Auto-detect format if not specified @@ -370,9 +309,7 @@ program // Check if processor supports validation if (!processor.validate) { - console.error( - `Error: Validation not supported for format '${format}'`, - ); + console.error(`Error: Validation not supported for format '${format}'`); process.exit(1); } @@ -381,7 +318,7 @@ program // Output results if (options.quiet) { - console.log(result.valid ? "valid" : "invalid"); + console.log(result.valid ? 'valid' : 'invalid'); } else if (options.json) { console.log(JSON.stringify(result, null, 2)); } else { @@ -389,13 +326,13 @@ program console.log(`\nValidation Results for: ${result.filename}`); console.log(`Format: ${result.format}`); console.log(`File size: ${result.filesize} bytes`); - console.log(`Status: ${result.valid ? "✓ VALID" : "✗ INVALID"}`); + console.log(`Status: ${result.valid ? '✓ VALID' : '✗ INVALID'}`); console.log(`Errors: ${result.errors}`); console.log(`Warnings: ${result.warnings}\n`); if (result.errors > 0 || result.warnings > 0) { if (result.errors > 0) { - console.log("Errors:"); + console.log('Errors:'); result.results .filter((r) => !r.valid) .forEach((check) => { @@ -407,7 +344,7 @@ program } if (result.warnings > 0) { - console.log("\nWarnings:"); + console.log('\nWarnings:'); result.results.forEach((check) => { if (check.warnings && check.warnings.length > 0) { console.log(` ⚠ ${check.description}`); @@ -421,39 +358,39 @@ program // Show sub-results if available if (result.sub_results && result.sub_results.length > 0) { - console.log("\nSub-results:"); + console.log('\nSub-results:'); result.sub_results.forEach((sub, idx) => { console.log(` [${idx + 1}] ${sub.filename}`); console.log( - ` Status: ${sub.valid ? "✓" : "✗"} (${sub.errors} errors, ${sub.warnings} warnings)`, + ` Status: ${sub.valid ? '✓' : '✗'} (${sub.errors} errors, ${sub.warnings} warnings)` ); }); } - console.log(""); + console.log(''); } // Exit with appropriate code process.exit(result.valid ? 0 : 1); } catch (error) { console.error( - "Error validating file:", - error instanceof Error ? error.message : String(error), + 'Error validating file:', + error instanceof Error ? error.message : String(error) ); process.exit(1); } - }, + } ); program - .command("history ") - .option("--format ", "Output format: raw or baton", "raw") - .option("--out ", "Write output to a file instead of stdout") - .option("--source ", "History source: auto, grid3, snap", "auto") - .option("--anonymous-uuid ", "Anonymous UUID for baton export") - .option("--export-date ", "Export date for baton export (ISO string)") - .option("--encryption ", "Encryption label for baton export", "none") - .option("--version ", "Baton export version", "1.0") + .command('history ') + .option('--format ', 'Output format: raw or baton', 'raw') + .option('--out ', 'Write output to a file instead of stdout') + .option('--source ', 'History source: auto, grid3, snap', 'auto') + .option('--anonymous-uuid ', 'Anonymous UUID for baton export') + .option('--export-date ', 'Export date for baton export (ISO string)') + .option('--encryption ', 'Encryption label for baton export', 'none') + .option('--version ', 'Baton export version', '1.0') .action( async ( input: string, @@ -465,48 +402,38 @@ program exportDate?: string; encryption?: string; version?: string; - }, + } ) => { try { if (!(await pathExists(input))) { throw new Error(`File not found: ${input}`); } - const normalizedSource = (options.source || "auto").toLowerCase(); + const normalizedSource = (options.source || 'auto').toLowerCase(); const ext = extname(input).toLowerCase(); - const isGrid3Db = - ext === ".sqlite" || - basename(input).toLowerCase() === "history.sqlite"; - const isSnap = ext === ".sps" || ext === ".spb"; + const isGrid3Db = ext === '.sqlite' || basename(input).toLowerCase() === 'history.sqlite'; + const isSnap = ext === '.sps' || ext === '.spb'; let entries; - if ( - normalizedSource === "grid3" || - (normalizedSource === "auto" && isGrid3Db) - ) { + if (normalizedSource === 'grid3' || (normalizedSource === 'auto' && isGrid3Db)) { entries = await readGrid3History(input); - } else if ( - normalizedSource === "snap" || - (normalizedSource === "auto" && isSnap) - ) { + } else if (normalizedSource === 'snap' || (normalizedSource === 'auto' && isSnap)) { entries = await readSnapUsage(input); } else { - throw new Error( - "Unable to detect history source. Use --source grid3 or --source snap.", - ); + throw new Error('Unable to detect history source. Use --source grid3 or --source snap.'); } - const format = (options.format || "raw").toLowerCase(); + const format = (options.format || 'raw').toLowerCase(); let payload: unknown = entries; - if (format === "baton") { + if (format === 'baton') { payload = exportHistoryToBaton(entries, { version: options.version, exportDate: options.exportDate, encryption: options.encryption, anonymousUUID: options.anonymousUuid, }); - } else if (format !== "raw") { + } else if (format !== 'raw') { throw new Error(`Unsupported format: ${format}`); } @@ -518,54 +445,35 @@ program } } catch (error) { console.error( - "Error exporting history:", - error instanceof Error ? error.message : String(error), + 'Error exporting history:', + error instanceof Error ? error.message : String(error) ); process.exit(1); } - }, + } ); program - .command("metrics ") - .option("--format ", "Format type (auto-detected if not specified)") - .option("--pretty", "Pretty print JSON output") - .option("--out ", "Write output to a file instead of stdout") - .option( - "--preserve-all-buttons", - "Preserve all buttons including navigation/system buttons", - ) + .command('metrics ') + .option('--format ', 'Format type (auto-detected if not specified)') + .option('--pretty', 'Pretty print JSON output') + .option('--out ', 'Write output to a file instead of stdout') + .option('--preserve-all-buttons', 'Preserve all buttons including navigation/system buttons') + .option('--no-exclude-navigation', "Don't exclude navigation buttons (Home, Back)") + .option('--no-exclude-system', "Don't exclude system buttons (Delete, Clear, etc.)") + .option('--exclude-buttons ', 'Comma-separated list of button labels/terms to exclude') + .option('--gridset-password ', 'Password for encrypted Grid3 archives (.gridsetx)') + .option('--access-method ', 'direct or scanning', 'direct') + .option('--scanning-pattern ', 'linear, row-column, or block', 'row-column') .option( - "--no-exclude-navigation", - "Don't exclude navigation buttons (Home, Back)", + '--selection-method ', + 'auto-1-switch, step-1-switch, or step-2-switch', + 'auto-1-switch' ) - .option( - "--no-exclude-system", - "Don't exclude system buttons (Delete, Clear, etc.)", - ) - .option( - "--exclude-buttons ", - "Comma-separated list of button labels/terms to exclude", - ) - .option( - "--gridset-password ", - "Password for encrypted Grid3 archives (.gridsetx)", - ) - .option("--access-method ", "direct or scanning", "direct") - .option( - "--scanning-pattern ", - "linear, row-column, or block", - "row-column", - ) - .option( - "--selection-method ", - "auto-1-switch, step-1-switch, or step-2-switch", - "auto-1-switch", - ) - .option("--error-correction", "Enable scanning error correction", false) - .option("--use-prediction", "Enable prediction in CARE scoring", false) - .option("--no-smart-grammar", "Disable smart grammar word forms") - .option("--care", "Include CARE comparison output", false) + .option('--error-correction', 'Enable scanning error correction', false) + .option('--use-prediction', 'Enable prediction in CARE scoring', false) + .option('--no-smart-grammar', 'Disable smart grammar word forms') + .option('--care', 'Include CARE comparison output', false) .action( async ( file: string, @@ -585,7 +493,7 @@ program usePrediction?: boolean; smartGrammar?: boolean; care?: boolean; - }, + } ) => { try { const filteringOptions = parseFilteringOptions(options); @@ -593,52 +501,44 @@ program const processor = getProcessor(format, filteringOptions); const tree = await processor.loadIntoTree(file); - const accessMethod = (options.accessMethod || "direct").toLowerCase(); - const scanningPattern = ( - options.scanningPattern || "row-column" - ).toLowerCase(); - const selectionMethodParam = ( - options.selectionMethod || "auto-1-switch" - ).toLowerCase(); + const accessMethod = (options.accessMethod || 'direct').toLowerCase(); + const scanningPattern = (options.scanningPattern || 'row-column').toLowerCase(); + const selectionMethodParam = (options.selectionMethod || 'auto-1-switch').toLowerCase(); const errorCorrection = !!options.errorCorrection; let scanningConfig = undefined; - if (accessMethod === "scanning") { + if (accessMethod === 'scanning') { let cellScanningOrder = CellScanningOrder.SimpleScan; let blockScanEnabled = false; switch (scanningPattern) { - case "linear": + case 'linear': cellScanningOrder = CellScanningOrder.SimpleScan; break; - case "row-column": + case 'row-column': cellScanningOrder = CellScanningOrder.RowColumnScan; break; - case "block": + case 'block': cellScanningOrder = CellScanningOrder.RowColumnScan; blockScanEnabled = true; break; default: - throw new Error( - `Unsupported scanning pattern: ${scanningPattern}`, - ); + throw new Error(`Unsupported scanning pattern: ${scanningPattern}`); } let selectionMethod = ScanningSelectionMethod.AutoScan; switch (selectionMethodParam) { - case "auto-1-switch": + case 'auto-1-switch': selectionMethod = ScanningSelectionMethod.AutoScan; break; - case "step-1-switch": + case 'step-1-switch': selectionMethod = ScanningSelectionMethod.StepScan1Switch; break; - case "step-2-switch": + case 'step-2-switch': selectionMethod = ScanningSelectionMethod.StepScan2Switch; break; default: - throw new Error( - `Unsupported selection method: ${selectionMethodParam}`, - ); + throw new Error(`Unsupported selection method: ${selectionMethodParam}`); } scanningConfig = { @@ -677,9 +577,7 @@ program care, }; - const output = options.pretty - ? JSON.stringify(result, null, 2) - : JSON.stringify(result); + const output = options.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result); if (options.out) { await writeTextToPath(options.out, output); } else { @@ -687,12 +585,12 @@ program } } catch (error) { console.error( - "Error calculating metrics:", - error instanceof Error ? error.message : String(error), + 'Error calculating metrics:', + error instanceof Error ? error.message : String(error) ); process.exit(1); } - }, + } ); // Show help if no command provided diff --git a/src/cli/prettyPrint.ts b/src/cli/prettyPrint.ts index 8463f89..df480dd 100644 --- a/src/cli/prettyPrint.ts +++ b/src/cli/prettyPrint.ts @@ -1,23 +1,23 @@ -import { AACTree } from "../core/treeStructure"; +import { AACTree } from '../core/treeStructure'; export function prettyPrintTree(tree: AACTree): string { - let output = ""; + let output = ''; for (const pageId in tree.pages) { const page = tree.pages[pageId]; output += `Page: ${page.name} (ID: ${page.id})\n`; if (!page.buttons || page.buttons.length === 0) { - output += " (no buttons)\n"; + output += ' (no buttons)\n'; } else { for (const btn of page.buttons) { const intentStr = String(btn.semanticAction?.intent); - const isNavigate = intentStr === "NAVIGATE_TO" || !!btn.targetPageId; - const buttonType = isNavigate ? "NAVIGATE" : "SPEAK"; + const isNavigate = intentStr === 'NAVIGATE_TO' || !!btn.targetPageId; + const buttonType = isNavigate ? 'NAVIGATE' : 'SPEAK'; output += ` - Button: ${JSON.stringify(btn.label)} [${buttonType}`; if (isNavigate) { const target = btn.semanticAction?.targetId || btn.targetPageId; if (target) output += ` to page: ${target}`; } - output += "]\n"; + output += ']\n'; } } } diff --git a/src/core/analyze.ts b/src/core/analyze.ts index d9ae2b7..f9335ed 100644 --- a/src/core/analyze.ts +++ b/src/core/analyze.ts @@ -1,55 +1,52 @@ -import { OpmlProcessor } from "../processors/opmlProcessor"; -import { ObfProcessor } from "../processors/obfProcessor"; -import { TouchChatProcessor } from "../processors/touchchatProcessor"; -import { GridsetProcessor } from "../processors/gridsetProcessor"; -import { AstericsGridProcessor } from "../processors/astericsGridProcessor"; -import { SnapProcessor } from "../processors/snapProcessor"; -import { DotProcessor } from "../processors/dotProcessor"; -import { ExcelProcessor } from "../processors/excelProcessor"; -import { ApplePanelsProcessor } from "../processors/applePanelsProcessor"; -import { AACTree } from "./treeStructure"; -import { BaseProcessor, ProcessorOptions } from "./baseProcessor"; +import { OpmlProcessor } from '../processors/opmlProcessor'; +import { ObfProcessor } from '../processors/obfProcessor'; +import { TouchChatProcessor } from '../processors/touchchatProcessor'; +import { GridsetProcessor } from '../processors/gridsetProcessor'; +import { AstericsGridProcessor } from '../processors/astericsGridProcessor'; +import { SnapProcessor } from '../processors/snapProcessor'; +import { DotProcessor } from '../processors/dotProcessor'; +import { ExcelProcessor } from '../processors/excelProcessor'; +import { ApplePanelsProcessor } from '../processors/applePanelsProcessor'; +import { AACTree } from './treeStructure'; +import { BaseProcessor, ProcessorOptions } from './baseProcessor'; /** * Resolve a processor instance by friendly format name or common extension. * @param format Format key or extension (e.g., 'snap', 'obf', 'xlsx') * @param options Optional processor configuration */ -export function getProcessor( - format: string, - options?: ProcessorOptions, -): BaseProcessor { - const normalizedFormat = (format || "").toLowerCase(); +export function getProcessor(format: string, options?: ProcessorOptions): BaseProcessor { + const normalizedFormat = (format || '').toLowerCase(); switch (normalizedFormat) { - case "opml": + case 'opml': return new OpmlProcessor(options); - case "obf": - case "obfset": // Obfset files use ObfProcessor + case 'obf': + case 'obfset': // Obfset files use ObfProcessor return new ObfProcessor(options); - case "touchchat": - case "ce": // TouchChat file extension + case 'touchchat': + case 'ce': // TouchChat file extension return new TouchChatProcessor(options); - case "gridset": - case "gridsetx": + case 'gridset': + case 'gridsetx': return new GridsetProcessor(options); // Grid3 format - case "grd": // Asterics Grid file extension + case 'grd': // Asterics Grid file extension return new AstericsGridProcessor(options); - case "snap": - case "sps": // Snap file extension - case "spb": // Snap backup file extension + case 'snap': + case 'sps': // Snap file extension + case 'spb': // Snap backup file extension return new SnapProcessor(options); - case "dot": + case 'dot': return new DotProcessor(options); - case "excel": - case "xlsx": // Excel file extension + case 'excel': + case 'xlsx': // Excel file extension return new ExcelProcessor(options); - case "applepanels": - case "panels": // Apple Panels file extension - case "ascconfig": // Apple Panels folder format + case 'applepanels': + case 'panels': // Apple Panels file extension + case 'ascconfig': // Apple Panels folder format return new ApplePanelsProcessor(options); default: - throw new Error("Unknown format: " + format); + throw new Error('Unknown format: ' + format); } } @@ -58,10 +55,7 @@ export function getProcessor( * @param file Path to the source file * @param format Format key or extension (passed to getProcessor) */ -export async function analyze( - file: string, - format: string, -): Promise<{ tree: AACTree }> { +export async function analyze(file: string, format: string): Promise<{ tree: AACTree }> { const processor = getProcessor(format); const tree = await processor.loadIntoTree(file); return { tree }; diff --git a/src/core/baseProcessor.ts b/src/core/baseProcessor.ts index fd4ac73..0487184 100644 --- a/src/core/baseProcessor.ts +++ b/src/core/baseProcessor.ts @@ -40,16 +40,11 @@ * See `src/utilities/translation/translationProcessor.ts` for shared utilities. */ -import { AACTree, AACButton, AACSemanticCategory } from "./treeStructure"; -import { StringCasing, detectCasing, isNumericOrEmpty } from "./stringCasing"; -import { ValidationResult } from "../validation/validationTypes"; -import { - BinaryOutput, - defaultFileAdapter, - FileAdapter, - ProcessorInput, -} from "../utils/io"; -import { getZipAdapter, ZipAdapter } from "../utils/zip"; +import { AACTree, AACButton, AACSemanticCategory } from './treeStructure'; +import { StringCasing, detectCasing, isNumericOrEmpty } from './stringCasing'; +import { ValidationResult } from '../validation/validationTypes'; +import { BinaryOutput, defaultFileAdapter, FileAdapter, ProcessorInput } from '../utils/io'; +import { getZipAdapter, ZipAdapter } from '../utils/zip'; // Configuration options for processors export interface ProcessorConfig { @@ -82,10 +77,7 @@ export interface ProcessorConfig { fileAdapter: FileAdapter; // Adapter for handling encoding/decoding zip files - zipAdapter: ( - input?: ProcessorInput, - fileAdapter?: FileAdapter, - ) => Promise; + zipAdapter: (input?: ProcessorInput, fileAdapter?: FileAdapter) => Promise; } export type ProcessorOptions = Partial; @@ -108,7 +100,7 @@ export interface VocabLocation { export interface ProcessingError { message: string; - step: "EXTRACT" | "PROCESS" | "SAVE"; + step: 'EXTRACT' | 'PROCESS' | 'SAVE'; } export interface ExtractStringsResult { @@ -153,7 +145,7 @@ abstract class BaseProcessor { abstract processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string, + outputPath: string ): Promise; // Save tree structure back to file/buffer @@ -182,7 +174,7 @@ abstract class BaseProcessor { generateTranslatedDownload?( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise; // Helper method to determine if a button should be filtered out @@ -204,16 +196,13 @@ abstract class BaseProcessor { // Filter specific navigation intents (toolbar navigation only) if (this.options.excludeNavigationButtons) { const i = String(intent); - if (i === "GO_BACK" || i === "GO_HOME") { + if (i === 'GO_BACK' || i === 'GO_HOME') { return true; } } // Filter system/text editing buttons by category - if ( - this.options.excludeSystemButtons && - category === AACSemanticCategory.TEXT_EDITING - ) { + if (this.options.excludeSystemButtons && category === AACSemanticCategory.TEXT_EDITING) { return true; } @@ -221,10 +210,10 @@ abstract class BaseProcessor { if (this.options.excludeSystemButtons) { const i = String(intent); if ( - i === "DELETE_WORD" || - i === "DELETE_CHARACTER" || - i === "CLEAR_TEXT" || - i === "COPY_TEXT" + i === 'DELETE_WORD' || + i === 'DELETE_CHARACTER' || + i === 'CLEAR_TEXT' || + i === 'COPY_TEXT' ) { return true; } @@ -235,30 +224,25 @@ abstract class BaseProcessor { // Only apply label-based filtering if button doesn't have semantic actions if ( !button.semanticAction && - (this.options.excludeNavigationButtons || - this.options.excludeSystemButtons) + (this.options.excludeNavigationButtons || this.options.excludeSystemButtons) ) { - const label = button.label?.toLowerCase() || ""; - const message = button.message?.toLowerCase() || ""; + const label = button.label?.toLowerCase() || ''; + const message = button.message?.toLowerCase() || ''; // More conservative navigation terms (exclude "more" since it's often used for legitimate page navigation) - const navigationTerms = ["back", "home", "menu", "settings"]; - const systemTerms = ["delete", "clear", "copy", "paste", "undo", "redo"]; + const navigationTerms = ['back', 'home', 'menu', 'settings']; + const systemTerms = ['delete', 'clear', 'copy', 'paste', 'undo', 'redo']; if ( this.options.excludeNavigationButtons && - navigationTerms.some( - (term) => label.includes(term) || message.includes(term), - ) + navigationTerms.some((term) => label.includes(term) || message.includes(term)) ) { return true; } if ( this.options.excludeSystemButtons && - systemTerms.some( - (term) => label.includes(term) || message.includes(term), - ) + systemTerms.some((term) => label.includes(term) || message.includes(term)) ) { return true; } @@ -279,7 +263,7 @@ abstract class BaseProcessor { * @returns Promise with extracted strings and metadata */ protected async extractStringsWithMetadataGeneric( - filePath: string, + filePath: string ): Promise { try { const tree = await this.loadIntoTree(filePath); @@ -288,48 +272,30 @@ abstract class BaseProcessor { // Process all pages and buttons Object.values(tree.pages).forEach((page) => { // Process page names - if ( - page.name && - page.name.trim().length > 1 && - !isNumericOrEmpty(page.name) - ) { + if (page.name && page.name.trim().length > 1 && !isNumericOrEmpty(page.name)) { const key = page.name.trim().toLowerCase(); const vocabLocation: VocabLocation = { - table: "pages", + table: 'pages', id: page.id, - column: "NAME", + column: 'NAME', casing: detectCasing(page.name), }; - this.addToExtractedMap( - extractedMap, - key, - page.name.trim(), - vocabLocation, - ); + this.addToExtractedMap(extractedMap, key, page.name.trim(), vocabLocation); } page.buttons.forEach((button) => { // Process button labels - if ( - button.label && - button.label.trim().length > 1 && - !isNumericOrEmpty(button.label) - ) { + if (button.label && button.label.trim().length > 1 && !isNumericOrEmpty(button.label)) { const key = button.label.trim().toLowerCase(); const vocabLocation: VocabLocation = { - table: "buttons", + table: 'buttons', id: button.id, - column: "LABEL", + column: 'LABEL', casing: detectCasing(button.label), }; - this.addToExtractedMap( - extractedMap, - key, - button.label.trim(), - vocabLocation, - ); + this.addToExtractedMap(extractedMap, key, button.label.trim(), vocabLocation); } // Process button messages (if different from label) @@ -341,18 +307,13 @@ abstract class BaseProcessor { ) { const key = button.message.trim().toLowerCase(); const vocabLocation: VocabLocation = { - table: "buttons", + table: 'buttons', id: button.id, - column: "MESSAGE", + column: 'MESSAGE', casing: detectCasing(button.message), }; - this.addToExtractedMap( - extractedMap, - key, - button.message.trim(), - vocabLocation, - ); + this.addToExtractedMap(extractedMap, key, button.message.trim(), vocabLocation); } }); }); @@ -363,11 +324,8 @@ abstract class BaseProcessor { return { errors: [ { - message: - error instanceof Error - ? error.message - : "Unknown extraction error", - step: "EXTRACT" as const, + message: error instanceof Error ? error.message : 'Unknown extraction error', + step: 'EXTRACT' as const, }, ], extractedStrings: [], @@ -386,14 +344,14 @@ abstract class BaseProcessor { protected async generateTranslatedDownloadGeneric( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { // Build translation map from the provided data const translations = new Map(); sourceStrings.forEach((sourceString) => { const translated = translatedStrings.find( - (ts) => ts.sourcestringid.toString() === sourceString.id.toString(), + (ts) => ts.sourcestringid.toString() === sourceString.id.toString() ); if (translated) { @@ -425,7 +383,7 @@ abstract class BaseProcessor { extractedMap: Map, key: string, originalString: string, - vocabLocation: VocabLocation, + vocabLocation: VocabLocation ): void { const existing = extractedMap.get(key); if (existing) { @@ -446,9 +404,9 @@ abstract class BaseProcessor { * @returns Path for the translated output file */ protected generateTranslatedOutputPath(filePath: string): string { - const lastDotIndex = filePath.lastIndexOf("."); + const lastDotIndex = filePath.lastIndexOf('.'); if (lastDotIndex === -1) { - return filePath + "_translated"; + return filePath + '_translated'; } const basePath = filePath.substring(0, lastDotIndex); diff --git a/src/core/stringCasing.ts b/src/core/stringCasing.ts index d07e8f1..41b68de 100644 --- a/src/core/stringCasing.ts +++ b/src/core/stringCasing.ts @@ -4,17 +4,17 @@ */ export enum StringCasing { - LOWER = "lower", - SNAKE = "snake", - CONSTANT = "constant", - CAMEL = "camel", - UPPER = "upper", - KEBAB = "kebab", - CAPITAL = "capital", - HEADER = "header", - PASCAL = "pascal", - TITLE = "title", - SENTENCE = "sentence", + LOWER = 'lower', + SNAKE = 'snake', + CONSTANT = 'constant', + CAMEL = 'camel', + UPPER = 'upper', + KEBAB = 'kebab', + CAPITAL = 'capital', + HEADER = 'header', + PASCAL = 'pascal', + TITLE = 'title', + SENTENCE = 'sentence', } /** @@ -35,17 +35,17 @@ export function detectCasing(text: string): StringCasing { // Check for specific patterns // CONSTANT_CASE (ALL_CAPS_WITH_UNDERSCORES) - if (/^[A-Z][A-Z0-9_]*$/.test(trimmed) && trimmed.includes("_")) { + if (/^[A-Z][A-Z0-9_]*$/.test(trimmed) && trimmed.includes('_')) { return StringCasing.CONSTANT; } // snake_case (lowercase_with_underscores) - if (/^[a-z][a-z0-9_]*$/.test(trimmed) && trimmed.includes("_")) { + if (/^[a-z][a-z0-9_]*$/.test(trimmed) && trimmed.includes('_')) { return StringCasing.SNAKE; } // kebab-case (lowercase-with-hyphens) - if (/^[a-z][a-z0-9-]*$/.test(trimmed) && trimmed.includes("-")) { + if (/^[a-z][a-z0-9-]*$/.test(trimmed) && trimmed.includes('-')) { return StringCasing.KEBAB; } @@ -64,11 +64,7 @@ export function detectCasing(text: string): StringCasing { } // UPPER CASE (ALL UPPERCASE) - but only if more than one character - if ( - trimmed === trimmed.toUpperCase() && - /[A-Z]/.test(trimmed) && - trimmed.length > 1 - ) { + if (trimmed === trimmed.toUpperCase() && /[A-Z]/.test(trimmed) && trimmed.length > 1) { return StringCasing.UPPER; } @@ -85,22 +81,22 @@ export function detectCasing(text: string): StringCasing { (word) => word.length > 0 && word[0] === word[0].toUpperCase() && - (word.length === 1 || word.slice(1) === word.slice(1).toLowerCase()), + (word.length === 1 || word.slice(1) === word.slice(1).toLowerCase()) ) ) { return StringCasing.TITLE; } // Header-Case (First-Letter-Of-Each-Word-Capitalized-With-Hyphens) - if (trimmed.includes("-")) { - const hyphenWords = trimmed.split("-"); + if (trimmed.includes('-')) { + const hyphenWords = trimmed.split('-'); if ( hyphenWords.length > 1 && hyphenWords.every( (word) => word.length > 0 && word[0] === word[0].toUpperCase() && - (word.length === 1 || word.slice(1) === word.slice(1).toLowerCase()), + (word.length === 1 || word.slice(1) === word.slice(1).toLowerCase()) ) ) { return StringCasing.HEADER; @@ -136,10 +132,7 @@ export function detectCasing(text: string): StringCasing { * @param text Input string * @param targetCasing Desired casing variant */ -export function convertCasing( - text: string, - targetCasing: StringCasing, -): string { +export function convertCasing(text: string, targetCasing: StringCasing): string { if (!text || text.length === 0) return text; const trimmed = text.trim(); @@ -161,10 +154,8 @@ export function convertCasing( case StringCasing.TITLE: return trimmed .split(/\s+/) - .map( - (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), - ) - .join(" "); + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); case StringCasing.CAMEL: return trimmed @@ -172,43 +163,39 @@ export function convertCasing( .map((word, index) => index === 0 ? word.toLowerCase() - : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() ) - .join(""); + .join(''); case StringCasing.PASCAL: return trimmed .split(/[\s_-]+/) - .map( - (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), - ) - .join(""); + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(''); case StringCasing.SNAKE: return trimmed .split(/[\s-]+/) .map((word) => word.toLowerCase()) - .join("_"); + .join('_'); case StringCasing.CONSTANT: return trimmed .split(/[\s-]+/) .map((word) => word.toUpperCase()) - .join("_"); + .join('_'); case StringCasing.KEBAB: return trimmed .split(/[\s_]+/) .map((word) => word.toLowerCase()) - .join("-"); + .join('-'); case StringCasing.HEADER: return trimmed .split(/[\s_]+/) - .map( - (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), - ) - .join("-"); + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join('-'); default: return trimmed; diff --git a/src/core/treeStructure.ts b/src/core/treeStructure.ts index 29bba19..a346ce5 100644 --- a/src/core/treeStructure.ts +++ b/src/core/treeStructure.ts @@ -8,7 +8,7 @@ import type { TouchChatMetadata, CellScanningOrder, ScanningSelectionMethod, -} from "../types/aac"; +} from '../types/aac'; // Re-export for consumers export type { @@ -24,60 +24,60 @@ export type { // Semantic action categories for cross-platform compatibility export enum AACSemanticCategory { - COMMUNICATION = "communication", // Speech, text output - NAVIGATION = "navigation", // Page/grid navigation - TEXT_EDITING = "text_editing", // Text manipulation - SYSTEM_CONTROL = "system_control", // Device/app control - MEDIA = "media", // Audio/video playback - ACCESSIBILITY = "accessibility", // Switch scanning, etc. - CUSTOM = "custom", // Platform-specific extensions + COMMUNICATION = 'communication', // Speech, text output + NAVIGATION = 'navigation', // Page/grid navigation + TEXT_EDITING = 'text_editing', // Text manipulation + SYSTEM_CONTROL = 'system_control', // Device/app control + MEDIA = 'media', // Audio/video playback + ACCESSIBILITY = 'accessibility', // Switch scanning, etc. + CUSTOM = 'custom', // Platform-specific extensions } // Semantic intents within each category export enum AACSemanticIntent { // Communication - SPEAK_TEXT = "SPEAK_TEXT", - SPEAK_IMMEDIATE = "SPEAK_IMMEDIATE", - STOP_SPEECH = "STOP_SPEECH", - INSERT_TEXT = "INSERT_TEXT", + SPEAK_TEXT = 'SPEAK_TEXT', + SPEAK_IMMEDIATE = 'SPEAK_IMMEDIATE', + STOP_SPEECH = 'STOP_SPEECH', + INSERT_TEXT = 'INSERT_TEXT', // Navigation - NAVIGATE_TO = "NAVIGATE_TO", - GO_BACK = "GO_BACK", - GO_HOME = "GO_HOME", + NAVIGATE_TO = 'NAVIGATE_TO', + GO_BACK = 'GO_BACK', + GO_HOME = 'GO_HOME', // Text Editing - DELETE_WORD = "DELETE_WORD", - DELETE_CHARACTER = "DELETE_CHARACTER", - CLEAR_TEXT = "CLEAR_TEXT", - COPY_TEXT = "COPY_TEXT", - PASTE_TEXT = "PASTE_TEXT", + DELETE_WORD = 'DELETE_WORD', + DELETE_CHARACTER = 'DELETE_CHARACTER', + CLEAR_TEXT = 'CLEAR_TEXT', + COPY_TEXT = 'COPY_TEXT', + PASTE_TEXT = 'PASTE_TEXT', // System Control - SEND_KEYS = "SEND_KEYS", - MOUSE_CLICK = "MOUSE_CLICK", + SEND_KEYS = 'SEND_KEYS', + MOUSE_CLICK = 'MOUSE_CLICK', // Media - PLAY_SOUND = "PLAY_SOUND", - PLAY_VIDEO = "PLAY_VIDEO", + PLAY_SOUND = 'PLAY_SOUND', + PLAY_VIDEO = 'PLAY_VIDEO', // Accessibility - SCAN_NEXT = "SCAN_NEXT", - SCAN_SELECT = "SCAN_SELECT", + SCAN_NEXT = 'SCAN_NEXT', + SCAN_SELECT = 'SCAN_SELECT', // Custom - PLATFORM_SPECIFIC = "PLATFORM_SPECIFIC", + PLATFORM_SPECIFIC = 'PLATFORM_SPECIFIC', } /** * Scanning types for accessibility */ export enum AACScanType { - LINEAR = "linear", // Left-to-right, top-to-bottom - ROW_COLUMN = "row-column", // Scan rows, then columns - COLUMN_ROW = "column-row", // Scan columns, then rows - BLOCK_ROW_COLUMN = "block-row-column", // Scan blocks, then rows, then columns - BLOCK_COLUMN_ROW = "block-column-row", // Scan blocks, then columns, then rows + LINEAR = 'linear', // Left-to-right, top-to-bottom + ROW_COLUMN = 'row-column', // Scan rows, then columns + COLUMN_ROW = 'column-row', // Scan columns, then rows + BLOCK_ROW_COLUMN = 'block-row-column', // Scan blocks, then rows, then columns + BLOCK_COLUMN_ROW = 'block-column-row', // Scan blocks, then columns, then rows } /** @@ -143,7 +143,7 @@ export interface AACSemanticAction { // Fallback for unknown platforms fallback?: { - type: "SPEAK" | "NAVIGATE" | "ACTION"; + type: 'SPEAK' | 'NAVIGATE' | 'ACTION'; message?: string; targetPageId?: string; temporary_home?: boolean | string | null; @@ -169,7 +169,7 @@ export class AACButton { }; // Extended properties for advanced platforms - contentType?: "Normal" | "AutoContent" | "Workspace" | "LiveCell"; + contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell'; contentSubType?: string; image?: string; resolvedImageEntry?: string; // normalized zip path to resolved image, if present @@ -187,12 +187,7 @@ export class AACButton { * Scan block number (1-8) for block scanning */ scanBlock?: number; - visibility?: - | "Visible" - | "Hidden" - | "Disabled" - | "PointerAndTouchOnly" - | "Empty"; + visibility?: 'Visible' | 'Hidden' | 'Disabled' | 'PointerAndTouchOnly' | 'Empty'; directActivate?: boolean; audioDescription?: string; parameters?: { [key: string]: any }; @@ -212,8 +207,8 @@ export class AACButton { constructor({ id, - label = "", - message = "", + label = '', + message = '', targetPageId, semanticAction, audioRecording, @@ -254,7 +249,7 @@ export class AACButton { metadata?: string; }; style?: AACStyle; - contentType?: "Normal" | "AutoContent" | "Workspace" | "LiveCell"; + contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell'; contentSubType?: string; image?: string; resolvedImageEntry?: string; @@ -266,12 +261,7 @@ export class AACButton { rowSpan?: number; scanBlocks?: number[]; scanBlock?: number; - visibility?: - | "Visible" - | "Hidden" - | "Disabled" - | "PointerAndTouchOnly" - | "Empty"; + visibility?: 'Visible' | 'Hidden' | 'Disabled' | 'PointerAndTouchOnly' | 'Empty'; directActivate?: boolean; parameters?: { [key: string]: any }; predictions?: string[]; @@ -286,9 +276,9 @@ export class AACButton { semantic_id?: string; clone_id?: string; // Legacy constructor properties for backward compatibility - type?: "SPEAK" | "NAVIGATE" | "ACTION"; + type?: 'SPEAK' | 'NAVIGATE' | 'ACTION'; action?: { - type: "SPEAK" | "NAVIGATE" | "ACTION"; + type: 'SPEAK' | 'NAVIGATE' | 'ACTION'; targetPageId?: string; message?: string; } | null; @@ -323,83 +313,80 @@ export class AACButton { // Legacy mapping: if no semanticAction provided, derive from legacy `action` first if (!this.semanticAction && action) { - if ( - action.type === "NAVIGATE" && - (action.targetPageId || this.targetPageId) - ) { + if (action.type === 'NAVIGATE' && (action.targetPageId || this.targetPageId)) { if (!this.targetPageId) this.targetPageId = action.targetPageId; this.semanticAction = { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.NAVIGATE_TO, targetId: this.targetPageId, - fallback: { type: "NAVIGATE", targetPageId: this.targetPageId }, + fallback: { type: 'NAVIGATE', targetPageId: this.targetPageId }, }; - } else if (action.type === "SPEAK") { - const text = action.message || this.message || this.label || ""; + } else if (action.type === 'SPEAK') { + const text = action.message || this.message || this.label || ''; if (!this.message) this.message = text; this.semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, text, - fallback: { type: "SPEAK", message: text }, + fallback: { type: 'SPEAK', message: text }, }; } else { this.semanticAction = { category: AACSemanticCategory.SYSTEM_CONTROL, intent: AACSemanticIntent.PLATFORM_SPECIFIC, - fallback: { type: "ACTION" }, + fallback: { type: 'ACTION' }, }; } } // Legacy mapping: if still no semanticAction and `type` provided if (!this.semanticAction && type) { - if (type === "NAVIGATE" && this.targetPageId) { + if (type === 'NAVIGATE' && this.targetPageId) { this.semanticAction = { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.NAVIGATE_TO, targetId: this.targetPageId, - fallback: { type: "NAVIGATE", targetPageId: this.targetPageId }, + fallback: { type: 'NAVIGATE', targetPageId: this.targetPageId }, }; - } else if (type === "SPEAK") { - const text = this.message || this.label || ""; + } else if (type === 'SPEAK') { + const text = this.message || this.label || ''; this.semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, text, - fallback: { type: "SPEAK", message: text }, + fallback: { type: 'SPEAK', message: text }, }; } else { this.semanticAction = { category: AACSemanticCategory.SYSTEM_CONTROL, intent: AACSemanticIntent.PLATFORM_SPECIFIC, - fallback: { type: "ACTION" }, + fallback: { type: 'ACTION' }, }; } } } // Legacy compatibility properties - get type(): "SPEAK" | "NAVIGATE" | "ACTION" | undefined { + get type(): 'SPEAK' | 'NAVIGATE' | 'ACTION' | undefined { if (this.semanticAction) { const i = String(this.semanticAction.intent); - if (i === "NAVIGATE_TO") return "NAVIGATE"; - if (i === "SPEAK_TEXT" || i === "SPEAK_IMMEDIATE") return "SPEAK"; - return "ACTION"; + if (i === 'NAVIGATE_TO') return 'NAVIGATE'; + if (i === 'SPEAK_TEXT' || i === 'SPEAK_IMMEDIATE') return 'SPEAK'; + return 'ACTION'; } - if (this.targetPageId) return "NAVIGATE"; - if (this.message) return "SPEAK"; - return "SPEAK"; + if (this.targetPageId) return 'NAVIGATE'; + if (this.message) return 'SPEAK'; + return 'SPEAK'; } get action(): { - type: "SPEAK" | "NAVIGATE" | "ACTION"; + type: 'SPEAK' | 'NAVIGATE' | 'ACTION'; targetPageId?: string; message?: string; } | null { const t = this.type; if (!t) return null; - if (t === "SPEAK" && !this.message && !this.label && !this.semanticAction) { + if (t === 'SPEAK' && !this.message && !this.label && !this.semanticAction) { return null; } return { type: t, targetPageId: this.targetPageId, message: this.message }; @@ -421,7 +408,7 @@ export class AACPage { semantic_ids?: string[]; clone_ids?: string[]; // Scanning configuration for this page - scanningConfig?: import("../types/aac").ScanningConfig; + scanningConfig?: import('../types/aac').ScanningConfig; // Scanning support scanType?: AACScanType; @@ -429,7 +416,7 @@ export class AACPage { constructor({ id, - name = "", + name = '', grid = [], buttons = [], parentId = null, @@ -456,7 +443,7 @@ export class AACPage { sounds?: any[]; semantic_ids?: string[]; clone_ids?: string[]; - scanningConfig?: import("../types/aac").ScanningConfig; + scanningConfig?: import('../types/aac').ScanningConfig; scanBlocksConfig?: AACScanBlock[]; scanType?: AACScanType; }) { @@ -464,17 +451,10 @@ export class AACPage { this.name = name; if (Array.isArray(grid)) { this.grid = grid; - } else if ( - grid && - typeof grid === "object" && - "columns" in grid && - "rows" in grid - ) { + } else if (grid && typeof grid === 'object' && 'columns' in grid && 'rows' in grid) { const cols = (grid as any).columns as number; const rows = (grid as any).rows as number; - this.grid = Array.from({ length: rows }, () => - Array.from({ length: cols }, () => null), - ); + this.grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => null)); } else { this.grid = []; } @@ -552,11 +532,7 @@ export class AACTree { page.buttons .filter((b) => { const i = String(b.semanticAction?.intent); - return ( - i === "NAVIGATE_TO" || - !!b.semanticAction?.targetId || - !!b.targetPageId - ); + return i === 'NAVIGATE_TO' || !!b.semanticAction?.targetId || !!b.targetPageId; }) .forEach((b) => { const target = b.semanticAction?.targetId || b.targetPageId; diff --git a/src/dot.ts b/src/dot.ts index 80a94cf..7d05161 100644 --- a/src/dot.ts +++ b/src/dot.ts @@ -5,7 +5,7 @@ */ // Processor class -export { DotProcessor } from "./processors/dotProcessor"; +export { DotProcessor } from './processors/dotProcessor'; // Note: DOT doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/excel.ts b/src/excel.ts index 467388c..13a8020 100644 --- a/src/excel.ts +++ b/src/excel.ts @@ -5,7 +5,7 @@ */ // Processor class -export { ExcelProcessor } from "./processors/excelProcessor"; +export { ExcelProcessor } from './processors/excelProcessor'; // Note: Excel doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/gridset.ts b/src/gridset.ts index 6a40825..8d925a8 100644 --- a/src/gridset.ts +++ b/src/gridset.ts @@ -6,7 +6,7 @@ */ // Processor class -export { GridsetProcessor } from "./processors/gridsetProcessor"; +export { GridsetProcessor } from './processors/gridsetProcessor'; // === User & File System Helpers === export { @@ -29,7 +29,7 @@ export { type Grid3UserPath, type Grid3VocabularyPath, type Grid3HistoryEntry, -} from "./processors/gridset/helpers"; +} from './processors/gridset/helpers'; // === Wordlist Management === export { @@ -39,7 +39,7 @@ export { wordlistToXml, type WordList, type WordListItem, -} from "./processors/gridset/wordlistHelpers"; +} from './processors/gridset/wordlistHelpers'; // === Color Utilities === export { @@ -52,7 +52,7 @@ export { darkenColor, normalizeColor, ensureAlphaChannel, -} from "./processors/gridset/colorUtils"; +} from './processors/gridset/colorUtils'; // === Style Helpers === export { @@ -63,7 +63,7 @@ export { CellBackgroundShape, SHAPE_NAMES, ensureAlphaChannel as ensureAlphaChannelFromStyles, -} from "./processors/gridset/styleHelpers"; +} from './processors/gridset/styleHelpers'; // === Plugin & Workspace Detection === export { @@ -78,7 +78,7 @@ export { WORKSPACE_TYPES, LIVECELL_TYPES, AUTOCONTENT_TYPES, -} from "./processors/gridset/pluginTypes"; +} from './processors/gridset/pluginTypes'; // === Command Detection === export { @@ -94,7 +94,7 @@ export { type CommandParameter, type ExtractedParameters, Grid3CommandCategory, -} from "./processors/gridset/commands"; +} from './processors/gridset/commands'; // === Symbol Libraries === export { @@ -121,7 +121,7 @@ export { // Backward compatibility getSymbolsDir, getSymbolSearchDir, -} from "./processors/gridset/index"; +} from './processors/gridset/index'; // === Symbol Extraction === export { @@ -132,7 +132,7 @@ export { suggestExtractionStrategy, exportSymbolReferencesToCsv, createSymbolManifest, -} from "./processors/gridset/symbolExtractor"; +} from './processors/gridset/symbolExtractor'; // === Symbol Search === export { @@ -146,13 +146,13 @@ export { getSearchSuggestions, countLibrarySymbols, getSymbolSearchStats, -} from "./processors/gridset/symbolSearch"; +} from './processors/gridset/symbolSearch'; // === Password Management === export { resolveGridsetPassword, resolveGridsetPasswordFromEnv, -} from "./processors/gridset/password"; +} from './processors/gridset/password'; // === Image Debugging === export { @@ -160,4 +160,4 @@ export { formatImageAuditSummary, type ImageAuditResult, type ImageIssue, -} from "./processors/gridset/imageDebug"; +} from './processors/gridset/imageDebug'; diff --git a/src/index.browser.ts b/src/index.browser.ts index 590ede7..b54b183 100644 --- a/src/index.browser.ts +++ b/src/index.browser.ts @@ -15,39 +15,39 @@ // =================================================================== // CORE TYPES // =================================================================== -export * from "./core/treeStructure"; -export * from "./core/baseProcessor"; -export * from "./core/stringCasing"; +export * from './core/treeStructure'; +export * from './core/baseProcessor'; +export * from './core/stringCasing'; // =================================================================== // BROWSER-SAFE PROCESSORS // =================================================================== -export { DotProcessor } from "./processors/dotProcessor"; -export { OpmlProcessor } from "./processors/opmlProcessor"; -export { ObfProcessor } from "./processors/obfProcessor"; -export { GridsetProcessor } from "./processors/gridsetProcessor"; -export { SnapProcessor } from "./processors/snapProcessor"; -export { TouchChatProcessor } from "./processors/touchchatProcessor"; -export { ApplePanelsProcessor } from "./processors/applePanelsProcessor"; -export { AstericsGridProcessor } from "./processors/astericsGridProcessor"; +export { DotProcessor } from './processors/dotProcessor'; +export { OpmlProcessor } from './processors/opmlProcessor'; +export { ObfProcessor } from './processors/obfProcessor'; +export { GridsetProcessor } from './processors/gridsetProcessor'; +export { SnapProcessor } from './processors/snapProcessor'; +export { TouchChatProcessor } from './processors/touchchatProcessor'; +export { ApplePanelsProcessor } from './processors/applePanelsProcessor'; +export { AstericsGridProcessor } from './processors/astericsGridProcessor'; // =================================================================== // UTILITY FUNCTIONS // =================================================================== // Metrics namespace (pageset analytics) -export * as Metrics from "./metrics"; +export * as Metrics from './metrics'; -import { BaseProcessor, ProcessorOptions } from "./core/baseProcessor"; -import { DotProcessor } from "./processors/dotProcessor"; -import { OpmlProcessor } from "./processors/opmlProcessor"; -import { ObfProcessor } from "./processors/obfProcessor"; -import { GridsetProcessor } from "./processors/gridsetProcessor"; -import { SnapProcessor } from "./processors/snapProcessor"; -import { TouchChatProcessor } from "./processors/touchchatProcessor"; -import { ApplePanelsProcessor } from "./processors/applePanelsProcessor"; -import { AstericsGridProcessor } from "./processors/astericsGridProcessor"; -export { configureSqlJs } from "./utils/sqlite"; +import { BaseProcessor, ProcessorOptions } from './core/baseProcessor'; +import { DotProcessor } from './processors/dotProcessor'; +import { OpmlProcessor } from './processors/opmlProcessor'; +import { ObfProcessor } from './processors/obfProcessor'; +import { GridsetProcessor } from './processors/gridsetProcessor'; +import { SnapProcessor } from './processors/snapProcessor'; +import { TouchChatProcessor } from './processors/touchchatProcessor'; +import { ApplePanelsProcessor } from './processors/applePanelsProcessor'; +import { AstericsGridProcessor } from './processors/astericsGridProcessor'; +export { configureSqlJs } from './utils/sqlite'; /** * Factory function to get the appropriate processor for a file extension @@ -57,30 +57,30 @@ export { configureSqlJs } from "./utils/sqlite"; */ export function getProcessor( filePathOrExtension: string, - options?: ProcessorOptions, + options?: ProcessorOptions ): BaseProcessor { - const extension = filePathOrExtension.includes(".") - ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf(".")) + const extension = filePathOrExtension.includes('.') + ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf('.')) : filePathOrExtension; switch (extension.toLowerCase()) { - case ".dot": + case '.dot': return new DotProcessor(options); - case ".opml": + case '.opml': return new OpmlProcessor(options); - case ".obf": - case ".obz": + case '.obf': + case '.obz': return new ObfProcessor(options); - case ".gridset": + case '.gridset': return new GridsetProcessor(options); - case ".spb": - case ".sps": + case '.spb': + case '.sps': return new SnapProcessor(options); - case ".ce": + case '.ce': return new TouchChatProcessor(options); - case ".plist": + case '.plist': return new ApplePanelsProcessor(options); - case ".grd": + case '.grd': return new AstericsGridProcessor(options); default: throw new Error(`Unsupported file extension: ${extension}`); @@ -92,18 +92,7 @@ export function getProcessor( * @returns Array of supported file extensions */ export function getSupportedExtensions(): string[] { - return [ - ".dot", - ".opml", - ".obf", - ".obz", - ".gridset", - ".spb", - ".sps", - ".ce", - ".plist", - ".grd", - ]; + return ['.dot', '.opml', '.obf', '.obz', '.gridset', '.spb', '.sps', '.ce', '.plist', '.grd']; } /** diff --git a/src/index.node.ts b/src/index.node.ts index 59eae71..1c32b8f 100644 --- a/src/index.node.ts +++ b/src/index.node.ts @@ -9,60 +9,60 @@ // =================================================================== // CORE TYPES (always needed) // =================================================================== -export * from "./core/treeStructure"; -export * from "./core/baseProcessor"; -export * from "./core/stringCasing"; +export * from './core/treeStructure'; +export * from './core/baseProcessor'; +export * from './core/stringCasing'; // =================================================================== // PROCESSORS (main functionality) // =================================================================== -export * from "./processors"; +export * from './processors'; // =================================================================== // NAMESPACES // =================================================================== // Analytics namespace (usage/history) -export * as Analytics from "./analytics"; +export * as Analytics from './analytics'; // Validation namespace -export * as Validation from "./validation"; +export * as Validation from './validation'; // Metrics namespace (pageset analytics) -export * as Metrics from "./metrics"; +export * as Metrics from './metrics'; // Node-only morphology utilities (Grid 3 verbs parser) -export { Grid3VerbsParser } from "./utilities/analytics/morphology/grid3VerbsParser"; -export { WordFormGenerator } from "./utilities/analytics/morphology/wordFormGenerator"; +export { Grid3VerbsParser } from './utilities/analytics/morphology/grid3VerbsParser'; +export { WordFormGenerator } from './utilities/analytics/morphology/wordFormGenerator'; // Processor namespaces (platform-specific utilities) -export * as Gridset from "./gridset"; -export * as Snap from "./snap"; -export * as OBF from "./obf"; -export * as Obfset from "./obfset"; -export * as TouchChat from "./touchchat"; -export * as Dot from "./dot"; -export * as Excel from "./excel"; -export * as Opml from "./opml"; -export * as ApplePanels from "./applePanels"; -export * as AstericsGrid from "./astericsGrid"; -export * as Translation from "./translation"; +export * as Gridset from './gridset'; +export * as Snap from './snap'; +export * as OBF from './obf'; +export * as Obfset from './obfset'; +export * as TouchChat from './touchchat'; +export * as Dot from './dot'; +export * as Excel from './excel'; +export * as Opml from './opml'; +export * as ApplePanels from './applePanels'; +export * as AstericsGrid from './astericsGrid'; +export * as Translation from './translation'; // =================================================================== // UTILITY FUNCTIONS // =================================================================== -import { BaseProcessor, ProcessorOptions } from "./core/baseProcessor"; -import { DotProcessor } from "./processors/dotProcessor"; -import { ExcelProcessor } from "./processors/excelProcessor"; -import { OpmlProcessor } from "./processors/opmlProcessor"; -import { ObfProcessor } from "./processors/obfProcessor"; -import { GridsetProcessor } from "./processors/gridsetProcessor"; -import { SnapProcessor } from "./processors/snapProcessor"; -import { TouchChatProcessor } from "./processors/touchchatProcessor"; -import { ApplePanelsProcessor } from "./processors/applePanelsProcessor"; -import { AstericsGridProcessor } from "./processors/astericsGridProcessor"; -import { ObfsetProcessor } from "./processors/obfsetProcessor"; +import { BaseProcessor, ProcessorOptions } from './core/baseProcessor'; +import { DotProcessor } from './processors/dotProcessor'; +import { ExcelProcessor } from './processors/excelProcessor'; +import { OpmlProcessor } from './processors/opmlProcessor'; +import { ObfProcessor } from './processors/obfProcessor'; +import { GridsetProcessor } from './processors/gridsetProcessor'; +import { SnapProcessor } from './processors/snapProcessor'; +import { TouchChatProcessor } from './processors/touchchatProcessor'; +import { ApplePanelsProcessor } from './processors/applePanelsProcessor'; +import { AstericsGridProcessor } from './processors/astericsGridProcessor'; +import { ObfsetProcessor } from './processors/obfsetProcessor'; /** * Factory function to get the appropriate processor for a file extension @@ -76,36 +76,36 @@ import { ObfsetProcessor } from "./processors/obfsetProcessor"; */ export function getProcessor( filePathOrExtension: string, - options?: ProcessorOptions, + options?: ProcessorOptions ): BaseProcessor { // Extract extension from file path - const extension = filePathOrExtension.includes(".") - ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf(".")) + const extension = filePathOrExtension.includes('.') + ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf('.')) : filePathOrExtension; switch (extension.toLowerCase()) { - case ".dot": + case '.dot': return new DotProcessor(options); - case ".xlsx": + case '.xlsx': return new ExcelProcessor(options); - case ".opml": + case '.opml': return new OpmlProcessor(options); - case ".obf": - case ".obz": + case '.obf': + case '.obz': return new ObfProcessor(options); - case ".obfset": + case '.obfset': return new ObfsetProcessor(options); - case ".gridset": - case ".gridsetx": + case '.gridset': + case '.gridsetx': return new GridsetProcessor(options); - case ".spb": - case ".sps": + case '.spb': + case '.sps': return new SnapProcessor(options); - case ".ce": + case '.ce': return new TouchChatProcessor(options); - case ".plist": + case '.plist': return new ApplePanelsProcessor(options); - case ".grd": + case '.grd': return new AstericsGridProcessor(options); default: throw new Error(`Unsupported file extension: ${extension}`); @@ -118,19 +118,19 @@ export function getProcessor( */ export function getSupportedExtensions(): string[] { return [ - ".dot", - ".xlsx", - ".opml", - ".obf", - ".obz", - ".obfset", - ".gridset", - ".gridsetx", - ".spb", - ".sps", - ".ce", - ".plist", - ".grd", + '.dot', + '.xlsx', + '.opml', + '.obf', + '.obz', + '.obfset', + '.gridset', + '.gridsetx', + '.spb', + '.sps', + '.ce', + '.plist', + '.grd', ]; } diff --git a/src/index.ts b/src/index.ts index 1c03dce..ada9ed9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1 @@ -export * from "./index.node"; +export * from './index.node'; diff --git a/src/metrics.ts b/src/metrics.ts index eb46483..5ae2bc5 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -5,16 +5,16 @@ * Use this for analyzing AAC trees, not end-user usage logs. */ -export * from "./utilities/analytics/metrics/types"; -export * from "./utilities/analytics/metrics/effort"; -export * from "./utilities/analytics/metrics/obl-types"; -export { OblUtil, OblAnonymizer } from "./utilities/analytics/metrics/obl"; -export { MetricsCalculator } from "./utilities/analytics/metrics/core"; -export { VocabularyAnalyzer } from "./utilities/analytics/metrics/vocabulary"; -export { SentenceAnalyzer } from "./utilities/analytics/metrics/sentence"; -export { ComparisonAnalyzer } from "./utilities/analytics/metrics/comparison"; -export { MorphologyEngine } from "./utilities/analytics/morphology"; -export { WordFormGenerator } from "./utilities/analytics/morphology"; +export * from './utilities/analytics/metrics/types'; +export * from './utilities/analytics/metrics/effort'; +export * from './utilities/analytics/metrics/obl-types'; +export { OblUtil, OblAnonymizer } from './utilities/analytics/metrics/obl'; +export { MetricsCalculator } from './utilities/analytics/metrics/core'; +export { VocabularyAnalyzer } from './utilities/analytics/metrics/vocabulary'; +export { SentenceAnalyzer } from './utilities/analytics/metrics/sentence'; +export { ComparisonAnalyzer } from './utilities/analytics/metrics/comparison'; +export { MorphologyEngine } from './utilities/analytics/morphology'; +export { WordFormGenerator } from './utilities/analytics/morphology'; export type { MorphRuleSet, MorphRule, @@ -23,12 +23,12 @@ export type { AstericsWordForm, VerbFormWithConditions, Grid3VerbFormsDetailed, -} from "./utilities/analytics/morphology"; -export { ReferenceLoader } from "./utilities/analytics/reference"; +} from './utilities/analytics/morphology'; +export { ReferenceLoader } from './utilities/analytics/reference'; export { InMemoryReferenceLoader, createBrowserReferenceLoader, loadReferenceDataFromUrl, type ReferenceData, -} from "./utilities/analytics/reference/browser"; -export * from "./utilities/analytics/utils/idGenerator"; +} from './utilities/analytics/reference/browser'; +export * from './utilities/analytics/utils/idGenerator'; diff --git a/src/obf.ts b/src/obf.ts index dfedaaa..575b32c 100644 --- a/src/obf.ts +++ b/src/obf.ts @@ -5,8 +5,8 @@ */ // Processor class -export { ObfProcessor } from "./processors/obfProcessor"; -export { ObfsetProcessor } from "./processors/obfsetProcessor"; +export { ObfProcessor } from './processors/obfProcessor'; +export { ObfsetProcessor } from './processors/obfsetProcessor'; // Note: OBF doesn't currently have platform-specific helpers like Gridset/Snap // Future helper functions can be added here diff --git a/src/obfset.ts b/src/obfset.ts index d4021d6..bbcb063 100644 --- a/src/obfset.ts +++ b/src/obfset.ts @@ -5,7 +5,7 @@ */ // Processor class -export { ObfsetProcessor } from "./processors/obfsetProcessor"; +export { ObfsetProcessor } from './processors/obfsetProcessor'; // Note: Obfset doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/opml.ts b/src/opml.ts index 33be009..bd0ab3e 100644 --- a/src/opml.ts +++ b/src/opml.ts @@ -5,7 +5,7 @@ */ // Processor class -export { OpmlProcessor } from "./processors/opmlProcessor"; +export { OpmlProcessor } from './processors/opmlProcessor'; // Note: OPML doesn't currently have platform-specific helpers // Future helper functions can be added here diff --git a/src/processors/applePanelsProcessor.ts b/src/processors/applePanelsProcessor.ts index 8d6e82c..636be12 100644 --- a/src/processors/applePanelsProcessor.ts +++ b/src/processors/applePanelsProcessor.ts @@ -4,7 +4,7 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from "../core/baseProcessor"; +} from '../core/baseProcessor'; import { AACTree, AACPage, @@ -12,13 +12,13 @@ import { AACSemanticAction, AACSemanticCategory, AACSemanticIntent, -} from "../core/treeStructure"; -import plist, { PlistValue } from "plist"; +} from '../core/treeStructure'; +import plist, { PlistValue } from 'plist'; import { ValidationFailureError, buildValidationResultFromMessage, -} from "../validation/validationTypes"; -import { ProcessorInput, getBasename } from "../utils/io"; +} from '../validation/validationTypes'; +import { ProcessorInput, getBasename } from '../utils/io'; interface ApplePanelsActionParameters { CharString?: string; @@ -89,7 +89,7 @@ interface ApplePanelsPanelObject { DisplayText: string; FontSize: number; ID: string; - PanelObjectType: "Button"; + PanelObjectType: 'Button'; Rect: string; DisplayColor?: string; DisplayImageWeight?: string; @@ -117,42 +117,35 @@ interface ApplePanelsPanelDefinition { } function isNormalizedPanel( - panel: ApplePanelsPanel | ApplePanelsRawPanel, + panel: ApplePanelsPanel | ApplePanelsRawPanel ): panel is ApplePanelsPanel { - return typeof (panel as ApplePanelsPanel).id === "string"; + return typeof (panel as ApplePanelsPanel).id === 'string'; } -function normalizePanel( - panel: ApplePanelsRawPanel, - fallbackId: string, -): ApplePanelsPanel { +function normalizePanel(panel: ApplePanelsRawPanel, fallbackId: string): ApplePanelsPanel { const rawId = panel.ID || fallbackId; const buttons = Array.isArray(panel.PanelObjects) ? panel.PanelObjects.filter( - (obj): obj is ApplePanelsRawButton => obj.PanelObjectType === "Button", + (obj): obj is ApplePanelsRawButton => obj.PanelObjectType === 'Button' ) : []; const normalizedButtons: ApplePanelsButton[] = buttons.map((btn) => { const firstAction: ApplePanelsRawAction | undefined = - Array.isArray(btn.Actions) && btn.Actions.length > 0 - ? btn.Actions[0] - : undefined; + Array.isArray(btn.Actions) && btn.Actions.length > 0 ? btn.Actions[0] : undefined; const isCharSequence = firstAction && - (firstAction.ActionType === "ActionPressKeyCharSequence" || - firstAction.ActionType === "ActionSendKeys"); - const charString = isCharSequence - ? firstAction?.ActionParam?.CharString - : undefined; + (firstAction.ActionType === 'ActionPressKeyCharSequence' || + firstAction.ActionType === 'ActionSendKeys'); + const charString = isCharSequence ? firstAction?.ActionParam?.CharString : undefined; const targetPanel = - firstAction && firstAction.ActionType === "ActionOpenPanel" - ? firstAction.ActionParam?.PanelID?.replace(/^USER\./, "") + firstAction && firstAction.ActionType === 'ActionOpenPanel' + ? firstAction.ActionParam?.PanelID?.replace(/^USER\./, '') : undefined; return { - label: btn.DisplayText || "Button", - message: charString || btn.DisplayText || "Button", + label: btn.DisplayText || 'Button', + message: charString || btn.DisplayText || 'Button', DisplayColor: btn.DisplayColor, DisplayImageWeight: btn.DisplayImageWeight, FontSize: btn.FontSize, @@ -162,16 +155,14 @@ function normalizePanel( }); return { - id: rawId.replace(/^USER\./, ""), - name: panel.Name || "Panel", + id: rawId.replace(/^USER\./, ''), + name: panel.Name || 'Panel', buttons: normalizedButtons, }; } -function normalizeActionParameters( - input: unknown, -): ApplePanelsActionParameters { - if (typeof input === "object" && input !== null) { +function normalizeActionParameters(input: unknown): ApplePanelsActionParameters { + if (typeof input === 'object' && input !== null) { return { ...(input as Record) }; } return {}; @@ -183,14 +174,12 @@ class ApplePanelsProcessor extends BaseProcessor { } // Helper function to parse Apple Panels Rect format "{{x, y}, {width, height}}" private parseRect( - rectString: string, + rectString: string ): { x: number; y: number; width: number; height: number } | null { if (!rectString) return null; // Parse format like "{{0, 0}, {100, 25}}" - const match = rectString.match( - /\{\{(\d+),\s*(\d+)\},\s*\{(\d+),\s*(\d+)\}\}/, - ); + const match = rectString.match(/\{\{(\d+),\s*(\d+)\},\s*\{(\d+),\s*(\d+)\}\}/); if (!match) return null; return { @@ -205,7 +194,7 @@ class ApplePanelsProcessor extends BaseProcessor { private pixelToGrid( pixelX: number, pixelY: number, - cellSize: number = 25, + cellSize: number = 25 ): { gridX: number; gridY: number } { return { gridX: Math.floor(pixelX / cellSize), @@ -229,28 +218,21 @@ class ApplePanelsProcessor extends BaseProcessor { } async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { - const { - readBinaryFromInput, - readTextFromInput, - pathExists, - getFileSize, - join, - } = this.options.fileAdapter; + const { readBinaryFromInput, readTextFromInput, pathExists, getFileSize, join } = + this.options.fileAdapter; const filename = - typeof filePathOrBuffer === "string" - ? getBasename(filePathOrBuffer) - : "upload.plist"; + typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.plist'; let buffer: Uint8Array; try { - if (typeof filePathOrBuffer === "string") { - if (filePathOrBuffer.endsWith(".ascconfig")) { + if (typeof filePathOrBuffer === 'string') { + if (filePathOrBuffer.endsWith('.ascconfig')) { const panelDefsPath = join( filePathOrBuffer, - "Contents", - "Resources", - "PanelDefinitions.plist", + 'Contents', + 'Resources', + 'PanelDefinitions.plist' ); if (await pathExists(panelDefsPath)) { buffer = await readBinaryFromInput(panelDefsPath); @@ -258,15 +240,12 @@ class ApplePanelsProcessor extends BaseProcessor { const validation = buildValidationResultFromMessage({ filename, filesize: 0, - format: "applepanels", + format: 'applepanels', message: `Apple Panels file not found: ${panelDefsPath}`, - type: "missing", - description: "PanelDefinitions.plist", + type: 'missing', + description: 'PanelDefinitions.plist', }); - throw new ValidationFailureError( - "Apple Panels file not found", - validation, - ); + throw new ValidationFailureError('Apple Panels file not found', validation); } } else { buffer = await readBinaryFromInput(filePathOrBuffer); @@ -301,20 +280,17 @@ class ApplePanelsProcessor extends BaseProcessor { const validation = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: "applepanels", - message: "No panels found in Apple Panels file", - type: "structure", - description: "Panels definition", + format: 'applepanels', + message: 'No panels found in Apple Panels file', + type: 'structure', + description: 'Panels definition', }); - throw new ValidationFailureError( - "Apple Panels has no panels", - validation, - ); + throw new ValidationFailureError('Apple Panels has no panels', validation); } const data: ApplePanelsDocument = { panels: panelsData }; const tree = new AACTree(); - tree.metadata.format = "applepanels"; + tree.metadata.format = 'applepanels'; data.panels.forEach((panel) => { const page = new AACPage({ @@ -342,12 +318,12 @@ class ApplePanelsProcessor extends BaseProcessor { targetId: btn.targetPanel, platformData: { applePanels: { - actionType: "ActionOpenPanel", + actionType: 'ActionOpenPanel', parameters: { PanelID: `USER.${btn.targetPanel}` }, }, }, fallback: { - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId: btn.targetPanel, }, }; @@ -358,15 +334,15 @@ class ApplePanelsProcessor extends BaseProcessor { text: btn.message || btn.label, platformData: { applePanels: { - actionType: "ActionPressKeyCharSequence", + actionType: 'ActionPressKeyCharSequence', parameters: { - CharString: btn.message || btn.label || "", + CharString: btn.message || btn.label || '', isStickyKey: false, }, }, }, fallback: { - type: "SPEAK", + type: 'SPEAK', message: btn.message || btn.label, }, }; @@ -381,7 +357,7 @@ class ApplePanelsProcessor extends BaseProcessor { style: { backgroundColor: btn.DisplayColor, fontSize: btn.FontSize, - fontWeight: btn.DisplayImageWeight === "bold" ? "bold" : "normal", + fontWeight: btn.DisplayImageWeight === 'bold' ? 'bold' : 'normal', }, }); page.addButton(button); @@ -393,16 +369,8 @@ class ApplePanelsProcessor extends BaseProcessor { const gridWidth = Math.max(1, Math.ceil(rect.width / 25)); const gridHeight = Math.max(1, Math.ceil(rect.height / 25)); - for ( - let r = gridPos.gridY; - r < gridPos.gridY + gridHeight && r < maxRows; - r++ - ) { - for ( - let c = gridPos.gridX; - c < gridPos.gridX + gridWidth && c < maxCols; - c++ - ) { + for (let r = gridPos.gridY; r < gridPos.gridY + gridHeight && r < maxRows; r++) { + for (let c = gridPos.gridX; c < gridPos.gridX + gridWidth && c < maxCols; c++) { if (gridLayout[r] && gridLayout[r][c] === null) { gridLayout[r][c] = button; } @@ -424,30 +392,26 @@ class ApplePanelsProcessor extends BaseProcessor { const validation = buildValidationResultFromMessage({ filename, filesize: - typeof filePathOrBuffer === "string" + typeof filePathOrBuffer === 'string' ? await (async () => { return (await pathExists(filePathOrBuffer)) ? await getFileSize(filePathOrBuffer) : 0; })() : (await readBinaryFromInput(filePathOrBuffer)).byteLength, - format: "applepanels", - message: err?.message || "Failed to parse Apple Panels file", - type: "parse", - description: "Parse Apple Panels plist", + format: 'applepanels', + message: err?.message || 'Failed to parse Apple Panels file', + type: 'parse', + description: 'Parse Apple Panels plist', }); - throw new ValidationFailureError( - "Failed to load Apple Panels file", - validation, - err, - ); + throw new ValidationFailureError('Failed to load Apple Panels file', validation, err); } } async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string, + outputPath: string ): Promise { const { readBinaryFromInput, join } = this.options.fileAdapter; // Load the tree, apply translations, and save to new file @@ -480,19 +444,18 @@ class ApplePanelsProcessor extends BaseProcessor { if (button.semanticAction) { const intentStr = String(button.semanticAction.intent); - if (intentStr === "SPEAK_TEXT" || intentStr === "INSERT_TEXT") { - const updatedText = button.message || button.label || ""; + if (intentStr === 'SPEAK_TEXT' || intentStr === 'INSERT_TEXT') { + const updatedText = button.message || button.label || ''; button.semanticAction.text = updatedText; if (button.semanticAction.fallback) { button.semanticAction.fallback.message = updatedText; } - const platformParams = - button.semanticAction.platformData?.applePanels?.parameters; - if (platformParams && typeof platformParams === "object") { - if ("CharString" in platformParams) { + const platformParams = button.semanticAction.platformData?.applePanels?.parameters; + if (platformParams && typeof platformParams === 'object') { + if ('CharString' in platformParams) { platformParams.CharString = updatedText; } - if ("PanelID" in platformParams && button.targetPageId) { + if ('PanelID' in platformParams && button.targetPageId) { platformParams.PanelID = `USER.${button.targetPageId}`; } } @@ -504,73 +467,56 @@ class ApplePanelsProcessor extends BaseProcessor { // Save the translated tree to the requested location and return its content await this.saveFromTree(tree, outputPath); - if (outputPath.endsWith(".plist")) { + if (outputPath.endsWith('.plist')) { return await readBinaryFromInput(outputPath); } - const configPath = outputPath.endsWith(".ascconfig") - ? outputPath - : `${outputPath}.ascconfig`; - const panelDefsPath = join( - configPath, - "Contents", - "Resources", - "PanelDefinitions.plist", - ); + const configPath = outputPath.endsWith('.ascconfig') ? outputPath : `${outputPath}.ascconfig`; + const panelDefsPath = join(configPath, 'Contents', 'Resources', 'PanelDefinitions.plist'); return await readBinaryFromInput(panelDefsPath); } async saveFromTree(tree: AACTree, outputPath: string): Promise { - const { writeTextToPath, pathExists, mkDir, join, dirname } = - this.options.fileAdapter; + const { writeTextToPath, pathExists, mkDir, join, dirname } = this.options.fileAdapter; // Support two output modes: // 1) Single-file .plist (PanelDefinitions.plist content written directly) // 2) Apple Panels bundle folder (*.ascconfig) with Contents/Resources structure - const isSinglePlist = outputPath.endsWith(".plist"); + const isSinglePlist = outputPath.endsWith('.plist'); // Prepare folder structure only when exporting as bundle - let configPath = ""; - let contentsPath = ""; - let resourcesPath = ""; + let configPath = ''; + let contentsPath = ''; + let resourcesPath = ''; if (!isSinglePlist) { - configPath = outputPath.endsWith(".ascconfig") - ? outputPath - : `${outputPath}.ascconfig`; - contentsPath = join(configPath, "Contents"); - resourcesPath = join(contentsPath, "Resources"); - - if (!(await pathExists(configPath))) - await mkDir(configPath, { recursive: true }); - if (!(await pathExists(contentsPath))) - await mkDir(contentsPath, { recursive: true }); - if (!(await pathExists(resourcesPath))) - await mkDir(resourcesPath, { recursive: true }); + configPath = outputPath.endsWith('.ascconfig') ? outputPath : `${outputPath}.ascconfig`; + contentsPath = join(configPath, 'Contents'); + resourcesPath = join(contentsPath, 'Resources'); + + if (!(await pathExists(configPath))) await mkDir(configPath, { recursive: true }); + if (!(await pathExists(contentsPath))) await mkDir(contentsPath, { recursive: true }); + if (!(await pathExists(resourcesPath))) await mkDir(resourcesPath, { recursive: true }); // Create Info.plist (bundle mode only) const infoPlist = { - ASCConfigurationDisplayName: - tree.metadata?.name || "AAC Processors Export", + ASCConfigurationDisplayName: tree.metadata?.name || 'AAC Processors Export', ASCConfigurationIdentifier: `com.aacprocessors.${Date.now()}`, - ASCConfigurationProductSupportType: "VirtualKeyboard", - ASCConfigurationVersion: tree.metadata?.version || "7.1", - CFBundleDevelopmentRegion: tree.metadata?.locale || "en", - CFBundleIdentifier: "com.aacprocessors.panel.export", - CFBundleName: tree.metadata?.name || "AAC Processors Panels", - CFBundleShortVersionString: tree.metadata?.version || "1.0", - CFBundleVersion: "1", + ASCConfigurationProductSupportType: 'VirtualKeyboard', + ASCConfigurationVersion: tree.metadata?.version || '7.1', + CFBundleDevelopmentRegion: tree.metadata?.locale || 'en', + CFBundleIdentifier: 'com.aacprocessors.panel.export', + CFBundleName: tree.metadata?.name || 'AAC Processors Panels', + CFBundleShortVersionString: tree.metadata?.version || '1.0', + CFBundleVersion: '1', NSHumanReadableCopyright: tree.metadata?.copyright || - `Generated by AAC Processors${tree.metadata?.author ? ` - Author: ${tree.metadata.author}` : ""}`, + `Generated by AAC Processors${tree.metadata?.author ? ` - Author: ${tree.metadata.author}` : ''}`, }; const infoPlistContent = plist.build(infoPlist); - await writeTextToPath(join(contentsPath, "Info.plist"), infoPlistContent); + await writeTextToPath(join(contentsPath, 'Info.plist'), infoPlistContent); // Create AssetIndex.plist (empty) const assetIndexContent = plist.build({}); - await writeTextToPath( - join(resourcesPath, "AssetIndex.plist"), - assetIndexContent, - ); + await writeTextToPath(join(resourcesPath, 'AssetIndex.plist'), assetIndexContent); } // Build PanelDefinitions content from tree @@ -650,11 +596,11 @@ class ApplePanelsProcessor extends BaseProcessor { const buttonObj: ApplePanelsPanelObject = { ButtonType: 0, - DisplayText: button.label || "Button", + DisplayText: button.label || 'Button', FontSize: button.style?.fontSize || 12, ID: `Button.${button.id}`, - PanelObjectType: "Button", - Rect: rect ?? "{{0, 0}, {100, 25}}", + PanelObjectType: 'Button', + Rect: rect ?? '{{0, 0}, {100, 25}}', Actions: [], }; @@ -662,10 +608,10 @@ class ApplePanelsProcessor extends BaseProcessor { buttonObj.DisplayColor = button.style.backgroundColor; } - if (button.style?.fontWeight === "bold") { - buttonObj.DisplayImageWeight = "FontWeightBold"; + if (button.style?.fontWeight === 'bold') { + buttonObj.DisplayImageWeight = 'FontWeightBold'; } else { - buttonObj.DisplayImageWeight = "FontWeightRegular"; + buttonObj.DisplayImageWeight = 'FontWeightRegular'; } // Add actions - prefer semantic action if available @@ -685,21 +631,18 @@ class ApplePanelsProcessor extends BaseProcessor { HideSwitchDockContextualButtons: false, HideTitlebar: false, ID: panelId, - Name: page.name || "Panel", + Name: page.name || 'Panel', PanelObjects: panelObjects, - ProductSupportType: "All", - Rect: "{{15, 75}, {425, 55}}", + ProductSupportType: 'All', + Rect: '{{15, 75}, {425, 55}}', ScanStyle: 0, - ShowPanelLocationString: "CustomPanelList", + ShowPanelLocationString: 'CustomPanelList', UsesPinnedResizing: false, }; }); const panelsValue: Record = Object.fromEntries( - Object.entries(panelsDict).map(([key, value]) => [ - key, - value as unknown as PlistValue, - ]), + Object.entries(panelsDict).map(([key, value]) => [key, value as unknown as PlistValue]) ); const panelDefinitions: PlistValue = { @@ -719,10 +662,7 @@ class ApplePanelsProcessor extends BaseProcessor { await writeTextToPath(outputPath, panelDefsContent); } else { // Write into bundle structure - await writeTextToPath( - join(resourcesPath, "PanelDefinitions.plist"), - panelDefsContent, - ); + await writeTextToPath(join(resourcesPath, 'PanelDefinitions.plist'), panelDefsContent); } } @@ -742,40 +682,36 @@ class ApplePanelsProcessor extends BaseProcessor { if (button.semanticAction) { const intentStr = String(button.semanticAction.intent); switch (intentStr) { - case "NAVIGATE_TO": + case 'NAVIGATE_TO': return { ActionParam: { - PanelID: `USER.${button.semanticAction.targetId || button.targetPageId || ""}`, + PanelID: `USER.${button.semanticAction.targetId || button.targetPageId || ''}`, }, ActionRecordedOffset: 0.0, - ActionType: "ActionOpenPanel", + ActionType: 'ActionOpenPanel', ID: `Action.${button.id}`, }; - case "SPEAK_TEXT": - case "INSERT_TEXT": + case 'SPEAK_TEXT': + case 'INSERT_TEXT': return { ActionParam: { - CharString: - button.semanticAction.text || - button.message || - button.label || - "", + CharString: button.semanticAction.text || button.message || button.label || '', isStickyKey: false, }, ActionRecordedOffset: 0.0, - ActionType: "ActionPressKeyCharSequence", + ActionType: 'ActionPressKeyCharSequence', ID: `Action.${button.id}`, }; - case "SEND_KEYS": + case 'SEND_KEYS': return { ActionParam: { - CharString: button.semanticAction.text || "", + CharString: button.semanticAction.text || '', isStickyKey: false, }, ActionRecordedOffset: 0.0, - ActionType: "ActionSendKeys", + ActionType: 'ActionSendKeys', ID: `Action.${button.id}`, }; @@ -784,14 +720,11 @@ class ApplePanelsProcessor extends BaseProcessor { return { ActionParam: { CharString: - button.semanticAction.fallback?.message || - button.message || - button.label || - "", + button.semanticAction.fallback?.message || button.message || button.label || '', isStickyKey: false, }, ActionRecordedOffset: 0.0, - ActionType: "ActionPressKeyCharSequence", + ActionType: 'ActionPressKeyCharSequence', ID: `Action.${button.id}`, }; } @@ -800,11 +733,11 @@ class ApplePanelsProcessor extends BaseProcessor { // Default SPEAK action if no semantic action return { ActionParam: { - CharString: button.message || button.label || "", + CharString: button.message || button.label || '', isStickyKey: false, }, ActionRecordedOffset: 0.0, - ActionType: "ActionPressKeyCharSequence", + ActionType: 'ActionPressKeyCharSequence', ID: `Action.${button.id}`, }; } @@ -824,13 +757,9 @@ class ApplePanelsProcessor extends BaseProcessor { generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { - return this.generateTranslatedDownloadGeneric( - filePath, - translatedStrings, - sourceStrings, - ); + return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); } } diff --git a/src/processors/astericsGridProcessor.ts b/src/processors/astericsGridProcessor.ts index ed8364e..e925210 100644 --- a/src/processors/astericsGridProcessor.ts +++ b/src/processors/astericsGridProcessor.ts @@ -4,7 +4,7 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from "../core/baseProcessor"; +} from '../core/baseProcessor'; import { AACTree, AACPage, @@ -13,12 +13,12 @@ import { AACSemanticCategory, AACSemanticIntent, AstericsGridMetadata, -} from "../core/treeStructure"; +} from '../core/treeStructure'; import { ValidationFailureError, buildValidationResultFromMessage, -} from "../validation/validationTypes"; -import { ProcessorInput, getBasename, encodeBase64 } from "../utils/io"; +} from '../validation/validationTypes'; +import { ProcessorInput, getBasename, encodeBase64 } from '../utils/io'; // Asterics Grid data model interfaces interface GridData { @@ -111,334 +111,333 @@ interface ColorSchemeDefinition { const DEFAULT_COLOR_SCHEME_DEFINITIONS: ColorSchemeDefinition[] = [ { - name: "CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT", + name: 'CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT', categories: [ - "CC_PRONOUN_PERSON_NAME", - "CC_NOUN", - "CC_VERB", - "CC_DESCRIPTOR", - "CC_SOCIAL_EXPRESSIONS", - "CC_MISC", - "CC_PLACE", - "CC_CATEGORY", - "CC_IMPORTANT", - "CC_OTHERS", + 'CC_PRONOUN_PERSON_NAME', + 'CC_NOUN', + 'CC_VERB', + 'CC_DESCRIPTOR', + 'CC_SOCIAL_EXPRESSIONS', + 'CC_MISC', + 'CC_PLACE', + 'CC_CATEGORY', + 'CC_IMPORTANT', + 'CC_OTHERS', ], colors: [ - "#fafad0", - "#fbf3e4", - "#dff4df", - "#eaeffd", - "#fff0f6", - "#ffffff", - "#fbf2ff", - "#ddccc1", - "#FCE8E8", - "#e4e4e4", + '#fafad0', + '#fbf3e4', + '#dff4df', + '#eaeffd', + '#fff0f6', + '#ffffff', + '#fbf2ff', + '#ddccc1', + '#FCE8E8', + '#e4e4e4', ], mappings: { - CC_ADJECTIVE: "CC_DESCRIPTOR", - CC_ADVERB: "CC_DESCRIPTOR", - CC_ARTICLE: "CC_MISC", - CC_PREPOSITION: "CC_MISC", - CC_CONJUNCTION: "CC_MISC", - CC_INTERJECTION: "CC_SOCIAL_EXPRESSIONS", + CC_ADJECTIVE: 'CC_DESCRIPTOR', + CC_ADVERB: 'CC_DESCRIPTOR', + CC_ARTICLE: 'CC_MISC', + CC_PREPOSITION: 'CC_MISC', + CC_CONJUNCTION: 'CC_MISC', + CC_INTERJECTION: 'CC_SOCIAL_EXPRESSIONS', }, }, { - name: "CS_MODIFIED_FITZGERALD_KEY_LIGHT", + name: 'CS_MODIFIED_FITZGERALD_KEY_LIGHT', categories: [ - "CC_PRONOUN_PERSON_NAME", - "CC_NOUN", - "CC_VERB", - "CC_DESCRIPTOR", - "CC_SOCIAL_EXPRESSIONS", - "CC_MISC", - "CC_PLACE", - "CC_CATEGORY", - "CC_IMPORTANT", - "CC_OTHERS", + 'CC_PRONOUN_PERSON_NAME', + 'CC_NOUN', + 'CC_VERB', + 'CC_DESCRIPTOR', + 'CC_SOCIAL_EXPRESSIONS', + 'CC_MISC', + 'CC_PLACE', + 'CC_CATEGORY', + 'CC_IMPORTANT', + 'CC_OTHERS', ], colors: [ - "#fdfd96", - "#ffda89", - "#c7f3c7", - "#84b6f4", - "#fdcae1", - "#ffffff", - "#bc98f3", - "#d8af97", - "#ff9688", - "#bdbfbf", + '#fdfd96', + '#ffda89', + '#c7f3c7', + '#84b6f4', + '#fdcae1', + '#ffffff', + '#bc98f3', + '#d8af97', + '#ff9688', + '#bdbfbf', ], mappings: { - CC_ADJECTIVE: "CC_DESCRIPTOR", - CC_ADVERB: "CC_DESCRIPTOR", - CC_ARTICLE: "CC_MISC", - CC_PREPOSITION: "CC_MISC", - CC_CONJUNCTION: "CC_MISC", - CC_INTERJECTION: "CC_SOCIAL_EXPRESSIONS", + CC_ADJECTIVE: 'CC_DESCRIPTOR', + CC_ADVERB: 'CC_DESCRIPTOR', + CC_ARTICLE: 'CC_MISC', + CC_PREPOSITION: 'CC_MISC', + CC_CONJUNCTION: 'CC_MISC', + CC_INTERJECTION: 'CC_SOCIAL_EXPRESSIONS', }, }, { - name: "CS_MODIFIED_FITZGERALD_KEY_MEDIUM", + name: 'CS_MODIFIED_FITZGERALD_KEY_MEDIUM', categories: [ - "CC_PRONOUN_PERSON_NAME", - "CC_NOUN", - "CC_VERB", - "CC_DESCRIPTOR", - "CC_SOCIAL_EXPRESSIONS", - "CC_MISC", - "CC_PLACE", - "CC_CATEGORY", - "CC_IMPORTANT", - "CC_OTHERS", + 'CC_PRONOUN_PERSON_NAME', + 'CC_NOUN', + 'CC_VERB', + 'CC_DESCRIPTOR', + 'CC_SOCIAL_EXPRESSIONS', + 'CC_MISC', + 'CC_PLACE', + 'CC_CATEGORY', + 'CC_IMPORTANT', + 'CC_OTHERS', ], colors: [ - "#ffff6b", - "#ffb56b", - "#b5ff6b", - "#6bb5ff", - "#ff6bff", - "#ffffff", - "#ce6bff", - "#bf9075", - "#ff704d", - "#a3a3a3", + '#ffff6b', + '#ffb56b', + '#b5ff6b', + '#6bb5ff', + '#ff6bff', + '#ffffff', + '#ce6bff', + '#bf9075', + '#ff704d', + '#a3a3a3', ], mappings: { - CC_ADJECTIVE: "CC_DESCRIPTOR", - CC_ADVERB: "CC_DESCRIPTOR", - CC_ARTICLE: "CC_MISC", - CC_PREPOSITION: "CC_MISC", - CC_CONJUNCTION: "CC_MISC", - CC_INTERJECTION: "CC_SOCIAL_EXPRESSIONS", + CC_ADJECTIVE: 'CC_DESCRIPTOR', + CC_ADVERB: 'CC_DESCRIPTOR', + CC_ARTICLE: 'CC_MISC', + CC_PREPOSITION: 'CC_MISC', + CC_CONJUNCTION: 'CC_MISC', + CC_INTERJECTION: 'CC_SOCIAL_EXPRESSIONS', }, }, { - name: "CS_MODIFIED_FITZGERALD_KEY_DARK", + name: 'CS_MODIFIED_FITZGERALD_KEY_DARK', categories: [ - "CC_PRONOUN_PERSON_NAME", - "CC_NOUN", - "CC_VERB", - "CC_DESCRIPTOR", - "CC_SOCIAL_EXPRESSIONS", - "CC_MISC", - "CC_PLACE", - "CC_CATEGORY", - "CC_IMPORTANT", - "CC_OTHERS", + 'CC_PRONOUN_PERSON_NAME', + 'CC_NOUN', + 'CC_VERB', + 'CC_DESCRIPTOR', + 'CC_SOCIAL_EXPRESSIONS', + 'CC_MISC', + 'CC_PLACE', + 'CC_CATEGORY', + 'CC_IMPORTANT', + 'CC_OTHERS', ], colors: [ - "#79791F", - "#804c26", - "#4c8026", - "#264c80", - "#802680", - "#747474", - "#602680", - "#52331f", - "#80261a", - "#464646", + '#79791F', + '#804c26', + '#4c8026', + '#264c80', + '#802680', + '#747474', + '#602680', + '#52331f', + '#80261a', + '#464646', ], mappings: { - CC_ADJECTIVE: "CC_DESCRIPTOR", - CC_ADVERB: "CC_DESCRIPTOR", - CC_ARTICLE: "CC_MISC", - CC_PREPOSITION: "CC_MISC", - CC_CONJUNCTION: "CC_MISC", - CC_INTERJECTION: "CC_SOCIAL_EXPRESSIONS", + CC_ADJECTIVE: 'CC_DESCRIPTOR', + CC_ADVERB: 'CC_DESCRIPTOR', + CC_ARTICLE: 'CC_MISC', + CC_PREPOSITION: 'CC_MISC', + CC_CONJUNCTION: 'CC_MISC', + CC_INTERJECTION: 'CC_SOCIAL_EXPRESSIONS', }, }, { - name: "CS_GOOSENS_VERY_LIGHT", + name: 'CS_GOOSENS_VERY_LIGHT', categories: [ - "CC_VERB", - "CC_DESCRIPTOR", - "CC_PREPOSITION", - "CC_NOUN", - "CC_QUESTION_NEGATION_PRONOUN", + 'CC_VERB', + 'CC_DESCRIPTOR', + 'CC_PREPOSITION', + 'CC_NOUN', + 'CC_QUESTION_NEGATION_PRONOUN', ], - colors: ["#fff0f6", "#eaeffd", "#dff4df", "#fafad0", "#fbf3e4"], + colors: ['#fff0f6', '#eaeffd', '#dff4df', '#fafad0', '#fbf3e4'], }, { - name: "CS_GOOSENS_LIGHT", + name: 'CS_GOOSENS_LIGHT', categories: [ - "CC_VERB", - "CC_DESCRIPTOR", - "CC_PREPOSITION", - "CC_NOUN", - "CC_QUESTION_NEGATION_PRONOUN", + 'CC_VERB', + 'CC_DESCRIPTOR', + 'CC_PREPOSITION', + 'CC_NOUN', + 'CC_QUESTION_NEGATION_PRONOUN', ], - colors: ["#fdcae1", "#84b6f4", "#c7f3c7", "#fdfd96", "#ffda89"], + colors: ['#fdcae1', '#84b6f4', '#c7f3c7', '#fdfd96', '#ffda89'], }, { - name: "CS_GOOSENS_MEDIUM", + name: 'CS_GOOSENS_MEDIUM', categories: [ - "CC_VERB", - "CC_DESCRIPTOR", - "CC_PREPOSITION", - "CC_NOUN", - "CC_QUESTION_NEGATION_PRONOUN", + 'CC_VERB', + 'CC_DESCRIPTOR', + 'CC_PREPOSITION', + 'CC_NOUN', + 'CC_QUESTION_NEGATION_PRONOUN', ], - colors: ["#ff6bff", "#6bb5ff", "#b5ff6b", "#ffff6b", "#ffb56b"], + colors: ['#ff6bff', '#6bb5ff', '#b5ff6b', '#ffff6b', '#ffb56b'], }, { - name: "CS_GOOSENS_DARK", + name: 'CS_GOOSENS_DARK', categories: [ - "CC_VERB", - "CC_DESCRIPTOR", - "CC_PREPOSITION", - "CC_NOUN", - "CC_QUESTION_NEGATION_PRONOUN", + 'CC_VERB', + 'CC_DESCRIPTOR', + 'CC_PREPOSITION', + 'CC_NOUN', + 'CC_QUESTION_NEGATION_PRONOUN', ], - colors: ["#802680", "#264c80", "#4c8026", "#79791F", "#804c26"], + colors: ['#802680', '#264c80', '#4c8026', '#79791F', '#804c26'], }, { - name: "CS_MONTESSORI_VERY_LIGHT", + name: 'CS_MONTESSORI_VERY_LIGHT', categories: [ - "CC_NOUN", - "CC_ARTICLE", - "CC_ADJECTIVE", - "CC_VERB", - "CC_PREPOSITION", - "CC_ADVERB", - "CC_PRONOUN_PERSON_NAME", - "CC_CONJUNCTION", - "CC_INTERJECTION", - "CC_CATEGORY", + 'CC_NOUN', + 'CC_ARTICLE', + 'CC_ADJECTIVE', + 'CC_VERB', + 'CC_PREPOSITION', + 'CC_ADVERB', + 'CC_PRONOUN_PERSON_NAME', + 'CC_CONJUNCTION', + 'CC_INTERJECTION', + 'CC_CATEGORY', ], colors: [ - "#ffffff", - "#e3f5fa", - "#eaeffd", - "#FCE8E8", - "#dff4df", - "#fbf3e4", - "#fbf2ff", - "#fff0f6", - "#fbf7e4", - "#e4e4e4", + '#ffffff', + '#e3f5fa', + '#eaeffd', + '#FCE8E8', + '#dff4df', + '#fbf3e4', + '#fbf2ff', + '#fff0f6', + '#fbf7e4', + '#e4e4e4', ], customBorders: { - CC_NOUN: "#353535", + CC_NOUN: '#353535', }, }, { - name: "CS_MONTESSORI_LIGHT", + name: 'CS_MONTESSORI_LIGHT', categories: [ - "CC_NOUN", - "CC_ARTICLE", - "CC_ADJECTIVE", - "CC_VERB", - "CC_PREPOSITION", - "CC_ADVERB", - "CC_PRONOUN_PERSON_NAME", - "CC_CONJUNCTION", - "CC_INTERJECTION", - "CC_CATEGORY", + 'CC_NOUN', + 'CC_ARTICLE', + 'CC_ADJECTIVE', + 'CC_VERB', + 'CC_PREPOSITION', + 'CC_ADVERB', + 'CC_PRONOUN_PERSON_NAME', + 'CC_CONJUNCTION', + 'CC_INTERJECTION', + 'CC_CATEGORY', ], colors: [ - "#afafaf", - "#a8e0f0", - "#a5bbf7", - "#f4a8a8", - "#ace3ac", - "#f2d7a6", - "#e4a5ff", - "#ffa5c9", - "#f2e5a6", - "#d1d1d1", + '#afafaf', + '#a8e0f0', + '#a5bbf7', + '#f4a8a8', + '#ace3ac', + '#f2d7a6', + '#e4a5ff', + '#ffa5c9', + '#f2e5a6', + '#d1d1d1', ], }, { - name: "CS_MONTESSORI_MEDIUM", + name: 'CS_MONTESSORI_MEDIUM', categories: [ - "CC_NOUN", - "CC_ARTICLE", - "CC_ADJECTIVE", - "CC_VERB", - "CC_PREPOSITION", - "CC_ADVERB", - "CC_PRONOUN_PERSON_NAME", - "CC_CONJUNCTION", - "CC_INTERJECTION", - "CC_CATEGORY", + 'CC_NOUN', + 'CC_ARTICLE', + 'CC_ADJECTIVE', + 'CC_VERB', + 'CC_PREPOSITION', + 'CC_ADVERB', + 'CC_PRONOUN_PERSON_NAME', + 'CC_CONJUNCTION', + 'CC_INTERJECTION', + 'CC_CATEGORY', ], colors: [ - "#000000", - "#4ca6d9", - "#1347ae", - "#e73a0f", - "#04bf82", - "#fd9030", - "#6118a2", - "#f1c9d1", - "#aa996b", - "#d1d1d1", + '#000000', + '#4ca6d9', + '#1347ae', + '#e73a0f', + '#04bf82', + '#fd9030', + '#6118a2', + '#f1c9d1', + '#aa996b', + '#d1d1d1', ], }, { - name: "CS_MONTESSORI_DARK", + name: 'CS_MONTESSORI_DARK', categories: [ - "CC_NOUN", - "CC_ARTICLE", - "CC_ADJECTIVE", - "CC_VERB", - "CC_PREPOSITION", - "CC_ADVERB", - "CC_PRONOUN_PERSON_NAME", - "CC_CONJUNCTION", - "CC_INTERJECTION", - "CC_CATEGORY", + 'CC_NOUN', + 'CC_ARTICLE', + 'CC_ADJECTIVE', + 'CC_VERB', + 'CC_PREPOSITION', + 'CC_ADVERB', + 'CC_PRONOUN_PERSON_NAME', + 'CC_CONJUNCTION', + 'CC_INTERJECTION', + 'CC_CATEGORY', ], colors: [ - "#464646", - "#18728c", - "#0d3298", - "#931212", - "#287728", - "#BC5800", - "#7500a7", - "#a70043", - "#807351", - "#747474", + '#464646', + '#18728c', + '#0d3298', + '#931212', + '#287728', + '#BC5800', + '#7500a7', + '#a70043', + '#807351', + '#747474', ], }, ]; const COLOR_SCHEME_ALIASES: Record = { - CS_DEFAULT: "CS_MODIFIED_FITZGERALD_KEY_LIGHT", - CS_MONTESSORI: "CS_MONTESSORI_LIGHT", - CS_MONTESSORI_LIGHT: "CS_MONTESSORI_LIGHT", - CS_MONTESSORI_MEDIUM: "CS_MONTESSORI_MEDIUM", - CS_MONTESSORI_DARK: "CS_MONTESSORI_DARK", - CS_MONTESSORI_VERY_LIGHT: "CS_MONTESSORI_VERY_LIGHT", - CS_MODIFIED_FITZGERALD_KEY: "CS_MODIFIED_FITZGERALD_KEY_LIGHT", - CS_MODIFIED_FITZGERALD_KEY_LIGHT: "CS_MODIFIED_FITZGERALD_KEY_LIGHT", - CS_MODIFIED_FITZGERALD_KEY_MEDIUM: "CS_MODIFIED_FITZGERALD_KEY_MEDIUM", - CS_MODIFIED_FITZGERALD_KEY_DARK: "CS_MODIFIED_FITZGERALD_KEY_DARK", - CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT: - "CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT", - CS_GOOSENS: "CS_GOOSENS_LIGHT", - CS_GOOSENS_LIGHT: "CS_GOOSENS_LIGHT", - CS_GOOSENS_MEDIUM: "CS_GOOSENS_MEDIUM", - CS_GOOSENS_DARK: "CS_GOOSENS_DARK", - CS_GOOSENS_VERY_LIGHT: "CS_GOOSENS_VERY_LIGHT", + CS_DEFAULT: 'CS_MODIFIED_FITZGERALD_KEY_LIGHT', + CS_MONTESSORI: 'CS_MONTESSORI_LIGHT', + CS_MONTESSORI_LIGHT: 'CS_MONTESSORI_LIGHT', + CS_MONTESSORI_MEDIUM: 'CS_MONTESSORI_MEDIUM', + CS_MONTESSORI_DARK: 'CS_MONTESSORI_DARK', + CS_MONTESSORI_VERY_LIGHT: 'CS_MONTESSORI_VERY_LIGHT', + CS_MODIFIED_FITZGERALD_KEY: 'CS_MODIFIED_FITZGERALD_KEY_LIGHT', + CS_MODIFIED_FITZGERALD_KEY_LIGHT: 'CS_MODIFIED_FITZGERALD_KEY_LIGHT', + CS_MODIFIED_FITZGERALD_KEY_MEDIUM: 'CS_MODIFIED_FITZGERALD_KEY_MEDIUM', + CS_MODIFIED_FITZGERALD_KEY_DARK: 'CS_MODIFIED_FITZGERALD_KEY_DARK', + CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT: 'CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT', + CS_GOOSENS: 'CS_GOOSENS_LIGHT', + CS_GOOSENS_LIGHT: 'CS_GOOSENS_LIGHT', + CS_GOOSENS_MEDIUM: 'CS_GOOSENS_MEDIUM', + CS_GOOSENS_DARK: 'CS_GOOSENS_DARK', + CS_GOOSENS_VERY_LIGHT: 'CS_GOOSENS_VERY_LIGHT', }; export function normalizeHexColor(hexColor: string): string | null { - if (!hexColor || typeof hexColor !== "string") return null; + if (!hexColor || typeof hexColor !== 'string') return null; let value = hexColor.trim().toLowerCase(); - if (!value.startsWith("#")) { + if (!value.startsWith('#')) { return null; } value = value.slice(1); if (value.length === 3) { value = value - .split("") + .split('') .map((ch) => ch + ch) - .join(""); + .join(''); } if (value.length !== 6 || /[^0-9a-f]/.test(value)) { return null; @@ -461,24 +460,22 @@ export function adjustHexColor(hexColor: string, amount: number): string { export function getHighContrastNeutralColor(backgroundColor: string): string { const normalized = normalizeHexColor(backgroundColor); if (!normalized) { - return "#808080"; + return '#808080'; } - return calculateLuminance(normalized) < 0.5 ? "#f5f5f5" : "#808080"; + return calculateLuminance(normalized) < 0.5 ? '#f5f5f5' : '#808080'; } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; + return typeof value === 'object' && value !== null; } -function normalizeStringRecord( - input: unknown, -): Record | undefined { +function normalizeStringRecord(input: unknown): Record | undefined { if (!isRecord(input)) { return undefined; } const entries: [string, string][] = []; Object.entries(input).forEach(([key, value]) => { - if (typeof value === "string") { + if (typeof value === 'string') { entries.push([key, value]); } }); @@ -492,7 +489,7 @@ function normalizeColorScheme(raw: unknown): ColorSchemeDefinition | null { if (!isRecord(raw)) return null; const scheme = raw; const nameCandidate = [scheme.name, scheme.key, scheme.id].find( - (value): value is string => typeof value === "string" && value.length > 0, + (value): value is string => typeof value === 'string' && value.length > 0 ); if (!nameCandidate) return null; @@ -500,17 +497,15 @@ function normalizeColorScheme(raw: unknown): ColorSchemeDefinition | null { let colors: string[] = []; if (Array.isArray(scheme.categories) && Array.isArray(scheme.colors)) { categories = scheme.categories.filter( - (value: unknown): value is string => typeof value === "string", - ); - colors = scheme.colors.filter( - (value: unknown): value is string => typeof value === "string", + (value: unknown): value is string => typeof value === 'string' ); + colors = scheme.colors.filter((value: unknown): value is string => typeof value === 'string'); } else if (isRecord(scheme.colorMap)) { const colorMap = scheme.colorMap; categories = Object.keys(colorMap); colors = categories.map((category) => { const colorValue = colorMap[category]; - return typeof colorValue === "string" ? colorValue : "#ffffff"; + return typeof colorValue === 'string' ? colorValue : '#ffffff'; }); } @@ -535,25 +530,20 @@ function normalizeColorScheme(raw: unknown): ColorSchemeDefinition | null { }; } -function getAllColorSchemeDefinitions( - colorConfig?: AstericsColorConfig, -): ColorSchemeDefinition[] { - const rawAdditional: unknown[] = Array.isArray( - colorConfig?.additionalColorSchemes, - ) +function getAllColorSchemeDefinitions(colorConfig?: AstericsColorConfig): ColorSchemeDefinition[] { + const rawAdditional: unknown[] = Array.isArray(colorConfig?.additionalColorSchemes) ? colorConfig.additionalColorSchemes : []; const additional = rawAdditional .map((scheme) => normalizeColorScheme(scheme)) - .filter( - (value: ColorSchemeDefinition | null): value is ColorSchemeDefinition => - Boolean(value), + .filter((value: ColorSchemeDefinition | null): value is ColorSchemeDefinition => + Boolean(value) ); return [...DEFAULT_COLOR_SCHEME_DEFINITIONS, ...additional]; } function getActiveColorSchemeDefinition( - colorConfig?: AstericsColorConfig, + colorConfig?: AstericsColorConfig ): ColorSchemeDefinition | null { if (!colorConfig || colorConfig.colorSchemesActivated === false) { return null; @@ -564,12 +554,9 @@ function getActiveColorSchemeDefinition( } const activeName: string | undefined = - (typeof colorConfig.activeColorScheme === "string" && - colorConfig.activeColorScheme) || + (typeof colorConfig.activeColorScheme === 'string' && colorConfig.activeColorScheme) || undefined; - const normalizedName = activeName - ? COLOR_SCHEME_ALIASES[activeName] || activeName - : undefined; + const normalizedName = activeName ? COLOR_SCHEME_ALIASES[activeName] || activeName : undefined; if (normalizedName) { const match = schemes.find((scheme) => scheme.name === normalizedName); @@ -584,7 +571,7 @@ function getActiveColorSchemeDefinition( function getSchemeColorForCategory( category: string | undefined, scheme: ColorSchemeDefinition | null, - fallback?: string, + fallback?: string ): string | undefined { if (!scheme || !category) return fallback; let index = scheme.categories.indexOf(category); @@ -595,7 +582,7 @@ function getSchemeColorForCategory( return fallback; } const color = scheme.colors[index]; - return typeof color === "string" ? color : fallback; + return typeof color === 'string' ? color : fallback; } function resolveBorderColor( @@ -604,83 +591,68 @@ function resolveBorderColor( scheme: ColorSchemeDefinition | null, backgroundColor: string, schemeColor?: string, - fallbackBorder?: string, + fallbackBorder?: string ): string { - const defaultBorderColor = (fallbackBorder || "#808080").toLowerCase(); + const defaultBorderColor = (fallbackBorder || '#808080').toLowerCase(); const colorMode = - typeof colorConfig.colorMode === "string" - ? colorConfig.colorMode - : "COLOR_MODE_BACKGROUND"; + typeof colorConfig.colorMode === 'string' ? colorConfig.colorMode : 'COLOR_MODE_BACKGROUND'; - if (colorMode === "COLOR_MODE_BORDER") { + if (colorMode === 'COLOR_MODE_BORDER') { return ( - getSchemeColorForCategory( - element.colorCategory, - scheme, - fallbackBorder || "#808080", - ) || + getSchemeColorForCategory(element.colorCategory, scheme, fallbackBorder || '#808080') || fallbackBorder || - "#808080" + '#808080' ); } - if (colorMode === "COLOR_MODE_BOTH") { + if (colorMode === 'COLOR_MODE_BOTH') { if (!element.colorCategory) { - return "transparent"; + return 'transparent'; } const customBorder = scheme?.customBorders?.[element.colorCategory]; - if (typeof customBorder === "string") { + if (typeof customBorder === 'string') { return customBorder; } const baseColor = schemeColor || - getSchemeColorForCategory( - element.colorCategory, - scheme, - backgroundColor, - ) || + getSchemeColorForCategory(element.colorCategory, scheme, backgroundColor) || backgroundColor; const isDark = calculateLuminance(baseColor) < 0.5; const adjustment = isDark ? 60 : -40; return adjustHexColor(baseColor, adjustment); } - if (defaultBorderColor !== "#808080") { - return fallbackBorder || "#808080"; + if (defaultBorderColor !== '#808080') { + return fallbackBorder || '#808080'; } const gridBackground = - typeof colorConfig.gridBackgroundColor === "string" + typeof colorConfig.gridBackgroundColor === 'string' ? colorConfig.gridBackgroundColor - : "#ffffff"; + : '#ffffff'; return getHighContrastNeutralColor(gridBackground); } function resolveButtonColors( element: GridElement, colorConfig: AstericsColorConfig = {}, - scheme?: ColorSchemeDefinition | null, + scheme?: ColorSchemeDefinition | null ): { backgroundColor: string; borderColor: string; fontColor: string } { const fallbackBackground = - typeof colorConfig.elementBackgroundColor === "string" + typeof colorConfig.elementBackgroundColor === 'string' ? colorConfig.elementBackgroundColor - : "#FFFFFF"; + : '#FFFFFF'; const fallbackBorder = - typeof colorConfig.elementBorderColor === "string" - ? colorConfig.elementBorderColor - : "#808080"; + typeof colorConfig.elementBorderColor === 'string' ? colorConfig.elementBorderColor : '#808080'; const colorMode = - typeof colorConfig.colorMode === "string" - ? colorConfig.colorMode - : "COLOR_MODE_BACKGROUND"; + typeof colorConfig.colorMode === 'string' ? colorConfig.colorMode : 'COLOR_MODE_BACKGROUND'; const isSchemeActive = colorConfig?.colorSchemesActivated !== false; const schemeColor = - isSchemeActive && colorMode !== "COLOR_MODE_BORDER" + isSchemeActive && colorMode !== 'COLOR_MODE_BORDER' ? getSchemeColorForCategory(element.colorCategory, scheme || null) : undefined; - const backgroundColor = - element.backgroundColor || schemeColor || fallbackBackground || "#FFFFFF"; + const backgroundColor = element.backgroundColor || schemeColor || fallbackBackground || '#FFFFFF'; const borderColor = resolveBorderColor( element, @@ -688,13 +660,11 @@ function resolveButtonColors( scheme || null, backgroundColor, schemeColor, - fallbackBorder, + fallbackBorder ); const fontColor = - element.fontColor || - colorConfig?.fontColor || - getContrastingTextColor(backgroundColor); + element.fontColor || colorConfig?.fontColor || getContrastingTextColor(backgroundColor); return { backgroundColor, @@ -710,7 +680,7 @@ function resolveButtonColors( */ export function calculateLuminance(hexColor: string): number { // Remove # if present - const hex = hexColor.replace("#", ""); + const hex = hexColor.replace('#', ''); // Parse RGB values const r = parseInt(hex.substring(0, 2), 16) / 255; @@ -734,7 +704,7 @@ export function calculateLuminance(hexColor: string): number { export function getContrastingTextColor(backgroundColor: string): string { const luminance = calculateLuminance(backgroundColor); // WCAG threshold: use white text if luminance < 0.5, black otherwise - return luminance < 0.5 ? "#FFFFFF" : "#000000"; + return luminance < 0.5 ? '#FFFFFF' : '#000000'; } /** @@ -742,13 +712,11 @@ export function getContrastingTextColor(backgroundColor: string): string { * Asterics Grid: true = hidden, false = visible * Maps to: 'Hidden' | 'Visible' | undefined */ -function mapAstericsVisibility( - hidden: boolean | undefined, -): "Hidden" | "Visible" | undefined { +function mapAstericsVisibility(hidden: boolean | undefined): 'Hidden' | 'Visible' | undefined { if (hidden === undefined) { return undefined; // Default to visible } - return hidden ? "Hidden" : "Visible"; + return hidden ? 'Hidden' : 'Visible'; } class AstericsGridProcessor extends BaseProcessor { @@ -784,9 +752,7 @@ class AstericsGridProcessor extends BaseProcessor { return texts; } - private async extractRawTexts( - filePathOrBuffer: ProcessorInput, - ): Promise { + private async extractRawTexts(filePathOrBuffer: ProcessorInput): Promise { const { readTextFromInput } = this.options.fileAdapter; let content = await readTextFromInput(filePathOrBuffer); @@ -803,14 +769,14 @@ class AstericsGridProcessor extends BaseProcessor { grdFile.grids.forEach((grid: GridData) => { // Extract grid labels Object.values(grid.label || {}).forEach((label) => { - if (label && typeof label === "string") texts.push(label); + if (label && typeof label === 'string') texts.push(label); }); // Extract element texts grid.gridElements.forEach((element: GridElement) => { // Element labels Object.values(element.label || {}).forEach((label) => { - if (label && typeof label === "string") texts.push(label); + if (label && typeof label === 'string') texts.push(label); }); // Word forms @@ -833,39 +799,39 @@ class AstericsGridProcessor extends BaseProcessor { private extractActionTexts(action: GridAction, texts: string[]): void { switch (action.modelName) { - case "GridActionSpeakCustom": - if (action.speakText && typeof action.speakText === "object") { + case 'GridActionSpeakCustom': + if (action.speakText && typeof action.speakText === 'object') { const speakTextMap = action.speakText as Record; Object.values(speakTextMap).forEach((textValue) => { - if (typeof textValue === "string" && textValue.length > 0) { + if (typeof textValue === 'string' && textValue.length > 0) { texts.push(textValue); } }); } break; - case "GridActionChangeLang": - if (action.language && typeof action.language === "string") { + case 'GridActionChangeLang': + if (action.language && typeof action.language === 'string') { texts.push(action.language); } - if (action.voice && typeof action.voice === "string") { + if (action.voice && typeof action.voice === 'string') { texts.push(action.voice); } break; - case "GridActionHTTP": - if (action.restUrl && typeof action.restUrl === "string") { + case 'GridActionHTTP': + if (action.restUrl && typeof action.restUrl === 'string') { texts.push(action.restUrl); } - if (action.body && typeof action.body === "string") { + if (action.body && typeof action.body === 'string') { texts.push(action.body); } break; - case "GridActionOpenWebpage": - if (action.openURL && typeof action.openURL === "string") { + case 'GridActionOpenWebpage': + if (action.openURL && typeof action.openURL === 'string') { texts.push(action.openURL); } break; - case "GridActionMatrix": - if (action.sendText && typeof action.sendText === "string") { + case 'GridActionMatrix': + if (action.sendText && typeof action.sendText === 'string') { texts.push(action.sendText); } break; @@ -878,9 +844,7 @@ class AstericsGridProcessor extends BaseProcessor { const tree = new AACTree(); const filename = - typeof filePathOrBuffer === "string" - ? getBasename(filePathOrBuffer) - : "upload.grd"; + typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.grd'; const buffer = await readBinaryFromInput(filePathOrBuffer); try { @@ -897,25 +861,19 @@ class AstericsGridProcessor extends BaseProcessor { const validationResult = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: "asterics", - message: "Missing grids array in Asterics .grd file", - type: "structure", - description: "Asterics grid collection", + format: 'asterics', + message: 'Missing grids array in Asterics .grd file', + type: 'structure', + description: 'Asterics grid collection', }); - throw new ValidationFailureError( - "Invalid Asterics grid file", - validationResult, - ); + throw new ValidationFailureError('Invalid Asterics grid file', validationResult); } const rawColorConfig = grdFile.metadata?.colorConfig; - const colorConfig: AstericsColorConfig | undefined = isRecord( - rawColorConfig, - ) + const colorConfig: AstericsColorConfig | undefined = isRecord(rawColorConfig) ? (rawColorConfig as AstericsColorConfig) : undefined; - const activeColorSchemeDefinition = - getActiveColorSchemeDefinition(colorConfig); + const activeColorSchemeDefinition = getActiveColorSchemeDefinition(colorConfig); grdFile.grids.forEach((grid: GridData) => { const page = new AACPage({ @@ -925,14 +883,12 @@ class AstericsGridProcessor extends BaseProcessor { buttons: [], parentId: null, style: { - backgroundColor: colorConfig?.gridBackgroundColor || "#FFFFFF", - borderColor: colorConfig?.elementBorderColor || "#CCCCCC", + backgroundColor: colorConfig?.gridBackgroundColor || '#FFFFFF', + borderColor: colorConfig?.elementBorderColor || '#CCCCCC', borderWidth: colorConfig?.borderWidth || 1, - fontFamily: colorConfig?.fontFamily || "Arial", - fontSize: colorConfig?.fontSizePct - ? colorConfig.fontSizePct * 16 - : 16, - fontColor: colorConfig?.fontColor || "#000000", + fontFamily: colorConfig?.fontFamily || 'Arial', + fontSize: colorConfig?.fontSizePct ? colorConfig.fontSizePct * 16 : 16, + fontColor: colorConfig?.fontColor || '#000000', }, }); tree.addPage(page); @@ -953,7 +909,7 @@ class AstericsGridProcessor extends BaseProcessor { const button = this.createButtonFromElement( element, colorConfig, - activeColorSchemeDefinition, + activeColorSchemeDefinition ); page.addButton(button); @@ -962,16 +918,8 @@ class AstericsGridProcessor extends BaseProcessor { const buttonWidth = element.width || 1; const buttonHeight = element.height || 1; - for ( - let r = buttonY; - r < buttonY + buttonHeight && r < maxRows; - r++ - ) { - for ( - let c = buttonX; - c < buttonX + buttonWidth && c < maxCols; - c++ - ) { + for (let r = buttonY; r < buttonY + buttonHeight && r < maxRows; r++) { + for (let c = buttonX; c < buttonX + buttonWidth && c < maxCols; c++) { if (gridLayout[r] && gridLayout[r][c] === null) { gridLayout[r][c] = button; } @@ -979,12 +927,10 @@ class AstericsGridProcessor extends BaseProcessor { } const navAction = element.actions.find( - (a: GridAction) => a.modelName === "GridActionNavigate", + (a: GridAction) => a.modelName === 'GridActionNavigate' ); const targetGridId = - navAction && typeof navAction.toGridId === "string" - ? navAction.toGridId - : undefined; + navAction && typeof navAction.toGridId === 'string' ? navAction.toGridId : undefined; if (targetGridId) { const targetPage = tree.getPage(targetGridId); if (targetPage) { @@ -997,7 +943,7 @@ class AstericsGridProcessor extends BaseProcessor { }); const astericsMetadata: AstericsGridMetadata = { - format: "asterics", + format: 'asterics', hasGlobalGrid: false, }; @@ -1021,10 +967,10 @@ class AstericsGridProcessor extends BaseProcessor { if (languages.size > 0) { astericsMetadata.languages = Array.from(languages).sort(); - astericsMetadata.locale = languages.has("en") - ? "en" - : languages.has("de") - ? "de" + astericsMetadata.locale = languages.has('en') + ? 'en' + : languages.has('de') + ? 'de' : astericsMetadata.languages[0]; } } @@ -1043,99 +989,75 @@ class AstericsGridProcessor extends BaseProcessor { const validationResult = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: "asterics", - message: err?.message || "Failed to parse Asterics grid file", - type: "parse", - description: "Parse Asterics grid JSON", + format: 'asterics', + message: err?.message || 'Failed to parse Asterics grid file', + type: 'parse', + description: 'Parse Asterics grid JSON', }); - throw new ValidationFailureError( - "Failed to load Asterics grid", - validationResult, - err, - ); + throw new ValidationFailureError('Failed to load Asterics grid', validationResult, err); } } - private getLocalizedLabel( - labelMap: { [lang: string]: string } | undefined, - ): string { - if (!labelMap) return ""; + private getLocalizedLabel(labelMap: { [lang: string]: string } | undefined): string { + if (!labelMap) return ''; // Prefer English, then any available language - return ( - labelMap.en || - labelMap.de || - labelMap.es || - Object.values(labelMap)[0] || - "" - ); + return labelMap.en || labelMap.de || labelMap.es || Object.values(labelMap)[0] || ''; } private getLocalizedText(text: unknown): string { - if (typeof text === "string") return text; + if (typeof text === 'string') return text; if (isRecord(text)) { - const preferred = ["en", "de", "es"]; + const preferred = ['en', 'de', 'es']; for (const lang of preferred) { const value = text[lang]; - if (typeof value === "string" && value.length > 0) { + if (typeof value === 'string' && value.length > 0) { return value; } } const fallback = Object.values(text).find( - (value): value is string => - typeof value === "string" && value.length > 0, + (value): value is string => typeof value === 'string' && value.length > 0 ); if (fallback) { return fallback; } } - return ""; + return ''; } private createButtonFromElement( element: GridElement, colorConfig?: AstericsColorConfig, - activeColorScheme?: ColorSchemeDefinition | null, + activeColorScheme?: ColorSchemeDefinition | null ): AACButton { let audioRecording; if (this.loadAudio) { const audioAction = element.actions.find( - (a: GridAction) => a.modelName === "GridActionAudio", + (a: GridAction) => a.modelName === 'GridActionAudio' ); - if (audioAction && typeof audioAction.dataBase64 === "string") { + if (audioAction && typeof audioAction.dataBase64 === 'string') { const parsedId = Number.parseInt(String(audioAction.id), 10); const metadata: Record = {}; - if (typeof audioAction.mimeType === "string") { + if (typeof audioAction.mimeType === 'string') { metadata.mimeType = audioAction.mimeType; } - if (typeof audioAction.durationMs === "number") { + if (typeof audioAction.durationMs === 'number') { metadata.durationMs = audioAction.durationMs; } audioRecording = { id: Number.isNaN(parsedId) ? undefined : parsedId, - data: Buffer.from(audioAction.dataBase64, "base64"), - identifier: - typeof audioAction.filename === "string" - ? audioAction.filename - : undefined, + data: Buffer.from(audioAction.dataBase64, 'base64'), + identifier: typeof audioAction.filename === 'string' ? audioAction.filename : undefined, metadata: JSON.stringify(metadata), }; } } - const colorStyles = resolveButtonColors( - element, - colorConfig, - activeColorScheme, - ); + const colorStyles = resolveButtonColors(element, colorConfig, activeColorScheme); - const navAction = element.actions.find( - (a: GridAction) => a.modelName === "GridActionNavigate", - ); + const navAction = element.actions.find((a: GridAction) => a.modelName === 'GridActionNavigate'); const targetPageId = - navAction && typeof navAction.toGridId === "string" - ? navAction.toGridId - : null; + navAction && typeof navAction.toGridId === 'string' ? navAction.toGridId : null; const label = this.getLocalizedLabel(element.label); @@ -1154,20 +1076,18 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId: targetPageId, }, }; } else { // Check for other action types - const collectAction = element.actions.find( - (a) => a.modelName === "GridActionCollectElement", - ); + const collectAction = element.actions.find((a) => a.modelName === 'GridActionCollectElement'); if (collectAction) { // Handle text editing actions switch (collectAction.action) { - case "COLLECT_ACTION_REMOVE_WORD": + case 'COLLECT_ACTION_REMOVE_WORD': semanticAction = { category: AACSemanticCategory.TEXT_EDITING, intent: AACSemanticIntent.DELETE_WORD, @@ -1178,13 +1098,13 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Delete word", + type: 'ACTION', + message: 'Delete word', }, }; break; - case "COLLECT_ACTION_REMOVE_CHAR": + case 'COLLECT_ACTION_REMOVE_CHAR': semanticAction = { category: AACSemanticCategory.TEXT_EDITING, intent: AACSemanticIntent.DELETE_CHARACTER, @@ -1195,13 +1115,13 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Delete character", + type: 'ACTION', + message: 'Delete character', }, }; break; - case "COLLECT_ACTION_CLEAR": + case 'COLLECT_ACTION_CLEAR': semanticAction = { category: AACSemanticCategory.TEXT_EDITING, intent: AACSemanticIntent.CLEAR_TEXT, @@ -1212,8 +1132,8 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Clear text", + type: 'ACTION', + message: 'Clear text', }, }; break; @@ -1223,7 +1143,7 @@ class AstericsGridProcessor extends BaseProcessor { // Check for navigation actions with special nav types if (!semanticAction && navAction) { switch (navAction.navType) { - case "TO_LAST": + case 'TO_LAST': semanticAction = { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.GO_BACK, @@ -1234,13 +1154,13 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Go back", + type: 'ACTION', + message: 'Go back', }, }; break; - case "TO_HOME": + case 'TO_HOME': semanticAction = { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.GO_HOME, @@ -1251,8 +1171,8 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Go home", + type: 'ACTION', + message: 'Go home', }, }; break; @@ -1262,14 +1182,12 @@ class AstericsGridProcessor extends BaseProcessor { // Check for speak actions if no other semantic action was found if (!semanticAction) { const speakAction = element.actions.find( - (a) => - a.modelName === "GridActionSpeakCustom" || - a.modelName === "GridActionSpeak", + (a) => a.modelName === 'GridActionSpeakCustom' || a.modelName === 'GridActionSpeak' ); if (speakAction) { const speakText = - speakAction.modelName === "GridActionSpeakCustom" + speakAction.modelName === 'GridActionSpeakCustom' ? this.getLocalizedText(speakAction.speakText) : label; @@ -1284,7 +1202,7 @@ class AstericsGridProcessor extends BaseProcessor { }, }, fallback: { - type: "SPEAK", + type: 'SPEAK', message: speakText, }, }; @@ -1296,12 +1214,12 @@ class AstericsGridProcessor extends BaseProcessor { text: label, platformData: { astericsGrid: { - modelName: "GridActionSpeak", + modelName: 'GridActionSpeak', properties: {}, }, }, fallback: { - type: "SPEAK", + type: 'SPEAK', message: label, }, }; @@ -1314,7 +1232,7 @@ class AstericsGridProcessor extends BaseProcessor { element.backgroundColor || colorStyles.backgroundColor || colorConfig?.elementBackgroundColor || - "#FFFFFF"; + '#FFFFFF'; // Determine font color with priority: // 1. Explicit element.fontColor (highest priority) @@ -1335,11 +1253,11 @@ class AstericsGridProcessor extends BaseProcessor { // We need to strip the Data URL prefix before decoding try { let base64Data = element.image.data; - let imageFormat = "png"; // Default format + let imageFormat = 'png'; // Default format // Check if this is a Data URL and extract the base64 part const dataUrlMatch = base64Data.match( - /^data:image\/(png|jpeg|jpg|gif|svg\+xml);base64,(.+)/, + /^data:image\/(png|jpeg|jpg|gif|svg\+xml);base64,(.+)/ ); if (dataUrlMatch) { imageFormat = dataUrlMatch[1]; @@ -1347,7 +1265,7 @@ class AstericsGridProcessor extends BaseProcessor { } // Decode the base64 data - imageData = Buffer.from(base64Data, "base64"); + imageData = Buffer.from(base64Data, 'base64'); // Use detected format for filename imageName = element.image.id || `image.${imageFormat}`; @@ -1369,27 +1287,19 @@ class AstericsGridProcessor extends BaseProcessor { image: imageName, // Store image filename/reference style: { backgroundColor: finalBackgroundColor, - borderColor: - colorStyles.borderColor || - colorConfig?.elementBorderColor || - "#CCCCCC", + borderColor: colorStyles.borderColor || colorConfig?.elementBorderColor || '#CCCCCC', borderWidth: colorConfig?.borderWidth || 1, - fontFamily: colorConfig?.fontFamily || "Arial", + fontFamily: colorConfig?.fontFamily || 'Arial', fontSize: colorConfig?.fontSizePct ? colorConfig.fontSizePct * 16 : 16, fontColor: fontColor, }, - wordForms: - element.wordForms && element.wordForms.length > 0 - ? element.wordForms - : undefined, + wordForms: element.wordForms && element.wordForms.length > 0 ? element.wordForms : undefined, parameters: { ...(imageData ? { imageData: imageData } : {}), - ...(element.actions?.some( - (a: GridAction) => a.modelName === "GridActionWordForm", - ) + ...(element.actions?.some((a: GridAction) => a.modelName === 'GridActionWordForm') ? { wordFormActions: element.actions.filter( - (a: GridAction) => a.modelName === "GridActionWordForm", + (a: GridAction) => a.modelName === 'GridActionWordForm' ), } : {}), @@ -1400,10 +1310,9 @@ class AstericsGridProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string, + outputPath: string ): Promise { - const { readTextFromInput, readBinaryFromInput, writeTextToPath } = - this.options.fileAdapter; + const { readTextFromInput, readBinaryFromInput, writeTextToPath } = this.options.fileAdapter; let content = await readTextFromInput(filePathOrBuffer); @@ -1424,7 +1333,7 @@ class AstericsGridProcessor extends BaseProcessor { private applyTranslationsToGridFile( grdFile: AstericsGridFile, - translations: Map, + translations: Map ): void { grdFile.grids.forEach((grid: GridData) => { // Translate grid labels @@ -1475,20 +1384,14 @@ class AstericsGridProcessor extends BaseProcessor { }); } - private applyTranslationsToAction( - action: GridAction, - translations: Map, - ): void { + private applyTranslationsToAction(action: GridAction, translations: Map): void { switch (action.modelName) { - case "GridActionSpeakCustom": - if (action.speakText && typeof action.speakText === "object") { + case 'GridActionSpeakCustom': + if (action.speakText && typeof action.speakText === 'object') { const speakTextMap = action.speakText as Record; Object.keys(speakTextMap).forEach((lang) => { const originalText = speakTextMap[lang]; - if ( - typeof originalText === "string" && - translations.has(originalText) - ) { + if (typeof originalText === 'string' && translations.has(originalText)) { const translation = translations.get(originalText); if (translation !== undefined) { speakTextMap[lang] = translation; @@ -1497,59 +1400,44 @@ class AstericsGridProcessor extends BaseProcessor { }); } break; - case "GridActionChangeLang": - if ( - typeof action.language === "string" && - translations.has(action.language) - ) { + case 'GridActionChangeLang': + if (typeof action.language === 'string' && translations.has(action.language)) { const translation = translations.get(action.language); if (translation !== undefined) { action.language = translation; } } - if ( - typeof action.voice === "string" && - translations.has(action.voice) - ) { + if (typeof action.voice === 'string' && translations.has(action.voice)) { const translation = translations.get(action.voice); if (translation !== undefined) { action.voice = translation; } } break; - case "GridActionHTTP": - if ( - typeof action.restUrl === "string" && - translations.has(action.restUrl) - ) { + case 'GridActionHTTP': + if (typeof action.restUrl === 'string' && translations.has(action.restUrl)) { const translation = translations.get(action.restUrl); if (translation !== undefined) { action.restUrl = translation; } } - if (typeof action.body === "string" && translations.has(action.body)) { + if (typeof action.body === 'string' && translations.has(action.body)) { const translation = translations.get(action.body); if (translation !== undefined) { action.body = translation; } } break; - case "GridActionOpenWebpage": - if ( - typeof action.openURL === "string" && - translations.has(action.openURL) - ) { + case 'GridActionOpenWebpage': + if (typeof action.openURL === 'string' && translations.has(action.openURL)) { const translation = translations.get(action.openURL); if (translation !== undefined) { action.openURL = translation; } } break; - case "GridActionMatrix": - if ( - typeof action.sendText === "string" && - translations.has(action.sendText) - ) { + case 'GridActionMatrix': + if (typeof action.sendText === 'string' && translations.has(action.sendText)) { const translation = translations.get(action.sendText); if (translation !== undefined) { action.sendText = translation; @@ -1566,12 +1454,12 @@ class AstericsGridProcessor extends BaseProcessor { // Use default Asterics Grid styling instead of taking from first page // This prevents issues where the first page has unusual colors (like purple) const defaultPageStyle = { - backgroundColor: "#FFFFFF", // White background by default - borderColor: "#CCCCCC", + backgroundColor: '#FFFFFF', // White background by default + borderColor: '#CCCCCC', borderWidth: 1, - fontFamily: "Arial", + fontFamily: 'Arial', fontSize: 16, - fontColor: "#000000", + fontColor: '#000000', }; const grids: GridData[] = Object.values(tree.pages).map((page) => { @@ -1592,175 +1480,148 @@ class AstericsGridProcessor extends BaseProcessor { // Filter out navigation/system buttons if configured const filteredButtons = this.filterPageButtons(page.buttons); - const gridElements: GridElement[] = filteredButtons.map( - (button, index) => { - // Use grid position if available, otherwise arrange in rows of 4 - const gridWidth = 4; - const position = buttonPositions.get(button.id); - const calculatedX = position ? position.x : index % gridWidth; - const calculatedY = position - ? position.y - : Math.floor(index / gridWidth); - const actions: GridAction[] = []; - - // Add appropriate actions - prefer semantic actions - if (button.semanticAction?.platformData?.astericsGrid) { - // Use original AstericsGrid action data - const astericsData = - button.semanticAction.platformData.astericsGrid; - actions.push({ - id: `grid-action-${button.id}`, - ...astericsData.properties, - modelName: astericsData.modelName, - modelVersion: - astericsData.properties.modelVersion || - '{"major": 5, "minor": 0, "patch": 0}', - }); - } else if ( - button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO - ) { - // Create navigation action from semantic data - const targetId = - button.semanticAction.targetId || button.targetPageId; - actions.push({ - id: `grid-action-navigate-${button.id}`, - modelName: "GridActionNavigate", - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - navType: "navigateToGrid", - toGridId: targetId, - }); - } else if ( - button.semanticAction?.intent === AACSemanticIntent.GO_BACK - ) { - // Create back navigation action - actions.push({ - id: `grid-action-navigate-back-${button.id}`, - modelName: "GridActionNavigate", - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - navType: "TO_LAST", - }); - } else if ( - button.semanticAction?.intent === AACSemanticIntent.GO_HOME - ) { - // Create home navigation action - actions.push({ - id: `grid-action-navigate-home-${button.id}`, - modelName: "GridActionNavigate", - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - navType: "TO_HOME", - }); - } else if ( - button.semanticAction?.intent === AACSemanticIntent.DELETE_WORD - ) { - // Create delete word action - actions.push({ - id: `grid-action-delete-word-${button.id}`, - modelName: "GridActionCollectElement", - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - action: "COLLECT_ACTION_REMOVE_WORD", - }); - } else if ( - button.semanticAction?.intent === AACSemanticIntent.DELETE_CHARACTER - ) { - // Create delete character action - actions.push({ - id: `grid-action-delete-char-${button.id}`, - modelName: "GridActionCollectElement", - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - action: "COLLECT_ACTION_REMOVE_CHAR", - }); - } else if ( - button.semanticAction?.intent === AACSemanticIntent.CLEAR_TEXT - ) { - // Create clear text action + const gridElements: GridElement[] = filteredButtons.map((button, index) => { + // Use grid position if available, otherwise arrange in rows of 4 + const gridWidth = 4; + const position = buttonPositions.get(button.id); + const calculatedX = position ? position.x : index % gridWidth; + const calculatedY = position ? position.y : Math.floor(index / gridWidth); + const actions: GridAction[] = []; + + // Add appropriate actions - prefer semantic actions + if (button.semanticAction?.platformData?.astericsGrid) { + // Use original AstericsGrid action data + const astericsData = button.semanticAction.platformData.astericsGrid; + actions.push({ + id: `grid-action-${button.id}`, + ...astericsData.properties, + modelName: astericsData.modelName, + modelVersion: + astericsData.properties.modelVersion || '{"major": 5, "minor": 0, "patch": 0}', + }); + } else if (button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO) { + // Create navigation action from semantic data + const targetId = button.semanticAction.targetId || button.targetPageId; + actions.push({ + id: `grid-action-navigate-${button.id}`, + modelName: 'GridActionNavigate', + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + navType: 'navigateToGrid', + toGridId: targetId, + }); + } else if (button.semanticAction?.intent === AACSemanticIntent.GO_BACK) { + // Create back navigation action + actions.push({ + id: `grid-action-navigate-back-${button.id}`, + modelName: 'GridActionNavigate', + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + navType: 'TO_LAST', + }); + } else if (button.semanticAction?.intent === AACSemanticIntent.GO_HOME) { + // Create home navigation action + actions.push({ + id: `grid-action-navigate-home-${button.id}`, + modelName: 'GridActionNavigate', + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + navType: 'TO_HOME', + }); + } else if (button.semanticAction?.intent === AACSemanticIntent.DELETE_WORD) { + // Create delete word action + actions.push({ + id: `grid-action-delete-word-${button.id}`, + modelName: 'GridActionCollectElement', + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + action: 'COLLECT_ACTION_REMOVE_WORD', + }); + } else if (button.semanticAction?.intent === AACSemanticIntent.DELETE_CHARACTER) { + // Create delete character action + actions.push({ + id: `grid-action-delete-char-${button.id}`, + modelName: 'GridActionCollectElement', + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + action: 'COLLECT_ACTION_REMOVE_CHAR', + }); + } else if (button.semanticAction?.intent === AACSemanticIntent.CLEAR_TEXT) { + // Create clear text action + actions.push({ + id: `grid-action-clear-${button.id}`, + modelName: 'GridActionCollectElement', + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + action: 'COLLECT_ACTION_CLEAR', + }); + } else if (button.semanticAction?.intent === AACSemanticIntent.SPEAK_TEXT) { + // Create speak action from semantic data + if (button.semanticAction.text && button.semanticAction.text !== button.label) { actions.push({ - id: `grid-action-clear-${button.id}`, - modelName: "GridActionCollectElement", + id: `grid-action-speak-${button.id}`, + modelName: 'GridActionSpeakCustom', modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - action: "COLLECT_ACTION_CLEAR", + speakText: { en: button.semanticAction.text }, }); - } else if ( - button.semanticAction?.intent === AACSemanticIntent.SPEAK_TEXT - ) { - // Create speak action from semantic data - if ( - button.semanticAction.text && - button.semanticAction.text !== button.label - ) { - actions.push({ - id: `grid-action-speak-${button.id}`, - modelName: "GridActionSpeakCustom", - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - speakText: { en: button.semanticAction.text }, - }); - } else { - actions.push({ - id: `grid-action-speak-${button.id}`, - modelName: "GridActionSpeak", - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - }); - } } else { - // Default to speak action if no semantic action actions.push({ id: `grid-action-speak-${button.id}`, - modelName: "GridActionSpeak", + modelName: 'GridActionSpeak', modelVersion: '{"major": 5, "minor": 0, "patch": 0}', }); } + } else { + // Default to speak action if no semantic action + actions.push({ + id: `grid-action-speak-${button.id}`, + modelName: 'GridActionSpeak', + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + }); + } - // Add audio action if present - if (button.audioRecording && button.audioRecording.data) { - const metadata = JSON.parse(button.audioRecording.metadata || "{}"); - actions.push({ - id: - button.audioRecording.id?.toString() || - `grid-action-audio-${button.id}`, - modelName: "GridActionAudio", - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - dataBase64: encodeBase64(button.audioRecording.data), - mimeType: metadata.mimeType || "audio/wav", - durationMs: metadata.durationMs || 0, - filename: - button.audioRecording.identifier || `audio-${button.id}`, - }); - } + // Add audio action if present + if (button.audioRecording && button.audioRecording.data) { + const metadata = JSON.parse(button.audioRecording.metadata || '{}'); + actions.push({ + id: button.audioRecording.id?.toString() || `grid-action-audio-${button.id}`, + modelName: 'GridActionAudio', + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + dataBase64: encodeBase64(button.audioRecording.data), + mimeType: metadata.mimeType || 'audio/wav', + durationMs: metadata.durationMs || 0, + filename: button.audioRecording.identifier || `audio-${button.id}`, + }); + } - const locale = tree.metadata?.locale || "en"; + const locale = tree.metadata?.locale || 'en'; - if ( - button.parameters?.wordFormActions && - Array.isArray(button.parameters.wordFormActions) - ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - actions.push(...button.parameters.wordFormActions); - } + if ( + button.parameters?.wordFormActions && + Array.isArray(button.parameters.wordFormActions) + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + actions.push(...button.parameters.wordFormActions); + } - return { - id: button.id, - modelName: "GridElement", - modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - width: 1, - height: 1, - x: calculatedX, - y: calculatedY, - label: { [locale]: button.label }, - wordForms: button.wordForms || [], - image: { - data: null, - author: undefined, - authorURL: undefined, - }, - actions: actions, - type: "ELEMENT_TYPE_NORMAL", - additionalProps: {}, - backgroundColor: - button.style?.backgroundColor || - page.style?.backgroundColor || - defaultPageStyle.backgroundColor, - }; - }, - ); + return { + id: button.id, + modelName: 'GridElement', + modelVersion: '{"major": 5, "minor": 0, "patch": 0}', + width: 1, + height: 1, + x: calculatedX, + y: calculatedY, + label: { [locale]: button.label }, + wordForms: button.wordForms || [], + image: { + data: null, + author: undefined, + authorURL: undefined, + }, + actions: actions, + type: 'ELEMENT_TYPE_NORMAL', + additionalProps: {}, + backgroundColor: + button.style?.backgroundColor || + page.style?.backgroundColor || + defaultPageStyle.backgroundColor, + }; + }); // Calculate grid dimensions based on button count const gridWidth = 4; @@ -1770,9 +1631,9 @@ class AstericsGridProcessor extends BaseProcessor { return { id: page.id, - modelName: "GridData", + modelName: 'GridData', modelVersion: '{"major": 5, "minor": 0, "patch": 0}', - label: { [tree.metadata?.locale || "en"]: page.name }, + label: { [tree.metadata?.locale || 'en']: page.name }, rowCount: calculatedRows, minColumnCount: calculatedCols, gridElements: gridElements, @@ -1780,8 +1641,7 @@ class AstericsGridProcessor extends BaseProcessor { }); // Determine the home grid ID from tree.rootId, fallback to first grid - const homeGridId = - tree.rootId || (grids.length > 0 ? grids[0].id : undefined); + const homeGridId = tree.rootId || (grids.length > 0 ? grids[0].id : undefined); const grdFile: AstericsGridFile = { grids: grids, @@ -1798,11 +1658,11 @@ class AstericsGridProcessor extends BaseProcessor { // Add additional properties that might be useful elementMargin: 2, // Default margin borderRadius: 4, // Default border radius - colorMode: "default", + colorMode: 'default', lineHeight: 1.2, maxLines: 2, - textPosition: "center", - fittingMode: "fit", + textPosition: 'center', + fittingMode: 'fit', }, }, }; @@ -1817,7 +1677,7 @@ class AstericsGridProcessor extends BaseProcessor { filePath: string, elementId: string, audioData: Buffer, - metadata?: string, + metadata?: string ): Promise { const { readTextFromInput, writeTextToPath } = this.options.fileAdapter; let content = await readTextFromInput(filePath); @@ -1837,17 +1697,15 @@ class AstericsGridProcessor extends BaseProcessor { elementFound = true; // Remove existing audio action if present - element.actions = element.actions.filter( - (a) => a.modelName !== "GridActionAudio", - ); + element.actions = element.actions.filter((a) => a.modelName !== 'GridActionAudio'); // Add new audio action const audioAction: GridAction = { id: `grid-action-audio-${elementId}`, - modelName: "GridActionAudio", + modelName: 'GridActionAudio', modelVersion: '{"major": 5, "minor": 0, "patch": 0}', dataBase64: encodeBase64(audioData), - mimeType: "audio/wav", + mimeType: 'audio/wav', durationMs: 0, // Could be calculated from audio data filename: `audio-${elementId}.wav`, }; @@ -1855,12 +1713,9 @@ class AstericsGridProcessor extends BaseProcessor { if (metadata) { try { const parsedMetadata = JSON.parse(metadata); - audioAction.mimeType = - parsedMetadata.mimeType || audioAction.mimeType; - audioAction.durationMs = - parsedMetadata.durationMs || audioAction.durationMs; - audioAction.filename = - parsedMetadata.filename || audioAction.filename; + audioAction.mimeType = parsedMetadata.mimeType || audioAction.mimeType; + audioAction.durationMs = parsedMetadata.durationMs || audioAction.durationMs; + audioAction.filename = parsedMetadata.filename || audioAction.filename; } catch (_e) { // Use defaults if metadata parsing fails } @@ -1885,14 +1740,11 @@ class AstericsGridProcessor extends BaseProcessor { async createAudioEnhancedGridFile( sourceFilePath: string, targetFilePath: string, - audioMappings: Map, + audioMappings: Map ): Promise { const { writeBinaryToPath, readBinaryFromInput } = this.options.fileAdapter; // Copy the source file to target - await writeBinaryToPath( - targetFilePath, - await readBinaryFromInput(sourceFilePath), - ); + await writeBinaryToPath(targetFilePath, await readBinaryFromInput(sourceFilePath)); // Add audio recordings to the copy await Promise.all( @@ -1903,13 +1755,13 @@ class AstericsGridProcessor extends BaseProcessor { targetFilePath, elementId, audioInfo.audioData as Buffer, - audioInfo.metadata as string, + audioInfo.metadata as string ); } catch (error) { // Failed to add audio to element - continue with others console.warn(`Failed to add audio to element ${elementId}:`, error); } - }), + }) ); } @@ -1945,10 +1797,7 @@ class AstericsGridProcessor extends BaseProcessor { /** * Check if an element has audio recording */ - async hasAudioRecording( - filePathOrBuffer: ProcessorInput, - elementId: string, - ): Promise { + async hasAudioRecording(filePathOrBuffer: ProcessorInput, elementId: string): Promise { const { readTextFromInput } = this.options.fileAdapter; let content = await readTextFromInput(filePathOrBuffer); @@ -1963,9 +1812,7 @@ class AstericsGridProcessor extends BaseProcessor { for (const grid of grdFile.grids) { for (const element of grid.gridElements) { if (element.id === elementId) { - return element.actions.some( - (action) => action.modelName === "GridActionAudio", - ); + return element.actions.some((action) => action.modelName === 'GridActionAudio'); } } } @@ -1991,13 +1838,9 @@ class AstericsGridProcessor extends BaseProcessor { generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { - return this.generateTranslatedDownloadGeneric( - filePath, - translatedStrings, - sourceStrings, - ); + return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); } } diff --git a/src/processors/dotProcessor.ts b/src/processors/dotProcessor.ts index f398a79..132c0e2 100644 --- a/src/processors/dotProcessor.ts +++ b/src/processors/dotProcessor.ts @@ -4,18 +4,13 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from "../core/baseProcessor"; -import { - AACTree, - AACPage, - AACButton, - AACSemanticIntent, -} from "../core/treeStructure"; +} from '../core/baseProcessor'; +import { AACTree, AACPage, AACButton, AACSemanticIntent } from '../core/treeStructure'; import { ValidationFailureError, buildValidationResultFromMessage, -} from "../validation/validationTypes"; -import { ProcessorInput, getBasename, encodeText } from "../utils/io"; +} from '../validation/validationTypes'; +import { ProcessorInput, getBasename, encodeText } from '../utils/io'; interface DotNode { id: string; @@ -40,8 +35,7 @@ class DotProcessor extends BaseProcessor { const edges: DotEdge[] = []; // Extract all edge statements using regex to handle single-line DOT files - const edgeRegex = - /"?([^"\s]+)"?\s*->\s*"?([^"\s]+)"?(?:\s*\[label="([^"]+)"\])?/g; + const edgeRegex = /"?([^"\s]+)"?\s*->\s*"?([^"\s]+)"?(?:\s*\[label="([^"]+)"\])?/g; // We need to find nodes, but avoid matching the target of an edge which might look like a node definition // e.g. A -> B [label="L"] -- "B [label="L"]" looks like a node def @@ -65,10 +59,7 @@ class DotProcessor extends BaseProcessor { // Mask this edge in the content so we don't match it as a node // We replace it with spaces to preserve indices if needed, but simple replacement is enough here - maskedContent = maskedContent.replace( - fullMatch, - " ".repeat(fullMatch.length), - ); + maskedContent = maskedContent.replace(fullMatch, ' '.repeat(fullMatch.length)); } // Now find explicit node definitions in the masked content @@ -82,7 +73,7 @@ class DotProcessor extends BaseProcessor { while ((nodeMatch = nodeRegex.exec(maskedContent)) !== null) { const [, id, rawLabel] = nodeMatch; // Unescape the label: replace \" with " and \\ with \ - const label = rawLabel.replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + const label = rawLabel.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); // Only update if not already defined or if we want to override the implicit label nodes.set(id, { id, label }); } @@ -117,9 +108,7 @@ class DotProcessor extends BaseProcessor { const { readBinaryFromInput, readTextFromInput } = this.options.fileAdapter; const filename = - typeof filePathOrBuffer === "string" - ? getBasename(filePathOrBuffer) - : "upload.dot"; + typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.dot'; const buffer = await readBinaryFromInput(filePathOrBuffer); const filesize = buffer.byteLength; @@ -130,38 +119,34 @@ class DotProcessor extends BaseProcessor { const validation = buildValidationResultFromMessage({ filename, filesize, - format: "dot", - message: "DOT file is empty", - type: "content", - description: "DOT file content", + format: 'dot', + message: 'DOT file is empty', + type: 'content', + description: 'DOT file content', }); - throw new ValidationFailureError("Empty DOT content", validation); + throw new ValidationFailureError('Empty DOT content', validation); } // Check for binary data (contains null bytes or non-printable characters) const head = content.substring(0, 100); for (let i = 0; i < head.length; i++) { const code = head.charCodeAt(i); - if ( - code === 0 || - (code >= 0 && code <= 8) || - (code >= 14 && code <= 31) - ) { + if (code === 0 || (code >= 0 && code <= 8) || (code >= 14 && code <= 31)) { const validation = buildValidationResultFromMessage({ filename, filesize, - format: "dot", - message: "DOT appears to be binary data", - type: "content", - description: "DOT file content", + format: 'dot', + message: 'DOT appears to be binary data', + type: 'content', + description: 'DOT file content', }); - throw new ValidationFailureError("Invalid DOT content", validation); + throw new ValidationFailureError('Invalid DOT content', validation); } } const { nodes, edges } = this.parseDotFile(content); const tree = new AACTree(); - tree.metadata.format = "dot"; + tree.metadata.format = 'dot'; // Create pages for each node and add a self button representing the node label for (const node of nodes) { @@ -183,9 +168,9 @@ class DotProcessor extends BaseProcessor { semanticAction: { intent: AACSemanticIntent.SPEAK_TEXT, text: node.label, - fallback: { type: "SPEAK", message: node.label }, + fallback: { type: 'SPEAK', message: node.label }, }, - }), + }) ); } @@ -196,7 +181,7 @@ class DotProcessor extends BaseProcessor { const button = new AACButton({ id: `nav_${edge.from}_${edge.to}`, label: edge.label || edge.to, - message: "", + message: '', targetPageId: edge.to, }); @@ -213,23 +198,19 @@ class DotProcessor extends BaseProcessor { const validation = buildValidationResultFromMessage({ filename, filesize, - format: "dot", - message: error?.message || "Failed to parse DOT file", - type: "parse", - description: "Parse DOT graph", + format: 'dot', + message: error?.message || 'Failed to parse DOT file', + type: 'parse', + description: 'Parse DOT graph', }); - throw new ValidationFailureError( - "Failed to load DOT file", - validation, - error, - ); + throw new ValidationFailureError('Failed to load DOT file', validation, error); } } async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string, + outputPath: string ): Promise { const { readTextFromInput, writeBinaryToPath } = this.options.fileAdapter; @@ -237,19 +218,19 @@ class DotProcessor extends BaseProcessor { let translatedContent = content; translations.forEach((translation, text) => { - if (typeof text === "string" && typeof translation === "string") { + if (typeof text === 'string' && typeof translation === 'string') { // Escape special regex characters in the text - const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const escapedTranslation = translation.replace(/\$/g, "$$$$"); // Escape $ in replacement + const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedTranslation = translation.replace(/\$/g, '$$$$'); // Escape $ in replacement translatedContent = translatedContent.replace( - new RegExp(`label="${escapedText}"`, "g"), - `label="${escapedTranslation}"`, + new RegExp(`label="${escapedText}"`, 'g'), + `label="${escapedTranslation}"` ); } }); - const resultBuffer = encodeText(translatedContent || ""); + const resultBuffer = encodeText(translatedContent || ''); await writeBinaryToPath(outputPath, resultBuffer); return resultBuffer; } @@ -257,11 +238,11 @@ class DotProcessor extends BaseProcessor { async saveFromTree(tree: AACTree, _outputPath: string): Promise { const { writeTextToPath } = this.options.fileAdapter; - let dotContent = `digraph "${tree.metadata?.name || "AACBoard"}" {\n`; + let dotContent = `digraph "${tree.metadata?.name || 'AACBoard'}" {\n`; // Helper to escape DOT string const escapeDotString = (str: string): string => { - return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }; if (tree.metadata?.name) { @@ -281,9 +262,7 @@ class DotProcessor extends BaseProcessor { .filter((btn: AACButton) => { const intentStr = String(btn.semanticAction?.intent); return ( - intentStr === "NAVIGATE_TO" || - !!btn.targetPageId || - !!btn.semanticAction?.targetId + intentStr === 'NAVIGATE_TO' || !!btn.targetPageId || !!btn.semanticAction?.targetId ); }) .forEach((btn: AACButton) => { @@ -294,7 +273,7 @@ class DotProcessor extends BaseProcessor { }); } - dotContent += "}\n"; + dotContent += '}\n'; await writeTextToPath(_outputPath, dotContent); } @@ -302,9 +281,7 @@ class DotProcessor extends BaseProcessor { * Extract strings with metadata for aac-tools-platform compatibility * Uses the generic implementation from BaseProcessor */ - async extractStringsWithMetadata( - filePath: string, - ): Promise { + async extractStringsWithMetadata(filePath: string): Promise { return this.extractStringsWithMetadataGeneric(filePath); } @@ -315,13 +292,9 @@ class DotProcessor extends BaseProcessor { async generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { - return this.generateTranslatedDownloadGeneric( - filePath, - translatedStrings, - sourceStrings, - ); + return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); } } diff --git a/src/processors/excelProcessor.ts b/src/processors/excelProcessor.ts index e328900..923ee74 100644 --- a/src/processors/excelProcessor.ts +++ b/src/processors/excelProcessor.ts @@ -1,18 +1,13 @@ -import { ProcessorInput } from "../utils/io"; -import * as ExcelJS from "exceljs"; +import { ProcessorInput } from '../utils/io'; +import * as ExcelJS from 'exceljs'; import { BaseProcessor, ExtractStringsResult, TranslatedString, SourceString, -} from "../core/baseProcessor"; -import { - AACTree, - AACPage, - AACButton, - AACSemanticIntent, -} from "../core/treeStructure"; -import { AACStyle } from "../types/aac"; +} from '../core/baseProcessor'; +import { AACTree, AACPage, AACButton, AACSemanticIntent } from '../core/treeStructure'; +import { AACStyle } from '../types/aac'; /** * Excel Processor for converting AAC grids to Excel format @@ -20,13 +15,7 @@ import { AACStyle } from "../types/aac"; * Supports visual styling, navigation links, and vocabulary analysis workflows */ export class ExcelProcessor extends BaseProcessor { - private static readonly NAVIGATION_BUTTONS = [ - "Home", - "Message Bar", - "Delete", - "Back", - "Clear", - ]; + private static readonly NAVIGATION_BUTTONS = ['Home', 'Message Bar', 'Delete', 'Back', 'Clear']; /** * Extract all text content from an Excel file @@ -34,7 +23,7 @@ export class ExcelProcessor extends BaseProcessor { * @returns Promise resolving to all text content found in the Excel file */ async extractTexts(_filePathOrBuffer: ProcessorInput): Promise { - console.warn("ExcelProcessor.extractTexts is not implemented yet."); + console.warn('ExcelProcessor.extractTexts is not implemented yet.'); return Promise.resolve([]); } @@ -44,9 +33,9 @@ export class ExcelProcessor extends BaseProcessor { * @returns Promise resolving to an AACTree representation of the Excel file */ async loadIntoTree(_filePathOrBuffer: ProcessorInput): Promise { - console.warn("ExcelProcessor.loadIntoTree is not implemented yet."); + console.warn('ExcelProcessor.loadIntoTree is not implemented yet.'); const tree = new AACTree(); - tree.metadata.format = "excel"; + tree.metadata.format = 'excel'; return Promise.resolve(tree); } @@ -60,10 +49,10 @@ export class ExcelProcessor extends BaseProcessor { async processTexts( _filePathOrBuffer: ProcessorInput, _translations: Map, - outputPath: string, + outputPath: string ): Promise { const { dirname, pathExists, mkDir } = this.options.fileAdapter; - console.warn("ExcelProcessor.processTexts is not implemented yet."); + console.warn('ExcelProcessor.processTexts is not implemented yet.'); const outputDir = dirname(outputPath); if (!(await pathExists(outputDir))) { await mkDir(outputDir, { recursive: true }); @@ -82,13 +71,10 @@ export class ExcelProcessor extends BaseProcessor { workbook: ExcelJS.Workbook, page: AACPage, tree: AACTree, - usedNames: Set = new Set(), + usedNames: Set = new Set() ): void { // Create worksheet with page name (sanitized for Excel and unique) - const worksheetName = this.getUniqueWorksheetName( - page.name || page.id, - usedNames, - ); + const worksheetName = this.getUniqueWorksheetName(page.name || page.id, usedNames); const worksheet = workbook.addWorksheet(worksheetName); // Determine grid dimensions @@ -152,7 +138,7 @@ export class ExcelProcessor extends BaseProcessor { private convertGridLayout( worksheet: ExcelJS.Worksheet, grid: Array>, - startRow: number, + startRow: number ): void { for (let row = 0; row < grid.length; row++) { for (let col = 0; col < grid[row].length; col++) { @@ -179,7 +165,7 @@ export class ExcelProcessor extends BaseProcessor { buttons: AACButton[], rows: number, cols: number, - startRow: number, + startRow: number ): void { for (let i = 0; i < buttons.length; i++) { const button = buttons[i]; @@ -205,12 +191,12 @@ export class ExcelProcessor extends BaseProcessor { worksheet: ExcelJS.Worksheet, button: AACButton, row: number, - col: number, + col: number ): void { const cell = worksheet.getCell(row, col); // Set cell value to button label - cell.value = button.label || ""; + cell.value = button.label || ''; // Add button message as cell comment if different from label if (button.message && button.message !== button.label) { @@ -223,10 +209,7 @@ export class ExcelProcessor extends BaseProcessor { } // Add navigation link if this is a navigation button - if ( - button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && - button.targetPageId - ) { + if (button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && button.targetPageId) { this.addNavigationLink(cell, button.targetPageId); } @@ -247,8 +230,8 @@ export class ExcelProcessor extends BaseProcessor { // Background color if (style.backgroundColor) { fill = { - type: "pattern", - pattern: "solid", + type: 'pattern', + pattern: 'solid', fgColor: { argb: this.convertColorToArgb(style.backgroundColor) }, }; } @@ -269,12 +252,12 @@ export class ExcelProcessor extends BaseProcessor { } // Font weight - if (style.fontWeight === "bold") { + if (style.fontWeight === 'bold') { font.bold = true; } // Font style - if (style.fontStyle === "italic") { + if (style.fontStyle === 'italic') { font.italic = true; } @@ -284,12 +267,12 @@ export class ExcelProcessor extends BaseProcessor { } // Border - if (style.borderColor || typeof style.borderWidth === "number") { + if (style.borderColor || typeof style.borderWidth === 'number') { const borderWidth = style.borderWidth ?? 1; - const borderStyle = borderWidth > 1 ? "thick" : "thin"; + const borderStyle = borderWidth > 1 ? 'thick' : 'thin'; const borderColor = style.borderColor ? { argb: this.convertColorToArgb(style.borderColor) } - : { argb: "FF000000" }; // Default black + : { argb: 'FF000000' }; // Default black border = { top: { style: borderStyle, color: borderColor }, @@ -312,8 +295,8 @@ export class ExcelProcessor extends BaseProcessor { // Center align text cell.alignment = { - vertical: "middle", - horizontal: "center", + vertical: 'middle', + horizontal: 'center', wrapText: true, }; } @@ -324,16 +307,16 @@ export class ExcelProcessor extends BaseProcessor { * @returns ARGB color string */ private convertColorToArgb(color?: string): string { - if (!color) return "FFFFFFFF"; // Default white + if (!color) return 'FFFFFFFF'; // Default white // Remove any whitespace color = color.trim(); // If already in hex format - if (color.startsWith("#")) { + if (color.startsWith('#')) { const hex = color.substring(1); if (hex.length === 6) { - return "FF" + hex.toUpperCase(); // Add alpha channel + return 'FF' + hex.toUpperCase(); // Add alpha channel } else if (hex.length === 8) { return hex.toUpperCase(); // Already has alpha } @@ -342,30 +325,26 @@ export class ExcelProcessor extends BaseProcessor { // Handle rgb() format const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (rgbMatch) { - const r = parseInt(rgbMatch[1]).toString(16).padStart(2, "0"); - const g = parseInt(rgbMatch[2]).toString(16).padStart(2, "0"); - const b = parseInt(rgbMatch[3]).toString(16).padStart(2, "0"); - return "FF" + r.toUpperCase() + g.toUpperCase() + b.toUpperCase(); + const r = parseInt(rgbMatch[1]).toString(16).padStart(2, '0'); + const g = parseInt(rgbMatch[2]).toString(16).padStart(2, '0'); + const b = parseInt(rgbMatch[3]).toString(16).padStart(2, '0'); + return 'FF' + r.toUpperCase() + g.toUpperCase() + b.toUpperCase(); } // Handle rgba() format - const rgbaMatch = color.match( - /rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/, - ); + const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/); if (rgbaMatch) { - const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, "0"); - const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, "0"); - const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, "0"); + const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, '0'); + const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, '0'); + const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, '0'); const a = Math.round(parseFloat(rgbaMatch[4]) * 255) .toString(16) - .padStart(2, "0"); - return ( - a.toUpperCase() + r.toUpperCase() + g.toUpperCase() + b.toUpperCase() - ); + .padStart(2, '0'); + return a.toUpperCase() + r.toUpperCase() + g.toUpperCase() + b.toUpperCase(); } // Default fallback - return "FFFFFFFF"; + return 'FFFFFFFF'; } /** @@ -378,11 +357,11 @@ export class ExcelProcessor extends BaseProcessor { const sanitizedTargetName = this.sanitizeWorksheetName(targetPageId); cell.value = { text: - typeof cell.value === "string" + typeof cell.value === 'string' ? cell.value - : typeof cell.value === "number" || typeof cell.value === "boolean" + : typeof cell.value === 'number' || typeof cell.value === 'boolean' ? String(cell.value) - : "", + : '', hyperlink: `#'${sanitizedTargetName}'!A1`, }; } @@ -393,11 +372,7 @@ export class ExcelProcessor extends BaseProcessor { * @param row - Row number * @param col - Column number */ - private setCellSize( - worksheet: ExcelJS.Worksheet, - row: number, - col: number, - ): void { + private setCellSize(worksheet: ExcelJS.Worksheet, row: number, col: number): void { // Set column width (approximately 15 characters wide) const column = worksheet.getColumn(col); if (!column.width || column.width < 15) { @@ -417,11 +392,7 @@ export class ExcelProcessor extends BaseProcessor { * @param page - Current AAC page * @param tree - Full AAC tree for navigation context */ - private addNavigationRow( - worksheet: ExcelJS.Worksheet, - page: AACPage, - tree: AACTree, - ): void { + private addNavigationRow(worksheet: ExcelJS.Worksheet, page: AACPage, tree: AACTree): void { const navButtons = ExcelProcessor.NAVIGATION_BUTTONS; for (let i = 0; i < navButtons.length; i++) { @@ -430,32 +401,32 @@ export class ExcelProcessor extends BaseProcessor { // Style navigation buttons differently cell.fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFE0E0E0" }, // Light gray background + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' }, // Light gray background }; cell.font = { bold: true, - color: { argb: "FF000000" }, // Black text + color: { argb: 'FF000000' }, // Black text }; cell.border = { - top: { style: "thin", color: { argb: "FF000000" } }, - left: { style: "thin", color: { argb: "FF000000" } }, - bottom: { style: "thin", color: { argb: "FF000000" } }, - right: { style: "thin", color: { argb: "FF000000" } }, + top: { style: 'thin', color: { argb: 'FF000000' } }, + left: { style: 'thin', color: { argb: 'FF000000' } }, + bottom: { style: 'thin', color: { argb: 'FF000000' } }, + right: { style: 'thin', color: { argb: 'FF000000' } }, }; cell.alignment = { - vertical: "middle", - horizontal: "center", + vertical: 'middle', + horizontal: 'center', }; // Add navigation functionality for specific buttons - if (navButtons[i] === "Home" && tree.rootId) { + if (navButtons[i] === 'Home' && tree.rootId) { this.addNavigationLink(cell, tree.rootId); - } else if (navButtons[i] === "Back" && page.parentId) { + } else if (navButtons[i] === 'Back' && page.parentId) { this.addNavigationLink(cell, page.parentId); } } @@ -472,7 +443,7 @@ export class ExcelProcessor extends BaseProcessor { worksheet: ExcelJS.Worksheet, rows: number, cols: number, - startRow: number, + startRow: number ): void { // Set default column widths for (let col = 1; col <= cols; col++) { @@ -492,7 +463,7 @@ export class ExcelProcessor extends BaseProcessor { // Freeze navigation row if present if (startRow > 1) { - worksheet.views = [{ state: "frozen", ySplit: 1 }]; + worksheet.views = [{ state: 'frozen', ySplit: 1 }]; } } @@ -506,12 +477,12 @@ export class ExcelProcessor extends BaseProcessor { // - Max 31 characters // - Cannot contain: \ / ? * [ ] : // - Cannot be empty - let cleaned = (name || "").replace(/[\\/?*:]/g, "_"); - cleaned = cleaned.replace(/\[/g, "_").replace(/\]/g, "_"); + let cleaned = (name || '').replace(/[\\/?*:]/g, '_'); + cleaned = cleaned.replace(/\[/g, '_').replace(/\]/g, '_'); cleaned = cleaned.substring(0, 31); if (cleaned.length === 0) { - return "Sheet1"; + return 'Sheet1'; } return cleaned; @@ -577,16 +548,13 @@ export class ExcelProcessor extends BaseProcessor { await this.saveFromTreeAsync(tree, outputPath); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - console.error("Failed to save Excel file:", message); + console.error('Failed to save Excel file:', message); try { - const fallbackPath = outputPath.replace(/\.xlsx$/i, "_error.txt"); + const fallbackPath = outputPath.replace(/\.xlsx$/i, '_error.txt'); await mkDir(dirname(fallbackPath), { recursive: true }); - await writeTextToPath( - fallbackPath, - `Error saving Excel file: ${message}`, - ); + await writeTextToPath(fallbackPath, `Error saving Excel file: ${message}`); } catch (writeError) { - console.error("Failed to write Excel error file:", writeError); + console.error('Failed to write Excel error file:', writeError); } } } @@ -594,25 +562,22 @@ export class ExcelProcessor extends BaseProcessor { /** * Async version of saveFromTree for internal use */ - private async saveFromTreeAsync( - tree: AACTree, - outputPath: string, - ): Promise { + private async saveFromTreeAsync(tree: AACTree, outputPath: string): Promise { const workbook = new ExcelJS.Workbook(); const metadata = tree.metadata; // Set workbook properties from tree metadata - workbook.creator = metadata?.author || "AACProcessors"; - workbook.lastModifiedBy = "AACProcessors"; + workbook.creator = metadata?.author || 'AACProcessors'; + workbook.lastModifiedBy = 'AACProcessors'; workbook.created = new Date(); workbook.modified = new Date(); - workbook.title = metadata?.name || ""; - workbook.subject = metadata?.description || ""; + workbook.title = metadata?.name || ''; + workbook.subject = metadata?.description || ''; // If no pages, create a default empty worksheet if (Object.keys(tree.pages).length === 0) { - const worksheet = workbook.addWorksheet("Empty"); - worksheet.getCell("A1").value = "No AAC pages found"; + const worksheet = workbook.addWorksheet('Empty'); + worksheet.getCell('A1').value = 'No AAC pages found'; await workbook.xlsx.writeFile(outputPath); return; } @@ -645,12 +610,8 @@ export class ExcelProcessor extends BaseProcessor { generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { - return this.generateTranslatedDownloadGeneric( - filePath, - translatedStrings, - sourceStrings, - ); + return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); } } diff --git a/src/processors/gridset/cellHelpers.ts b/src/processors/gridset/cellHelpers.ts index 41f35ff..c784867 100644 --- a/src/processors/gridset/cellHelpers.ts +++ b/src/processors/gridset/cellHelpers.ts @@ -5,7 +5,7 @@ * and calculating cell spans in grid layouts. */ -import type { AACPage, AACButton } from "../../core/treeStructure"; +import type { AACPage, AACButton } from '../../core/treeStructure'; /** * Cell position with span information @@ -39,7 +39,7 @@ export interface CellPosition { export function findButtonPosition( page: AACPage, button: AACButton, - fallbackIndex: number, + fallbackIndex: number ): CellPosition { if (page.grid && page.grid.length > 0) { // Search for button in grid layout and calculate span @@ -78,8 +78,7 @@ export function findButtonPosition( } // Fallback positioning - const gridCols = - page.grid?.[0]?.length || Math.ceil(Math.sqrt(page.buttons.length)); + const gridCols = page.grid?.[0]?.length || Math.ceil(Math.sqrt(page.buttons.length)); return { x: fallbackIndex % gridCols, y: Math.floor(fallbackIndex / gridCols), @@ -118,6 +117,6 @@ export function cellPositionKey(x: number, y: number): string { * console.log(pos); // { x: 5, y: 3 } */ export function parseCellPositionKey(key: string): { x: number; y: number } { - const [x, y] = key.split(",").map(Number); + const [x, y] = key.split(',').map(Number); return { x, y }; } diff --git a/src/processors/gridset/colorUtils.ts b/src/processors/gridset/colorUtils.ts index 53b7863..4f59fee 100644 --- a/src/processors/gridset/colorUtils.ts +++ b/src/processors/gridset/colorUtils.ts @@ -168,9 +168,7 @@ const CSS_COLORS: Record = { * @param name - CSS color name (case-insensitive) * @returns RGB tuple [r, g, b] or undefined if not found */ -export function getNamedColor( - name: string, -): [number, number, number] | undefined { +export function getNamedColor(name: string): [number, number, number] | undefined { const color = CSS_COLORS[name.toLowerCase()]; return color; } @@ -198,7 +196,7 @@ export function rgbaToHex(r: number, g: number, b: number, a: number): string { */ export function channelToHex(value: number): string { const clamped = Math.max(0, Math.min(255, Math.round(value))); - return clamped.toString(16).padStart(2, "0").toUpperCase(); + return clamped.toString(16).padStart(2, '0').toUpperCase(); } /** @@ -233,16 +231,14 @@ export function clampAlpha(value: number): number { */ export function toHexColor(value: string): string | undefined { // Try hex format - const hexMatch = value.match( - /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i, - ); + const hexMatch = value.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i); if (hexMatch) { const hex = hexMatch[1]; if (hex.length === 3 || hex.length === 4) { return `#${hex - .split("") + .split('') .map((char) => char + char) - .join("")}`; + .join('')}`; } return `#${hex}`; } @@ -251,7 +247,7 @@ export function toHexColor(value: string): string | undefined { const rgbMatch = value.match(/^rgba?\((.+)\)$/i); if (rgbMatch) { const parts = rgbMatch[1] - .split(",") + .split(',') .map((part) => part.trim()) .filter(Boolean); if (parts.length === 3 || parts.length === 4) { @@ -282,7 +278,7 @@ export function toHexColor(value: string): string | undefined { export function darkenColor(hex: string, amount: number): string { const normalized = ensureAlphaChannel(hex).substring(1); // strip # const rgb = normalized.substring(0, 6); - const alpha = normalized.substring(6) || "FF"; + const alpha = normalized.substring(6) || 'FF'; const r = parseInt(rgb.substring(0, 2), 16); const g = parseInt(rgb.substring(2, 4), 16); const b = parseInt(rgb.substring(4, 6), 16); @@ -302,7 +298,7 @@ export function darkenColor(hex: string, amount: number): string { export function lightenColor(hex: string, amount: number): string { const normalized = ensureAlphaChannel(hex).substring(1); // strip # const rgb = normalized.substring(0, 6); - const alpha = normalized.substring(6) || "FF"; + const alpha = normalized.substring(6) || 'FF'; const r = parseInt(rgb.substring(0, 2), 16); const g = parseInt(rgb.substring(2, 4), 16); const b = parseInt(rgb.substring(4, 6), 16); @@ -326,7 +322,7 @@ export function hexToRgba(hex: string): { } { const normalized = ensureAlphaChannel(hex).substring(1); // strip # const rgb = normalized.substring(0, 6); - const alphaHex = normalized.substring(6) || "FF"; + const alphaHex = normalized.substring(6) || 'FF'; const r = parseInt(rgb.substring(0, 2), 16); const g = parseInt(rgb.substring(2, 4), 16); const b = parseInt(rgb.substring(4, 6), 16); @@ -340,10 +336,7 @@ export function hexToRgba(hex: string): { * @param fallback - Fallback color if input is invalid (default: white) * @returns Normalized color in format #AARRGGBBFF */ -export function normalizeColor( - input: string, - fallback: string = "#FFFFFFFF", -): string { +export function normalizeColor(input: string, fallback: string = '#FFFFFFFF'): string { const trimmed = input.trim(); if (!trimmed) { return fallback; @@ -363,11 +356,11 @@ export function normalizeColor( * @returns Color with alpha channel in format #AARRGGBBFF */ export function ensureAlphaChannel(color: string | undefined): string { - if (!color) return "#FFFFFFFF"; + if (!color) return '#FFFFFFFF'; // If already 8 digits (with alpha), return as is if (color.match(/^#[0-9A-Fa-f]{8}$/)) return color; // If 6 digits (no alpha), add FF for fully opaque - if (color.match(/^#[0-9A-Fa-f]{6}$/)) return color + "FF"; + if (color.match(/^#[0-9A-Fa-f]{6}$/)) return color + 'FF'; // If 3 digits (shorthand), expand to 8 if (color.match(/^#[0-9A-Fa-f]{3}$/)) { const r = color[1]; @@ -376,5 +369,5 @@ export function ensureAlphaChannel(color: string | undefined): string { return `#${r}${r}${g}${g}${b}${b}FF`; } // Invalid or unknown format, return white - return "#FFFFFFFF"; + return '#FFFFFFFF'; } diff --git a/src/processors/gridset/commands.ts b/src/processors/gridset/commands.ts index 77217cb..86e071d 100644 --- a/src/processors/gridset/commands.ts +++ b/src/processors/gridset/commands.ts @@ -16,23 +16,23 @@ * Command categories in Grid 3 */ export enum Grid3CommandCategory { - NAVIGATION = "navigation", - COMMUNICATION = "communication", - TEXT_EDITING = "text_editing", - COMPUTER_CONTROL = "computer_control", - WEB_BROWSER = "web_browser", - EMAIL = "email", - PHONE = "phone", - SMS = "sms", - SYSTEM = "system", - SETTINGS = "settings", - SPEECH = "speech", - AUTO_CONTENT = "auto_content", - ENVIRONMENT_CONTROL = "environment_control", - MOUSE = "mouse", - WINDOW = "window", - MEDIA = "media", - CUSTOM = "custom", + NAVIGATION = 'navigation', + COMMUNICATION = 'communication', + TEXT_EDITING = 'text_editing', + COMPUTER_CONTROL = 'computer_control', + WEB_BROWSER = 'web_browser', + EMAIL = 'email', + PHONE = 'phone', + SMS = 'sms', + SYSTEM = 'system', + SETTINGS = 'settings', + SPEECH = 'speech', + AUTO_CONTENT = 'auto_content', + ENVIRONMENT_CONTROL = 'environment_control', + MOUSE = 'mouse', + WINDOW = 'window', + MEDIA = 'media', + CUSTOM = 'custom', } /** @@ -40,7 +40,7 @@ export enum Grid3CommandCategory { */ export interface CommandParameter { key: string; - type: "string" | "number" | "boolean" | "grid" | "color" | "font"; + type: 'string' | 'number' | 'boolean' | 'grid' | 'color' | 'font'; required: boolean; description?: string; } @@ -55,7 +55,7 @@ export interface Grid3CommandDefinition { displayName: string; description: string; parameters?: CommandParameter[]; - platforms?: ("desktop" | "ios" | "medicare" | "medicareBionics")[]; + platforms?: ('desktop' | 'ios' | 'medicare' | 'medicareBionics')[]; deprecated?: boolean; } @@ -67,50 +67,50 @@ export const GRID3_COMMANDS: Record = { // ======================================== // NAVIGATION COMMANDS // ======================================== - "Jump.To": { - id: "Jump.To", + 'Jump.To': { + id: 'Jump.To', category: Grid3CommandCategory.NAVIGATION, - pluginId: "navigation", - displayName: "Jump To", - description: "Navigate to a specific grid", + pluginId: 'navigation', + displayName: 'Jump To', + description: 'Navigate to a specific grid', parameters: [ { - key: "grid", - type: "grid", + key: 'grid', + type: 'grid', required: true, - description: "Target grid name", + description: 'Target grid name', }, ], - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Jump.Back": { - id: "Jump.Back", + 'Jump.Back': { + id: 'Jump.Back', category: Grid3CommandCategory.NAVIGATION, - pluginId: "navigation", - displayName: "Jump Back", - description: "Navigate to the previous grid", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'navigation', + displayName: 'Jump Back', + description: 'Navigate to the previous grid', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Jump.Home": { - id: "Jump.Home", + 'Jump.Home': { + id: 'Jump.Home', category: Grid3CommandCategory.NAVIGATION, - pluginId: "navigation", - displayName: "Jump Home", - description: "Navigate to the home/start grid", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'navigation', + displayName: 'Jump Home', + description: 'Navigate to the home/start grid', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Jump.Favorite": { - id: "Jump.Favorite", + 'Jump.Favorite': { + id: 'Jump.Favorite', category: Grid3CommandCategory.NAVIGATION, - pluginId: "navigation", - displayName: "Jump To Favorite", - description: "Navigate to a favorite grid", + pluginId: 'navigation', + displayName: 'Jump To Favorite', + description: 'Navigate to a favorite grid', parameters: [ { - key: "favorite", - type: "number", + key: 'favorite', + type: 'number', required: true, - description: "Favorite slot number", + description: 'Favorite slot number', }, ], }, @@ -118,56 +118,56 @@ export const GRID3_COMMANDS: Record = { // ======================================== // COMMUNICATION COMMANDS // ======================================== - "Action.Speak": { - id: "Action.Speak", + 'Action.Speak': { + id: 'Action.Speak', category: Grid3CommandCategory.COMMUNICATION, - pluginId: "speech", - displayName: "Speak", - description: "Speak the current message bar contents", + pluginId: 'speech', + displayName: 'Speak', + description: 'Speak the current message bar contents', parameters: [ { - key: "unit", - type: "string", + key: 'unit', + type: 'string', required: false, - description: "Speaking unit (sentence/word/character)", + description: 'Speaking unit (sentence/word/character)', }, { - key: "movecaret", - type: "boolean", + key: 'movecaret', + type: 'boolean', required: false, - description: "Move caret after speaking", + description: 'Move caret after speaking', }, ], - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Action.InsertText": { - id: "Action.InsertText", + 'Action.InsertText': { + id: 'Action.InsertText', category: Grid3CommandCategory.COMMUNICATION, - pluginId: "core", - displayName: "Insert Text", - description: "Insert text into the message bar", + pluginId: 'core', + displayName: 'Insert Text', + description: 'Insert text into the message bar', parameters: [ { - key: "text", - type: "string", + key: 'text', + type: 'string', required: true, - description: "Text to insert", + description: 'Text to insert', }, ], - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Action.InsertTextAndSpeak": { - id: "Action.InsertTextAndSpeak", + 'Action.InsertTextAndSpeak': { + id: 'Action.InsertTextAndSpeak', category: Grid3CommandCategory.COMMUNICATION, - pluginId: "core", - displayName: "Insert Text and Speak", - description: "Insert text and speak immediately", + pluginId: 'core', + displayName: 'Insert Text and Speak', + description: 'Insert text and speak immediately', parameters: [ { - key: "text", - type: "string", + key: 'text', + type: 'string', required: true, - description: "Text to insert and speak", + description: 'Text to insert and speak', }, ], }, @@ -175,750 +175,748 @@ export const GRID3_COMMANDS: Record = { // ======================================== // TEXT EDITING COMMANDS // ======================================== - "Action.DeleteWord": { - id: "Action.DeleteWord", + 'Action.DeleteWord': { + id: 'Action.DeleteWord', category: Grid3CommandCategory.TEXT_EDITING, - pluginId: "core", - displayName: "Delete Word", - description: "Delete the last word in the message bar", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'core', + displayName: 'Delete Word', + description: 'Delete the last word in the message bar', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Action.DeleteLetter": { - id: "Action.DeleteLetter", + 'Action.DeleteLetter': { + id: 'Action.DeleteLetter', category: Grid3CommandCategory.TEXT_EDITING, - pluginId: "core", - displayName: "Delete Letter", - description: "Delete the last character in the message bar", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'core', + displayName: 'Delete Letter', + description: 'Delete the last character in the message bar', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Action.Clear": { - id: "Action.Clear", + 'Action.Clear': { + id: 'Action.Clear', category: Grid3CommandCategory.TEXT_EDITING, - pluginId: "core", - displayName: "Clear", - description: "Clear the message bar", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'core', + displayName: 'Clear', + description: 'Clear the message bar', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Action.Letter": { - id: "Action.Letter", + 'Action.Letter': { + id: 'Action.Letter', category: Grid3CommandCategory.TEXT_EDITING, - pluginId: "core", - displayName: "Insert Letter", - description: "Insert a single letter", + pluginId: 'core', + displayName: 'Insert Letter', + description: 'Insert a single letter', parameters: [ { - key: "letter", - type: "string", + key: 'letter', + type: 'string', required: true, - description: "Letter to insert", + description: 'Letter to insert', }, ], }, - "Action.Space": { - id: "Action.Space", + 'Action.Space': { + id: 'Action.Space', category: Grid3CommandCategory.TEXT_EDITING, - pluginId: "core", - displayName: "Space", - description: "Insert a space", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'core', + displayName: 'Space', + description: 'Insert a space', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Action.Backspace": { - id: "Action.Backspace", + 'Action.Backspace': { + id: 'Action.Backspace', category: Grid3CommandCategory.TEXT_EDITING, - pluginId: "core", - displayName: "Backspace", - description: "Delete character before cursor", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'core', + displayName: 'Backspace', + description: 'Delete character before cursor', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, // ======================================== // SPEECH COMMANDS // ======================================== - "Speech.ChangePublicVoice": { - id: "Speech.ChangePublicVoice", + 'Speech.ChangePublicVoice': { + id: 'Speech.ChangePublicVoice', category: Grid3CommandCategory.SPEECH, - pluginId: "speech", - displayName: "Change Voice", - description: "Change the public speaking voice", + pluginId: 'speech', + displayName: 'Change Voice', + description: 'Change the public speaking voice', parameters: [ { - key: "voice", - type: "string", + key: 'voice', + type: 'string', required: true, - description: "Voice name or ID", + description: 'Voice name or ID', }, ], }, - "Speech.ChangePublicSpeed": { - id: "Speech.ChangePublicSpeed", + 'Speech.ChangePublicSpeed': { + id: 'Speech.ChangePublicSpeed', category: Grid3CommandCategory.SPEECH, - pluginId: "speech", - displayName: "Change Speech Speed", - description: "Change the speaking speed", + pluginId: 'speech', + displayName: 'Change Speech Speed', + description: 'Change the speaking speed', parameters: [ { - key: "speed", - type: "number", + key: 'speed', + type: 'number', required: true, - description: "Speed percentage (50-200)", + description: 'Speed percentage (50-200)', }, ], }, - "Speech.ChangePublicPitch": { - id: "Speech.ChangePublicPitch", + 'Speech.ChangePublicPitch': { + id: 'Speech.ChangePublicPitch', category: Grid3CommandCategory.SPEECH, - pluginId: "speech", - displayName: "Change Speech Pitch", - description: "Change the voice pitch", + pluginId: 'speech', + displayName: 'Change Speech Pitch', + description: 'Change the voice pitch', parameters: [ { - key: "pitch", - type: "number", + key: 'pitch', + type: 'number', required: true, - description: "Pitch value", + description: 'Pitch value', }, ], }, - "Speech.ChangePublicVolume": { - id: "Speech.ChangePublicVolume", + 'Speech.ChangePublicVolume': { + id: 'Speech.ChangePublicVolume', category: Grid3CommandCategory.SPEECH, - pluginId: "speech", - displayName: "Change Speech Volume", - description: "Change the speech volume", + pluginId: 'speech', + displayName: 'Change Speech Volume', + description: 'Change the speech volume', parameters: [ { - key: "volume", - type: "number", + key: 'volume', + type: 'number', required: true, - description: "Volume percentage (0-100)", + description: 'Volume percentage (0-100)', }, ], }, - "Speech.SpeakNothing": { - id: "Action.SpeakNothing", + 'Speech.SpeakNothing': { + id: 'Action.SpeakNothing', category: Grid3CommandCategory.SPEECH, - pluginId: "speech", - displayName: "Speak Nothing", - description: "Speak without inserting text", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'speech', + displayName: 'Speak Nothing', + description: 'Speak without inserting text', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, // ======================================== // COMPUTER CONTROL COMMANDS // ======================================== - "ComputerControl.LeftClick": { - id: "ComputerControl.LeftClick", + 'ComputerControl.LeftClick': { + id: 'ComputerControl.LeftClick', category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: "computercontrol", - displayName: "Left Click", - description: "Perform left mouse click", - platforms: ["desktop", "medicareBionics"], + pluginId: 'computercontrol', + displayName: 'Left Click', + description: 'Perform left mouse click', + platforms: ['desktop', 'medicareBionics'], }, - "ComputerControl.RightClick": { - id: "ComputerControl.RightClick", + 'ComputerControl.RightClick': { + id: 'ComputerControl.RightClick', category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: "computercontrol", - displayName: "Right Click", - description: "Perform right mouse click", - platforms: ["desktop", "medicareBionics"], + pluginId: 'computercontrol', + displayName: 'Right Click', + description: 'Perform right mouse click', + platforms: ['desktop', 'medicareBionics'], }, - "ComputerControl.DoubleClick": { - id: "ComputerControl.DoubleClick", + 'ComputerControl.DoubleClick': { + id: 'ComputerControl.DoubleClick', category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: "computercontrol", - displayName: "Double Click", - description: "Perform double mouse click", - platforms: ["desktop", "medicareBionics"], + pluginId: 'computercontrol', + displayName: 'Double Click', + description: 'Perform double mouse click', + platforms: ['desktop', 'medicareBionics'], }, - "ComputerControl.MouseMove": { - id: "ComputerControl.MouseMove", + 'ComputerControl.MouseMove': { + id: 'ComputerControl.MouseMove', category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: "computercontrol", - displayName: "Move Mouse", - description: "Move mouse pointer", + pluginId: 'computercontrol', + displayName: 'Move Mouse', + description: 'Move mouse pointer', parameters: [ - { key: "x", type: "number", required: true, description: "X coordinate" }, - { key: "y", type: "number", required: true, description: "Y coordinate" }, + { key: 'x', type: 'number', required: true, description: 'X coordinate' }, + { key: 'y', type: 'number', required: true, description: 'Y coordinate' }, ], - platforms: ["desktop", "medicareBionics"], + platforms: ['desktop', 'medicareBionics'], }, - "ComputerControl.SendKeys": { - id: "ComputerControl.SendKeys", + 'ComputerControl.SendKeys': { + id: 'ComputerControl.SendKeys', category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: "computercontrol", - displayName: "Send Keys", - description: "Send keyboard input", + pluginId: 'computercontrol', + displayName: 'Send Keys', + description: 'Send keyboard input', parameters: [ { - key: "keys", - type: "string", + key: 'keys', + type: 'string', required: true, - description: "Key sequence to send", + description: 'Key sequence to send', }, ], - platforms: ["desktop", "medicareBionics"], + platforms: ['desktop', 'medicareBionics'], }, - "ComputerControl.WindowsKey": { - id: "ComputerControl.WindowsKey", + 'ComputerControl.WindowsKey': { + id: 'ComputerControl.WindowsKey', category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: "computercontrol", - displayName: "Windows Key", - description: "Press Windows key", - platforms: ["desktop"], + pluginId: 'computercontrol', + displayName: 'Windows Key', + description: 'Press Windows key', + platforms: ['desktop'], }, - "ComputerControl.MenuKey": { - id: "ComputerControl.MenuKey", + 'ComputerControl.MenuKey': { + id: 'ComputerControl.MenuKey', category: Grid3CommandCategory.COMPUTER_CONTROL, - pluginId: "computercontrol", - displayName: "Menu Key", - description: "Press context menu key", - platforms: ["desktop"], + pluginId: 'computercontrol', + displayName: 'Menu Key', + description: 'Press context menu key', + platforms: ['desktop'], }, // ======================================== // WEB BROWSER COMMANDS // ======================================== - "WebBrowser.Navigate": { - id: "WebBrowser.Navigate", + 'WebBrowser.Navigate': { + id: 'WebBrowser.Navigate', category: Grid3CommandCategory.WEB_BROWSER, - pluginId: "webbrowser", - displayName: "Navigate to URL", - description: "Open a URL in the web browser", + pluginId: 'webbrowser', + displayName: 'Navigate to URL', + description: 'Open a URL in the web browser', parameters: [ { - key: "url", - type: "string", + key: 'url', + type: 'string', required: true, - description: "URL to navigate to", + description: 'URL to navigate to', }, ], - platforms: ["desktop", "ios"], + platforms: ['desktop', 'ios'], }, - "WebBrowser.Back": { - id: "WebBrowser.Back", + 'WebBrowser.Back': { + id: 'WebBrowser.Back', category: Grid3CommandCategory.WEB_BROWSER, - pluginId: "webbrowser", - displayName: "Browser Back", - description: "Go back in browser history", - platforms: ["desktop", "ios"], + pluginId: 'webbrowser', + displayName: 'Browser Back', + description: 'Go back in browser history', + platforms: ['desktop', 'ios'], }, - "WebBrowser.Forward": { - id: "WebBrowser.Forward", + 'WebBrowser.Forward': { + id: 'WebBrowser.Forward', category: Grid3CommandCategory.WEB_BROWSER, - pluginId: "webbrowser", - displayName: "Browser Forward", - description: "Go forward in browser history", - platforms: ["desktop", "ios"], + pluginId: 'webbrowser', + displayName: 'Browser Forward', + description: 'Go forward in browser history', + platforms: ['desktop', 'ios'], }, - "WebBrowser.Refresh": { - id: "WebBrowser.Refresh", + 'WebBrowser.Refresh': { + id: 'WebBrowser.Refresh', category: Grid3CommandCategory.WEB_BROWSER, - pluginId: "webbrowser", - displayName: "Refresh Page", - description: "Refresh the current page", - platforms: ["desktop", "ios"], + pluginId: 'webbrowser', + displayName: 'Refresh Page', + description: 'Refresh the current page', + platforms: ['desktop', 'ios'], }, - "WebBrowser.Home": { - id: "WebBrowser.Home", + 'WebBrowser.Home': { + id: 'WebBrowser.Home', category: Grid3CommandCategory.WEB_BROWSER, - pluginId: "webbrowser", - displayName: "Browser Home", - description: "Navigate to browser home page", - platforms: ["desktop", "ios"], + pluginId: 'webbrowser', + displayName: 'Browser Home', + description: 'Navigate to browser home page', + platforms: ['desktop', 'ios'], }, - "WebBrowser.FavoriteAdd": { - id: "WebBrowser.FavoriteAdd", + 'WebBrowser.FavoriteAdd': { + id: 'WebBrowser.FavoriteAdd', category: Grid3CommandCategory.WEB_BROWSER, - pluginId: "webbrowser", - displayName: "Add Favorite", - description: "Add current page to favorites", - platforms: ["desktop", "ios"], + pluginId: 'webbrowser', + displayName: 'Add Favorite', + description: 'Add current page to favorites', + platforms: ['desktop', 'ios'], }, - "WebBrowser.ZoomIn": { - id: "WebBrowser.ZoomIn", + 'WebBrowser.ZoomIn': { + id: 'WebBrowser.ZoomIn', category: Grid3CommandCategory.WEB_BROWSER, - pluginId: "webbrowser", - displayName: "Zoom In", - description: "Zoom in the page", - platforms: ["desktop", "ios"], + pluginId: 'webbrowser', + displayName: 'Zoom In', + description: 'Zoom in the page', + platforms: ['desktop', 'ios'], }, - "WebBrowser.ZoomOut": { - id: "WebBrowser.ZoomOut", + 'WebBrowser.ZoomOut': { + id: 'WebBrowser.ZoomOut', category: Grid3CommandCategory.WEB_BROWSER, - pluginId: "webbrowser", - displayName: "Zoom Out", - description: "Zoom out the page", - platforms: ["desktop", "ios"], + pluginId: 'webbrowser', + displayName: 'Zoom Out', + description: 'Zoom out the page', + platforms: ['desktop', 'ios'], }, // ======================================== // EMAIL COMMANDS // ======================================== - "Email.SendTo": { - id: "Email.SendTo", + 'Email.SendTo': { + id: 'Email.SendTo', category: Grid3CommandCategory.EMAIL, - pluginId: "email", - displayName: "Send Email To", - description: "Send email to a recipient", + pluginId: 'email', + displayName: 'Send Email To', + description: 'Send email to a recipient', parameters: [ { - key: "recipient", - type: "string", + key: 'recipient', + type: 'string', required: true, - description: "Recipient email address", + description: 'Recipient email address', }, { - key: "subject", - type: "string", + key: 'subject', + type: 'string', required: false, - description: "Email subject", + description: 'Email subject', }, ], - platforms: ["desktop", "medicare", "medicareBionics"], + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "Email.AddRecipient": { - id: "Email.AddRecipient", + 'Email.AddRecipient': { + id: 'Email.AddRecipient', category: Grid3CommandCategory.EMAIL, - pluginId: "email", - displayName: "Add Recipient", - description: "Add a recipient to the email", + pluginId: 'email', + displayName: 'Add Recipient', + description: 'Add a recipient to the email', parameters: [ { - key: "recipient", - type: "string", + key: 'recipient', + type: 'string', required: true, - description: "Recipient email address", + description: 'Recipient email address', }, ], - platforms: ["desktop", "medicare", "medicareBionics"], + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "Email.SetSubject": { - id: "Email.SetSubject", + 'Email.SetSubject': { + id: 'Email.SetSubject', category: Grid3CommandCategory.EMAIL, - pluginId: "email", - displayName: "Set Subject", - description: "Set the email subject", + pluginId: 'email', + displayName: 'Set Subject', + description: 'Set the email subject', parameters: [ { - key: "subject", - type: "string", + key: 'subject', + type: 'string', required: true, - description: "Email subject", + description: 'Email subject', }, ], - platforms: ["desktop", "medicare", "medicareBionics"], + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "Email.AttachFile": { - id: "Email.AttachFile", + 'Email.AttachFile': { + id: 'Email.AttachFile', category: Grid3CommandCategory.EMAIL, - pluginId: "email", - displayName: "Attach File", - description: "Attach a file to the email", + pluginId: 'email', + displayName: 'Attach File', + description: 'Attach a file to the email', parameters: [ { - key: "filepath", - type: "string", + key: 'filepath', + type: 'string', required: true, - description: "Path to file", + description: 'Path to file', }, ], - platforms: ["desktop", "medicare", "medicareBionics"], + platforms: ['desktop', 'medicare', 'medicareBionics'], }, // ======================================== // PHONE COMMANDS // ======================================== - "Phone.Call": { - id: "Phone.Call", + 'Phone.Call': { + id: 'Phone.Call', category: Grid3CommandCategory.PHONE, - pluginId: "phone", - displayName: "Make Call", - description: "Initiate a phone call", + pluginId: 'phone', + displayName: 'Make Call', + description: 'Initiate a phone call', parameters: [ { - key: "number", - type: "string", + key: 'number', + type: 'string', required: true, - description: "Phone number to call", + description: 'Phone number to call', }, ], - platforms: ["desktop", "medicare", "medicareBionics"], + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "Phone.Answer": { - id: "Phone.Answer", + 'Phone.Answer': { + id: 'Phone.Answer', category: Grid3CommandCategory.PHONE, - pluginId: "phone", - displayName: "Answer Call", - description: "Answer incoming call", - platforms: ["desktop", "medicare", "medicareBionics"], + pluginId: 'phone', + displayName: 'Answer Call', + description: 'Answer incoming call', + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "Phone.Hangup": { - id: "Phone.Hangup", + 'Phone.Hangup': { + id: 'Phone.Hangup', category: Grid3CommandCategory.PHONE, - pluginId: "phone", - displayName: "End Call", - description: "End current call", - platforms: ["desktop", "medicare", "medicareBionics"], + pluginId: 'phone', + displayName: 'End Call', + description: 'End current call', + platforms: ['desktop', 'medicare', 'medicareBionics'], }, // ======================================== // SMS COMMANDS // ======================================== - "Sms.SendTo": { - id: "Sms.SendTo", + 'Sms.SendTo': { + id: 'Sms.SendTo', category: Grid3CommandCategory.SMS, - pluginId: "sms", - displayName: "Send SMS To", - description: "Send text message to a recipient", + pluginId: 'sms', + displayName: 'Send SMS To', + description: 'Send text message to a recipient', parameters: [ { - key: "recipient", - type: "string", + key: 'recipient', + type: 'string', required: true, - description: "Phone number", + description: 'Phone number', }, { - key: "message", - type: "string", + key: 'message', + type: 'string', required: false, - description: "Message text", + description: 'Message text', }, ], - platforms: ["desktop", "medicare", "medicareBionics"], + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "Sms.AddRecipient": { - id: "Sms.AddRecipient", + 'Sms.AddRecipient': { + id: 'Sms.AddRecipient', category: Grid3CommandCategory.SMS, - pluginId: "sms", - displayName: "Add SMS Recipient", - description: "Add a recipient to the SMS", + pluginId: 'sms', + displayName: 'Add SMS Recipient', + description: 'Add a recipient to the SMS', parameters: [ { - key: "recipient", - type: "string", + key: 'recipient', + type: 'string', required: true, - description: "Phone number", + description: 'Phone number', }, ], - platforms: ["desktop", "medicare", "medicareBionics"], + platforms: ['desktop', 'medicare', 'medicareBionics'], }, // ======================================== // SYSTEM COMMANDS // ======================================== - "System.LogOff": { - id: "System.LogOff", + 'System.LogOff': { + id: 'System.LogOff', category: Grid3CommandCategory.SYSTEM, - pluginId: "computersession", - displayName: "Log Off", - description: "Log off from Windows", - platforms: ["desktop", "medicare", "medicareBionics"], + pluginId: 'computersession', + displayName: 'Log Off', + description: 'Log off from Windows', + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "System.Lock": { - id: "System.Lock", + 'System.Lock': { + id: 'System.Lock', category: Grid3CommandCategory.SYSTEM, - pluginId: "computersession", - displayName: "Lock Computer", - description: "Lock the computer", - platforms: ["desktop", "medicare", "medicareBionics"], + pluginId: 'computersession', + displayName: 'Lock Computer', + description: 'Lock the computer', + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "System.Sleep": { - id: "System.Sleep", + 'System.Sleep': { + id: 'System.Sleep', category: Grid3CommandCategory.SYSTEM, - pluginId: "computersession", - displayName: "Sleep", - description: "Put computer to sleep", - platforms: ["desktop", "medicare", "medicareBionics"], + pluginId: 'computersession', + displayName: 'Sleep', + description: 'Put computer to sleep', + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "System.Restart": { - id: "System.Restart", + 'System.Restart': { + id: 'System.Restart', category: Grid3CommandCategory.SYSTEM, - pluginId: "computersession", - displayName: "Restart", - description: "Restart the computer", - platforms: ["desktop", "medicare", "medicareBionics"], + pluginId: 'computersession', + displayName: 'Restart', + description: 'Restart the computer', + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "System.ShutDown": { - id: "System.ShutDown", + 'System.ShutDown': { + id: 'System.ShutDown', category: Grid3CommandCategory.SYSTEM, - pluginId: "computersession", - displayName: "Shut Down", - description: "Shut down the computer", - platforms: ["desktop", "medicare", "medicareBionics"], + pluginId: 'computersession', + displayName: 'Shut Down', + description: 'Shut down the computer', + platforms: ['desktop', 'medicare', 'medicareBionics'], }, // ======================================== // SETTINGS COMMANDS // ======================================== - "Settings.RestoreAll": { - id: "Settings.RestoreAll", + 'Settings.RestoreAll': { + id: 'Settings.RestoreAll', category: Grid3CommandCategory.SETTINGS, - pluginId: "settings", - displayName: "Restore All Settings", - description: "Restore all settings to defaults", + pluginId: 'settings', + displayName: 'Restore All Settings', + description: 'Restore all settings to defaults', parameters: [ { - key: "indicatorenabled", - type: "boolean", + key: 'indicatorenabled', + type: 'boolean', required: false, - description: "Show indicator", + description: 'Show indicator', }, { - key: "action", - type: "string", + key: 'action', + type: 'string', required: false, - description: "Action to perform", + description: 'Action to perform', }, ], - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Settings.Open": { - id: "Settings.Open", + 'Settings.Open': { + id: 'Settings.Open', category: Grid3CommandCategory.SETTINGS, - pluginId: "settings", - displayName: "Open Settings", - description: "Open the settings window", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'settings', + displayName: 'Open Settings', + description: 'Open the settings window', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Scanning.Start": { - id: "Scanning.Start", + 'Scanning.Start': { + id: 'Scanning.Start', category: Grid3CommandCategory.SETTINGS, - pluginId: "access", - displayName: "Start Scanning", - description: "Start scanning access method", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'access', + displayName: 'Start Scanning', + description: 'Start scanning access method', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Scanning.Stop": { - id: "Scanning.Stop", + 'Scanning.Stop': { + id: 'Scanning.Stop', category: Grid3CommandCategory.SETTINGS, - pluginId: "access", - displayName: "Stop Scanning", - description: "Stop scanning access method", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'access', + displayName: 'Stop Scanning', + description: 'Stop scanning access method', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, // ======================================== // AUTO CONTENT COMMANDS // ======================================== - "AutoContent.Activate": { - id: "AutoContent.Activate", + 'AutoContent.Activate': { + id: 'AutoContent.Activate', category: Grid3CommandCategory.AUTO_CONTENT, - pluginId: "autocontent", - displayName: "Activate Auto Content", - description: "Activate an auto content cell", + pluginId: 'autocontent', + displayName: 'Activate Auto Content', + description: 'Activate an auto content cell', parameters: [ { - key: "autocontenttype", - type: "string", + key: 'autocontenttype', + type: 'string', required: true, - description: "Type of auto content", + description: 'Type of auto content', }, ], - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Prediction.Clear": { - id: "Prediction.Clear", + 'Prediction.Clear': { + id: 'Prediction.Clear', category: Grid3CommandCategory.AUTO_CONTENT, - pluginId: "prediction", - displayName: "Clear Prediction", - description: "Clear word prediction buffer", - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + pluginId: 'prediction', + displayName: 'Clear Prediction', + description: 'Clear word prediction buffer', + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, - "Prediction.PredictThis": { - id: "Prediction.PredictThis", + 'Prediction.PredictThis': { + id: 'Prediction.PredictThis', category: Grid3CommandCategory.AUTO_CONTENT, - pluginId: "prediction", - displayName: "Predict This", - description: "Provide suggestions based on word list", + pluginId: 'prediction', + displayName: 'Predict This', + description: 'Provide suggestions based on word list', parameters: [ { - key: "wordlist", - type: "string", // Actually highly structured, but string type is a placeholder + key: 'wordlist', + type: 'string', // Actually highly structured, but string type is a placeholder required: true, - description: "Word list for prediction", + description: 'Word list for prediction', }, ], }, - "Grammar.Change": { - id: "Grammar.Change", + 'Grammar.Change': { + id: 'Grammar.Change', category: Grid3CommandCategory.AUTO_CONTENT, - pluginId: "grammar", - displayName: "Change Grammar", - description: "Change grammar context", + pluginId: 'grammar', + displayName: 'Change Grammar', + description: 'Change grammar context', parameters: [ { - key: "context", - type: "string", + key: 'context', + type: 'string', required: true, - description: "Grammar context", + description: 'Grammar context', }, ], - platforms: ["desktop", "ios", "medicare", "medicareBionics"], + platforms: ['desktop', 'ios', 'medicare', 'medicareBionics'], }, // ======================================== // ENVIRONMENT CONTROL COMMANDS // ======================================== - "EnvControl.Send": { - id: "EnvControl.Send", + 'EnvControl.Send': { + id: 'EnvControl.Send', category: Grid3CommandCategory.ENVIRONMENT_CONTROL, - pluginId: "environmentcontrol", - displayName: "Send Environment Control", - description: "Send environment control command", + pluginId: 'environmentcontrol', + displayName: 'Send Environment Control', + description: 'Send environment control command', parameters: [ { - key: "code", - type: "string", + key: 'code', + type: 'string', required: true, - description: "IR/EC code to send", + description: 'IR/EC code to send', }, ], - platforms: ["desktop", "medicare", "medicareBionics"], + platforms: ['desktop', 'medicare', 'medicareBionics'], }, - "EnvControl.Learn": { - id: "EnvControl.Learn", + 'EnvControl.Learn': { + id: 'EnvControl.Learn', category: Grid3CommandCategory.ENVIRONMENT_CONTROL, - pluginId: "environmentcontrol", - displayName: "Learn Environment Control", - description: "Learn environment control code", - platforms: ["desktop", "medicare", "medicareBionics"], + pluginId: 'environmentcontrol', + displayName: 'Learn Environment Control', + description: 'Learn environment control code', + platforms: ['desktop', 'medicare', 'medicareBionics'], }, // ======================================== // MOUSE COMMANDS // ======================================== - "Mouse.LeftClick": { - id: "Mouse.LeftClick", + 'Mouse.LeftClick': { + id: 'Mouse.LeftClick', category: Grid3CommandCategory.MOUSE, - pluginId: "computercontrol", - displayName: "Mouse Left Click", - description: "Left mouse click", - platforms: ["desktop", "medicareBionics"], + pluginId: 'computercontrol', + displayName: 'Mouse Left Click', + description: 'Left mouse click', + platforms: ['desktop', 'medicareBionics'], }, - "Mouse.RightClick": { - id: "Mouse.RightClick", + 'Mouse.RightClick': { + id: 'Mouse.RightClick', category: Grid3CommandCategory.MOUSE, - pluginId: "computercontrol", - displayName: "Mouse Right Click", - description: "Right mouse click", - platforms: ["desktop", "medicareBionics"], + pluginId: 'computercontrol', + displayName: 'Mouse Right Click', + description: 'Right mouse click', + platforms: ['desktop', 'medicareBionics'], }, - "Mouse.DoubleClick": { - id: "Mouse.DoubleClick", + 'Mouse.DoubleClick': { + id: 'Mouse.DoubleClick', category: Grid3CommandCategory.MOUSE, - pluginId: "computercontrol", - displayName: "Mouse Double Click", - description: "Double mouse click", - platforms: ["desktop", "medicareBionics"], + pluginId: 'computercontrol', + displayName: 'Mouse Double Click', + description: 'Double mouse click', + platforms: ['desktop', 'medicareBionics'], }, - "Mouse.Move": { - id: "Mouse.Move", + 'Mouse.Move': { + id: 'Mouse.Move', category: Grid3CommandCategory.MOUSE, - pluginId: "computercontrol", - displayName: "Move Mouse", - description: "Move mouse pointer", + pluginId: 'computercontrol', + displayName: 'Move Mouse', + description: 'Move mouse pointer', parameters: [ - { key: "x", type: "number", required: true, description: "X coordinate" }, - { key: "y", type: "number", required: true, description: "Y coordinate" }, + { key: 'x', type: 'number', required: true, description: 'X coordinate' }, + { key: 'y', type: 'number', required: true, description: 'Y coordinate' }, ], - platforms: ["desktop", "medicareBionics"], + platforms: ['desktop', 'medicareBionics'], }, // ======================================== // WINDOW COMMANDS // ======================================== - "Window.Minimize": { - id: "Window.Minimize", + 'Window.Minimize': { + id: 'Window.Minimize', category: Grid3CommandCategory.WINDOW, - pluginId: "computercontrol", - displayName: "Minimize Window", - description: "Minimize active window", - platforms: ["desktop", "medicareBionics"], + pluginId: 'computercontrol', + displayName: 'Minimize Window', + description: 'Minimize active window', + platforms: ['desktop', 'medicareBionics'], }, - "Window.Maximize": { - id: "Window.Maximize", + 'Window.Maximize': { + id: 'Window.Maximize', category: Grid3CommandCategory.WINDOW, - pluginId: "computercontrol", - displayName: "Maximize Window", - description: "Maximize active window", - platforms: ["desktop", "medicareBionics"], + pluginId: 'computercontrol', + displayName: 'Maximize Window', + description: 'Maximize active window', + platforms: ['desktop', 'medicareBionics'], }, - "Window.Close": { - id: "Window.Close", + 'Window.Close': { + id: 'Window.Close', category: Grid3CommandCategory.WINDOW, - pluginId: "computercontrol", - displayName: "Close Window", - description: "Close active window", - platforms: ["desktop", "medicareBionics"], + pluginId: 'computercontrol', + displayName: 'Close Window', + description: 'Close active window', + platforms: ['desktop', 'medicareBionics'], }, - "Window.Switch": { - id: "Window.Switch", + 'Window.Switch': { + id: 'Window.Switch', category: Grid3CommandCategory.WINDOW, - pluginId: "computercontrol", - displayName: "Switch Window", - description: "Switch to next window", - platforms: ["desktop", "medicareBionics"], + pluginId: 'computercontrol', + displayName: 'Switch Window', + description: 'Switch to next window', + platforms: ['desktop', 'medicareBionics'], }, // ======================================== // MEDIA COMMANDS // ======================================== - "Media.PlayPause": { - id: "Media.PlayPause", + 'Media.PlayPause': { + id: 'Media.PlayPause', category: Grid3CommandCategory.MEDIA, - pluginId: "musicvideo", - displayName: "Play/Pause", - description: "Toggle play/pause media", - platforms: ["desktop", "ios"], + pluginId: 'musicvideo', + displayName: 'Play/Pause', + description: 'Toggle play/pause media', + platforms: ['desktop', 'ios'], }, - "Media.Next": { - id: "Media.Next", + 'Media.Next': { + id: 'Media.Next', category: Grid3CommandCategory.MEDIA, - pluginId: "musicvideo", - displayName: "Next Track", - description: "Skip to next track", - platforms: ["desktop", "ios"], + pluginId: 'musicvideo', + displayName: 'Next Track', + description: 'Skip to next track', + platforms: ['desktop', 'ios'], }, - "Media.Previous": { - id: "Media.Previous", + 'Media.Previous': { + id: 'Media.Previous', category: Grid3CommandCategory.MEDIA, - pluginId: "musicvideo", - displayName: "Previous Track", - description: "Go to previous track", - platforms: ["desktop", "ios"], + pluginId: 'musicvideo', + displayName: 'Previous Track', + description: 'Go to previous track', + platforms: ['desktop', 'ios'], }, - "Media.Stop": { - id: "Media.Stop", + 'Media.Stop': { + id: 'Media.Stop', category: Grid3CommandCategory.MEDIA, - pluginId: "musicvideo", - displayName: "Stop", - description: "Stop media playback", - platforms: ["desktop", "ios"], + pluginId: 'musicvideo', + displayName: 'Stop', + description: 'Stop media playback', + platforms: ['desktop', 'ios'], }, - "Media.VolumeUp": { - id: "Media.VolumeUp", + 'Media.VolumeUp': { + id: 'Media.VolumeUp', category: Grid3CommandCategory.MEDIA, - pluginId: "musicvideo", - displayName: "Volume Up", - description: "Increase volume", - platforms: ["desktop", "ios"], + pluginId: 'musicvideo', + displayName: 'Volume Up', + description: 'Increase volume', + platforms: ['desktop', 'ios'], }, - "Media.VolumeDown": { - id: "Media.VolumeDown", + 'Media.VolumeDown': { + id: 'Media.VolumeDown', category: Grid3CommandCategory.MEDIA, - pluginId: "musicvideo", - displayName: "Volume Down", - description: "Decrease volume", - platforms: ["desktop", "ios"], + pluginId: 'musicvideo', + displayName: 'Volume Down', + description: 'Decrease volume', + platforms: ['desktop', 'ios'], }, }; /** * Get command definition by ID */ -export function getCommandDefinition( - commandId: string, -): Grid3CommandDefinition | undefined { +export function getCommandDefinition(commandId: string): Grid3CommandDefinition | undefined { return GRID3_COMMANDS[commandId]; } @@ -932,23 +930,15 @@ export function isKnownCommand(commandId: string): boolean { /** * Get all commands for a specific plugin */ -export function getCommandsByPlugin( - pluginId: string, -): Grid3CommandDefinition[] { - return Object.values(GRID3_COMMANDS).filter( - (cmd) => cmd.pluginId === pluginId, - ); +export function getCommandsByPlugin(pluginId: string): Grid3CommandDefinition[] { + return Object.values(GRID3_COMMANDS).filter((cmd) => cmd.pluginId === pluginId); } /** * Get all commands in a category */ -export function getCommandsByCategory( - category: Grid3CommandCategory, -): Grid3CommandDefinition[] { - return Object.values(GRID3_COMMANDS).filter( - (cmd) => cmd.category === category, - ); +export function getCommandsByCategory(category: Grid3CommandCategory): Grid3CommandDefinition[] { + return Object.values(GRID3_COMMANDS).filter((cmd) => cmd.category === category); } /** @@ -962,9 +952,7 @@ export function getAllCommandIds(): string[] { * Get all plugin IDs that have commands */ export function getAllPluginIds(): string[] { - const plugins = new Set( - Object.values(GRID3_COMMANDS).map((cmd) => cmd.pluginId), - ); + const plugins = new Set(Object.values(GRID3_COMMANDS).map((cmd) => cmd.pluginId)); return Array.from(plugins).sort(); } @@ -984,18 +972,18 @@ export function extractCommandParameters(command: any): ExtractedParameters { const paramArray = Array.isArray(params) ? params : [params]; for (const param of paramArray) { - const key = param["@_Key"] || param.Key || param.key; - let value = param["#text"] ?? param.text ?? param.value; + const key = param['@_Key'] || param.Key || param.key; + let value = param['#text'] ?? param.text ?? param.value; if (key && value !== undefined) { // Try to convert to number if it looks numeric - if (typeof value === "string" && /^\d+$/.test(value)) { + if (typeof value === 'string' && /^\d+$/.test(value)) { value = parseInt(value, 10); - } else if (typeof value === "string" && /^\d+\.\d+$/.test(value)) { + } else if (typeof value === 'string' && /^\d+\.\d+$/.test(value)) { value = parseFloat(value); - } else if (value === "true") { + } else if (value === 'true') { value = true; - } else if (value === "false") { + } else if (value === 'false') { value = false; } @@ -1013,19 +1001,17 @@ export function detectCommand(commandObj: any): { id: string; definition?: Grid3CommandDefinition; parameters: ExtractedParameters; - category: Grid3CommandCategory | "unknown"; - pluginId: string | "unknown"; + category: Grid3CommandCategory | 'unknown'; + pluginId: string | 'unknown'; } { - const commandId = String( - commandObj["@_ID"] || commandObj.ID || commandObj.id || "", - ); + const commandId = String(commandObj['@_ID'] || commandObj.ID || commandObj.id || ''); if (!commandId) { return { - id: "unknown", + id: 'unknown', parameters: {}, - category: "unknown" as any, - pluginId: "unknown", + category: 'unknown' as any, + pluginId: 'unknown', }; } @@ -1036,7 +1022,7 @@ export function detectCommand(commandObj: any): { id: commandId, definition, parameters, - category: definition?.category || ("unknown" as any), - pluginId: definition?.pluginId || "unknown", + category: definition?.category || ('unknown' as any), + pluginId: definition?.pluginId || 'unknown', }; } diff --git a/src/processors/gridset/crypto.ts b/src/processors/gridset/crypto.ts index 528a82e..63a7a71 100644 --- a/src/processors/gridset/crypto.ts +++ b/src/processors/gridset/crypto.ts @@ -14,28 +14,23 @@ */ export function decryptGridsetEntry(buffer: Buffer, password?: string): Buffer { const nodeRequire = - typeof require === "function" - ? require - : (undefined as undefined | ((id: string) => any)); + typeof require === 'function' ? require : (undefined as undefined | ((id: string) => any)); if (!nodeRequire) { - throw new Error("Crypto utilities are not available in this environment."); + throw new Error('Crypto utilities are not available in this environment.'); } // Dynamic require to avoid breaking in browser environments - const cryptoModule = "crypto"; - const zlibModule = "zlib"; + const cryptoModule = 'crypto'; + const zlibModule = 'zlib'; const crypto = nodeRequire(cryptoModule); const zlib = nodeRequire(zlibModule); - const pwd = (password || "Chocolate").padEnd(32, " "); - const key = Buffer.from(pwd.slice(0, 32), "utf8"); - const iv = Buffer.from(pwd.slice(0, 16), "utf8"); + const pwd = (password || 'Chocolate').padEnd(32, ' '); + const key = Buffer.from(pwd.slice(0, 32), 'utf8'); + const iv = Buffer.from(pwd.slice(0, 16), 'utf8'); try { - const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); - const decrypted = Buffer.concat([ - decipher.update(buffer), - decipher.final(), - ]); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + const decrypted = Buffer.concat([decipher.update(buffer), decipher.final()]); try { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return zlib.inflateSync(decrypted); @@ -54,12 +49,10 @@ export function decryptGridsetEntry(buffer: Buffer, password?: string): Buffer { export function isCryptoAvailable(): boolean { try { const nodeRequire = - typeof require === "function" - ? require - : (undefined as undefined | ((id: string) => any)); + typeof require === 'function' ? require : (undefined as undefined | ((id: string) => any)); if (!nodeRequire) return false; - const cryptoModule = "crypto"; - const zlibModule = "zlib"; + const cryptoModule = 'crypto'; + const zlibModule = 'zlib'; nodeRequire(cryptoModule); nodeRequire(zlibModule); return true; diff --git a/src/processors/gridset/gridCalculations.ts b/src/processors/gridset/gridCalculations.ts index a7f6822..0207cef 100644 --- a/src/processors/gridset/gridCalculations.ts +++ b/src/processors/gridset/gridCalculations.ts @@ -5,7 +5,7 @@ * based on page layout and button count. */ -import type { AACPage } from "../../core/treeStructure"; +import type { AACPage } from '../../core/treeStructure'; /** * Grid definition structure for Grid 3 XML @@ -60,10 +60,7 @@ export function calculateColumnDefinitions(page: AACPage): GridDefinitions { * const rows = calculateRowDefinitions(page, false); * // Returns: { RowDefinition: [{}, {}, {}, {}] } for 4 rows */ -export function calculateRowDefinitions( - page: AACPage, - addWorkspaceOffset = false, -): RowDefinitions { +export function calculateRowDefinitions(page: AACPage, addWorkspaceOffset = false): RowDefinitions { let maxRows = 4; // Default minimum const offset = addWorkspaceOffset ? 1 : 0; @@ -72,8 +69,7 @@ export function calculateRowDefinitions( } else { // Fallback: estimate from button count const estimatedCols = Math.ceil(Math.sqrt(page.buttons.length)); - maxRows = - Math.max(4, Math.ceil(page.buttons.length / estimatedCols)) + offset; + maxRows = Math.max(4, Math.ceil(page.buttons.length / estimatedCols)) + offset; } return { diff --git a/src/processors/gridset/helpers.ts b/src/processors/gridset/helpers.ts index bac9378..e1462d6 100644 --- a/src/processors/gridset/helpers.ts +++ b/src/processors/gridset/helpers.ts @@ -1,16 +1,13 @@ -import { XMLBuilder } from "fast-xml-parser"; +import { XMLBuilder } from 'fast-xml-parser'; import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent, -} from "../../core/treeStructure"; -import { dotNetTicksToDate } from "../../utils/dotnetTicks"; -import { - getZipEntriesFromAdapter, - resolveGridsetPasswordFromEnv, -} from "./password"; +} from '../../core/treeStructure'; +import { dotNetTicksToDate } from '../../utils/dotnetTicks'; +import { getZipEntriesFromAdapter, resolveGridsetPasswordFromEnv } from './password'; import { defaultFileAdapter, extname, @@ -18,14 +15,14 @@ import { getNodeRequire, joinWin32, ProcessorInput, -} from "../../utils/io"; -import { getZipAdapter, ZipAdapter } from "../../utils/zip"; -import { requireBetterSqlite3 } from "../../utils/sqlite"; +} from '../../utils/io'; +import { getZipAdapter, ZipAdapter } from '../../utils/zip'; +import { requireBetterSqlite3 } from '../../utils/sqlite'; function normalizeZipPath(p: string): string { - const unified = p.replace(/\\/g, "/"); + const unified = p.replace(/\\/g, '/'); try { - return unified.normalize("NFC"); + return unified.normalize('NFC'); } catch { return unified; } @@ -35,10 +32,7 @@ function normalizeZipPath(p: string): string { * Build a map of button IDs to resolved image entry paths for a specific page. * Helpful when rewriting zip entry names or validating images referenced in a grid. */ -export function getPageTokenImageMap( - tree: AACTree, - pageId: string, -): Map { +export function getPageTokenImageMap(tree: AACTree, pageId: string): Map { const map = new Map(); const page: AACPage | undefined = tree.getPage(pageId); if (!page) return map; @@ -58,8 +52,7 @@ export function getAllowedImageEntries(tree: AACTree): Set { const out = new Set(); Object.values(tree.pages).forEach((page) => { page.buttons.forEach((btn: AACButton) => { - if (btn.resolvedImageEntry) - out.add(normalizeZipPath(String(btn.resolvedImageEntry))); + if (btn.resolvedImageEntry) out.add(normalizeZipPath(String(btn.resolvedImageEntry))); }); }); return out; @@ -76,7 +69,7 @@ export async function openImage( entryPath: string, password = resolveGridsetPasswordFromEnv(), fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input?: ProcessorInput) => Promise, + zipAdapter?: (input?: ProcessorInput) => Promise ): Promise { try { const zip = zipAdapter @@ -87,7 +80,7 @@ export async function openImage( const entry = entries.find((e) => normalizeZipPath(e.entryName) === want); if (!entry) return null; const data = await entry.getData(); - if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") { + if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') { return Buffer.from(data); } return data; @@ -102,9 +95,9 @@ export async function openImage( * @returns A UUID v4-like string in the format xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx */ export function generateGrid3Guid(): string { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0; - const v = c === "x" ? r : (r & 0x3) | 0x8; + const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } @@ -124,24 +117,24 @@ export function createSettingsXml( hoverTimeoutMs?: number; mouseclickEnabled?: boolean; language?: string; - }, + } ): string { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', }); const settingsData = { GridSetSettings: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', StartGrid: startGrid, - ScanEnabled: options?.scanEnabled?.toString() ?? "false", - ScanTimeoutMs: options?.scanTimeoutMs?.toString() ?? "2000", - HoverEnabled: options?.hoverEnabled?.toString() ?? "false", - HoverTimeoutMs: options?.hoverTimeoutMs?.toString() ?? "1000", - MouseclickEnabled: options?.mouseclickEnabled?.toString() ?? "true", - Language: options?.language ?? "en-US", + ScanEnabled: options?.scanEnabled?.toString() ?? 'false', + ScanTimeoutMs: options?.scanTimeoutMs?.toString() ?? '2000', + HoverEnabled: options?.hoverEnabled?.toString() ?? 'false', + HoverTimeoutMs: options?.hoverTimeoutMs?.toString() ?? '1000', + MouseclickEnabled: options?.mouseclickEnabled?.toString() ?? 'true', + Language: options?.language ?? 'en-US', }, }; @@ -154,16 +147,16 @@ export function createSettingsXml( * @returns XML string for FileMap.xml */ export function createFileMapXml( - grids: Array<{ name: string; path: string; dynamicFiles?: string[] }>, + grids: Array<{ name: string; path: string; dynamicFiles?: string[] }> ): string { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', }); const entries = grids.map((grid) => ({ - "@_StaticFile": grid.path, + '@_StaticFile': grid.path, ...(grid.dynamicFiles && grid.dynamicFiles.length > 0 ? { DynamicFiles: { File: grid.dynamicFiles } } : {}), @@ -171,7 +164,7 @@ export function createFileMapXml( const fileMapData = { FileMap: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', Entries: { Entry: entries, }, @@ -203,7 +196,7 @@ export interface Grid3HistoryEntry { timestamp: Date; latitude?: number | null; longitude?: number | null; - type?: "button" | "action" | "utterance" | "note" | "other"; + type?: 'button' | 'action' | 'utterance' | 'note' | 'other'; intent?: AACSemanticIntent | string; category?: AACSemanticCategory; }>; @@ -217,19 +210,17 @@ export interface Grid3HistoryEntry { */ export function getCommonDocumentsPath(): string { // Only works on Windows - if (process.platform !== "win32") { - return ""; + if (process.platform !== 'win32') { + return ''; } try { // Query registry for Common Documents path - const child_process = getNodeRequire()( - "child_process", - ) as typeof import("child_process"); + const child_process = getNodeRequire()('child_process') as typeof import('child_process'); const command = 'REG.EXE QUERY "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders" /V "Common Documents"'; const output = child_process.execSync(command, { - encoding: "utf-8", + encoding: 'utf-8', windowsHide: true, }); @@ -243,7 +234,7 @@ export function getCommonDocumentsPath(): string { } // Default fallback path - return "C:\\Users\\Public\\Documents"; + return 'C:\\Users\\Public\\Documents'; } /** @@ -255,20 +246,20 @@ export function getCommonDocumentsPath(): string { * @returns Array of Grid3 user path information */ export async function findGrid3UserPaths( - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { pathExists, listDir, isDirectory } = fileAdapter; const results: Grid3UserPath[] = []; // Only works on Windows - if (process.platform !== "win32") { + if (process.platform !== 'win32') { return results; } try { const commonDocs = getCommonDocumentsPath(); // Use Windows path joining so tests that mock a Windows platform stay consistent even on POSIX runners - const grid3BasePath = joinWin32(commonDocs, "Smartbox", "Grid 3", "Users"); + const grid3BasePath = joinWin32(commonDocs, 'Smartbox', 'Grid 3', 'Users'); // Check if Grid3 Users directory exists if (!(await pathExists(grid3BasePath))) { @@ -292,7 +283,7 @@ export async function findGrid3UserPaths( const langCode = langDir; const basePath = joinWin32(userPath, langCode); - const historyDbPath = joinWin32(basePath, "Phrases", "history.sqlite"); + const historyDbPath = joinWin32(basePath, 'Phrases', 'history.sqlite'); // Only include if history database exists if (await pathExists(historyDbPath)) { @@ -317,9 +308,7 @@ export async function findGrid3UserPaths( * Convenience method that returns just the database file paths * @returns Array of paths to history.sqlite files */ -export async function findGrid3HistoryDatabases( - fileAdapter?: FileAdapter, -): Promise { +export async function findGrid3HistoryDatabases(fileAdapter?: FileAdapter): Promise { const userPaths = await findGrid3UserPaths(fileAdapter); return userPaths.map((userPath) => userPath.historyDbPath); } @@ -338,17 +327,17 @@ export async function findGrid3Users(): Promise { */ export async function findGrid3Vocabularies( userName?: string, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { pathExists, listDir, isDirectory } = fileAdapter; const results: Grid3VocabularyPath[] = []; - if (process.platform !== "win32") { + if (process.platform !== 'win32') { return results; } const commonDocs = getCommonDocumentsPath(); - const grid3BasePath = joinWin32(commonDocs, "Smartbox", "Grid 3", "Users"); + const grid3BasePath = joinWin32(commonDocs, 'Smartbox', 'Grid 3', 'Users'); if (!(await pathExists(grid3BasePath))) { return results; @@ -362,19 +351,14 @@ export async function findGrid3Vocabularies( if (normalizedUser && userDir.toLowerCase() !== normalizedUser) continue; const userRoot = joinWin32(grid3BasePath, userDir); - const gridSetsDir = joinWin32(userRoot, "Grid Sets"); + const gridSetsDir = joinWin32(userRoot, 'Grid Sets'); if (!(await pathExists(gridSetsDir))) continue; const entries = await listDir(gridSetsDir); for (const entry of entries) { if (!(await pathExists(entry)) || (await isDirectory(entry))) continue; const ext = extname(entry).toLowerCase(); - if ( - ext === ".gridset" || - ext === ".gridsetx" || - ext === ".grd" || - ext === ".grdl" - ) { + if (ext === '.gridset' || ext === '.gridsetx' || ext === '.grd' || ext === '.grdl') { results.push({ userName: userDir, gridsetPath: joinWin32(gridSetsDir, entry), @@ -395,7 +379,7 @@ export async function findGrid3Vocabularies( export async function findGrid3UserHistory( userName: string, langCode?: string, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { if (!userName) return null; @@ -406,7 +390,7 @@ export async function findGrid3UserHistory( const match = userPaths.find( (u) => u.userName.toLowerCase() === normalizedUser && - (!normalizedLang || u.langCode.toLowerCase() === normalizedLang), + (!normalizedLang || u.langCode.toLowerCase() === normalizedLang) ); return match?.historyDbPath ?? null; @@ -416,13 +400,13 @@ export async function findGrid3UserHistory( * Check whether Grid 3 appears to be installed (Windows only) */ export async function isGrid3Installed( - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { pathExists } = fileAdapter; - if (process.platform !== "win32") return false; + if (process.platform !== 'win32') return false; const commonDocs = getCommonDocumentsPath(); if (!commonDocs) return false; - const grid3BasePath = joinWin32(commonDocs, "Smartbox", "Grid 3", "Users"); + const grid3BasePath = joinWin32(commonDocs, 'Smartbox', 'Grid 3', 'Users'); return await pathExists(grid3BasePath); } @@ -434,9 +418,9 @@ function parseGrid3ContentXml(xmlContent: string): string { parts.push(match[1]); } if (parts.length > 0) { - return parts.join(""); + return parts.join(''); } - return xmlContent.replace(/<[^>]+>/g, "").trim(); + return xmlContent.replace(/<[^>]+>/g, '').trim(); } /** @@ -446,7 +430,7 @@ function parseGrid3ContentXml(xmlContent: string): string { */ export async function readGrid3History( historyDbPath: string, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { pathExists } = fileAdapter; if (!(await pathExists(historyDbPath))) return []; @@ -466,7 +450,7 @@ export async function readGrid3History( INNER JOIN Phrases p ON p.Id = ph.PhraseId WHERE ph.Timestamp <> 0 ORDER BY ph.Timestamp ASC - `, + ` ) .all() as Array<{ PhraseId: number; @@ -481,13 +465,11 @@ export async function readGrid3History( for (const row of rows) { const phraseId: number = row.PhraseId; - const rawContentSource = [row.ContentXml, row.TextValue].find( - (candidate) => { - if (candidate === null || candidate === undefined) return false; - const asString = String(candidate); - return asString.trim().length > 0; - }, - ); + const rawContentSource = [row.ContentXml, row.TextValue].find((candidate) => { + if (candidate === null || candidate === undefined) return false; + const asString = String(candidate); + return asString.trim().length > 0; + }); if (rawContentSource === undefined) { continue; // Skip history rows with no usable text content } @@ -495,7 +477,7 @@ export async function readGrid3History( const rawContentText = String(rawContentSource); const contentText = parseGrid3ContentXml(rawContentText); const rawXml = - typeof row.ContentXml === "string" && row.ContentXml.trim().length > 0 + typeof row.ContentXml === 'string' && row.ContentXml.trim().length > 0 ? row.ContentXml : undefined; const entry = @@ -511,7 +493,7 @@ export async function readGrid3History( timestamp: dotNetTicksToDate(BigInt(row.TickValue ?? 0)), latitude: row.Latitude ?? null, longitude: row.Longitude ?? null, - type: "utterance", + type: 'utterance', intent: AACSemanticIntent.SPEAK_TEXT, category: AACSemanticCategory.COMMUNICATION, }); @@ -530,7 +512,7 @@ export async function readGrid3History( */ export async function readGrid3HistoryForUser( userName: string, - langCode?: string, + langCode?: string ): Promise { const dbPath = await findGrid3UserHistory(userName, langCode); if (!dbPath) return []; @@ -543,8 +525,6 @@ export async function readGrid3HistoryForUser( */ export async function readAllGrid3History(): Promise { const paths = await findGrid3HistoryDatabases(); - const history = await Promise.all( - paths.map(async (p) => await readGrid3History(p)), - ); + const history = await Promise.all(paths.map(async (p) => await readGrid3History(p))); return history.flat(); } diff --git a/src/processors/gridset/imageDebug.ts b/src/processors/gridset/imageDebug.ts index ce64806..1781859 100644 --- a/src/processors/gridset/imageDebug.ts +++ b/src/processors/gridset/imageDebug.ts @@ -5,16 +5,11 @@ * correctly in Grid3 gridsets. */ -import { getZipEntriesFromAdapter } from "./password"; -import { resolveGridsetPasswordFromEnv } from "./password"; -import { XMLParser } from "fast-xml-parser"; -import { - decodeText, - defaultFileAdapter, - FileAdapter, - type ProcessorInput, -} from "../../utils/io"; -import { getZipAdapter, ZipAdapter } from "../../utils/zip"; +import { getZipEntriesFromAdapter } from './password'; +import { resolveGridsetPasswordFromEnv } from './password'; +import { XMLParser } from 'fast-xml-parser'; +import { decodeText, defaultFileAdapter, FileAdapter, type ProcessorInput } from '../../utils/io'; +import { getZipAdapter, ZipAdapter } from '../../utils/zip'; export interface ImageIssue { gridName: string; @@ -22,7 +17,7 @@ export interface ImageIssue { cellY: number; declaredImage: string | undefined; expectedPaths: string[]; - issue: "not_found" | "symbol_library" | "external_reference"; + issue: 'not_found' | 'symbol_library' | 'external_reference'; suggestion: string; } @@ -52,7 +47,7 @@ export async function auditGridsetImages( gridsetBuffer: Uint8Array, password = resolveGridsetPasswordFromEnv(), fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise, + zipAdapter?: (input: ProcessorInput) => Promise ): Promise { const issues: ImageIssue[] = []; const availableImages = new Set(); @@ -70,15 +65,7 @@ export async function auditGridsetImages( const parser = new XMLParser(); // Collect all image files in the gridset - const imageExtensions = [ - ".png", - ".jpg", - ".jpeg", - ".bmp", - ".gif", - ".emf", - ".wmf", - ]; + const imageExtensions = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.emf', '.wmf']; for (const entry of entries) { const name = entry.entryName.toLowerCase(); if (imageExtensions.some((ext) => name.endsWith(ext))) { @@ -88,10 +75,7 @@ export async function auditGridsetImages( // Process each grid file for (const entry of entries) { - if ( - !entry.entryName.startsWith("Grids/") || - !entry.entryName.endsWith("grid.xml") - ) { + if (!entry.entryName.startsWith('Grids/') || !entry.entryName.endsWith('grid.xml')) { continue; } @@ -104,37 +88,27 @@ export async function auditGridsetImages( const gridNameMatch = entry.entryName.match(/^Grids\/([^/]+)\//); const gridName = gridNameMatch ? gridNameMatch[1] : entry.entryName; - const gridEntryPath = entry.entryName.replace(/\\/g, "/"); - const baseDir = gridEntryPath.replace(/\/grid\.xml$/, "/"); + const gridEntryPath = entry.entryName.replace(/\\/g, '/'); + const baseDir = gridEntryPath.replace(/\/grid\.xml$/, '/'); // Check for FileMap.xml - const fileMapEntry = entries.find( - (e) => e.entryName === baseDir + "FileMap.xml", - ); + const fileMapEntry = entries.find((e) => e.entryName === baseDir + 'FileMap.xml'); const dynamicFilesMap = new Map(); if (fileMapEntry) { try { const fmXml = decodeText(await fileMapEntry.getData()); const fmData = parser.parse(fmXml); - const fileEntries = - fmData?.FileMap?.Entries?.Entry || - fmData?.fileMap?.entries?.entry; + const fileEntries = fmData?.FileMap?.Entries?.Entry || fmData?.fileMap?.entries?.entry; if (fileEntries) { - const arr = Array.isArray(fileEntries) - ? fileEntries - : [fileEntries]; + const arr = Array.isArray(fileEntries) ? fileEntries : [fileEntries]; for (const ent of arr) { - const rawStaticFile = - ent["@_StaticFile"] || ent.StaticFile || ent.staticFile; + const rawStaticFile = ent['@_StaticFile'] || ent.StaticFile || ent.staticFile; const staticFile = - typeof rawStaticFile === "string" - ? rawStaticFile.replace(/\\/g, "/") - : ""; + typeof rawStaticFile === 'string' ? rawStaticFile.replace(/\\/g, '/') : ''; if (!staticFile) continue; const df = ent.DynamicFiles || ent.dynamicFiles; - const candidates = - df?.File || df?.file || df?.Files || df?.files; + const candidates = df?.File || df?.file || df?.Files || df?.files; const list: string[] = Array.isArray(candidates) ? candidates : candidates @@ -159,8 +133,7 @@ export async function auditGridsetImages( const content = cell.Content; if (!content) continue; - const captionAndImage = - content.CaptionAndImage || content.captionAndImage; + const captionAndImage = content.CaptionAndImage || content.captionAndImage; const imageCandidate = captionAndImage?.Image || captionAndImage?.image || @@ -171,14 +144,8 @@ export async function auditGridsetImages( cellsWithImages++; - const cellX = Math.max( - 0, - parseInt(String(cell["@_X"] || "1"), 10) - 1, - ); - const cellY = Math.max( - 0, - parseInt(String(cell["@_Y"] || "1"), 10) - 1, - ); + const cellX = Math.max(0, parseInt(String(cell['@_X'] || '1'), 10) - 1); + const cellY = Math.max(0, parseInt(String(cell['@_Y'] || '1'), 10) - 1); // Try to resolve the image const imageName = String(imageCandidate).trim(); @@ -199,38 +166,30 @@ export async function auditGridsetImages( `${baseDir}${cellX + 1}-${cellY + 1}.png`, ]; - let issue: ImageIssue["issue"]; + let issue: ImageIssue['issue']; let suggestion: string; - if (imageName.startsWith("[")) { + if (imageName.startsWith('[')) { // Check if it's a symbol library reference - if ( - imageName.includes("widgit") || - imageName.includes("Widgit") - ) { - issue = "symbol_library"; + if (imageName.includes('widgit') || imageName.includes('Widgit')) { + issue = 'symbol_library'; suggestion = - "This is a Widgit symbol library reference. These symbols are not stored in the gridset - they require the Widgit Symbols to be installed on the system."; - } else if ( - imageName.includes("grid3x") || - imageName.includes("Grid3") - ) { - issue = "external_reference"; + 'This is a Widgit symbol library reference. These symbols are not stored in the gridset - they require the Widgit Symbols to be installed on the system.'; + } else if (imageName.includes('grid3x') || imageName.includes('Grid3')) { + issue = 'external_reference'; suggestion = - "This is a built-in Grid3 resource reference. These images are not included in the gridset file."; + 'This is a built-in Grid3 resource reference. These images are not included in the gridset file.'; } else { - issue = "symbol_library"; + issue = 'symbol_library'; suggestion = `External symbol library reference: ${imageName}. Symbol libraries are not embedded in gridset files.`; } } else { - issue = "not_found"; + issue = 'not_found'; const similarImages = Array.from(availableImages).filter((img) => - img - .toLowerCase() - .includes(imageName.toLowerCase().substring(0, 10)), + img.toLowerCase().includes(imageName.toLowerCase().substring(0, 10)) ); if (similarImages.length > 0) { - suggestion = `Image not found. Did you mean one of these?\n ${similarImages.slice(0, 3).join("\n ")}`; + suggestion = `Image not found. Did you mean one of these?\n ${similarImages.slice(0, 3).join('\n ')}`; } else { suggestion = `Image file not found in gridset. The file may have been excluded or the path is incorrect.`; } @@ -272,19 +231,19 @@ export async function auditGridsetImages( export function formatImageAuditSummary(audit: ImageAuditResult): string { const lines: string[] = []; - lines.push("=== Grid3 Image Audit Summary ==="); + lines.push('=== Grid3 Image Audit Summary ==='); lines.push(`Total cells: ${audit.totalCells}`); lines.push(`Cells with images: ${audit.cellsWithImages}`); lines.push(`Resolved images: ${audit.resolvedImages}`); lines.push(`Unresolved images: ${audit.unresolvedImages}`); lines.push(`Available image files: ${audit.availableImages.length}`); - lines.push(""); + lines.push(''); if (audit.issues.length > 0) { - lines.push("=== Image Issues ==="); + lines.push('=== Image Issues ==='); // Group by issue type - const byType = new Map(); + const byType = new Map(); for (const issue of audit.issues) { const list = byType.get(issue.issue) || []; list.push(issue); @@ -296,7 +255,7 @@ export function formatImageAuditSummary(audit: ImageAuditResult): string { for (const issue of issues.slice(0, 5)) { // Show first 5 of each type lines.push( - ` [${issue.gridName}] Cell (${issue.cellX}, ${issue.cellY}): ${issue.declaredImage}`, + ` [${issue.gridName}] Cell (${issue.cellX}, ${issue.cellY}): ${issue.declaredImage}` ); lines.push(` → ${issue.suggestion}`); } @@ -306,5 +265,5 @@ export function formatImageAuditSummary(audit: ImageAuditResult): string { } } - return lines.join("\n"); + return lines.join('\n'); } diff --git a/src/processors/gridset/index.ts b/src/processors/gridset/index.ts index 9925b93..333ea17 100644 --- a/src/processors/gridset/index.ts +++ b/src/processors/gridset/index.ts @@ -18,7 +18,7 @@ export { CATEGORY_STYLES, createDefaultStylesXml, createCategoryStyle, -} from "./styleHelpers"; +} from './styleHelpers'; // Plugin cell type detection export { @@ -33,7 +33,7 @@ export { isLiveCell, isAutoContentCell, isRegularCell, -} from "./pluginTypes"; +} from './pluginTypes'; // Command definitions and detection export { @@ -49,22 +49,16 @@ export { type CommandParameter, type ExtractedParameters, Grid3CommandCategory, -} from "./commands"; +} from './commands'; // Import for local use in constant definitions -import { getAllCommandIds, getAllPluginIds } from "./commands"; -import { CellBackgroundShape } from "./styleHelpers"; -import { Grid3CellType } from "./pluginTypes"; -import { Grid3CommandCategory } from "./commands"; +import { getAllCommandIds, getAllPluginIds } from './commands'; +import { CellBackgroundShape } from './styleHelpers'; +import { Grid3CellType } from './pluginTypes'; +import { Grid3CommandCategory } from './commands'; // Color utilities -export { - ensureAlphaChannel, - darkenColor, - lightenColor, - hexToRgba, - rgbaToHex, -} from "./colorUtils"; +export { ensureAlphaChannel, darkenColor, lightenColor, hexToRgba, rgbaToHex } from './colorUtils'; // Password handling export { @@ -72,7 +66,7 @@ export { getZipEntriesWithPassword, getZipEntriesFromAdapter, resolveGridsetPasswordFromEnv, -} from "./password"; +} from './password'; // Helper functions export { @@ -95,7 +89,7 @@ export { type Grid3UserPath, type Grid3VocabularyPath, type Grid3HistoryEntry, -} from "./helpers"; +} from './helpers'; // Symbol library handling export { @@ -122,17 +116,17 @@ export { type SymbolResolutionResult, type SymbolUsageStats, type SymbolLibraryName, -} from "./symbols"; +} from './symbols'; // Backward compatibility aliases for old function names -export { getSymbolsDir, getSymbolSearchDir } from "./symbols"; +export { getSymbolsDir, getSymbolSearchDir } from './symbols'; // Image resolution export { resolveGrid3CellImage, isSymbolLibraryReference, parseImageSymbolReference, -} from "./resolver"; +} from './resolver'; // Symbol extraction and conversion export { @@ -147,7 +141,7 @@ export { type SymbolExtractionOptions, type SymbolReport, type SymbolManifest, -} from "./symbolExtractor"; +} from './symbolExtractor'; // Symbol search functionality export { @@ -165,7 +159,7 @@ export { type SymbolSearchOptions, type LibrarySearchIndex, type SymbolSearchStats, -} from "./symbolSearch"; +} from './symbolSearch'; /** * Get all Grid 3 command IDs as a readonly array @@ -182,9 +176,7 @@ export const GRID3_PLUGIN_IDS = Object.freeze(getAllPluginIds()); * Grid 3 cell shapes enum values */ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -export const GRID3_CELL_SHAPES = Object.freeze( - Object.values(CellBackgroundShape), -); +export const GRID3_CELL_SHAPES = Object.freeze(Object.values(CellBackgroundShape)); /** * Grid 3 cell types enum values @@ -196,6 +188,4 @@ export const GRID3_CELL_TYPES = Object.freeze(Object.values(Grid3CellType)); * Grid 3 command categories enum values */ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -export const GRID3_COMMAND_CATEGORIES = Object.freeze( - Object.values(Grid3CommandCategory), -); +export const GRID3_COMMAND_CATEGORIES = Object.freeze(Object.values(Grid3CommandCategory)); diff --git a/src/processors/gridset/password.ts b/src/processors/gridset/password.ts index 83b795c..75f8896 100644 --- a/src/processors/gridset/password.ts +++ b/src/processors/gridset/password.ts @@ -1,11 +1,11 @@ -import type JSZip from "jszip"; -import { ProcessorOptions } from "../../core/baseProcessor"; -import { ProcessorInput } from "../../utils/io"; -import { ZipAdapter } from "../../utils/zip"; +import type JSZip from 'jszip'; +import { ProcessorOptions } from '../../core/baseProcessor'; +import { ProcessorInput } from '../../utils/io'; +import { ZipAdapter } from '../../utils/zip'; function getExtension(source: string): string { - const index = source.lastIndexOf("."); - if (index === -1) return ""; + const index = source.lastIndexOf('.'); + if (index === -1) return ''; return source.slice(index); } @@ -17,25 +17,22 @@ function getExtension(source: string): string { */ export function resolveGridsetPassword( options?: ProcessorOptions, - source?: ProcessorInput, + source?: ProcessorInput ): string | undefined { if (options?.gridsetPassword) return options.gridsetPassword; - const envPassword = - typeof process !== "undefined" ? process.env?.GRIDSET_PASSWORD : undefined; + const envPassword = typeof process !== 'undefined' ? process.env?.GRIDSET_PASSWORD : undefined; if (envPassword) return envPassword; - if (typeof source === "string") { + if (typeof source === 'string') { const ext = getExtension(source).toLowerCase(); - if (ext === ".gridsetx") return envPassword; + if (ext === '.gridsetx') return envPassword; } return undefined; } export function resolveGridsetPasswordFromEnv(): string | undefined { - return typeof process !== "undefined" - ? process.env?.GRIDSET_PASSWORD - : undefined; + return typeof process !== 'undefined' ? process.env?.GRIDSET_PASSWORD : undefined; } /** @@ -54,10 +51,7 @@ export type ZipEntry = { getData: () => Promise; }; -export function getZipEntriesWithPassword( - zip: JSZip, - password?: string, -): ZipEntry[] { +export function getZipEntriesWithPassword(zip: JSZip, password?: string): ZipEntry[] { const entries: Array<{ name: string; entryName: string; @@ -70,7 +64,7 @@ export function getZipEntriesWithPassword( // in crypto.ts before the zip is loaded if (password) { console.warn( - "JSZip does not support zip-level password protection. For .gridsetx encrypted files, password is handled at the archive level.", + 'JSZip does not support zip-level password protection. For .gridsetx encrypted files, password is handled at the archive level.' ); } @@ -81,7 +75,7 @@ export function getZipEntriesWithPassword( dir: file.dir || false, getData: async () => { // Use 'uint8array' which is supported everywhere - return await file.async("uint8array"); + return await file.async('uint8array'); }, }); }); @@ -89,14 +83,9 @@ export function getZipEntriesWithPassword( return entries; } -export function getZipEntriesFromAdapter( - zip: ZipAdapter, - password?: string, -): ZipEntry[] { +export function getZipEntriesFromAdapter(zip: ZipAdapter, password?: string): ZipEntry[] { if (password) { - console.warn( - "Zip password support is handled at the archive level for .gridsetx files.", - ); + console.warn('Zip password support is handled at the archive level for .gridsetx files.'); } return zip.listFiles().map((entryName) => ({ diff --git a/src/processors/gridset/pluginTypes.ts b/src/processors/gridset/pluginTypes.ts index 0ed49fd..f314a95 100644 --- a/src/processors/gridset/pluginTypes.ts +++ b/src/processors/gridset/pluginTypes.ts @@ -13,10 +13,10 @@ * Cell types in Grid 3 */ export enum Grid3CellType { - Regular = "regular", - Workspace = "workspace", - LiveCell = "livecell", - AutoContent = "autocontent", + Regular = 'regular', + Workspace = 'workspace', + LiveCell = 'livecell', + AutoContent = 'autocontent', } /** @@ -35,56 +35,56 @@ export interface Grid3PluginMetadata { * Known workspace types in Grid 3 */ export const WORKSPACE_TYPES = { - CHAT: "Chat", - EMAIL: "Email", - WORD_PROCESSOR: "WordProcessor", - PHONE: "Phone", - SMS: "Sms", - WEB_BROWSER: "WebBrowser", - COMPUTER_CONTROL: "ComputerControl", - CALCULATOR: "Calculator", - TIMER: "Timer", - MUSIC_VIDEO: "MusicVideo", - PHOTOS: "Photos", - CONTACTS: "Contacts", - INTERACTIVE_LEARNING: "InteractiveLearning", - MESSAGE_BANKING: "MessageBanking", - ENVIRONMENT_CONTROL: "EnvironmentControl", - SETTINGS: "Settings", + CHAT: 'Chat', + EMAIL: 'Email', + WORD_PROCESSOR: 'WordProcessor', + PHONE: 'Phone', + SMS: 'Sms', + WEB_BROWSER: 'WebBrowser', + COMPUTER_CONTROL: 'ComputerControl', + CALCULATOR: 'Calculator', + TIMER: 'Timer', + MUSIC_VIDEO: 'MusicVideo', + PHOTOS: 'Photos', + CONTACTS: 'Contacts', + INTERACTIVE_LEARNING: 'InteractiveLearning', + MESSAGE_BANKING: 'MessageBanking', + ENVIRONMENT_CONTROL: 'EnvironmentControl', + SETTINGS: 'Settings', } as const; /** * Known live cell types in Grid 3 */ export const LIVECELL_TYPES = { - DIGITAL_CLOCK: "DigitalClock", - ANALOG_CLOCK: "AnalogClock", - DATE_DISPLAY: "DateDisplay", - PUBLIC_VOLUME: "PublicVolume", - PUBLIC_SPEED: "PublicSpeed", - PUBLIC_VOICE: "PublicVoice", - MESSAGES: "Messages", - BATTERY: "Battery", - WIFI_STRENGTH: "WifiStrength", - BLUETOOTH_STATUS: "BluetoothStatus", + DIGITAL_CLOCK: 'DigitalClock', + ANALOG_CLOCK: 'AnalogClock', + DATE_DISPLAY: 'DateDisplay', + PUBLIC_VOLUME: 'PublicVolume', + PUBLIC_SPEED: 'PublicSpeed', + PUBLIC_VOICE: 'PublicVoice', + MESSAGES: 'Messages', + BATTERY: 'Battery', + WIFI_STRENGTH: 'WifiStrength', + BLUETOOTH_STATUS: 'BluetoothStatus', } as const; /** * Known auto content types in Grid 3 */ export const AUTOCONTENT_TYPES = { - CHANGE_PUBLIC_VOICE: "ChangePublicVoice", - CHANGE_PUBLIC_SPEED: "ChangePublicSpeed", - EMAIL_CONTACTS: "EmailContacts", - EMAIL_RECIPIENTS: "EmailRecipients", - PHONE_CONTACTS: "PhoneContacts", - SMS_CONTACTS: "SmsContacts", - WEB_FAVORITES: "WebFavorites", - WEB_HISTORY: "WebHistory", - PREDICTION: "Prediction", - GRAMMAR: "Grammar", - CONTEXTUAL: "Contextual", - WORDLIST: "WordList", + CHANGE_PUBLIC_VOICE: 'ChangePublicVoice', + CHANGE_PUBLIC_SPEED: 'ChangePublicSpeed', + EMAIL_CONTACTS: 'EmailContacts', + EMAIL_RECIPIENTS: 'EmailRecipients', + PHONE_CONTACTS: 'PhoneContacts', + SMS_CONTACTS: 'SmsContacts', + WEB_FAVORITES: 'WebFavorites', + WEB_HISTORY: 'WebHistory', + PREDICTION: 'Prediction', + GRAMMAR: 'Grammar', + CONTEXTUAL: 'Contextual', + WORDLIST: 'WordList', } as const; /** @@ -93,15 +93,15 @@ export const AUTOCONTENT_TYPES = { export function getCellTypeDisplayName(cellType: Grid3CellType): string { switch (cellType) { case Grid3CellType.Workspace: - return "Workspace"; + return 'Workspace'; case Grid3CellType.LiveCell: - return "Live Cell"; + return 'Live Cell'; case Grid3CellType.AutoContent: - return "Auto Content"; + return 'Auto Content'; case Grid3CellType.Regular: - return "Regular"; + return 'Regular'; default: - return "Unknown"; + return 'Unknown'; } } @@ -120,44 +120,33 @@ export function detectPluginCellType(content: any): Grid3PluginMetadata { const contentSubType = content.ContentSubType || content.contentsubtype; // Workspace cells - full editing workspaces - if ( - contentType === "Workspace" || - content.Style?.BasedOnStyle === "Workspace" - ) { + if (contentType === 'Workspace' || content.Style?.BasedOnStyle === 'Workspace') { return { cellType: Grid3CellType.Workspace, subType: contentSubType || undefined, - pluginId: inferWorkspacePlugin(String(contentSubType || "")), - displayName: contentSubType ? `${contentSubType} Workspace` : "Workspace", + pluginId: inferWorkspacePlugin(String(contentSubType || '')), + displayName: contentSubType ? `${contentSubType} Workspace` : 'Workspace', }; } // LiveCell detection - dynamic content displays - if ( - contentType === "LiveCell" || - content.Style?.BasedOnStyle === "LiveCell" - ) { + if (contentType === 'LiveCell' || content.Style?.BasedOnStyle === 'LiveCell') { return { cellType: Grid3CellType.LiveCell, liveCellType: contentSubType || undefined, - pluginId: inferLiveCellPlugin(String(contentSubType || "")), - displayName: contentSubType || "Live Cell", + pluginId: inferLiveCellPlugin(String(contentSubType || '')), + displayName: contentSubType || 'Live Cell', }; } // AutoContent detection - dynamic word/content suggestions - if ( - contentType === "AutoContent" || - content.Style?.BasedOnStyle === "AutoContent" - ) { + if (contentType === 'AutoContent' || content.Style?.BasedOnStyle === 'AutoContent') { const autoContentType = extractAutoContentType(content) || contentSubType; return { cellType: Grid3CellType.AutoContent, autoContentType: autoContentType ? String(autoContentType) : undefined, - pluginId: inferAutoContentPlugin( - autoContentType ? String(autoContentType) : undefined, - ), - displayName: autoContentType ? String(autoContentType) : "Auto Content", + pluginId: inferAutoContentPlugin(autoContentType ? String(autoContentType) : undefined), + displayName: autoContentType ? String(autoContentType) : 'Auto Content', }; } @@ -177,19 +166,15 @@ function extractAutoContentType(content: any): string | undefined { const commandArr = Array.isArray(commands) ? commands : [commands]; for (const command of commandArr) { - const commandId = command["@_ID"] || command.ID || command.id; - if (commandId === "AutoContent.Activate") { + const commandId = command['@_ID'] || command.ID || command.id; + if (commandId === 'AutoContent.Activate') { const parameters = command.Parameter || command.parameter; - const paramArr = Array.isArray(parameters) - ? parameters - : parameters - ? [parameters] - : []; + const paramArr = Array.isArray(parameters) ? parameters : parameters ? [parameters] : []; for (const param of paramArr) { - const key = param["@_Key"] || param.Key || param.key; - if (key === "autocontenttype") { - return String(param["#text"] || param.text || param.value || ""); + const key = param['@_Key'] || param.Key || param.key; + if (key === 'autocontenttype') { + return String(param['#text'] || param.text || param.value || ''); } } } @@ -206,29 +191,23 @@ function inferWorkspacePlugin(subType?: string): string | undefined { const normalized = subType.toLowerCase(); - if (normalized.includes("chat")) return "Grid3.Chat"; - if (normalized.includes("email") || normalized.includes("mail")) - return "Grid3.Email"; - if (normalized.includes("word") || normalized.includes("doc")) - return "Grid3.WordProcessor"; - if (normalized.includes("phone")) return "Grid3.Phone"; - if (normalized.includes("sms") || normalized.includes("text")) - return "Grid3.Sms"; - if (normalized.includes("browser") || normalized.includes("web")) - return "Grid3.WebBrowser"; - if (normalized.includes("computer")) return "Grid3.ComputerControl"; - if (normalized.includes("calc")) return "Grid3.Calculator"; - if (normalized.includes("timer")) return "Grid3.Timer"; - if (normalized.includes("music") || normalized.includes("video")) - return "Grid3.MusicVideo"; - if (normalized.includes("photo") || normalized.includes("image")) - return "Grid3.Photos"; - if (normalized.includes("contact")) return "Grid3.Contacts"; - if (normalized.includes("learning")) return "Grid3.InteractiveLearning"; - if (normalized.includes("message") && normalized.includes("banking")) - return "Grid3.MessageBanking"; - if (normalized.includes("control")) return "Grid3.EnvironmentControl"; - if (normalized.includes("settings")) return "Grid3.Settings"; + if (normalized.includes('chat')) return 'Grid3.Chat'; + if (normalized.includes('email') || normalized.includes('mail')) return 'Grid3.Email'; + if (normalized.includes('word') || normalized.includes('doc')) return 'Grid3.WordProcessor'; + if (normalized.includes('phone')) return 'Grid3.Phone'; + if (normalized.includes('sms') || normalized.includes('text')) return 'Grid3.Sms'; + if (normalized.includes('browser') || normalized.includes('web')) return 'Grid3.WebBrowser'; + if (normalized.includes('computer')) return 'Grid3.ComputerControl'; + if (normalized.includes('calc')) return 'Grid3.Calculator'; + if (normalized.includes('timer')) return 'Grid3.Timer'; + if (normalized.includes('music') || normalized.includes('video')) return 'Grid3.MusicVideo'; + if (normalized.includes('photo') || normalized.includes('image')) return 'Grid3.Photos'; + if (normalized.includes('contact')) return 'Grid3.Contacts'; + if (normalized.includes('learning')) return 'Grid3.InteractiveLearning'; + if (normalized.includes('message') && normalized.includes('banking')) + return 'Grid3.MessageBanking'; + if (normalized.includes('control')) return 'Grid3.EnvironmentControl'; + if (normalized.includes('settings')) return 'Grid3.Settings'; return `Grid3.${subType}`; } @@ -241,15 +220,15 @@ function inferLiveCellPlugin(liveCellType?: string): string | undefined { const normalized = liveCellType.toLowerCase(); - if (normalized.includes("clock")) return "Grid3.Clock"; - if (normalized.includes("date")) return "Grid3.Clock"; - if (normalized.includes("volume")) return "Grid3.Volume"; - if (normalized.includes("speed")) return "Grid3.Speed"; - if (normalized.includes("voice")) return "Grid3.Speech"; - if (normalized.includes("message")) return "Grid3.Chat"; - if (normalized.includes("battery")) return "Grid3.Battery"; - if (normalized.includes("wifi")) return "Grid3.Wifi"; - if (normalized.includes("bluetooth")) return "Grid3.Bluetooth"; + if (normalized.includes('clock')) return 'Grid3.Clock'; + if (normalized.includes('date')) return 'Grid3.Clock'; + if (normalized.includes('volume')) return 'Grid3.Volume'; + if (normalized.includes('speed')) return 'Grid3.Speed'; + if (normalized.includes('voice')) return 'Grid3.Speech'; + if (normalized.includes('message')) return 'Grid3.Chat'; + if (normalized.includes('battery')) return 'Grid3.Battery'; + if (normalized.includes('wifi')) return 'Grid3.Wifi'; + if (normalized.includes('bluetooth')) return 'Grid3.Bluetooth'; return `Grid3.${liveCellType}`; } @@ -262,23 +241,20 @@ function inferAutoContentPlugin(autoContentType?: string): string | undefined { const normalized = autoContentType.toLowerCase(); - if (normalized.includes("voice") || normalized.includes("speed")) - return "Grid3.Speech"; - if (normalized.includes("email") || normalized.includes("mail")) - return "Grid3.Email"; - if (normalized.includes("phone")) return "Grid3.Phone"; - if (normalized.includes("sms") || normalized.includes("text")) - return "Grid3.Sms"; + if (normalized.includes('voice') || normalized.includes('speed')) return 'Grid3.Speech'; + if (normalized.includes('email') || normalized.includes('mail')) return 'Grid3.Email'; + if (normalized.includes('phone')) return 'Grid3.Phone'; + if (normalized.includes('sms') || normalized.includes('text')) return 'Grid3.Sms'; if ( - normalized.includes("web") || - normalized.includes("favorite") || - normalized.includes("history") + normalized.includes('web') || + normalized.includes('favorite') || + normalized.includes('history') ) { - return "Grid3.WebBrowser"; + return 'Grid3.WebBrowser'; } - if (normalized.includes("prediction")) return "Grid3.Prediction"; - if (normalized.includes("grammar")) return "Grid3.Grammar"; - if (normalized.includes("context")) return "Grid3.AutoContent"; + if (normalized.includes('prediction')) return 'Grid3.Prediction'; + if (normalized.includes('grammar')) return 'Grid3.Grammar'; + if (normalized.includes('context')) return 'Grid3.AutoContent'; return undefined; } diff --git a/src/processors/gridset/resolver.ts b/src/processors/gridset/resolver.ts index 6b1ed21..5fe396a 100644 --- a/src/processors/gridset/resolver.ts +++ b/src/processors/gridset/resolver.ts @@ -1,9 +1,9 @@ -import { isSymbolReference, parseSymbolReference } from "./symbols"; +import { isSymbolReference, parseSymbolReference } from './symbols'; function normalizeZipPathLocal(p: string): string { - const unified = p.replace(/\\/g, "/"); + const unified = p.replace(/\\/g, '/'); try { - return unified.normalize("NFC"); + return unified.normalize('NFC'); } catch { return unified; } @@ -14,7 +14,7 @@ function listZipEntries(zip: any, zipEntries?: any[]): string[] { const raw: unknown = Array.isArray(zipEntries) && zipEntries.length > 0 ? zipEntries - : typeof zip?.getEntries === "function" + : typeof zip?.getEntries === 'function' ? zip.getEntries() : []; let entries: unknown[] = []; @@ -33,8 +33,8 @@ function extFromName(name?: string): string | undefined { } function joinBaseDir(baseDir: string, leaf: string): string { - const base = normalizeZipPathLocal(baseDir).replace(/\/?$/, "/"); - return normalizeZipPathLocal(base + leaf.replace(/^\//, "")); + const base = normalizeZipPathLocal(baseDir).replace(/\/?$/, '/'); + return normalizeZipPathLocal(base + leaf.replace(/^\//, '')); } export function resolveGrid3CellImage( @@ -47,7 +47,7 @@ export function resolveGrid3CellImage( dynamicFiles?: string[]; builtinHandler?: (name: string) => string | null; }, - zipEntries?: any[], + zipEntries?: any[] ): string | null { const { baseDir, dynamicFiles } = args; const imageName = args.imageName?.trim(); @@ -58,8 +58,7 @@ export function resolveGrid3CellImage( const has = (p: string): boolean => entries.has(normalizeZipPathLocal(p)); // Debug logging for cells that fail to resolve - const shouldDebug = - imageName?.startsWith("-") && x !== undefined && y !== undefined; + const shouldDebug = imageName?.startsWith('-') && x !== undefined && y !== undefined; const debugLog = (msg: string): void => { if (shouldDebug) { console.log(`[Resolver] ${baseDir} (${x},${y}) "${imageName}": ${msg}`); @@ -68,13 +67,13 @@ export function resolveGrid3CellImage( // Built-in resource like [grid3x]... (old format, not symbol library) // Check this BEFORE general symbol references to avoid misclassification - if (imageName && imageName.startsWith("[")) { + if (imageName && imageName.startsWith('[')) { // Check if it's a symbol library reference like [widgit]/food/apple.png // Symbol library references have a path after the library name if (isSymbolReference(imageName)) { const parsed = parseSymbolReference(imageName); // If it's grid3x, it's a built-in resource, not a symbol library - if (parsed.library !== "grid3x") { + if (parsed.library !== 'grid3x') { // Symbol library references are NOT stored as files in the gridset // They are resolved from the external Grid 3 installation // Return null to indicate this is an external symbol reference @@ -94,11 +93,9 @@ export function resolveGrid3CellImage( // Check for partial image names that start with '-' (common in Grid3) // These are coordinate-based suffixes like "-0-text-0.png" that need // to be prefixed with the cell coordinates - if (imageName.startsWith("-") && x != null && y != null) { + if (imageName.startsWith('-') && x != null && y != null) { const coordPrefixed = joinBaseDir(baseDir, `${x}-${y}${imageName}`); - debugLog( - `trying coord-prefixed: ${coordPrefixed}, found: ${has(coordPrefixed)}`, - ); + debugLog(`trying coord-prefixed: ${coordPrefixed}, found: ${has(coordPrefixed)}`); if (has(coordPrefixed)) return coordPrefixed; } @@ -137,9 +134,7 @@ export function resolveGrid3CellImage( `${x}-${y}.jpg`, `${x}-${y}.png`, ].map((n) => joinBaseDir(baseDir, n)); - debugLog( - `trying candidates: ${candidates.filter(has).join(", ") || "none found"}`, - ); + debugLog(`trying candidates: ${candidates.filter(has).join(', ') || 'none found'}`); for (const c of candidates) { if (has(c)) return c; } @@ -166,7 +161,7 @@ export function isSymbolLibraryReference(imageName?: string): boolean { * @returns Parsed reference or null if not a symbol reference */ export function parseImageSymbolReference( - imageName: string, + imageName: string ): ReturnType | null { if (!isSymbolLibraryReference(imageName)) { return null; diff --git a/src/processors/gridset/styleHelpers.ts b/src/processors/gridset/styleHelpers.ts index d8d1fb4..c10146c 100644 --- a/src/processors/gridset/styleHelpers.ts +++ b/src/processors/gridset/styleHelpers.ts @@ -5,8 +5,8 @@ * style XML generation, and style conversion utilities. */ -import { XMLBuilder } from "fast-xml-parser"; -import { ensureAlphaChannel, darkenColor } from "./colorUtils"; +import { XMLBuilder } from 'fast-xml-parser'; +import { ensureAlphaChannel, darkenColor } from './colorUtils'; /** * Cell background shapes supported by Grid 3 @@ -30,17 +30,17 @@ export enum CellBackgroundShape { * Human-readable shape names */ export const SHAPE_NAMES: Record = { - [CellBackgroundShape.Rectangle]: "Rectangle", - [CellBackgroundShape.RoundedRectangle]: "Rounded Rectangle", - [CellBackgroundShape.FoldedCorner]: "Folded Corner", - [CellBackgroundShape.Octagon]: "Octagon", - [CellBackgroundShape.Folder]: "Folder", - [CellBackgroundShape.Ellipse]: "Ellipse", - [CellBackgroundShape.SpeechBubble]: "Speech Bubble", - [CellBackgroundShape.ThoughtBubble]: "Thought Bubble", - [CellBackgroundShape.Star]: "Star", - [CellBackgroundShape.Circle]: "Circle", - [CellBackgroundShape.ColouredCorner]: "Coloured Corner", + [CellBackgroundShape.Rectangle]: 'Rectangle', + [CellBackgroundShape.RoundedRectangle]: 'Rounded Rectangle', + [CellBackgroundShape.FoldedCorner]: 'Folded Corner', + [CellBackgroundShape.Octagon]: 'Octagon', + [CellBackgroundShape.Folder]: 'Folder', + [CellBackgroundShape.Ellipse]: 'Ellipse', + [CellBackgroundShape.SpeechBubble]: 'Speech Bubble', + [CellBackgroundShape.ThoughtBubble]: 'Thought Bubble', + [CellBackgroundShape.Star]: 'Star', + [CellBackgroundShape.Circle]: 'Circle', + [CellBackgroundShape.ColouredCorner]: 'Coloured Corner', }; /** @@ -62,44 +62,44 @@ export interface Grid3Style { */ export const DEFAULT_GRID3_STYLES: Record = { Default: { - BackColour: "#E2EDF8FF", - TileColour: "#FFFFFFFF", - BorderColour: "#000000FF", - FontColour: "#000000FF", - FontName: "Arial", - FontSize: "16", + BackColour: '#E2EDF8FF', + TileColour: '#FFFFFFFF', + BorderColour: '#000000FF', + FontColour: '#000000FF', + FontName: 'Arial', + FontSize: '16', }, Workspace: { - BackColour: "#FFFFFFFF", - TileColour: "#FFFFFFFF", - BorderColour: "#CCCCCCFF", - FontColour: "#000000FF", - FontName: "Arial", - FontSize: "14", + BackColour: '#FFFFFFFF', + TileColour: '#FFFFFFFF', + BorderColour: '#CCCCCCFF', + FontColour: '#000000FF', + FontName: 'Arial', + FontSize: '14', }, - "Auto content": { - BackColour: "#E8F4F8FF", - TileColour: "#E8F4F8FF", - BorderColour: "#2C82C9FF", - FontColour: "#000000FF", - FontName: "Arial", - FontSize: "14", + 'Auto content': { + BackColour: '#E8F4F8FF', + TileColour: '#E8F4F8FF', + BorderColour: '#2C82C9FF', + FontColour: '#000000FF', + FontName: 'Arial', + FontSize: '14', }, - "Vocab cell": { - BackColour: "#E8F4F8FF", - TileColour: "#E8F4F8FF", - BorderColour: "#2C82C9FF", - FontColour: "#000000FF", - FontName: "Arial", - FontSize: "14", + 'Vocab cell': { + BackColour: '#E8F4F8FF', + TileColour: '#E8F4F8FF', + BorderColour: '#2C82C9FF', + FontColour: '#000000FF', + FontName: 'Arial', + FontSize: '14', }, - "Keyboard key": { - BackColour: "#F0F0F0FF", - TileColour: "#F0F0F0FF", - BorderColour: "#808080FF", - FontColour: "#000000FF", - FontName: "Arial", - FontSize: "12", + 'Keyboard key': { + BackColour: '#F0F0F0FF', + TileColour: '#F0F0F0FF', + BorderColour: '#808080FF', + FontColour: '#000000FF', + FontName: 'Arial', + FontSize: '12', }, }; @@ -107,61 +107,61 @@ export const DEFAULT_GRID3_STYLES: Record = { * Category-specific styles for navigation and organization */ export const CATEGORY_STYLES: Record = { - "Actions category style": { - BackColour: "#4472C4FF", - TileColour: "#4472C4FF", - BorderColour: "#2F5496FF", - FontColour: "#FFFFFFFF", - FontName: "Arial", - FontSize: "16", + 'Actions category style': { + BackColour: '#4472C4FF', + TileColour: '#4472C4FF', + BorderColour: '#2F5496FF', + FontColour: '#FFFFFFFF', + FontName: 'Arial', + FontSize: '16', }, - "People category style": { - BackColour: "#ED7D31FF", - TileColour: "#ED7D31FF", - BorderColour: "#C65911FF", - FontColour: "#FFFFFFFF", - FontName: "Arial", - FontSize: "16", + 'People category style': { + BackColour: '#ED7D31FF', + TileColour: '#ED7D31FF', + BorderColour: '#C65911FF', + FontColour: '#FFFFFFFF', + FontName: 'Arial', + FontSize: '16', }, - "Places category style": { - BackColour: "#A5A5A5FF", - TileColour: "#A5A5A5FF", - BorderColour: "#595959FF", - FontColour: "#FFFFFFFF", - FontName: "Arial", - FontSize: "16", + 'Places category style': { + BackColour: '#A5A5A5FF', + TileColour: '#A5A5A5FF', + BorderColour: '#595959FF', + FontColour: '#FFFFFFFF', + FontName: 'Arial', + FontSize: '16', }, - "Descriptive category style": { - BackColour: "#70AD47FF", - TileColour: "#70AD47FF", - BorderColour: "#4F7C2FFF", - FontColour: "#FFFFFFFF", - FontName: "Arial", - FontSize: "16", + 'Descriptive category style': { + BackColour: '#70AD47FF', + TileColour: '#70AD47FF', + BorderColour: '#4F7C2FFF', + FontColour: '#FFFFFFFF', + FontName: 'Arial', + FontSize: '16', }, - "Social category style": { - BackColour: "#FFC000FF", - TileColour: "#FFC000FF", - BorderColour: "#BF8F00FF", - FontColour: "#000000FF", - FontName: "Arial", - FontSize: "16", + 'Social category style': { + BackColour: '#FFC000FF', + TileColour: '#FFC000FF', + BorderColour: '#BF8F00FF', + FontColour: '#000000FF', + FontName: 'Arial', + FontSize: '16', }, - "Questions category style": { - BackColour: "#5B9BD5FF", - TileColour: "#5B9BD5FF", - BorderColour: "#2E5C8AFF", - FontColour: "#FFFFFFFF", - FontName: "Arial", - FontSize: "16", + 'Questions category style': { + BackColour: '#5B9BD5FF', + TileColour: '#5B9BD5FF', + BorderColour: '#2E5C8AFF', + FontColour: '#FFFFFFFF', + FontName: 'Arial', + FontSize: '16', }, - "Little words category style": { - BackColour: "#C55A11FF", - TileColour: "#C55A11FF", - BorderColour: "#8B3F0AFF", - FontColour: "#FFFFFFFF", - FontName: "Arial", - FontSize: "16", + 'Little words category style': { + BackColour: '#C55A11FF', + TileColour: '#C55A11FF', + BorderColour: '#8B3F0AFF', + FontColour: '#FFFFFFFF', + FontName: 'Arial', + FontSize: '16', }, }; @@ -169,20 +169,18 @@ export const CATEGORY_STYLES: Record = { * Re-export ensureAlphaChannel from colorUtils for backward compatibility * @deprecated Use ensureAlphaChannel from colorUtils instead */ -export { ensureAlphaChannel } from "./colorUtils"; +export { ensureAlphaChannel } from './colorUtils'; /** * Create a Grid3 style XML string with default and category styles * @param includeCategories - Whether to include category-specific styles (default: true) * @returns XML string for Settings0/styles.xml */ -export function createDefaultStylesXml( - includeCategories: boolean = true, -): string { +export function createDefaultStylesXml(includeCategories: boolean = true): string { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', }); const styles = { ...DEFAULT_GRID3_STYLES }; @@ -191,7 +189,7 @@ export function createDefaultStylesXml( } const styleArray = Object.entries(styles).map(([key, style]) => ({ - "@_Key": key, + '@_Key': key, BackColour: style.BackColour, TileColour: style.TileColour, BorderColour: style.BorderColour, @@ -202,7 +200,7 @@ export function createDefaultStylesXml( const stylesData = { StyleData: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', Styles: { Style: styleArray, }, @@ -222,14 +220,14 @@ export function createDefaultStylesXml( export function createCategoryStyle( categoryName: string, backgroundColor: string, - fontColor: string = "#FFFFFFFF", + fontColor: string = '#FFFFFFFF' ): Grid3Style { return { BackColour: ensureAlphaChannel(backgroundColor), TileColour: ensureAlphaChannel(backgroundColor), BorderColour: ensureAlphaChannel(darkenColor(backgroundColor, 30)), FontColour: ensureAlphaChannel(fontColor), - FontName: "Arial", - FontSize: "16", + FontName: 'Arial', + FontSize: '16', }; } diff --git a/src/processors/gridset/symbolAlignment.ts b/src/processors/gridset/symbolAlignment.ts index 99b3c15..b26fe1e 100644 --- a/src/processors/gridset/symbolAlignment.ts +++ b/src/processors/gridset/symbolAlignment.ts @@ -61,10 +61,10 @@ export interface TranslatedMessage { */ export function parseMessageWithSymbols( message: string, - richTextSymbols?: Array<{ text: string; image?: string }>, + richTextSymbols?: Array<{ text: string; image?: string }> ): ParsedMessage { // Normalize whitespace for consistent tokenization - const normalizedMessage = message.trim().replace(/\s+/g, " "); + const normalizedMessage = message.trim().replace(/\s+/g, ' '); // Tokenize into words, preserving punctuation const words: string[] = []; @@ -99,7 +99,7 @@ export function parseMessageWithSymbols( if (wordIndex !== -1) { const pos = wordPositions[wordIndex]; symbols.push({ - symbolRef: sym.image || "", + symbolRef: sym.image || '', wordIndex, originalWord: sym.text, startPos: pos.start, @@ -107,15 +107,15 @@ export function parseMessageWithSymbols( }); } else { // Fuzzy match - find closest word (handles case differences, punctuation) - const normalizedSymText = sym.text.toLowerCase().replace(/[^\w]/g, ""); + const normalizedSymText = sym.text.toLowerCase().replace(/[^\w]/g, ''); const fuzzyIndex = words.findIndex( - (w) => w.toLowerCase().replace(/[^\w]/g, "") === normalizedSymText, + (w) => w.toLowerCase().replace(/[^\w]/g, '') === normalizedSymText ); if (fuzzyIndex !== -1) { const pos = wordPositions[fuzzyIndex]; symbols.push({ - symbolRef: sym.image || "", + symbolRef: sym.image || '', wordIndex: fuzzyIndex, originalWord: words[fuzzyIndex], startPos: pos.start, @@ -151,23 +151,23 @@ export function parseMessageWithSymbols( */ export function alignWords( originalWords: string[], - translatedWords: string[], -): TranslatedMessage["alignment"] { - const alignment: TranslatedMessage["alignment"] = []; + translatedWords: string[] +): TranslatedMessage['alignment'] { + const alignment: TranslatedMessage['alignment'] = []; // Strategy 1: Try to match identical words (numbers, names, cognates) const matchedTranslatedIndices = new Set(); for (let origIdx = 0; origIdx < originalWords.length; origIdx++) { const origWord = originalWords[origIdx]; - const normalizedOrig = origWord.toLowerCase().replace(/[^\w]/g, ""); + const normalizedOrig = origWord.toLowerCase().replace(/[^\w]/g, ''); // Try to find this word in the translation for (let transIdx = 0; transIdx < translatedWords.length; transIdx++) { if (matchedTranslatedIndices.has(transIdx)) continue; const transWord = translatedWords[transIdx]; - const normalizedTrans = transWord.toLowerCase().replace(/[^\w]/g, ""); + const normalizedTrans = transWord.toLowerCase().replace(/[^\w]/g, ''); // Exact match (case-insensitive, ignoring punctuation) if (normalizedOrig === normalizedTrans && normalizedOrig.length > 0) { @@ -231,7 +231,7 @@ export function alignWords( export function reattachSymbols( translatedText: string, originalParsed: ParsedMessage, - alignment: TranslatedMessage["alignment"], + alignment: TranslatedMessage['alignment'] ): { text: string; richTextSymbols: Array<{ text: string; image?: string }>; @@ -239,7 +239,7 @@ export function reattachSymbols( // Tokenize the translated text const translatedWords = translatedText .trim() - .replace(/\s+/g, " ") + .replace(/\s+/g, ' ') .split(/\s+/) .filter((w) => w.length > 0); @@ -248,14 +248,9 @@ export function reattachSymbols( for (const symbol of originalParsed.symbols) { // Find the alignment for this word - const wordAlignment = alignment.find( - (a) => a.originalIndex === symbol.wordIndex, - ); - - if ( - wordAlignment && - wordAlignment.translatedIndex < translatedWords.length - ) { + const wordAlignment = alignment.find((a) => a.originalIndex === symbol.wordIndex); + + if (wordAlignment && wordAlignment.translatedIndex < translatedWords.length) { const translatedWord = translatedWords[wordAlignment.translatedIndex]; // Attach the symbol to the translated word @@ -289,16 +284,13 @@ export function reattachSymbols( export function translateWithSymbols( originalMessage: string, translatedText: string, - richTextSymbols?: Array<{ text: string; image?: string }>, + richTextSymbols?: Array<{ text: string; image?: string }> ): { text: string; richTextSymbols: Array<{ text: string; image?: string }>; } { // Step 1: Parse original message - const parsedOriginal = parseMessageWithSymbols( - originalMessage, - richTextSymbols, - ); + const parsedOriginal = parseMessageWithSymbols(originalMessage, richTextSymbols); // If no symbols, return as-is if (parsedOriginal.symbols.length === 0) { @@ -311,7 +303,7 @@ export function translateWithSymbols( // Step 2: Tokenize translated text const translatedWords = translatedText .trim() - .replace(/\s+/g, " ") + .replace(/\s+/g, ' ') .split(/\s+/) .filter((w) => w.length > 0); @@ -335,7 +327,7 @@ export function translateWithSymbols( * @returns Array of symbol attachments */ export function extractSymbolsFromButton( - button: any, + button: any ): Array<{ text: string; image?: string }> | undefined { // First check richText structure if (button.semanticAction?.richText?.symbols) { @@ -348,7 +340,7 @@ export function extractSymbolsFromButton( // Check if button has a symbol library reference as image if (button.symbolLibrary && button.symbolPath) { // Create a symbol attachment for the label/message - const text = button.label || button.message || ""; + const text = button.label || button.message || ''; if (text) { return [ { @@ -360,8 +352,8 @@ export function extractSymbolsFromButton( } // Check if image field contains a symbol reference - if (button.image && button.image.startsWith("[")) { - const text = button.label || button.message || ""; + if (button.image && button.image.startsWith('[')) { + const text = button.label || button.message || ''; if (text) { return [ { diff --git a/src/processors/gridset/symbolExtractor.ts b/src/processors/gridset/symbolExtractor.ts index 1a974e3..1414ffe 100644 --- a/src/processors/gridset/symbolExtractor.ts +++ b/src/processors/gridset/symbolExtractor.ts @@ -12,17 +12,9 @@ * c. For Tawasol: provide alternative sources */ -import { - resolveSymbolReference, - parseSymbolReference, - type SymbolReference, -} from "./symbols"; -import { - defaultFileAdapter, - FileAdapter, - ProcessorInput, -} from "../../utils/io"; -import { getZipAdapter, ZipAdapter } from "../../utils/zip"; +import { resolveSymbolReference, parseSymbolReference, type SymbolReference } from './symbols'; +import { defaultFileAdapter, FileAdapter, ProcessorInput } from '../../utils/io'; +import { getZipAdapter, ZipAdapter } from '../../utils/zip'; /** * Image extraction result @@ -30,8 +22,8 @@ import { getZipAdapter, ZipAdapter } from "../../utils/zip"; export interface ExtractedImage { found: boolean; data?: Buffer; - format?: "png" | "jpg" | "jpeg" | "gif" | "svg" | "unknown"; - source: "embedded" | "symbol-library" | "external-file" | "not-found"; + format?: 'png' | 'jpg' | 'jpeg' | 'gif' | 'svg' | 'unknown'; + source: 'embedded' | 'symbol-library' | 'external-file' | 'not-found'; reference?: string; error?: string; metadata?: { @@ -69,22 +61,22 @@ const OPEN_LICENSE_SYMBOLS: { }; } = { tawasl: { - name: "Tawasol", - attribution: "Tawasol symbols by Mada (Qatar Assistive Technology Center)", - license: "CC BY-SA 4.0", - url: "https://mada.org.qa/en/resources/tawasol-symbols", - alternativeSources: ["https://github.com/mada-qatar/Tawasol"], + name: 'Tawasol', + attribution: 'Tawasol symbols by Mada (Qatar Assistive Technology Center)', + license: 'CC BY-SA 4.0', + url: 'https://mada.org.qa/en/resources/tawasol-symbols', + alternativeSources: ['https://github.com/mada-qatar/Tawasol'], }, blissx: { - name: "Blissymbols", - attribution: "Blissymbolics Communication International", - license: "CC BY-ND 3.0", - url: "https://blissymbolics.org", + name: 'Blissymbols', + attribution: 'Blissymbolics Communication International', + license: 'CC BY-ND 3.0', + url: 'https://blissymbolics.org', }, symoji: { - name: "Symoji", - attribution: "Smartbox Assistive Technology", - license: "Proprietary - Free use in Grid 3", + name: 'Symoji', + attribution: 'Smartbox Assistive Technology', + license: 'Proprietary - Free use in Grid 3', }, }; @@ -102,7 +94,7 @@ export async function extractButtonImage( symbolReference: string | undefined, options: SymbolExtractionOptions = {}, fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise, + zipAdapter?: (input: ProcessorInput) => Promise ): Promise { // Priority 1: Use embedded image if available if (resolvedImageEntry && options.preferEmbedded !== false) { @@ -120,7 +112,7 @@ export async function extractButtonImage( found: true, data, format, - source: "embedded", + source: 'embedded', reference: resolvedImageEntry, }; } @@ -137,7 +129,7 @@ export async function extractButtonImage( // Not found return { found: false, - source: "not-found", + source: 'not-found', }; } @@ -149,21 +141,20 @@ export async function extractButtonImage( */ export async function extractSymbolLibraryImage( reference: string, - options: SymbolExtractionOptions = {}, + options: SymbolExtractionOptions = {} ): Promise { const ref = parseSymbolReferenceSafe(reference); if (!ref || !ref.isValid) { return { found: false, - source: "not-found", + source: 'not-found', reference, }; } // Get library metadata - const libInfo = - OPEN_LICENSE_SYMBOLS[ref.library as keyof typeof OPEN_LICENSE_SYMBOLS]; + const libInfo = OPEN_LICENSE_SYMBOLS[ref.library as keyof typeof OPEN_LICENSE_SYMBOLS]; // Resolve symbol reference and extract from .symbols file const resolved = await resolveSymbolReference(reference, { @@ -185,7 +176,7 @@ export async function extractSymbolLibraryImage( return { found: false, - source: "symbol-library", + source: 'symbol-library', reference: reference, metadata, error: resolved.error, @@ -194,12 +185,12 @@ export async function extractSymbolLibraryImage( // Successfully extracted! const data = resolved.data; - const format = data ? detectImageFormat(data) : "unknown"; + const format = data ? detectImageFormat(data) : 'unknown'; return { found: true, data, format, - source: "symbol-library", + source: 'symbol-library', reference: reference, metadata, }; @@ -215,11 +206,11 @@ export function convertToAstericsImage(extracted: ExtractedImage): any { if (extracted.found && extracted.data) { // Embed as base64 - image.data = Buffer.from(extracted.data).toString("base64"); + image.data = Buffer.from(extracted.data).toString('base64'); } // Even if embedded, add attribution for symbol libraries - if (extracted.source === "symbol-library") { + if (extracted.source === 'symbol-library') { if (extracted.metadata?.attribution) { image.author = extracted.metadata.attribution; } @@ -292,16 +283,14 @@ export function analyzeSymbolExtraction(tree: any): SymbolReport { report.byLibrary[button.symbolLibrary] = (report.byLibrary[button.symbolLibrary] || 0) + 1; - const ref = `[${button.symbolLibrary}]${button.symbolPath || ""}`; + const ref = `[${button.symbolLibrary}]${button.symbolPath || ''}`; const libInfo = - OPEN_LICENSE_SYMBOLS[ - button.symbolLibrary as keyof typeof OPEN_LICENSE_SYMBOLS - ]; + OPEN_LICENSE_SYMBOLS[button.symbolLibrary as keyof typeof OPEN_LICENSE_SYMBOLS]; report.missingSymbols.push({ reference: ref, library: button.symbolLibrary, - path: button.symbolPath || "", + path: button.symbolPath || '', attribution: libInfo?.attribution, license: libInfo?.license, }); @@ -326,29 +315,20 @@ export function suggestExtractionStrategy(report: SymbolReport): string { const suggestions: string[] = []; if (report.embedded > 0) { - suggestions.push( - `✓ Can extract ${report.embedded} embedded images directly`, - ); + suggestions.push(`✓ Can extract ${report.embedded} embedded images directly`); } if (report.symbolLibraries > 0) { - suggestions.push( - `⚠ ${report.symbolLibraries} symbol library references found:`, - ); + suggestions.push(`⚠ ${report.symbolLibraries} symbol library references found:`); Object.entries(report.byLibrary).forEach(([lib, count]) => { - const libInfo = - OPEN_LICENSE_SYMBOLS[lib as keyof typeof OPEN_LICENSE_SYMBOLS]; + const libInfo = OPEN_LICENSE_SYMBOLS[lib as keyof typeof OPEN_LICENSE_SYMBOLS]; if (libInfo) { suggestions.push(` - ${lib}: ${count} symbols (${libInfo.license})`); if (libInfo.alternativeSources) { - suggestions.push( - ` Alternative: ${libInfo.alternativeSources.join(", ")}`, - ); + suggestions.push(` Alternative: ${libInfo.alternativeSources.join(', ')}`); } } else { - suggestions.push( - ` - ${lib}: ${count} symbols (Proprietary - requires Grid 3)`, - ); + suggestions.push(` - ${lib}: ${count} symbols (Proprietary - requires Grid 3)`); } }); } @@ -357,52 +337,37 @@ export function suggestExtractionStrategy(report: SymbolReport): string { suggestions.push(`✗ ${report.notFound} images not found`); } - return suggestions.join("\n"); + return suggestions.join('\n'); } /** * Detect image format from buffer */ -function detectImageFormat( - buffer: Buffer, -): "png" | "jpg" | "jpeg" | "gif" | "svg" | "unknown" { - if (buffer.length < 4) return "unknown"; +function detectImageFormat(buffer: Buffer): 'png' | 'jpg' | 'jpeg' | 'gif' | 'svg' | 'unknown' { + if (buffer.length < 4) return 'unknown'; // PNG: 89 50 4E 47 - if ( - buffer[0] === 0x89 && - buffer[1] === 0x50 && - buffer[2] === 0x4e && - buffer[3] === 0x47 - ) { - return "png"; + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) { + return 'png'; } // JPEG: FF D8 FF if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { - return "jpg"; + return 'jpg'; } // GIF: 47 49 46 38 - if ( - buffer[0] === 0x47 && - buffer[1] === 0x49 && - buffer[2] === 0x46 && - buffer[3] === 0x38 - ) { - return "gif"; + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) { + return 'gif'; } // SVG (check for { const { writeTextToPath } = fileAdapter; - const lines = ["Reference,Library,Path,Attribution,License"]; + const lines = ['Reference,Library,Path,Attribution,License']; for (const symbol of report.missingSymbols) { lines.push( - `"${symbol.reference}","${symbol.library}","${symbol.path}","${symbol.attribution || ""}","${symbol.license || ""}"`, + `"${symbol.reference}","${symbol.library}","${symbol.path}","${symbol.attribution || ''}","${symbol.license || ''}"` ); } - await writeTextToPath(outputPath, lines.join("\n")); + await writeTextToPath(outputPath, lines.join('\n')); } /** @@ -462,10 +427,7 @@ export interface SymbolManifest { }>; } -export function createSymbolManifest( - tree: any, - gridsetName: string, -): SymbolManifest { +export function createSymbolManifest(tree: any, gridsetName: string): SymbolManifest { const manifest: SymbolManifest = { generatedAt: new Date().toISOString(), gridset: gridsetName, @@ -493,9 +455,7 @@ export function createSymbolManifest( if (!manifest.libraries[button.symbolLibrary]) { const libInfo = - OPEN_LICENSE_SYMBOLS[ - button.symbolLibrary as keyof typeof OPEN_LICENSE_SYMBOLS - ]; + OPEN_LICENSE_SYMBOLS[button.symbolLibrary as keyof typeof OPEN_LICENSE_SYMBOLS]; manifest.libraries[button.symbolLibrary] = { count: 0, attribution: libInfo?.attribution, @@ -506,7 +466,7 @@ export function createSymbolManifest( manifest.libraries[button.symbolLibrary].count++; - const ref = `[${button.symbolLibrary}]${button.symbolPath || ""}`; + const ref = `[${button.symbolLibrary}]${button.symbolPath || ''}`; manifest.symbols.push({ pageId, buttonId: button.id, diff --git a/src/processors/gridset/symbolSearch.ts b/src/processors/gridset/symbolSearch.ts index 1474766..ae32bf4 100644 --- a/src/processors/gridset/symbolSearch.ts +++ b/src/processors/gridset/symbolSearch.ts @@ -9,7 +9,7 @@ * active family=active family.png=active family */ -import { defaultFileAdapter, FileAdapter } from "../../utils/io"; +import { defaultFileAdapter, FileAdapter } from '../../utils/io'; /** * Symbol search result @@ -49,25 +49,25 @@ export interface LibrarySearchIndex { */ export async function parsePixFile( pixFilePath: string, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { readTextFromInput, basename } = fileAdapter; const content = await readTextFromInput(pixFilePath); - const library = basename(pixFilePath, ".pix"); + const library = basename(pixFilePath, '.pix'); const searchTerms = new Map(); const filenames = new Map(); - const lines = content.split("\n"); + const lines = content.split('\n'); for (const line of lines) { const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("encoding=")) { + if (!trimmed || trimmed.startsWith('encoding=')) { continue; } // Format: searchTerm=symbolFilename=searchTerm - const parts = trimmed.split("="); + const parts = trimmed.split('='); if (parts.length >= 3) { const searchTerm = parts[0]; const symbolFilename = parts[1]; @@ -88,16 +88,16 @@ export async function parsePixFile( */ export async function loadSearchIndexes( options: SymbolSearchOptions = {}, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise> { const { listDir, pathExists, join, basename } = fileAdapter; - const { grid3Path, locale = "en-GB", libraries: specifiedLibs } = options; + const { grid3Path, locale = 'en-GB', libraries: specifiedLibs } = options; if (!grid3Path) { - throw new Error("grid3Path is required for symbol search"); + throw new Error('grid3Path is required for symbol search'); } - const searchIndexesDir = join(grid3Path, "Locale", locale, "symbolsearch"); + const searchIndexesDir = join(grid3Path, 'Locale', locale, 'symbolsearch'); if (!(await pathExists(searchIndexesDir))) { throw new Error(`Symbol search directory not found: ${searchIndexesDir}`); @@ -107,19 +107,15 @@ export async function loadSearchIndexes( const files = await listDir(searchIndexesDir); for (const file of files) { - if (!file.endsWith(".pix")) { + if (!file.endsWith('.pix')) { continue; } - const libraryName = basename(file, ".pix"); + const libraryName = basename(file, '.pix'); // Filter libraries if specified if (specifiedLibs && specifiedLibs.length > 0) { - if ( - !specifiedLibs.some( - (lib) => lib.toLowerCase() === libraryName.toLowerCase(), - ) - ) { + if (!specifiedLibs.some((lib) => lib.toLowerCase() === libraryName.toLowerCase())) { continue; } } @@ -144,7 +140,7 @@ export async function loadSearchIndexes( */ export async function searchSymbols( searchTerm: string, - options: SymbolSearchOptions = {}, + options: SymbolSearchOptions = {} ): Promise { const indexes = await loadSearchIndexes(options); const results: SymbolSearchResult[] = []; @@ -172,11 +168,7 @@ export async function searchSymbols( if (term.includes(lowerSearchTerm) || lowerSearchTerm.includes(term)) { // Skip if already added as exact match if ( - results.some( - (r) => - r.library === libraryName && - r.symbolFilename === symbolFilename, - ) + results.some((r) => r.library === libraryName && r.symbolFilename === symbolFilename) ) { continue; } @@ -214,7 +206,7 @@ export async function searchSymbols( export async function getSymbolFilename( searchTerm: string, library: string, - options: SymbolSearchOptions = {}, + options: SymbolSearchOptions = {} ): Promise { const indexes = await loadSearchIndexes({ ...options, @@ -239,7 +231,7 @@ export async function getSymbolFilename( export async function getSymbolDisplayName( symbolFilename: string, library: string, - options: SymbolSearchOptions = {}, + options: SymbolSearchOptions = {} ): Promise { const indexes = await loadSearchIndexes({ ...options, @@ -262,7 +254,7 @@ export async function getSymbolDisplayName( */ export async function getAllSearchTerms( library: string, - options: SymbolSearchOptions = {}, + options: SymbolSearchOptions = {} ): Promise { const indexes = await loadSearchIndexes({ ...options, @@ -285,7 +277,7 @@ export async function getAllSearchTerms( */ export async function getSearchSuggestions( partialTerm: string, - options: SymbolSearchOptions = {}, + options: SymbolSearchOptions = {} ): Promise { const indexes = await loadSearchIndexes(options); const suggestions = new Set(); @@ -310,7 +302,7 @@ export async function getSearchSuggestions( */ export async function searchSymbolsWithReferences( searchTerm: string, - options: SymbolSearchOptions = {}, + options: SymbolSearchOptions = {} ): Promise { const results = await searchSymbols(searchTerm, options); @@ -323,7 +315,7 @@ export async function searchSymbolsWithReferences( * @returns Map of library name to symbol count */ export async function countLibrarySymbols( - options: SymbolSearchOptions = {}, + options: SymbolSearchOptions = {} ): Promise> { const indexes = await loadSearchIndexes(options); const counts = new Map(); @@ -356,7 +348,7 @@ export interface SymbolSearchStats { * @returns Statistics about available symbols */ export async function getSymbolSearchStats( - options: SymbolSearchOptions = {}, + options: SymbolSearchOptions = {} ): Promise { const indexes = await loadSearchIndexes(options); const stats: SymbolSearchStats = { diff --git a/src/processors/gridset/symbols.ts b/src/processors/gridset/symbols.ts index a72d3f7..5070b49 100644 --- a/src/processors/gridset/symbols.ts +++ b/src/processors/gridset/symbols.ts @@ -13,57 +13,52 @@ * This module provides symbol resolution and metadata extraction. */ -import { - defaultFileAdapter, - FileAdapter, - ProcessorInput, -} from "../../utils/io"; -import { getZipAdapter, ZipAdapter } from "../../utils/zip"; +import { defaultFileAdapter, FileAdapter, ProcessorInput } from '../../utils/io'; +import { getZipAdapter, ZipAdapter } from '../../utils/zip'; /** * Default Grid 3 installation paths by platform */ const DEFAULT_GRID3_PATHS = { - win32: "C:\\Program Files (x86)\\Smartbox\\Grid 3", - darwin: "/Applications/Grid 3.app/Contents/Resources", - linux: "/opt/smartbox/grid3", + win32: 'C:\\Program Files (x86)\\Smartbox\\Grid 3', + darwin: '/Applications/Grid 3.app/Contents/Resources', + linux: '/opt/smartbox/grid3', }; /** * Path to Symbols directory within Grid 3 installation * Contains .symbols ZIP archives with actual images */ -const SYMBOLS_SUBDIR = "Resources\\Symbols"; +const SYMBOLS_SUBDIR = 'Resources\\Symbols'; /** * Path to symbol search indexes within Grid 3 installation * Contains .pix index files for searching */ -const SYMBOLSEARCH_SUBDIR = "Locale"; +const SYMBOLSEARCH_SUBDIR = 'Locale'; /** * Known symbol libraries in Grid 3 */ export const SYMBOL_LIBRARIES = { - WIDGIT: "widgit", - TAWASL: "tawasl", - SSNAPS: "ssnaps", - GRID3X: "grid3x", - GRID2X: "grid2x", - BLISSX: "blissx", - EYEGAZ: "eyegaz", - INTERL: "interl", - METACM: "metacm", - MJPCS: "mjpcs#", - PCSHC: "pcshc#", - PCSTL: "pcstl#", - SESENS: "sesens", - SSTIX: "sstix#", - SYMOJI: "symoji", + WIDGIT: 'widgit', + TAWASL: 'tawasl', + SSNAPS: 'ssnaps', + GRID3X: 'grid3x', + GRID2X: 'grid2x', + BLISSX: 'blissx', + EYEGAZ: 'eyegaz', + INTERL: 'interl', + METACM: 'metacm', + MJPCS: 'mjpcs#', + PCSHC: 'pcshc#', + PCSTL: 'pcstl#', + SESENS: 'sesens', + SSTIX: 'sstix#', + SYMOJI: 'symoji', } as const; -export type SymbolLibraryName = - (typeof SYMBOL_LIBRARIES)[keyof typeof SYMBOL_LIBRARIES]; +export type SymbolLibraryName = (typeof SYMBOL_LIBRARIES)[keyof typeof SYMBOL_LIBRARIES]; /** * Symbol reference parsed from Grid 3 format @@ -111,7 +106,7 @@ export interface SymbolResolutionResult { /** * Default locale to use */ -export const DEFAULT_LOCALE = "en-GB"; +export const DEFAULT_LOCALE = 'en-GB'; /** * Parse a symbol reference string @@ -126,7 +121,7 @@ export function parseSymbolReference(reference: string): SymbolReference { if (!match) { return { - library: "", + library: '', path: trimmed, fullReference: trimmed, isValid: false, @@ -137,7 +132,7 @@ export function parseSymbolReference(reference: string): SymbolReference { return { library: library.toLowerCase(), - path: symbolPath.replace(/^\\+/, "").trim(), // Remove leading slashes + path: symbolPath.replace(/^\\+/, '').trim(), // Remove leading slashes fullReference: trimmed, isValid: true, }; @@ -149,23 +144,19 @@ export function parseSymbolReference(reference: string): SymbolReference { * @returns True if it's a symbol reference like [widgit]/... */ export function isSymbolReference(reference: string): boolean { - return reference.trim().startsWith("["); + return reference.trim().startsWith('['); } /** * Get the default Grid 3 installation path for the current platform * @returns Default Grid 3 path or empty string if not found */ -export async function getDefaultGrid3Path( - fileAdapter?: FileAdapter, -): Promise { +export async function getDefaultGrid3Path(fileAdapter?: FileAdapter): Promise { const { pathExists } = fileAdapter ?? defaultFileAdapter; const platform = ( - typeof process !== "undefined" && process.platform - ? process.platform - : "unknown" + typeof process !== 'undefined' && process.platform ? process.platform : 'unknown' ) as keyof typeof DEFAULT_GRID3_PATHS; - const defaultPath = DEFAULT_GRID3_PATHS[platform] || ""; + const defaultPath = DEFAULT_GRID3_PATHS[platform] || ''; try { if (defaultPath && (await pathExists(defaultPath))) { @@ -174,11 +165,11 @@ export async function getDefaultGrid3Path( // Try to find Grid 3 in common locations const commonPaths = [ - "C:\\Program Files (x86)\\Smartbox\\Grid 3", - "C:\\Program Files\\Smartbox\\Grid 3", - "C:\\Program Files\\Smartbox\\Grid 3", - "/Applications/Grid 3.app", - "/opt/smartbox/grid3", + 'C:\\Program Files (x86)\\Smartbox\\Grid 3', + 'C:\\Program Files\\Smartbox\\Grid 3', + 'C:\\Program Files\\Smartbox\\Grid 3', + '/Applications/Grid 3.app', + '/opt/smartbox/grid3', ]; for (const testPath of commonPaths) { @@ -187,10 +178,10 @@ export async function getDefaultGrid3Path( } } } catch { - return ""; + return ''; } - return ""; + return ''; } /** @@ -201,7 +192,7 @@ export async function getDefaultGrid3Path( */ export function getSymbolLibrariesDir( grid3Path: string, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): string { const { join } = fileAdapter; return join(grid3Path, SYMBOLS_SUBDIR); @@ -217,10 +208,10 @@ export function getSymbolLibrariesDir( export function getSymbolSearchIndexesDir( grid3Path: string, locale: string = DEFAULT_LOCALE, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): string { const { join } = fileAdapter; - return join(grid3Path, SYMBOLSEARCH_SUBDIR, locale, "symbolsearch"); + return join(grid3Path, SYMBOLSEARCH_SUBDIR, locale, 'symbolsearch'); } /** @@ -230,12 +221,10 @@ export function getSymbolSearchIndexesDir( */ export async function getAvailableSymbolLibraries( options: SymbolResolutionOptions = {}, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { - const { pathExists, getFileSize, listDir, join, basename } = - fileAdapter ?? defaultFileAdapter; - const grid3Path = - options.grid3Path || options.symbolDir || (await getDefaultGrid3Path()); + const { pathExists, getFileSize, listDir, join, basename } = fileAdapter ?? defaultFileAdapter; + const grid3Path = options.grid3Path || options.symbolDir || (await getDefaultGrid3Path()); if (!grid3Path) { return []; @@ -251,17 +240,17 @@ export async function getAvailableSymbolLibraries( const files = await listDir(symbolsDir); for (const file of files) { - if (file.endsWith(".symbols")) { + if (file.endsWith('.symbols')) { const fullPath = join(symbolsDir, file); const size = await getFileSize(fullPath); - const libraryName = basename(file, ".symbols"); + const libraryName = basename(file, '.symbols'); libraries.push({ name: libraryName, pixFile: fullPath, // Reuse this field for the .symbols file path exists: true, size, - locale: "global", // .symbols files are not locale-specific + locale: 'global', // .symbols files are not locale-specific }); } } @@ -278,11 +267,10 @@ export async function getAvailableSymbolLibraries( export async function getSymbolLibraryInfo( libraryName: string, options: SymbolResolutionOptions = {}, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { const { pathExists, getFileSize, join } = fileAdapter ?? defaultFileAdapter; - const grid3Path = - options.grid3Path || options.symbolDir || (await getDefaultGrid3Path()); + const grid3Path = options.grid3Path || options.symbolDir || (await getDefaultGrid3Path()); if (!grid3Path) { return undefined; @@ -293,9 +281,9 @@ export async function getSymbolLibraryInfo( // Try different case variations const variations = [ - normalizedLibName + ".symbols", - normalizedLibName.toUpperCase() + ".symbols", - libraryName + ".symbols", + normalizedLibName + '.symbols', + normalizedLibName.toUpperCase() + '.symbols', + libraryName + '.symbols', ]; for (const file of variations) { @@ -307,7 +295,7 @@ export async function getSymbolLibraryInfo( pixFile: fullPath, exists: true, size, - locale: "global", + locale: 'global', }; } } @@ -325,7 +313,7 @@ export async function resolveSymbolReference( reference: string, options: SymbolResolutionOptions = {}, fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise, + zipAdapter?: (input: ProcessorInput) => Promise ): Promise { const parsed = parseSymbolReference(reference); @@ -333,7 +321,7 @@ export async function resolveSymbolReference( return { reference: parsed, found: false, - error: "Invalid symbol reference format", + error: 'Invalid symbol reference format', }; } @@ -343,7 +331,7 @@ export async function resolveSymbolReference( return { reference: parsed, found: false, - error: "Grid 3 installation not found. Please specify grid3Path.", + error: 'Grid 3 installation not found. Please specify grid3Path.', }; } @@ -353,16 +341,14 @@ export async function resolveSymbolReference( return { reference: parsed, found: false, - error: `Symbol library '${parsed.library}' not found at ${libraryInfo?.pixFile || "unknown"}`, + error: `Symbol library '${parsed.library}' not found at ${libraryInfo?.pixFile || 'unknown'}`, }; } try { // .symbols files are ZIP archives const zipFile = libraryInfo.pixFile; - const zip = zipAdapter - ? await zipAdapter(zipFile) - : await getZipAdapter(zipFile, fileAdapter); + const zip = zipAdapter ? await zipAdapter(zipFile) : await getZipAdapter(zipFile, fileAdapter); // The path in the symbol reference becomes the path within the symbols/ folder // e.g., [tawasl]/above bw.png becomes symbols/above bw.png @@ -372,9 +358,7 @@ export async function resolveSymbolReference( if (!entry) { // Try without the symbols/ prefix (in case reference already includes it) - const altPath = parsed.path.startsWith("symbols/") - ? parsed.path - : `symbols/${parsed.path}`; + const altPath = parsed.path.startsWith('symbols/') ? parsed.path : `symbols/${parsed.path}`; const altEntry = await zip.readFile(altPath); if (!altEntry) { @@ -438,7 +422,7 @@ export function extractSymbolReferences(tree: any): string[] { // Check for symbol library metadata if (button.symbolLibrary) { - const ref = `[${button.symbolLibrary}]${button.symbolPath || ""}`; + const ref = `[${button.symbolLibrary}]${button.symbolPath || ''}`; references.add(ref); } } @@ -454,12 +438,9 @@ export function extractSymbolReferences(tree: any): string[] { * @param symbolPath - Path within the library * @returns Formatted symbol reference */ -export function createSymbolReference( - library: string, - symbolPath: string, -): string { - const normalizedLib = library.toLowerCase().replace(/\[|\]/g, ""); - const normalizedPath = symbolPath.replace(/^\\+/, ""); +export function createSymbolReference(library: string, symbolPath: string): string { + const normalizedLib = library.toLowerCase().replace(/\[|\]/g, ''); + const normalizedPath = symbolPath.replace(/^\\+/, ''); return `[${normalizedLib}]${normalizedPath}`; } @@ -489,10 +470,8 @@ export function getSymbolPath(reference: string): string { * @returns True if it's a known library */ export function isKnownSymbolLibrary(libraryName: string): boolean { - const normalized = libraryName.toLowerCase().replace(/\[|\]/g, ""); - return Object.values(SYMBOL_LIBRARIES).includes( - normalized as SymbolLibraryName, - ); + const normalized = libraryName.toLowerCase().replace(/\[|\]/g, ''); + return Object.values(SYMBOL_LIBRARIES).includes(normalized as SymbolLibraryName); } /** @@ -501,30 +480,27 @@ export function isKnownSymbolLibrary(libraryName: string): boolean { * @returns Human-readable display name */ export function getSymbolLibraryDisplayName(libraryName: string): string { - const normalized = libraryName.toLowerCase().replace(/\[|\]/g, ""); + const normalized = libraryName.toLowerCase().replace(/\[|\]/g, ''); const displayNames: Record = { - widgit: "Widgit Symbols", - tawasl: "Tawasol (Arabic)", - ssnaps: "Smartbox Symbol Snapshots", - grid3x: "Grid 3 Extended", - grid2x: "Grid 2 Extended", - blissx: "Blissymbols", - eyegaz: "Eye Gaze Symbols", - interl: "International Symbols", - metacm: "MetaComm", - mjpcs: "Mayer-Johnson PCS", - pcshc: "PCS High Contrast", - pcstl: "PCS Thin Line", - sesens: "Sensory Software", - sstix: "Smartbox TIX", - symoji: "Symbol Emoji", + widgit: 'Widgit Symbols', + tawasl: 'Tawasol (Arabic)', + ssnaps: 'Smartbox Symbol Snapshots', + grid3x: 'Grid 3 Extended', + grid2x: 'Grid 2 Extended', + blissx: 'Blissymbols', + eyegaz: 'Eye Gaze Symbols', + interl: 'International Symbols', + metacm: 'MetaComm', + mjpcs: 'Mayer-Johnson PCS', + pcshc: 'PCS High Contrast', + pcstl: 'PCS Thin Line', + sesens: 'Sensory Software', + sstix: 'Smartbox TIX', + symoji: 'Symbol Emoji', }; - return ( - displayNames[normalized] || - normalized.charAt(0).toUpperCase() + normalized.slice(1) - ); + return displayNames[normalized] || normalized.charAt(0).toUpperCase() + normalized.slice(1); } /** @@ -568,14 +544,10 @@ export function analyzeSymbolUsage(tree: any): SymbolUsageStats { * @param cellY - Cell Y coordinate * @returns Generated filename */ -export function symbolReferenceToFilename( - reference: string, - cellX: number, - cellY: number, -): string { +export function symbolReferenceToFilename(reference: string, cellX: number, cellY: number): string { const parsed = parseSymbolReference(reference); - const dotIndex = parsed.path.lastIndexOf("."); - const ext = dotIndex >= 0 ? parsed.path.slice(dotIndex) : ".png"; + const dotIndex = parsed.path.lastIndexOf('.'); + const ext = dotIndex >= 0 ? parsed.path.slice(dotIndex) : '.png'; // Grid 3 format: {x}-{y}-0-text-0.{ext} return `${cellX}-${cellY}-0-text-0${ext}`; @@ -597,9 +569,6 @@ export function getSymbolsDir(grid3Path: string): string { * @deprecated Use getSymbolSearchIndexesDir() instead - more descriptive name * Get the symbol search directory for a given locale (where .pix index files are) */ -export function getSymbolSearchDir( - grid3Path: string, - locale: string = DEFAULT_LOCALE, -): string { +export function getSymbolSearchDir(grid3Path: string, locale: string = DEFAULT_LOCALE): string { return getSymbolSearchIndexesDir(grid3Path, locale); } diff --git a/src/processors/gridset/wordlistHelpers.ts b/src/processors/gridset/wordlistHelpers.ts index e5e3dcb..e964c9e 100644 --- a/src/processors/gridset/wordlistHelpers.ts +++ b/src/processors/gridset/wordlistHelpers.ts @@ -9,18 +9,11 @@ * do not have equivalent wordlist functionality. */ -import { XMLParser, XMLBuilder } from "fast-xml-parser"; -import { - getZipEntriesFromAdapter, - resolveGridsetPasswordFromEnv, -} from "./password"; -import { - defaultFileAdapter, - FileAdapter, - type ProcessorInput, -} from "../../utils/io"; -import { decodeText } from "../../utils/io"; -import { getZipAdapter, ZipAdapter, ZipFile } from "../../utils/zip"; +import { XMLParser, XMLBuilder } from 'fast-xml-parser'; +import { getZipEntriesFromAdapter, resolveGridsetPasswordFromEnv } from './password'; +import { defaultFileAdapter, FileAdapter, type ProcessorInput } from '../../utils/io'; +import { decodeText } from '../../utils/io'; +import { getZipAdapter, ZipAdapter, ZipFile } from '../../utils/zip'; /** * Represents a single item in a wordlist @@ -60,22 +53,22 @@ export interface WordList { * ]); */ export function createWordlist( - input: string[] | WordListItem[] | Record, + input: string[] | WordListItem[] | Record ): WordList { let items: WordListItem[] = []; if (Array.isArray(input)) { // Handle array input items = input.map((item) => { - if (typeof item === "string") { + if (typeof item === 'string') { return { text: item }; } return item; }); - } else if (typeof input === "object") { + } else if (typeof input === 'object') { // Handle dictionary/object input items = Object.entries(input).map(([, value]) => { - if (typeof value === "string") { + if (typeof value === 'string') { return { text: value }; } return value; @@ -102,18 +95,15 @@ export function wordlistToXml(wordlist: WordList): string { }, }, }, - Image: item.image || "", - PartOfSpeech: item.partOfSpeech || "Unknown", + Image: item.image || '', + PartOfSpeech: item.partOfSpeech || 'Unknown', }, })); const wordlistData = { WordList: { Items: { - WordListItem: - items.length === 1 - ? items[0].WordListItem - : items.map((i) => i.WordListItem), + WordListItem: items.length === 1 ? items[0].WordListItem : items.map((i) => i.WordListItem), }, }, }; @@ -143,7 +133,7 @@ export async function extractWordlists( gridsetBuffer: Uint8Array, password = resolveGridsetPasswordFromEnv(), fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise, + zipAdapter?: (input: ProcessorInput) => Promise ): Promise> { const wordlists = new Map(); const parser = new XMLParser(); @@ -156,10 +146,7 @@ export async function extractWordlists( // Process each grid file for (const entry of entries) { - if ( - entry.entryName.startsWith("Grids/") && - entry.entryName.endsWith("grid.xml") - ) { + if (entry.entryName.startsWith('Grids/') && entry.entryName.endsWith('grid.xml')) { try { const xmlContent = decodeText(await entry.getData()); const data = parser.parse(xmlContent); @@ -189,13 +176,9 @@ export async function extractWordlists( const items: WordListItem[] = itemArray.map((item: any) => ({ text: - item.Text?.p?.s?.r || - item.Text?.s?.r || - item.text?.p?.s?.r || - item.text?.s?.r || - "", + item.Text?.p?.s?.r || item.Text?.s?.r || item.text?.p?.s?.r || item.text?.s?.r || '', image: item.Image || item.image || undefined, - partOfSpeech: item.PartOfSpeech || item.partOfSpeech || "Unknown", + partOfSpeech: item.PartOfSpeech || item.partOfSpeech || 'Unknown', })); if (items.length > 0) { @@ -203,10 +186,7 @@ export async function extractWordlists( } } catch (error) { // Skip grids with parsing errors - console.warn( - `Failed to extract wordlist from ${entry.entryName}:`, - error, - ); + console.warn(`Failed to extract wordlist from ${entry.entryName}:`, error); } } } @@ -237,13 +217,13 @@ export async function updateWordlist( wordlist: WordList, password = resolveGridsetPasswordFromEnv(), fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise, + zipAdapter?: (input: ProcessorInput) => Promise ): Promise { const parser = new XMLParser(); const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', suppressEmptyNode: false, }); @@ -257,10 +237,7 @@ export async function updateWordlist( // Find and update the grid for (const entry of entries) { - if ( - entry.entryName.startsWith("Grids/") && - entry.entryName.endsWith("grid.xml") - ) { + if (entry.entryName.startsWith('Grids/') && entry.entryName.endsWith('grid.xml')) { const match = entry.entryName.match(/^Grids\/([^/]+)\//); const currentGridName = match ? match[1] : null; @@ -284,17 +261,15 @@ export async function updateWordlist( }, }, }, - Image: item.image || "", - PartOfSpeech: item.partOfSpeech || "Unknown", + Image: item.image || '', + PartOfSpeech: item.partOfSpeech || 'Unknown', }, })); grid.WordList = { Items: { WordListItem: - items.length === 1 - ? items[0].WordListItem - : items.map((i) => i.WordListItem), + items.length === 1 ? items[0].WordListItem : items.map((i) => i.WordListItem), }, }; @@ -306,11 +281,8 @@ export async function updateWordlist( }); found = true; } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - throw new Error( - `Failed to update wordlist in grid "${gridName}": ${message}`, - ); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to update wordlist in grid "${gridName}": ${message}`); } } } diff --git a/src/processors/gridset/xmlFormatter.ts b/src/processors/gridset/xmlFormatter.ts index f08d962..13d8221 100644 --- a/src/processors/gridset/xmlFormatter.ts +++ b/src/processors/gridset/xmlFormatter.ts @@ -10,7 +10,7 @@ * Tags that Grid 3 requires in full opening/closing format instead of self-closing * Grid 3 cannot parse - it requires */ -const TAGS_NEEDING_EXPANSION = ["AudioDescription", "VideoDescription"]; +const TAGS_NEEDING_EXPANSION = ['AudioDescription', 'VideoDescription']; /** * Format XML string to match Grid 3's requirements @@ -33,26 +33,23 @@ export function formatGrid3Xml(xml: string): string { let formatted = xml; // Convert Unix line endings to Windows (\r\n) for Grid 3 compatibility - formatted = formatted.replace(/\n/g, "\r\n"); + formatted = formatted.replace(/\n/g, '\r\n'); // Add space before /> in self-closing tags to match Grid 3's expected format // Grid 3 original files use not - formatted = formatted.replace(/<(\w+)([^>]*)\/>/g, "<$1$2 />"); + formatted = formatted.replace(/<(\w+)([^>]*)\/>/g, '<$1$2 />'); // Decode XML entities back to plain text to match Grid 3's expected format // Grid 3 expects plain apostrophes, not ' formatted = formatted.replace(/'/g, "'"); formatted = formatted.replace(/"/g, '"'); - formatted = formatted.replace(/</g, "<"); - formatted = formatted.replace(/>/g, ">"); + formatted = formatted.replace(/</g, '<'); + formatted = formatted.replace(/>/g, '>'); // Expand only specific self-closing tags that Grid 3 requires in full opening/closing format // This must be done AFTER adding spaces, so we need to match the format with spaces for (const tag of TAGS_NEEDING_EXPANSION) { - formatted = formatted.replace( - new RegExp(`<${tag}(\\s+[^>]*)? />`, "g"), - `<${tag}$1>`, - ); + formatted = formatted.replace(new RegExp(`<${tag}(\\s+[^>]*)? />`, 'g'), `<${tag}$1>`); } return formatted; @@ -72,24 +69,15 @@ export function formatEmptyCaptionsWithCdata(xml: string): string { // Convert empty/whitespace captions to CDATA format for Grid 3 compatibility // Grid 3 requires for empty captions, not plain text - formatted = formatted.replace( - /<\/Caption>/g, - " ", - ); - formatted = formatted.replace( - / <\/Caption>/g, - " ", - ); - formatted = formatted.replace( - / {2}<\/Caption>/g, - " ", - ); + formatted = formatted.replace(/<\/Caption>/g, ' '); + formatted = formatted.replace(/ <\/Caption>/g, ' '); + formatted = formatted.replace(/ {2}<\/Caption>/g, ' '); // Preserve CDATA in tags for text parameters // Spaces in tags must use CDATA or they get stripped during rendering // e.g., becomes - formatted = formatted.replace(/ <\/r>/g, " "); - formatted = formatted.replace(/ {2}<\/r>/g, " "); + formatted = formatted.replace(/ <\/r>/g, ' '); + formatted = formatted.replace(/ {2}<\/r>/g, ' '); return formatted; } diff --git a/src/processors/gridsetProcessor.ts b/src/processors/gridsetProcessor.ts index 145a92d..7c6102b 100644 --- a/src/processors/gridsetProcessor.ts +++ b/src/processors/gridsetProcessor.ts @@ -4,7 +4,7 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from "../core/baseProcessor"; +} from '../core/baseProcessor'; import { AACTree, AACPage, @@ -13,42 +13,39 @@ import { AACSemanticCategory, AACSemanticIntent, GridSetMetadata, -} from "../core/treeStructure"; -import { AACStyle } from "../types/aac"; -import { XMLParser, XMLBuilder } from "fast-xml-parser"; -import { resolveGrid3CellImage } from "./gridset/resolver"; +} from '../core/treeStructure'; +import { AACStyle } from '../types/aac'; +import { XMLParser, XMLBuilder } from 'fast-xml-parser'; +import { resolveGrid3CellImage } from './gridset/resolver'; import { extractAllButtonsForTranslation, validateTranslationResults, type ButtonForTranslation, type LLMLTranslationResult, -} from "../utilities/translation/translationProcessor"; +} from '../utilities/translation/translationProcessor'; import { getZipEntriesFromAdapter, resolveGridsetPassword, type ZipEntry, -} from "./gridset/password"; -import { decryptGridsetEntry } from "./gridset/crypto"; -import { formatGrid3XmlComplete } from "./gridset/xmlFormatter"; +} from './gridset/password'; +import { decryptGridsetEntry } from './gridset/crypto'; +import { formatGrid3XmlComplete } from './gridset/xmlFormatter'; import { calculateColumnDefinitions as calcColumnDefs, calculateRowDefinitions as calcRowDefs, -} from "./gridset/gridCalculations"; -import { findButtonPosition as findButtonPos } from "./gridset/cellHelpers"; -import { GridsetValidator } from "../validation/gridsetValidator"; -import { ValidationResult } from "../validation/validationTypes"; +} from './gridset/gridCalculations'; +import { findButtonPosition as findButtonPos } from './gridset/cellHelpers'; +import { GridsetValidator } from '../validation/gridsetValidator'; +import { ValidationResult } from '../validation/validationTypes'; // New imports for enhanced Grid 3 support -import { detectPluginCellType, Grid3CellType } from "./gridset/pluginTypes"; -import { detectCommand } from "./gridset/commands"; -import { type SymbolReference, parseSymbolReference } from "./gridset/symbols"; -import { isSymbolLibraryReference } from "./gridset/resolver"; -import { generateCloneId } from "../utilities/analytics/utils/idGenerator"; -import { - translateWithSymbols, - extractSymbolsFromButton, -} from "./gridset/symbolAlignment"; -import { ProcessorInput, decodeText } from "../utils/io"; -import { ZipFile } from "../utils/zip"; +import { detectPluginCellType, Grid3CellType } from './gridset/pluginTypes'; +import { detectCommand } from './gridset/commands'; +import { type SymbolReference, parseSymbolReference } from './gridset/symbols'; +import { isSymbolLibraryReference } from './gridset/resolver'; +import { generateCloneId } from '../utilities/analytics/utils/idGenerator'; +import { translateWithSymbols, extractSymbolsFromButton } from './gridset/symbolAlignment'; +import { ProcessorInput, decodeText } from '../utils/io'; +import { ZipFile } from '../utils/zip'; class GridsetProcessor extends BaseProcessor { constructor(options?: ProcessorOptions) { @@ -62,12 +59,10 @@ class GridsetProcessor extends BaseProcessor { // Helper function to ensure color has alpha channel (Grid3 format) private ensureAlphaChannel(color: string | undefined): string { - if (!color) return "#FFFFFFFF"; + if (!color) return '#FFFFFFFF'; // Handle rgb() and rgba() formats - const rgbMatch = color.match( - /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/, - ); + const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); if (rgbMatch) { const r = parseInt(rgbMatch[1]); const g = parseInt(rgbMatch[2]); @@ -76,14 +71,14 @@ class GridsetProcessor extends BaseProcessor { const alphaHex = Math.round(a * 255) .toString(16) .toUpperCase() - .padStart(2, "0"); - return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}${alphaHex}`; + .padStart(2, '0'); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${alphaHex}`; } // If already 8 digits (with alpha), return as is if (color.match(/^#[0-9A-Fa-f]{8}$/)) return color; // If 6 digits (no alpha), add FF for fully opaque - if (color.match(/^#[0-9A-Fa-f]{6}$/)) return color + "FF"; + if (color.match(/^#[0-9A-Fa-f]{6}$/)) return color + 'FF'; // If 3 digits (shorthand), expand to 8 if (color.match(/^#[0-9A-Fa-f]{3}$/)) { const r = color[1]; @@ -92,7 +87,7 @@ class GridsetProcessor extends BaseProcessor { return `#${r}${r}${g}${g}${b}${b}FF`; } // Invalid or unknown format, return white - return "#FFFFFFFF"; + return '#FFFFFFFF'; } /** @@ -100,7 +95,7 @@ class GridsetProcessor extends BaseProcessor { * Uses WCAG relative luminance formula to determine contrast */ private getContrastFontColor(backgroundColor: string | undefined): string { - if (!backgroundColor) return "#FF000000FF"; // Default to black + if (!backgroundColor) return '#FF000000FF'; // Default to black // Parse color from various formats let r = 255, @@ -108,9 +103,7 @@ class GridsetProcessor extends BaseProcessor { b = 255; // Handle hex colors - const hexMatch = backgroundColor.match( - /#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/, - ); + const hexMatch = backgroundColor.match(/#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/); if (hexMatch) { r = parseInt(hexMatch[1], 16); g = parseInt(hexMatch[2], 16); @@ -130,7 +123,7 @@ class GridsetProcessor extends BaseProcessor { // Use white text for dark backgrounds (luminance < 0.5), black for light backgrounds // Return 6-digit hex (ensureAlphaChannel will add FF for alpha) - return luminance < 0.5 ? "#FFFFFF" : "#000000"; + return luminance < 0.5 ? '#FFFFFF' : '#000000'; } /** @@ -141,13 +134,10 @@ class GridsetProcessor extends BaseProcessor { // Sometimes the param itself is the WordList, sometimes it has a WordList property const wordList = - param.WordList || - param.wordlist || - (param.Items || param.items ? param : undefined); + param.WordList || param.wordlist || (param.Items || param.items ? param : undefined); if (!wordList || !(wordList.Items || wordList.items)) return []; - const items = - wordList.Items?.WordListItem || wordList.items?.wordlistitem || []; + const items = wordList.Items?.WordListItem || wordList.items?.wordlistitem || []; const itemArr = Array.isArray(items) ? items : [items]; const words: string[] = []; @@ -156,9 +146,9 @@ class GridsetProcessor extends BaseProcessor { if (text) { const val = this.textOf(text); if (val) words.push(val); - } else if (item["#text"] !== undefined) { - words.push(String(item["#text"])); - } else if (typeof item === "string") { + } else if (item['#text'] !== undefined) { + words.push(String(item['#text'])); + } else if (typeof item === 'string') { words.push(item); } } @@ -166,32 +156,29 @@ class GridsetProcessor extends BaseProcessor { } // Helper function to generate Grid3 commands from semantic actions - private generateCommandsFromSemanticAction( - button: AACButton, - tree?: AACTree, - ): any { + private generateCommandsFromSemanticAction(button: AACButton, tree?: AACTree): any { const semanticAction = button.semanticAction; if (!semanticAction) { // Default to insert text action with structured XML format // Use two elements: one for the word, one for the space (CDATA preserves whitespace) - let text = button.message || button.label || ""; + let text = button.message || button.label || ''; // Remove trailing space from message if present (we'll add it as separate segment) - if (text.endsWith(" ")) { + if (text.endsWith(' ')) { text = text.slice(0, -1); } return { Command: { - "@_ID": "Action.InsertText", + '@_ID': 'Action.InsertText', Parameter: { - "@_Key": "text", + '@_Key': 'text', p: { s: [ { r: text, }, { - r: { __cdata: " " }, + r: { __cdata: ' ' }, }, ], }, @@ -203,16 +190,14 @@ class GridsetProcessor extends BaseProcessor { // Use platform-specific Grid3 data if available if (semanticAction.platformData?.grid3) { const grid3Data = semanticAction.platformData.grid3; - const params = Object.entries(grid3Data.parameters || {}).map( - ([key, value]) => ({ - "@_Key": key, - "#text": String(value), - }), - ); + const params = Object.entries(grid3Data.parameters || {}).map(([key, value]) => ({ + '@_Key': key, + '#text': String(value), + })); return { Command: { - "@_ID": grid3Data.commandId, + '@_ID': grid3Data.commandId, ...(params.length > 0 ? { Parameter: params } : {}), }, }; @@ -221,9 +206,9 @@ class GridsetProcessor extends BaseProcessor { // Convert semantic actions to Grid3 commands const intentStr = String(semanticAction.intent); switch (intentStr) { - case "NAVIGATE_TO": { + case 'NAVIGATE_TO': { // For Grid3, we need to use the grid name, not the ID - let targetGridName = semanticAction.targetId || ""; + let targetGridName = semanticAction.targetId || ''; if (tree && semanticAction.targetId) { const targetPage = tree.getPage(semanticAction.targetId); if (targetPage) { @@ -232,70 +217,70 @@ class GridsetProcessor extends BaseProcessor { } return { Command: { - "@_ID": "Jump.To", + '@_ID': 'Jump.To', Parameter: { - "@_Key": "grid", - "#text": targetGridName, + '@_Key': 'grid', + '#text': targetGridName, }, }, }; } - case "GO_BACK": + case 'GO_BACK': return { Command: { - "@_ID": "Jump.Back", + '@_ID': 'Jump.Back', }, }; - case "GO_HOME": + case 'GO_HOME': return { Command: { - "@_ID": "Jump.Home", + '@_ID': 'Jump.Home', }, }; - case "DELETE_WORD": + case 'DELETE_WORD': return { Command: { - "@_ID": "Action.DeleteWord", + '@_ID': 'Action.DeleteWord', }, }; - case "DELETE_CHARACTER": + case 'DELETE_CHARACTER': return { Command: { - "@_ID": "Action.DeleteLetter", + '@_ID': 'Action.DeleteLetter', }, }; - case "CLEAR_TEXT": + case 'CLEAR_TEXT': return { Command: { - "@_ID": "Action.Clear", + '@_ID': 'Action.Clear', }, }; - case "SPEAK_TEXT": - case "SPEAK_IMMEDIATE": { + case 'SPEAK_TEXT': + case 'SPEAK_IMMEDIATE': { // Users can speak the complete sentence with a dedicated Speak button // Use two elements: one for the word, one for the space (CDATA preserves whitespace) // Grid3 requires explicit trailing space for automatic word spacing // For communication buttons, insert text into message bar (sentence building) - let text = semanticAction.text || button.message || button.label || ""; + let text = semanticAction.text || button.message || button.label || ''; // Remove trailing space from message if present (we'll add it as separate segment) - if (text.endsWith(" ")) { + if (text.endsWith(' ')) { text = text.slice(0, -1); } return { Command: { - "@_ID": "Action.InsertText", + '@_ID': 'Action.InsertText', Parameter: { - "@_Key": "text", + '@_Key': 'text', p: { s: [ { r: text, }, { - r: { __cdata: " " }, + r: { __cdata: ' ' }, }, ], }, @@ -304,25 +289,25 @@ class GridsetProcessor extends BaseProcessor { }; } - case "INSERT_TEXT": { + case 'INSERT_TEXT': { // Use two elements: one for the word, one for the space (CDATA preserves whitespace) // Add trailing space for word buttons to enable sentence building - let text = semanticAction.text || button.message || button.label || ""; + let text = semanticAction.text || button.message || button.label || ''; // Remove trailing space from message if present (we'll add it as separate segment) - if (text.endsWith(" ")) { + if (text.endsWith(' ')) { text = text.slice(0, -1); } return { Command: { - "@_ID": "Action.InsertText", + '@_ID': 'Action.InsertText', Parameter: { - "@_Key": "text", + '@_Key': 'text', p: { s: [ { r: text, }, { - r: { __cdata: " " }, + r: { __cdata: ' ' }, }, ], }, @@ -334,23 +319,23 @@ class GridsetProcessor extends BaseProcessor { default: { // Use two elements: one for the word, one for the space (CDATA preserves whitespace) // Fallback to insert text with structured XML format - let text = semanticAction.text || button.message || button.label || ""; + let text = semanticAction.text || button.message || button.label || ''; // Remove trailing space from message if present (we'll add it as separate segment) - if (text.endsWith(" ")) { + if (text.endsWith(' ')) { text = text.slice(0, -1); } return { Command: { - "@_ID": "Action.InsertText", + '@_ID': 'Action.InsertText', Parameter: { - "@_Key": "text", + '@_Key': 'text', p: { s: [ { r: text, }, { - r: { __cdata: " " }, + r: { __cdata: ' ' }, }, ], }, @@ -370,9 +355,7 @@ class GridsetProcessor extends BaseProcessor { borderColor: grid3Style.BorderColour, fontColor: grid3Style.FontColour, fontFamily: grid3Style.FontName, - fontSize: grid3Style.FontSize - ? parseInt(String(grid3Style.FontSize)) - : undefined, + fontSize: grid3Style.FontSize ? parseInt(String(grid3Style.FontSize)) : undefined, backgroundShape: grid3Style.BackgroundShape !== undefined ? parseInt(String(grid3Style.BackgroundShape)) @@ -390,10 +373,10 @@ class GridsetProcessor extends BaseProcessor { // Helper to safely extract text from XML parser values private textOf(val: any): string | undefined { if (!val) return undefined; - if (typeof val === "string") return val; - if (typeof val === "number") return String(val); + if (typeof val === 'string') return val; + if (typeof val === 'number') return String(val); - if (typeof val === "object") { + if (typeof val === 'object') { // Don't immediately return #text - it might be whitespace alongside structured content // Process structured format first:

text

@@ -405,18 +388,18 @@ class GridsetProcessor extends BaseProcessor { if (s.r !== undefined) { const rElements = Array.isArray(s.r) ? s.r : [s.r]; for (const r of rElements) { - if (typeof r === "number") { + if (typeof r === 'number') { if (r !== 0) { parts.push(String(r)); } continue; } - if (typeof r === "object" && r !== null) { + if (typeof r === 'object' && r !== null) { // Check for #text (regular text) or #cdata (CDATA sections) - if ("#text" in r) { - parts.push(String(r["#text"])); - } else if ("#cdata" in r) { - parts.push(String(r["#cdata"])); + if ('#text' in r) { + parts.push(String(r['#text'])); + } else if ('#cdata' in r) { + parts.push(String(r['#cdata'])); } else { parts.push(String(r)); } @@ -439,7 +422,7 @@ class GridsetProcessor extends BaseProcessor { } if (parts.length > 0) { - return parts.join("").trim(); + return parts.join('').trim(); } } return undefined; @@ -479,18 +462,17 @@ class GridsetProcessor extends BaseProcessor { ignoreDeclaration: true, parseTagValue: false, trimValues: false, - textNodeName: "#text", - cdataProp: "#cdata", + textNodeName: '#text', + cdataProp: '#cdata', }; const parser = new XMLParser(options); const isEncryptedArchive = - typeof filePathOrBuffer === "string" && - filePathOrBuffer.toLowerCase().endsWith(".gridsetx"); + typeof filePathOrBuffer === 'string' && filePathOrBuffer.toLowerCase().endsWith('.gridsetx'); const encryptedContentPassword = this.getGridsetPassword(filePathOrBuffer); // Initialize metadata const metadata: GridSetMetadata = { - format: "gridset", + format: 'gridset', isSmartBox: isEncryptedArchive, // SmartBox files are .gridsetx encrypted archives passwordProtected: !!password, }; @@ -506,35 +488,27 @@ class GridsetProcessor extends BaseProcessor { // Parse FileMap.xml if present to index dynamic files per grid const fileMapIndex = new Map(); try { - const fmEntry = entries.find((e) => e.entryName.endsWith("FileMap.xml")); + const fmEntry = entries.find((e) => e.entryName.endsWith('FileMap.xml')); if (fmEntry) { const fmXml = decodeText(await readEntryBuffer(fmEntry)); const fmData = parser.parse(fmXml); - const entries = - fmData?.FileMap?.Entries?.Entry || fmData?.fileMap?.entries?.entry; + const entries = fmData?.FileMap?.Entries?.Entry || fmData?.fileMap?.entries?.entry; if (entries) { const arr = Array.isArray(entries) ? entries : [entries]; for (const ent of arr) { - const rawStaticFile = - ent["@_StaticFile"] || ent.StaticFile || ent.staticFile; + const rawStaticFile = ent['@_StaticFile'] || ent.StaticFile || ent.staticFile; const staticFile = - typeof rawStaticFile === "string" - ? rawStaticFile.replace(/\\/g, "/") - : ""; + typeof rawStaticFile === 'string' ? rawStaticFile.replace(/\\/g, '/') : ''; if (!staticFile) continue; const df = ent.DynamicFiles || ent.dynamicFiles; const candidates = df?.File || df?.file || df?.Files || df?.files; - const list = Array.isArray(candidates) - ? candidates - : candidates - ? [candidates] - : []; + const list = Array.isArray(candidates) ? candidates : candidates ? [candidates] : []; const files: string[] = []; for (const v of list) { if (!v) continue; - if (typeof v === "string") files.push(v.replace(/\\/g, "/")); - else if (typeof v === "object" && "#text" in v) - files.push(String(v["#text"]).replace(/\\/g, "/")); + if (typeof v === 'string') files.push(v.replace(/\\/g, '/')); + else if (typeof v === 'object' && '#text' in v) + files.push(String(v['#text']).replace(/\\/g, '/')); } fileMapIndex.set(staticFile, files); } @@ -547,9 +521,7 @@ class GridsetProcessor extends BaseProcessor { // First, load styles from Settings0/Styles/styles.xml (Grid3 format) const styles = new Map(); const styleEntry = entries.find( - (entry) => - entry.entryName.endsWith("styles.xml") || - entry.entryName.endsWith("style.xml"), + (entry) => entry.entryName.endsWith('styles.xml') || entry.entryName.endsWith('style.xml') ); if (styleEntry) { try { @@ -562,8 +534,8 @@ class GridsetProcessor extends BaseProcessor { ? styleData.StyleData.Styles.Style : [styleData.StyleData.Styles.Style]; styleArray.forEach((style: any) => { - if (style["@_Key"]) { - styles.set(String(style["@_Key"]), style); + if (style['@_Key']) { + styles.set(String(style['@_Key']), style); } }); } @@ -573,31 +545,31 @@ class GridsetProcessor extends BaseProcessor { ? styleData.Styles.Style : [styleData.Styles.Style]; styleArray.forEach((style: any) => { - if (style["@_ID"]) { - styles.set(String(style["@_ID"]), style); + if (style['@_ID']) { + styles.set(String(style['@_ID']), style); } }); } } catch (e) { - console.warn("Failed to parse styles.xml:", e); + console.warn('Failed to parse styles.xml:', e); } } // Debug: log all entry names - console.log("[Gridset] Total zip entries:", entries.length); + console.log('[Gridset] Total zip entries:', entries.length); const normalizeEntryName = (entryName: string): string => - entryName.replace(/\\/g, "/").toLowerCase(); + entryName.replace(/\\/g, '/').toLowerCase(); const isGridXmlEntry = (entryName: string): boolean => { const normalized = normalizeEntryName(entryName); - if (!normalized.endsWith("grid.xml")) return false; - return normalized.startsWith("grids/") || normalized.includes("/grids/"); + if (!normalized.endsWith('grid.xml')) return false; + return normalized.startsWith('grids/') || normalized.includes('/grids/'); }; const gridEntries = entries.filter((e) => isGridXmlEntry(e.entryName)); - console.log("[Gridset] Grid XML entries found:", gridEntries.length); + console.log('[Gridset] Grid XML entries found:', gridEntries.length); if (gridEntries.length > 0) { console.log( - "[Gridset] First few grid entries:", - gridEntries.slice(0, 3).map((e) => e.entryName), + '[Gridset] First few grid entries:', + gridEntries.slice(0, 3).map((e) => e.entryName) ); } @@ -606,11 +578,11 @@ class GridsetProcessor extends BaseProcessor { const imageEntries = entries.filter((e) => { const name = e.entryName.toLowerCase(); return ( - name.endsWith(".png") || - name.endsWith(".jpg") || - name.endsWith(".jpeg") || - name.endsWith(".gif") || - name.endsWith(".svg") + name.endsWith('.png') || + name.endsWith('.jpg') || + name.endsWith('.jpeg') || + name.endsWith('.gif') || + name.endsWith('.svg') ); }); @@ -620,7 +592,7 @@ class GridsetProcessor extends BaseProcessor { const data = isEncryptedArchive ? decryptGridsetEntry(Buffer.from(raw), encryptedContentPassword) : Buffer.from(raw); - const normalizedEntry = imageEntry.entryName.replace(/\\/g, "/"); + const normalizedEntry = imageEntry.entryName.replace(/\\/g, '/'); imageDataCache.set(normalizedEntry, data); } catch (_err) { // Silently fail - individual image loading failures shouldn't break the entire load @@ -641,9 +613,7 @@ class GridsetProcessor extends BaseProcessor { const gridId = this.textOf(grid.GridGuid || grid.gridGuid || grid.id); const gridName = - this.textOf(grid.Name) || - this.textOf(grid.name) || - this.textOf(grid["@_Name"]); + this.textOf(grid.Name) || this.textOf(grid.name) || this.textOf(grid['@_Name']); const folderMatch = entry.entryName.match(/^Grids\/([^/]+)\//); const folderName = folderMatch ? folderMatch[1] : undefined; @@ -677,7 +647,7 @@ class GridsetProcessor extends BaseProcessor { xmlContent = decodeText(buffer); console.log( `[Gridset] Raw XML content (first 200 chars) for ${entry.entryName}:`, - xmlContent.substring(0, 200), + xmlContent.substring(0, 200) ); } catch (_e) { // Skip unreadable files @@ -686,10 +656,7 @@ class GridsetProcessor extends BaseProcessor { let data: Record; try { data = parser.parse(xmlContent) as Record; - console.log( - `[Gridset] Parsed ${entry.entryName}, root keys:`, - Object.keys(data), - ); + console.log(`[Gridset] Parsed ${entry.entryName}, root keys:`, Object.keys(data)); } catch (error: any) { // Skip malformed XML but log the specific error console.warn(`Malformed XML in ${entry.entryName}: ${error.message}`); @@ -697,9 +664,7 @@ class GridsetProcessor extends BaseProcessor { } // Grid3 XML: root - const grid = - (data as { Grid?: any; grid?: any }).Grid || - (data as { grid?: any }).grid; + const grid = (data as { Grid?: any; grid?: any }).Grid || (data as { grid?: any }).grid; if (!grid) { console.warn(`[Gridset] No Grid/grid found in ${entry.entryName}`); continue; @@ -707,9 +672,7 @@ class GridsetProcessor extends BaseProcessor { // Defensive: GridGuid and Name required const gridId = this.textOf(grid.GridGuid || grid.gridGuid || grid.id); let gridName = - this.textOf(grid.Name) || - this.textOf(grid.name) || - this.textOf(grid["@_Name"]); + this.textOf(grid.Name) || this.textOf(grid.name) || this.textOf(grid['@_Name']); if (!gridName) { // Fallback: get folder name from entry path const match = entry.entryName.match(/^Grids\/([^/]+)\//); @@ -733,16 +696,8 @@ class GridsetProcessor extends BaseProcessor { // Calculate grid dimensions from ColumnDefinitions and RowDefinitions const columnDefs = grid.ColumnDefinitions?.ColumnDefinition || []; const rowDefs = grid.RowDefinitions?.RowDefinition || []; - const maxCols = Array.isArray(columnDefs) - ? columnDefs.length - : columnDefs - ? 1 - : 5; - const maxRows = Array.isArray(rowDefs) - ? rowDefs.length - : rowDefs - ? 1 - : 4; + const maxCols = Array.isArray(columnDefs) ? columnDefs.length : columnDefs ? 1 : 5; + const maxRows = Array.isArray(rowDefs) ? rowDefs.length : rowDefs ? 1 : 4; // Process buttons: const cells = grid.Cells?.Cell || grid.cells?.cell; @@ -762,8 +717,7 @@ class GridsetProcessor extends BaseProcessor { // Extract words from grid-level AutoContentCommands (e.g., Prediction Bar) if (grid.AutoContentCommands) { - const collections = - grid.AutoContentCommands.AutoContentCommandCollection; + const collections = grid.AutoContentCommands.AutoContentCommandCollection; const collectionArr = Array.isArray(collections) ? collections : collections @@ -772,23 +726,15 @@ class GridsetProcessor extends BaseProcessor { collectionArr.forEach((collection: any) => { const commands = collection.Commands?.Command; - const commandArr = Array.isArray(commands) - ? commands - : commands - ? [commands] - : []; + const commandArr = Array.isArray(commands) ? commands : commands ? [commands] : []; commandArr.forEach((command: any) => { - const commandId = command["@_ID"] || command.ID || command.id; - if (commandId === "Prediction.PredictThis") { + const commandId = command['@_ID'] || command.ID || command.id; + if (commandId === 'Prediction.PredictThis') { const params = command.Parameter; - const paramArr = Array.isArray(params) - ? params - : params - ? [params] - : []; + const paramArr = Array.isArray(params) ? params : params ? [params] : []; const wordListParam = paramArr.find( - (p: any) => (p["@_Key"] || p.Key || p.key) === "wordlist", + (p: any) => (p['@_Key'] || p.Key || p.key) === 'wordlist' ); if (wordListParam) { @@ -811,9 +757,7 @@ class GridsetProcessor extends BaseProcessor { const pageWordListItems: PageWordListItem[] = []; if (grid.WordList && grid.WordList.Items) { const items = - grid.WordList.Items.WordListItem || - grid.WordList.Items.wordlistitem || - []; + grid.WordList.Items.WordListItem || grid.WordList.Items.wordlistitem || []; const itemArr = Array.isArray(items) ? items : items ? [items] : []; for (const item of itemArr) { @@ -824,20 +768,19 @@ class GridsetProcessor extends BaseProcessor { // Debug: log WordList items with spaces to check extraction if (pageWordListItems.length < 3) { console.log( - `[WordList] Extracted text: "${val}" (length: ${val.length}, has spaces: ${val.includes(" ")})`, + `[WordList] Extracted text: "${val}" (length: ${val.length}, has spaces: ${val.includes(' ')})` ); console.log( `[WordList] Chars:`, Array.from(val) .map((c) => `"${c}" (${c.charCodeAt(0)})`) - .join(", "), + .join(', ') ); } pageWordListItems.push({ text: val, image: item.Image || item.image || undefined, - partOfSpeech: - item.PartOfSpeech || item.partOfSpeech || undefined, + partOfSpeech: item.PartOfSpeech || item.partOfSpeech || undefined, }); } } @@ -858,7 +801,7 @@ class GridsetProcessor extends BaseProcessor { const findNextAvailablePosition = ( width: number, height: number, - gridLayout: (AACButton | null)[][], + gridLayout: (AACButton | null)[][] ): { x: number; y: number } => { for (let y = 0; y < maxRows; y++) { for (let x = 0; x <= maxCols - width; x++) { @@ -886,7 +829,7 @@ class GridsetProcessor extends BaseProcessor { const findNextAvailableXInRow = ( rowY: number, width: number, - gridLayout: (AACButton | null)[][], + gridLayout: (AACButton | null)[][] ): number => { for (let x = 0; x <= maxCols - width; x++) { let fits = true; @@ -914,8 +857,8 @@ class GridsetProcessor extends BaseProcessor { cellArr.forEach((cell: any, idx: number) => { if (!cell || !cell.Content) return; - const hasX = cell["@_X"] !== undefined; - const hasY = cell["@_Y"] !== undefined; + const hasX = cell['@_X'] !== undefined; + const hasY = cell['@_Y'] !== undefined; if (hasX && hasY) { cellsWithExplicitPosition.push({ cell, idx }); @@ -938,12 +881,12 @@ class GridsetProcessor extends BaseProcessor { allCellsToProcess.forEach(({ cell, idx }) => { // Extract span information first - const colSpan = parseInt(String(cell["@_ColumnSpan"] || "1"), 10); - const rowSpan = parseInt(String(cell["@_RowSpan"] || "1"), 10); + const colSpan = parseInt(String(cell['@_ColumnSpan'] || '1'), 10); + const rowSpan = parseInt(String(cell['@_RowSpan'] || '1'), 10); // Determine position based on what attributes are present - const hasX = cell["@_X"] !== undefined; - const hasY = cell["@_Y"] !== undefined; + const hasX = cell['@_X'] !== undefined; + const hasY = cell['@_Y'] !== undefined; let cellX: number; let cellY: number; @@ -951,17 +894,17 @@ class GridsetProcessor extends BaseProcessor { if (hasX && hasY) { // Explicit position: both X and Y provided // Grid 3 XML coordinates are already 0-based, use them directly - cellX = Math.max(0, parseInt(String(cell["@_X"]), 10)); - cellY = Math.max(0, parseInt(String(cell["@_Y"]), 10)); + cellX = Math.max(0, parseInt(String(cell['@_X']), 10)); + cellY = Math.max(0, parseInt(String(cell['@_Y']), 10)); } else if (hasY && !hasX) { // Y-only: auto-flow X in the specified row // Grid 3 XML coordinates are already 0-based, use them directly - cellY = Math.max(0, parseInt(String(cell["@_Y"]), 10)); + cellY = Math.max(0, parseInt(String(cell['@_Y']), 10)); cellX = findNextAvailableXInRow(cellY, colSpan, gridLayout); } else if (!hasY && hasX) { // X-only: place at specified X in next available row // Grid 3 XML coordinates are already 0-based, use them directly - cellX = Math.max(0, parseInt(String(cell["@_X"]), 10)); + cellX = Math.max(0, parseInt(String(cell['@_X']), 10)); // Find first row where this X position is available cellY = 0; let found = false; @@ -981,27 +924,19 @@ class GridsetProcessor extends BaseProcessor { } if (!found) { // No available row found, use auto-flow - const pos = findNextAvailablePosition( - colSpan, - rowSpan, - gridLayout, - ); + const pos = findNextAvailablePosition(colSpan, rowSpan, gridLayout); cellX = pos.x; cellY = pos.y; } } else { // No position: auto-flow both X and Y - const pos = findNextAvailablePosition( - colSpan, - rowSpan, - gridLayout, - ); + const pos = findNextAvailablePosition(colSpan, rowSpan, gridLayout); cellX = pos.x; cellY = pos.y; } // Extract scan block number (1-8) for block scanning support - const scanBlock = parseInt(String(cell["@_ScanBlock"] || "1"), 10); + const scanBlock = parseInt(String(cell['@_ScanBlock'] || '1'), 10); // Extract visibility from Grid 3's child element // Grid 3 stores visibility as a child element, not an attribute @@ -1011,30 +946,30 @@ class GridsetProcessor extends BaseProcessor { // Map Grid 3 visibility values to AAC standard values // Grid 3 can have additional values like TouchOnly, PointerOnly that map to PointerAndTouchOnly let cellVisibility: - | "Visible" - | "Hidden" - | "Disabled" - | "PointerAndTouchOnly" - | "Empty" + | 'Visible' + | 'Hidden' + | 'Disabled' + | 'PointerAndTouchOnly' + | 'Empty' | undefined; if (grid3Visibility) { const vis = String(grid3Visibility); // Direct mapping for standard values if ( - vis === "Visible" || - vis === "Hidden" || - vis === "Disabled" || - vis === "PointerAndTouchOnly" + vis === 'Visible' || + vis === 'Hidden' || + vis === 'Disabled' || + vis === 'PointerAndTouchOnly' ) { cellVisibility = vis; } // Map Grid 3 specific values to AAC standard - else if (vis === "TouchOnly" || vis === "PointerOnly") { - cellVisibility = "PointerAndTouchOnly"; + else if (vis === 'TouchOnly' || vis === 'PointerOnly') { + cellVisibility = 'PointerAndTouchOnly'; } // Grid 3 may use 'Empty' for cells that exist but have no content - else if (vis === "Empty") { - cellVisibility = "Empty"; + else if (vis === 'Empty') { + cellVisibility = 'Empty'; } // Unknown visibility - default to Visible else { @@ -1044,12 +979,8 @@ class GridsetProcessor extends BaseProcessor { // Extract label from CaptionAndImage/Caption const content = cell.Content; - const captionAndImage = - content.CaptionAndImage || content.captionAndImage; - let label = - this.textOf( - captionAndImage?.Caption || captionAndImage?.caption, - ) || ""; + const captionAndImage = content.CaptionAndImage || content.captionAndImage; + let label = this.textOf(captionAndImage?.Caption || captionAndImage?.caption) || ''; // Check if cell has an image/symbol (needed to decide if we should keep it) const hasImageCandidate = !!( @@ -1064,12 +995,12 @@ class GridsetProcessor extends BaseProcessor { // If no caption, try other sources or create a placeholder if (!label) { // For cells without captions, check if they have images/symbols before skipping - if (content.ContentType === "AutoContent") { + if (content.ContentType === 'AutoContent') { label = `AutoContent_${idx}`; } else if ( hasImageCandidate || - content.ContentType === "Workspace" || - content.ContentType === "LiveCell" + content.ContentType === 'Workspace' || + content.ContentType === 'LiveCell' ) { // Keep cells with images/symbols even if no caption label = `Cell_${idx}`; @@ -1085,18 +1016,18 @@ class GridsetProcessor extends BaseProcessor { // Friendly labels for workspace/prediction cells when captions are missing if (pluginMetadata.cellType === Grid3CellType.Workspace) { - if (!label || label.startsWith("Cell_")) { + if (!label || label.startsWith('Cell_')) { label = pluginMetadata.displayName || pluginMetadata.subType || pluginMetadata.pluginId || - "Workspace"; + 'Workspace'; } } if ( pluginMetadata.cellType === Grid3CellType.AutoContent && - pluginMetadata.autoContentType === "Prediction" + pluginMetadata.autoContentType === 'Prediction' ) { predictionCellCounter += 1; // Always surface a friendly label for predictions even if a placeholder exists @@ -1107,7 +1038,7 @@ class GridsetProcessor extends BaseProcessor { let isMoreButton = false; if ( pluginMetadata.cellType === Grid3CellType.AutoContent && - pluginMetadata.autoContentType === "WordList" && + pluginMetadata.autoContentType === 'WordList' && pageWordListItems.length > 0 ) { // Track this cell for potential "more" button @@ -1122,13 +1053,12 @@ class GridsetProcessor extends BaseProcessor { // The "more" button replaces the last WordList cell const cellsNeededForWordList = pageWordListItems.length; const availableWordListCells = wordListAutoContentCells.length; - const isLastWordListCell = - availableWordListCells === cellsNeededForWordList + 1; // +1 for "more" button + const isLastWordListCell = availableWordListCells === cellsNeededForWordList + 1; // +1 for "more" button if (isLastWordListCell) { // This cell becomes the "more" button - label = "more..."; - message = "more..."; + label = 'more...'; + message = 'more...'; isMoreButton = true; } else if (wordListCellIndex < pageWordListItems.length) { // Populate this cell with the next WordList item @@ -1154,8 +1084,7 @@ class GridsetProcessor extends BaseProcessor { let detectedCommands: any[] = []; // Store detected command metadata let buttonPos: string | undefined; // Part-of-speech from Action.InsertText - const commands = - content.Commands?.Command || content.commands?.command; + const commands = content.Commands?.Command || content.commands?.command; let predictionWords: string[] | undefined; // Resolve image for this cell using FileMap and coordinate heuristics @@ -1166,11 +1095,9 @@ class GridsetProcessor extends BaseProcessor { captionAndImage?.imageName || captionAndImage?.Symbol || captionAndImage?.symbol; - const declaredImageName = imageCandidate - ? this.textOf(imageCandidate) - : undefined; - const gridEntryPath = entry.entryName.replace(/\\/g, "/"); - const baseDir = gridEntryPath.replace(/\/grid\.xml$/, "/"); + const declaredImageName = imageCandidate ? this.textOf(imageCandidate) : undefined; + const gridEntryPath = entry.entryName.replace(/\\/g, '/'); + const baseDir = gridEntryPath.replace(/\/grid\.xml$/, '/'); const dynamicFiles = fileMapIndex.get(gridEntryPath) || []; const resolvedImageEntry = resolveGrid3CellImage( @@ -1182,17 +1109,17 @@ class GridsetProcessor extends BaseProcessor { y: cellY, dynamicFiles, }, - entries, + entries ) || undefined; // Debug: log resolution for cells with images if (declaredImageName && resolvedImageEntry) { console.log( - `[GridsetProcessor] Cell (${cellX + 1},${cellY + 1}) [XML coords]: ${declaredImageName} -> ${resolvedImageEntry}`, + `[GridsetProcessor] Cell (${cellX + 1},${cellY + 1}) [XML coords]: ${declaredImageName} -> ${resolvedImageEntry}` ); } else if (declaredImageName && !resolvedImageEntry) { console.log( - `[GridsetProcessor] Cell (${cellX + 1},${cellY + 1}) [XML coords]: ${declaredImageName} -> NOT FOUND`, + `[GridsetProcessor] Cell (${cellX + 1},${cellY + 1}) [XML coords]: ${declaredImageName} -> NOT FOUND` ); } @@ -1203,36 +1130,23 @@ class GridsetProcessor extends BaseProcessor { // Check if image is a symbol library reference let symbolLibraryRef: SymbolReference | null = null; - if ( - declaredImageName && - isSymbolLibraryReference(declaredImageName) - ) { + if (declaredImageName && isSymbolLibraryReference(declaredImageName)) { symbolLibraryRef = parseSymbolReference(declaredImageName); } if (commands) { - const commandArr = Array.isArray(commands) - ? commands - : [commands]; + const commandArr = Array.isArray(commands) ? commands : [commands]; detectedCommands = commandArr.map((cmd) => detectCommand(cmd)); // Scan all commands for vocabulary (predictions) before identifying primary action commandArr.forEach((cmd) => { - const id = cmd["@_ID"] || cmd.ID || cmd.id; - if (id === "Prediction.PredictThis") { + const id = cmd['@_ID'] || cmd.ID || cmd.id; + if (id === 'Prediction.PredictThis') { const params = cmd.Parameter || cmd.parameter; - const pArr = params - ? Array.isArray(params) - ? params - : [params] - : []; + const pArr = params ? (Array.isArray(params) ? params : [params]) : []; let wlP: any; for (const p of pArr) { - if ( - p["@_Key"] === "wordlist" || - p.Key === "wordlist" || - p.key === "wordlist" - ) { + if (p['@_Key'] === 'wordlist' || p.Key === 'wordlist' || p.key === 'wordlist') { wlP = p; break; } @@ -1247,7 +1161,7 @@ class GridsetProcessor extends BaseProcessor { }); for (const command of commandArr) { - const commandId = command["@_ID"] || command.ID || command.id; + const commandId = command['@_ID'] || command.ID || command.id; const parameters = command.Parameter || command.parameter; const paramArr = parameters ? Array.isArray(parameters) @@ -1258,11 +1172,7 @@ class GridsetProcessor extends BaseProcessor { // Helper to get raw parameter object const getRawParam = (key: string): any | undefined => { for (const param of paramArr) { - if ( - param["@_Key"] === key || - param.Key === key || - param.key === key - ) { + if (param['@_Key'] === key || param.Key === key || param.key === key) { return param; } } @@ -1273,24 +1183,20 @@ class GridsetProcessor extends BaseProcessor { const getParam = (key: string): string | undefined => { const param = getRawParam(key); if (param === undefined) return undefined; - const simpleValue = - param["#text"] ?? param.text ?? param.value; - if (typeof simpleValue === "string") return simpleValue; - if (typeof simpleValue === "number") - return String(simpleValue); + const simpleValue = param['#text'] ?? param.text ?? param.value; + if (typeof simpleValue === 'string') return simpleValue; + if (typeof simpleValue === 'number') return String(simpleValue); const structuredValue = this.textOf(param); if (structuredValue !== undefined) return structuredValue; - if (typeof param === "string") return param; + if (typeof param === 'string') return param; return undefined; }; // Skip PredictThis in primary action loop as it was handled in pre-pass // unless we need a primary action and nothing else exists - if (commandId === "Prediction.PredictThis") { - const wlParam = getRawParam("wordlist"); - const words = wlParam - ? this._extractWordsFromWordList(wlParam) - : []; + if (commandId === 'Prediction.PredictThis') { + const wlParam = getRawParam('wordlist'); + const words = wlParam ? this._extractWordsFromWordList(wlParam) : []; if (words.length > 0) { predictionWords = words; } @@ -1299,23 +1205,22 @@ class GridsetProcessor extends BaseProcessor { semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.PLATFORM_SPECIFIC, - text: words.slice(0, 3).join(", "), + text: words.slice(0, 3).join(', '), platformData: { grid3: { commandId, parameters: { wordlist: words } }, }, - fallback: { type: "ACTION", message: "Predict words" }, + fallback: { type: 'ACTION', message: 'Predict words' }, }; } continue; } switch (commandId) { - case "Jump.To": { - const gridTarget = getParam("grid"); + case 'Jump.To': { + const gridTarget = getParam('grid'); if (gridTarget) { // Resolve grid name to grid ID for navigation - const targetGridId = - gridNameToIdMap.get(gridTarget) || gridTarget; + const targetGridId = gridNameToIdMap.get(gridTarget) || gridTarget; // Always set navigationTarget even if another command already // set semanticAction (e.g. Jump.SetBookmark + Jump.To). navigationTarget = targetGridId; @@ -1332,12 +1237,12 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId: targetGridId, }, }; _legacyAction = { - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId: targetGridId, }; } @@ -1345,7 +1250,7 @@ class GridsetProcessor extends BaseProcessor { break; } - case "Jump.Back": + case 'Jump.Back': if (!semanticAction) { semanticAction = { category: AACSemanticCategory.NAVIGATION, @@ -1357,20 +1262,19 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Go back", + type: 'ACTION', + message: 'Go back', }, }; _legacyAction = { - type: "GO_BACK", + type: 'GO_BACK', }; } break; - case "Jump.Home": - case "Jump.SetHome": - if (!navigationTarget) - navigationTarget = tree.rootId || undefined; + case 'Jump.Home': + case 'Jump.SetHome': + if (!navigationTarget) navigationTarget = tree.rootId || undefined; if (!semanticAction) { semanticAction = { category: AACSemanticCategory.NAVIGATION, @@ -1383,25 +1287,23 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Go home", + type: 'ACTION', + message: 'Go home', }, }; _legacyAction = { - type: "GO_HOME", + type: 'GO_HOME', }; } break; - case "Jump.ToKeyboard": { + case 'Jump.ToKeyboard': { // Prefer explicit keyboard page metadata when available. // Some Gridsets resolve the keyboard page in metadata // without preserving tree.keyboardGridName during parse. - const keyboardGridName = (tree as any) - .keyboardGridName as string; + const keyboardGridName = (tree as any).keyboardGridName as string; const keyboardPageId = - tree.metadata?.defaultKeyboardPageId || - gridNameToIdMap.get(keyboardGridName); + tree.metadata?.defaultKeyboardPageId || gridNameToIdMap.get(keyboardGridName); if (keyboardPageId && !navigationTarget) { navigationTarget = keyboardPageId; } @@ -1417,7 +1319,7 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId: keyboardPageId, }, }; @@ -1425,9 +1327,9 @@ class GridsetProcessor extends BaseProcessor { break; } - case "Action.InsertTextAndSpeak": { + case 'Action.InsertTextAndSpeak': { if (!semanticAction) { - const insertText = getParam("text"); + const insertText = getParam('text'); semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_IMMEDIATE, @@ -1439,7 +1341,7 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "SPEAK", + type: 'SPEAK', message: insertText, }, }; @@ -1447,18 +1349,16 @@ class GridsetProcessor extends BaseProcessor { break; } - case "Prediction.PredictThis": { - const wlParam = getRawParam("wordlist"); - const words = wlParam - ? this._extractWordsFromWordList(wlParam) - : []; + case 'Prediction.PredictThis': { + const wlParam = getRawParam('wordlist'); + const words = wlParam ? this._extractWordsFromWordList(wlParam) : []; if (words.length > 0) { predictionWords = words; if (!semanticAction) { semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.PLATFORM_SPECIFIC, - text: words.slice(0, 3).join(", "), // Provide first few as preview + text: words.slice(0, 3).join(', '), // Provide first few as preview platformData: { grid3: { commandId, @@ -1466,8 +1366,8 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Predict words", + type: 'ACTION', + message: 'Predict words', }, }; } @@ -1476,10 +1376,10 @@ class GridsetProcessor extends BaseProcessor { continue; } - case "Action.Speak": { + case 'Action.Speak': { // speak - const speakUnit = getParam("unit"); - const moveCaret = getParam("movecaret"); + const speakUnit = getParam('unit'); + const moveCaret = getParam('movecaret'); if (!semanticAction) { semanticAction = { category: AACSemanticCategory.COMMUNICATION, @@ -1494,24 +1394,22 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "SPEAK", - message: "Speak text", + type: 'SPEAK', + message: 'Speak text', }, }; _legacyAction = { - type: "SPEAK", + type: 'SPEAK', unit: speakUnit, - moveCaret: moveCaret - ? parseInt(String(moveCaret)) - : undefined, + moveCaret: moveCaret ? parseInt(String(moveCaret)) : undefined, }; } break; } - case "Action.InsertText": { - const insertText = getParam("text"); - const posParam = getParam("pos"); + case 'Action.InsertText': { + const insertText = getParam('text'); + const posParam = getParam('pos'); // Always extract POS even if semanticAction is already set if (posParam) { buttonPos = posParam; @@ -1528,19 +1426,19 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "SPEAK", + type: 'SPEAK', message: insertText, }, }; _legacyAction = { - type: "INSERT_TEXT", + type: 'INSERT_TEXT', text: insertText, }; } break; } - case "Action.DeleteWord": + case 'Action.DeleteWord': if (!semanticAction) { semanticAction = { category: AACSemanticCategory.TEXT_EDITING, @@ -1552,17 +1450,17 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Delete word", + type: 'ACTION', + message: 'Delete word', }, }; _legacyAction = { - type: "DELETE_WORD", + type: 'DELETE_WORD', }; } break; - case "Action.DeleteLetter": + case 'Action.DeleteLetter': if (!semanticAction) { semanticAction = { category: AACSemanticCategory.TEXT_EDITING, @@ -1574,17 +1472,17 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Delete character", + type: 'ACTION', + message: 'Delete character', }, }; _legacyAction = { - type: "DELETE_CHARACTER", + type: 'DELETE_CHARACTER', }; } break; - case "Action.Clear": + case 'Action.Clear': // action if (!semanticAction) { semanticAction = { @@ -1597,19 +1495,19 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Clear text", + type: 'ACTION', + message: 'Clear text', }, }; _legacyAction = { - type: "CLEAR_TEXT", + type: 'CLEAR_TEXT', }; } break; - case "Action.Letter": { + case 'Action.Letter': { // action - const letter = getParam("letter"); + const letter = getParam('letter'); if (!semanticAction) { semanticAction = { category: AACSemanticCategory.TEXT_EDITING, @@ -1622,19 +1520,19 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", + type: 'ACTION', message: letter, }, }; _legacyAction = { - type: "INSERT_LETTER", + type: 'INSERT_LETTER', letter, }; } break; } - case "Settings.RestAll": + case 'Settings.RestAll': // action if (!semanticAction) { semanticAction = { @@ -1644,25 +1542,25 @@ class GridsetProcessor extends BaseProcessor { grid3: { commandId, parameters: { - indicatorenabled: getParam("indicatorenabled"), - action: getParam("action"), + indicatorenabled: getParam('indicatorenabled'), + action: getParam('action'), }, }, }, fallback: { - type: "ACTION", - message: "Settings action", + type: 'ACTION', + message: 'Settings action', }, }; _legacyAction = { - type: "SETTINGS", - indicatorEnabled: getParam("indicatorenabled") === "1", - settingsAction: getParam("action"), + type: 'SETTINGS', + indicatorEnabled: getParam('indicatorenabled') === '1', + settingsAction: getParam('action'), }; } break; - case "AutoContent.Activate": + case 'AutoContent.Activate': // action if (!semanticAction) { semanticAction = { @@ -1672,18 +1570,18 @@ class GridsetProcessor extends BaseProcessor { grid3: { commandId, parameters: { - autocontenttype: getParam("autocontenttype"), + autocontenttype: getParam('autocontenttype'), }, }, }, fallback: { - type: "ACTION", - message: "Auto content", + type: 'ACTION', + message: 'Auto content', }, }; _legacyAction = { - type: "AUTO_CONTENT", - autoContentType: getParam("autocontenttype"), + type: 'AUTO_CONTENT', + autoContentType: getParam('autocontenttype'), }; } break; @@ -1692,7 +1590,7 @@ class GridsetProcessor extends BaseProcessor { // Unknown command - preserve as generic action if (commandId && !semanticAction) { const allParams = Object.fromEntries( - paramArr.map((p) => [p.Key || p.key, p["#text"]]), + paramArr.map((p) => [p.Key || p.key, p['#text']]) ); semanticAction = { category: AACSemanticCategory.CUSTOM, @@ -1704,8 +1602,8 @@ class GridsetProcessor extends BaseProcessor { }, }, fallback: { - type: "ACTION", - message: "Unknown command", + type: 'ACTION', + message: 'Unknown command', }, }; // legacy action not needed for unknown commands @@ -1726,14 +1624,14 @@ class GridsetProcessor extends BaseProcessor { intent: AACSemanticIntent.SPEAK_TEXT, text: String(message), fallback: { - type: "SPEAK", + type: 'SPEAK', message: String(message), }, }; } // Get style information from cell attributes and Content.Style - let cellStyleId = cell["@_StyleID"] || cell["@_styleid"]; + let cellStyleId = cell['@_StyleID'] || cell['@_styleid']; // Grid3 format: check Content.Style.BasedOnStyle if (!cellStyleId && content.Style?.BasedOnStyle) { @@ -1742,28 +1640,21 @@ class GridsetProcessor extends BaseProcessor { const cellStyle = this.getStyleById( styles, - cellStyleId ? String(cellStyleId) : undefined, + cellStyleId ? String(cellStyleId) : undefined ); // Also check for inline style overrides const inlineStyle: any = {}; - if (cell["@_BackColour"]) - inlineStyle.backgroundColor = cell["@_BackColour"]; - if (cell["@_FontColour"]) - inlineStyle.fontColor = cell["@_FontColour"]; - if (cell["@_BorderColour"]) - inlineStyle.borderColor = cell["@_BorderColour"]; + if (cell['@_BackColour']) inlineStyle.backgroundColor = cell['@_BackColour']; + if (cell['@_FontColour']) inlineStyle.fontColor = cell['@_FontColour']; + if (cell['@_BorderColour']) inlineStyle.borderColor = cell['@_BorderColour']; // Grid3 inline styles from Content.Style if (content.Style) { - if (content.Style.BackColour) - inlineStyle.backgroundColor = content.Style.BackColour; - if (content.Style.FontColour) - inlineStyle.fontColor = content.Style.FontColour; - if (content.Style.BorderColour) - inlineStyle.borderColor = content.Style.BorderColour; - if (content.Style.FontName) - inlineStyle.fontFamily = content.Style.FontName; + if (content.Style.BackColour) inlineStyle.backgroundColor = content.Style.BackColour; + if (content.Style.FontColour) inlineStyle.fontColor = content.Style.FontColour; + if (content.Style.BorderColour) inlineStyle.borderColor = content.Style.BorderColour; + if (content.Style.FontName) inlineStyle.fontFamily = content.Style.FontName; if (content.Style.FontSize) inlineStyle.fontSize = parseInt(String(content.Style.FontSize)); } @@ -1772,12 +1663,10 @@ class GridsetProcessor extends BaseProcessor { const grammar: Record = {}; if (buttonPos) grammar.pos = buttonPos; detectedCommands.forEach((cmd) => { - if (!grammar.pos && cmd.parameters.pos) - grammar.pos = cmd.parameters.pos; + if (!grammar.pos && cmd.parameters.pos) grammar.pos = cmd.parameters.pos; if (cmd.parameters.person) grammar.person = cmd.parameters.person; if (cmd.parameters.number) grammar.number = cmd.parameters.number; - if (cmd.parameters.feature) - grammar.feature = cmd.parameters.feature; + if (cmd.parameters.feature) grammar.feature = cmd.parameters.feature; }); const isSmartGrammarCell = Object.keys(grammar).length > 0; const effectivePos = buttonPos || grammar.pos || undefined; @@ -1786,9 +1675,7 @@ class GridsetProcessor extends BaseProcessor { id: `${gridId}_btn_${idx}`, label: String(label), message: String(message), - targetPageId: navigationTarget - ? String(navigationTarget) - : undefined, + targetPageId: navigationTarget ? String(navigationTarget) : undefined, semanticAction: semanticAction, semantic_id: cell.semantic_id || cell.SemanticId || undefined, // Extract semantic_id if present image: declaredImageName, @@ -1800,12 +1687,12 @@ class GridsetProcessor extends BaseProcessor { scanBlock: scanBlock, // Add scan block number for block scanning metrics contentType: pluginMetadata.cellType === Grid3CellType.Regular - ? "Normal" + ? 'Normal' : pluginMetadata.cellType === Grid3CellType.Workspace - ? "Workspace" + ? 'Workspace' : pluginMetadata.cellType === Grid3CellType.LiveCell - ? "LiveCell" - : "AutoContent", + ? 'LiveCell' + : 'AutoContent', contentSubType: pluginMetadata.subType || pluginMetadata.liveCellType || @@ -1837,7 +1724,7 @@ class GridsetProcessor extends BaseProcessor { : undefined, predictionSlot: pluginMetadata.cellType === Grid3CellType.AutoContent && - pluginMetadata.autoContentType === "Prediction" + pluginMetadata.autoContentType === 'Prediction' ? predictionCellCounter : undefined, // Store page name for Grid3 image lookup @@ -1846,14 +1733,12 @@ class GridsetProcessor extends BaseProcessor { isMoreButton: isMoreButton || undefined, wordListItemIndex: pluginMetadata.cellType === Grid3CellType.AutoContent && - pluginMetadata.autoContentType === "WordList" && + pluginMetadata.autoContentType === 'WordList' && !isMoreButton ? wordListCellIndex - 1 : undefined, // Store binary image data for conversion to other formats - ...(imageData - ? { imageData, image_id: resolvedImageEntry } - : {}), + ...(imageData ? { imageData, image_id: resolvedImageEntry } : {}), }, }); @@ -1881,13 +1766,7 @@ class GridsetProcessor extends BaseProcessor { row.forEach((btn, colIndex) => { if (btn) { // Generate clone_id based on position and label - btn.clone_id = generateCloneId( - maxRows, - maxCols, - rowIndex, - colIndex, - btn.label, - ); + btn.clone_id = generateCloneId(maxRows, maxCols, rowIndex, colIndex, btn.label); cloneIds.push(btn.clone_id); // Track semantic_id if present @@ -1915,10 +1794,7 @@ class GridsetProcessor extends BaseProcessor { for (const pageId in tree.pages) { const page = tree.pages[pageId]; page.buttons.forEach((btn: AACButton) => { - if ( - btn.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && - btn.targetPageId - ) { + if (btn.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && btn.targetPageId) { const targetPage = tree.getPage(btn.targetPageId); if (targetPage) { targetPage.parentId = page.id; @@ -1929,9 +1805,7 @@ class GridsetProcessor extends BaseProcessor { // Read settings.xml to get the StartGrid (home page) try { - const settingsEntry = entries.find((e) => - e.entryName.endsWith("settings.xml"), - ); + const settingsEntry = entries.find((e) => e.entryName.endsWith('settings.xml')); if (settingsEntry) { const settingsXml = decodeText(await readEntryBuffer(settingsEntry)); const settingsData = parser.parse(settingsXml); @@ -1951,7 +1825,7 @@ class GridsetProcessor extends BaseProcessor { settingsData?.GridSetSettings?.PrimaryLanguage || settingsData?.gridSetSettings?.primaryLanguage || settingsData?.GridsetSettings?.PrimaryLanguage; - if (gsLang && typeof gsLang === "string") { + if (gsLang && typeof gsLang === 'string') { metadata.locale = gsLang; metadata.languages = [gsLang]; } @@ -1990,12 +1864,9 @@ class GridsetProcessor extends BaseProcessor { if (thumbBg) metadata.thumbnailBackground = thumbBg; const picSearchKeys = - settingsData?.GridSetSettings?.PictureSearch?.PictureSearchKeys - ?.PictureSearchKey || - settingsData?.gridSetSettings?.pictureSearch?.pictureSearchKeys - ?.pictureSearchKey || - settingsData?.GridsetSettings?.PictureSearch?.PictureSearchKeys - ?.PictureSearchKey; + settingsData?.GridSetSettings?.PictureSearch?.PictureSearchKeys?.PictureSearchKey || + settingsData?.gridSetSettings?.pictureSearch?.pictureSearchKeys?.pictureSearchKey || + settingsData?.GridsetSettings?.PictureSearch?.PictureSearchKeys?.PictureSearchKey; if (picSearchKeys) { metadata.pictureSearchKeys = Array.isArray(picSearchKeys) ? picSearchKeys @@ -2009,8 +1880,8 @@ class GridsetProcessor extends BaseProcessor { if (appearance) { metadata.appearance = { textAtTop: - appearance.TextAtTop === "1" || - appearance.textAtTop === "1" || + appearance.TextAtTop === '1' || + appearance.textAtTop === '1' || appearance.TextAtTop === 1, computerControlCellSize: appearance.ComputerControlCellSize ? parseFloat(String(appearance.ComputerControlCellSize)) @@ -2023,7 +1894,7 @@ class GridsetProcessor extends BaseProcessor { settingsData?.gridSetSettings?.startGrid || settingsData?.GridsetSettings?.StartGrid; - if (startGridName && typeof startGridName === "string") { + if (startGridName && typeof startGridName === 'string') { // Resolve the grid name to grid ID const homeGridId = gridNameToIdMap.get(startGridName); if (homeGridId) { @@ -2037,10 +1908,9 @@ class GridsetProcessor extends BaseProcessor { settingsData?.GridSetSettings?.KeyboardGrid || settingsData?.gridSetSettings?.keyboardGrid || settingsData?.GridsetSettings?.KeyboardGrid; - if (keyboardGridName && typeof keyboardGridName === "string") { + if (keyboardGridName && typeof keyboardGridName === 'string') { (tree as any).keyboardGridName = keyboardGridName; - metadata.defaultKeyboardPageId = - gridNameToIdMap.get(keyboardGridName); + metadata.defaultKeyboardPageId = gridNameToIdMap.get(keyboardGridName); } } } catch (_e) { @@ -2053,16 +1923,14 @@ class GridsetProcessor extends BaseProcessor { Object.values(tree.pages).forEach((page) => { page.buttons.forEach((button) => { if ( - button?.semanticAction?.platformData?.grid3?.commandId === - "Jump.ToKeyboard" && + button?.semanticAction?.platformData?.grid3?.commandId === 'Jump.ToKeyboard' && !button.targetPageId ) { button.targetPageId = metadata.defaultKeyboardPageId; if (button.semanticAction) { button.semanticAction.targetId = metadata.defaultKeyboardPageId; - if (button.semanticAction.fallback?.type === "NAVIGATE") { - button.semanticAction.fallback.targetPageId = - metadata.defaultKeyboardPageId; + if (button.semanticAction.fallback?.type === 'NAVIGATE') { + button.semanticAction.fallback.targetPageId = metadata.defaultKeyboardPageId; } } } @@ -2076,7 +1944,7 @@ class GridsetProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string, + outputPath: string ): Promise { const { readBinaryFromInput } = this.options.fileAdapter; // Load the tree, apply translations, and save to new file @@ -2109,11 +1977,7 @@ class GridsetProcessor extends BaseProcessor { if (symbols && symbols.length > 0) { // Use symbol-aware translation to preserve symbol positions - const result = translateWithSymbols( - originalMessage, - translatedText, - symbols, - ); + const result = translateWithSymbols(originalMessage, translatedText, symbols); // Update the message button.message = result.text; @@ -2159,9 +2023,7 @@ class GridsetProcessor extends BaseProcessor { * @param filePathOrBuffer - Path to gridset file or buffer * @returns Promise resolving to symbol information for LLM processing */ - async extractSymbolsForLLM( - filePathOrBuffer: string | Buffer, - ): Promise { + async extractSymbolsForLLM(filePathOrBuffer: string | Buffer): Promise { const tree = await this.loadIntoTree(filePathOrBuffer); // Collect all buttons from all pages @@ -2198,15 +2060,13 @@ class GridsetProcessor extends BaseProcessor { filePathOrBuffer: string | Buffer, llmTranslations: LLMLTranslationResult[], outputPath: string, - options?: { allowPartial?: boolean }, + options?: { allowPartial?: boolean } ): Promise { const { readBinaryFromInput } = this.options.fileAdapter; const tree = await this.loadIntoTree(filePathOrBuffer); // Validate translations using shared utility - const buttonIds = Object.values(tree.pages).flatMap((page) => - page.buttons.map((b) => b.id), - ); + const buttonIds = Object.values(tree.pages).flatMap((page) => page.buttons.map((b) => b.id)); validateTranslationResults(llmTranslations, buttonIds, options); // Create a map for quick lookup @@ -2276,7 +2136,7 @@ class GridsetProcessor extends BaseProcessor { // Helper function to add style and return its ID const addStyle = (style: AACStyle | undefined): string => { - if (!style) return ""; + if (!style) return ''; const normalizedStyle: AACStyle = { ...style }; const styleKey = JSON.stringify(normalizedStyle); const existing = uniqueStyles.get(styleKey); @@ -2297,7 +2157,7 @@ class GridsetProcessor extends BaseProcessor { // Get the home/start grid from tree.rootId, fallback to first page const pages = Object.values(tree.pages); - let startGrid = ""; + let startGrid = ''; if (tree.rootId) { const homePage = tree.getPage(tree.rootId); @@ -2313,68 +2173,64 @@ class GridsetProcessor extends BaseProcessor { // Create Settings0/settings.xml with proper Grid3 structure const settingsData = { - "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, + '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, GridSetSettings: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - Name: tree.metadata?.name || "", - Description: tree.metadata?.description || "", - Author: tree.metadata?.author || "", - PrimaryLanguage: tree.metadata?.locale || "en-US", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + Name: tree.metadata?.name || '', + Description: tree.metadata?.description || '', + Author: tree.metadata?.author || '', + PrimaryLanguage: tree.metadata?.locale || 'en-US', StartGrid: startGrid, // Add other common Grid3 settings - Thumbnail: (tree.metadata as any)?.thumbnail || "", - ThumbnailBackground: (tree.metadata as any)?.thumbnailBackground || "", - DocumentationUrl: - tree.metadata?.homepageUrl || tree.metadata?.url || "", - DocumentationSlug: (tree.metadata as any)?.documentationSlug || "", - ScanEnabled: "false", - ScanTimeoutMs: "2000", - HoverEnabled: "false", - HoverTimeoutMs: "1000", - MouseclickEnabled: "true", - Language: tree.metadata?.locale || "en-US", + Thumbnail: (tree.metadata as any)?.thumbnail || '', + ThumbnailBackground: (tree.metadata as any)?.thumbnailBackground || '', + DocumentationUrl: tree.metadata?.homepageUrl || tree.metadata?.url || '', + DocumentationSlug: (tree.metadata as any)?.documentationSlug || '', + ScanEnabled: 'false', + ScanTimeoutMs: '2000', + HoverEnabled: 'false', + HoverTimeoutMs: '1000', + MouseclickEnabled: 'true', + Language: tree.metadata?.locale || 'en-US', }, }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', suppressEmptyNode: true, }); const settingsXmlContent = settingsBuilder.build(settingsData); files.push({ - name: "Settings0/settings.xml", + name: 'Settings0/settings.xml', data: settingsXmlContent, }); // Create Settings0/Styles/style.xml if there are styles if (uniqueStyles.size > 0) { - const stylesArray = Array.from(uniqueStyles.values()).map( - ({ id, style }) => { - const styleObj = { - "@_Key": id, - // When TileColour is present, BackColour is the surround (outer area) - // For "None" surround, just use BackColour for the fill (no TileColour) - BackColour: this.ensureAlphaChannel(style.backgroundColor), - BorderColour: this.ensureAlphaChannel(style.borderColor), - // Calculate font color based on background if not explicitly set - FontColour: this.ensureAlphaChannel( - style.fontColor || - this.getContrastFontColor(style.backgroundColor), - ), - FontName: style.fontFamily || "Arial", - FontSize: style.fontSize?.toString() || "16", - }; - // Don't add TileColour - just use BackColour as the fill color - return styleObj; - }, - ); + const stylesArray = Array.from(uniqueStyles.values()).map(({ id, style }) => { + const styleObj = { + '@_Key': id, + // When TileColour is present, BackColour is the surround (outer area) + // For "None" surround, just use BackColour for the fill (no TileColour) + BackColour: this.ensureAlphaChannel(style.backgroundColor), + BorderColour: this.ensureAlphaChannel(style.borderColor), + // Calculate font color based on background if not explicitly set + FontColour: this.ensureAlphaChannel( + style.fontColor || this.getContrastFontColor(style.backgroundColor) + ), + FontName: style.fontFamily || 'Arial', + FontSize: style.fontSize?.toString() || '16', + }; + // Don't add TileColour - just use BackColour as the fill color + return styleObj; + }); const styleData = { - "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, + '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, StyleData: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', Styles: { Style: stylesArray, }, @@ -2384,11 +2240,11 @@ class GridsetProcessor extends BaseProcessor { const styleBuilder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', }); const styleXmlContent = styleBuilder.build(styleData); files.push({ - name: "Settings0/Styles/styles.xml", + name: 'Settings0/Styles/styles.xml', data: styleXmlContent, }); } @@ -2400,168 +2256,146 @@ class GridsetProcessor extends BaseProcessor { Object.values(tree.pages).forEach((page) => { const gridData = { Grid: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', GridGuid: page.id, // Calculate grid dimensions based on actual layout ColumnDefinitions: this.calculateColumnDefinitions(page), RowDefinitions: this.calculateRowDefinitions(page, false), // No automatic workspace row injection - AutoContentCommands: "", + AutoContentCommands: '', Cells: page.buttons.length > 0 ? { Cell: [ // Regular button cells - ...this.filterPageButtons(page.buttons).map( - (button, btnIndex) => { - const buttonStyleId = button.style - ? addStyle(button.style) - : ""; - - // Find button position in grid layout - const position = this.findButtonPosition( - page, - button, - btnIndex, - ); - - // Use position directly from tree - const yOffset = 0; - - // Build CaptionAndImage object - const captionAndImage: Record = { - Caption: button.label || "", - }; + ...this.filterPageButtons(page.buttons).map((button, btnIndex) => { + const buttonStyleId = button.style ? addStyle(button.style) : ''; - // Add image reference if button has an image - // Grid3 uses coordinate-based naming: {x}-{y}-0-text-0.{ext} - if (button.image) { - // Try to determine file extension from image name or default to PNG - let imageExt = "png"; - const imageMatch = button.image.match( - /\.(png|jpg|jpeg|gif|svg)$/i, - ); - if (imageMatch) { - imageExt = imageMatch[1].toLowerCase(); - } + // Find button position in grid layout + const position = this.findButtonPosition(page, button, btnIndex); - // Extract image data from button parameters if available - // (AstericsGridProcessor stores it there during loadIntoTree) - // Also handle data URLs from OBZ conversion - let imageData = Buffer.alloc(0); - let hasImageData = false; - - if ( - button.parameters && - button.parameters.imageData && - Buffer.isBuffer(button.parameters.imageData) - ) { - imageData = button.parameters.imageData as any; - hasImageData = imageData.length > 0; - } else if ( - button.image && - typeof button.image === "string" && - button.image.startsWith("data:image") - ) { - // Convert data URL to Buffer (for OBZ → Grid3 conversion) - try { - const matches = button.image.match( - /^data:image\/(\w+);base64,(.+)$/, - ); - if (matches) { - const extension = matches[1]; // e.g., 'png', 'jpeg', 'gif' - const base64Data = matches[2]; - imageData = Buffer.from(base64Data, "base64"); - imageExt = extension; // Override the detected extension - hasImageData = imageData.length > 0; - } - } catch (err) { - console.warn( - `[Grid3] Failed to convert data URL to Buffer for button ${button.id}:`, - err, - ); - } - } + // Use position directly from tree + const yOffset = 0; - // Only add image reference if we have actual image data - if (hasImageData) { - // Grid3 dynamically constructs image filenames by prepending cell coordinates - // The XML should only contain the suffix: -0-text-0.{ext} - // Grid3 automatically adds the X-Y prefix based on the Cell's position - captionAndImage.Image = `-0-text-0.${imageExt}`; - - // Store image data for later writing to ZIP - buttonImages.set(button.id, { - imageData: imageData, - ext: imageExt, - pageName: page.name || page.id, - x: position.x, - y: position.y + yOffset, - }); + // Build CaptionAndImage object + const captionAndImage: Record = { + Caption: button.label || '', + }; + + // Add image reference if button has an image + // Grid3 uses coordinate-based naming: {x}-{y}-0-text-0.{ext} + if (button.image) { + // Try to determine file extension from image name or default to PNG + let imageExt = 'png'; + const imageMatch = button.image.match(/\.(png|jpg|jpeg|gif|svg)$/i); + if (imageMatch) { + imageExt = imageMatch[1].toLowerCase(); + } + + // Extract image data from button parameters if available + // (AstericsGridProcessor stores it there during loadIntoTree) + // Also handle data URLs from OBZ conversion + let imageData = Buffer.alloc(0); + let hasImageData = false; + + if ( + button.parameters && + button.parameters.imageData && + Buffer.isBuffer(button.parameters.imageData) + ) { + imageData = button.parameters.imageData as any; + hasImageData = imageData.length > 0; + } else if ( + button.image && + typeof button.image === 'string' && + button.image.startsWith('data:image') + ) { + // Convert data URL to Buffer (for OBZ → Grid3 conversion) + try { + const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/); + if (matches) { + const extension = matches[1]; // e.g., 'png', 'jpeg', 'gif' + const base64Data = matches[2]; + imageData = Buffer.from(base64Data, 'base64'); + imageExt = extension; // Override the detected extension + hasImageData = imageData.length > 0; + } + } catch (err) { + console.warn( + `[Grid3] Failed to convert data URL to Buffer for button ${button.id}:`, + err + ); } } - const cellData: Record = { - "@_X": position.x + 1, // Grid3 uses 1-based X coordinates - "@_Y": position.y + yOffset + 1, // Grid3 uses 1-based Y coordinates with workspace offset - "@_ColumnSpan": position.columnSpan, - "@_RowSpan": position.rowSpan, - Content: { - ContentType: - button.contentType === "Normal" - ? undefined - : button.contentType, - ContentSubType: button.contentSubType, - Commands: this.generateCommandsFromSemanticAction( - button, - tree, - ), - CaptionAndImage: captionAndImage, - }, - }; + // Only add image reference if we have actual image data + if (hasImageData) { + // Grid3 dynamically constructs image filenames by prepending cell coordinates + // The XML should only contain the suffix: -0-text-0.{ext} + // Grid3 automatically adds the X-Y prefix based on the Cell's position + captionAndImage.Image = `-0-text-0.${imageExt}`; + + // Store image data for later writing to ZIP + buttonImages.set(button.id, { + imageData: imageData, + ext: imageExt, + pageName: page.name || page.id, + x: position.x, + y: position.y + yOffset, + }); + } + } - // Add style reference and inline color overrides if available - // Some Grid3 versions need inline colors in addition to style references - if (buttonStyleId || button.style) { - const styleObj: any = {}; + const cellData: Record = { + '@_X': position.x + 1, // Grid3 uses 1-based X coordinates + '@_Y': position.y + yOffset + 1, // Grid3 uses 1-based Y coordinates with workspace offset + '@_ColumnSpan': position.columnSpan, + '@_RowSpan': position.rowSpan, + Content: { + ContentType: + button.contentType === 'Normal' ? undefined : button.contentType, + ContentSubType: button.contentSubType, + Commands: this.generateCommandsFromSemanticAction(button, tree), + CaptionAndImage: captionAndImage, + }, + }; - // Add style reference if we have one - if (buttonStyleId) { - styleObj.BasedOnStyle = buttonStyleId; - } + // Add style reference and inline color overrides if available + // Some Grid3 versions need inline colors in addition to style references + if (buttonStyleId || button.style) { + const styleObj: any = {}; - // Add inline color overrides for better Grid3 compatibility - if (button.style?.backgroundColor) { - // Use BackColour for fill (no TileColour means no surround, just the fill) - styleObj.BackColour = this.ensureAlphaChannel( - button.style.backgroundColor, - ); - } - if (button.style?.borderColor) { - styleObj.BorderColour = this.ensureAlphaChannel( - button.style.borderColor, - ); - } - // Always add font color inline - either from button style or calculated from background - const fontColor = - button.style?.fontColor || - this.getContrastFontColor( - button.style?.backgroundColor, - ); - styleObj.FontColour = - this.ensureAlphaChannel(fontColor); - if (button.style?.fontFamily) { - styleObj.FontName = button.style.fontFamily; - } - if (button.style?.fontSize) { - styleObj.FontSize = button.style.fontSize; - } + // Add style reference if we have one + if (buttonStyleId) { + styleObj.BasedOnStyle = buttonStyleId; + } - (cellData as any).Content.Style = styleObj; + // Add inline color overrides for better Grid3 compatibility + if (button.style?.backgroundColor) { + // Use BackColour for fill (no TileColour means no surround, just the fill) + styleObj.BackColour = this.ensureAlphaChannel( + button.style.backgroundColor + ); + } + if (button.style?.borderColor) { + styleObj.BorderColour = this.ensureAlphaChannel(button.style.borderColor); + } + // Always add font color inline - either from button style or calculated from background + const fontColor = + button.style?.fontColor || + this.getContrastFontColor(button.style?.backgroundColor); + styleObj.FontColour = this.ensureAlphaChannel(fontColor); + if (button.style?.fontFamily) { + styleObj.FontName = button.style.fontFamily; + } + if (button.style?.fontSize) { + styleObj.FontSize = button.style.fontSize; } - return cellData; - }, - ), + (cellData as any).Content.Style = styleObj; + } + + return cellData; + }), ], } : { Cell: [] }, @@ -2572,9 +2406,9 @@ class GridsetProcessor extends BaseProcessor { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', suppressEmptyNode: true, - cdataPropName: "__cdata", + cdataPropName: '__cdata', }); const xmlContent = builder.build(gridData); @@ -2601,30 +2435,26 @@ class GridsetProcessor extends BaseProcessor { // Create FileMap.xml to map all grid files with their dynamic image files const fileMapData = { - "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, + '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, FileMap: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', Entries: { Entry: gridFilePaths.map((gridPath) => { // Find all image files for this grid - const gridName = - gridPath.match(/Grids\/([^/]+)\/grid\.xml$/)?.[1] || ""; + const gridName = gridPath.match(/Grids\/([^/]+)\/grid\.xml$/)?.[1] || ''; const imageFiles: string[] = []; // Collect image filenames for buttons on this page // IMPORTANT: FileMap.xml requires full paths like "Grids/PageName/1-5-0-text-0.png" buttonImages.forEach((imgData) => { - if ( - imgData.pageName === gridName && - imgData.imageData.length > 0 - ) { + if (imgData.pageName === gridName && imgData.imageData.length > 0) { const imagePath = `Grids/${gridName}/${imgData.x}-${imgData.y}-0-text-0.${imgData.ext}`; imageFiles.push(imagePath); } }); return { - "@_StaticFile": gridPath, + '@_StaticFile': gridPath, DynamicFiles: imageFiles.length > 0 ? { @@ -2640,11 +2470,11 @@ class GridsetProcessor extends BaseProcessor { const fileMapBuilder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', }); const fileMapXmlContent = fileMapBuilder.build(fileMapData); files.push({ - name: "FileMap.xml", + name: 'FileMap.xml', data: fileMapXmlContent, }); @@ -2663,7 +2493,7 @@ class GridsetProcessor extends BaseProcessor { // Helper method to calculate row definitions based on page layout private calculateRowDefinitions( page: AACPage, - addWorkspaceOffset = false, + addWorkspaceOffset = false ): { RowDefinition: any[] } { return calcRowDefs(page, addWorkspaceOffset); } @@ -2677,11 +2507,7 @@ class GridsetProcessor extends BaseProcessor { * @param tree - Modified AACTree with pages to save * @param outputPath - Path where the modified gridset should be saved */ - async saveModifiedTree( - originalPath: string, - tree: AACTree, - outputPath: string, - ): Promise { + async saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise { const { readBinaryFromInput, writeBinaryToPath } = this.options.fileAdapter; if (Object.keys(tree.pages).length === 0) { @@ -2691,7 +2517,7 @@ class GridsetProcessor extends BaseProcessor { return; } - const AdmZip = (await import("adm-zip")).default; + const AdmZip = (await import('adm-zip')).default; const originalZip = new AdmZip(originalPath); const outputZip = new AdmZip(); @@ -2710,12 +2536,12 @@ class GridsetProcessor extends BaseProcessor { // Create XML parser and builder const parser = new XMLParser({ ignoreAttributes: false, - attributeNamePrefix: "@_", + attributeNamePrefix: '@_', }); const gridBuilder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', suppressEmptyNode: true, // Preserve Grid 3 XML formatting requirements suppressBooleanAttributes: false, @@ -2736,7 +2562,7 @@ class GridsetProcessor extends BaseProcessor { } // Parse the original grid XML - const originalContent = originalEntry.getData().toString("utf-8"); + const originalContent = originalEntry.getData().toString('utf-8'); const originalGrid = parser.parse(originalContent); if (!originalGrid.Grid) { @@ -2757,35 +2583,27 @@ class GridsetProcessor extends BaseProcessor { // Update cells in the original grid const originalCells = originalGrid.Grid.Cells?.Cell; if (originalCells) { - const cellArray = Array.isArray(originalCells) - ? originalCells - : [originalCells]; + const cellArray = Array.isArray(originalCells) ? originalCells : [originalCells]; for (const cell of cellArray) { if (!cell.Content) continue; // Get cell position - const x = parseInt( - String(cell["@_X"] || cell["@_Column"] || "0"), - 10, - ); - const y = parseInt(String(cell["@_Y"] || cell["@_Row"] || "0"), 10); + const x = parseInt(String(cell['@_X'] || cell['@_Column'] || '0'), 10); + const y = parseInt(String(cell['@_Y'] || cell['@_Row'] || '0'), 10); const key = `${x},${y}`; // Check if there's a modified button for this position const modifiedButton = buttonsByPosition.get(key); if (modifiedButton) { // Check if this is an AutoContent/WordList cell - const contentType = - cell.Content.ContentType || cell.Content.contentType; - const contentSubType = - cell.Content.ContentSubType || cell.Content.contentsubtype; + const contentType = cell.Content.ContentType || cell.Content.contentType; + const contentSubType = cell.Content.ContentSubType || cell.Content.contentsubtype; - const isWordListCell = - contentType === "AutoContent" && contentSubType === "WordList"; + const isWordListCell = contentType === 'AutoContent' && contentSubType === 'WordList'; const isPredictionCell = - contentType === "AutoContent" && contentSubType === "Prediction"; + contentType === 'AutoContent' && contentSubType === 'Prediction'; if (isWordListCell) { // For WordList cells, we need to add the word to the page's WordList @@ -2805,27 +2623,23 @@ class GridsetProcessor extends BaseProcessor { // For regular cells, update the caption directly // CDATA wrapping for empty captions will be done in post-processing if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) { - const captionAndImage = - cell.Content.CaptionAndImage || cell.Content.captionAndImage; + const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage; // Check if the label is a placeholder (generated during extraction) const isPlaceholderLabel = !modifiedButton.label || - modifiedButton.label.startsWith("Cell_") || - modifiedButton.label.startsWith("AutoContent_") || - modifiedButton.label.startsWith("Prediction "); + modifiedButton.label.startsWith('Cell_') || + modifiedButton.label.startsWith('AutoContent_') || + modifiedButton.label.startsWith('Prediction '); if (!isPlaceholderLabel) { // Only update caption with real content, not placeholders captionAndImage.Caption = modifiedButton.label; // Remove xsi:nil attribute when adding content - if ( - captionAndImage["@_xsi:nil"] || - captionAndImage["xsi:nil"] - ) { - delete captionAndImage["@_xsi:nil"]; - delete captionAndImage["xsi:nil"]; + if (captionAndImage['@_xsi:nil'] || captionAndImage['xsi:nil']) { + delete captionAndImage['@_xsi:nil']; + delete captionAndImage['xsi:nil']; } } } @@ -2834,9 +2648,9 @@ class GridsetProcessor extends BaseProcessor { // But skip placeholder labels const isPlaceholderMessage = !modifiedButton.message || - modifiedButton.message.startsWith("Cell_") || - modifiedButton.message.startsWith("AutoContent_") || - modifiedButton.message.startsWith("Prediction "); + modifiedButton.message.startsWith('Cell_') || + modifiedButton.message.startsWith('AutoContent_') || + modifiedButton.message.startsWith('Prediction '); if ( !isPlaceholderMessage && @@ -2845,16 +2659,13 @@ class GridsetProcessor extends BaseProcessor { ) { // For simple text content if (!cell.Content.Commands) { - cell.Content["#text"] = modifiedButton.message; + cell.Content['#text'] = modifiedButton.message; } } // Update image if present if (modifiedButton.image) { - if ( - cell.Content.CaptionAndImage || - cell.Content.captionAndImage - ) { + if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) { const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage; captionAndImage.Image = modifiedButton.image; @@ -2883,14 +2694,13 @@ class GridsetProcessor extends BaseProcessor { : []; const cell = cellArray.find((c: any) => { - const cellY = parseInt(String(c["@_Y"] || c["@_Row"] || "0"), 10); + const cellY = parseInt(String(c['@_Y'] || c['@_Row'] || '0'), 10); // Check Y position first if (cellY !== pos.y) { return false; } - const cellX = - c["@_X"] !== undefined ? parseInt(String(c["@_X"]), 10) : undefined; + const cellX = c['@_X'] !== undefined ? parseInt(String(c['@_X']), 10) : undefined; // If cell has no X attribute (full-width cell), it matches any button at this Y if (cellX === undefined) { @@ -2902,13 +2712,10 @@ class GridsetProcessor extends BaseProcessor { }); if (cell) { - const contentType = - cell.Content?.ContentType || cell.Content?.contentType; - const contentSubType = - cell.Content?.ContentSubType || cell.Content?.contentsubtype; + const contentType = cell.Content?.ContentType || cell.Content?.contentType; + const contentSubType = cell.Content?.ContentSubType || cell.Content?.contentsubtype; - const isWordListCell = - contentType === "AutoContent" && contentSubType === "WordList"; + const isWordListCell = contentType === 'AutoContent' && contentSubType === 'WordList'; // Note: Prediction cells are already skipped earlier, so they won't reach here @@ -2924,8 +2731,8 @@ class GridsetProcessor extends BaseProcessor { }, }, }, - Image: "", // No image for user-added words - PartOfSpeech: "Unknown", + Image: '', // No image for user-added words + PartOfSpeech: 'Unknown', }); } } @@ -2936,12 +2743,8 @@ class GridsetProcessor extends BaseProcessor { const existingWordList = originalGrid.Grid.WordList; if (existingWordList && existingWordList.Items) { const existingItems = - existingWordList.Items.WordListItem || - existingWordList.Items.wordlistitem || - []; - const itemsArray = Array.isArray(existingItems) - ? existingItems - : [existingItems]; + existingWordList.Items.WordListItem || existingWordList.Items.wordlistitem || []; + const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems]; // Merge existing and new items const allItems = [...itemsArray, ...newWordListItems]; @@ -2971,7 +2774,7 @@ class GridsetProcessor extends BaseProcessor { if (modifiedGridFiles.has(entry.entryName)) { const newContent = newGridFiles.get(entry.entryName); if (newContent) { - outputZip.addFile(entry.entryName, Buffer.from(newContent, "utf8")); + outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8')); } continue; } @@ -2991,47 +2794,40 @@ class GridsetProcessor extends BaseProcessor { private createBasicGridXml(page: AACPage): string { const gridData = { Grid: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', GridGuid: page.id, ColumnDefinitions: this.calculateColumnDefinitions(page), RowDefinitions: this.calculateRowDefinitions(page, false), - AutoContentCommands: "", + AutoContentCommands: '', Cells: page.buttons.length > 0 ? { - Cell: this.filterPageButtons(page.buttons).map( - (button, btnIndex) => { - const position = this.findButtonPosition( - page, - button, - btnIndex, - ); - - const cell: Record = { - "@_X": position.x, - "@_Y": position.y, - Content: { - CaptionAndImage: { - Caption: button.label || "", - }, + Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => { + const position = this.findButtonPosition(page, button, btnIndex); + + const cell: Record = { + '@_X': position.x, + '@_Y': position.y, + Content: { + CaptionAndImage: { + Caption: button.label || '', }, - }; + }, + }; - if (button.image) { - (cell.Content as any).CaptionAndImage.Image = - button.image; - } + if (button.image) { + (cell.Content as any).CaptionAndImage.Image = button.image; + } - if (position.columnSpan > 1) { - cell["@_ColumnSpan"] = position.columnSpan; - } - if (position.rowSpan > 1) { - cell["@_RowSpan"] = position.rowSpan; - } + if (position.columnSpan > 1) { + cell['@_ColumnSpan'] = position.columnSpan; + } + if (position.rowSpan > 1) { + cell['@_RowSpan'] = position.rowSpan; + } - return cell; - }, - ), + return cell; + }), } : undefined, }, @@ -3040,7 +2836,7 @@ class GridsetProcessor extends BaseProcessor { const gridBuilder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', suppressEmptyNode: true, // Preserve Grid 3 XML formatting requirements suppressBooleanAttributes: false, @@ -3056,7 +2852,7 @@ class GridsetProcessor extends BaseProcessor { private findButtonPosition( page: AACPage, button: AACButton, - fallbackIndex: number, + fallbackIndex: number ): { x: number; y: number; @@ -3081,13 +2877,9 @@ class GridsetProcessor extends BaseProcessor { generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { - return this.generateTranslatedDownloadGeneric( - filePath, - translatedStrings, - sourceStrings, - ); + return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); } /** diff --git a/src/processors/index.ts b/src/processors/index.ts index be22444..a3f04aa 100644 --- a/src/processors/index.ts +++ b/src/processors/index.ts @@ -7,13 +7,13 @@ * - import { Snap } from 'aac-processors/snap'; */ -export { ApplePanelsProcessor } from "./applePanelsProcessor"; -export { DotProcessor } from "./dotProcessor"; -export { ExcelProcessor } from "./excelProcessor"; -export { GridsetProcessor } from "./gridsetProcessor"; -export { ObfProcessor } from "./obfProcessor"; -export { OpmlProcessor } from "./opmlProcessor"; -export { SnapProcessor } from "./snapProcessor"; -export { TouchChatProcessor } from "./touchchatProcessor"; -export { AstericsGridProcessor } from "./astericsGridProcessor"; -export { ObfsetProcessor } from "./obfsetProcessor"; +export { ApplePanelsProcessor } from './applePanelsProcessor'; +export { DotProcessor } from './dotProcessor'; +export { ExcelProcessor } from './excelProcessor'; +export { GridsetProcessor } from './gridsetProcessor'; +export { ObfProcessor } from './obfProcessor'; +export { OpmlProcessor } from './opmlProcessor'; +export { SnapProcessor } from './snapProcessor'; +export { TouchChatProcessor } from './touchchatProcessor'; +export { AstericsGridProcessor } from './astericsGridProcessor'; +export { ObfsetProcessor } from './obfsetProcessor'; diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index 9601014..f191796 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -4,7 +4,7 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from "../core/baseProcessor"; +} from '../core/baseProcessor'; import { AACTree, AACPage, @@ -13,19 +13,19 @@ import { AACSemanticCategory, AACSemanticIntent, AACTreeMetadata, -} from "../core/treeStructure"; -import { generateCloneId } from "../utilities/analytics/utils/idGenerator"; -import { ValidationResult } from "../validation/validationTypes"; +} from '../core/treeStructure'; +import { generateCloneId } from '../utilities/analytics/utils/idGenerator'; +import { ValidationResult } from '../validation/validationTypes'; import { extractAllButtonsForTranslation, validateTranslationResults, type ButtonForTranslation, type LLMLTranslationResult, -} from "../utilities/translation/translationProcessor"; -import { ProcessorInput, encodeBase64, decodeText } from "../utils/io"; -import { ZipAdapter } from "../utils/zip"; +} from '../utilities/translation/translationProcessor'; +import { ProcessorInput, encodeBase64, decodeText } from '../utils/io'; +import { ZipAdapter } from '../utils/zip'; -const OBF_FORMAT_VERSION = "open-board-0.1"; +const OBF_FORMAT_VERSION = 'open-board-0.1'; interface ObfButton { id: string; @@ -57,13 +57,11 @@ interface ObfManifest { * OBF: true = hidden, false/undefined = visible * Maps to: 'Hidden' | 'Visible' | undefined */ -function mapObfVisibility( - hidden: boolean | undefined, -): "Hidden" | "Visible" | undefined { +function mapObfVisibility(hidden: boolean | undefined): 'Hidden' | 'Visible' | undefined { if (hidden === undefined) { return undefined; // Default to visible } - return hidden ? "Hidden" : "Visible"; + return hidden ? 'Hidden' : 'Visible'; } interface ObfGrid { @@ -114,10 +112,7 @@ class ObfProcessor extends BaseProcessor { /** * Extract an image from the ZIP file as a Buffer */ - private async extractImageAsBuffer( - imageId: string, - images: any[], - ): Promise { + private async extractImageAsBuffer(imageId: string, images: any[]): Promise { if (!this.zipFile || !images) { return null; } @@ -139,7 +134,7 @@ class ObfProcessor extends BaseProcessor { try { const buffer = await this.zipFile.readFile(imagePath as string); if (buffer) { - if (typeof Buffer !== "undefined") { + if (typeof Buffer !== 'undefined') { return Buffer.from(buffer); } return null; @@ -155,10 +150,7 @@ class ObfProcessor extends BaseProcessor { /** * Extract an image from the ZIP file and convert to data URL */ - private async extractImageAsDataUrl( - imageId: string, - images: ObfImage[], - ): Promise { + private async extractImageAsDataUrl(imageId: string, images: ObfImage[]): Promise { // Check cache first if (this.imageCache.has(imageId)) { return this.imageCache.get(imageId) ?? null; @@ -217,41 +209,36 @@ class ObfProcessor extends BaseProcessor { } private getMimeTypeFromFilename(filename: string): string { - const ext = filename.toLowerCase().split(".").pop(); + const ext = filename.toLowerCase().split('.').pop(); switch (ext) { - case "png": - return "image/png"; - case "jpg": - case "jpeg": - return "image/jpeg"; - case "gif": - return "image/gif"; - case "svg": - return "image/svg+xml"; - case "webp": - return "image/webp"; + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'gif': + return 'image/gif'; + case 'svg': + return 'image/svg+xml'; + case 'webp': + return 'image/webp'; default: - return "image/png"; + return 'image/png'; } } private getPageFilename(id: string, metadata: any): string { if (metadata._obfPagePaths && id in metadata._obfPagePaths) return metadata._obfPagePaths[id] as string; - if (id.endsWith(".obf")) return id; + if (id.endsWith('.obf')) return id; return `${id}.obf`; } - private async processBoard( - boardData: ObfBoard, - _boardPath: string, - ): Promise { + private async processBoard(boardData: ObfBoard, _boardPath: string): Promise { const sourceButtons = boardData.buttons || []; // Calculate page ID first (used to make button IDs unique) - const pageId = boardData?.id - ? String(boardData.id) - : _boardPath?.split(/[/\\]/).pop() || ""; + const pageId = boardData?.id ? String(boardData.id) : _boardPath?.split(/[/\\]/).pop() || ''; const images = boardData.images; @@ -263,17 +250,17 @@ class ObfProcessor extends BaseProcessor { intent: AACSemanticIntent.NAVIGATE_TO, targetId: btn.load_board.path, fallback: { - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId: btn.load_board.path, }, } : { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: String(btn?.vocalization || btn?.label || ""), + text: String(btn?.vocalization || btn?.label || ''), fallback: { - type: "SPEAK", - message: String(btn?.vocalization || btn?.label || ""), + type: 'SPEAK', + message: String(btn?.vocalization || btn?.label || ''), }, }; @@ -281,18 +268,12 @@ class ObfProcessor extends BaseProcessor { let resolvedImage: string | undefined; let imageBuffer: Buffer | undefined; if (btn.image_id && images) { - resolvedImage = - (await this.extractImageAsDataUrl(btn.image_id, images)) || - undefined; - imageBuffer = - (await this.extractImageAsBuffer(btn.image_id, images)) || - undefined; + resolvedImage = (await this.extractImageAsDataUrl(btn.image_id, images)) || undefined; + imageBuffer = (await this.extractImageAsBuffer(btn.image_id, images)) || undefined; // save image data if (images) { - const imageIndex = images?.findIndex( - (img: any) => img.id === btn.image_id, - ); + const imageIndex = images?.findIndex((img: any) => img.id === btn.image_id); if (imageIndex !== -1) { images[imageIndex].data = resolvedImage; } @@ -315,8 +296,8 @@ class ObfProcessor extends BaseProcessor { return new AACButton({ id: String(btn.id), - label: String(btn?.label || ""), - message: String(btn?.vocalization || btn?.label || ""), + label: String(btn?.label || ''), + message: String(btn?.vocalization || btn?.label || ''), visibility: mapObfVisibility(btn.hidden), style: { backgroundColor: btn.background_color, @@ -324,22 +305,19 @@ class ObfProcessor extends BaseProcessor { }, image: resolvedImage, // Set the resolved image data URL resolvedImageEntry: resolvedImage, - parameters: - Object.keys(buttonParameters).length > 0 - ? buttonParameters - : undefined, + parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined, semanticAction, targetPageId: btn.load_board?.path, semantic_id: btn.semantic_id, // Extract semantic_id if present }); - }), + }) ); const buttonMap = new Map(buttons.map((btn) => [btn.id, btn])); const page = new AACPage({ id: pageId, // Use the page ID we calculated earlier - name: String(boardData?.name || ""), + name: String(boardData?.name || ''), grid: [], buttons, parentId: null, @@ -352,30 +330,25 @@ class ObfProcessor extends BaseProcessor { // Process grid layout if available if (boardData.grid) { const rows = - typeof boardData.grid.rows === "number" + typeof boardData.grid.rows === 'number' ? boardData.grid.rows : boardData.grid.order?.length || 0; const cols = - typeof boardData.grid.columns === "number" + typeof boardData.grid.columns === 'number' ? boardData.grid.columns : boardData.grid.order ? boardData.grid.order.reduce( - (max, row) => - Math.max(max, Array.isArray(row) ? row.length : 0), - 0, + (max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), + 0 ) : 0; if (rows > 0 && cols > 0) { - const grid: Array> = Array.from( - { length: rows }, - () => Array.from({ length: cols }, () => null), + const grid: Array> = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => null) ); - if ( - Array.isArray(boardData.grid.order) && - boardData.grid.order.length - ) { + if (Array.isArray(boardData.grid.order) && boardData.grid.order.length) { boardData.grid.order.forEach((orderRow, rowIndex) => { if (!Array.isArray(orderRow)) return; orderRow.forEach((cellId, colIndex) => { @@ -389,7 +362,7 @@ class ObfProcessor extends BaseProcessor { }); } else { for (const btn of sourceButtons) { - if (typeof btn.box_id === "number") { + if (typeof btn.box_id === 'number') { const row = Math.floor(btn.box_id / cols); const col = btn.box_id % cols; if (row < rows && col < cols) { @@ -412,13 +385,7 @@ class ObfProcessor extends BaseProcessor { row.forEach((btn, colIndex) => { if (btn) { // Generate clone_id based on position and label - btn.clone_id = generateCloneId( - rows, - cols, - rowIndex, - colIndex, - btn.label, - ); + btn.clone_id = generateCloneId(rows, cols, rowIndex, colIndex, btn.label); cloneIds.push(btn.clone_id); // Track semantic_id if present @@ -450,9 +417,8 @@ class ObfProcessor extends BaseProcessor { const page = tree.pages[pageId]; if (page.name) texts.push(page.name); page.buttons.forEach((btn) => { - if (typeof btn.label === "string") texts.push(btn.label); - if (typeof btn.message === "string" && btn.message !== btn.label) - texts.push(btn.message); + if (typeof btn.label === 'string') texts.push(btn.label); + if (typeof btn.message === 'string' && btn.message !== btn.label) texts.push(btn.message); }); } @@ -460,36 +426,27 @@ class ObfProcessor extends BaseProcessor { } async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { - const { - readBinaryFromInput, - readTextFromInput, - listDir, - join, - isDirectory, - } = this.options.fileAdapter; + const { readBinaryFromInput, readTextFromInput, listDir, join, isDirectory } = + this.options.fileAdapter; // Detailed logging for debugging input const bufferLength = - typeof filePathOrBuffer === "string" + typeof filePathOrBuffer === 'string' ? null : (await readBinaryFromInput(filePathOrBuffer)).byteLength; - console.log("[OBF] loadIntoTree called with:", { + console.log('[OBF] loadIntoTree called with:', { type: typeof filePathOrBuffer, - isBuffer: - typeof Buffer !== "undefined" && Buffer.isBuffer(filePathOrBuffer), + isBuffer: typeof Buffer !== 'undefined' && Buffer.isBuffer(filePathOrBuffer), value: - typeof filePathOrBuffer === "string" + typeof filePathOrBuffer === 'string' ? filePathOrBuffer : `[Buffer of length ${bufferLength ?? 0}]`, }); const tree = new AACTree(); // Helper: try to parse JSON OBF - async function tryParseObfJson( - data: ProcessorInput, - ): Promise { + async function tryParseObfJson(data: ProcessorInput): Promise { try { - const str = - typeof data === "string" ? data : await readTextFromInput(data); + const str = typeof data === 'string' ? data : await readTextFromInput(data); // Check for empty or whitespace-only content if (!str.trim()) { @@ -497,10 +454,10 @@ class ObfProcessor extends BaseProcessor { } const obj = JSON.parse(str); - if (obj && typeof obj === "object" && "id" in obj && "buttons" in obj) { + if (obj && typeof obj === 'object' && 'id' in obj && 'buttons' in obj) { // Validate buttons is an array if (!Array.isArray(obj.buttons)) { - throw new Error("Invalid OBF: buttons must be an array"); + throw new Error('Invalid OBF: buttons must be an array'); } return obj as ObfBoard; } @@ -511,20 +468,17 @@ class ObfProcessor extends BaseProcessor { } // If input is a string path and ends with .obf, treat as JSON - if ( - typeof filePathOrBuffer === "string" && - filePathOrBuffer.toLowerCase().endsWith(".obf") - ) { + if (typeof filePathOrBuffer === 'string' && filePathOrBuffer.toLowerCase().endsWith('.obf')) { try { const content = await readTextFromInput(filePathOrBuffer); const boardData = await tryParseObfJson(content); if (boardData) { - console.log("[OBF] Detected .obf file, parsed as JSON"); + console.log('[OBF] Detected .obf file, parsed as JSON'); const page = await this.processBoard(boardData, filePathOrBuffer); tree.addPage(page); // Set metadata from root board - tree.metadata.format = "obf"; + tree.metadata.format = 'obf'; tree.metadata.name = boardData.name; tree.metadata.description = boardData.description_html; tree.metadata.locale = boardData.locale; @@ -535,40 +489,38 @@ class ObfProcessor extends BaseProcessor { return tree; } else { - throw new Error("Invalid OBF JSON content"); + throw new Error('Invalid OBF JSON content'); } } catch (err) { - console.error("[OBF] Error reading .obf file:", err); + console.error('[OBF] Error reading .obf file:', err); throw err; } } // Determine if input is ZIP, directory, or OBF JSON string/buffer - let fileType: "obf" | "zip" | "dir" = "obf"; - if (typeof filePathOrBuffer !== "string") { + let fileType: 'obf' | 'zip' | 'dir' = 'obf'; + if (typeof filePathOrBuffer !== 'string') { const bytes = await readBinaryFromInput(filePathOrBuffer); - if (bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b) - fileType = "zip"; + if (bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b) fileType = 'zip'; } else { if (await isDirectory(filePathOrBuffer)) { - fileType = "dir"; + fileType = 'dir'; } else { const lowered = filePathOrBuffer.toLowerCase(); - if (lowered.endsWith(".zip") || lowered.endsWith(".obz")) - fileType = "zip"; + if (lowered.endsWith('.zip') || lowered.endsWith('.obz')) fileType = 'zip'; } } // Check if input is a buffer or string that parses as OBF JSON; throw if neither JSON nor ZIP - if (fileType === "obf") { + if (fileType === 'obf') { const asJson = await tryParseObfJson(filePathOrBuffer); - if (!asJson) throw new Error("Invalid OBF content: not JSON and not ZIP"); - console.log("[OBF] Detected buffer/string as OBF JSON"); - const page = await this.processBoard(asJson, "[bufferOrString]"); + if (!asJson) throw new Error('Invalid OBF content: not JSON and not ZIP'); + console.log('[OBF] Detected buffer/string as OBF JSON'); + const page = await this.processBoard(asJson, '[bufferOrString]'); tree.addPage(page); // Set metadata from root board - tree.metadata.format = "obf"; + tree.metadata.format = 'obf'; tree.metadata.name = asJson.name; tree.metadata.description = asJson.description_html; tree.metadata.locale = asJson.locale; @@ -584,22 +536,20 @@ class ObfProcessor extends BaseProcessor { this.zipFile = { readFile: async (name: string): Promise => { - return await readBinaryFromInput( - join(filePathOrBuffer as string, name), - ); + return await readBinaryFromInput(join(filePathOrBuffer as string, name)); }, listFiles: () => { - throw new Error("Not implemented for directory input"); + throw new Error('Not implemented for directory input'); }, writeFiles: () => { - throw new Error("Not implemented for directory input"); + throw new Error('Not implemented for directory input'); }, }; - if (fileType === "zip") { + if (fileType === 'zip') { try { this.zipFile = await this.options.zipAdapter(filePathOrBuffer); } catch (err) { - console.error("[OBF] Error loading ZIP:", err); + console.error('[OBF] Error loading ZIP:', err); throw err; } } @@ -607,32 +557,23 @@ class ObfProcessor extends BaseProcessor { // Store the ZIP file reference for image extraction this.imageCache.clear(); // Clear cache for new file - console.log( - "[OBF] Detected zip archive or directory, extracting .obf files", - ); + console.log('[OBF] Detected zip archive or directory, extracting .obf files'); // List manifest and OBF files const filesInZip = - fileType === "zip" - ? this.zipFile.listFiles() - : await listDir(filePathOrBuffer as string); - const manifestFile = filesInZip.filter( - (name) => name.toLowerCase() === "manifest.json", - ); - let obfEntries = filesInZip.filter((name) => - name.toLowerCase().endsWith(".obf"), - ); + fileType === 'zip' ? this.zipFile.listFiles() : await listDir(filePathOrBuffer as string); + const manifestFile = filesInZip.filter((name) => name.toLowerCase() === 'manifest.json'); + let obfEntries = filesInZip.filter((name) => name.toLowerCase().endsWith('.obf')); // Attempt to read manifest if (manifestFile && manifestFile.length === 1) { try { const content = await this.zipFile.readFile(manifestFile[0]); const data = decodeText(content); - const str = - typeof data === "string" ? data : await readTextFromInput(data); - if (!str.trim()) throw new Error("Manifest object missing"); + const str = typeof data === 'string' ? data : await readTextFromInput(data); + if (!str.trim()) throw new Error('Manifest object missing'); const manifestObject = JSON.parse(str) as ObfManifest; - if (!manifestObject) throw new Error("Manifest object is empty"); + if (!manifestObject) throw new Error('Manifest object is empty'); // Replace OBF file list if (manifestObject.paths && manifestObject.paths.boards) { @@ -641,13 +582,11 @@ class ObfProcessor extends BaseProcessor { // Move root board to top of list if (manifestObject.root) { - obfEntries = obfEntries.filter( - (item) => item !== manifestObject.root, - ); + obfEntries = obfEntries.filter((item) => item !== manifestObject.root); obfEntries.unshift(manifestObject.root); } } catch (err) { - console.warn("[OBF] Error processing mainfest", err); + console.warn('[OBF] Error processing mainfest', err); } } @@ -662,7 +601,7 @@ class ObfProcessor extends BaseProcessor { // Set metadata if not already set (use first board as reference) if (!tree.metadata.format) { - tree.metadata.format = "obf"; + tree.metadata.format = 'obf'; tree.metadata.name = boardData.name; tree.metadata.description = boardData.description_html; tree.metadata.locale = boardData.locale; @@ -675,10 +614,10 @@ class ObfProcessor extends BaseProcessor { tree.metadata._obfPagePaths[page.id] = entryName; } } else { - console.warn("[OBF] Skipped entry (not valid OBF JSON):", entryName); + console.warn('[OBF] Skipped entry (not valid OBF JSON):', entryName); } } catch (err) { - console.warn("[OBF] Error processing entry:", entryName, err); + console.warn('[OBF] Error processing entry:', entryName, err); } } @@ -695,10 +634,7 @@ class ObfProcessor extends BaseProcessor { const totalRows = Array.isArray(page.grid) ? page.grid.length : 0; const totalColumns = totalRows > 0 - ? page.grid.reduce( - (max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), - 0, - ) + ? page.grid.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0) : 0; if (totalRows === 0 || totalColumns === 0) { @@ -706,7 +642,7 @@ class ObfProcessor extends BaseProcessor { return { rows: 0, columns: 0, order: [], buttonPositions }; } const fallbackRow: string[] = page.buttons.map((button, index) => { - const id = String(button.id ?? ""); + const id = String(button.id ?? ''); buttonPositions.set(id, index); return id; }); @@ -726,7 +662,7 @@ class ObfProcessor extends BaseProcessor { for (let colIndex = 0; colIndex < totalColumns; colIndex++) { const cell = sourceRow[colIndex] || null; if (cell) { - const id = String(cell.id ?? ""); + const id = String(cell.id ?? ''); orderRow.push(id); buttonPositions.set(id, rowIndex * totalColumns + colIndex); } else { @@ -743,10 +679,9 @@ class ObfProcessor extends BaseProcessor { page: AACPage, fallbackName: string, metadata?: AACTreeMetadata, - embedData = false, + embedData = false ): ObfBoard { - const { rows, columns, order, buttonPositions } = - this.buildGridMetadata(page); + const { rows, columns, order, buttonPositions } = this.buildGridMetadata(page); const boardName = metadata?.name && page.id === metadata?.defaultHomePageId ? metadata.name @@ -763,7 +698,7 @@ class ObfProcessor extends BaseProcessor { format: OBF_FORMAT_VERSION, id: page.id, url: metadata?.url, - locale: metadata?.locale || page.locale || "en", + locale: metadata?.locale || page.locale || 'en', name: boardName, description_html: metadata?.description && page.id === metadata?.defaultHomePageId @@ -790,17 +725,16 @@ class ObfProcessor extends BaseProcessor { label: button.label, vocalization: button.message || button.label, load_board: - button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && - button.targetPageId + button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && button.targetPageId ? { path: button.targetPageId, } : undefined, background_color: button.style?.backgroundColor, border_color: button.style?.borderColor, - box_id: buttonPositions.get(String(button.id ?? "")), + box_id: buttonPositions.get(String(button.id ?? '')), image_id: imageId, - hidden: button.visibility === "Hidden" || false, + hidden: button.visibility === 'Hidden' || false, }; }), images, @@ -811,7 +745,7 @@ class ObfProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string, + outputPath: string ): Promise { const { readBinaryFromInput } = this.options.fileAdapter; // Load the tree, apply translations, and save to new file @@ -849,37 +783,26 @@ class ObfProcessor extends BaseProcessor { return await readBinaryFromInput(outputPath); } - async saveFromTree( - tree: AACTree, - outputPath: string, - embedData = false, - ): Promise { + async saveFromTree(tree: AACTree, outputPath: string, embedData = false): Promise { const { writeTextToPath, writeBinaryToPath, pathExists, mkDir, join } = this.options.fileAdapter; - if (outputPath.endsWith(".obf")) { + if (outputPath.endsWith('.obf')) { // Save as single OBF JSON file - const rootPage = tree.rootId - ? tree.getPage(tree.rootId) - : Object.values(tree.pages)[0]; + const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0]; if (!rootPage) { - throw new Error("No pages to save"); + throw new Error('No pages to save'); } const obfBoard = this.createObfBoardFromPage( rootPage, - "Exported Board", + 'Exported Board', tree.metadata, - embedData, + embedData ); await writeTextToPath(outputPath, JSON.stringify(obfBoard, null, 2)); } else { const files = Object.values(tree.pages).map((page) => { - const obfBoard = this.createObfBoardFromPage( - page, - "Board", - tree.metadata, - embedData, - ); + const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata, embedData); const obfContent = JSON.stringify(obfBoard, null, 2); const name = this.getPageFilename(page.id, tree.metadata); return { @@ -895,28 +818,28 @@ class ObfProcessor extends BaseProcessor { Object.entries(tree.pages).map(([id, page]) => [ id, this.getPageFilename(page.id, tree.metadata), - ]), + ]) ), images: {}, //TODO Add support for saving images as files sounds: {}, //TODO Add support for saving sounds as files }, }; files.push({ - name: "manifest.json", + name: 'manifest.json', data: new TextEncoder().encode(JSON.stringify(manifest)), }); - if (outputPath.endsWith(".obz") || outputPath.endsWith(".zip")) { - console.log("[OBF] Saving to ZIP file:", outputPath); + if (outputPath.endsWith('.obz') || outputPath.endsWith('.zip')) { + console.log('[OBF] Saving to ZIP file:', outputPath); const fileExists = await pathExists(outputPath); this.zipFile = await this.options.zipAdapter( fileExists ? outputPath : undefined, - this.options.fileAdapter, + this.options.fileAdapter ); const zipData = await this.zipFile.writeFiles(files); await writeBinaryToPath(outputPath, zipData); } else { - console.log("[OBF] Saving to directory:", outputPath); + console.log('[OBF] Saving to directory:', outputPath); if (!(await pathExists(outputPath))) await mkDir(outputPath); for (const file of files) { const filePath = join(outputPath, file.name); @@ -934,15 +857,11 @@ class ObfProcessor extends BaseProcessor { * @param tree - Modified AACTree with pages to save * @param outputPath - Path where the modified file should be saved */ - async saveModifiedTree( - originalPath: string, - tree: AACTree, - outputPath: string, - ): Promise { + async saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise { const { writeBinaryToPath, readBinaryFromInput } = this.options.fileAdapter; // If output is .obf (single file), use regular save - if (outputPath.endsWith(".obf")) { + if (outputPath.endsWith('.obf')) { await this.saveFromTree(tree, outputPath); return; } @@ -954,7 +873,7 @@ class ObfProcessor extends BaseProcessor { return; } - const AdmZip = (await import("adm-zip")).default; + const AdmZip = (await import('adm-zip')).default; const originalZip = new AdmZip(originalPath); const outputZip = new AdmZip(); @@ -968,18 +887,14 @@ class ObfProcessor extends BaseProcessor { const obfFilename = this.getPageFilename(page.id, tree.metadata); modifiedObfFiles.add(obfFilename); - const obfBoard = this.createObfBoardFromPage( - page, - "Board", - tree.metadata, - ); + const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata); const obfContent = JSON.stringify(obfBoard, null, 2); newObfFiles.set(obfFilename, obfContent); } // Generate updated manifest if we have pages if (Object.keys(tree.pages).length > 0) { - modifiedObfFiles.add("manifest.json"); + modifiedObfFiles.add('manifest.json'); const manifest: ObfManifest = { format: OBF_FORMAT_VERSION, @@ -989,14 +904,14 @@ class ObfProcessor extends BaseProcessor { Object.entries(tree.pages).map(([id, page]) => [ id, this.getPageFilename(page.id, tree.metadata), - ]), + ]) ), images: {}, sounds: {}, }, }; - newObfFiles.set("manifest.json", JSON.stringify(manifest)); + newObfFiles.set('manifest.json', JSON.stringify(manifest)); } // Copy all files from original zip, replacing modified .obf files @@ -1007,7 +922,7 @@ class ObfProcessor extends BaseProcessor { if (modifiedObfFiles.has(entry.entryName)) { const newContent = newObfFiles.get(entry.entryName); if (newContent) { - outputZip.addFile(entry.entryName, Buffer.from(newContent, "utf8")); + outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8')); } continue; } @@ -1025,9 +940,7 @@ class ObfProcessor extends BaseProcessor { * Extract strings with metadata for aac-tools-platform compatibility * Uses the generic implementation from BaseProcessor */ - async extractStringsWithMetadata( - filePath: string, - ): Promise { + async extractStringsWithMetadata(filePath: string): Promise { return this.extractStringsWithMetadataGeneric(filePath); } @@ -1038,13 +951,9 @@ class ObfProcessor extends BaseProcessor { async generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { - return this.generateTranslatedDownloadGeneric( - filePath, - translatedStrings, - sourceStrings, - ); + return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); } /** @@ -1066,9 +975,7 @@ class ObfProcessor extends BaseProcessor { * @param filePathOrBuffer - Path to OBF/OBZ file or buffer * @returns Promise resolving to symbol information for LLM processing */ - async extractSymbolsForLLM( - filePathOrBuffer: ProcessorInput, - ): Promise { + async extractSymbolsForLLM(filePathOrBuffer: ProcessorInput): Promise { const tree = await this.loadIntoTree(filePathOrBuffer); // Collect all buttons from all pages @@ -1105,15 +1012,13 @@ class ObfProcessor extends BaseProcessor { filePathOrBuffer: ProcessorInput, llmTranslations: LLMLTranslationResult[], outputPath: string, - options?: { allowPartial?: boolean }, + options?: { allowPartial?: boolean } ): Promise { const { readBinaryFromInput } = this.options.fileAdapter; const tree = await this.loadIntoTree(filePathOrBuffer); // Validate translations using shared utility - const buttonIds = Object.values(tree.pages).flatMap((page) => - page.buttons.map((b) => b.id), - ); + const buttonIds = Object.values(tree.pages).flatMap((page) => page.buttons.map((b) => b.id)); validateTranslationResults(llmTranslations, buttonIds, options); // Create a map for quick lookup @@ -1158,14 +1063,12 @@ class ObfProcessor extends BaseProcessor { return await readBinaryFromInput(outputPath); } - private getObfValidator(): typeof import("../validation/obfValidator").ObfValidator { + private getObfValidator(): typeof import('../validation/obfValidator').ObfValidator { try { // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-return - return require("../validation/obfValidator").ObfValidator; + return require('../validation/obfValidator').ObfValidator; } catch (_error) { - throw new Error( - "Validation utilities are not available in this environment.", - ); + throw new Error('Validation utilities are not available in this environment.'); } } } diff --git a/src/processors/obfsetProcessor.ts b/src/processors/obfsetProcessor.ts index ee6aff4..92feb1c 100644 --- a/src/processors/obfsetProcessor.ts +++ b/src/processors/obfsetProcessor.ts @@ -3,16 +3,16 @@ * These are pre-extracted board sets in JSON array format */ -import { AACTree } from "../core/treeStructure"; +import { AACTree } from '../core/treeStructure'; import { AACPage, AACButton, AACSemanticAction, AACSemanticCategory, AACSemanticIntent, -} from "../core/treeStructure"; -import { BaseProcessor, ProcessorOptions } from "../core/baseProcessor"; -import { ProcessorInput } from "../utils/io"; +} from '../core/treeStructure'; +import { BaseProcessor, ProcessorOptions } from '../core/baseProcessor'; +import { ProcessorInput } from '../utils/io'; interface ObfsetButton { id: string; @@ -64,7 +64,7 @@ export class ObfsetProcessor extends BaseProcessor { async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { const { readTextFromInput } = this.options.fileAdapter; const tree = new AACTree(); - tree.metadata.format = "obfset"; + tree.metadata.format = 'obfset'; const content = await readTextFromInput(filePathOrBuffer); const boards: ObfsetBoard[] = JSON.parse(content); @@ -98,9 +98,7 @@ export class ObfsetProcessor extends BaseProcessor { const cols = boardData.grid?.columns || 6; // Initialize grid with nulls - page.grid = Array.from({ length: rows }, () => - Array.from({ length: cols }, () => null), - ); + page.grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => null)); // Create button map by ID const buttonMap = new Map(); @@ -129,14 +127,14 @@ export class ObfsetProcessor extends BaseProcessor { intent: AACSemanticIntent.NAVIGATE_TO, targetId: btnData.load_board.id, fallback: { - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId: btnData.load_board.id, add_to_sentence: btnData.load_board.add_to_sentence, temporary_home: btnData.load_board.temporary_home, }, platformData: { grid3: { - commandId: "GO_TO_BOARD", + commandId: 'GO_TO_BOARD', parameters: { add_to_sentence: btnData.load_board.add_to_sentence, temporary_home: btnData.load_board.temporary_home, @@ -149,15 +147,15 @@ export class ObfsetProcessor extends BaseProcessor { semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: btnData.label || "", - fallback: { type: "SPEAK", message: btnData.label || "" }, + text: btnData.label || '', + fallback: { type: 'SPEAK', message: btnData.label || '' }, }; } const button = new AACButton({ id: btnData.id, - label: btnData.label || "", - message: btnData.label || "", + label: btnData.label || '', + message: btnData.label || '', targetPageId: btnData.load_board?.id, semanticAction, semantic_id: btnData.semantic_id, @@ -210,10 +208,10 @@ export class ObfsetProcessor extends BaseProcessor { async processTexts( _filePathOrBuffer: ProcessorInput, _translations: Map, - _outputPath: string, + _outputPath: string ): Promise { await Promise.resolve(); - throw new Error("processTexts is not supported for .obfset currently"); + throw new Error('processTexts is not supported for .obfset currently'); } /** @@ -221,10 +219,10 @@ export class ObfsetProcessor extends BaseProcessor { */ async saveFromTree(_tree: AACTree, _outputPath: string): Promise { await Promise.resolve(); - throw new Error("saveFromTree is not supported for .obfset currently"); + throw new Error('saveFromTree is not supported for .obfset currently'); } supportsExtension(extension: string): boolean { - return extension === ".obfset"; + return extension === '.obfset'; } } diff --git a/src/processors/opmlProcessor.ts b/src/processors/opmlProcessor.ts index a4bd798..f66bf23 100644 --- a/src/processors/opmlProcessor.ts +++ b/src/processors/opmlProcessor.ts @@ -4,22 +4,17 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from "../core/baseProcessor"; -import { - AACTree, - AACPage, - AACButton, - AACSemanticIntent, -} from "../core/treeStructure"; -import { XMLParser, XMLValidator, XMLBuilder } from "fast-xml-parser"; +} from '../core/baseProcessor'; +import { AACTree, AACPage, AACButton, AACSemanticIntent } from '../core/treeStructure'; +import { XMLParser, XMLValidator, XMLBuilder } from 'fast-xml-parser'; import { ValidationFailureError, buildValidationResultFromMessage, -} from "../validation/validationTypes"; -import { ProcessorInput, getBasename, encodeText } from "../utils/io"; +} from '../validation/validationTypes'; +import { ProcessorInput, getBasename, encodeText } from '../utils/io'; interface OpmlOutline { - "@_text"?: string; + '@_text'?: string; text?: string; _attributes?: { text: string; @@ -42,21 +37,21 @@ class OpmlProcessor extends BaseProcessor { } private processOutline( outline: OpmlOutline, - parentId: string | null = null, + parentId: string | null = null ): { page: AACPage | null; childPages: AACPage[] } { - if (!outline || typeof outline !== "object") { + if (!outline || typeof outline !== 'object') { return { page: null, childPages: [] }; } const text = - outline["@_text"] || + outline['@_text'] || (outline._attributes && outline._attributes.text) || (outline as any).text; - if (!text || typeof text !== "string") { + if (!text || typeof text !== 'string') { // Skip invalid outlines return { page: null, childPages: [] }; } const page = new AACPage({ - id: text.replace(/[^a-zA-Z0-9]/g, "_"), + id: text.replace(/[^a-zA-Z0-9]/g, '_'), name: text, grid: [], buttons: [], @@ -66,27 +61,24 @@ class OpmlProcessor extends BaseProcessor { const childPages: AACPage[] = []; if (outline.outline) { - const children = Array.isArray(outline.outline) - ? outline.outline - : [outline.outline]; + const children = Array.isArray(outline.outline) ? outline.outline : [outline.outline]; children.forEach((child) => { const childText = - child["@_text"] || - (child._attributes && child._attributes.text) || - (child as any).text; - if (childText && typeof childText === "string") { + child['@_text'] || (child._attributes && child._attributes.text) || (child as any).text; + if (childText && typeof childText === 'string') { const button = new AACButton({ id: `nav_${page.id}_${childText}`, label: childText, - message: "", - targetPageId: childText.replace(/[^a-zA-Z0-9]/g, "_"), + message: '', + targetPageId: childText.replace(/[^a-zA-Z0-9]/g, '_'), }); page.addButton(button); - const { page: childPage, childPages: grandChildren } = - this.processOutline(child, page.id); - if (childPage && childPage.id) - childPages.push(childPage, ...grandChildren); + const { page: childPage, childPages: grandChildren } = this.processOutline( + child, + page.id + ); + if (childPage && childPage.id) childPages.push(childPage, ...grandChildren); } }); } @@ -108,15 +100,11 @@ class OpmlProcessor extends BaseProcessor { // Handle different attribute formats let textValue: string | undefined; - if ( - node && - node._attributes && - typeof node._attributes.text === "string" - ) { + if (node && node._attributes && typeof node._attributes.text === 'string') { textValue = node._attributes.text; - } else if (node && typeof node["@_text"] === "string") { - textValue = node["@_text"]; - } else if (node && typeof node.text === "string") { + } else if (node && typeof node['@_text'] === 'string') { + textValue = node['@_text']; + } else if (node && typeof node.text === 'string') { textValue = node.text; } @@ -125,9 +113,7 @@ class OpmlProcessor extends BaseProcessor { } if (node && node.outline) { - const children = Array.isArray(node.outline) - ? node.outline - : [node.outline]; + const children = Array.isArray(node.outline) ? node.outline : [node.outline]; children.forEach(processNode); } } @@ -143,9 +129,7 @@ class OpmlProcessor extends BaseProcessor { const { readBinaryFromInput, readTextFromInput } = this.options.fileAdapter; const filename = - typeof filePathOrBuffer === "string" - ? getBasename(filePathOrBuffer) - : "upload.opml"; + typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.opml'; const buffer = await readBinaryFromInput(filePathOrBuffer); const content = await readTextFromInput(buffer); @@ -154,38 +138,33 @@ class OpmlProcessor extends BaseProcessor { const validationResult = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: "opml", - message: "Empty OPML content", - type: "content", - description: "OPML content is empty", + format: 'opml', + message: 'Empty OPML content', + type: 'content', + description: 'OPML content is empty', }); - throw new ValidationFailureError( - "Empty OPML content", - validationResult, - ); + throw new ValidationFailureError('Empty OPML content', validationResult); } // Validate XML before parsing, fast-xml-parser is permissive by default const validationResult = XMLValidator.validate(content); if (validationResult !== true) { - const reason = - (validationResult as any)?.err?.msg || - JSON.stringify(validationResult); + const reason = (validationResult as any)?.err?.msg || JSON.stringify(validationResult); const structured = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: "opml", + format: 'opml', message: `Invalid OPML XML: ${reason}`, - type: "xml", - description: "OPML XML validation", + type: 'xml', + description: 'OPML XML validation', }); - throw new ValidationFailureError("Invalid OPML XML", structured); + throw new ValidationFailureError('Invalid OPML XML', structured); } const parser = new XMLParser({ ignoreAttributes: false }); const data = parser.parse(content) as OpmlDocument; const tree = new AACTree(); - tree.metadata.format = "opml"; + tree.metadata.format = 'opml'; // Handle case where body.outline might not exist or be in different formats const bodyOutline = data.opml?.body?.outline; @@ -193,12 +172,12 @@ class OpmlProcessor extends BaseProcessor { const structured = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: "opml", - message: "Missing body.outline in OPML document", - type: "structure", - description: "OPML outline root", + format: 'opml', + message: 'Missing body.outline in OPML document', + type: 'structure', + description: 'OPML outline root', }); - throw new ValidationFailureError("Invalid OPML structure", structured); + throw new ValidationFailureError('Invalid OPML structure', structured); } const outlines = Array.isArray(bodyOutline) ? bodyOutline : [bodyOutline]; @@ -227,23 +206,19 @@ class OpmlProcessor extends BaseProcessor { const validationResult = buildValidationResultFromMessage({ filename, filesize: buffer.byteLength, - format: "opml", - message: err?.message || "Failed to parse OPML", - type: "parse", - description: "Parse OPML XML", + format: 'opml', + message: err?.message || 'Failed to parse OPML', + type: 'parse', + description: 'Parse OPML XML', }); - throw new ValidationFailureError( - "Failed to load OPML file", - validationResult, - err, - ); + throw new ValidationFailureError('Failed to load OPML file', validationResult, err); } } async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string, + outputPath: string ): Promise { const { writeBinaryToPath, readTextFromInput } = this.options.fileAdapter; @@ -253,16 +228,13 @@ class OpmlProcessor extends BaseProcessor { // Apply translations to text attributes in OPML outline elements translations.forEach((translation, originalText) => { - if (typeof originalText === "string" && typeof translation === "string") { + if (typeof originalText === 'string' && typeof translation === 'string') { // Replace text attributes in outline elements const textAttrRegex = new RegExp( - `text="${originalText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`, - "g", - ); - translatedContent = translatedContent.replace( - textAttrRegex, - `text="${translation}"`, + `text="${originalText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`, + 'g' ); + translatedContent = translatedContent.replace(textAttrRegex, `text="${translation}"`); } }); @@ -275,21 +247,18 @@ class OpmlProcessor extends BaseProcessor { const { writeTextToPath } = this.options.fileAdapter; // Helper to recursively build outline nodes with cycle detection - function buildOutline( - page: AACPage, - visited: Set = new Set(), - ): OpmlOutline { + function buildOutline(page: AACPage, visited: Set = new Set()): OpmlOutline { // Prevent infinite recursion by tracking visited pages if (visited.has(page.id)) { return { - "@_text": `${page.name || page.id} (circular reference)`, + '@_text': `${page.name || page.id} (circular reference)`, }; } visited.add(page.id); const outline: OpmlOutline = { - "@_text": page.name || page.id, + '@_text': page.name || page.id, }; // Find child pages (by NAVIGATE buttons) @@ -298,7 +267,7 @@ class OpmlProcessor extends BaseProcessor { (b) => b.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && !!b.targetPageId && - !!tree.pages[b.targetPageId], + !!tree.pages[b.targetPageId] ) .map((b) => { const targetId = b.targetPageId; @@ -311,9 +280,7 @@ class OpmlProcessor extends BaseProcessor { } return buildOutline(targetPage, new Set(visited)); }) - .filter( - (childOutline): childOutline is OpmlOutline => childOutline !== null, - ); + .filter((childOutline): childOutline is OpmlOutline => childOutline !== null); if (childOutlines.length) outline.outline = childOutlines; return outline; } @@ -321,27 +288,18 @@ class OpmlProcessor extends BaseProcessor { const navigatedIds = new Set(); Object.values(tree.pages).forEach((page) => { page.buttons.forEach((b) => { - if ( - b.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && - b.targetPageId - ) + if (b.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && b.targetPageId) navigatedIds.add(b.targetPageId); }); }); - let rootPages = Object.values(tree.pages).filter( - (page) => !navigatedIds.has(page.id), - ); + let rootPages = Object.values(tree.pages).filter((page) => !navigatedIds.has(page.id)); // If no rootPages, fall back to tree.rootId const treeRootId = tree.rootId; - if ( - (!rootPages || rootPages.length === 0) && - treeRootId && - tree.pages[treeRootId] - ) { + if ((!rootPages || rootPages.length === 0) && treeRootId && tree.pages[treeRootId]) { rootPages = [tree.pages[treeRootId]]; } else if (treeRootId) { rootPages = rootPages.sort((a, b) => - a.id === treeRootId ? -1 : b.id === treeRootId ? 1 : 0, + a.id === treeRootId ? -1 : b.id === treeRootId ? 1 : 0 ); } // Build outlines @@ -349,8 +307,8 @@ class OpmlProcessor extends BaseProcessor { // Compose OPML document const opmlObj = { opml: { - "@_version": "2.0", - head: { title: "Exported OPML" }, + '@_version': '2.0', + head: { title: 'Exported OPML' }, body: { outline: outlines }, }, }; @@ -358,12 +316,11 @@ class OpmlProcessor extends BaseProcessor { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, - indentBy: " ", + indentBy: ' ', suppressEmptyNode: false, - attributeNamePrefix: "@_", + attributeNamePrefix: '@_', }); - const xml = - '\n' + builder.build(opmlObj); + const xml = '\n' + builder.build(opmlObj); await writeTextToPath(outputPath, xml); } @@ -382,13 +339,9 @@ class OpmlProcessor extends BaseProcessor { generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { - return this.generateTranslatedDownloadGeneric( - filePath, - translatedStrings, - sourceStrings, - ); + return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); } } diff --git a/src/processors/snap/helpers.ts b/src/processors/snap/helpers.ts index 7d02528..af3957e 100644 --- a/src/processors/snap/helpers.ts +++ b/src/processors/snap/helpers.ts @@ -3,16 +3,16 @@ import { AACSemanticCategory, AACSemanticIntent, AACButton, -} from "../../core/treeStructure"; -import { dotNetTicksToDate } from "../../utils/dotnetTicks"; +} from '../../core/treeStructure'; +import { dotNetTicksToDate } from '../../utils/dotnetTicks'; import { defaultFileAdapter, extname, FileAdapter, getNodeRequire, ProcessorInput, -} from "../../utils/io"; -import { requireBetterSqlite3 } from "../../utils/sqlite"; +} from '../../utils/io'; +import { requireBetterSqlite3 } from '../../utils/sqlite'; // Minimal Snap helpers (stubs) to align with processors//helpers pattern // NOTE: Snap files can store different types of image data in PageSetData: @@ -27,13 +27,11 @@ async function collectFiles( root: string, matcher: (fullPath: string) => boolean, maxDepth = 3, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { listDir, join, isDirectory } = fileAdapter; const results = new Set(); - const stack: Array<{ dir: string; depth: number }> = [ - { dir: root, depth: 0 }, - ]; + const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; while (stack.length > 0) { const current = stack.pop(); @@ -64,10 +62,7 @@ async function collectFiles( * Build a map of button IDs to resolved image entries for a specific page. * Mirrors the Grid helper for consumers that expect image reference data. */ -export function getPageTokenImageMap( - tree: AACTree, - pageId: string, -): Map { +export function getPageTokenImageMap(tree: AACTree, pageId: string): Map { const map = new Map(); const page = tree.getPage(pageId); if (!page) return map; @@ -86,19 +81,13 @@ export function getAllowedImageEntries(tree: AACTree): Set { Object.values(tree.pages).forEach((page) => { page.buttons.forEach((btn: AACButton) => { // Extract image_id from parameters if it exists - if ( - btn.parameters?.image_id && - typeof btn.parameters.image_id === "string" - ) { + if (btn.parameters?.image_id && typeof btn.parameters.image_id === 'string') { out.add(btn.parameters.image_id); } // Also add resolvedImageEntry if it's a symbol identifier - if ( - btn.resolvedImageEntry && - typeof btn.resolvedImageEntry === "string" - ) { + if (btn.resolvedImageEntry && typeof btn.resolvedImageEntry === 'string') { const entry = btn.resolvedImageEntry; - if (entry.startsWith("SYM:")) { + if (entry.startsWith('SYM:')) { out.add(entry); } } @@ -116,38 +105,33 @@ export function getAllowedImageEntries(tree: AACTree): Set { export async function openImage( dbOrFile: ProcessorInput, entryPath: string, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { - const { mkTempDir, join, writeBinaryToPath, removePath, dirname } = - fileAdapter; + const { mkTempDir, join, writeBinaryToPath, removePath, dirname } = fileAdapter; let dbPath: string; let cleanupNeeded = false; // Handle Buffer input by writing to temp file if (Buffer.isBuffer(dbOrFile)) { - const tempDir = await mkTempDir(join(process.cwd(), "snap-")); - dbPath = join(tempDir, "temp.sps"); + const tempDir = await mkTempDir(join(process.cwd(), 'snap-')); + dbPath = join(tempDir, 'temp.sps'); await writeBinaryToPath(dbPath, dbOrFile); cleanupNeeded = true; - } else if (typeof dbOrFile === "string") { + } else if (typeof dbOrFile === 'string') { dbPath = dbOrFile; } else { return null; } - const better_sqlite3 = getNodeRequire()("better-sqlite3"); + const better_sqlite3 = getNodeRequire()('better-sqlite3'); let db = null; try { db = new better_sqlite3.Database(dbPath, { readonly: true }); // Query PageSetData for the symbol const row = db - .prepare( - "SELECT Id, Identifier, Data FROM PageSetData WHERE Identifier = ?", - ) - .get(entryPath) as - | { Id: number; Identifier: string; Data: Buffer } - | undefined; + .prepare('SELECT Id, Identifier, Data FROM PageSetData WHERE Identifier = ?') + .get(entryPath) as { Id: number; Identifier: string; Data: Buffer } | undefined; if (row && row.Data && row.Data.length > 0) { // Snap files can store different types of image data: @@ -197,7 +181,7 @@ export interface SnapUsageEntry { timestamp: Date; modeling?: boolean; accessMethod?: number | null; - type?: "button" | "action" | "utterance" | "note" | "other"; + type?: 'button' | 'action' | 'utterance' | 'note' | 'other'; buttonId?: string | null; intent?: AACSemanticIntent | string; category?: AACSemanticCategory; @@ -216,14 +200,14 @@ export interface SnapUsageEntry { * @returns Array of Snap package path information */ export async function findSnapPackages( - packageNamePattern = "TobiiDynavox", - fileAdapter: FileAdapter = defaultFileAdapter, + packageNamePattern = 'TobiiDynavox', + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { join, listDir, isDirectory, pathExists } = fileAdapter; const results: SnapPackagePath[] = []; // Only works on Windows - if (process.platform !== "win32") { + if (process.platform !== 'win32') { return results; } @@ -233,7 +217,7 @@ export async function findSnapPackages( return results; } - const packagesPath = join(localAppData, "Packages"); + const packagesPath = join(localAppData, 'Packages'); // Check if Packages directory exists if (!(await pathExists(packagesPath))) { @@ -270,8 +254,8 @@ export async function findSnapPackages( * @returns Path to the first matching Snap package, or null if not found */ export async function findSnapPackagePath( - packageNamePattern = "TobiiDynavox", - fileAdapter?: FileAdapter, + packageNamePattern = 'TobiiDynavox', + fileAdapter?: FileAdapter ): Promise { const packages = await findSnapPackages(packageNamePattern, fileAdapter); return packages.length > 0 ? packages[0].packagePath : null; @@ -285,25 +269,22 @@ export async function findSnapPackagePath( * @returns Array of user info with vocab paths */ export async function findSnapUsers( - packageNamePattern = "TobiiDynavox", - fileAdapter: FileAdapter = defaultFileAdapter, + packageNamePattern = 'TobiiDynavox', + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { join, listDir, isDirectory, pathExists } = fileAdapter; const results: SnapUserInfo[] = []; - if (process.platform !== "win32") { + if (process.platform !== 'win32') { return results; } - const packagePath = await findSnapPackagePath( - packageNamePattern, - fileAdapter, - ); + const packagePath = await findSnapPackagePath(packageNamePattern, fileAdapter); if (!packagePath) { return results; } - const usersRoot = join(packagePath, "LocalState", "Users"); + const usersRoot = join(packagePath, 'LocalState', 'Users'); if (!(await pathExists(usersRoot))) { return results; } @@ -311,17 +292,17 @@ export async function findSnapUsers( const entries = await listDir(usersRoot); for (const entry of entries) { if (!(await isDirectory(entry))) continue; - if (entry.toLowerCase().startsWith("swiftkey")) continue; + if (entry.toLowerCase().startsWith('swiftkey')) continue; const userPath = join(usersRoot, entry); const vocabPaths = await collectFiles( userPath, (full) => { const ext = extname(full).toLowerCase(); - return ext === ".sps" || ext === ".spb"; + return ext === '.sps' || ext === '.spb'; }, 2, - fileAdapter, + fileAdapter ); results.push({ @@ -342,8 +323,8 @@ export async function findSnapUsers( */ export async function findSnapUserVocabularies( userId?: string, - packageNamePattern = "TobiiDynavox", - fileAdapter?: FileAdapter, + packageNamePattern = 'TobiiDynavox', + fileAdapter?: FileAdapter ): Promise { const allUsers = await findSnapUsers(packageNamePattern, fileAdapter); const users = allUsers.filter((u) => !userId || u.userId === userId); @@ -359,8 +340,8 @@ export async function findSnapUserVocabularies( */ export async function findSnapUserHistory( userId: string, - packageNamePattern = "TobiiDynavox", - fileAdapter: FileAdapter = defaultFileAdapter, + packageNamePattern = 'TobiiDynavox', + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { basename } = fileAdapter; const allUsers = await findSnapUsers(packageNamePattern, fileAdapter); @@ -369,17 +350,17 @@ export async function findSnapUserHistory( return await collectFiles( user.userPath, - (full) => basename(full).toLowerCase().includes("history"), + (full) => basename(full).toLowerCase().includes('history'), 2, - fileAdapter, + fileAdapter ); } /** * Check whether TD Snap appears to be installed (Windows only) */ -export function isSnapInstalled(packageNamePattern = "TobiiDynavox"): boolean { - if (process.platform !== "win32") return false; +export function isSnapInstalled(packageNamePattern = 'TobiiDynavox'): boolean { + if (process.platform !== 'win32') return false; return Boolean(findSnapPackagePath(packageNamePattern)); } @@ -388,7 +369,7 @@ export function isSnapInstalled(packageNamePattern = "TobiiDynavox"): boolean { */ export async function readSnapUsage( pagesetPath: string, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { pathExists } = fileAdapter; if (!(await pathExists(pagesetPath))) return []; @@ -398,7 +379,7 @@ export async function readSnapUsage( const tableCheck = db .prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('ButtonUsage','Button')", + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('ButtonUsage','Button')" ) .all(); if (tableCheck.length < 2) return []; @@ -417,7 +398,7 @@ export async function readSnapUsage( LEFT JOIN Button b ON bu.ButtonUniqueId = b.UniqueId WHERE bu.Timestamp IS NOT NULL ORDER BY bu.Timestamp ASC - `, + ` ) .all() as Array<{ ButtonId?: string; @@ -431,10 +412,10 @@ export async function readSnapUsage( const events = new Map(); for (const row of rows) { - const buttonId: string = row.ButtonId ?? "unknown"; + const buttonId: string = row.ButtonId ?? 'unknown'; const label = row.Label ?? undefined; const message = row.Message ?? undefined; - const content = message || label || ""; + const content = message || label || ''; const entry = events.get(buttonId) ?? @@ -453,7 +434,7 @@ export async function readSnapUsage( timestamp: dotNetTicksToDate(BigInt(row.TickValue ?? 0)), modeling: row.Modeling === 1, accessMethod: row.AccessMethod ?? null, - type: "button", + type: 'button', buttonId: row.ButtonId, intent: AACSemanticIntent.SPEAK_TEXT, category: AACSemanticCategory.COMMUNICATION, @@ -470,13 +451,11 @@ export async function readSnapUsage( */ export async function readSnapUsageForUser( userId?: string, - packageNamePattern = "TobiiDynavox", + packageNamePattern = 'TobiiDynavox' ): Promise { const allUsers = await findSnapUsers(packageNamePattern); const users = allUsers.filter((u) => !userId || u.userId === userId); const pagesets = users.flatMap((u) => u.vocabPaths); - const usage = await Promise.all( - pagesets.map(async (p) => await readSnapUsage(p)), - ); + const usage = await Promise.all(pagesets.map(async (p) => await readSnapUsage(p))); return usage.flat(); } diff --git a/src/processors/snapProcessor.ts b/src/processors/snapProcessor.ts index 62b7fba..15e021e 100644 --- a/src/processors/snapProcessor.ts +++ b/src/processors/snapProcessor.ts @@ -4,7 +4,7 @@ import { ExtractStringsResult, TranslatedString, SourceString, -} from "../core/baseProcessor"; +} from '../core/baseProcessor'; import { AACTree, AACPage, @@ -13,12 +13,12 @@ import { AACSemanticCategory, AACSemanticIntent, SnapMetadata, -} from "../core/treeStructure"; -import { generateCloneId } from "../utilities/analytics/utils/idGenerator"; -import { SnapValidator } from "../validation/snapValidator"; -import { ValidationResult } from "../validation/validationTypes"; -import { ProcessorInput, getNodeRequire, isNodeRuntime } from "../utils/io"; -import { openSqliteDatabase, requireBetterSqlite3 } from "../utils/sqlite"; +} from '../core/treeStructure'; +import { generateCloneId } from '../utilities/analytics/utils/idGenerator'; +import { SnapValidator } from '../validation/snapValidator'; +import { ValidationResult } from '../validation/validationTypes'; +import { ProcessorInput, getNodeRequire, isNodeRuntime } from '../utils/io'; +import { openSqliteDatabase, requireBetterSqlite3 } from '../utils/sqlite'; /** * Convert a Buffer or Uint8Array to base64 string (browser and Node compatible) @@ -27,13 +27,13 @@ import { openSqliteDatabase, requireBetterSqlite3 } from "../utils/sqlite"; */ function arrayBufferToBase64(data: Buffer | Uint8Array): string { // Node.js environment - Buffer has built-in base64 encoding - if (typeof Buffer !== "undefined" && data instanceof Buffer) { - return data.toString("base64"); + if (typeof Buffer !== 'undefined' && data instanceof Buffer) { + return data.toString('base64'); } // Browser environment - use btoa with binary string conversion const bytes = new Uint8Array(data); - let binary = ""; + let binary = ''; const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); @@ -66,13 +66,11 @@ interface SnapButton { * Snap: 0 = hidden, 1 (or non-zero) = visible * Maps to: 'Hidden' | 'Visible' | undefined */ -function mapSnapVisibility( - visible: number | null | undefined, -): "Hidden" | "Visible" | undefined { +function mapSnapVisibility(visible: number | null | undefined): 'Hidden' | 'Visible' | undefined { if (visible === null || visible === undefined) { return undefined; // Default to visible } - return visible === 0 ? "Hidden" : "Visible"; + return visible === 0 ? 'Hidden' : 'Visible'; } interface SnapPage { @@ -86,24 +84,20 @@ interface SnapPage { class SnapProcessor extends BaseProcessor { private symbolResolver: unknown | null = null; private loadAudio: boolean = false; - private pageLayoutPreference: "largest" | "smallest" | "scanning" | number = - "scanning"; // Default to scanning for metrics + private pageLayoutPreference: 'largest' | 'smallest' | 'scanning' | number = 'scanning'; // Default to scanning for metrics constructor( symbolResolver: unknown | null = null, options?: ProcessorOptions & { loadAudio?: boolean; - pageLayoutPreference?: "largest" | "smallest" | "scanning" | number; - }, + pageLayoutPreference?: 'largest' | 'smallest' | 'scanning' | number; + } ) { super(options); this.symbolResolver = symbolResolver; - this.loadAudio = - options?.loadAudio !== undefined ? options.loadAudio : true; + this.loadAudio = options?.loadAudio !== undefined ? options.loadAudio : true; this.pageLayoutPreference = - options?.pageLayoutPreference !== undefined - ? options.pageLayoutPreference - : "scanning"; // Default to scanning + options?.pageLayoutPreference !== undefined ? options.pageLayoutPreference : 'scanning'; // Default to scanning } async extractTexts(filePathOrBuffer: ProcessorInput): Promise { @@ -126,8 +120,7 @@ class SnapProcessor extends BaseProcessor { } async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { - const { writeBinaryToPath, removePath, mkTempDir, basename, join } = - this.options.fileAdapter; + const { writeBinaryToPath, removePath, mkTempDir, basename, join } = this.options.fileAdapter; const tree = new AACTree(); let dbResult: Awaited> | null = null; let cleanupTempZip: (() => Promise) | null = null; @@ -136,23 +129,20 @@ class SnapProcessor extends BaseProcessor { // Handle .sub.zip files (Snap pageset backups containing .sps files) let inputFile = filePathOrBuffer; - if (typeof filePathOrBuffer === "string") { + if (typeof filePathOrBuffer === 'string') { const fileName = basename(filePathOrBuffer).toLowerCase(); - if ( - fileName.endsWith(".sub.zip") || - filePathOrBuffer.endsWith(".sub") - ) { + if (fileName.endsWith('.sub.zip') || filePathOrBuffer.endsWith('.sub')) { // Extract .sub.zip to find the embedded .sps file - const tempDir = await mkTempDir("snap-sub-"); + const tempDir = await mkTempDir('snap-sub-'); const zip = await this.options.zipAdapter(filePathOrBuffer); // Find the .sps file in the archive const files = zip.listFiles(); - const spsFile = files.find((f) => f.endsWith(".sps")); + const spsFile = files.find((f) => f.endsWith('.sps')); if (!spsFile) { await removePath(tempDir, { recursive: true, force: true }); - throw new Error("No .sps file found in .sub.zip archive"); + throw new Error('No .sps file found in .sub.zip archive'); } // Extract the .sps file @@ -175,9 +165,7 @@ class SnapProcessor extends BaseProcessor { const getTableColumns = (tableName: string): Set => { try { - const rows = db - .prepare(`PRAGMA table_info(${tableName})`) - .all() as Array<{ + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string; }>; return new Set(rows.map((row) => row.name)); @@ -187,7 +175,7 @@ class SnapProcessor extends BaseProcessor { }; // Load pages first, using UniqueId as canonical id - const pages = db.prepare("SELECT * FROM Page").all(); + const pages = db.prepare('SELECT * FROM Page').all(); // Load PageSetProperties to find default Keyboard and Home pages let defaultKeyboardPageId: string | undefined; @@ -195,7 +183,7 @@ class SnapProcessor extends BaseProcessor { let dashboardPageId: string | undefined; let toolbarId: string | undefined; try { - const properties = db.prepare("SELECT * FROM PageSetProperties").get(); + const properties = db.prepare('SELECT * FROM PageSetProperties').get(); if (properties) { defaultKeyboardPageId = properties.DefaultKeyboardPageUniqueId; defaultHomePageId = properties.DefaultHomePageUniqueId; @@ -203,11 +191,11 @@ class SnapProcessor extends BaseProcessor { toolbarId = properties.ToolBarUniqueId; const hasGlobalToolbar = - toolbarId && toolbarId !== "00000000-0000-0000-0000-000000000000"; + toolbarId && toolbarId !== '00000000-0000-0000-0000-000000000000'; // Store metadata in tree const metadata: SnapMetadata = { - format: "snap", + format: 'snap', name: properties.Name || properties.PageSetName || undefined, description: properties.Description || undefined, author: properties.Author || undefined, @@ -230,7 +218,7 @@ class SnapProcessor extends BaseProcessor { } } } catch (e) { - console.warn("[SnapProcessor] Failed to load PageSetProperties:", e); + console.warn('[SnapProcessor] Failed to load PageSetProperties:', e); } // If still no root, fallback to first page (but don't override a valid defaultHomePageId) @@ -262,13 +250,10 @@ class SnapProcessor extends BaseProcessor { // Try to find toolbar page even if not set in PageSetProperties // Some SNAP files have a toolbar page but don't set ToolBarUniqueId // This must be done AFTER pages are added to the tree - if ( - !tree.toolbarId || - tree.toolbarId === "00000000-0000-0000-0000-000000000000" - ) { + if (!tree.toolbarId || tree.toolbarId === '00000000-0000-0000-0000-000000000000') { const toolbarPage = Object.values(tree.pages).find((p) => { - const name = (p.name || "").toLowerCase(); - return name === "tool bar" || name === "toolbar"; + const name = (p.name || '').toLowerCase(); + return name === 'tool bar' || name === 'toolbar'; }); if (toolbarPage) { tree.toolbarId = toolbarPage.id; @@ -290,9 +275,7 @@ class SnapProcessor extends BaseProcessor { const scanGroupsByPageLayout = new Map(); try { const scanGroupRows = db - .prepare( - "SELECT Id, SerializedGridPositions, PageLayoutId FROM ScanGroup ORDER BY Id", - ) + .prepare('SELECT Id, SerializedGridPositions, PageLayoutId FROM ScanGroup ORDER BY Id') .all() as { Id: number; SerializedGridPositions: string; @@ -342,7 +325,7 @@ class SnapProcessor extends BaseProcessor { } } catch (e) { // No ScanGroups table or error loading, continue without scan blocks - console.warn("[SnapProcessor] Failed to load ScanGroups:", e); + console.warn('[SnapProcessor] Failed to load ScanGroups:', e); } // Load buttons per page, using UniqueId for page id @@ -354,27 +337,25 @@ class SnapProcessor extends BaseProcessor { let selectedPageLayoutId: number | null = null; try { const pageLayouts = db - .prepare( - "SELECT Id, PageLayoutSetting FROM PageLayout WHERE PageId = ?", - ) + .prepare('SELECT Id, PageLayoutSetting FROM PageLayout WHERE PageId = ?') .all(pageRow.Id) as { Id: number; PageLayoutSetting: string }[]; if (pageLayouts && pageLayouts.length > 0) { // Parse PageLayoutSetting: "columns,rows,hasScanGroups,?" const layoutsWithInfo = pageLayouts.map((pl) => { - const parts = pl.PageLayoutSetting.split(","); + const parts = pl.PageLayoutSetting.split(','); const cols = parseInt(parts[0], 10) || 0; const rows = parseInt(parts[1], 10) || 0; - const hasScanning = parts[2] === "True"; + const hasScanning = parts[2] === 'True'; const size = cols * rows; return { id: pl.Id, cols, rows, size, hasScanning }; }); // Select based on preference - if (typeof this.pageLayoutPreference === "number") { + if (typeof this.pageLayoutPreference === 'number') { // Specific PageLayoutId selectedPageLayoutId = this.pageLayoutPreference; - } else if (this.pageLayoutPreference === "largest") { + } else if (this.pageLayoutPreference === 'largest') { // Select layout with largest grid size, prefer layouts with ScanGroups layoutsWithInfo.sort((a, b) => { const sizeDiff = b.size - a.size; @@ -385,7 +366,7 @@ class SnapProcessor extends BaseProcessor { return (bHasScanning ? 1 : 0) - (aHasScanning ? 1 : 0); }); selectedPageLayoutId = layoutsWithInfo[0].id; - } else if (this.pageLayoutPreference === "smallest") { + } else if (this.pageLayoutPreference === 'smallest') { // Select layout with smallest grid size, prefer layouts with ScanGroups layoutsWithInfo.sort((a, b) => { const sizeDiff = a.size - b.size; @@ -396,10 +377,10 @@ class SnapProcessor extends BaseProcessor { return (bHasScanning ? 1 : 0) - (aHasScanning ? 1 : 0); }); selectedPageLayoutId = layoutsWithInfo[0].id; - } else if (this.pageLayoutPreference === "scanning") { + } else if (this.pageLayoutPreference === 'scanning') { // Select layout with scanning enabled (check against actual ScanGroups) const scanningLayouts = layoutsWithInfo.filter((l) => - scanGroupsByPageLayout.has(l.id), + scanGroupsByPageLayout.has(l.id) ); if (scanningLayouts.length > 0) { scanningLayouts.sort((a, b) => b.size - a.size); @@ -413,134 +394,98 @@ class SnapProcessor extends BaseProcessor { } } catch (e) { // Error selecting PageLayout, will load all buttons - console.warn( - `[SnapProcessor] Failed to select PageLayout for page ${pageRow.Id}:`, - e, - ); + console.warn(`[SnapProcessor] Failed to select PageLayout for page ${pageRow.Id}:`, e); } // Load buttons let buttons: any[] = []; try { - const buttonColumns = getTableColumns("Button"); + const buttonColumns = getTableColumns('Button'); const selectFields = [ - "b.Id", - "b.Label", - "b.Message", - buttonColumns.has("LibrarySymbolId") - ? "b.LibrarySymbolId" - : "NULL AS LibrarySymbolId", - buttonColumns.has("PageSetImageId") - ? "b.PageSetImageId" - : "NULL AS PageSetImageId", - buttonColumns.has("BorderColor") - ? "b.BorderColor" - : "NULL AS BorderColor", - buttonColumns.has("BorderThickness") - ? "b.BorderThickness" - : "NULL AS BorderThickness", - buttonColumns.has("FontSize") ? "b.FontSize" : "NULL AS FontSize", - buttonColumns.has("FontFamily") - ? "b.FontFamily" - : "NULL AS FontFamily", - buttonColumns.has("FontStyle") - ? "b.FontStyle" - : "NULL AS FontStyle", - buttonColumns.has("LabelColor") - ? "b.LabelColor" - : "NULL AS LabelColor", - buttonColumns.has("BackgroundColor") - ? "b.BackgroundColor" - : "NULL AS BackgroundColor", - buttonColumns.has("NavigatePageId") - ? "b.NavigatePageId" - : "NULL AS NavigatePageId", - buttonColumns.has("ContentType") - ? "b.ContentType" - : "NULL AS ContentType", + 'b.Id', + 'b.Label', + 'b.Message', + buttonColumns.has('LibrarySymbolId') ? 'b.LibrarySymbolId' : 'NULL AS LibrarySymbolId', + buttonColumns.has('PageSetImageId') ? 'b.PageSetImageId' : 'NULL AS PageSetImageId', + buttonColumns.has('BorderColor') ? 'b.BorderColor' : 'NULL AS BorderColor', + buttonColumns.has('BorderThickness') ? 'b.BorderThickness' : 'NULL AS BorderThickness', + buttonColumns.has('FontSize') ? 'b.FontSize' : 'NULL AS FontSize', + buttonColumns.has('FontFamily') ? 'b.FontFamily' : 'NULL AS FontFamily', + buttonColumns.has('FontStyle') ? 'b.FontStyle' : 'NULL AS FontStyle', + buttonColumns.has('LabelColor') ? 'b.LabelColor' : 'NULL AS LabelColor', + buttonColumns.has('BackgroundColor') ? 'b.BackgroundColor' : 'NULL AS BackgroundColor', + buttonColumns.has('NavigatePageId') ? 'b.NavigatePageId' : 'NULL AS NavigatePageId', + buttonColumns.has('ContentType') ? 'b.ContentType' : 'NULL AS ContentType', ]; if (this.loadAudio) { selectFields.push( - buttonColumns.has("MessageRecordingId") - ? "b.MessageRecordingId" - : "NULL AS MessageRecordingId", + buttonColumns.has('MessageRecordingId') + ? 'b.MessageRecordingId' + : 'NULL AS MessageRecordingId' ); selectFields.push( - buttonColumns.has("UseMessageRecording") - ? "b.UseMessageRecording" - : "NULL AS UseMessageRecording", + buttonColumns.has('UseMessageRecording') + ? 'b.UseMessageRecording' + : 'NULL AS UseMessageRecording' ); selectFields.push( - buttonColumns.has("SerializedMessageSoundMetadata") - ? "b.SerializedMessageSoundMetadata" - : "NULL AS SerializedMessageSoundMetadata", + buttonColumns.has('SerializedMessageSoundMetadata') + ? 'b.SerializedMessageSoundMetadata' + : 'NULL AS SerializedMessageSoundMetadata' ); } - const placementColumns = getTableColumns("ElementPlacement"); - const hasButtonPageLink = getTableColumns("ButtonPageLink").size > 0; + const placementColumns = getTableColumns('ElementPlacement'); + const hasButtonPageLink = getTableColumns('ButtonPageLink').size > 0; selectFields.push( - placementColumns.has("GridPosition") - ? "ep.GridPosition" - : "NULL AS GridPosition", - placementColumns.has("PageLayoutId") - ? "ep.PageLayoutId" - : "NULL AS PageLayoutId", - placementColumns.has("Visible") ? "ep.Visible" : "NULL AS Visible", - "er.PageId as ButtonPageId", + placementColumns.has('GridPosition') ? 'ep.GridPosition' : 'NULL AS GridPosition', + placementColumns.has('PageLayoutId') ? 'ep.PageLayoutId' : 'NULL AS PageLayoutId', + placementColumns.has('Visible') ? 'ep.Visible' : 'NULL AS Visible', + 'er.PageId as ButtonPageId' ); if (hasButtonPageLink) { - selectFields.push("bpl.PageUniqueId AS LinkedPageUniqueId"); + selectFields.push('bpl.PageUniqueId AS LinkedPageUniqueId'); } else { - selectFields.push("NULL AS LinkedPageUniqueId"); + selectFields.push('NULL AS LinkedPageUniqueId'); } - const hasCommandSequence = - getTableColumns("CommandSequence").size > 0; + const hasCommandSequence = getTableColumns('CommandSequence').size > 0; if (hasCommandSequence) { - selectFields.push("cs.SerializedCommands"); + selectFields.push('cs.SerializedCommands'); } else { - selectFields.push("NULL AS SerializedCommands"); + selectFields.push('NULL AS SerializedCommands'); } const buttonQuery = ` - SELECT ${selectFields.join(", ")} + SELECT ${selectFields.join(', ')} FROM Button b INNER JOIN ElementReference er ON b.ElementReferenceId = er.Id LEFT JOIN ElementPlacement ep ON ep.ElementReferenceId = er.Id - ${hasButtonPageLink ? "LEFT JOIN ButtonPageLink bpl ON b.Id = bpl.ButtonId" : ""} - ${hasCommandSequence ? "LEFT JOIN CommandSequence cs ON b.Id = cs.ButtonId" : ""} - WHERE er.PageId = ? ${selectedPageLayoutId ? "AND ep.PageLayoutId = ?" : ""} + ${hasButtonPageLink ? 'LEFT JOIN ButtonPageLink bpl ON b.Id = bpl.ButtonId' : ''} + ${hasCommandSequence ? 'LEFT JOIN CommandSequence cs ON b.Id = cs.ButtonId' : ''} + WHERE er.PageId = ? ${selectedPageLayoutId ? 'AND ep.PageLayoutId = ?' : ''} `; if (selectedPageLayoutId) { - buttons = db - .prepare(buttonQuery) - .all(pageRow.Id, selectedPageLayoutId); + buttons = db.prepare(buttonQuery).all(pageRow.Id, selectedPageLayoutId); } else { buttons = db.prepare(buttonQuery).all(pageRow.Id); } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); const errorCode = - err && typeof err === "object" && "code" in err - ? (err as any).code - : undefined; + err && typeof err === 'object' && 'code' in err ? (err as any).code : undefined; if ( - errorCode === "SQLITE_CORRUPT" || - errorCode === "SQLITE_NOTADB" || + errorCode === 'SQLITE_CORRUPT' || + errorCode === 'SQLITE_NOTADB' || /malformed/i.test(errorMessage) ) { - throw new Error( - `Snap database is corrupted or incomplete: ${errorMessage}`, - ); + throw new Error(`Snap database is corrupted or incomplete: ${errorMessage}`); } - console.warn( - `Failed to load buttons for page ${pageRow.Id}: ${errorMessage}`, - ); + console.warn(`Failed to load buttons for page ${pageRow.Id}: ${errorMessage}`); // Skip this page instead of loading all buttons buttons = []; } @@ -566,10 +511,7 @@ class SnapProcessor extends BaseProcessor { buttons.forEach((btnRow) => { // Determine navigation target UniqueId, if possible let targetPageUniqueId: string | undefined = undefined; - if ( - btnRow.NavigatePageId && - idToUniqueId[String(btnRow.NavigatePageId)] - ) { + if (btnRow.NavigatePageId && idToUniqueId[String(btnRow.NavigatePageId)]) { targetPageUniqueId = idToUniqueId[String(btnRow.NavigatePageId)]; } else if (btnRow.LinkedPageUniqueId) { targetPageUniqueId = String(btnRow.LinkedPageUniqueId); @@ -583,16 +525,16 @@ class SnapProcessor extends BaseProcessor { const commands = JSON.parse(btnRow.SerializedCommands as string); const values = commands.$values || []; for (const cmd of values) { - if (cmd.$type === "2" && cmd.LinkedPageId) { + if (cmd.$type === '2' && cmd.LinkedPageId) { // Normal Navigation targetPageUniqueId = String(cmd.LinkedPageId); - } else if (cmd.$type === "16") { + } else if (cmd.$type === '16') { // Go to Home targetPageUniqueId = defaultHomePageId; - } else if (cmd.$type === "17") { + } else if (cmd.$type === '17') { // Go to Keyboard targetPageUniqueId = defaultKeyboardPageId; - } else if (cmd.$type === "18") { + } else if (cmd.$type === '18') { // Go to Dashboard targetPageUniqueId = dashboardPageId; } @@ -603,27 +545,19 @@ class SnapProcessor extends BaseProcessor { } // Determine parent page association for this button - const parentPageId = btnRow.ButtonPageId - ? String(btnRow.ButtonPageId) - : undefined; + const parentPageId = btnRow.ButtonPageId ? String(btnRow.ButtonPageId) : undefined; const parentUniqueId = - parentPageId && idToUniqueId[parentPageId] - ? idToUniqueId[parentPageId] - : uniqueId; + parentPageId && idToUniqueId[parentPageId] ? idToUniqueId[parentPageId] : uniqueId; // Load audio recording if requested and available let audioRecording; - if ( - this.loadAudio && - btnRow.MessageRecordingId && - btnRow.MessageRecordingId > 0 - ) { + if (this.loadAudio && btnRow.MessageRecordingId && btnRow.MessageRecordingId > 0) { try { const recordingData = db .prepare( ` SELECT Id, Identifier, Data FROM PageSetData WHERE Id = ? - `, + ` ) .get(btnRow.MessageRecordingId) as | { Id: number; Identifier: string; Data: Buffer } @@ -638,10 +572,7 @@ class SnapProcessor extends BaseProcessor { }; } } catch (e) { - console.warn( - `[SnapProcessor] Failed to load audio for button ${btnRow.Id}:`, - e, - ); + console.warn(`[SnapProcessor] Failed to load audio for button ${btnRow.Id}:`, e); } } @@ -656,7 +587,7 @@ class SnapProcessor extends BaseProcessor { .prepare( ` SELECT Id, Identifier, Data FROM PageSetData WHERE Id = ? - `, + ` ) .get(btnRow.PageSetImageId) as | { Id: number; Identifier: string; Data: Buffer } @@ -677,14 +608,11 @@ class SnapProcessor extends BaseProcessor { data[3] === 0x47; // Check for JPEG: FF D8 FF const isJpeg = - data.length > 3 && - data[0] === 0xff && - data[1] === 0xd8 && - data[2] === 0xff; + data.length > 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff; if (isPng || isJpeg) { // Actual PNG/JPEG image - can be displayed - const mimeType = isPng ? "image/png" : "image/jpeg"; + const mimeType = isPng ? 'image/png' : 'image/jpeg'; const base64 = arrayBufferToBase64(data); buttonImage = `data:${mimeType};base64,${base64}`; buttonParameters.image_id = imageData.Identifier; @@ -697,7 +625,7 @@ class SnapProcessor extends BaseProcessor { } catch (e) { console.warn( `[SnapProcessor] Failed to load image for button ${btnRow.Id} (PageSetImageId: ${btnRow.PageSetImageId}):`, - e, + e ); } } @@ -717,7 +645,7 @@ class SnapProcessor extends BaseProcessor { }, }, fallback: { - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId: targetPageUniqueId, }, }; @@ -725,30 +653,28 @@ class SnapProcessor extends BaseProcessor { semanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: btnRow.Message || btnRow.Label || "", + text: btnRow.Message || btnRow.Label || '', platformData: { snap: { elementReferenceId: btnRow.Id, }, }, fallback: { - type: "SPEAK", - message: btnRow.Message || btnRow.Label || "", + type: 'SPEAK', + message: btnRow.Message || btnRow.Label || '', }, }; } const button = new AACButton({ id: String(btnRow.Id), - label: - btnRow.Label || (btnRow.ContentType === 1 ? "[Prediction]" : ""), + label: btnRow.Label || (btnRow.ContentType === 1 ? '[Prediction]' : ''), message: - btnRow.Message || - (btnRow.ContentType === 1 ? "[Prediction]" : btnRow.Label || ""), + btnRow.Message || (btnRow.ContentType === 1 ? '[Prediction]' : btnRow.Label || ''), targetPageId: targetPageUniqueId, semanticAction: semanticAction, - contentType: btnRow.ContentType === 1 ? "AutoContent" : undefined, - contentSubType: btnRow.ContentType === 1 ? "Prediction" : undefined, + contentType: btnRow.ContentType === 1 ? 'AutoContent' : undefined, + contentSubType: btnRow.ContentType === 1 ? 'Prediction' : undefined, audioRecording: audioRecording, visibility: mapSnapVisibility(btnRow.Visible as number), semantic_id: btnRow.LibrarySymbolId @@ -756,21 +682,14 @@ class SnapProcessor extends BaseProcessor { : undefined, // Extract semantic_id from LibrarySymbolId image: buttonImage, resolvedImageEntry: buttonImage, - parameters: - Object.keys(buttonParameters).length > 0 - ? buttonParameters - : undefined, + parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined, style: { backgroundColor: btnRow.BackgroundColor ? `#${btnRow.BackgroundColor.toString(16)}` : undefined, - borderColor: btnRow.BorderColor - ? `#${btnRow.BorderColor.toString(16)}` - : undefined, + borderColor: btnRow.BorderColor ? `#${btnRow.BorderColor.toString(16)}` : undefined, borderWidth: btnRow.BorderThickness, - fontColor: btnRow.LabelColor - ? `#${btnRow.LabelColor.toString(16)}` - : undefined, + fontColor: btnRow.LabelColor ? `#${btnRow.LabelColor.toString(16)}` : undefined, fontSize: btnRow.FontSize, fontFamily: btnRow.FontFamily, fontStyle: btnRow.FontStyle?.toString(), @@ -783,10 +702,10 @@ class SnapProcessor extends BaseProcessor { parentPage.addButton(button); // Add button to grid layout if position data is available - const gridPositionStr = String(btnRow.GridPosition || ""); - if (gridPositionStr && gridPositionStr.includes(",")) { + const gridPositionStr = String(btnRow.GridPosition || ''); + if (gridPositionStr && gridPositionStr.includes(',')) { // Parse comma-separated coordinates "x,y" - const [xStr, yStr] = gridPositionStr.split(","); + const [xStr, yStr] = gridPositionStr.split(','); const gridX = parseInt(xStr, 10); const gridY = parseInt(yStr, 10); @@ -799,25 +718,18 @@ class SnapProcessor extends BaseProcessor { // IMPORTANT: Only match against ScanGroups from the SAME PageLayout // A button can exist in multiple layouts with different positions const buttonPageLayoutId = btnRow.PageLayoutId as number; - if ( - buttonPageLayoutId && - scanGroupsByPageLayout.has(buttonPageLayoutId) - ) { - const scanGroups = - scanGroupsByPageLayout.get(buttonPageLayoutId); + if (buttonPageLayoutId && scanGroupsByPageLayout.has(buttonPageLayoutId)) { + const scanGroups = scanGroupsByPageLayout.get(buttonPageLayoutId); if (scanGroups && scanGroups.length > 0) { // Find which ScanGroup contains this button's position for (const scanGroup of scanGroups) { // Skip if positions array is null or undefined - if ( - !scanGroup.positions || - !Array.isArray(scanGroup.positions) - ) { + if (!scanGroup.positions || !Array.isArray(scanGroup.positions)) { continue; } const foundInGroup = scanGroup.positions.some( - (pos) => pos.Column === gridX && pos.Row === gridY, + (pos) => pos.Column === gridX && pos.Row === gridY ); if (foundInGroup) { @@ -845,13 +757,7 @@ class SnapProcessor extends BaseProcessor { // Generate clone_id for button at this position const rows = pageGrid.length; const cols = pageGrid[0] ? pageGrid[0].length : 10; - button.clone_id = generateCloneId( - rows, - cols, - gridY, - gridX, - button.label, - ); + button.clone_id = generateCloneId(rows, cols, gridY, gridX, button.label); pageGrid[gridY][gridX] = button; } } @@ -900,17 +806,15 @@ class SnapProcessor extends BaseProcessor { return tree; } catch (error: any) { const fileIdentifier = - typeof filePathOrBuffer === "string" - ? filePathOrBuffer - : "[buffer input]"; + typeof filePathOrBuffer === 'string' ? filePathOrBuffer : '[buffer input]'; // Provide more specific error messages - if (error.code === "SQLITE_NOTADB") { + if (error.code === 'SQLITE_NOTADB') { throw new Error( - `Invalid SQLite database file: ${typeof filePathOrBuffer === "string" ? filePathOrBuffer : "buffer"}`, + `Invalid SQLite database file: ${typeof filePathOrBuffer === 'string' ? filePathOrBuffer : 'buffer'}` ); - } else if (error.code === "ENOENT") { + } else if (error.code === 'ENOENT') { throw new Error(`File not found: ${fileIdentifier}`); - } else if (error.code === "EACCES") { + } else if (error.code === 'EACCES') { throw new Error(`Permission denied accessing file: ${fileIdentifier}`); } else { throw new Error(`Failed to load Snap file: ${error.message}`); @@ -926,10 +830,7 @@ class SnapProcessor extends BaseProcessor { try { await cleanupTempZip(); } catch (e) { - console.warn( - "[SnapProcessor] Failed to clean up temporary .sps file:", - e, - ); + console.warn('[SnapProcessor] Failed to clean up temporary .sps file:', e); } } } @@ -938,23 +839,15 @@ class SnapProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string, + outputPath: string ): Promise { - const { - pathExists, - mkDir, - writeBinaryToPath, - readBinaryFromInput, - removePath, - dirname, - } = this.options.fileAdapter; + const { pathExists, mkDir, writeBinaryToPath, readBinaryFromInput, removePath, dirname } = + this.options.fileAdapter; if (!isNodeRuntime()) { - throw new Error( - "processTexts is only supported in Node.js environments for Snap files.", - ); + throw new Error('processTexts is only supported in Node.js environments for Snap files.'); } - if (typeof filePathOrBuffer === "string") { + if (typeof filePathOrBuffer === 'string') { const inputPath = filePathOrBuffer; const outputDir = dirname(outputPath); const dirExists = await pathExists(outputDir); @@ -971,9 +864,7 @@ class SnapProcessor extends BaseProcessor { try { const getColumns = (tableName: string): Set => { try { - const rows = db - .prepare(`PRAGMA table_info(${tableName})`) - .all() as Array<{ + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string; }>; return new Set(rows.map((row) => row.name)); @@ -982,36 +873,36 @@ class SnapProcessor extends BaseProcessor { } }; - const pageColumns = getColumns("Page"); - const buttonColumns = getColumns("Button"); + const pageColumns = getColumns('Page'); + const buttonColumns = getColumns('Button'); const pageUpdates: string[] = []; const pageWhere: string[] = []; - const pageColumnsToUse: Array<"Name" | "Title"> = []; + const pageColumnsToUse: Array<'Name' | 'Title'> = []; - if (pageColumns.has("Name")) { - pageUpdates.push("Name = ?"); - pageWhere.push("Name = ?"); - pageColumnsToUse.push("Name"); + if (pageColumns.has('Name')) { + pageUpdates.push('Name = ?'); + pageWhere.push('Name = ?'); + pageColumnsToUse.push('Name'); } - if (pageColumns.has("Title")) { - pageUpdates.push("Title = ?"); - pageWhere.push("Title = ?"); - pageColumnsToUse.push("Title"); + if (pageColumns.has('Title')) { + pageUpdates.push('Title = ?'); + pageWhere.push('Title = ?'); + pageColumnsToUse.push('Title'); } const updatePage = pageUpdates.length > 0 ? db.prepare( - `UPDATE Page SET ${pageUpdates.join(", ")} WHERE ${pageWhere.join(" OR ")}`, + `UPDATE Page SET ${pageUpdates.join(', ')} WHERE ${pageWhere.join(' OR ')}` ) : null; - const updateLabel = buttonColumns.has("Label") - ? db.prepare("UPDATE Button SET Label = ? WHERE Label = ?") + const updateLabel = buttonColumns.has('Label') + ? db.prepare('UPDATE Button SET Label = ? WHERE Label = ?') : null; - const updateMessage = buttonColumns.has("Message") - ? db.prepare("UPDATE Button SET Message = ? WHERE Message = ?") + const updateMessage = buttonColumns.has('Message') + ? db.prepare('UPDATE Button SET Message = ? WHERE Message = ?') : null; const entries = Array.from(translations.entries()); @@ -1076,9 +967,7 @@ class SnapProcessor extends BaseProcessor { async saveFromTree(tree: AACTree, outputPath: string): Promise { const { pathExists, mkDir, removePath, dirname } = this.options.fileAdapter; if (!isNodeRuntime()) { - throw new Error( - "saveFromTree is only supported in Node.js environments for Snap files.", - ); + throw new Error('saveFromTree is only supported in Node.js environments for Snap files.'); } const outputDir = dirname(outputPath); const dirExists = await pathExists(outputDir); @@ -1166,10 +1055,10 @@ class SnapProcessor extends BaseProcessor { const pageIdMap = new Map(); const pageSetDataIdentifierMap = new Map(); const insertPageSetData = db.prepare( - "INSERT INTO PageSetData (Id, Identifier, Data, RefCount) VALUES (?, ?, ?, ?)", + 'INSERT INTO PageSetData (Id, Identifier, Data, RefCount) VALUES (?, ?, ?, ?)' ); const incrementRefCount = db.prepare( - "UPDATE PageSetData SET RefCount = RefCount + 1 WHERE Id = ?", + 'UPDATE PageSetData SET RefCount = RefCount + 1 WHERE Id = ?' ); // First pass: create all pages @@ -1178,16 +1067,16 @@ class SnapProcessor extends BaseProcessor { pageIdMap.set(page.id, numericPageId); const insertPage = db.prepare( - "INSERT INTO Page (Id, UniqueId, Title, Name, BackgroundColor) VALUES (?, ?, ?, ?, ?)", + 'INSERT INTO Page (Id, UniqueId, Title, Name, BackgroundColor) VALUES (?, ?, ?, ?, ?)' ); insertPage.run( numericPageId, page.id, - page.name || "", - page.name || "", + page.name || '', + page.name || '', page.style?.backgroundColor - ? parseInt(page.style.backgroundColor.replace("#", ""), 16) - : null, + ? parseInt(page.style.backgroundColor.replace('#', ''), 16) + : null ); }); @@ -1219,7 +1108,7 @@ class SnapProcessor extends BaseProcessor { // Insert ElementReference const insertElementRef = db.prepare( - "INSERT INTO ElementReference (Id, PageId) VALUES (?, ?)", + 'INSERT INTO ElementReference (Id, PageId) VALUES (?, ?)' ); insertElementRef.run(elementRefId, numericPageId); @@ -1228,13 +1117,12 @@ class SnapProcessor extends BaseProcessor { // Use semantic action if available if (button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO) { - const targetId = - button.semanticAction.targetId || button.targetPageId; + const targetId = button.semanticAction.targetId || button.targetPageId; navigatePageId = targetId ? pageIdMap.get(targetId) || null : null; } const insertButton = db.prepare( - "INSERT INTO Button (Id, Label, Message, NavigatePageId, ElementReferenceId, LibrarySymbolId, PageSetImageId, MessageRecordingId, SerializedMessageSoundMetadata, UseMessageRecording, LabelColor, BackgroundColor, BorderColor, BorderThickness, FontSize, FontFamily, FontStyle) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'INSERT INTO Button (Id, Label, Message, NavigatePageId, ElementReferenceId, LibrarySymbolId, PageSetImageId, MessageRecordingId, SerializedMessageSoundMetadata, UseMessageRecording, LabelColor, BackgroundColor, BorderColor, BorderThickness, FontSize, FontFamily, FontStyle) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ); const audio = button.audioRecording; @@ -1264,23 +1152,14 @@ class SnapProcessor extends BaseProcessor { // Handle image data from button.parameters.imageData or button.image (data URL) let pageSetImageId: number | null = null; - if ( - button.parameters?.imageData && - Buffer.isBuffer(button.parameters.imageData) - ) { + if (button.parameters?.imageData && Buffer.isBuffer(button.parameters.imageData)) { // Use existing image data buffer const imageIdentifier: string = - (button.parameters.image_id as string) || - `IMG_${buttonIdCounter}`; + (button.parameters.image_id as string) || `IMG_${buttonIdCounter}`; let imageId = pageSetDataIdentifierMap.get(imageIdentifier); if (!imageId) { imageId = pageSetDataIdCounter++; - insertPageSetData.run( - imageId, - imageIdentifier, - button.parameters.imageData, - 1, - ); + insertPageSetData.run(imageId, imageIdentifier, button.parameters.imageData, 1); pageSetDataIdentifierMap.set(imageIdentifier, imageId); } else { incrementRefCount.run(imageId); @@ -1288,19 +1167,16 @@ class SnapProcessor extends BaseProcessor { pageSetImageId = imageId; } else if ( button.image && - typeof button.image === "string" && - button.image.startsWith("data:image") + typeof button.image === 'string' && + button.image.startsWith('data:image') ) { // Convert data URL to buffer try { - const matches = button.image.match( - /^data:image\/(\w+);base64,(.+)$/, - ); + const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/); if (matches && matches[2]) { - const imageData = Buffer.from(matches[2], "base64"); + const imageData = Buffer.from(matches[2], 'base64'); const imageIdentifier: string = - (button.parameters?.image_id as string) || - `IMG_${buttonIdCounter}`; + (button.parameters?.image_id as string) || `IMG_${buttonIdCounter}`; let imageId = pageSetDataIdentifierMap.get(imageIdentifier); if (!imageId) { imageId = pageSetDataIdCounter++; @@ -1314,7 +1190,7 @@ class SnapProcessor extends BaseProcessor { } catch (err) { console.warn( `[SnapProcessor] Failed to convert data URL to Buffer for button ${button.id}:`, - err, + err ); } } @@ -1325,8 +1201,8 @@ class SnapProcessor extends BaseProcessor { try { insertButton.run( buttonIdCounter++, - button.label || "", - button.message || button.label || "", + button.label || '', + button.message || button.label || '', navigatePageId, elementRefId, null, // LibrarySymbolId - not used for embedded images @@ -1335,24 +1211,22 @@ class SnapProcessor extends BaseProcessor { serializedMetadata, useMessageRecording, button.style?.fontColor - ? parseInt(button.style.fontColor.replace("#", ""), 16) + ? parseInt(button.style.fontColor.replace('#', ''), 16) : null, button.style?.backgroundColor - ? parseInt(button.style.backgroundColor.replace("#", ""), 16) + ? parseInt(button.style.backgroundColor.replace('#', ''), 16) : null, button.style?.borderColor - ? parseInt(button.style.borderColor.replace("#", ""), 16) + ? parseInt(button.style.borderColor.replace('#', ''), 16) : null, button.style?.borderWidth, button.style?.fontSize, button.style?.fontFamily, - button.style?.fontStyle - ? parseInt(button.style.fontStyle) - : null, + button.style?.fontStyle ? parseInt(button.style.fontStyle) : null ); break; // Success } catch (err: any) { - if (err.code === "SQLITE_IOERR" && retries > 1) { + if (err.code === 'SQLITE_IOERR' && retries > 1) { retries--; // Wait a bit before retrying const now = Date.now(); @@ -1367,7 +1241,7 @@ class SnapProcessor extends BaseProcessor { // Insert ElementPlacement const insertPlacement = db.prepare( - "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition) VALUES (?, ?, ?)", + 'INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition) VALUES (?, ?, ?)' ); insertPlacement.run(placementIdCounter++, elementRefId, gridPosition); }); @@ -1391,9 +1265,7 @@ class SnapProcessor extends BaseProcessor { tree.metadata?.defaultHomePageId || tree.rootId || null, tree.metadata?.defaultKeyboardPageId || null, tree.metadata?.dashboardId || null, - tree.metadata?.hasGlobalToolbar - ? tree.metadata.toolbarId || null - : null, + tree.metadata?.hasGlobalToolbar ? tree.metadata.toolbarId || null : null ); } finally { db.close(); @@ -1407,15 +1279,13 @@ class SnapProcessor extends BaseProcessor { dbPath: string, buttonId: number, audioData: Uint8Array, - metadata?: string, + metadata?: string ): Promise { if (!isNodeRuntime()) { - throw new Error( - "addAudioToButton is only supported in Node.js environments.", - ); + throw new Error('addAudioToButton is only supported in Node.js environments.'); } const Database = requireBetterSqlite3(); - const crypto = getNodeRequire()("crypto") as typeof import("crypto"); + const crypto = getNodeRequire()('crypto') as typeof import('crypto'); const db = new Database(dbPath, { fileMustExist: true }); try { @@ -1429,16 +1299,13 @@ class SnapProcessor extends BaseProcessor { `); // Generate SHA1 hash for the identifier - const sha1Hash = crypto - .createHash("sha1") - .update(audioData) - .digest("hex"); + const sha1Hash = crypto.createHash('sha1').update(audioData).digest('hex'); const identifier = `SND:${sha1Hash}`; // Check if audio with this identifier already exists let audioId; const existingAudio = db - .prepare("SELECT Id FROM PageSetData WHERE Identifier = ?") + .prepare('SELECT Id FROM PageSetData WHERE Identifier = ?') .get(identifier) as { Id: number } | undefined; if (existingAudio) { @@ -1446,18 +1313,16 @@ class SnapProcessor extends BaseProcessor { } else { // Insert new audio data const result = db - .prepare("INSERT INTO PageSetData (Identifier, Data) VALUES (?, ?)") + .prepare('INSERT INTO PageSetData (Identifier, Data) VALUES (?, ?)') .run(identifier, audioData); audioId = Number(result.lastInsertRowid); } // Update button to reference the audio const updateButton = db.prepare( - "UPDATE Button SET MessageRecordingId = ?, UseMessageRecording = 1, SerializedMessageSoundMetadata = ? WHERE Id = ?", + 'UPDATE Button SET MessageRecordingId = ?, UseMessageRecording = 1, SerializedMessageSoundMetadata = ? WHERE Id = ?' ); - const metadataJson = metadata - ? JSON.stringify({ FileName: metadata }) - : null; + const metadataJson = metadata ? JSON.stringify({ FileName: metadata }) : null; updateButton.run(audioId, metadataJson, buttonId); return Promise.resolve(audioId); @@ -1472,28 +1337,18 @@ class SnapProcessor extends BaseProcessor { async createAudioEnhancedPageset( sourceDbPath: string, targetDbPath: string, - audioMappings: Map, + audioMappings: Map ): Promise { const { writeBinaryToPath, readBinaryFromInput } = this.options.fileAdapter; if (!isNodeRuntime()) { - throw new Error( - "createAudioEnhancedPageset is only supported in Node.js environments.", - ); + throw new Error('createAudioEnhancedPageset is only supported in Node.js environments.'); } // Copy the source database to target - await writeBinaryToPath( - targetDbPath, - await readBinaryFromInput(sourceDbPath), - ); + await writeBinaryToPath(targetDbPath, await readBinaryFromInput(sourceDbPath)); // Add audio recordings to the copy for (const [buttonId, audioInfo] of audioMappings.entries()) { - await this.addAudioToButton( - targetDbPath, - buttonId, - audioInfo.audioData, - audioInfo.metadata, - ); + await this.addAudioToButton(targetDbPath, buttonId, audioInfo.audioData, audioInfo.metadata); } } @@ -1502,7 +1357,7 @@ class SnapProcessor extends BaseProcessor { */ extractButtonsForAudio( dbPath: string, - pageUniqueId: string, + pageUniqueId: string ): Array<{ id: number; label: string; @@ -1510,18 +1365,16 @@ class SnapProcessor extends BaseProcessor { hasAudio: boolean; }> { if (!isNodeRuntime()) { - throw new Error( - "extractButtonsForAudio is only supported in Node.js environments.", - ); + throw new Error('extractButtonsForAudio is only supported in Node.js environments.'); } const Database = requireBetterSqlite3(); const db = new Database(dbPath, { readonly: true }); try { // Find the page by UniqueId - const page = db - .prepare("SELECT * FROM Page WHERE UniqueId = ?") - .get(pageUniqueId) as { Id: number } | undefined; + const page = db.prepare('SELECT * FROM Page WHERE UniqueId = ?').get(pageUniqueId) as + | { Id: number } + | undefined; if (!page) { throw new Error(`Page with UniqueId ${pageUniqueId} not found`); } @@ -1535,7 +1388,7 @@ class SnapProcessor extends BaseProcessor { FROM Button b JOIN ElementReference er ON b.ElementReferenceId = er.Id WHERE er.PageId = ? - `, + ` ) .all(page.Id) as Array<{ Id: number; @@ -1547,8 +1400,8 @@ class SnapProcessor extends BaseProcessor { return buttons.map((btn) => ({ id: btn.Id, - label: btn.Label || "", - message: btn.Message || btn.Label || "", + label: btn.Label || '', + message: btn.Message || btn.Label || '', hasAudio: !!(btn.MessageRecordingId && btn.MessageRecordingId > 0), })); } finally { @@ -1560,9 +1413,7 @@ class SnapProcessor extends BaseProcessor { * Extract strings with metadata for aac-tools-platform compatibility * Uses the generic implementation from BaseProcessor */ - async extractStringsWithMetadata( - filePath: string, - ): Promise { + async extractStringsWithMetadata(filePath: string): Promise { return this.extractStringsWithMetadataGeneric(filePath); } @@ -1573,13 +1424,9 @@ class SnapProcessor extends BaseProcessor { async generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { - return this.generateTranslatedDownloadGeneric( - filePath, - translatedStrings, - sourceStrings, - ); + return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); } /** @@ -1598,15 +1445,11 @@ class SnapProcessor extends BaseProcessor { * @returns Promise resolving to available PageLayouts with their dimensions */ async getAvailablePageLayouts(filePath: string): Promise { - const { writeBinaryToPath, removePath, pathExists, join } = - this.options.fileAdapter; + const { writeBinaryToPath, removePath, pathExists, join } = this.options.fileAdapter; if (!isNodeRuntime()) { - throw new Error( - "getAvailablePageLayouts is only supported in Node.js environments.", - ); + throw new Error('getAvailablePageLayouts is only supported in Node.js environments.'); } - const dbPath = - typeof filePath === "string" ? filePath : join(process.cwd(), "temp.spb"); + const dbPath = typeof filePath === 'string' ? filePath : join(process.cwd(), 'temp.spb'); if (Buffer.isBuffer(filePath)) { await writeBinaryToPath(dbPath, filePath); @@ -1627,16 +1470,16 @@ class SnapProcessor extends BaseProcessor { FROM PageLayout pl GROUP BY pl.PageLayoutSetting ORDER BY pl.PageLayoutSetting - `, + ` ) .all() as Array<{ Id: number; PageLayoutSetting: string }>; // Parse the PageLayoutSetting format: "columns,rows,hasScanGroups,?" const layouts: PageLayoutInfo[] = pageLayouts.map((pl) => { - const parts = pl.PageLayoutSetting.split(","); + const parts = pl.PageLayoutSetting.split(','); const cols = parseInt(parts[0], 10) || 0; const rows = parseInt(parts[1], 10) || 0; - const hasScanning = parts[2] === "True"; + const hasScanning = parts[2] === 'True'; return { id: pl.Id, @@ -1644,7 +1487,7 @@ class SnapProcessor extends BaseProcessor { rows, size: cols * rows, hasScanning, - label: `${cols}×${rows}${hasScanning ? " (with scanning)" : ""}`, + label: `${cols}×${rows}${hasScanning ? ' (with scanning)' : ''}`, }; }); @@ -1657,10 +1500,7 @@ class SnapProcessor extends BaseProcessor { return layouts; } catch (error) { - console.error( - "[SnapProcessor] Failed to get available page layouts:", - error, - ); + console.error('[SnapProcessor] Failed to get available page layouts:', error); return []; } finally { if (db) { @@ -1673,7 +1513,7 @@ class SnapProcessor extends BaseProcessor { try { await removePath(dbPath); } catch (e) { - console.warn("Failed to clean up temporary file:", e); + console.warn('Failed to clean up temporary file:', e); } } } diff --git a/src/processors/touchchat/helpers.ts b/src/processors/touchchat/helpers.ts index 6d11cbf..08b636d 100644 --- a/src/processors/touchchat/helpers.ts +++ b/src/processors/touchchat/helpers.ts @@ -1,4 +1,4 @@ -import { AACTree } from "../../core/treeStructure"; +import { AACTree } from '../../core/treeStructure'; // Minimal TouchChat helpers (stubs) to align with processors//helpers pattern // NOTE: TouchChat buttons currently do not populate resolvedImageEntry; these helpers @@ -8,10 +8,7 @@ import { AACTree } from "../../core/treeStructure"; * Build a map of button IDs to resolved image entry strings for a page. * Returns an empty map when no images are present. */ -export function getPageTokenImageMap( - tree: AACTree, - pageId: string, -): Map { +export function getPageTokenImageMap(tree: AACTree, pageId: string): Map { const map = new Map(); const page = tree.getPage(pageId); if (!page) return map; @@ -33,9 +30,6 @@ export function getAllowedImageEntries(_tree: AACTree): Set { * Read a binary asset from a .ce file. * Not implemented yet; provided for API symmetry with other processors. */ -export function openImage( - _ceFile: string | Buffer, - _entryPath: string, -): Buffer | null { +export function openImage(_ceFile: string | Buffer, _entryPath: string): Buffer | null { return null; } diff --git a/src/processors/touchchatProcessor.ts b/src/processors/touchchatProcessor.ts index 575a0d4..3cc772a 100644 --- a/src/processors/touchchatProcessor.ts +++ b/src/processors/touchchatProcessor.ts @@ -6,7 +6,7 @@ import { SourceString, VocabLocation, ExtractedString, -} from "../core/baseProcessor"; +} from '../core/baseProcessor'; import { AACTree, AACPage, @@ -14,25 +14,25 @@ import { AACSemanticAction, AACSemanticCategory, AACSemanticIntent, -} from "../core/treeStructure"; -import { generateCloneId } from "../utilities/analytics/utils/idGenerator"; -import { detectCasing, isNumericOrEmpty } from "../core/stringCasing"; -import { TouchChatValidator } from "../validation/touchChatValidator"; -import { ValidationResult } from "../validation/validationTypes"; -import { ProcessorInput, isNodeRuntime } from "../utils/io"; +} from '../core/treeStructure'; +import { generateCloneId } from '../utilities/analytics/utils/idGenerator'; +import { detectCasing, isNumericOrEmpty } from '../core/stringCasing'; +import { TouchChatValidator } from '../validation/touchChatValidator'; +import { ValidationResult } from '../validation/validationTypes'; +import { ProcessorInput, isNodeRuntime } from '../utils/io'; import { extractAllButtonsForTranslation, validateTranslationResults, type ButtonForTranslation, type LLMLTranslationResult, -} from "../utilities/translation/translationProcessor"; +} from '../utilities/translation/translationProcessor'; import { openSqliteDatabase, requireBetterSqlite3, type SqliteDatabaseAdapter, -} from "../utils/sqlite"; -import { getZipEntriesFromAdapter } from "./gridset"; -import { ZipFile } from "../utils/zip"; +} from '../utils/sqlite'; +import { getZipEntriesFromAdapter } from './gridset'; +import { ZipFile } from '../utils/zip'; interface TouchChatButton { id: number; @@ -56,18 +56,14 @@ interface TouchChatPage { feature: number | null; } -const toNumberOrUndefined = ( - value: number | null | undefined, -): number | undefined => (typeof value === "number" ? value : undefined); +const toNumberOrUndefined = (value: number | null | undefined): number | undefined => + typeof value === 'number' ? value : undefined; -const toStringOrUndefined = ( - value: string | null | undefined, -): string | undefined => - typeof value === "string" && value.length > 0 ? value : undefined; +const toStringOrUndefined = (value: string | null | undefined): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined; -const toBooleanOrUndefined = ( - value: number | null | undefined, -): boolean | undefined => (typeof value === "number" ? value !== 0 : undefined); +const toBooleanOrUndefined = (value: number | null | undefined): boolean | undefined => + typeof value === 'number' ? value !== 0 : undefined; interface TouchChatButtonStyle { id: number; @@ -90,11 +86,11 @@ interface TouchChatPageStyle { } function intToHex(colorInt: number | null | undefined): string | undefined { - if (colorInt === null || typeof colorInt === "undefined") { + if (colorInt === null || typeof colorInt === 'undefined') { return undefined; } // Assuming the color is in ARGB format, we mask out the alpha channel - return `#${(colorInt & 0x00ffffff).toString(16).padStart(6, "0")}`; + return `#${(colorInt & 0x00ffffff).toString(16).padStart(6, '0')}`; } /** @@ -103,12 +99,12 @@ function intToHex(colorInt: number | null | undefined): string | undefined { * Maps to: 'Hidden' | 'Visible' | undefined */ function mapTouchChatVisibility( - visible: number | null | undefined, -): "Visible" | "Hidden" | undefined { + visible: number | null | undefined +): 'Visible' | 'Hidden' | undefined { if (visible === null || visible === undefined) { return undefined; // Default to visible } - return visible === 0 ? "Hidden" : "Visible"; + return visible === 0 ? 'Hidden' : 'Visible'; } class TouchChatProcessor extends BaseProcessor { @@ -125,7 +121,7 @@ class TouchChatProcessor extends BaseProcessor { this.tree = await this.loadIntoTree(filePathOrBuffer); } if (!this.tree) { - throw new Error("No tree available - call loadIntoTree first"); + throw new Error('No tree available - call loadIntoTree first'); } const texts: string[] = []; for (const pageId in this.tree.pages) { @@ -152,9 +148,9 @@ class TouchChatProcessor extends BaseProcessor { // Step 1: Unzip const zipInput = await readBinaryFromInput(filePathOrBuffer); const zip = await this.options.zipAdapter(zipInput); - const vocabEntry = zip.listFiles().find((name) => name.endsWith(".c4v")); + const vocabEntry = zip.listFiles().find((name) => name.endsWith('.c4v')); if (!vocabEntry) { - throw new Error("No .c4v vocab DB found in TouchChat export"); + throw new Error('No .c4v vocab DB found in TouchChat export'); } const dbBuffer = await zip.readFile(vocabEntry); const dbResult = await openSqliteDatabase(dbBuffer, { @@ -173,9 +169,7 @@ class TouchChatProcessor extends BaseProcessor { const getTableColumns = (tableName: string): Set => { if (!db) return new Set(); try { - const rows = db - .prepare(`PRAGMA table_info(${tableName})`) - .all() as Array<{ + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string; }>; return new Set(rows.map((row) => row.name)); @@ -188,8 +182,7 @@ class TouchChatProcessor extends BaseProcessor { const idMappings = new Map(); const numericToRid = new Map(); try { - const mappingQuery = - "SELECT numeric_id, string_id FROM page_id_mapping"; + const mappingQuery = 'SELECT numeric_id, string_id FROM page_id_mapping'; const mappings = db.prepare(mappingQuery).all() as { numeric_id: number; string_id: string; @@ -206,14 +199,12 @@ class TouchChatProcessor extends BaseProcessor { const pageStyles = new Map(); try { const buttonStyleRows = db - .prepare("SELECT * FROM button_styles") + .prepare('SELECT * FROM button_styles') .all() as TouchChatButtonStyle[]; buttonStyleRows.forEach((style) => { buttonStyles.set(style.id, style); }); - const pageStyleRows = db - .prepare("SELECT * FROM page_styles") - .all() as TouchChatPageStyle[]; + const pageStyleRows = db.prepare('SELECT * FROM page_styles').all() as TouchChatPageStyle[]; pageStyleRows.forEach((style) => { pageStyles.set(style.id, style); }); @@ -222,11 +213,11 @@ class TouchChatProcessor extends BaseProcessor { } // First, load all pages and get their names from resources - const resourceColumns = getTableColumns("resources"); - const hasRid = resourceColumns.has("rid"); + const resourceColumns = getTableColumns('resources'); + const hasRid = resourceColumns.has('rid'); const pageQuery = ` - SELECT p.*, r.name${hasRid ? ", r.rid" : ""} + SELECT p.*, r.name${hasRid ? ', r.rid' : ''} FROM pages p JOIN resources r ON r.id = p.resource_id `; @@ -237,15 +228,13 @@ class TouchChatProcessor extends BaseProcessor { pages.forEach((pageRow) => { // Use resource RID (UUID) if available, otherwise mapped string ID, then numeric ID const pageId = - (hasRid ? pageRow.rid : null) || - idMappings.get(pageRow.id) || - String(pageRow.id); + (hasRid ? pageRow.rid : null) || idMappings.get(pageRow.id) || String(pageRow.id); numericToRid.set(pageRow.id, pageId); const style = pageStyles.get(pageRow.page_style_id); const page = new AACPage({ id: pageId, - name: pageRow.name || "", + name: pageRow.name || '', grid: [], buttons: [], parentId: null, @@ -269,9 +258,7 @@ class TouchChatProcessor extends BaseProcessor { JOIN button_boxes bb ON bb.id = bbc.button_box_id `; try { - const buttonBoxCells = db - .prepare(buttonBoxQuery) - .all() as (TouchChatButton & { + const buttonBoxCells = db.prepare(buttonBoxQuery).all() as (TouchChatButton & { box_id: number; layout_x: number; layout_y: number; @@ -307,31 +294,31 @@ class TouchChatProcessor extends BaseProcessor { const semanticAction: AACSemanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: cell.message || cell.label || "", + text: cell.message || cell.label || '', platformData: { touchChat: { actionCode: 0, // Default speak action - actionData: cell.message || cell.label || "", + actionData: cell.message || cell.label || '', resourceId: cell.resource_id, }, }, fallback: { - type: "SPEAK", - message: cell.message || cell.label || "", + type: 'SPEAK', + message: cell.message || cell.label || '', }, }; const button = new AACButton({ id: String(cell.id), - label: cell.label || "", - message: cell.message || "", + label: cell.label || '', + message: cell.message || '', semanticAction: semanticAction, semantic_id: (((cell as any).symbol_link_id || (cell as any).symbolLinkId) as | string | undefined) || undefined, // Extract semantic_id from symbol_link_id visibility: mapTouchChatVisibility( - ((cell as any).visible as number | null | undefined) || undefined, + ((cell as any).visible as number | null | undefined) || undefined ), // Note: TouchChat does not use scan blocks in the file // Scanning is a runtime feature (linear/row-column patterns) @@ -343,8 +330,8 @@ class TouchChatProcessor extends BaseProcessor { fontColor: intToHex(style?.font_color), fontSize: toNumberOrUndefined(style?.font_height), fontFamily: toStringOrUndefined(style?.font_name), - fontWeight: style?.font_bold ? "bold" : undefined, - fontStyle: style?.font_italic ? "italic" : undefined, + fontWeight: style?.font_bold ? 'bold' : undefined, + fontStyle: style?.font_italic ? 'italic' : undefined, textUnderline: toBooleanOrUndefined(style?.font_underline), transparent: toBooleanOrUndefined(style?.transparent), labelOnTop: toBooleanOrUndefined(style?.label_on_top), @@ -359,9 +346,7 @@ class TouchChatProcessor extends BaseProcessor { }); // Map button boxes to pages - const boxInstances = db - .prepare("SELECT * FROM button_box_instances") - .all() as { + const boxInstances = db.prepare('SELECT * FROM button_box_instances').all() as { id: number; page_id: number; button_box_id: number; @@ -376,8 +361,7 @@ class TouchChatProcessor extends BaseProcessor { boxInstances.forEach((instance) => { // Use mapped string ID if available, otherwise use numeric ID as string - const pageId = - numericToRid.get(instance.page_id) || String(instance.page_id); + const pageId = numericToRid.get(instance.page_id) || String(instance.page_id); const page = tree.getPage(pageId); const boxData = buttonBoxes.get(instance.button_box_id); if (page && boxData) { @@ -420,16 +404,8 @@ class TouchChatProcessor extends BaseProcessor { page.addButton(button); // Place button in grid (handle span) - for ( - let r = absoluteY; - r < absoluteY + safeSpanY && r < 10; - r++ - ) { - for ( - let c = absoluteX; - c < absoluteX + safeSpanX && c < 10; - c++ - ) { + for (let r = absoluteY; r < absoluteY + safeSpanY && r < 10; r++) { + for (let c = absoluteX; c < absoluteX + safeSpanX && c < 10; c++) { if (pageGrid && pageGrid[r] && pageGrid[r][c] === null) { pageGrid[r][c] = button; } @@ -455,13 +431,7 @@ class TouchChatProcessor extends BaseProcessor { // Generate clone_id based on position and label const rows = grid.length; const cols = grid[0] ? grid[0].length : 10; - btn.clone_id = generateCloneId( - rows, - cols, - rowIndex, - colIndex, - btn.label, - ); + btn.clone_id = generateCloneId(rows, cols, rowIndex, colIndex, btn.label); cloneIds.push(btn.clone_id); // Track semantic_id if present @@ -493,9 +463,7 @@ class TouchChatProcessor extends BaseProcessor { WHERE r.type = 7 `; try { - const pageButtons = db - .prepare(pageButtonsQuery) - .all() as (TouchChatButton & { + const pageButtons = db.prepare(pageButtonsQuery).all() as (TouchChatButton & { type: number; })[]; pageButtons.forEach((btnRow) => { @@ -504,23 +472,23 @@ class TouchChatProcessor extends BaseProcessor { const semanticAction: AACSemanticAction = { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: btnRow.message || btnRow.label || "", + text: btnRow.message || btnRow.label || '', platformData: { touchChat: { actionCode: 0, // Default speak action - actionData: btnRow.message || btnRow.label || "", + actionData: btnRow.message || btnRow.label || '', }, }, fallback: { - type: "SPEAK", - message: btnRow.message || btnRow.label || "", + type: 'SPEAK', + message: btnRow.message || btnRow.label || '', }, }; const button = new AACButton({ id: String(btnRow.id), - label: btnRow.label || "", - message: btnRow.message || "", + label: btnRow.label || '', + message: btnRow.message || '', semanticAction: semanticAction, visibility: mapTouchChatVisibility(btnRow.visible), // Note: TouchChat does not use scan blocks in the file @@ -533,8 +501,8 @@ class TouchChatProcessor extends BaseProcessor { fontColor: intToHex(style?.font_color), fontSize: toNumberOrUndefined(style?.font_height), fontFamily: toStringOrUndefined(style?.font_name), - fontWeight: style?.font_bold ? "bold" : undefined, - fontStyle: style?.font_italic ? "italic" : undefined, + fontWeight: style?.font_bold ? 'bold' : undefined, + fontStyle: style?.font_italic ? 'italic' : undefined, textUnderline: toBooleanOrUndefined(style?.font_underline), transparent: toBooleanOrUndefined(style?.transparent), labelOnTop: toBooleanOrUndefined(style?.label_on_top), @@ -542,7 +510,7 @@ class TouchChatProcessor extends BaseProcessor { }); // Find the page that references this resource const page = Object.values(tree.pages).find( - (p) => p.id === (numericToRid.get(btnRow.id) || String(btnRow.id)), + (p) => p.id === (numericToRid.get(btnRow.id) || String(btnRow.id)) ); if (page) page.addButton(button); }); @@ -552,10 +520,10 @@ class TouchChatProcessor extends BaseProcessor { // Load navigation actions const navActionsQuery = ` - SELECT a.resource_id, COALESCE(${hasRid ? "r_rid.rid, r_id.rid, " : ""}r_id.id, ad.value) as target_page_id + SELECT a.resource_id, COALESCE(${hasRid ? 'r_rid.rid, r_id.rid, ' : ''}r_id.id, ad.value) as target_page_id FROM actions a JOIN action_data ad ON ad.action_id = a.id - ${hasRid ? "LEFT JOIN resources r_rid ON r_rid.rid = ad.value AND r_rid.type = 7" : ""} + ${hasRid ? 'LEFT JOIN resources r_rid ON r_rid.rid = ad.value AND r_rid.type = 7' : ''} LEFT JOIN resources r_id ON (CASE WHEN ad.value GLOB '[0-9]*' THEN CAST(ad.value AS INTEGER) ELSE -1 END) = r_id.id AND r_id.type = 7 WHERE a.code IN (1, 8, 9) `; @@ -569,9 +537,7 @@ class TouchChatProcessor extends BaseProcessor { for (const pageId in tree.pages) { const page = tree.pages[pageId]; const button = page.buttons.find( - (b) => - b.semanticAction?.platformData?.touchChat?.resourceId === - nav.resource_id, + (b) => b.semanticAction?.platformData?.touchChat?.resourceId === nav.resource_id ); if (button) { // Use mapped string ID for target page if available @@ -593,7 +559,7 @@ class TouchChatProcessor extends BaseProcessor { }, }, fallback: { - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId: String(targetPageId), }, }; @@ -609,11 +575,8 @@ class TouchChatProcessor extends BaseProcessor { // Try to load root ID from multiple sources in order of priority try { // First, try to get HOME page from special_pages table (TouchChat specific) - const specialPagesQuery = - "SELECT page_id FROM special_pages WHERE name = 'HOME'"; - const homePageRow = db.prepare(specialPagesQuery).get() as - | { page_id: number } - | undefined; + const specialPagesQuery = "SELECT page_id FROM special_pages WHERE name = 'HOME'"; + const homePageRow = db.prepare(specialPagesQuery).get() as { page_id: number } | undefined; if (homePageRow) { // The page_id is the page's id (not resource_id), need to get the RID @@ -624,9 +587,7 @@ class TouchChatProcessor extends BaseProcessor { WHERE p.id = ? LIMIT 1 `; - const homePage = db - .prepare(homePageIdQuery) - .get(homePageRow.page_id) as + const homePage = db.prepare(homePageIdQuery).get(homePageRow.page_id) as | { id: number; rid?: string; @@ -644,11 +605,8 @@ class TouchChatProcessor extends BaseProcessor { // If no HOME page found, try tree_metadata table (general fallback) if (!tree.rootId) { - const metadataQuery = - "SELECT value FROM tree_metadata WHERE key = 'rootId'"; - const rootIdRow = db.prepare(metadataQuery).get() as - | { value: string } - | undefined; + const metadataQuery = "SELECT value FROM tree_metadata WHERE key = 'rootId'"; + const rootIdRow = db.prepare(metadataQuery).get() as { value: string } | undefined; if (rootIdRow && tree.getPage(rootIdRow.value)) { tree.rootId = rootIdRow.value; tree.metadata.defaultHomePageId = rootIdRow.value; @@ -669,7 +627,7 @@ class TouchChatProcessor extends BaseProcessor { } // Set metadata for TouchChat files - tree.metadata.format = "touchchat"; + tree.metadata.format = 'touchchat'; return tree; } finally { @@ -685,7 +643,7 @@ class TouchChatProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string, + outputPath: string ): Promise { const { pathExists, @@ -699,7 +657,7 @@ class TouchChatProcessor extends BaseProcessor { } = this.options.fileAdapter; if (!isNodeRuntime()) { throw new Error( - "processTexts is only supported in Node.js environments for TouchChat files.", + 'processTexts is only supported in Node.js environments for TouchChat files.' ); } /** @@ -710,7 +668,7 @@ class TouchChatProcessor extends BaseProcessor { * For file paths, we preserve the original archive and update text in-place * within the embedded SQLite database, ensuring assets and metadata remain intact. */ - if (typeof filePathOrBuffer === "string") { + if (typeof filePathOrBuffer === 'string') { const inputPath = filePathOrBuffer; const outputDir = dirname(outputPath); const dirExists = await pathExists(outputDir); @@ -723,15 +681,13 @@ class TouchChatProcessor extends BaseProcessor { const zip = await this.options.zipAdapter(inputPath); const entries = getZipEntriesFromAdapter(zip); - const vocabEntry = entries.find((entry) => - entry.entryName.endsWith(".c4v"), - ); + const vocabEntry = entries.find((entry) => entry.entryName.endsWith('.c4v')); if (!vocabEntry) { - throw new Error("No .c4v vocab DB found in TouchChat export"); + throw new Error('No .c4v vocab DB found in TouchChat export'); } - const tempDir = await mkTempDir("touchchat-translate-"); - const dbPath = join(tempDir, "vocab.c4v"); + const tempDir = await mkTempDir('touchchat-translate-'); + const dbPath = join(tempDir, 'vocab.c4v'); try { await writeBinaryToPath(dbPath, await vocabEntry.getData()); @@ -740,9 +696,7 @@ class TouchChatProcessor extends BaseProcessor { try { const getColumns = (tableName: string): Set => { try { - const rows = db - .prepare(`PRAGMA table_info(${tableName})`) - .all() as Array<{ + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string; }>; return new Set(rows.map((row) => row.name)); @@ -751,23 +705,23 @@ class TouchChatProcessor extends BaseProcessor { } }; - const resourceColumns = getColumns("resources"); - const pageColumns = getColumns("pages"); - const buttonColumns = getColumns("buttons"); + const resourceColumns = getColumns('resources'); + const pageColumns = getColumns('pages'); + const buttonColumns = getColumns('buttons'); - const updatePageResourceName = resourceColumns.has("name") + const updatePageResourceName = resourceColumns.has('name') ? db.prepare( - "UPDATE resources SET name = ? WHERE name = ? AND id IN (SELECT resource_id FROM pages)", + 'UPDATE resources SET name = ? WHERE name = ? AND id IN (SELECT resource_id FROM pages)' ) : null; - const updatePageName = pageColumns.has("name") - ? db.prepare("UPDATE pages SET name = ? WHERE name = ?") + const updatePageName = pageColumns.has('name') + ? db.prepare('UPDATE pages SET name = ? WHERE name = ?') : null; - const updateButtonLabel = buttonColumns.has("label") - ? db.prepare("UPDATE buttons SET label = ? WHERE label = ?") + const updateButtonLabel = buttonColumns.has('label') + ? db.prepare('UPDATE buttons SET label = ? WHERE label = ?') : null; - const updateButtonMessage = buttonColumns.has("message") - ? db.prepare("UPDATE buttons SET message = ? WHERE message = ?") + const updateButtonMessage = buttonColumns.has('message') + ? db.prepare('UPDATE buttons SET message = ? WHERE message = ?') : null; const entriesToUpdate = Array.from(translations.entries()); @@ -856,23 +810,17 @@ class TouchChatProcessor extends BaseProcessor { } async saveFromTree(tree: AACTree, outputPath: string): Promise { - const { - writeBinaryToPath, - mkTempDir, - readBinaryFromInput, - pathExists, - removePath, - join, - } = this.options.fileAdapter; + const { writeBinaryToPath, mkTempDir, readBinaryFromInput, pathExists, removePath, join } = + this.options.fileAdapter; if (!isNodeRuntime()) { throw new Error( - "saveFromTree is only supported in Node.js environments for TouchChat files.", + 'saveFromTree is only supported in Node.js environments for TouchChat files.' ); } // Create a TouchChat database that matches the expected schema for loading - const tmpDir = await mkTempDir("touchchat-export-"); - const dbPath = join(tmpDir, "vocab.c4v"); + const tmpDir = await mkTempDir('touchchat-export-'); + const dbPath = join(tmpDir, 'vocab.c4v'); try { const Database = requireBetterSqlite3(); @@ -1005,13 +953,13 @@ class TouchChatProcessor extends BaseProcessor { `); // Insert default styles - db.prepare("INSERT INTO button_styles (id) VALUES (1)").run(); - db.prepare("INSERT INTO page_styles (id) VALUES (1)").run(); + db.prepare('INSERT INTO button_styles (id) VALUES (1)').run(); + db.prepare('INSERT INTO page_styles (id) VALUES (1)').run(); // Helper function to convert hex color to integer const hexToInt = (hexColor?: string): number | null => { if (!hexColor) return null; - const hex = hexColor.replace("#", ""); + const hex = hexColor.replace('#', ''); return parseInt(hex, 16); }; @@ -1034,9 +982,7 @@ class TouchChatProcessor extends BaseProcessor { // First pass: create pages and map IDs Object.values(tree.pages).forEach((page) => { // Try to use numeric ID if possible, otherwise assign sequential ID - const numericPageId = /^\d+$/.test(page.id) - ? parseInt(page.id) - : pageIdCounter++; + const numericPageId = /^\d+$/.test(page.id) ? parseInt(page.id) : pageIdCounter++; pageIdMap.set(page.id, numericPageId); // Create page style if needed @@ -1048,16 +994,16 @@ class TouchChatProcessor extends BaseProcessor { pageStyleMap.set(styleKey, pageStyleId); const insertPageStyle = db.prepare( - "INSERT INTO page_styles (id, bg_color, force_bg_color) VALUES (?, ?, ?)", + 'INSERT INTO page_styles (id, bg_color, force_bg_color) VALUES (?, ?, ?)' ); insertPageStyle.run( pageStyleId, hexToInt(page.style.backgroundColor), - page.style.backgroundColor ? 1 : 0, + page.style.backgroundColor ? 1 : 0 ); } else { const existingPageStyleId = pageStyleMap.get(styleKey); - if (typeof existingPageStyleId === "number") { + if (typeof existingPageStyleId === 'number') { pageStyleId = existingPageStyleId; } } @@ -1066,24 +1012,19 @@ class TouchChatProcessor extends BaseProcessor { // Insert resource for page name const pageResourceId = resourceIdCounter++; const insertResource = db.prepare( - "INSERT INTO resources (id, name, type) VALUES (?, ?, ?)", + 'INSERT INTO resources (id, name, type) VALUES (?, ?, ?)' ); - insertResource.run(pageResourceId, page.name || "Page", 0); + insertResource.run(pageResourceId, page.name || 'Page', 0); // Insert page with original ID preserved and style const insertPage = db.prepare( - "INSERT INTO pages (id, resource_id, name, page_style_id) VALUES (?, ?, ?, ?)", - ); - insertPage.run( - numericPageId, - pageResourceId, - page.name || "Page", - pageStyleId, + 'INSERT INTO pages (id, resource_id, name, page_style_id) VALUES (?, ?, ?, ?)' ); + insertPage.run(numericPageId, pageResourceId, page.name || 'Page', pageStyleId); // Store ID mapping const insertIdMapping = db.prepare( - "INSERT INTO page_id_mapping (numeric_id, string_id) VALUES (?, ?)", + 'INSERT INTO page_id_mapping (numeric_id, string_id) VALUES (?, ?)' ); insertIdMapping.run(numericPageId, page.id); }); @@ -1111,17 +1052,13 @@ class TouchChatProcessor extends BaseProcessor { // Create a resource for the button box const buttonBoxResourceId = resourceIdCounter++; const insertButtonBoxResource = db.prepare( - "INSERT INTO resources (id, name, type) VALUES (?, ?, ?)", - ); - insertButtonBoxResource.run( - buttonBoxResourceId, - page.name || "ButtonBox", - 0, + 'INSERT INTO resources (id, name, type) VALUES (?, ?, ?)' ); + insertButtonBoxResource.run(buttonBoxResourceId, page.name || 'ButtonBox', 0); // Insert button box with layout dimensions const insertButtonBox = db.prepare( - "INSERT INTO button_boxes (id, resource_id, layout_x, layout_y, init_size_x, init_size_y) VALUES (?, ?, ?, ?, ?, ?)", + 'INSERT INTO button_boxes (id, resource_id, layout_x, layout_y, init_size_x, init_size_y) VALUES (?, ?, ?, ?, ?, ?)' ); insertButtonBox.run( buttonBoxId, @@ -1129,12 +1066,12 @@ class TouchChatProcessor extends BaseProcessor { gridWidth, gridHeight, 10000, // init_size_x in internal units - 10000, // init_size_y in internal units + 10000 // init_size_y in internal units ); // Create button box instance with calculated dimensions const insertButtonBoxInstance = db.prepare( - "INSERT INTO button_box_instances (id, page_id, button_box_id, position_x, position_y, size_x, size_y) VALUES (?, ?, ?, ?, ?, ?, ?)", + 'INSERT INTO button_box_instances (id, page_id, button_box_id, position_x, position_y, size_x, size_y) VALUES (?, ?, ?, ?, ?, ?, ?)' ); insertButtonBoxInstance.run( buttonBoxInstanceIdCounter++, @@ -1143,7 +1080,7 @@ class TouchChatProcessor extends BaseProcessor { 0, // Box starts at origin 0, gridWidth, - gridHeight, + gridHeight ); // Insert buttons @@ -1173,9 +1110,9 @@ class TouchChatProcessor extends BaseProcessor { } const buttonResourceId = resourceIdCounter++; const insertResource = db.prepare( - "INSERT INTO resources (id, name, type) VALUES (?, ?, ?)", + 'INSERT INTO resources (id, name, type) VALUES (?, ?, ?)' ); - insertResource.run(buttonResourceId, button.label || "Button", 7); + insertResource.run(buttonResourceId, button.label || 'Button', 7); const numericButtonId = parseInt(button.id) || buttonIdCounter++; @@ -1188,7 +1125,7 @@ class TouchChatProcessor extends BaseProcessor { buttonStyleMap.set(styleKey, buttonStyleId); const insertButtonStyle = db.prepare( - "INSERT INTO button_styles (id, label_on_top, transparent, font_color, body_color, border_color, border_width, font_name, font_bold, font_underline, font_italic, font_height) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'INSERT INTO button_styles (id, label_on_top, transparent, font_color, body_color, border_color, border_width, font_name, font_bold, font_underline, font_italic, font_height) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ); insertButtonStyle.run( buttonStyleId, @@ -1199,14 +1136,14 @@ class TouchChatProcessor extends BaseProcessor { hexToInt(button.style.borderColor), button.style.borderWidth, button.style.fontFamily, - button.style.fontWeight === "bold" ? 1 : 0, + button.style.fontWeight === 'bold' ? 1 : 0, button.style.textUnderline ? 1 : 0, - button.style.fontStyle === "italic" ? 1 : 0, - button.style.fontSize, + button.style.fontStyle === 'italic' ? 1 : 0, + button.style.fontSize ); } else { const existingButtonStyleId = buttonStyleMap.get(styleKey); - if (typeof existingButtonStyleId === "number") { + if (typeof existingButtonStyleId === 'number') { buttonStyleId = existingButtonStyleId; } } @@ -1214,22 +1151,22 @@ class TouchChatProcessor extends BaseProcessor { if (!insertedButtonIds.has(numericButtonId)) { const insertButton = db.prepare( - "INSERT INTO buttons (id, resource_id, label, message, visible, button_style_id) VALUES (?, ?, ?, ?, ?, ?)", + 'INSERT INTO buttons (id, resource_id, label, message, visible, button_style_id) VALUES (?, ?, ?, ?, ?, ?)' ); insertButton.run( numericButtonId, buttonResourceId, - button.label || "", - button.message || button.label || "", + button.label || '', + button.message || button.label || '', 1, - buttonStyleId, + buttonStyleId ); insertedButtonIds.add(numericButtonId); } // Insert button box cell with styling const insertButtonBoxCell = db.prepare( - "INSERT INTO button_box_cells (button_box_id, resource_id, location, span_x, span_y, button_style_id, label, message, box_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'INSERT INTO button_box_cells (button_box_id, resource_id, location, span_x, span_y, button_style_id, label, message, box_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ); insertButtonBoxCell.run( buttonBoxId, @@ -1238,35 +1175,29 @@ class TouchChatProcessor extends BaseProcessor { buttonSpanX, buttonSpanY, buttonStyleId, - button.label || "", - button.message || button.label || "", - buttonLocation, + button.label || '', + button.message || button.label || '', + buttonLocation ); // Handle actions - prefer semantic actions - if ( - button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO - ) { - const targetId = - button.semanticAction.targetId || button.targetPageId; + if (button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO) { + const targetId = button.semanticAction.targetId || button.targetPageId; const targetPageId = targetId ? pageIdMap.get(targetId) : null; if (targetPageId) { // Insert navigation action const insertAction = db.prepare( - "INSERT INTO actions (id, resource_id, code) VALUES (?, ?, ?)", + 'INSERT INTO actions (id, resource_id, code) VALUES (?, ?, ?)' ); - const actionCode = - button.semanticAction.platformData?.touchChat?.actionCode || - 1; + const actionCode = button.semanticAction.platformData?.touchChat?.actionCode || 1; insertAction.run(actionIdCounter, buttonResourceId, actionCode); // Insert action data const insertActionData = db.prepare( - "INSERT INTO action_data (action_id, value) VALUES (?, ?)", + 'INSERT INTO action_data (action_id, value) VALUES (?, ?)' ); const actionData = - button.semanticAction.platformData?.touchChat?.actionData || - String(targetPageId); + button.semanticAction.platformData?.touchChat?.actionData || String(targetPageId); insertActionData.run(actionIdCounter, actionData); actionIdCounter++; } @@ -1277,10 +1208,8 @@ class TouchChatProcessor extends BaseProcessor { // Save tree metadata (root ID) if (tree.rootId) { - const insertMetadata = db.prepare( - "INSERT INTO tree_metadata (key, value) VALUES (?, ?)", - ); - insertMetadata.run("rootId", tree.rootId); + const insertMetadata = db.prepare('INSERT INTO tree_metadata (key, value) VALUES (?, ?)'); + insertMetadata.run('rootId', tree.rootId); } db.close(); @@ -1290,7 +1219,7 @@ class TouchChatProcessor extends BaseProcessor { const data = await readBinaryFromInput(dbPath); const zipData = await zip.writeFiles([ { - name: "vocab.c4v", + name: 'vocab.c4v', data, }, ]); @@ -1309,9 +1238,7 @@ class TouchChatProcessor extends BaseProcessor { * @param filePath - Path to the TouchChat .ce file * @returns Promise with extracted strings and any errors */ - async extractStringsWithMetadata( - filePath: string, - ): Promise { + async extractStringsWithMetadata(filePath: string): Promise { try { const tree = await this.loadIntoTree(filePath); const extractedMap = new Map(); @@ -1320,25 +1247,16 @@ class TouchChatProcessor extends BaseProcessor { Object.values(tree.pages).forEach((page) => { page.buttons.forEach((button) => { // Process button labels - if ( - button.label && - button.label.trim().length > 1 && - !isNumericOrEmpty(button.label) - ) { + if (button.label && button.label.trim().length > 1 && !isNumericOrEmpty(button.label)) { const key = button.label.trim().toLowerCase(); const vocabLocation: VocabLocation = { - table: "buttons", + table: 'buttons', id: parseInt(button.id) || 0, - column: "LABEL", + column: 'LABEL', casing: detectCasing(button.label), }; - this.addToExtractedMap( - extractedMap, - key, - button.label.trim(), - vocabLocation, - ); + this.addToExtractedMap(extractedMap, key, button.label.trim(), vocabLocation); } // Process button messages (if different from label) @@ -1350,18 +1268,13 @@ class TouchChatProcessor extends BaseProcessor { ) { const key = button.message.trim().toLowerCase(); const vocabLocation: VocabLocation = { - table: "buttons", + table: 'buttons', id: parseInt(button.id) || 0, - column: "MESSAGE", + column: 'MESSAGE', casing: detectCasing(button.message), }; - this.addToExtractedMap( - extractedMap, - key, - button.message.trim(), - vocabLocation, - ); + this.addToExtractedMap(extractedMap, key, button.message.trim(), vocabLocation); } }); }); @@ -1372,11 +1285,8 @@ class TouchChatProcessor extends BaseProcessor { return Promise.resolve({ errors: [ { - message: - error instanceof Error - ? error.message - : "Unknown extraction error", - step: "EXTRACT" as const, + message: error instanceof Error ? error.message : 'Unknown extraction error', + step: 'EXTRACT' as const, }, ], extractedStrings: [], @@ -1394,7 +1304,7 @@ class TouchChatProcessor extends BaseProcessor { async generateTranslatedDownload( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { try { // Build translation map from the provided data @@ -1402,7 +1312,7 @@ class TouchChatProcessor extends BaseProcessor { sourceStrings.forEach((sourceString) => { const translated = translatedStrings.find( - (ts) => ts.sourcestringid.toString() === sourceString.id.toString(), + (ts) => ts.sourcestringid.toString() === sourceString.id.toString() ); if (translated) { @@ -1415,7 +1325,7 @@ class TouchChatProcessor extends BaseProcessor { }); // Generate output path for TouchChat files - const outputPath = filePath.replace(/\.ce$/, "_translated.ce"); + const outputPath = filePath.replace(/\.ce$/, '_translated.ce'); // Use existing processTexts method await this.processTexts(filePath, translations, outputPath); @@ -1424,8 +1334,8 @@ class TouchChatProcessor extends BaseProcessor { } catch (error) { return Promise.reject( new Error( - `Failed to generate translated download: ${error instanceof Error ? error.message : "Unknown error"}`, - ), + `Failed to generate translated download: ${error instanceof Error ? error.message : 'Unknown error'}` + ) ); } } @@ -1436,10 +1346,7 @@ class TouchChatProcessor extends BaseProcessor { * @returns Promise with validation result */ async validate(filePath: string): Promise { - return await TouchChatValidator.validateFile( - filePath, - this.options.fileAdapter, - ); + return await TouchChatValidator.validateFile(filePath, this.options.fileAdapter); } /** @@ -1451,9 +1358,7 @@ class TouchChatProcessor extends BaseProcessor { * @param filePathOrBuffer - Path to TouchChat .ce file or buffer * @returns Promise resolving to symbol information for LLM processing */ - async extractSymbolsForLLM( - filePathOrBuffer: string | Buffer, - ): Promise { + async extractSymbolsForLLM(filePathOrBuffer: string | Buffer): Promise { const tree = await this.loadIntoTree(filePathOrBuffer); // Collect all buttons from all pages @@ -1490,20 +1395,18 @@ class TouchChatProcessor extends BaseProcessor { filePathOrBuffer: string | Uint8Array, llmTranslations: LLMLTranslationResult[], outputPath: string, - options?: { allowPartial?: boolean }, + options?: { allowPartial?: boolean } ): Promise { const { readBinaryFromInput } = this.options.fileAdapter; if (!isNodeRuntime()) { throw new Error( - "processLLMTranslations is only supported in Node.js environments for TouchChat files.", + 'processLLMTranslations is only supported in Node.js environments for TouchChat files.' ); } const tree = await this.loadIntoTree(filePathOrBuffer); // Validate translations using shared utility - const buttonIds = Object.values(tree.pages).flatMap((page) => - page.buttons.map((b) => b.id), - ); + const buttonIds = Object.values(tree.pages).flatMap((page) => page.buttons.map((b) => b.id)); validateTranslationResults(llmTranslations, buttonIds, options); // Create a map for quick lookup diff --git a/src/snap.ts b/src/snap.ts index 7a23245..6c93d67 100644 --- a/src/snap.ts +++ b/src/snap.ts @@ -5,7 +5,7 @@ */ // Processor class -export { SnapProcessor } from "./processors/snapProcessor"; +export { SnapProcessor } from './processors/snapProcessor'; // === Snap Helpers === export { @@ -23,4 +23,4 @@ export { type SnapPackagePath, type SnapUserInfo, type SnapUsageEntry, -} from "./processors/snap/helpers"; +} from './processors/snap/helpers'; diff --git a/src/touchchat.ts b/src/touchchat.ts index b4fc70f..75f76f2 100644 --- a/src/touchchat.ts +++ b/src/touchchat.ts @@ -5,11 +5,11 @@ */ // Processor class -export { TouchChatProcessor } from "./processors/touchchatProcessor"; +export { TouchChatProcessor } from './processors/touchchatProcessor'; // === TouchChat Helpers === export { getPageTokenImageMap, getAllowedImageEntries, openImage, -} from "./processors/touchchat/helpers"; +} from './processors/touchchat/helpers'; diff --git a/src/translation.ts b/src/translation.ts index 5734c24..1784421 100644 --- a/src/translation.ts +++ b/src/translation.ts @@ -20,7 +20,7 @@ export { type SymbolInfo, type ButtonForTranslation, type LLMLTranslationResult, -} from "./utilities/translation/translationProcessor"; +} from './utilities/translation/translationProcessor'; // Translation types -export { type TranslatedString, type SourceString } from "./core/baseProcessor"; +export { type TranslatedString, type SourceString } from './core/baseProcessor'; diff --git a/src/types/aac.ts b/src/types/aac.ts index 9066ffc..6bb0fcd 100644 --- a/src/types/aac.ts +++ b/src/types/aac.ts @@ -7,19 +7,19 @@ */ export enum ScanningSelectionMethod { /** Automatically advance through items at timed intervals (1 Switch) */ - AutoScan = "AutoScan", + AutoScan = 'AutoScan', /** Automatic scanning with overscan (two-stage scanning) */ - AutoScanWithOverscan = "AutoScanWithOverscan", + AutoScanWithOverscan = 'AutoScanWithOverscan', /** Hold switch to advance, release to select */ - HoldToAdvance = "HoldToAdvance", + HoldToAdvance = 'HoldToAdvance', /** Hold to advance with overscan */ - HoldToAdvanceWithOverscan = "HoldToAdvanceWithOverscan", + HoldToAdvanceWithOverscan = 'HoldToAdvanceWithOverscan', /** Tap switch to advance, tap again to select (Automatic) */ - TapToAdvance = "TapToAdvance", + TapToAdvance = 'TapToAdvance', /** Tap switch to advance, another switch to select (2 Switch Step Scan) */ - StepScan2Switch = "StepScan2Switch", + StepScan2Switch = 'StepScan2Switch', /** Tap switch 1 to advance, tap switch 1 again to select (1 Switch Step Scan) */ - StepScan1Switch = "StepScan1Switch", + StepScan1Switch = 'StepScan1Switch', } /** @@ -28,13 +28,13 @@ export enum ScanningSelectionMethod { */ export enum CellScanningOrder { /** Simple linear scan across rows (left-to-right, top-to-bottom) */ - SimpleScan = "SimpleScan", + SimpleScan = 'SimpleScan', /** Simple linear scan down columns (top-to-bottom, left-to-right) */ - SimpleScanColumnsFirst = "SimpleScanColumnsFirst", + SimpleScanColumnsFirst = 'SimpleScanColumnsFirst', /** Row-group scanning: highlight rows first, then cells within selected row */ - RowColumnScan = "RowColumnScan", + RowColumnScan = 'RowColumnScan', /** Column-group scanning: highlight columns first, then cells within selected column */ - ColumnRowScan = "ColumnRowScan", + ColumnRowScan = 'ColumnRowScan', } /** @@ -55,7 +55,7 @@ export interface ScanningConfig { /** Time in milliseconds to wait before auto-accepting selection */ dwellTime?: number; /** How the selection is accepted */ - acceptScanMethod?: "Switch" | "Timeout" | "Hold"; + acceptScanMethod?: 'Switch' | 'Timeout' | 'Hold'; /** Whether to factor in error correction effort (e.g., missed hits) */ errorCorrectionEnabled?: boolean; /** Maximum number of loops before the scan times out */ @@ -92,7 +92,7 @@ export interface AACButton { metadata?: string; }; // Extended properties for advanced platforms - contentType?: "Normal" | "AutoContent" | "Workspace" | "LiveCell"; + contentType?: 'Normal' | 'AutoContent' | 'Workspace' | 'LiveCell'; contentSubType?: string; image?: string; resolvedImageEntry?: string; // normalized zip path to resolved image, if present @@ -114,12 +114,7 @@ export interface AACButton { * Reduces scanning effort by grouping buttons */ scanBlock?: number; - visibility?: - | "Visible" - | "Hidden" - | "Disabled" - | "PointerAndTouchOnly" - | "Empty"; + visibility?: 'Visible' | 'Hidden' | 'Disabled' | 'PointerAndTouchOnly' | 'Empty'; directActivate?: boolean; audioDescription?: string; parameters?: { [key: string]: any }; @@ -184,7 +179,7 @@ export interface AACTreeMetadata { * Snap-specific metadata */ export interface SnapMetadata extends AACTreeMetadata { - format: "snap"; + format: 'snap'; dashboardId?: string; } @@ -192,7 +187,7 @@ export interface SnapMetadata extends AACTreeMetadata { * GridSet-specific metadata */ export interface GridSetMetadata extends AACTreeMetadata { - format: "gridset"; + format: 'gridset'; isSmartBox?: boolean; passwordProtected?: boolean; pictureSearchKeys?: string[]; @@ -210,7 +205,7 @@ export interface GridSetMetadata extends AACTreeMetadata { * Asterics-specific metadata */ export interface AstericsGridMetadata extends AACTreeMetadata { - format: "asterics"; + format: 'asterics'; hasGlobalGrid?: boolean; globalGridId?: string; } @@ -219,7 +214,7 @@ export interface AstericsGridMetadata extends AACTreeMetadata { * TouchChat-specific metadata */ export interface TouchChatMetadata extends AACTreeMetadata { - format: "touchchat"; + format: 'touchchat'; } export interface AACTree { diff --git a/src/utilities/analytics/history.ts b/src/utilities/analytics/history.ts index 62e25f3..682abcf 100644 --- a/src/utilities/analytics/history.ts +++ b/src/utilities/analytics/history.ts @@ -1,23 +1,20 @@ -import { dotNetTicksToDate } from "../../utils/dotnetTicks"; +import { dotNetTicksToDate } from '../../utils/dotnetTicks'; import { findGrid3Users, Grid3UserPath, readAllGrid3History as readAllGrid3HistoryImpl, readGrid3History as readGrid3HistoryImpl, readGrid3HistoryForUser as readGrid3HistoryForUserImpl, -} from "../../processors/gridset/helpers"; +} from '../../processors/gridset/helpers'; import { findSnapUsers, readSnapUsage as readSnapUsageImpl, readSnapUsageForUser as readSnapUsageForUserImpl, SnapUserInfo, -} from "../../processors/snap/helpers"; -import { - AACSemanticCategory, - AACSemanticIntent, -} from "../../core/treeStructure"; +} from '../../processors/snap/helpers'; +import { AACSemanticCategory, AACSemanticIntent } from '../../core/treeStructure'; -export type HistorySource = "Grid" | "Snap" | "OBL" | string; +export type HistorySource = 'Grid' | 'Snap' | 'OBL' | string; export interface HistoryOccurrence { timestamp: Date; @@ -33,7 +30,7 @@ export interface HistoryOccurrence { vocalization?: string; imageUrl?: string; actions?: any[]; // For OBL actions - type?: "button" | "action" | "utterance" | "note" | "other"; + type?: 'button' | 'action' | 'utterance' | 'note' | 'other'; // Semantic semantic alignment intent?: AACSemanticIntent | string; category?: AACSemanticCategory; @@ -81,14 +78,12 @@ export interface BatonExport { } const generateUuid = (): string => { - if (typeof globalThis.crypto?.randomUUID === "function") { + if (typeof globalThis.crypto?.randomUUID === 'function') { return globalThis.crypto.randomUUID(); } // RFC4122-ish fallback for Node without crypto.randomUUID - const hex = "0123456789abcdef"; - const bytes = Array.from({ length: 16 }, () => - Math.floor(Math.random() * 256), - ); + const hex = '0123456789abcdef'; + const bytes = Array.from({ length: 16 }, () => Math.floor(Math.random() * 256)); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const toHex = (b: number): string => hex[(b >> 4) & 0x0f] + hex[b & 0x0f]; @@ -97,16 +92,16 @@ const generateUuid = (): string => { toHex(bytes[1]) + toHex(bytes[2]) + toHex(bytes[3]) + - "-" + + '-' + toHex(bytes[4]) + toHex(bytes[5]) + - "-" + + '-' + toHex(bytes[6]) + toHex(bytes[7]) + - "-" + + '-' + toHex(bytes[8]) + toHex(bytes[9]) + - "-" + + '-' + toHex(bytes[10]) + toHex(bytes[11]) + toHex(bytes[12]) + @@ -123,7 +118,7 @@ export function exportHistoryToBaton( exportDate?: string | Date; encryption?: string; anonymousUUID?: string; - }, + } ): BatonExport { const exportDate = options?.exportDate instanceof Date @@ -143,9 +138,9 @@ export function exportHistoryToBaton( })); return { - version: options?.version || "1.0", + version: options?.version || '1.0', exportDate, - encryption: options?.encryption || "none", + encryption: options?.encryption || 'none', sentenceCount: sentences.length, sentences, }; @@ -154,13 +149,11 @@ export function exportHistoryToBaton( /** * Read Grid 3 phrase history from a history.sqlite database and tag entries with their source. */ -export async function readGrid3History( - historyDbPath: string, -): Promise { +export async function readGrid3History(historyDbPath: string): Promise { const history = await readGrid3HistoryImpl(historyDbPath); return history.map((e) => ({ ...e, - source: "Grid", + source: 'Grid', })); } @@ -169,12 +162,12 @@ export async function readGrid3History( */ export async function readGrid3HistoryForUser( userName: string, - langCode?: string, + langCode?: string ): Promise { const history = await readGrid3HistoryForUserImpl(userName, langCode); return history.map((e) => ({ ...e, - source: "Grid", + source: 'Grid', })); } @@ -183,17 +176,15 @@ export async function readGrid3HistoryForUser( */ export async function readAllGrid3History(): Promise { const history = await readAllGrid3HistoryImpl(); - return history.map((e) => ({ ...e, source: "Grid" })); + return history.map((e) => ({ ...e, source: 'Grid' })); } /** * Read Snap button usage from a pageset database and tag entries with source. */ -export async function readSnapUsage( - pagesetPath: string, -): Promise { +export async function readSnapUsage(pagesetPath: string): Promise { const usage = await readSnapUsageImpl(pagesetPath); - return usage.map((e) => ({ ...e, source: "Snap" })); + return usage.map((e) => ({ ...e, source: 'Snap' })); } /** @@ -201,12 +192,12 @@ export async function readSnapUsage( */ export async function readSnapUsageForUser( userId?: string, - packageNamePattern = "TobiiDynavox", + packageNamePattern = 'TobiiDynavox' ): Promise { const usage = await readSnapUsageForUserImpl(userId, packageNamePattern); return usage.map((e) => ({ ...e, - source: "Snap", + source: 'Snap', })); } @@ -229,7 +220,7 @@ export async function collectUnifiedHistory(): Promise { const gridHistory = await readAllGrid3History(); const users = await findSnapUsers(); const snapHistory = await Promise.all( - users.map(async (u) => await readSnapUsageForUser(u.userId)), + users.map(async (u) => await readSnapUsageForUser(u.userId)) ); return [...gridHistory, ...snapHistory.flat()]; } diff --git a/src/utilities/analytics/index.ts b/src/utilities/analytics/index.ts index c036b13..579711b 100644 --- a/src/utilities/analytics/index.ts +++ b/src/utilities/analytics/index.ts @@ -10,55 +10,55 @@ * @module */ -import { defaultFileAdapter, FileAdapter } from "../../utils/io"; +import { defaultFileAdapter, FileAdapter } from '../../utils/io'; // Always-available exports -export * from "./metrics/types"; -export * from "./metrics/effort"; -export * from "./utils/idGenerator"; +export * from './metrics/types'; +export * from './metrics/effort'; +export * from './utils/idGenerator'; // Export history functionality -export * from "./history"; +export * from './history'; // Export OBL logging support -export * from "./metrics/obl-types"; -export { OblUtil, OblAnonymizer } from "./metrics/obl"; +export * from './metrics/obl-types'; +export { OblUtil, OblAnonymizer } from './metrics/obl'; // Export core metrics calculator -export { MetricsCalculator } from "./metrics/core"; +export { MetricsCalculator } from './metrics/core'; // Export vocabulary and comparison analyzers -export { VocabularyAnalyzer } from "./metrics/vocabulary"; -export { SentenceAnalyzer } from "./metrics/sentence"; -export { ComparisonAnalyzer } from "./metrics/comparison"; -export { ReferenceLoader } from "./reference"; +export { VocabularyAnalyzer } from './metrics/vocabulary'; +export { SentenceAnalyzer } from './metrics/sentence'; +export { ComparisonAnalyzer } from './metrics/comparison'; +export { ReferenceLoader } from './reference'; /** * Get the default reference data path */ export function getReferenceDataPath(fileAdapter: FileAdapter): string { const { join } = fileAdapter; - return join(__dirname, "reference", "data"); + return join(__dirname, 'reference', 'data'); } /** * Check if reference data files exist */ export async function hasReferenceData( - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { pathExists, join } = fileAdapter; const dataPath = getReferenceDataPath(fileAdapter); const requiredFiles = [ - "core_lists.en.json", - "common_words.en.json", - "sentences.en.json", - "synonyms.en.json", - "fringe.en.json", + 'core_lists.en.json', + 'common_words.en.json', + 'sentences.en.json', + 'synonyms.en.json', + 'fringe.en.json', ]; const existingPaths = await Promise.all( - requiredFiles.map(async (file) => await pathExists(join(dataPath, file))), + requiredFiles.map(async (file) => await pathExists(join(dataPath, file))) ); return existingPaths.every((exists) => exists); } diff --git a/src/utilities/analytics/metrics/comparison.ts b/src/utilities/analytics/metrics/comparison.ts index 6419221..372cdb1 100644 --- a/src/utilities/analytics/metrics/comparison.ts +++ b/src/utilities/analytics/metrics/comparison.ts @@ -5,15 +5,12 @@ * analyze vocabulary differences, and generate CARE component scores. */ -import { MetricsResult, ButtonMetrics, ComparisonResult } from "./types"; -import { SentenceAnalyzer } from "./sentence"; -import { VocabularyAnalyzer } from "./vocabulary"; -import { - ReferenceLoader, - type ReferenceDataProvider, -} from "../reference/index"; -import { spellingEffort, predictionEffort } from "./effort"; -import { MetricsOptions } from "./types"; +import { MetricsResult, ButtonMetrics, ComparisonResult } from './types'; +import { SentenceAnalyzer } from './sentence'; +import { VocabularyAnalyzer } from './vocabulary'; +import { ReferenceLoader, type ReferenceDataProvider } from '../reference/index'; +import { spellingEffort, predictionEffort } from './effort'; +import { MetricsOptions } from './types'; export class ComparisonAnalyzer { private vocabAnalyzer: VocabularyAnalyzer; @@ -30,7 +27,7 @@ export class ComparisonAnalyzer { return word .toLowerCase() .trim() - .replace(/[.?!,]/g, ""); + .replace(/[.?!,]/g, ''); } /** @@ -42,7 +39,7 @@ export class ComparisonAnalyzer { options?: { includeSentences?: boolean; locale?: string; - } & Partial, + } & Partial ): Promise { // Create base result from target const baseResult = { ...targetResult }; @@ -108,7 +105,7 @@ export class ComparisonAnalyzer { targetResult, compareResult, overlappingWords, - options, + options ); // Analyze high/low effort words @@ -132,20 +129,16 @@ export class ComparisonAnalyzer { highEffortWords.sort((a, b) => { const targetBtnA = targetWords.get(a); const targetBtnB = targetWords.get(b); - const diffA = - (targetBtnA?.effort || 0) - (compareWords.get(a)?.effort || 0); - const diffB = - (targetBtnB?.effort || 0) - (compareWords.get(b)?.effort || 0); + const diffA = (targetBtnA?.effort || 0) - (compareWords.get(a)?.effort || 0); + const diffB = (targetBtnB?.effort || 0) - (compareWords.get(b)?.effort || 0); return diffB - diffA; }); lowEffortWords.sort((a, b) => { const targetBtnA = targetWords.get(a); const targetBtnB = targetWords.get(b); - const diffA = - (compareWords.get(a)?.effort || 0) - (targetBtnA?.effort || 0); - const diffB = - (compareWords.get(b)?.effort || 0) - (targetBtnB?.effort || 0); + const diffA = (compareWords.get(a)?.effort || 0) - (targetBtnA?.effort || 0); + const diffB = (compareWords.get(b)?.effort || 0) - (targetBtnB?.effort || 0); return diffB - diffA; }); @@ -153,14 +146,8 @@ export class ComparisonAnalyzer { let sentences: any[] = []; if (options?.includeSentences) { const testSentences = await this.referenceLoader.loadSentences(); - const targetSentences = this.sentenceAnalyzer.analyzeSentences( - targetResult, - testSentences, - ); - const compareSentences = this.sentenceAnalyzer.analyzeSentences( - compareResult, - testSentences, - ); + const targetSentences = this.sentenceAnalyzer.analyzeSentences(targetResult, testSentences); + const compareSentences = this.sentenceAnalyzer.analyzeSentences(compareResult, testSentences); sentences = targetSentences.map((ts, idx) => ({ sentence: ts.sentence, @@ -229,10 +216,7 @@ export class ComparisonAnalyzer { // Fringe vocabulary analysis const fringeWords = await this.analyzeFringe(targetWords, compareWords); - const commonFringeWords = await this.analyzeCommonFringe( - targetWords, - compareWords, - ); + const commonFringeWords = await this.analyzeCommonFringe(targetWords, compareWords); return { ...baseResult, @@ -292,8 +276,8 @@ export class ComparisonAnalyzer { options?: { includeSentences?: boolean; locale?: string; - } & Partial, - ): Promise { + } & Partial + ): Promise { // Load common words with baseline efforts (matching Ruby line 527-534) const commonWordsData = await this.referenceLoader.loadCommonWords(); const commonWords = new Map(); @@ -304,13 +288,13 @@ export class ComparisonAnalyzer { // Determine prediction settings (default: use common words efforts, not prediction) const usePrediction = options?.usePrediction || false; // Default FALSE (use common words) const predictionSelections = options?.predictionSelections || 1.5; - const debugMode = process.env.DEBUG_METRICS === "true"; + const debugMode = process.env.DEBUG_METRICS === 'true'; // Helper function to calculate fallback effort const getFallbackEffort = ( word: string, hasPrediction: boolean, - spellingBaseEffort?: number, + spellingBaseEffort?: number ): number => { const wordLower = word.toLowerCase(); @@ -322,12 +306,7 @@ export class ComparisonAnalyzer { // If usePrediction is true and prediction is available, use prediction if (usePrediction && hasPrediction && spellingBaseEffort !== undefined) { - return predictionEffort( - spellingBaseEffort, - 2.5, - predictionSelections, - 2, - ); + return predictionEffort(spellingBaseEffort, 2.5, predictionSelections, 2); } // Fallback to manual spelling (matching Ruby spelling_effort: 10 + word.length * 2.5) @@ -336,18 +315,16 @@ export class ComparisonAnalyzer { // Debug: Check settings const targetHasPrediction = - targetResult.has_dynamic_prediction && - targetResult.spelling_effort_base !== undefined; + targetResult.has_dynamic_prediction && targetResult.spelling_effort_base !== undefined; const _compareHasPrediction = - compareResult.has_dynamic_prediction && - compareResult.spelling_effort_base !== undefined; + compareResult.has_dynamic_prediction && compareResult.spelling_effort_base !== undefined; if (debugMode) { console.log(`\n🔍 DEBUG Fallback Effort Settings:`); console.log(` Common words loaded: ${commonWords.size}`); console.log(` usePrediction option: ${usePrediction}`); console.log(` Target has prediction capability: ${targetHasPrediction}`); console.log( - ` Target spelling_base: ${targetResult.spelling_effort_base?.toFixed(2) || "undefined"}`, + ` Target spelling_base: ${targetResult.spelling_effort_base?.toFixed(2) || 'undefined'}` ); } // Create word maps with normalized keys @@ -398,7 +375,7 @@ export class ComparisonAnalyzer { targetCoreEffort += getFallbackEffort( word, targetResult.has_dynamic_prediction || false, - targetResult.spelling_effort_base, + targetResult.spelling_effort_base ); } @@ -409,15 +386,13 @@ export class ComparisonAnalyzer { compCoreEffort += getFallbackEffort( word, compareResult.has_dynamic_prediction || false, - compareResult.spelling_effort_base, + compareResult.spelling_effort_base ); } }); - const avgCoreEffort = - allCoreWords.size > 0 ? targetCoreEffort / allCoreWords.size : 0; - const avgCompCoreEffort = - allCoreWords.size > 0 ? compCoreEffort / allCoreWords.size : 0; + const avgCoreEffort = allCoreWords.size > 0 ? targetCoreEffort / allCoreWords.size : 0; + const avgCompCoreEffort = allCoreWords.size > 0 ? compCoreEffort / allCoreWords.size : 0; // Calculate core component scores (matching Ruby lines 644-647) const coreScore = avgCoreEffort * 5.0; @@ -442,7 +417,7 @@ export class ComparisonAnalyzer { targetSentenceEffort += getFallbackEffort( word, targetResult.has_dynamic_prediction || false, - targetResult.spelling_effort_base, + targetResult.spelling_effort_base ); } @@ -452,7 +427,7 @@ export class ComparisonAnalyzer { compSentenceEffort += getFallbackEffort( word, compareResult.has_dynamic_prediction || false, - compareResult.spelling_effort_base, + compareResult.spelling_effort_base ); } }); @@ -468,8 +443,7 @@ export class ComparisonAnalyzer { : 0; const compAvgSentenceEffort = compSentenceEfforts.length > 0 - ? compSentenceEfforts.reduce((a, b) => a + b, 0) / - compSentenceEfforts.length + ? compSentenceEfforts.reduce((a, b) => a + b, 0) / compSentenceEfforts.length : 0; // Sentence component scores (matching Ruby line 665-668) @@ -495,8 +469,8 @@ export class ComparisonAnalyzer { getFallbackEffort( word, targetResult.has_dynamic_prediction || false, - targetResult.spelling_effort_base, - ), + targetResult.spelling_effort_base + ) ); } @@ -508,8 +482,8 @@ export class ComparisonAnalyzer { getFallbackEffort( word, compareResult.has_dynamic_prediction || false, - compareResult.spelling_effort_base, - ), + compareResult.spelling_effort_base + ) ); } }); @@ -520,8 +494,7 @@ export class ComparisonAnalyzer { : 0; const avgCompFringeEffort = compFringeEfforts.length > 0 - ? compFringeEfforts.reduce((a, b) => a + b, 0) / - compFringeEfforts.length + ? compFringeEfforts.reduce((a, b) => a + b, 0) / compFringeEfforts.length : 0; // Fringe component scores (matching Ruby line 684-687) @@ -547,13 +520,11 @@ export class ComparisonAnalyzer { const avgCommonFringeEffort = commonFringeEfforts.length > 0 - ? commonFringeEfforts.reduce((a, b) => a + b, 0) / - commonFringeEfforts.length + ? commonFringeEfforts.reduce((a, b) => a + b, 0) / commonFringeEfforts.length : 0; const avgCompCommonFringeEffort = compCommonFringeEfforts.length > 0 - ? compCommonFringeEfforts.reduce((a, b) => a + b, 0) / - compCommonFringeEfforts.length + ? compCommonFringeEfforts.reduce((a, b) => a + b, 0) / compCommonFringeEfforts.length : 0; // Common fringe component scores (matching Ruby line 702-705) @@ -565,11 +536,7 @@ export class ComparisonAnalyzer { const targetEffortTally = coreScore + sentenceScore + fringeScore + commonFringeScore + PLACEHOLDER; const compEffortTally = - compCoreScore + - compSentenceScore + - compFringeScore + - compCommonFringeScore + - PLACEHOLDER; + compCoreScore + compSentenceScore + compFringeScore + compCommonFringeScore + PLACEHOLDER; // Calculate final CARE scores (matching Ruby line 710-711) // res[:target_effort_score] = [0.0, 350.0 - target_effort_tally].max @@ -596,11 +563,10 @@ export class ComparisonAnalyzer { */ private async analyzeFringe( targetWords: Map, - compareWords: Map, + compareWords: Map ): Promise> { const fringe = await this.referenceLoader.loadFringe(); - const result: Array<{ word: string; effort: number; comp_effort: number }> = - []; + const result: Array<{ word: string; effort: number; comp_effort: number }> = []; fringe.forEach((word) => { const key = this.normalize(word); @@ -625,11 +591,10 @@ export class ComparisonAnalyzer { */ private async analyzeCommonFringe( targetWords: Map, - compareWords: Map, + compareWords: Map ): Promise> { const fringe = await this.referenceLoader.loadFringe(); - const result: Array<{ word: string; effort: number; comp_effort: number }> = - []; + const result: Array<{ word: string; effort: number; comp_effort: number }> = []; fringe.forEach((word) => { const key = this.normalize(word); diff --git a/src/utilities/analytics/metrics/core.ts b/src/utilities/analytics/metrics/core.ts index f049a2f..e26c49d 100644 --- a/src/utilities/analytics/metrics/core.ts +++ b/src/utilities/analytics/metrics/core.ts @@ -13,9 +13,9 @@ import { AACButton, AACSemanticCategory, AACScanType, -} from "../../../core/treeStructure"; -import { CellScanningOrder, ScanningSelectionMethod } from "../../../types/aac"; -import { ButtonMetrics, MetricsOptions, MetricsResult } from "./types"; +} from '../../../core/treeStructure'; +import { CellScanningOrder, ScanningSelectionMethod } from '../../../types/aac'; +import { ButtonMetrics, MetricsOptions, MetricsResult } from './types'; import { baseBoardEffort, distanceEffort, @@ -23,8 +23,8 @@ import { EFFORT_CONSTANTS, localScanEffort, scanningEffort, -} from "./effort"; -import { MorphologyEngine } from "../morphology"; +} from './effort'; +import { MorphologyEngine } from '../morphology'; interface ToVisitItem { board: AACPage; @@ -38,7 +38,7 @@ interface ToVisitItem { } export class MetricsCalculator { - private locale: string = "en"; + private locale: string = 'en'; /** * Main analysis function - calculates metrics for an AAC tree @@ -57,10 +57,10 @@ export class MetricsCalculator { rootBoard = Object.values(tree.pages).find((p: AACPage) => !p.parentId); } if (!rootBoard) { - throw new Error("No root board found in tree"); + throw new Error('No root board found in tree'); } - this.locale = tree.metadata?.locale || (rootBoard as any).locale || "en"; + this.locale = tree.metadata?.locale || (rootBoard as any).locale || 'en'; // Step 1: Build semantic/clone reference maps const { setRefs, setPcts } = this.buildReferenceMaps(tree); @@ -74,9 +74,9 @@ export class MetricsCalculator { if (btn.targetPageId && btn.semanticAction) { // Check for temporary_home in platformData or fallback const tempHome = - btn.semanticAction.platformData?.grid3?.parameters - ?.temporary_home || btn.semanticAction.fallback?.temporary_home; - if (tempHome === "prior") { + btn.semanticAction.platformData?.grid3?.parameters?.temporary_home || + btn.semanticAction.fallback?.temporary_home; + if (tempHome === 'prior') { startBoards.push(board); } else if (tempHome === true && btn.targetPageId) { const targetBoard = tree.getPage(btn.targetPageId); @@ -98,13 +98,7 @@ export class MetricsCalculator { // Analyze from each starting board startBoards.forEach((startBoard) => { - const result = this.analyzeFrom( - tree, - startBoard, - setPcts, - startBoard === rootBoard, - options, - ); + const result = this.analyzeFrom(tree, startBoard, setPcts, startBoard === rootBoard, options); result.buttons.forEach((btn) => { const existing = knownButtons.get(btn.label); @@ -123,13 +117,10 @@ export class MetricsCalculator { }); // Update buttons using dynamic spelling effort if applicable - const buttons = Array.from(knownButtons.values()).sort( - (a, b) => a.effort - b.effort, - ); + const buttons = Array.from(knownButtons.values()).sort((a, b) => a.effort - b.effort); // Expand morphological predictions from POS tags if enabled or auto-detected - const useSmartGrammar = - options.useSmartGrammar === true || this.treeHasPosTags(tree); + const useSmartGrammar = options.useSmartGrammar === true || this.treeHasPosTags(tree); if (useSmartGrammar) { this.expandMorphologicalPredictions(tree, options); } @@ -138,13 +129,11 @@ export class MetricsCalculator { const { wordFormMetrics, replacedLabels } = this.calculateWordFormMetrics( tree, buttons, - options, + options ); // Remove buttons that were replaced by lower-effort word forms - const filteredButtons = buttons.filter( - (btn) => !replacedLabels.has(btn.label.toLowerCase()), - ); + const filteredButtons = buttons.filter((btn) => !replacedLabels.has(btn.label.toLowerCase())); // Add word forms and re-sort filteredButtons.push(...wordFormMetrics); @@ -165,20 +154,12 @@ export class MetricsCalculator { // A page is prediction-capable if it has an AutoContent Prediction button reachable from root // We already have analyzed from rootBoard if (rootBoard) { - const rootAnalysis = this.analyzeFrom( - tree, - rootBoard, - setPcts, - true, - options, - ); + const rootAnalysis = this.analyzeFrom(tree, rootBoard, setPcts, true, options); // Scan reached pages for prediction slots for (const [pageId, _] of rootAnalysis.visitedBoardEfforts) { const page = tree.getPage(pageId); const hasPredictionSlot = page?.buttons.some( - (b) => - b.contentType === "AutoContent" && - b.contentSubType === "Prediction", + (b) => b.contentType === 'AutoContent' && b.contentSubType === 'Prediction' ); if (hasPredictionSlot) { hasDynamicPrediction = true; @@ -189,7 +170,7 @@ export class MetricsCalculator { } return { - analysis_version: "0.2", + analysis_version: '0.2', locale: this.locale, total_boards: Object.keys(tree.pages).length, total_buttons: totalButtons, @@ -212,7 +193,7 @@ export class MetricsCalculator { private identifySpellingMetrics( tree: AACTree, options: MetricsOptions, - setPcts: { [id: string]: number }, + setPcts: { [id: string]: number } ): { spellingPage: AACPage | null; spellingBaseEffort: number; @@ -233,11 +214,7 @@ export class MetricsCalculator { spellingPage = Object.values(tree.pages).find((p) => { const name = p.name.toLowerCase(); - return ( - name.includes("keyboard") || - name.includes("spelling") || - name.includes("abc") - ); + return name.includes('keyboard') || name.includes('spelling') || name.includes('abc'); }) || null; } @@ -262,33 +239,24 @@ export class MetricsCalculator { // Analyze specifically to find the lowest effort path to the spelling page const result = this.analyzeFrom(tree, rootBoard, setPcts, true, options); - const spellingBaseEffort = - result.visitedBoardEfforts.get(spellingPage.id) ?? 10; + const spellingBaseEffort = result.visitedBoardEfforts.get(spellingPage.id) ?? 10; // Calculate average effort of alphabetical buttons on that page const letters = spellingPage.buttons.filter( - (b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label), + (b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label) ); let avgEffort = 2.5; if (letters.length > 0) { // We need to calculate the effort of these buttons relative to the spelling page itself // (as if the user is already on the keyboard) - const keyboardResult = this.analyzeFrom( - tree, - spellingPage, - setPcts, - false, - options, - ); + const keyboardResult = this.analyzeFrom(tree, spellingPage, setPcts, false, options); const keyboardLetters = keyboardResult.buttons.filter( - (b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label), + (b) => b.label.length === 1 && /[a-zA-Z]/.test(b.label) ); if (keyboardLetters.length > 0) { - avgEffort = - keyboardLetters.reduce((sum, b) => sum + b.effort, 0) / - keyboardLetters.length; + avgEffort = keyboardLetters.reduce((sum, b) => sum + b.effort, 0) / keyboardLetters.length; } } @@ -339,7 +307,7 @@ export class MetricsCalculator { Object.entries(setRefs).forEach(([id, count]) => { // Extract location from ID (Ruby uses id.split(/-/)[1]) - const parts = id.split("-"); + const parts = id.split('-'); if (parts.length >= 2) { const loc = parts[1]; const cellCount = cellRefs[loc] || totalBoards; @@ -364,7 +332,7 @@ export class MetricsCalculator { board: AACPage, currentRowIndex: number, currentColIndex: number, - priorScanBlocks: Set, + priorScanBlocks: Set ): number { // Block scanning: count unique scan blocks before current position // Reuse the priorScanBlocks set from the parent scope @@ -372,15 +340,12 @@ export class MetricsCalculator { const row = board.grid[r]; if (!row) continue; for (let c = 0; c < row.length; c++) { - if (r === currentRowIndex && c === currentColIndex) - return priorScanBlocks.size; + if (r === currentRowIndex && c === currentColIndex) return priorScanBlocks.size; const btn = row[c]; if (btn && (btn.label || btn.id).length > 0) { const block = btn.scanBlock || - (btn.scanBlocks && btn.scanBlocks.length > 0 - ? btn.scanBlocks[0] - : null); + (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null); if (block !== null) priorScanBlocks.add(block); } } @@ -396,7 +361,7 @@ export class MetricsCalculator { brd: AACPage, setPcts: { [id: string]: number }, _isRoot: boolean, - options: MetricsOptions = {}, + options: MetricsOptions = {} ): { buttons: ButtonMetrics[]; levels: { [level: number]: ButtonMetrics[] }; @@ -420,14 +385,7 @@ export class MetricsCalculator { while (toVisit.length > 0) { const item = toVisit.shift(); if (!item) break; - const { - board, - level, - entryX, - entryY, - priorEffort = 0, - temporaryHomeId, - } = item; + const { board, level, entryX, entryY, priorEffort = 0, temporaryHomeId } = item; // Skip if already visited at a lower level with equal or better prior effort // Skip if already visited at a strictly lower level @@ -453,13 +411,10 @@ export class MetricsCalculator { if (!btn) return; if (btn.clone_id && setPcts[btn.clone_id]) { - reuseDiscount += - EFFORT_CONSTANTS.REUSED_CLONE_FROM_OTHER_BONUS * - setPcts[btn.clone_id]; + reuseDiscount += EFFORT_CONSTANTS.REUSED_CLONE_FROM_OTHER_BONUS * setPcts[btn.clone_id]; } else if (btn.semantic_id && setPcts[btn.semantic_id]) { reuseDiscount += - EFFORT_CONSTANTS.REUSED_SEMANTIC_FROM_OTHER_BONUS * - setPcts[btn.semantic_id]; + EFFORT_CONSTANTS.REUSED_SEMANTIC_FROM_OTHER_BONUS * setPcts[btn.semantic_id]; } }); }); @@ -503,14 +458,12 @@ export class MetricsCalculator { let buttonEffort = boardEffort; // Debug for specific button (disabled for production) - const debugSpecificButton = btn.label === "$938c2cc0dc"; + const debugSpecificButton = btn.label === '$938c2cc0dc'; if (debugSpecificButton) { console.log( - `\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`, - ); - console.log( - ` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`, + `\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:` ); + console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`); console.log(` Current level: ${level}`); console.log(` Prior positions: ${priorItems}`); console.log(` Starting effort: ${buttonEffort.toFixed(6)}`); @@ -519,18 +472,14 @@ export class MetricsCalculator { // Apply semantic_id discounts if (btn.semantic_id && boardPcts[btn.semantic_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / - boardPcts[btn.semantic_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id]; const old = buttonEffort; buttonEffort = Math.min(buttonEffort, buttonEffort * discount); if (debugSpecificButton) console.log( - ` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`, + ` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})` ); - } else if ( - btn.semantic_id && - boardPcts[`upstream-${btn.semantic_id}`] - ) { + } else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) { const discount = EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT / boardPcts[`upstream-${btn.semantic_id}`]; @@ -538,15 +487,14 @@ export class MetricsCalculator { buttonEffort = Math.min(buttonEffort, buttonEffort * discount); if (debugSpecificButton) console.log( - ` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`, + ` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})` ); } // Apply clone_id discounts if (btn.clone_id && boardPcts[btn.clone_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / - boardPcts[btn.clone_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id]; buttonEffort = Math.min(buttonEffort, buttonEffort * discount); } else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) { const discount = @@ -563,42 +511,31 @@ export class MetricsCalculator { btn, rowIndex, colIndex, - scanningConfig, + scanningConfig ); // Determine effective costs based on selection method - let currentStepCost = - options.scanStepCost ?? EFFORT_CONSTANTS.SCAN_STEP_COST; + let currentStepCost = options.scanStepCost ?? EFFORT_CONSTANTS.SCAN_STEP_COST; const currentSelectionCost = options.scanSelectionCost ?? EFFORT_CONSTANTS.SCAN_SELECTION_COST; // Step Scan 2 Switch: Every step is a physical selection with Switch 1 - if ( - scanningConfig?.selectionMethod === - ScanningSelectionMethod.StepScan2Switch - ) { + if (scanningConfig?.selectionMethod === ScanningSelectionMethod.StepScan2Switch) { // The cost of moving is now a selection cost currentStepCost = currentSelectionCost; } else if ( - scanningConfig?.selectionMethod === - ScanningSelectionMethod.StepScan1Switch + scanningConfig?.selectionMethod === ScanningSelectionMethod.StepScan1Switch ) { // Single switch step scan: every step is a physical selection currentStepCost = currentSelectionCost; } - let sEffort = scanningEffort( - steps, - selections, - currentStepCost, - currentSelectionCost, - ); + let sEffort = scanningEffort(steps, selections, currentStepCost, currentSelectionCost); // Factor in error correction if enabled if (scanningConfig?.errorCorrectionEnabled) { const errorRate = - scanningConfig.errorRate ?? - EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE; + scanningConfig.errorRate ?? EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE; // A "miss" results in needing to wait for a loop (or part of one) // We model this as errorRate * (loopSteps * stepCost) const retryPenalty = loopSteps * currentStepCost; @@ -608,13 +545,11 @@ export class MetricsCalculator { // Apply discounts to scanning effort (similar to touch) if (btn.semantic_id && boardPcts[btn.semantic_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / - boardPcts[btn.semantic_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id]; sEffort = Math.min(sEffort, sEffort * discount); } else if (btn.clone_id && boardPcts[btn.clone_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / - boardPcts[btn.clone_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id]; sEffort = Math.min(sEffort, sEffort * discount); } @@ -627,8 +562,7 @@ export class MetricsCalculator { if (btn.semantic_id) { if (boardPcts[btn.semantic_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / - boardPcts[btn.semantic_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id]; distance = Math.min(distance, distance * discount); } else if (boardPcts[`upstream-${btn.semantic_id}`]) { const discount = @@ -645,8 +579,7 @@ export class MetricsCalculator { if (btn.clone_id) { if (boardPcts[btn.clone_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / - boardPcts[btn.clone_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id]; distance = Math.min(distance, distance * discount); } else if (boardPcts[`upstream-${btn.clone_id}`]) { const discount = @@ -655,8 +588,7 @@ export class MetricsCalculator { distance = Math.min(distance, distance * discount); } else if (level > 0 && setPcts[btn.clone_id]) { const discount = - EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / - setPcts[btn.clone_id]; + EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id]; distance = Math.min(distance, distance * discount); } } @@ -665,8 +597,7 @@ export class MetricsCalculator { // Add visual scan or local scan effort if ( - distance > - EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN || + distance > EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN || (entryX === 1.0 && entryY === 1.0) ) { buttonEffort += visualScanEffort(priorItems); @@ -694,14 +625,11 @@ export class MetricsCalculator { // The visitedBoardIds map stores the *lowest* level a board was visited. // If it's already in the map, it means we've processed it or scheduled it at a lower level. if (visitedBoardIds.get(nextBoard.id) === undefined) { - const changeEffort = - EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT; + const changeEffort = EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT; const tempHomeId = - btn.semanticAction?.platformData?.grid3?.parameters - ?.temporary_home === "prior" + btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior' ? board.id - : btn.semanticAction?.platformData?.grid3?.parameters - ?.temporary_home === true + : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true ? btn.targetPageId : temporaryHomeId; @@ -721,29 +649,26 @@ export class MetricsCalculator { // Track word if it speaks or adds to sentence const isSpeak = - btn.semanticAction?.category === - AACSemanticCategory.COMMUNICATION && !btn.targetPageId; // Must not be a navigation button + btn.semanticAction?.category === AACSemanticCategory.COMMUNICATION && !btn.targetPageId; // Must not be a navigation button const addToSentence = - btn.semanticAction?.platformData?.grid3?.parameters - ?.add_to_sentence || + btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence || btn.semanticAction?.fallback?.add_to_sentence; if (isSpeak || addToSentence) { let finalEffort = buttonEffort; // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350) - const changeEffort = - EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT; + const changeEffort = EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT; if (btn.clone_id && boardPcts[btn.clone_id]) { const discount = Math.min( changeEffort, - (changeEffort * 0.3) / boardPcts[btn.clone_id], + (changeEffort * 0.3) / boardPcts[btn.clone_id] ); finalEffort -= discount; } else if (btn.semantic_id && boardPcts[btn.semantic_id]) { const discount = Math.min( changeEffort, - (changeEffort * 0.5) / boardPcts[btn.semantic_id], + (changeEffort * 0.5) / boardPcts[btn.semantic_id] ); finalEffort -= discount; } @@ -779,10 +704,7 @@ export class MetricsCalculator { // Calculate total_buttons as sum of all button counts (matching Ruby line 136) // Ruby: total_buttons: buttons.map{|b| b[:count] || 1}.sum - const calculatedTotalButtons = buttons.reduce( - (sum, btn) => sum + (btn.count || 1), - 0, - ); + const calculatedTotalButtons = buttons.reduce((sum, btn) => sum + (btn.count || 1), 0); return { buttons, @@ -795,10 +717,7 @@ export class MetricsCalculator { /** * Calculate what percentage of links to this board match semantic_id/clone_id */ - private calculateBoardLinkPercentages( - tree: AACTree, - board: AACPage, - ): { [id: string]: number } { + private calculateBoardLinkPercentages(tree: AACTree, board: AACPage): { [id: string]: number } { const boardPcts: { [id: string]: number } = {}; let totalLinks = 0; @@ -815,12 +734,10 @@ export class MetricsCalculator { // Also count IDs present on the source board that links to this one sourceBoard.semantic_ids?.forEach((id: string) => { - boardPcts[`upstream-${id}`] = - (boardPcts[`upstream-${id}`] || 0) + 1; + boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1; }); sourceBoard.clone_ids?.forEach((id: string) => { - boardPcts[`upstream-${id}`] = - (boardPcts[`upstream-${id}`] || 0) + 1; + boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1; }); } }); @@ -833,7 +750,7 @@ export class MetricsCalculator { }); } - boardPcts["all"] = totalLinks; + boardPcts['all'] = totalLinks; return boardPcts; } @@ -845,7 +762,7 @@ export class MetricsCalculator { for (const page of Object.values(tree.pages)) { for (const row of page.grid) { for (const btn of row) { - if (btn?.pos && btn.pos !== "Unknown" && btn.pos !== "Ignore") { + if (btn?.pos && btn.pos !== 'Unknown' && btn.pos !== 'Ignore') { return true; } } @@ -862,95 +779,92 @@ export class MetricsCalculator { * button's predictions array. This is done as a pre-processing step * before calculateWordFormMetrics assigns effort to each form. */ - private expandMorphologicalPredictions( - tree: AACTree, - options: MetricsOptions, - ): void { - const locale = options.morphologyLocale || "en-gb"; + private expandMorphologicalPredictions(tree: AACTree, options: MetricsOptions): void { + const locale = options.morphologyLocale || 'en-gb'; const morph = new MorphologyEngine(locale); // Words that should never be POS-inferred (function words, determiners, etc.) const skipInference = new Set([ - "a", - "an", - "the", - "to", - "in", - "on", - "at", - "of", - "for", - "and", - "or", - "but", - "not", - "no", - "yes", - "is", - "am", - "are", - "was", - "were", - "be", - "been", - "being", - "has", - "have", - "had", - "do", - "does", - "did", - "will", - "would", - "could", - "should", - "shall", - "may", - "might", - "can", - "must", - "with", - "from", - "by", - "up", - "down", - "out", - "off", - "over", - "under", - "again", - "then", - "than", - "so", - "if", - "when", - "where", - "how", - "what", - "who", - "which", - "that", - "this", - "these", - "those", - "here", - "there", - "now", - "very", - "just", - "more", - "also", - "too", - "please", - "thank", - "hi", - "hello", - "bye", - "goodbye", - "okay", - "oh", - "wow", - "sorry", + 'a', + 'an', + 'the', + 'to', + 'in', + 'on', + 'at', + 'of', + 'for', + 'and', + 'or', + 'but', + 'not', + 'no', + 'yes', + 'is', + 'am', + 'are', + 'was', + 'were', + 'be', + 'been', + 'being', + 'has', + 'have', + 'had', + 'do', + 'does', + 'did', + 'will', + 'would', + 'could', + 'should', + 'shall', + 'may', + 'might', + 'can', + 'must', + 'with', + 'from', + 'by', + 'up', + 'down', + 'out', + 'off', + 'over', + 'under', + 'again', + 'then', + 'than', + 'so', + 'if', + 'when', + 'where', + 'how', + 'what', + 'who', + 'which', + 'that', + 'this', + 'these', + 'those', + 'here', + 'there', + 'now', + 'very', + 'just', + 'more', + 'also', + 'too', + 'please', + 'thank', + 'hi', + 'hello', + 'bye', + 'goodbye', + 'okay', + 'oh', + 'wow', + 'sorry', ]); for (const page of Object.values(tree.pages)) { @@ -965,15 +879,11 @@ export class MetricsCalculator { // they are clearly nouns (e.g., "bird", "tree", "cloud"). // Strategy: check irregular tables first for confident POS, // then fall back to Noun for single-word content labels. - if (!pos || pos === "Unknown" || pos === "Ignore") { + if (!pos || pos === 'Unknown' || pos === 'Ignore') { const lower = btn.label.toLowerCase(); // Skip function words and multi-word labels - if ( - !skipInference.has(lower) && - !lower.includes(" ") && - lower.length > 1 - ) { + if (!skipInference.has(lower) && !lower.includes(' ') && lower.length > 1) { // Check irregular tables for confident POS assignment const inferredPOS = morph.inferPOS(lower); if (inferredPOS) { @@ -982,13 +892,13 @@ export class MetricsCalculator { } else { // Default to Noun for untagged content words. // This generates plurals (e.g., bird → birds, tree → trees). - pos = "Noun"; - btn.pos = "Noun"; + pos = 'Noun'; + btn.pos = 'Noun'; } } } - if (!pos || pos === "Unknown" || pos === "Ignore") continue; + if (!pos || pos === 'Unknown' || pos === 'Ignore') continue; const forms = morph.inflect(btn.label, pos); if (forms.length > 0) { @@ -1020,7 +930,7 @@ export class MetricsCalculator { private calculateWordFormMetrics( tree: AACTree, buttons: ButtonMetrics[], - _options: MetricsOptions = {}, + _options: MetricsOptions = {} ): { wordFormMetrics: ButtonMetrics[]; replacedLabels: Set } { const wordFormMetrics: ButtonMetrics[] = []; const replacedLabels = new Set(); @@ -1038,7 +948,7 @@ export class MetricsCalculator { row.forEach((btn: AACButton | null) => { if (!btn || !btn.label) return; const lower = btn.label.toLowerCase(); - if (btn.pos && btn.pos !== "Unknown" && btn.pos !== "Ignore") { + if (btn.pos && btn.pos !== 'Unknown' && btn.pos !== 'Ignore') { treePosMap.set(lower, btn.pos); } if (btn.predictions && btn.predictions.length > 0) { @@ -1055,7 +965,7 @@ export class MetricsCalculator { // propagate the POS tag so it's available in the output. buttons.forEach((btn) => { const lower = btn.label.toLowerCase(); - if (!btn.pos || btn.pos === "Unknown" || btn.pos === "Ignore") { + if (!btn.pos || btn.pos === 'Unknown' || btn.pos === 'Ignore') { const treePos = treePosMap.get(lower); if (treePos) btn.pos = treePos; } @@ -1083,9 +993,7 @@ export class MetricsCalculator { // These require an extra confirmation tap from the user. Smart grammar // morphology outcomes are generated automatically and need no extra tap. const suggestWordsSet = new Set( - ((btn.parameters?.predictions || []) as string[]).map((w) => - w.toLowerCase(), - ), + ((btn.parameters?.predictions || []) as string[]).map((w) => w.toLowerCase()) ); // Calculate effort for each word form @@ -1102,8 +1010,7 @@ export class MetricsCalculator { // Using similar logic to button scanning effort const predictionPriorItems = predictionRowIndex * predictionsGridCols + predictionColIndex; - const predictionSelectionEffort = - visualScanEffort(predictionPriorItems); + const predictionSelectionEffort = visualScanEffort(predictionPriorItems); // Add confirmation cost for Suggest Words outcomes only. // Suggest Words requires an explicit tap on the prediction bar, @@ -1114,9 +1021,7 @@ export class MetricsCalculator { // Word form effort = parent button's cumulative effort + selection effort + confirmation const wordFormEffort = - parentMetrics.effort + - predictionSelectionEffort + - suggestWordsConfirmation; + parentMetrics.effort + predictionSelectionEffort + suggestWordsConfirmation; // Check if this word already exists as a regular button const existingBtn = existingLabels.get(wordFormLower); @@ -1157,8 +1062,8 @@ export class MetricsCalculator { console.log( `📝 Calculated ${wordFormMetrics.length} word form metrics` + (replacedLabels.size > 0 - ? ` (${replacedLabels.size} replaced higher-effort buttons: ${Array.from(replacedLabels).join(", ")})` - : ""), + ? ` (${replacedLabels.size} replaced higher-effort buttons: ${Array.from(replacedLabels).join(', ')})` + : '') ); return { wordFormMetrics, replacedLabels }; @@ -1195,7 +1100,7 @@ export class MetricsCalculator { btn: AACButton, rowIndex: number, colIndex: number, - overrideConfig?: any, + overrideConfig?: any ): { steps: number; selections: number; loopSteps: number } { const config = overrideConfig || board.scanningConfig; // Determine scanning type from local scanType or scanningConfig @@ -1203,14 +1108,10 @@ export class MetricsCalculator { if (config?.cellScanningOrder) { const order = config.cellScanningOrder; // String matching for CellScanningOrder - if (order === CellScanningOrder.RowColumnScan) - type = AACScanType.ROW_COLUMN; - else if (order === CellScanningOrder.ColumnRowScan) - type = AACScanType.COLUMN_ROW; - else if (order === CellScanningOrder.SimpleScanColumnsFirst) - type = AACScanType.COLUMN_ROW; - else if (order === CellScanningOrder.SimpleScan) - type = AACScanType.LINEAR; + if (order === CellScanningOrder.RowColumnScan) type = AACScanType.ROW_COLUMN; + else if (order === CellScanningOrder.ColumnRowScan) type = AACScanType.COLUMN_ROW; + else if (order === CellScanningOrder.SimpleScanColumnsFirst) type = AACScanType.COLUMN_ROW; + else if (order === CellScanningOrder.SimpleScan) type = AACScanType.LINEAR; } // Force block scan if enabled in config @@ -1221,10 +1122,7 @@ export class MetricsCalculator { if (isBlockScan) { const blockId = - btn.scanBlock || - (btn.scanBlocks && btn.scanBlocks.length > 0 - ? btn.scanBlocks[0] - : null); + btn.scanBlock || (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null); // If no block assigned, treat as its own block at the end (fallback) if (blockId === null) { @@ -1277,7 +1175,7 @@ export class MetricsCalculator { for (let r = 0; r < board.grid.length; r++) { for (let c = 0; c < board.grid[r].length; c++) { const b = board.grid[r][c]; - if (b && (b.label || "").length > 0) { + if (b && (b.label || '').length > 0) { totalVisible++; if (!found) { if (b === btn) { diff --git a/src/utilities/analytics/metrics/effort.ts b/src/utilities/analytics/metrics/effort.ts index eba0fce..406460e 100644 --- a/src/utilities/analytics/metrics/effort.ts +++ b/src/utilities/analytics/metrics/effort.ts @@ -83,12 +83,10 @@ export function distanceEffort( x: number, y: number, entryX: number = 1.0, - entryY: number = 1.0, + entryY: number = 1.0 ): number { const distance = Math.sqrt(Math.pow(x - entryX, 2) + Math.pow(y - entryY, 2)); - return ( - (distance / EFFORT_CONSTANTS.SQRT2) * EFFORT_CONSTANTS.DISTANCE_MULTIPLIER - ); + return (distance / EFFORT_CONSTANTS.SQRT2) * EFFORT_CONSTANTS.DISTANCE_MULTIPLIER; } /** @@ -102,7 +100,7 @@ export function distanceEffort( export function spellingEffort( word: string, entryEffort: number = 10, - perLetterEffort: number = 2.5, + perLetterEffort: number = 2.5 ): number { return entryEffort + word.length * perLetterEffort; } @@ -125,7 +123,7 @@ export function predictionEffort( entryEffort: number = 10, perLetterEffort: number = 2.5, avgSelections: number = 1.5, - lettersToType: number = 2, + lettersToType: number = 2 ): number { // Cost to navigate to keyboard + type first few letters + select from predictions const typingCost = lettersToType * perLetterEffort; @@ -142,11 +140,7 @@ export function predictionEffort( * @param buttonCount - Number of visible buttons * @returns Base board effort score */ -export function baseBoardEffort( - rows: number, - cols: number, - buttonCount: number, -): number { +export function baseBoardEffort(rows: number, cols: number, buttonCount: number): number { const sizeEffort = buttonSizeEffort(rows, cols); const fieldEffort = fieldSizeEffort(buttonCount); return sizeEffort + fieldEffort; @@ -159,10 +153,7 @@ export function baseBoardEffort( * @param reuseDiscount - Calculated reuse discount * @returns Adjusted board effort */ -export function applyReuseDiscount( - boardEffort: number, - reuseDiscount: number, -): number { +export function applyReuseDiscount(boardEffort: number, reuseDiscount: number): number { return Math.max(0, boardEffort - reuseDiscount); } @@ -177,23 +168,20 @@ export function applyReuseDiscount( export function calculateButtonEffort( baseEffort: number, boardPcts: { [id: string]: number }, - button: { semantic_id?: string; clone_id?: string }, + button: { semantic_id?: string; clone_id?: string } ): number { let buttonEffort = baseEffort; // Apply discounts for semantic_id if (button.semantic_id && boardPcts[button.semantic_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / - boardPcts[button.semantic_id]; + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.semantic_id]; buttonEffort = Math.min(buttonEffort, buttonEffort * discount); } // Apply discounts for clone_id if (button.clone_id && boardPcts[button.clone_id]) { - const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / - boardPcts[button.clone_id]; + const discount = EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.clone_id]; buttonEffort = Math.min(buttonEffort, buttonEffort * discount); } @@ -213,7 +201,7 @@ export function calculateDistanceWithDiscounts( distance: number, boardPcts: { [id: string]: number }, button: { semantic_id?: string; clone_id?: string }, - setPcts: { [id: string]: number }, + setPcts: { [id: string]: number } ): number { let adjustedDistance = distance; @@ -221,20 +209,12 @@ export function calculateDistanceWithDiscounts( if (button.semantic_id) { if (boardPcts[button.semantic_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / - boardPcts[button.semantic_id]; - adjustedDistance = Math.min( - adjustedDistance, - adjustedDistance * discount, - ); + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.semantic_id]; + adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount); } else if (setPcts[button.semantic_id]) { const discount = - EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT / - setPcts[button.semantic_id]; - adjustedDistance = Math.min( - adjustedDistance, - adjustedDistance * discount, - ); + EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT / setPcts[button.semantic_id]; + adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount); } } @@ -242,20 +222,12 @@ export function calculateDistanceWithDiscounts( if (button.clone_id) { if (boardPcts[button.clone_id]) { const discount = - EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / - boardPcts[button.clone_id]; - adjustedDistance = Math.min( - adjustedDistance, - adjustedDistance * discount, - ); + EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[button.clone_id]; + adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount); } else if (setPcts[button.clone_id]) { const discount = - EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / - setPcts[button.clone_id]; - adjustedDistance = Math.min( - adjustedDistance, - adjustedDistance * discount, - ); + EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[button.clone_id]; + adjustedDistance = Math.min(adjustedDistance, adjustedDistance * discount); } } @@ -295,7 +267,7 @@ export function scanningEffort( steps: number, selections: number, stepCost: number = EFFORT_CONSTANTS.SCAN_STEP_COST, - selectionCost: number = EFFORT_CONSTANTS.SCAN_SELECTION_COST, + selectionCost: number = EFFORT_CONSTANTS.SCAN_SELECTION_COST ): number { return steps * stepCost + selections * selectionCost; } diff --git a/src/utilities/analytics/metrics/index.ts b/src/utilities/analytics/metrics/index.ts index 50cf540..03a09b6 100644 --- a/src/utilities/analytics/metrics/index.ts +++ b/src/utilities/analytics/metrics/index.ts @@ -8,10 +8,10 @@ * - Comparative analysis between board sets */ -export { MetricsCalculator } from "./core"; -export { VocabularyAnalyzer } from "./vocabulary"; -export { SentenceAnalyzer } from "./sentence"; -export { ComparisonAnalyzer } from "./comparison"; +export { MetricsCalculator } from './core'; +export { VocabularyAnalyzer } from './vocabulary'; +export { SentenceAnalyzer } from './sentence'; +export { ComparisonAnalyzer } from './comparison'; -export * from "./types"; -export * from "./effort"; +export * from './types'; +export * from './effort'; diff --git a/src/utilities/analytics/metrics/obl-types.ts b/src/utilities/analytics/metrics/obl-types.ts index b06bbcc..f13be02 100644 --- a/src/utilities/analytics/metrics/obl-types.ts +++ b/src/utilities/analytics/metrics/obl-types.ts @@ -15,7 +15,7 @@ export interface OblAction { export interface OblEventBase { id: string; timestamp: string; // ISO 8601 - type: "button" | "action" | "utterance" | "note" | "other" | string; + type: 'button' | 'action' | 'utterance' | 'note' | 'other' | string; locale?: string; geo?: [number, number, number?]; // lat, long, alt location_id?: string; @@ -29,7 +29,7 @@ export interface OblEventBase { } export interface OblButtonEvent extends OblEventBase { - type: "button"; + type: 'button'; label: string; spoken: boolean; button_id?: string; @@ -40,7 +40,7 @@ export interface OblButtonEvent extends OblEventBase { } export interface OblActionEvent extends OblEventBase { - type: "action"; + type: 'action'; action: string; destination_board_id?: string; text?: string; @@ -48,7 +48,7 @@ export interface OblActionEvent extends OblEventBase { } export interface OblUtteranceEvent extends OblEventBase { - type: "utterance"; + type: 'utterance'; text: string; buttons?: Array<{ id?: string; @@ -61,7 +61,7 @@ export interface OblUtteranceEvent extends OblEventBase { } export interface OblNoteEvent extends OblEventBase { - type: "note"; + type: 'note'; text: string; author_name?: string; author_email?: string; @@ -77,7 +77,7 @@ export type OblEvent = export interface OblSession { id: string; - type: "log" | string; + type: 'log' | string; started: string; // ISO 8601 ended: string; // ISO 8601 device_id?: string; @@ -88,7 +88,7 @@ export interface OblSession { } export interface OblFile { - format: "open-board-log-0.1" | string; + format: 'open-board-log-0.1' | string; user_id: string; user_name?: string; source?: string; diff --git a/src/utilities/analytics/metrics/obl.ts b/src/utilities/analytics/metrics/obl.ts index 85bf07c..2a3e1cc 100644 --- a/src/utilities/analytics/metrics/obl.ts +++ b/src/utilities/analytics/metrics/obl.ts @@ -6,12 +6,9 @@ import { OblUtteranceEvent, OblActionEvent, OblNoteEvent, -} from "./obl-types"; -import { HistoryEntry, HistoryOccurrence } from "../history"; -import { - AACSemanticIntent, - AACSemanticCategory, -} from "../../../core/treeStructure"; +} from './obl-types'; +import { HistoryEntry, HistoryOccurrence } from '../history'; +import { AACSemanticIntent, AACSemanticCategory } from '../../../core/treeStructure'; /** * .obl (Open Board Logging) Utility @@ -26,8 +23,8 @@ export class OblUtil { static parse(json: string): OblFile { // Remove potential comment at the start let cleanJson = json.trim(); - if (cleanJson.startsWith("/*")) { - const endComment = cleanJson.indexOf("*/"); + if (cleanJson.startsWith('/*')) { + const endComment = cleanJson.indexOf('*/'); if (endComment !== -1) { cleanJson = cleanJson.substring(endComment + 2).trim(); } @@ -52,7 +49,7 @@ export class OblUtil { */ static toHistoryEntries(obl: OblFile): HistoryEntry[] { const entries: HistoryEntry[] = []; - const source = obl.source || "OBL"; + const source = obl.source || 'OBL'; // OBL is session-based and event-based. // HistoryEntry is content-based with occurrences. @@ -61,7 +58,7 @@ export class OblUtil { for (const session of obl.sessions) { for (const event of session.events) { - let content = ""; + let content = ''; const evtAny = event as any; const occurrence: HistoryOccurrence = { timestamp: new Date(event.timestamp), @@ -69,7 +66,7 @@ export class OblUtil { pageId: evtAny.board_id || null, latitude: event.geo?.[0] || null, longitude: event.geo?.[1] || null, - type: event.type as HistoryOccurrence["type"], + type: event.type as HistoryOccurrence['type'], // Store all other OBL fields in the occurrence buttonId: evtAny.button_id || null, boardId: evtAny.board_id || null, @@ -79,21 +76,21 @@ export class OblUtil { actions: evtAny.actions, }; - if (event.type === "button") { + if (event.type === 'button') { const btn = event as OblButtonEvent; content = btn.vocalization || btn.label; - } else if (event.type === "utterance") { + } else if (event.type === 'utterance') { const utt = event as OblUtteranceEvent; content = utt.text; - } else if (event.type === "action") { + } else if (event.type === 'action') { const act = event as OblActionEvent; content = act.action; - } else if (event.type === "note") { + } else if (event.type === 'note') { const note = event as OblNoteEvent; content = note.text; } else { const evtAny = event as any; - content = evtAny.label || evtAny.text || evtAny.action || "unknown"; + content = evtAny.label || evtAny.text || evtAny.action || 'unknown'; } const occurrences = contentMap.get(content) || []; @@ -107,9 +104,7 @@ export class OblUtil { id: `obl:${content}`, source: source, content: content, - occurrences: occurrences.sort( - (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), - ), + occurrences: occurrences.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()), }); }); @@ -119,11 +114,7 @@ export class OblUtil { /** * Convert HistoryEntries to an OBL file object. */ - static fromHistoryEntries( - entries: HistoryEntry[], - userId: string, - source?: string, - ): OblFile { + static fromHistoryEntries(entries: HistoryEntry[], userId: string, source?: string): OblFile { const events: OblEvent[] = []; for (const entry of entries) { @@ -131,33 +122,33 @@ export class OblUtil { const timestamp = occ.timestamp.toISOString(); const intent = occ.intent as string; - let oblType: OblEvent["type"] = occ.type || "button"; + let oblType: OblEvent['type'] = occ.type || 'button'; let actionStr: string | undefined = undefined; // Smart mapping based on AACSemanticIntent if (intent === (AACSemanticIntent.CLEAR_TEXT as string)) { - oblType = "action"; - actionStr = ":clear"; + oblType = 'action'; + actionStr = ':clear'; } else if (intent === (AACSemanticIntent.GO_HOME as string)) { - oblType = "action"; - actionStr = ":home"; + oblType = 'action'; + actionStr = ':home'; } else if (intent === (AACSemanticIntent.NAVIGATE_TO as string)) { - oblType = "action"; - actionStr = ":open_board"; + oblType = 'action'; + actionStr = ':open_board'; } else if (intent === (AACSemanticIntent.GO_BACK as string)) { - oblType = "action"; - actionStr = ":back"; + oblType = 'action'; + actionStr = ':back'; } else if (intent === (AACSemanticIntent.DELETE_CHARACTER as string)) { - oblType = "action"; - actionStr = ":backspace"; + oblType = 'action'; + actionStr = ':backspace'; } else if ( intent === (AACSemanticIntent.SPEAK_IMMEDIATE as string) || intent === (AACSemanticIntent.SPEAK_TEXT as string) ) { // Speak could be a button or an utterance or an action - if (oblType !== "utterance" && oblType !== "button") { - oblType = "action"; - actionStr = ":speak"; + if (oblType !== 'utterance' && oblType !== 'button') { + oblType = 'action'; + actionStr = ':speak'; } } @@ -177,22 +168,19 @@ export class OblUtil { common.geo = [occ.latitude, occ.longitude]; } - if (oblType === "utterance") { + if (oblType === 'utterance') { events.push({ ...common, text: entry.content, } as OblUtteranceEvent); - } else if (oblType === "action") { + } else if (oblType === 'action') { events.push({ ...common, action: actionStr || entry.content, destination_board_id: occ.boardId || undefined, - text: - intent === (AACSemanticIntent.SPEAK_TEXT as string) - ? entry.content - : undefined, + text: intent === (AACSemanticIntent.SPEAK_TEXT as string) ? entry.content : undefined, } as OblActionEvent); - } else if (oblType === "note") { + } else if (oblType === 'note') { events.push({ ...common, text: entry.content, @@ -201,12 +189,11 @@ export class OblUtil { // Default to button events.push({ ...common, - type: "button", + type: 'button', label: occ.vocalization ? entry.content : entry.content, spoken: occ.spoken ?? - (occ.category as string) === - (AACSemanticCategory.COMMUNICATION as string), + (occ.category as string) === (AACSemanticCategory.COMMUNICATION as string), button_id: occ.buttonId || undefined, board_id: occ.boardId || occ.pageId || undefined, vocalization: occ.vocalization || undefined, @@ -220,25 +207,22 @@ export class OblUtil { // Sort events by timestamp events.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); - const started = - events.length > 0 ? events[0].timestamp : new Date().toISOString(); + const started = events.length > 0 ? events[0].timestamp : new Date().toISOString(); const ended = - events.length > 0 - ? events[events.length - 1].timestamp - : new Date().toISOString(); + events.length > 0 ? events[events.length - 1].timestamp : new Date().toISOString(); const session: OblSession = { - id: "session-1", - type: "log", + id: 'session-1', + type: 'log', started, ended, events, }; return { - format: "open-board-log-0.1", + format: 'open-board-log-0.1', user_id: userId, - source: source || "aac-processors", + source: source || 'aac-processors', sessions: [session], }; } @@ -258,28 +242,28 @@ export class OblAnonymizer { for (const session of newObl.sessions) { session.anonymizations = session.anonymizations || []; - if (types.includes("timestamp_shift")) { + if (types.includes('timestamp_shift')) { this.applyTimestampShift(session); - if (!session.anonymizations.includes("timestamp_shift")) - session.anonymizations.push("timestamp_shift"); + if (!session.anonymizations.includes('timestamp_shift')) + session.anonymizations.push('timestamp_shift'); } - if (types.includes("geolocation_masking")) { + if (types.includes('geolocation_masking')) { this.applyGeolocationMasking(session); - if (!session.anonymizations.includes("geolocation_masking")) - session.anonymizations.push("geolocation_masking"); + if (!session.anonymizations.includes('geolocation_masking')) + session.anonymizations.push('geolocation_masking'); } - if (types.includes("url_stripping")) { + if (types.includes('url_stripping')) { this.applyUrlStripping(session); - if (!session.anonymizations.includes("url_stripping")) - session.anonymizations.push("url_stripping"); + if (!session.anonymizations.includes('url_stripping')) + session.anonymizations.push('url_stripping'); } - if (types.includes("name_masking")) { + if (types.includes('name_masking')) { this.applyNameMasking(newObl, session); - if (!session.anonymizations.includes("name_masking")) - session.anonymizations.push("name_masking"); + if (!session.anonymizations.includes('name_masking')) + session.anonymizations.push('name_masking'); } } @@ -290,30 +274,20 @@ export class OblAnonymizer { if (session.events.length === 0) return; const firstEventTime = - session.events.length > 0 - ? new Date(session.events[0].timestamp).getTime() - : Infinity; - const sessionStartTime = session.started - ? new Date(session.started).getTime() - : Infinity; + session.events.length > 0 ? new Date(session.events[0].timestamp).getTime() : Infinity; + const sessionStartTime = session.started ? new Date(session.started).getTime() : Infinity; const firstTimestamp = Math.min(firstEventTime, sessionStartTime); if (firstTimestamp === Infinity) return; - const targetStart = new Date("2000-01-01T00:00:00.000Z").getTime(); + const targetStart = new Date('2000-01-01T00:00:00.000Z').getTime(); const offset = targetStart - firstTimestamp; - session.started = new Date( - new Date(session.started).getTime() + offset, - ).toISOString(); - session.ended = new Date( - new Date(session.ended).getTime() + offset, - ).toISOString(); + session.started = new Date(new Date(session.started).getTime() + offset).toISOString(); + session.ended = new Date(new Date(session.ended).getTime() + offset).toISOString(); for (const event of session.events) { - event.timestamp = new Date( - new Date(event.timestamp).getTime() + offset, - ).toISOString(); + event.timestamp = new Date(new Date(event.timestamp).getTime() + offset).toISOString(); } } @@ -326,10 +300,10 @@ export class OblAnonymizer { private static applyUrlStripping(session: OblSession): void { for (const event of session.events) { - if (event.type === "button") { + if (event.type === 'button') { delete (event as OblButtonEvent).image_url; } - if (event.type === "note") { + if (event.type === 'note') { delete (event as OblNoteEvent).author_url; delete (event as OblNoteEvent).author_email; } @@ -339,7 +313,7 @@ export class OblAnonymizer { private static applyNameMasking(obl: OblFile, session: OblSession): void { delete obl.user_name; for (const event of session.events) { - if (event.type === "note") { + if (event.type === 'note') { delete (event as OblNoteEvent).author_name; } } diff --git a/src/utilities/analytics/metrics/sentence.ts b/src/utilities/analytics/metrics/sentence.ts index 456d5fd..c30425e 100644 --- a/src/utilities/analytics/metrics/sentence.ts +++ b/src/utilities/analytics/metrics/sentence.ts @@ -5,8 +5,8 @@ * from the AAC board set, including spelling fallback for missing words. */ -import { MetricsResult } from "./types"; -import { spellingEffort } from "./effort"; +import { MetricsResult } from './types'; +import { spellingEffort } from './effort'; export interface SentenceAnalysis { sentence: string; // Full sentence text @@ -22,10 +22,7 @@ export class SentenceAnalyzer { /** * Analyze effort to construct a set of test sentences */ - analyzeSentences( - metrics: MetricsResult, - sentences: string[][], - ): SentenceAnalysis[] { + analyzeSentences(metrics: MetricsResult, sentences: string[][]): SentenceAnalysis[] { return sentences.map((words) => this.analyzeSentence(metrics, words)); } @@ -33,8 +30,7 @@ export class SentenceAnalyzer { * Analyze effort to construct a single sentence */ analyzeSentence(metrics: MetricsResult, words: string[]): SentenceAnalysis { - const wordEfforts: Array<{ word: string; effort: number; typed: boolean }> = - []; + const wordEfforts: Array<{ word: string; effort: number; typed: boolean }> = []; let totalEffort = 0; let typing = false; const missingWords: string[] = []; @@ -64,7 +60,7 @@ export class SentenceAnalyzer { const baseSpell = spellingEffort( word, metrics.spelling_effort_base || 10, - metrics.spelling_effort_per_letter || 2.5, + metrics.spelling_effort_per_letter || 2.5 ); if (metrics.has_dynamic_prediction) { @@ -115,7 +111,7 @@ export class SentenceAnalyzer { } return word.toLowerCase(); }) - .join(" "); + .join(' '); } /** @@ -138,8 +134,7 @@ export class SentenceAnalyzer { const sentencesWithoutTyping = totalSentences - sentencesRequiringTyping; const efforts = analyses.map((a) => a.effort); - const averageEffort = - efforts.reduce((sum, e) => sum + e, 0) / efforts.length; + const averageEffort = efforts.reduce((sum, e) => sum + e, 0) / efforts.length; const minEffort = Math.min(...efforts); const maxEffort = Math.max(...efforts); @@ -147,16 +142,12 @@ export class SentenceAnalyzer { const sortedEfforts = [...efforts].sort((a, b) => a - b); const medianEffort = sortedEfforts.length % 2 === 0 - ? (sortedEfforts[sortedEfforts.length / 2 - 1] + - sortedEfforts[sortedEfforts.length / 2]) / + ? (sortedEfforts[sortedEfforts.length / 2 - 1] + sortedEfforts[sortedEfforts.length / 2]) / 2 : sortedEfforts[Math.floor(sortedEfforts.length / 2)]; const totalWords = analyses.reduce((sum, a) => sum + a.words.length, 0); - const wordsRequiringTyping = analyses.reduce( - (sum, a) => sum + a.missing_words.length, - 0, - ); + const wordsRequiringTyping = analyses.reduce((sum, a) => sum + a.missing_words.length, 0); const typingPercent = (wordsRequiringTyping / totalWords) * 100; return { diff --git a/src/utilities/analytics/metrics/types.ts b/src/utilities/analytics/metrics/types.ts index 4f00abf..3747bc7 100644 --- a/src/utilities/analytics/metrics/types.ts +++ b/src/utilities/analytics/metrics/types.ts @@ -4,7 +4,7 @@ * Defines the data structures used for AAC metrics analysis */ -import { ScanningConfig } from "../../../types/aac"; +import { ScanningConfig } from '../../../types/aac'; // import { AACTree } from '../../../types/aac'; diff --git a/src/utilities/analytics/metrics/vocabulary.ts b/src/utilities/analytics/metrics/vocabulary.ts index fa8b6cf..01adc5a 100644 --- a/src/utilities/analytics/metrics/vocabulary.ts +++ b/src/utilities/analytics/metrics/vocabulary.ts @@ -5,12 +5,9 @@ * and identifies missing/extra words compared to reference lists. */ -import { MetricsResult, CoreList } from "./types"; -import { - ReferenceLoader, - type ReferenceDataProvider, -} from "../reference/index"; -import { spellingEffort } from "./effort"; +import { MetricsResult, CoreList } from './types'; +import { ReferenceLoader, type ReferenceDataProvider } from '../reference/index'; +import { spellingEffort } from './effort'; export interface VocabularyAnalysis { // Coverage statistics for each core list @@ -55,7 +52,7 @@ export class VocabularyAnalyzer { locale?: string; highEffortThreshold?: number; lowEffortThreshold?: number; - }, + } ): Promise { // const locale = options?.locale || metrics.locale || 'en'; const highEffortThreshold = options?.highEffortThreshold || 5.0; @@ -75,7 +72,7 @@ export class VocabularyAnalyzer { }); // Analyze each core list - const core_coverage: VocabularyAnalysis["core_coverage"] = {}; + const core_coverage: VocabularyAnalysis['core_coverage'] = {}; coreLists.forEach((list) => { const analysis = this.analyzeCoreList(list, wordEffortMap); @@ -127,8 +124,8 @@ export class VocabularyAnalyzer { */ private analyzeCoreList( list: CoreList, - wordEffortMap: Map, - ): VocabularyAnalysis["core_coverage"][string] { + wordEffortMap: Map + ): VocabularyAnalysis['core_coverage'][string] { const covered: string[] = []; const missing: string[] = []; let totalEffort = 0; @@ -162,15 +159,13 @@ export class VocabularyAnalyzer { */ calculateCoverage( wordList: string[], - metrics: MetricsResult, + metrics: MetricsResult ): { covered: string[]; missing: string[]; coverage_percent: number; } { - const wordSet = new Set( - metrics.buttons.map((btn) => btn.label.toLowerCase()), - ); + const wordSet = new Set(metrics.buttons.map((btn) => btn.label.toLowerCase())); const covered: string[] = []; const missing: string[] = []; @@ -194,17 +189,11 @@ export class VocabularyAnalyzer { * Get effort for a word, or calculate spelling effort if missing */ getWordEffort(word: string, metrics: MetricsResult): number { - const btn = metrics.buttons.find( - (b) => b.label.toLowerCase() === word.toLowerCase(), - ); + const btn = metrics.buttons.find((b) => b.label.toLowerCase() === word.toLowerCase()); if (btn) { return btn.effort; } - return spellingEffort( - word, - metrics.spelling_effort_base, - metrics.spelling_effort_per_letter, - ); + return spellingEffort(word, metrics.spelling_effort_base, metrics.spelling_effort_per_letter); } /** diff --git a/src/utilities/analytics/morphology/engine.ts b/src/utilities/analytics/morphology/engine.ts index 88f0574..54d40c8 100644 --- a/src/utilities/analytics/morphology/engine.ts +++ b/src/utilities/analytics/morphology/engine.ts @@ -1,5 +1,5 @@ -import { MorphRuleSet, MorphRule } from "./types"; -import type { Grid3VerbForms } from "./grid3VerbsParser"; +import { MorphRuleSet, MorphRule } from './types'; +import type { Grid3VerbForms } from './grid3VerbsParser'; export class MorphologyEngine { private ruleSet: MorphRuleSet; @@ -7,7 +7,7 @@ export class MorphologyEngine { private cache = new Map(); constructor(ruleSetOrLocale: string | MorphRuleSet) { - if (typeof ruleSetOrLocale === "string") { + if (typeof ruleSetOrLocale === 'string') { this.ruleSet = this.loadBundled(ruleSetOrLocale); } else { this.ruleSet = ruleSetOrLocale; @@ -35,8 +35,7 @@ export class MorphologyEngine { if (cached) return cached; if (this.grid3Verbs) { - const forms = - this.grid3Verbs.get(base) || this.grid3Verbs.get(base.toLowerCase()); + const forms = this.grid3Verbs.get(base) || this.grid3Verbs.get(base.toLowerCase()); if (forms) { this.cache.set(key, forms); return forms; @@ -55,12 +54,12 @@ export class MorphologyEngine { } expandVocabulary( - buttons: Array<{ label: string; pos?: string; predictions?: string[] }>, + buttons: Array<{ label: string; pos?: string; predictions?: string[] }> ): Map { const result = new Map(); for (const btn of buttons) { const pos = btn.pos; - if (!pos || pos === "Unknown" || pos === "Ignore") continue; + if (!pos || pos === 'Unknown' || pos === 'Ignore') continue; const forms = this.inflect(btn.label, pos); if (forms.length > 0) { result.set(btn.label, forms); @@ -69,10 +68,7 @@ export class MorphologyEngine { return result; } - inflectWithSlots( - base: string, - pos: string, - ): Array<{ slot: string; form: string }> { + inflectWithSlots(base: string, pos: string): Array<{ slot: string; form: string }> { const lower = base.toLowerCase(); const result: Array<{ slot: string; form: string }> = []; const seen = new Set(); @@ -82,7 +78,7 @@ export class MorphologyEngine { if (irregular) { for (const [slot, value] of Object.entries(irregular)) { - if (slot === "extra" && Array.isArray(value)) { + if (slot === 'extra' && Array.isArray(value)) { value.forEach((v) => { if (!seen.has(v)) { seen.add(v); @@ -111,9 +107,9 @@ export class MorphologyEngine { if (irregular && irregular[slot] !== undefined) continue; let rules: MorphRule[]; - if (typeof rulesOrAlias === "string") { + if (typeof rulesOrAlias === 'string') { const aliased = regularSlots[rulesOrAlias]; - if (!aliased || typeof aliased === "string") continue; + if (!aliased || typeof aliased === 'string') continue; rules = aliased; } else { rules = rulesOrAlias; @@ -138,7 +134,7 @@ export class MorphologyEngine { if (irregular) { for (const [slot, value] of Object.entries(irregular)) { - if (slot === "extra" && Array.isArray(value)) { + if (slot === 'extra' && Array.isArray(value)) { value.forEach((v) => forms.add(v)); } else if (Array.isArray(value)) { value.forEach((v) => forms.add(v)); @@ -156,9 +152,9 @@ export class MorphologyEngine { if (irregular && irregular[slot] !== undefined) continue; let rules: MorphRule[]; - if (typeof rulesOrAlias === "string") { + if (typeof rulesOrAlias === 'string') { const aliased = regularSlots[rulesOrAlias]; - if (!aliased || typeof aliased === "string") continue; + if (!aliased || typeof aliased === 'string') continue; rules = aliased; } else { rules = rulesOrAlias; @@ -175,9 +171,9 @@ export class MorphologyEngine { private applyRules(word: string, rules: MorphRule[]): string | undefined { for (const rule of rules) { - const regex = new RegExp(rule.match, "i"); + const regex = new RegExp(rule.match, 'i'); if (regex.test(word)) { - return word.replace(new RegExp(rule.match, "i"), rule.replace); + return word.replace(new RegExp(rule.match, 'i'), rule.replace); } } return undefined; @@ -190,7 +186,7 @@ export class MorphologyEngine { */ inferPOS(word: string): string | null { const lower = word.toLowerCase(); - for (const pos of ["Verb", "Noun", "Adjective", "Pronoun"]) { + for (const pos of ['Verb', 'Noun', 'Adjective', 'Pronoun']) { if (this.ruleSet.irregular[pos]?.[lower]) { return pos; } @@ -199,15 +195,15 @@ export class MorphologyEngine { } private loadBundled(locale: string): MorphRuleSet { - const normalized = locale.toLowerCase().replace("_", "-"); + const normalized = locale.toLowerCase().replace('_', '-'); switch (normalized) { - case "en-gb": - case "en-us": - case "en-au": - case "en-ca": - case "en-nz": - case "en-za": - case "en": + case 'en-gb': + case 'en-us': + case 'en-au': + case 'en-ca': + case 'en-nz': + case 'en-za': + case 'en': return builtinEn(); default: return { locale, version: 1, irregular: {}, regular: {} }; @@ -217,717 +213,717 @@ export class MorphologyEngine { function builtinEn(): MorphRuleSet { return { - locale: "en-gb", + locale: 'en-gb', version: 1, irregular: { Verb: { be: { - "3sg": "is", - past: "was", - pastPart: "been", - presPart: "being", - extra: ["am", "are", "were"], + '3sg': 'is', + past: 'was', + pastPart: 'been', + presPart: 'being', + extra: ['am', 'are', 'were'], }, have: { - "3sg": "has", - past: "had", - pastPart: "had", - presPart: "having", + '3sg': 'has', + past: 'had', + pastPart: 'had', + presPart: 'having', }, - do: { "3sg": "does", past: "did", pastPart: "done", presPart: "doing" }, + do: { '3sg': 'does', past: 'did', pastPart: 'done', presPart: 'doing' }, go: { - "3sg": "goes", - past: "went", - pastPart: "gone", - presPart: "going", + '3sg': 'goes', + past: 'went', + pastPart: 'gone', + presPart: 'going', }, say: { - "3sg": "says", - past: "said", - pastPart: "said", - presPart: "saying", + '3sg': 'says', + past: 'said', + pastPart: 'said', + presPart: 'saying', }, get: { - "3sg": "gets", - past: "got", - pastPart: "got", - presPart: "getting", + '3sg': 'gets', + past: 'got', + pastPart: 'got', + presPart: 'getting', }, make: { - "3sg": "makes", - past: "made", - pastPart: "made", - presPart: "making", + '3sg': 'makes', + past: 'made', + pastPart: 'made', + presPart: 'making', }, come: { - "3sg": "comes", - past: "came", - pastPart: "come", - presPart: "coming", + '3sg': 'comes', + past: 'came', + pastPart: 'come', + presPart: 'coming', }, take: { - "3sg": "takes", - past: "took", - pastPart: "taken", - presPart: "taking", + '3sg': 'takes', + past: 'took', + pastPart: 'taken', + presPart: 'taking', }, know: { - "3sg": "knows", - past: "knew", - pastPart: "known", - presPart: "knowing", + '3sg': 'knows', + past: 'knew', + pastPart: 'known', + presPart: 'knowing', }, think: { - "3sg": "thinks", - past: "thought", - pastPart: "thought", - presPart: "thinking", + '3sg': 'thinks', + past: 'thought', + pastPart: 'thought', + presPart: 'thinking', }, see: { - "3sg": "sees", - past: "saw", - pastPart: "seen", - presPart: "seeing", + '3sg': 'sees', + past: 'saw', + pastPart: 'seen', + presPart: 'seeing', }, give: { - "3sg": "gives", - past: "gave", - pastPart: "given", - presPart: "giving", + '3sg': 'gives', + past: 'gave', + pastPart: 'given', + presPart: 'giving', }, find: { - "3sg": "finds", - past: "found", - pastPart: "found", - presPart: "finding", + '3sg': 'finds', + past: 'found', + pastPart: 'found', + presPart: 'finding', }, tell: { - "3sg": "tells", - past: "told", - pastPart: "told", - presPart: "telling", + '3sg': 'tells', + past: 'told', + pastPart: 'told', + presPart: 'telling', }, feel: { - "3sg": "feels", - past: "felt", - pastPart: "felt", - presPart: "feeling", + '3sg': 'feels', + past: 'felt', + pastPart: 'felt', + presPart: 'feeling', }, run: { - "3sg": "runs", - past: "ran", - pastPart: "run", - presPart: "running", + '3sg': 'runs', + past: 'ran', + pastPart: 'run', + presPart: 'running', }, fly: { - "3sg": "flies", - past: "flew", - pastPart: "flown", - presPart: "flying", + '3sg': 'flies', + past: 'flew', + pastPart: 'flown', + presPart: 'flying', }, try: { - "3sg": "tries", - past: "tried", - pastPart: "tried", - presPart: "trying", + '3sg': 'tries', + past: 'tried', + pastPart: 'tried', + presPart: 'trying', }, leave: { - "3sg": "leaves", - past: "left", - pastPart: "left", - presPart: "leaving", + '3sg': 'leaves', + past: 'left', + pastPart: 'left', + presPart: 'leaving', }, call: { - "3sg": "calls", - past: "called", - pastPart: "called", - presPart: "calling", + '3sg': 'calls', + past: 'called', + pastPart: 'called', + presPart: 'calling', }, ask: { - "3sg": "asks", - past: "asked", - pastPart: "asked", - presPart: "asking", + '3sg': 'asks', + past: 'asked', + pastPart: 'asked', + presPart: 'asking', }, put: { - "3sg": "puts", - past: "put", - pastPart: "put", - presPart: "putting", + '3sg': 'puts', + past: 'put', + pastPart: 'put', + presPart: 'putting', }, read: { - "3sg": "reads", - past: "read", - pastPart: "read", - presPart: "reading", + '3sg': 'reads', + past: 'read', + pastPart: 'read', + presPart: 'reading', }, eat: { - "3sg": "eats", - past: "ate", - pastPart: "eaten", - presPart: "eating", + '3sg': 'eats', + past: 'ate', + pastPart: 'eaten', + presPart: 'eating', }, drink: { - "3sg": "drinks", - past: "drank", - pastPart: "drunk", - presPart: "drinking", + '3sg': 'drinks', + past: 'drank', + pastPart: 'drunk', + presPart: 'drinking', }, sleep: { - "3sg": "sleeps", - past: "slept", - pastPart: "slept", - presPart: "sleeping", + '3sg': 'sleeps', + past: 'slept', + pastPart: 'slept', + presPart: 'sleeping', }, speak: { - "3sg": "speaks", - past: "spoke", - pastPart: "spoken", - presPart: "speaking", + '3sg': 'speaks', + past: 'spoke', + pastPart: 'spoken', + presPart: 'speaking', }, write: { - "3sg": "writes", - past: "wrote", - pastPart: "written", - presPart: "writing", + '3sg': 'writes', + past: 'wrote', + pastPart: 'written', + presPart: 'writing', }, sit: { - "3sg": "sits", - past: "sat", - pastPart: "sat", - presPart: "sitting", + '3sg': 'sits', + past: 'sat', + pastPart: 'sat', + presPart: 'sitting', }, stand: { - "3sg": "stands", - past: "stood", - pastPart: "stood", - presPart: "standing", + '3sg': 'stands', + past: 'stood', + pastPart: 'stood', + presPart: 'standing', }, fall: { - "3sg": "falls", - past: "fell", - pastPart: "fallen", - presPart: "falling", + '3sg': 'falls', + past: 'fell', + pastPart: 'fallen', + presPart: 'falling', }, hold: { - "3sg": "holds", - past: "held", - pastPart: "held", - presPart: "holding", + '3sg': 'holds', + past: 'held', + pastPart: 'held', + presPart: 'holding', }, keep: { - "3sg": "keeps", - past: "kept", - pastPart: "kept", - presPart: "keeping", + '3sg': 'keeps', + past: 'kept', + pastPart: 'kept', + presPart: 'keeping', }, buy: { - "3sg": "buys", - past: "bought", - pastPart: "bought", - presPart: "buying", + '3sg': 'buys', + past: 'bought', + pastPart: 'bought', + presPart: 'buying', }, bring: { - "3sg": "brings", - past: "brought", - pastPart: "brought", - presPart: "bringing", + '3sg': 'brings', + past: 'brought', + pastPart: 'brought', + presPart: 'bringing', }, catch: { - "3sg": "catches", - past: "caught", - pastPart: "caught", - presPart: "catching", + '3sg': 'catches', + past: 'caught', + pastPart: 'caught', + presPart: 'catching', }, teach: { - "3sg": "teaches", - past: "taught", - pastPart: "taught", - presPart: "teaching", + '3sg': 'teaches', + past: 'taught', + pastPart: 'taught', + presPart: 'teaching', }, fight: { - "3sg": "fights", - past: "fought", - pastPart: "fought", - presPart: "fighting", + '3sg': 'fights', + past: 'fought', + pastPart: 'fought', + presPart: 'fighting', }, swim: { - "3sg": "swims", - past: "swam", - pastPart: "swum", - presPart: "swimming", + '3sg': 'swims', + past: 'swam', + pastPart: 'swum', + presPart: 'swimming', }, sing: { - "3sg": "sings", - past: "sang", - pastPart: "sung", - presPart: "singing", + '3sg': 'sings', + past: 'sang', + pastPart: 'sung', + presPart: 'singing', }, draw: { - "3sg": "draws", - past: "drew", - pastPart: "drawn", - presPart: "drawing", + '3sg': 'draws', + past: 'drew', + pastPart: 'drawn', + presPart: 'drawing', }, drive: { - "3sg": "drives", - past: "drove", - pastPart: "driven", - presPart: "driving", + '3sg': 'drives', + past: 'drove', + pastPart: 'driven', + presPart: 'driving', }, ride: { - "3sg": "rides", - past: "rode", - pastPart: "ridden", - presPart: "riding", + '3sg': 'rides', + past: 'rode', + pastPart: 'ridden', + presPart: 'riding', }, grow: { - "3sg": "grows", - past: "grew", - pastPart: "grown", - presPart: "growing", + '3sg': 'grows', + past: 'grew', + pastPart: 'grown', + presPart: 'growing', }, throw: { - "3sg": "throws", - past: "threw", - pastPart: "thrown", - presPart: "throwing", + '3sg': 'throws', + past: 'threw', + pastPart: 'thrown', + presPart: 'throwing', }, break: { - "3sg": "breaks", - past: "broke", - pastPart: "broken", - presPart: "breaking", + '3sg': 'breaks', + past: 'broke', + pastPart: 'broken', + presPart: 'breaking', }, wake: { - "3sg": "wakes", - past: "woke", - pastPart: "woken", - presPart: "waking", + '3sg': 'wakes', + past: 'woke', + pastPart: 'woken', + presPart: 'waking', }, wear: { - "3sg": "wears", - past: "wore", - pastPart: "worn", - presPart: "wearing", + '3sg': 'wears', + past: 'wore', + pastPart: 'worn', + presPart: 'wearing', }, win: { - "3sg": "wins", - past: "won", - pastPart: "won", - presPart: "winning", + '3sg': 'wins', + past: 'won', + pastPart: 'won', + presPart: 'winning', }, choose: { - "3sg": "chooses", - past: "chose", - pastPart: "chosen", - presPart: "choosing", + '3sg': 'chooses', + past: 'chose', + pastPart: 'chosen', + presPart: 'choosing', }, hide: { - "3sg": "hides", - past: "hid", - pastPart: "hidden", - presPart: "hiding", + '3sg': 'hides', + past: 'hid', + pastPart: 'hidden', + presPart: 'hiding', }, steal: { - "3sg": "steals", - past: "stole", - pastPart: "stolen", - presPart: "stealing", + '3sg': 'steals', + past: 'stole', + pastPart: 'stolen', + presPart: 'stealing', }, begin: { - "3sg": "begins", - past: "began", - pastPart: "begun", - presPart: "beginning", + '3sg': 'begins', + past: 'began', + pastPart: 'begun', + presPart: 'beginning', }, ring: { - "3sg": "rings", - past: "rang", - pastPart: "rung", - presPart: "ringing", + '3sg': 'rings', + past: 'rang', + pastPart: 'rung', + presPart: 'ringing', }, swing: { - "3sg": "swings", - past: "swung", - pastPart: "swung", - presPart: "swinging", + '3sg': 'swings', + past: 'swung', + pastPart: 'swung', + presPart: 'swinging', }, blow: { - "3sg": "blows", - past: "blew", - pastPart: "blown", - presPart: "blowing", + '3sg': 'blows', + past: 'blew', + pastPart: 'blown', + presPart: 'blowing', }, show: { - "3sg": "shows", - past: "showed", - pastPart: "shown", - presPart: "showing", + '3sg': 'shows', + past: 'showed', + pastPart: 'shown', + presPart: 'showing', }, shut: { - "3sg": "shuts", - past: "shut", - pastPart: "shut", - presPart: "shutting", + '3sg': 'shuts', + past: 'shut', + pastPart: 'shut', + presPart: 'shutting', }, cut: { - "3sg": "cuts", - past: "cut", - pastPart: "cut", - presPart: "cutting", + '3sg': 'cuts', + past: 'cut', + pastPart: 'cut', + presPart: 'cutting', }, hit: { - "3sg": "hits", - past: "hit", - pastPart: "hit", - presPart: "hitting", + '3sg': 'hits', + past: 'hit', + pastPart: 'hit', + presPart: 'hitting', }, hurt: { - "3sg": "hurts", - past: "hurt", - pastPart: "hurt", - presPart: "hurting", + '3sg': 'hurts', + past: 'hurt', + pastPart: 'hurt', + presPart: 'hurting', }, let: { - "3sg": "lets", - past: "let", - pastPart: "let", - presPart: "letting", + '3sg': 'lets', + past: 'let', + pastPart: 'let', + presPart: 'letting', }, set: { - "3sg": "sets", - past: "set", - pastPart: "set", - presPart: "setting", + '3sg': 'sets', + past: 'set', + pastPart: 'set', + presPart: 'setting', }, cost: { - "3sg": "costs", - past: "cost", - pastPart: "cost", - presPart: "costing", + '3sg': 'costs', + past: 'cost', + pastPart: 'cost', + presPart: 'costing', }, send: { - "3sg": "sends", - past: "sent", - pastPart: "sent", - presPart: "sending", + '3sg': 'sends', + past: 'sent', + pastPart: 'sent', + presPart: 'sending', }, build: { - "3sg": "builds", - past: "built", - pastPart: "built", - presPart: "building", + '3sg': 'builds', + past: 'built', + pastPart: 'built', + presPart: 'building', }, spend: { - "3sg": "spends", - past: "spent", - pastPart: "spent", - presPart: "spending", + '3sg': 'spends', + past: 'spent', + pastPart: 'spent', + presPart: 'spending', }, lend: { - "3sg": "lends", - past: "lent", - pastPart: "lent", - presPart: "lending", + '3sg': 'lends', + past: 'lent', + pastPart: 'lent', + presPart: 'lending', }, lose: { - "3sg": "loses", - past: "lost", - pastPart: "lost", - presPart: "losing", + '3sg': 'loses', + past: 'lost', + pastPart: 'lost', + presPart: 'losing', }, mean: { - "3sg": "means", - past: "meant", - pastPart: "meant", - presPart: "meaning", + '3sg': 'means', + past: 'meant', + pastPart: 'meant', + presPart: 'meaning', }, meet: { - "3sg": "meets", - past: "met", - pastPart: "met", - presPart: "meeting", + '3sg': 'meets', + past: 'met', + pastPart: 'met', + presPart: 'meeting', }, pay: { - "3sg": "pays", - past: "paid", - pastPart: "paid", - presPart: "paying", + '3sg': 'pays', + past: 'paid', + pastPart: 'paid', + presPart: 'paying', }, sell: { - "3sg": "sells", - past: "sold", - pastPart: "sold", - presPart: "selling", + '3sg': 'sells', + past: 'sold', + pastPart: 'sold', + presPart: 'selling', }, hang: { - "3sg": "hangs", - past: "hung", - pastPart: "hung", - presPart: "hanging", + '3sg': 'hangs', + past: 'hung', + pastPart: 'hung', + presPart: 'hanging', }, shine: { - "3sg": "shines", - past: "shone", - pastPart: "shone", - presPart: "shining", + '3sg': 'shines', + past: 'shone', + pastPart: 'shone', + presPart: 'shining', }, dig: { - "3sg": "digs", - past: "dug", - pastPart: "dug", - presPart: "digging", + '3sg': 'digs', + past: 'dug', + pastPart: 'dug', + presPart: 'digging', }, stick: { - "3sg": "sticks", - past: "stuck", - pastPart: "stuck", - presPart: "sticking", + '3sg': 'sticks', + past: 'stuck', + pastPart: 'stuck', + presPart: 'sticking', }, spin: { - "3sg": "spins", - past: "spun", - pastPart: "spun", - presPart: "spinning", + '3sg': 'spins', + past: 'spun', + pastPart: 'spun', + presPart: 'spinning', }, spread: { - "3sg": "spreads", - past: "spread", - pastPart: "spread", - presPart: "spreading", + '3sg': 'spreads', + past: 'spread', + pastPart: 'spread', + presPart: 'spreading', }, bite: { - "3sg": "bites", - past: "bit", - pastPart: "bitten", - presPart: "biting", + '3sg': 'bites', + past: 'bit', + pastPart: 'bitten', + presPart: 'biting', }, feed: { - "3sg": "feeds", - past: "fed", - pastPart: "fed", - presPart: "feeding", + '3sg': 'feeds', + past: 'fed', + pastPart: 'fed', + presPart: 'feeding', }, lead: { - "3sg": "leads", - past: "led", - pastPart: "led", - presPart: "leading", + '3sg': 'leads', + past: 'led', + pastPart: 'led', + presPart: 'leading', }, light: { - "3sg": "lights", - past: "lit", - pastPart: "lit", - presPart: "lighting", + '3sg': 'lights', + past: 'lit', + pastPart: 'lit', + presPart: 'lighting', }, shoot: { - "3sg": "shoots", - past: "shot", - pastPart: "shot", - presPart: "shooting", + '3sg': 'shoots', + past: 'shot', + pastPart: 'shot', + presPart: 'shooting', }, slide: { - "3sg": "slides", - past: "slid", - pastPart: "slid", - presPart: "sliding", + '3sg': 'slides', + past: 'slid', + pastPart: 'slid', + presPart: 'sliding', }, }, Noun: { - child: { plural: "children" }, - person: { plural: "people" }, - man: { plural: "men" }, - woman: { plural: "women" }, - mouse: { plural: "mice" }, - foot: { plural: "feet" }, - tooth: { plural: "teeth" }, - goose: { plural: "geese" }, - sheep: { plural: "sheep" }, - fish: { plural: "fish" }, - deer: { plural: "deer" }, - ox: { plural: "oxen" }, - leaf: { plural: "leaves" }, - loaf: { plural: "loaves" }, - wolf: { plural: "wolves" }, - calf: { plural: "calves" }, - half: { plural: "halves" }, - knife: { plural: "knives" }, - life: { plural: "lives" }, - wife: { plural: "wives" }, - self: { plural: "selves" }, - shelf: { plural: "shelves" }, - elf: { plural: "elves" }, - thief: { plural: "thieves" }, - roof: { plural: "roofs" }, - chief: { plural: "chiefs" }, - belief: { plural: "beliefs" }, - proof: { plural: "proofs" }, - hoof: { plural: "hooves" }, - scarf: { plural: "scarves" }, - wharf: { plural: "wharves" }, - bus: { plural: "buses" }, - glass: { plural: "glasses" }, - class: { plural: "classes" }, - box: { plural: "boxes" }, - fox: { plural: "foxes" }, - watch: { plural: "watches" }, - match: { plural: "matches" }, - brush: { plural: "brushes" }, - dish: { plural: "dishes" }, - wish: { plural: "wishes" }, - wash: { plural: "washes" }, - bush: { plural: "bushes" }, - push: { plural: "pushes" }, - potato: { plural: "potatoes" }, - tomato: { plural: "tomatoes" }, - hero: { plural: "heroes" }, - echo: { plural: "echoes" }, - veto: { plural: "vetoes" }, - mango: { plural: "mangoes" }, - mosquito: { plural: "mosquitoes" }, - tornado: { plural: "tornadoes" }, - volcano: { plural: "volcanoes" }, - radio: { plural: "radios" }, - studio: { plural: "studios" }, - video: { plural: "videos" }, - piano: { plural: "pianos" }, - photo: { plural: "photos" }, - zoo: { plural: "zoos" }, - bamboo: { plural: "bamboos" }, - embryo: { plural: "embryos" }, - ratio: { plural: "ratios" }, - scenario: { plural: "scenarios" }, - analysis: { plural: "analyses" }, - basis: { plural: "bases" }, - crisis: { plural: "crises" }, - diagnosis: { plural: "diagnoses" }, - hypothesis: { plural: "hypotheses" }, - oasis: { plural: "oases" }, - parenthesis: { plural: "parentheses" }, - synthesis: { plural: "syntheses" }, - thesis: { plural: "theses" }, - phenomenon: { plural: "phenomena" }, - criterion: { plural: "criteria" }, - datum: { plural: "data" }, - medium: { plural: "media" }, - curriculum: { plural: "curricula" }, - bacterium: { plural: "bacteria" }, - stimulus: { plural: "stimuli" }, - syllabus: { plural: "syllabi" }, - focus: { plural: "foci" }, - nucleus: { plural: "nuclei" }, - fungus: { plural: "fungi" }, - cactus: { plural: "cacti" }, - appendix: { plural: "appendices" }, - index: { plural: "indices" }, - matrix: { plural: "matrices" }, - vertex: { plural: "vertices" }, + child: { plural: 'children' }, + person: { plural: 'people' }, + man: { plural: 'men' }, + woman: { plural: 'women' }, + mouse: { plural: 'mice' }, + foot: { plural: 'feet' }, + tooth: { plural: 'teeth' }, + goose: { plural: 'geese' }, + sheep: { plural: 'sheep' }, + fish: { plural: 'fish' }, + deer: { plural: 'deer' }, + ox: { plural: 'oxen' }, + leaf: { plural: 'leaves' }, + loaf: { plural: 'loaves' }, + wolf: { plural: 'wolves' }, + calf: { plural: 'calves' }, + half: { plural: 'halves' }, + knife: { plural: 'knives' }, + life: { plural: 'lives' }, + wife: { plural: 'wives' }, + self: { plural: 'selves' }, + shelf: { plural: 'shelves' }, + elf: { plural: 'elves' }, + thief: { plural: 'thieves' }, + roof: { plural: 'roofs' }, + chief: { plural: 'chiefs' }, + belief: { plural: 'beliefs' }, + proof: { plural: 'proofs' }, + hoof: { plural: 'hooves' }, + scarf: { plural: 'scarves' }, + wharf: { plural: 'wharves' }, + bus: { plural: 'buses' }, + glass: { plural: 'glasses' }, + class: { plural: 'classes' }, + box: { plural: 'boxes' }, + fox: { plural: 'foxes' }, + watch: { plural: 'watches' }, + match: { plural: 'matches' }, + brush: { plural: 'brushes' }, + dish: { plural: 'dishes' }, + wish: { plural: 'wishes' }, + wash: { plural: 'washes' }, + bush: { plural: 'bushes' }, + push: { plural: 'pushes' }, + potato: { plural: 'potatoes' }, + tomato: { plural: 'tomatoes' }, + hero: { plural: 'heroes' }, + echo: { plural: 'echoes' }, + veto: { plural: 'vetoes' }, + mango: { plural: 'mangoes' }, + mosquito: { plural: 'mosquitoes' }, + tornado: { plural: 'tornadoes' }, + volcano: { plural: 'volcanoes' }, + radio: { plural: 'radios' }, + studio: { plural: 'studios' }, + video: { plural: 'videos' }, + piano: { plural: 'pianos' }, + photo: { plural: 'photos' }, + zoo: { plural: 'zoos' }, + bamboo: { plural: 'bamboos' }, + embryo: { plural: 'embryos' }, + ratio: { plural: 'ratios' }, + scenario: { plural: 'scenarios' }, + analysis: { plural: 'analyses' }, + basis: { plural: 'bases' }, + crisis: { plural: 'crises' }, + diagnosis: { plural: 'diagnoses' }, + hypothesis: { plural: 'hypotheses' }, + oasis: { plural: 'oases' }, + parenthesis: { plural: 'parentheses' }, + synthesis: { plural: 'syntheses' }, + thesis: { plural: 'theses' }, + phenomenon: { plural: 'phenomena' }, + criterion: { plural: 'criteria' }, + datum: { plural: 'data' }, + medium: { plural: 'media' }, + curriculum: { plural: 'curricula' }, + bacterium: { plural: 'bacteria' }, + stimulus: { plural: 'stimuli' }, + syllabus: { plural: 'syllabi' }, + focus: { plural: 'foci' }, + nucleus: { plural: 'nuclei' }, + fungus: { plural: 'fungi' }, + cactus: { plural: 'cacti' }, + appendix: { plural: 'appendices' }, + index: { plural: 'indices' }, + matrix: { plural: 'matrices' }, + vertex: { plural: 'vertices' }, }, Adjective: { - good: { comparative: "better", superlative: "best" }, - bad: { comparative: "worse", superlative: "worst" }, - far: { comparative: "farther", superlative: "farthest" }, - little: { comparative: "less", superlative: "least" }, - much: { comparative: "more", superlative: "most" }, - many: { comparative: "more", superlative: "most" }, - well: { comparative: "better", superlative: "best" }, + good: { comparative: 'better', superlative: 'best' }, + bad: { comparative: 'worse', superlative: 'worst' }, + far: { comparative: 'farther', superlative: 'farthest' }, + little: { comparative: 'less', superlative: 'least' }, + much: { comparative: 'more', superlative: 'most' }, + many: { comparative: 'more', superlative: 'most' }, + well: { comparative: 'better', superlative: 'best' }, old: { - comparative: "older", - superlative: "oldest", - extra: ["elder", "eldest"], + comparative: 'older', + superlative: 'oldest', + extra: ['elder', 'eldest'], }, late: { - comparative: "later", - superlative: "latest", - extra: ["latter", "last"], + comparative: 'later', + superlative: 'latest', + extra: ['latter', 'last'], }, }, Pronoun: { i: { - objective: "me", - possessive: "my", - possessivePronoun: "mine", + objective: 'me', + possessive: 'my', + possessivePronoun: 'mine', }, you: { - objective: "you", - possessive: "your", - possessivePronoun: "yours", + objective: 'you', + possessive: 'your', + possessivePronoun: 'yours', }, he: { - objective: "him", - possessive: "his", - possessivePronoun: "his", + objective: 'him', + possessive: 'his', + possessivePronoun: 'his', }, she: { - objective: "her", - possessive: "her", - possessivePronoun: "hers", + objective: 'her', + possessive: 'her', + possessivePronoun: 'hers', }, it: { - objective: "it", - possessive: "its", + objective: 'it', + possessive: 'its', }, we: { - objective: "us", - possessive: "our", - possessivePronoun: "ours", + objective: 'us', + possessive: 'our', + possessivePronoun: 'ours', }, they: { - objective: "them", - possessive: "their", - possessivePronoun: "theirs", - }, - mine: { extra: ["my"] }, - yours: { extra: ["your"] }, - his: { extra: ["him"] }, - hers: { extra: ["her"] }, - ours: { extra: ["our"] }, - theirs: { extra: ["their"] }, + objective: 'them', + possessive: 'their', + possessivePronoun: 'theirs', + }, + mine: { extra: ['my'] }, + yours: { extra: ['your'] }, + his: { extra: ['him'] }, + hers: { extra: ['her'] }, + ours: { extra: ['our'] }, + theirs: { extra: ['their'] }, }, }, regular: { Verb: { - "3sg": [ - { match: "(ss|sh|ch|x|z|o)$", replace: "$1es" }, - { match: "([^aeiou])y$", replace: "$1ies" }, - { match: "$", replace: "s" }, + '3sg': [ + { match: '(ss|sh|ch|x|z|o)$', replace: '$1es' }, + { match: '([^aeiou])y$', replace: '$1ies' }, + { match: '$', replace: 's' }, ], past: [ - { match: "([^aeiou])y$", replace: "$1ied" }, - { match: "([^aeiou][aeiou])([^aeiouwxy])$", replace: "$1$2$2ed" }, - { match: "(.*)e$", replace: "$1ed" }, - { match: "$", replace: "ed" }, + { match: '([^aeiou])y$', replace: '$1ied' }, + { match: '([^aeiou][aeiou])([^aeiouwxy])$', replace: '$1$2$2ed' }, + { match: '(.*)e$', replace: '$1ed' }, + { match: '$', replace: 'ed' }, ], - pastPart: "past", + pastPart: 'past', presPart: [ - { match: "ie$", replace: "ying" }, - { match: "(.*)e$", replace: "$1ing" }, - { match: "([^aeiou][aeiou])([^aeiouwxy])$", replace: "$1$2$2ing" }, - { match: "$", replace: "ing" }, + { match: 'ie$', replace: 'ying' }, + { match: '(.*)e$', replace: '$1ing' }, + { match: '([^aeiou][aeiou])([^aeiouwxy])$', replace: '$1$2$2ing' }, + { match: '$', replace: 'ing' }, ], }, Noun: { plural: [ - { match: "(ss|sh|ch|x|z)$", replace: "$1es" }, - { match: "([^aeiou])y$", replace: "$1ies" }, - { match: "fe$", replace: "ves" }, - { match: "f$", replace: "ves" }, - { match: "$", replace: "s" }, + { match: '(ss|sh|ch|x|z)$', replace: '$1es' }, + { match: '([^aeiou])y$', replace: '$1ies' }, + { match: 'fe$', replace: 'ves' }, + { match: 'f$', replace: 'ves' }, + { match: '$', replace: 's' }, ], }, Adjective: { comparative: [ - { match: "e$", replace: "r" }, - { match: "([^aeiou])y$", replace: "$1ier" }, - { match: "([^aeiou][aeiou])([^aeiouwxy])$", replace: "$1$2$2er" }, - { match: "$", replace: "er" }, + { match: 'e$', replace: 'r' }, + { match: '([^aeiou])y$', replace: '$1ier' }, + { match: '([^aeiou][aeiou])([^aeiouwxy])$', replace: '$1$2$2er' }, + { match: '$', replace: 'er' }, ], superlative: [ - { match: "e$", replace: "st" }, - { match: "([^aeiou])y$", replace: "$1iest" }, - { match: "([^aeiou][aeiou])([^aeiouwxy])$", replace: "$1$2$2est" }, - { match: "$", replace: "est" }, + { match: 'e$', replace: 'st' }, + { match: '([^aeiou])y$', replace: '$1iest' }, + { match: '([^aeiou][aeiou])([^aeiouwxy])$', replace: '$1$2$2est' }, + { match: '$', replace: 'est' }, ], }, }, diff --git a/src/utilities/analytics/morphology/grid3VerbsParser.ts b/src/utilities/analytics/morphology/grid3VerbsParser.ts index a9f7693..abb0d1b 100644 --- a/src/utilities/analytics/morphology/grid3VerbsParser.ts +++ b/src/utilities/analytics/morphology/grid3VerbsParser.ts @@ -1,7 +1,7 @@ -import { XMLParser } from "fast-xml-parser"; -import AdmZip from "adm-zip"; -import { join, dirname, basename } from "path"; -import type { VerbFormWithConditions, Grid3VerbFormsDetailed } from "./types"; +import { XMLParser } from 'fast-xml-parser'; +import AdmZip from 'adm-zip'; +import { join, dirname, basename } from 'path'; +import type { VerbFormWithConditions, Grid3VerbFormsDetailed } from './types'; export interface Grid3VerbForms { locale: string; @@ -30,18 +30,17 @@ export class Grid3VerbsParser { private parser = new XMLParser({ ignoreAttributes: false, ignoreDeclaration: true, - textNodeName: "#text", + textNodeName: '#text', }); parseXml(xmlContent: string, locale?: string): Grid3VerbForms { const data = this.parser.parse(xmlContent); const verbdata = data.verbdata || data.Verbdata; if (!verbdata) { - return { locale: locale || "unknown", verbs: new Map() }; + return { locale: locale || 'unknown', verbs: new Map() }; } - const detectedLocale = - verbdata["@_locale"] || verbdata.locale || locale || "unknown"; + const detectedLocale = verbdata['@_locale'] || verbdata.locale || locale || 'unknown'; const ruleSets = this.parseRuleSets(verbdata); const verbs = this.parseVerbs(verbdata); const result = new Map(); @@ -56,18 +55,14 @@ export class Grid3VerbsParser { return { locale: detectedLocale, verbs: result }; } - parseXmlDetailed( - xmlContent: string, - locale?: string, - ): Grid3VerbFormsDetailed { + parseXmlDetailed(xmlContent: string, locale?: string): Grid3VerbFormsDetailed { const data = this.parser.parse(xmlContent); const verbdata = data.verbdata || data.Verbdata; if (!verbdata) { - return { locale: locale || "unknown", verbs: new Map() }; + return { locale: locale || 'unknown', verbs: new Map() }; } - const detectedLocale = - verbdata["@_locale"] || verbdata.locale || locale || "unknown"; + const detectedLocale = verbdata['@_locale'] || verbdata.locale || locale || 'unknown'; const ruleSets = this.parseRuleSets(verbdata); const verbs = this.parseVerbs(verbdata); const result = new Map(); @@ -84,8 +79,8 @@ export class Grid3VerbsParser { /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-argument */ parseXmlFileDetailed(filePath: string): Grid3VerbFormsDetailed { - const fs = require("fs"); - const xml = fs.readFileSync(filePath, "utf-8"); + const fs = require('fs'); + const xml = fs.readFileSync(filePath, 'utf-8'); return this.parseXmlDetailed(xml); } /* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-argument */ @@ -93,41 +88,34 @@ export class Grid3VerbsParser { parseZip(zipPath: string): Grid3VerbForms { const zip = new AdmZip(zipPath); const entries = zip.getEntries(); - const verbEntry = entries.find((e) => - e.entryName.toLowerCase().endsWith("verbs.xml"), - ); + const verbEntry = entries.find((e) => e.entryName.toLowerCase().endsWith('verbs.xml')); if (!verbEntry) { const locale = basename(dirname(zipPath)); return { locale, verbs: new Map() }; } - const xml = verbEntry.getData().toString("utf-8"); + const xml = verbEntry.getData().toString('utf-8'); return this.parseXml(xml); } parseZipDetailed(zipPath: string): Grid3VerbFormsDetailed { const zip = new AdmZip(zipPath); const entries = zip.getEntries(); - const verbEntry = entries.find((e) => - e.entryName.toLowerCase().endsWith("verbs.xml"), - ); + const verbEntry = entries.find((e) => e.entryName.toLowerCase().endsWith('verbs.xml')); if (!verbEntry) { const locale = basename(dirname(zipPath)); return { locale, verbs: new Map() }; } - const xml = verbEntry.getData().toString("utf-8"); + const xml = verbEntry.getData().toString('utf-8'); return this.parseXmlDetailed(xml); } // eslint-disable-next-line @typescript-eslint/require-await - async parseLocale( - locale: string, - grid3InstallPath?: string, - ): Promise { + async parseLocale(locale: string, grid3InstallPath?: string): Promise { const installPath = grid3InstallPath || this.getDefaultInstallPath(); if (!installPath) { return { locale, verbs: new Map() }; } - const zipPath = join(installPath, "Locale", locale, "verbs", "verbs.zip"); + const zipPath = join(installPath, 'Locale', locale, 'verbs', 'verbs.zip'); try { return this.parseZip(zipPath); } catch { @@ -135,15 +123,13 @@ export class Grid3VerbsParser { } } - async parseInstalledLocales( - grid3InstallPath?: string, - ): Promise> { + async parseInstalledLocales(grid3InstallPath?: string): Promise> { const installPath = grid3InstallPath || this.getDefaultInstallPath(); const results = new Map(); if (!installPath) return results; - const fs = await import("fs"); - const localeDir = join(installPath, "Locale"); + const fs = await import('fs'); + const localeDir = join(installPath, 'Locale'); let locales: string[]; try { locales = fs @@ -154,7 +140,7 @@ export class Grid3VerbsParser { } for (const locale of locales) { - const verbsZip = join(localeDir, locale, "verbs", "verbs.zip"); + const verbsZip = join(localeDir, locale, 'verbs', 'verbs.zip'); try { fs.accessSync(verbsZip, fs.constants.R_OK); const forms = this.parseZip(verbsZip); @@ -178,23 +164,18 @@ export class Grid3VerbsParser { * This allows users to supply morphology data copied from any Grid 3 * installation without needing Grid 3 installed on this machine. */ + parseCustomDirectory(dirPath: string, detailed?: false): Map; parseCustomDirectory( dirPath: string, - detailed?: false, - ): Map; - parseCustomDirectory( - dirPath: string, - detailed: true, - ): Map; + detailed: true + ): Map; // eslint-disable-next-line @typescript-eslint/no-var-requires parseCustomDirectory( dirPath: string, - detailed = false, - ): - | Map - | Map { + detailed = false + ): Map | Map { /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ - const fs = require("fs"); + const fs = require('fs'); let locales: string[]; try { locales = fs @@ -205,12 +186,9 @@ export class Grid3VerbsParser { } if (detailed) { - const results = new Map< - string, - import("./types").Grid3VerbFormsDetailed - >(); + const results = new Map(); for (const locale of locales) { - const verbsZip = join(dirPath, locale, "verbs", "verbs.zip"); + const verbsZip = join(dirPath, locale, 'verbs', 'verbs.zip'); try { fs.accessSync(verbsZip, fs.constants.R_OK); results.set(locale, this.parseZipDetailed(verbsZip)); @@ -223,7 +201,7 @@ export class Grid3VerbsParser { const results = new Map(); for (const locale of locales) { - const verbsZip = join(dirPath, locale, "verbs", "verbs.zip"); + const verbsZip = join(dirPath, locale, 'verbs', 'verbs.zip'); try { fs.accessSync(verbsZip, fs.constants.R_OK); results.set(locale, this.parseZip(verbsZip)); @@ -236,16 +214,16 @@ export class Grid3VerbsParser { } private getDefaultInstallPath(): string | null { - if (typeof process === "undefined" || process.platform !== "win32") { + if (typeof process === 'undefined' || process.platform !== 'win32') { return null; } const paths = [ - "C:\\Program Files (x86)\\Smartbox\\Grid 3", - "C:\\Program Files\\Smartbox\\Grid 3", + 'C:\\Program Files (x86)\\Smartbox\\Grid 3', + 'C:\\Program Files\\Smartbox\\Grid 3', ]; try { // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require("fs"); + const fs = require('fs'); for (const p of paths) { if (fs.existsSync(p)) return p; } @@ -270,7 +248,7 @@ export class Grid3VerbsParser { if (ph) { const phArr = Array.isArray(ph) ? ph : [ph]; for (const p of phArr) { - const val = typeof p === "string" ? p : p["#text"] || p; + const val = typeof p === 'string' ? p : p['#text'] || p; if (val) placeholders.push(val); } } @@ -280,8 +258,8 @@ export class Grid3VerbsParser { if (pr) { const prArr = Array.isArray(pr) ? pr : [pr]; for (const rule of prArr) { - const type = rule["@_type"] || rule.type; - const value = rule["@_value"] || rule.value; + const type = rule['@_type'] || rule.type; + const value = rule['@_value'] || rule.value; if (type && value) { participleRules.set(type, value); } @@ -296,21 +274,21 @@ export class Grid3VerbsParser { if (cr) { const crArr = Array.isArray(cr) ? cr : [cr]; for (const rule of crArr) { - const value = rule["@_value"] || rule.value; + const value = rule['@_value'] || rule.value; if (!value) continue; const conditions = new Map(); for (const attr of [ - "time", - "number", - "person", - "aspect", - "mood", - "voice", - "tense", - "polarity", + 'time', + 'number', + 'person', + 'aspect', + 'mood', + 'voice', + 'tense', + 'polarity', ]) { const v = rule[`@_${attr}`] || rule[attr]; - if (v && v !== "*") { + if (v && v !== '*') { conditions.set(attr, String(v)); } } @@ -332,18 +310,18 @@ export class Grid3VerbsParser { const arr = Array.isArray(verbsData) ? verbsData : [verbsData]; for (const v of arr) { - const root = v["@_root"] || v.root; + const root = v['@_root'] || v.root; if (!root) continue; - const ruleId = v["@_ruleid"] || v.ruleid || undefined; + const ruleId = v['@_ruleid'] || v.ruleid || undefined; const placeholderValues = new Map(); const rphv = v.ruleplaceholdervalues?.ruleplaceholdervalue; if (rphv) { const rphvArr = Array.isArray(rphv) ? rphv : [rphv]; for (const ph of rphvArr) { - const placeholder = ph["@_placeholder"] || ph.placeholder; - const value = ph["@_value"] || ph.value; + const placeholder = ph['@_placeholder'] || ph.placeholder; + const value = ph['@_value'] || ph.value; if (placeholder && value) { placeholderValues.set(placeholder, value); } @@ -355,8 +333,8 @@ export class Grid3VerbsParser { if (parts) { const partsArr = Array.isArray(parts) ? parts : [parts]; for (const p of partsArr) { - const type = p["@_type"] || p.type; - const value = p["@_value"] || p.value; + const type = p['@_type'] || p.type; + const value = p['@_value'] || p.value; if (type && value) { participleOverrides.set(type, value); } @@ -371,23 +349,16 @@ export class Grid3VerbsParser { if (conjs) { const conjArr = Array.isArray(conjs) ? conjs : [conjs]; for (const c of conjArr) { - const value = c["@_value"] || c.value; + const value = c['@_value'] || c.value; if (!value) continue; const conditions = new Map(); const parts2 = c.part; if (parts2) { const pArr = Array.isArray(parts2) ? parts2 : [parts2]; for (const p of pArr) { - for (const attr of [ - "time", - "number", - "person", - "aspect", - "mood", - "voice", - ]) { + for (const attr of ['time', 'number', 'person', 'aspect', 'mood', 'voice']) { const pv = p[`@_${attr}`] || p[attr]; - if (pv && pv !== "*") { + if (pv && pv !== '*') { conditions.set(attr, String(pv)); } } @@ -409,10 +380,7 @@ export class Grid3VerbsParser { return verbs; } - private generateForms( - verb: ParsedVerb, - ruleSets: Map, - ): string[] { + private generateForms(verb: ParsedVerb, ruleSets: Map): string[] { const forms = new Set(); const resolvedParticiples = new Map(); @@ -462,7 +430,7 @@ export class Grid3VerbsParser { private generateFormsDetailed( verb: ParsedVerb, - ruleSets: Map, + ruleSets: Map ): VerbFormWithConditions[] { const forms = new Map>(); const resolvedParticiples = new Map(); @@ -495,7 +463,7 @@ export class Grid3VerbsParser { for (const conjRule of appliedRule.conjugationRules) { const resolved = this.resolveTemplate(conjRule.value, fullContext); - if (resolved && !resolved.includes(" ") && resolved !== "-") { + if (resolved && !resolved.includes(' ') && resolved !== '-') { const trimmed = resolved.trim(); if (trimmed.length > 0 && trimmed !== verb.root) { const existing = forms.get(trimmed); @@ -512,14 +480,9 @@ export class Grid3VerbsParser { } for (const [type, value] of resolvedParticiples) { - if ( - !value.includes(" ") && - value !== "-" && - value.trim().length > 0 && - value !== verb.root - ) { + if (!value.includes(' ') && value !== '-' && value.trim().length > 0 && value !== verb.root) { const conditions = forms.get(value) || new Map(); - conditions.set("participleType", type); + conditions.set('participleType', type); forms.set(value, conditions); } } @@ -527,8 +490,8 @@ export class Grid3VerbsParser { for (const conj of verb.conjugationOverrides) { if ( conj.value && - !conj.value.includes(" ") && - conj.value !== "-" && + !conj.value.includes(' ') && + conj.value !== '-' && conj.value.trim().length > 0 && conj.value !== verb.root ) { @@ -549,13 +512,13 @@ export class Grid3VerbsParser { private buildContext( verb: ParsedVerb, - resolvedParticiples: Map, + resolvedParticiples: Map ): Map { const context = new Map(); - context.set("{root}", verb.root); + context.set('{root}', verb.root); for (const [key, value] of verb.placeholderValues) { context.set(key, value); - if (!key.startsWith("{")) { + if (!key.startsWith('{')) { context.set(`{${key}}`, value); } } @@ -565,13 +528,10 @@ export class Grid3VerbsParser { return context; } - private resolveTemplate( - template: string, - context: Map, - ): string { + private resolveTemplate(template: string, context: Map): string { let result = template; for (const [key, value] of context) { - const keyToReplace = key.startsWith("{") ? key : `{${key}}`; + const keyToReplace = key.startsWith('{') ? key : `{${key}}`; result = result.split(keyToReplace).join(value); } return result; @@ -579,8 +539,8 @@ export class Grid3VerbsParser { private addIfSingleWord(form: string, set: Set): void { if (!form) return; - if (form.includes(" ")) return; - if (form === "-") return; + if (form.includes(' ')) return; + if (form === '-') return; const trimmed = form.trim(); if (trimmed.length > 0) { set.add(trimmed); diff --git a/src/utilities/analytics/morphology/index.ts b/src/utilities/analytics/morphology/index.ts index ef093e1..eaa37a3 100644 --- a/src/utilities/analytics/morphology/index.ts +++ b/src/utilities/analytics/morphology/index.ts @@ -1,5 +1,5 @@ -export { MorphologyEngine } from "./engine"; -export { WordFormGenerator } from "./wordFormGenerator"; +export { MorphologyEngine } from './engine'; +export { WordFormGenerator } from './wordFormGenerator'; export type { MorphRuleSet, MorphRule, @@ -7,5 +7,5 @@ export type { AstericsWordForm, VerbFormWithConditions, Grid3VerbFormsDetailed, -} from "./types"; -export type { Grid3VerbForms } from "./grid3VerbsParser"; +} from './types'; +export type { Grid3VerbForms } from './grid3VerbsParser'; diff --git a/src/utilities/analytics/morphology/wordFormGenerator.ts b/src/utilities/analytics/morphology/wordFormGenerator.ts index 0119e1e..cfe4d6d 100644 --- a/src/utilities/analytics/morphology/wordFormGenerator.ts +++ b/src/utilities/analytics/morphology/wordFormGenerator.ts @@ -1,31 +1,31 @@ -import { MorphologyEngine } from "./engine"; -import { Grid3VerbsParser } from "./grid3VerbsParser"; -import type { AstericsWordForm, VerbFormWithConditions } from "./types"; +import { MorphologyEngine } from './engine'; +import { Grid3VerbsParser } from './grid3VerbsParser'; +import type { AstericsWordForm, VerbFormWithConditions } from './types'; const SLOT_TAG_MAP: Record = { - "3sg": ["3.PERS"], - past: ["PAST"], - pastPart: ["PAST", "PARTICIPLE"], - presPart: ["GERUND"], - plural: ["PLURAL"], - comparative: ["COMPARATIVE"], - superlative: ["SUPERLATIVE"], + '3sg': ['3.PERS'], + past: ['PAST'], + pastPart: ['PAST', 'PARTICIPLE'], + presPart: ['GERUND'], + plural: ['PLURAL'], + comparative: ['COMPARATIVE'], + superlative: ['SUPERLATIVE'], }; const CONDITION_TAG_MAP: Record> = { - person: { first: ["1.PERS"], second: ["2.PERS"], third: ["3.PERS"] }, - number: { singular: [], plural: ["PLURAL"] }, - time: { present: ["PRESENT"], past: ["PAST"], future: ["FUTURE"] }, - aspect: { simple: [], continuous: ["CONTINUOUS"], perfect: ["PERFECT"] }, + person: { first: ['1.PERS'], second: ['2.PERS'], third: ['3.PERS'] }, + number: { singular: [], plural: ['PLURAL'] }, + time: { present: ['PRESENT'], past: ['PAST'], future: ['FUTURE'] }, + aspect: { simple: [], continuous: ['CONTINUOUS'], perfect: ['PERFECT'] }, mood: { - imperative: ["IMPERATIVE"], + imperative: ['IMPERATIVE'], indicative: [], - conditional: ["CONDITIONAL"], + conditional: ['CONDITIONAL'], }, participleType: { - presentparticiple: ["GERUND"], - pastparticiple: ["PAST", "PARTICIPLE"], - infinitive: ["BASE"], + presentparticiple: ['GERUND'], + pastparticiple: ['PAST', 'PARTICIPLE'], + infinitive: ['BASE'], }, }; @@ -34,10 +34,10 @@ export class WordFormGenerator { base: string, pos: string, engine: MorphologyEngine, - lang: string = "en", + lang: string = 'en' ): AstericsWordForm[] { const forms = engine.inflectWithSlots(base, pos); - const result: AstericsWordForm[] = [{ lang, tags: ["BASE"], value: base }]; + const result: AstericsWordForm[] = [{ lang, tags: ['BASE'], value: base }]; for (const { slot, form } of forms) { const tags = SLOT_TAG_MAP[slot] || [slot.toUpperCase()]; @@ -50,9 +50,9 @@ export class WordFormGenerator { generateFromGrid3Conditions( base: string, formsWithConditions: VerbFormWithConditions[], - lang: string = "en", + lang: string = 'en' ): AstericsWordForm[] { - const result: AstericsWordForm[] = [{ lang, tags: ["BASE"], value: base }]; + const result: AstericsWordForm[] = [{ lang, tags: ['BASE'], value: base }]; for (const form of formsWithConditions) { const tags = this.conditionsToTags(form.conditions); @@ -68,12 +68,11 @@ export class WordFormGenerator { engine: MorphologyEngine, grid3Parser: Grid3VerbsParser, verbsZipPath?: string, - lang: string = "en", + lang: string = 'en' ): AstericsWordForm[] { if (verbsZipPath) { const detailed = grid3Parser.parseZipDetailed(verbsZipPath); - const forms = - detailed.verbs.get(base) || detailed.verbs.get(base.toLowerCase()); + const forms = detailed.verbs.get(base) || detailed.verbs.get(base.toLowerCase()); if (forms && forms.length > 0) { return this.generateFromGrid3Conditions(base, forms, lang); } @@ -90,13 +89,13 @@ export class WordFormGenerator { tags.push(...mapped); } } - return tags.length > 0 ? tags : ["UNKNOWN"]; + return tags.length > 0 ? tags : ['UNKNOWN']; } private deduplicate(forms: AstericsWordForm[]): AstericsWordForm[] { const seen = new Set(); return forms.filter((f) => { - const key = `${f.value}|${f.tags.sort().join(",")}`; + const key = `${f.value}|${f.tags.sort().join(',')}`; if (seen.has(key)) return false; seen.add(key); return true; diff --git a/src/utilities/analytics/reference/browser.ts b/src/utilities/analytics/reference/browser.ts index 38503bb..b8e158c 100644 --- a/src/utilities/analytics/reference/browser.ts +++ b/src/utilities/analytics/reference/browser.ts @@ -2,8 +2,8 @@ * Browser-friendly reference data loader using fetch. */ -import type { CoreList, CommonWordsData, SynonymsData } from "../metrics/types"; -import type { ReferenceDataProvider } from "./index"; +import type { CoreList, CommonWordsData, SynonymsData } from '../metrics/types'; +import type { ReferenceDataProvider } from './index'; export interface ReferenceData { coreLists: CoreList[]; @@ -46,16 +46,12 @@ export class InMemoryReferenceLoader implements ReferenceDataProvider { } async loadCommonFringe(): Promise { - const commonWords = new Set( - this.data.commonWords.words.map((w) => w.toLowerCase()), - ); + const commonWords = new Set(this.data.commonWords.words.map((w) => w.toLowerCase())); const coreWords = new Set(); this.data.coreLists.forEach((list) => { list.words.forEach((word) => coreWords.add(word.toLowerCase())); }); - return Promise.resolve( - Array.from(commonWords).filter((word) => !coreWords.has(word)), - ); + return Promise.resolve(Array.from(commonWords).filter((word) => !coreWords.has(word))); } async loadAll(): Promise { @@ -65,9 +61,9 @@ export class InMemoryReferenceLoader implements ReferenceDataProvider { export async function loadReferenceDataFromUrl( baseUrl: string, - locale = "en", + locale = 'en' ): Promise { - const root = baseUrl.replace(/\/$/, ""); + const root = baseUrl.replace(/\/$/, ''); const fetchJson = async (name: string): Promise => { const res = await fetch(`${root}/${name}.${locale}.json`); if (!res.ok) { @@ -76,15 +72,14 @@ export async function loadReferenceDataFromUrl( return (await res.json()) as T; }; - const [coreLists, commonWords, synonyms, sentences, fringe, baseWords] = - await Promise.all([ - fetchJson("core_lists"), - fetchJson("common_words"), - fetchJson("synonyms"), - fetchJson("sentences"), - fetchJson("fringe"), - fetchJson<{ [word: string]: boolean }>("base_words"), - ]); + const [coreLists, commonWords, synonyms, sentences, fringe, baseWords] = await Promise.all([ + fetchJson('core_lists'), + fetchJson('common_words'), + fetchJson('synonyms'), + fetchJson('sentences'), + fetchJson('fringe'), + fetchJson<{ [word: string]: boolean }>('base_words'), + ]); return { coreLists, @@ -98,7 +93,7 @@ export async function loadReferenceDataFromUrl( export async function createBrowserReferenceLoader( baseUrl: string, - locale = "en", + locale = 'en' ): Promise { const data = await loadReferenceDataFromUrl(baseUrl, locale); return new InMemoryReferenceLoader(data); diff --git a/src/utilities/analytics/reference/index.ts b/src/utilities/analytics/reference/index.ts index 94a62b2..235fcd3 100644 --- a/src/utilities/analytics/reference/index.ts +++ b/src/utilities/analytics/reference/index.ts @@ -5,8 +5,8 @@ * for AAC metrics analysis. */ -import { CoreList, CommonWordsData, SynonymsData } from "../metrics/types"; -import { defaultFileAdapter, FileAdapter } from "../../../utils/io"; +import { CoreList, CommonWordsData, SynonymsData } from '../metrics/types'; +import { defaultFileAdapter, FileAdapter } from '../../../utils/io'; export interface ReferenceDataProvider { loadCoreLists(): Promise; @@ -33,8 +33,8 @@ export class ReferenceLoader { constructor( dataDir?: string, - locale: string = "en", - fileAdapter: FileAdapter = defaultFileAdapter, + locale: string = 'en', + fileAdapter: FileAdapter = defaultFileAdapter ) { this.locale = locale; this.fileAdapter = fileAdapter; @@ -44,7 +44,7 @@ export class ReferenceLoader { } else { // Resolve the data directory relative to this file's location // Use __dirname which works correctly after compilation - this.dataDir = this.fileAdapter.join(__dirname, "data"); + this.dataDir = this.fileAdapter.join(__dirname, 'data'); } } @@ -53,10 +53,7 @@ export class ReferenceLoader { */ async loadCoreLists(): Promise { const { readTextFromInput } = this.fileAdapter; - const filePath = this.fileAdapter.join( - this.dataDir, - `core_lists.${this.locale}.json`, - ); + const filePath = this.fileAdapter.join(this.dataDir, `core_lists.${this.locale}.json`); const content = await readTextFromInput(filePath); return JSON.parse(String(content)) as CoreList[]; } @@ -131,9 +128,7 @@ export class ReferenceLoader { */ async loadCommonFringe(): Promise { const commonWordsData = await this.loadCommonWords(); - const commonWords = new Set( - commonWordsData.words.map((w) => w.toLowerCase()), - ); + const commonWords = new Set(commonWordsData.words.map((w) => w.toLowerCase())); const coreLists = await this.loadCoreLists(); const coreWords = new Set(); @@ -142,9 +137,7 @@ export class ReferenceLoader { }); // Common fringe = common words - core words - const commonFringe = Array.from(commonWords).filter( - (word) => !coreWords.has(word), - ); + const commonFringe = Array.from(commonWords).filter((word) => !coreWords.has(word)); return commonFringe; } @@ -173,29 +166,27 @@ export class ReferenceLoader { /** * Get the default reference data path */ -export function getReferenceDataPath( - fileAdapter: FileAdapter = defaultFileAdapter, -): string { - return String(fileAdapter.join(__dirname, "data")); +export function getReferenceDataPath(fileAdapter: FileAdapter = defaultFileAdapter): string { + return String(fileAdapter.join(__dirname, 'data')); } /** * Check if reference data files exist */ export async function hasReferenceData( - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { pathExists, join } = fileAdapter; const dataPath = getReferenceDataPath(); const requiredFiles = [ - "core_lists.en.json", - "common_words.en.json", - "sentences.en.json", - "synonyms.en.json", - "fringe.en.json", + 'core_lists.en.json', + 'common_words.en.json', + 'sentences.en.json', + 'synonyms.en.json', + 'fringe.en.json', ]; const existingPaths = await Promise.all( - requiredFiles.map(async (file) => await pathExists(join(dataPath, file))), + requiredFiles.map(async (file) => await pathExists(join(dataPath, file))) ); return existingPaths.every((exists) => exists); } diff --git a/src/utilities/analytics/utils/idGenerator.ts b/src/utilities/analytics/utils/idGenerator.ts index 06560a6..6cde77c 100644 --- a/src/utilities/analytics/utils/idGenerator.ts +++ b/src/utilities/analytics/utils/idGenerator.ts @@ -16,8 +16,8 @@ export function normalizeLabelForCloneId(label: string): string { return label .toLowerCase() - .replace(/['']/g, "") // Remove apostrophes - .replace(/\s+/g, "_") // Replace spaces with underscores + .replace(/['']/g, '') // Remove apostrophes + .replace(/\s+/g, '_') // Replace spaces with underscores .trim(); } @@ -39,7 +39,7 @@ export function generateCloneId( cols: number, row: number, col: number, - label: string, + label: string ): string { const normalizedLabel = normalizeLabelForCloneId(label); return `${rows}x${cols}-${row}.${col}-${normalizedLabel}`; @@ -57,7 +57,7 @@ export function generateCloneId( * @returns A semantic_id string (hash-based) */ export function generateSemanticId(message: string, label: string): string { - const content = `${message || ""}::${label || ""}`; + const content = `${message || ''}::${label || ''}`; // Simple hash function (djb2 algorithm) let hash = 5381; for (let i = 0; i < content.length; i++) { @@ -73,9 +73,7 @@ export function generateSemanticId(message: string, label: string): string { * @param buttons - Array of buttons to scan * @returns Array of unique semantic_id strings */ -export function extractSemanticIds( - buttons: Array<{ semantic_id?: string }>, -): string[] { +export function extractSemanticIds(buttons: Array<{ semantic_id?: string }>): string[] { const ids = new Set(); for (const button of buttons) { if (button.semantic_id) { @@ -91,9 +89,7 @@ export function extractSemanticIds( * @param buttons - Array of buttons to scan * @returns Array of unique clone_id strings */ -export function extractCloneIds( - buttons: Array<{ clone_id?: string }>, -): string[] { +export function extractCloneIds(buttons: Array<{ clone_id?: string }>): string[] { const ids = new Set(); for (const button of buttons) { if (button.clone_id) { diff --git a/src/utilities/symbolTools.ts b/src/utilities/symbolTools.ts index 70dc3fd..ae69264 100644 --- a/src/utilities/symbolTools.ts +++ b/src/utilities/symbolTools.ts @@ -1,10 +1,10 @@ -import { extractSymbolReferences } from "../processors/gridset/symbols"; -import { defaultFileAdapter, FileAdapter } from "../utils/io"; +import { extractSymbolReferences } from '../processors/gridset/symbols'; +import { defaultFileAdapter, FileAdapter } from '../utils/io'; // Dynamic imports for optional dependencies -type Database = typeof import("better-sqlite3"); -type AdmZip = typeof import("adm-zip"); -type XMLParser = typeof import("fast-xml-parser").XMLParser; +type Database = typeof import('better-sqlite3'); +type AdmZip = typeof import('adm-zip'); +type XMLParser = typeof import('fast-xml-parser').XMLParser; // --- Base Classes --- export abstract class SymbolExtractor { @@ -16,11 +16,7 @@ export abstract class SymbolResolver { protected dbPath: string; protected fileAdapter: FileAdapter; - constructor( - symbolPath: string, - dbPath: string, - fileAdapter: FileAdapter = defaultFileAdapter, - ) { + constructor(symbolPath: string, dbPath: string, fileAdapter: FileAdapter = defaultFileAdapter) { this.symbolPath = symbolPath; this.dbPath = dbPath; this.fileAdapter = fileAdapter; @@ -33,19 +29,17 @@ export abstract class SymbolResolver { let Database: Database | null = null; try { // eslint-disable-next-line @typescript-eslint/no-var-requires - Database = require("better-sqlite3"); + Database = require('better-sqlite3'); } catch { Database = null; } export class SnapSymbolExtractor extends SymbolExtractor { getSymbolReferences(filePath: string): string[] { - if (!Database) throw new Error("better-sqlite3 not installed"); + if (!Database) throw new Error('better-sqlite3 not installed'); const db = new Database(filePath, { readonly: true }); const rows = db - .prepare( - "SELECT DISTINCT LibrarySymbolId FROM Button WHERE LibrarySymbolId IS NOT NULL", - ) + .prepare('SELECT DISTINCT LibrarySymbolId FROM Button WHERE LibrarySymbolId IS NOT NULL') .all() as { LibrarySymbolId: number }[]; db.close(); return rows.map((row) => String(row.LibrarySymbolId)); @@ -55,12 +49,10 @@ export class SnapSymbolExtractor extends SymbolExtractor { export class SnapSymbolResolver extends SymbolResolver { async resolveSymbol(symbolRef: string): Promise { const { join, writeBinaryToPath } = this.fileAdapter; - if (!Database) throw new Error("better-sqlite3 not installed"); + if (!Database) throw new Error('better-sqlite3 not installed'); const db = new Database(this.dbPath, { readonly: true }); - const query = "SELECT ImageData FROM Symbol WHERE Id = ?"; - const row = db.prepare(query).get(symbolRef) as - | { ImageData: Buffer } - | undefined; + const query = 'SELECT ImageData FROM Symbol WHERE Id = ?'; + const row = db.prepare(query).get(symbolRef) as { ImageData: Buffer } | undefined; db.close(); if (!row) return null; @@ -76,9 +68,9 @@ let XMLParser: XMLParser | null = null; try { // Dynamic requires for optional dependencies // eslint-disable-next-line @typescript-eslint/no-var-requires - const admZipModule = require("adm-zip"); + const admZipModule = require('adm-zip'); // eslint-disable-next-line @typescript-eslint/no-var-requires - const fxpModule = require("fast-xml-parser"); + const fxpModule = require('fast-xml-parser'); AdmZip = admZipModule; XMLParser = fxpModule.XMLParser; } catch { @@ -88,12 +80,11 @@ try { export class Grid3SymbolExtractor extends SymbolExtractor { getSymbolReferences(filePath: string): string[] { - if (!AdmZip || !XMLParser) - throw new Error("adm-zip or fast-xml-parser not installed"); + if (!AdmZip || !XMLParser) throw new Error('adm-zip or fast-xml-parser not installed'); // Import GridsetProcessor dynamically to avoid circular dependencies // eslint-disable-next-line @typescript-eslint/no-var-requires - const { GridsetProcessor } = require("../processors/gridsetProcessor"); + const { GridsetProcessor } = require('../processors/gridsetProcessor'); const proc = new GridsetProcessor(); const tree = proc.loadIntoTree(filePath); @@ -134,11 +125,11 @@ export class TouchChatSymbolResolver extends SymbolResolver { export async function resolveSymbol( label: string, symbolDir: string, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { const { join, pathExists } = fileAdapter; - const cleanLabel = label.toLowerCase().replace(/[^a-z0-9]/g, ""); - const exts = [".png", ".jpg", ".svg"]; + const cleanLabel = label.toLowerCase().replace(/[^a-z0-9]/g, ''); + const exts = ['.png', '.jpg', '.svg']; for (const ext of exts) { const symbolPath = join(symbolDir, cleanLabel + ext); diff --git a/src/utilities/translation/translationProcessor.ts b/src/utilities/translation/translationProcessor.ts index 831712a..d21ebc0 100644 --- a/src/utilities/translation/translationProcessor.ts +++ b/src/utilities/translation/translationProcessor.ts @@ -79,7 +79,7 @@ export function normalizeButtonForTranslation( pageId?: string; pageName?: string; }, - grammar?: any, + grammar?: any ): ButtonForTranslation { return { buttonId, @@ -103,15 +103,12 @@ export function normalizeButtonForTranslation( * @param button - Button object from any AAC format * @returns Array of symbol info, or undefined if no symbols */ -export function extractSymbolsFromButton( - button: any, -): SymbolInfo[] | undefined { +export function extractSymbolsFromButton(button: any): SymbolInfo[] | undefined { const symbols: SymbolInfo[] = []; // Method 1: Check for semanticAction.richText.symbols (gridset format) if (button.semanticAction?.richText?.symbols) { - const richTextSymbols = button.semanticAction.richText - .symbols as SymbolInfo[]; + const richTextSymbols = button.semanticAction.richText.symbols as SymbolInfo[]; if (Array.isArray(richTextSymbols) && richTextSymbols.length > 0) { symbols.push(...richTextSymbols); return symbols; @@ -119,7 +116,7 @@ export function extractSymbolsFromButton( } // Determine the text to attach symbol to - const text = button.label || button.message || ""; + const text = button.label || button.message || ''; if (!text) { return undefined; } @@ -136,11 +133,7 @@ export function extractSymbolsFromButton( } // Method 3: Check if image field contains a symbol reference - if ( - button.image && - typeof button.image === "string" && - button.image.startsWith("[") - ) { + if (button.image && typeof button.image === 'string' && button.image.startsWith('[')) { symbols.push({ text, image: button.image, @@ -164,18 +157,16 @@ export function extractSymbolsFromButton( */ export function extractAllButtonsForTranslation( buttons: any[], - contextFn?: (button: any) => { pageId?: string; pageName?: string }, + contextFn?: (button: any) => { pageId?: string; pageName?: string } ): ButtonForTranslation[] { const results: ButtonForTranslation[] = []; for (const button of buttons) { if (!button) continue; - const buttonId = (button.id || - button.buttonId || - `button_${results.length}`) as string; - const label = (button.label || "") as string; - const message = (button.message || "") as string; + const buttonId = (button.id || button.buttonId || `button_${results.length}`) as string; + const label = (button.label || '') as string; + const message = (button.message || '') as string; const symbols = extractSymbolsFromButton(button); // Only include buttons that have text to translate @@ -185,14 +176,7 @@ export function extractAllButtonsForTranslation( const grammar = button.parameters?.grammar || undefined; results.push( - normalizeButtonForTranslation( - buttonId, - label, - message, - symbols || [], - context, - grammar, - ), + normalizeButtonForTranslation(buttonId, label, message, symbols || [], context, grammar) ); } @@ -211,7 +195,7 @@ export function extractAllButtonsForTranslation( */ export function createTranslationPrompt( buttons: ButtonForTranslation[], - targetLanguage: string, + targetLanguage: string ): string { const buttonsData = JSON.stringify(buttons, null, 2); @@ -264,10 +248,10 @@ Ensure all symbol image references are preserved exactly as provided.`; export function validateTranslationResults( translations: LLMLTranslationResult[], originalButtonIds?: string[], - options?: { allowPartial?: boolean }, + options?: { allowPartial?: boolean } ): void { if (!Array.isArray(translations)) { - throw new Error("Translation results must be an array"); + throw new Error('Translation results must be an array'); } const translatedIds = new Set(translations.map((t) => t.buttonId)); @@ -284,12 +268,10 @@ export function validateTranslationResults( // Check each translation has required fields for (const trans of translations) { if (!trans.buttonId) { - throw new Error("Translation missing buttonId"); + throw new Error('Translation missing buttonId'); } if (!trans.translatedMessage && !trans.translatedLabel) { - throw new Error( - `Translation for ${trans.buttonId} has no translated text`, - ); + throw new Error(`Translation for ${trans.buttonId} has no translated text`); } } } diff --git a/src/utils/io.ts b/src/utils/io.ts index e71f511..7a77bce 100644 --- a/src/utils/io.ts +++ b/src/utils/io.ts @@ -4,10 +4,7 @@ export type BinaryOutput = Buffer | Uint8Array; export interface FileAdapter { readBinaryFromInput: (input: ProcessorInput) => Promise; - readTextFromInput: ( - input: ProcessorInput, - encoding?: BufferEncoding, - ) => Promise; + readTextFromInput: (input: ProcessorInput, encoding?: BufferEncoding) => Promise; writeBinaryToPath: (outputPath: string, data: BinaryOutput) => Promise; writeTextToPath: (outputPath: string, text: string) => Promise; pathExists: (path: string) => Promise; @@ -15,115 +12,103 @@ export interface FileAdapter { getFileSize: (path: string) => Promise; mkDir: (path: string, options?: { recursive?: boolean }) => Promise; listDir: (path: string) => Promise; - removePath: ( - path: string, - options?: { recursive?: boolean; force?: boolean }, - ) => Promise; + removePath: (path: string, options?: { recursive?: boolean; force?: boolean }) => Promise; mkTempDir: (prefix: string) => Promise; join: (...pathParts: string[]) => string; dirname: (path: string) => string; basename: (path: string, suffix?: string) => string; } -let cachedFs: typeof import("node:fs") | null = null; -let cachedPath: typeof import("path") | null = null; -let cachedOs: typeof import("os") | null = null; +let cachedFs: typeof import('node:fs') | null = null; +let cachedPath: typeof import('path') | null = null; +let cachedOs: typeof import('os') | null = null; let cachedRequire: NodeRequire | null | undefined = undefined; type NodeRequire = (id: string) => any; export function getNodeRequire(): NodeRequire { if (cachedRequire === undefined) { - if (typeof require === "function") { + if (typeof require === 'function') { cachedRequire = require; - } else if (typeof globalThis !== "undefined") { + } else if (typeof globalThis !== 'undefined') { const maybeRequire = (globalThis as { require?: unknown }).require; - cachedRequire = - typeof maybeRequire === "function" - ? (maybeRequire as NodeRequire) - : null; + cachedRequire = typeof maybeRequire === 'function' ? (maybeRequire as NodeRequire) : null; } else { cachedRequire = null; } } if (!cachedRequire) { - throw new Error("File system access is not available in this environment."); + throw new Error('File system access is not available in this environment.'); } return cachedRequire; } -function getFs(): typeof import("node:fs") { +function getFs(): typeof import('node:fs') { if (!cachedFs) { try { const nodeRequire = getNodeRequire(); - const fsModule = "node:fs"; + const fsModule = 'node:fs'; cachedFs = nodeRequire(fsModule); } catch { - throw new Error( - "File system access is not available in this environment.", - ); + throw new Error('File system access is not available in this environment.'); } } if (!cachedFs) { - throw new Error("File system access is not available in this environment."); + throw new Error('File system access is not available in this environment.'); } return cachedFs; } -function getPath(): typeof import("path") { +function getPath(): typeof import('path') { if (!cachedPath) { try { const nodeRequire = getNodeRequire(); - const pathModule = "path"; + const pathModule = 'path'; cachedPath = nodeRequire(pathModule); } catch { - throw new Error("Path utilities are not available in this environment."); + throw new Error('Path utilities are not available in this environment.'); } } if (!cachedPath) { - throw new Error("Path utilities are not available in this environment."); + throw new Error('Path utilities are not available in this environment.'); } return cachedPath; } -export function getOs(): typeof import("os") { +export function getOs(): typeof import('os') { if (!cachedOs) { try { const nodeRequire = getNodeRequire(); - const osModule = "os"; + const osModule = 'os'; cachedOs = nodeRequire(osModule); } catch { - throw new Error("OS utilities are not available in this environment."); + throw new Error('OS utilities are not available in this environment.'); } } if (!cachedOs) { - throw new Error("OS utilities are not available in this environment."); + throw new Error('OS utilities are not available in this environment.'); } return cachedOs; } export function isNodeRuntime(): boolean { - return typeof process !== "undefined" && !!process.versions?.node; + return typeof process !== 'undefined' && !!process.versions?.node; } export function getBasename(filePath: string): string { - const trimmed = filePath.replace(/[/\\]+$/, "") || filePath; + const trimmed = filePath.replace(/[/\\]+$/, '') || filePath; const parts = trimmed.split(/[/\\]/); return parts[parts.length - 1] || trimmed; } -export function toUint8Array( - input: Uint8Array | ArrayBuffer | Buffer, -): Uint8Array { +export function toUint8Array(input: Uint8Array | ArrayBuffer | Buffer): Uint8Array { if (input instanceof Uint8Array) { return input; } return new Uint8Array(input); } -export function toArrayBuffer( - input: Uint8Array | ArrayBuffer | Buffer, -): ArrayBuffer { +export function toArrayBuffer(input: Uint8Array | ArrayBuffer | Buffer): ArrayBuffer { if (input instanceof ArrayBuffer) { return input; } @@ -132,19 +117,19 @@ export function toArrayBuffer( } export function decodeText(input: Uint8Array): string { - if (typeof Buffer !== "undefined" && Buffer.isBuffer(input)) { - return input.toString("utf8"); + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { + return input.toString('utf8'); } - const decoder = new TextDecoder("utf-8"); + const decoder = new TextDecoder('utf-8'); return decoder.decode(input); } export function encodeBase64(input: Uint8Array): string { - if (typeof Buffer !== "undefined" && Buffer.isBuffer(input)) { - return input.toString("base64"); + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { + return input.toString('base64'); } // Browser fallback using btoa - let binary = ""; + let binary = ''; const len = input.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(input[i]); @@ -153,27 +138,25 @@ export function encodeBase64(input: Uint8Array): string { } export function encodeText(text: string): BinaryOutput { - if (typeof Buffer !== "undefined") { - return Buffer.from(text, "utf8"); + if (typeof Buffer !== 'undefined') { + return Buffer.from(text, 'utf8'); } return new TextEncoder().encode(text); } // extname algorithm from node:path -const splitDeviceRe = - /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/; //eslint-disable-line -const splitTailRe = - /^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/; //eslint-disable-line +const splitDeviceRe = /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/; //eslint-disable-line +const splitTailRe = /^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/; //eslint-disable-line export function extname(path: string): string { - const tail = splitDeviceRe.exec(path)?.at(3) ?? ""; - return splitTailRe.exec(tail)?.at(3) ?? ""; + const tail = splitDeviceRe.exec(path)?.at(3) ?? ''; + return splitTailRe.exec(tail)?.at(3) ?? ''; } async function readBinaryFromInput(input: ProcessorInput): Promise { - if (typeof input === "string") { + if (typeof input === 'string') { return Promise.resolve(getFs().readFileSync(input)); } - if (typeof Buffer !== "undefined" && Buffer.isBuffer(input)) { + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { return Promise.resolve(input); } if (input instanceof ArrayBuffer) { @@ -184,12 +167,12 @@ async function readBinaryFromInput(input: ProcessorInput): Promise { async function readTextFromInput( input: ProcessorInput, - encoding: BufferEncoding = "utf8", + encoding: BufferEncoding = 'utf8' ): Promise { - if (typeof input === "string") { + if (typeof input === 'string') { return Promise.resolve(getFs().readFileSync(input, encoding)); } - if (typeof Buffer !== "undefined" && Buffer.isBuffer(input)) { + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { return Promise.resolve(input.toString(encoding)); } if (input instanceof ArrayBuffer) { @@ -198,19 +181,13 @@ async function readTextFromInput( return Promise.resolve(decodeText(input)); } -async function writeBinaryToPath( - outputPath: string, - data: BinaryOutput, -): Promise { +async function writeBinaryToPath(outputPath: string, data: BinaryOutput): Promise { getFs().writeFileSync(outputPath, data); await Promise.resolve(); } -async function writeTextToPath( - outputPath: string, - text: string, -): Promise { - getFs().writeFileSync(outputPath, text, "utf8"); +async function writeTextToPath(outputPath: string, text: string): Promise { + getFs().writeFileSync(outputPath, text, 'utf8'); await Promise.resolve(); } @@ -226,10 +203,7 @@ async function getFileSize(path: string): Promise { return Promise.resolve(getFs().statSync(path).size); } -async function mkDir( - path: string, - options?: { recursive?: boolean }, -): Promise { +async function mkDir(path: string, options?: { recursive?: boolean }): Promise { getFs().mkdirSync(path, options); await Promise.resolve(); } @@ -240,7 +214,7 @@ async function listDir(path: string): Promise { async function removePath( path: string, - options?: { recursive?: boolean; force?: boolean }, + options?: { recursive?: boolean; force?: boolean } ): Promise { getFs().rmSync(path, options); await Promise.resolve(); diff --git a/src/utils/sqlite.ts b/src/utils/sqlite.ts index 5a85009..826a4d8 100644 --- a/src/utils/sqlite.ts +++ b/src/utils/sqlite.ts @@ -1,10 +1,5 @@ -import type { SqlJsConfig, SqlJsStatic, InitSqlJsStatic } from "sql.js"; -import { - defaultFileAdapter, - FileAdapter, - getNodeRequire, - isNodeRuntime, -} from "./io"; +import type { SqlJsConfig, SqlJsStatic, InitSqlJsStatic } from 'sql.js'; +import { defaultFileAdapter, FileAdapter, getNodeRequire, isNodeRuntime } from './io'; export interface SqliteStatementAdapter { all(...params: unknown[]): any[]; @@ -37,13 +32,10 @@ export function configureSqlJs(config: SqlJsConfig): void { async function getSqlJsBrowser(): Promise { if (!sqlJsPromise) { - const isBrowser = - typeof globalThis !== "undefined" && - (globalThis as any).window !== undefined; - if (!isBrowser) throw new Error("Must be run in a browser"); + const isBrowser = typeof globalThis !== 'undefined' && (globalThis as any).window !== undefined; + if (!isBrowser) throw new Error('Must be run in a browser'); const window = (globalThis as any).window; - if (!("initSqlJs" in window)) - throw new Error("Need to add sql-wasm.js script element to DOM"); + if (!('initSqlJs' in window)) throw new Error('Need to add sql-wasm.js script element to DOM'); const initSqlJs = window.initSqlJs as InitSqlJsStatic; sqlJsPromise = initSqlJs(sqlJsConfig ?? {}); } @@ -99,35 +91,28 @@ function createSqlJsAdapter(db: { }; } -function getBetterSqlite3(): typeof import("better-sqlite3") { +function getBetterSqlite3(): typeof import('better-sqlite3') { try { const nodeRequire = getNodeRequire(); - return nodeRequire("better-sqlite3") as typeof import("better-sqlite3"); + return nodeRequire('better-sqlite3') as typeof import('better-sqlite3'); } catch { - throw new Error("better-sqlite3 is not available in this environment."); + throw new Error('better-sqlite3 is not available in this environment.'); } } -export function requireBetterSqlite3(): typeof import("better-sqlite3") { +export function requireBetterSqlite3(): typeof import('better-sqlite3') { return getBetterSqlite3(); } export async function openSqliteDatabase( input: string | Uint8Array | ArrayBuffer | Buffer, - options: SqliteOpenOptions = {}, + options: SqliteOpenOptions = {} ): Promise { - const { - readBinaryFromInput, - mkTempDir, - writeBinaryToPath, - removePath, - join, - } = options.fileAdapter ?? defaultFileAdapter; - if (typeof input === "string") { + const { readBinaryFromInput, mkTempDir, writeBinaryToPath, removePath, join } = + options.fileAdapter ?? defaultFileAdapter; + if (typeof input === 'string') { if (!isNodeRuntime()) { - throw new Error( - "SQLite file paths are not supported in browser environments.", - ); + throw new Error('SQLite file paths are not supported in browser environments.'); } const Database = getBetterSqlite3(); const db = new Database(input, { @@ -144,8 +129,8 @@ export async function openSqliteDatabase( return { db: createSqlJsAdapter(db) }; } - const tempDir = await mkTempDir("aac-sqlite-"); - const dbPath = join(tempDir, "input.sqlite"); + const tempDir = await mkTempDir('aac-sqlite-'); + const dbPath = join(tempDir, 'input.sqlite'); await writeBinaryToPath(dbPath, data); const Database = getBetterSqlite3(); @@ -159,7 +144,7 @@ export async function openSqliteDatabase( try { await removePath(tempDir, { recursive: true, force: true }); } catch (error) { - console.warn("Failed to clean up temporary SQLite files:", error); + console.warn('Failed to clean up temporary SQLite files:', error); } } }; diff --git a/src/utils/zip.ts b/src/utils/zip.ts index 88b96ca..9b344d9 100644 --- a/src/utils/zip.ts +++ b/src/utils/zip.ts @@ -4,7 +4,7 @@ import { ProcessorInput, FileAdapter, defaultFileAdapter, -} from "./io"; +} from './io'; export interface ZipAdapter { listFiles(): string[]; @@ -19,15 +19,15 @@ export interface ZipFile { export async function getZipAdapter( input?: ProcessorInput, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { const adapter = fileAdapter ?? defaultFileAdapter; if (isNodeRuntime()) { - const AdmZip = getNodeRequire()("adm-zip") as typeof import("adm-zip"); + const AdmZip = getNodeRequire()('adm-zip') as typeof import('adm-zip'); const zip = input === undefined ? new AdmZip(input) - : typeof input === "string" + : typeof input === 'string' ? new AdmZip(input) : new AdmZip(Buffer.from(await adapter.readBinaryFromInput(input))); return { @@ -51,12 +51,10 @@ export async function getZipAdapter( }; } - const module = await import("jszip"); + const module = await import('jszip'); const JSZip = module.default || module; - const zip = input - ? await JSZip.loadAsync(await adapter.readBinaryFromInput(input)) - : new JSZip(); + const zip = input ? await JSZip.loadAsync(await adapter.readBinaryFromInput(input)) : new JSZip(); return { listFiles: (): string[] => { return Object.entries(zip.files) @@ -66,13 +64,13 @@ export async function getZipAdapter( readFile: async (name: string): Promise => { const file = zip.file(name); if (!file) throw new Error(`Zip entry not found: ${name}`); - return file.async("uint8array"); + return file.async('uint8array'); }, writeFiles: async (files: ZipFile[]): Promise => { files.forEach((file) => { zip.file(file.name, file.data); }); - return await zip.generateAsync({ type: "uint8array" }); + return await zip.generateAsync({ type: 'uint8array' }); }, }; } diff --git a/src/validation.ts b/src/validation.ts index 43761cb..a8494b8 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -14,26 +14,26 @@ export { ValidationRule, ValidationFailureError, buildValidationResultFromMessage, -} from "./validation/validationTypes"; +} from './validation/validationTypes'; // Base validator -export { BaseValidator } from "./validation/baseValidator"; +export { BaseValidator } from './validation/baseValidator'; // Format-specific validators -export { ObfValidator } from "./validation/obfValidator"; -export { GridsetValidator } from "./validation/gridsetValidator"; -export { SnapValidator } from "./validation/snapValidator"; -export { TouchChatValidator } from "./validation/touchChatValidator"; -export { AstericsGridValidator } from "./validation/astericsValidator"; -export { ExcelValidator } from "./validation/excelValidator"; -export { OpmlValidator } from "./validation/opmlValidator"; -export { DotValidator } from "./validation/dotValidator"; -export { ApplePanelsValidator } from "./validation/applePanelsValidator"; -export { ObfsetValidator } from "./validation/obfsetValidator"; +export { ObfValidator } from './validation/obfValidator'; +export { GridsetValidator } from './validation/gridsetValidator'; +export { SnapValidator } from './validation/snapValidator'; +export { TouchChatValidator } from './validation/touchChatValidator'; +export { AstericsGridValidator } from './validation/astericsValidator'; +export { ExcelValidator } from './validation/excelValidator'; +export { OpmlValidator } from './validation/opmlValidator'; +export { DotValidator } from './validation/dotValidator'; +export { ApplePanelsValidator } from './validation/applePanelsValidator'; +export { ObfsetValidator } from './validation/obfsetValidator'; // Validator factory functions export { getValidatorForFormat, getValidatorForFile, validateFileOrBuffer, -} from "./validation/index"; +} from './validation/index'; diff --git a/src/validation/applePanelsValidator.ts b/src/validation/applePanelsValidator.ts index 645f2fb..5d0e35d 100644 --- a/src/validation/applePanelsValidator.ts +++ b/src/validation/applePanelsValidator.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/require-await */ -import plist from "plist"; -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; +import plist from 'plist'; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from "../utils/io"; +} from '../utils/io'; type PanelsContainer = { panels?: any; Panels?: Record }; @@ -18,23 +18,17 @@ type PanelsContainer = { panels?: any; Panels?: Record }; export class ApplePanelsValidator extends BaseValidator { static async validateFile( filePath: string, - fileAdapter: FileAdapter = defaultFileAdapter, + fileAdapter: FileAdapter = defaultFileAdapter ): Promise { - const { pathExists, isDirectory, getFileSize, readBinaryFromInput, join } = - fileAdapter; + const { pathExists, isDirectory, getFileSize, readBinaryFromInput, join } = fileAdapter; const validator = new ApplePanelsValidator(); let content: Uint8Array; const filename = getBasename(filePath); let size = 0; const isDir = await isDirectory(filePath); - if (isDir && filename.toLowerCase().endsWith(".ascconfig")) { - const panelPath = join( - filePath, - "Contents", - "Resources", - "PanelDefinitions.plist", - ); + if (isDir && filename.toLowerCase().endsWith('.ascconfig')) { + const panelPath = join(filePath, 'Contents', 'Resources', 'PanelDefinitions.plist'); if (!(await pathExists(panelPath))) { return validator.validate(Buffer.alloc(0), filename, 0); } @@ -48,27 +42,21 @@ export class ApplePanelsValidator extends BaseValidator { return validator.validate(content, filename, size); } - static async identifyFormat( - content: any, - filename: string, - ): Promise { + static async identifyFormat(content: any, filename: string): Promise { const name = filename.toLowerCase(); - if (name.endsWith(".plist") || name.endsWith(".ascconfig")) { + if (name.endsWith('.plist') || name.endsWith('.ascconfig')) { return true; } try { if ( - typeof content !== "string" && + typeof content !== 'string' && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const str = - typeof content === "string" - ? content - : decodeText(toUint8Array(content)); + const str = typeof content === 'string' ? content : decodeText(toUint8Array(content)); const parsed = plist.parse(str) as PanelsContainer; return Boolean(parsed.panels || parsed.Panels); } catch { @@ -79,18 +67,18 @@ export class ApplePanelsValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { this.reset(); - await this.add_check("filename", "file extension", async () => { + await this.add_check('filename', 'file extension', async () => { if (!filename.toLowerCase().match(/\.(plist|ascconfig)$/)) { - this.warn("filename should end with .plist or .ascconfig"); + this.warn('filename should end with .plist or .ascconfig'); } }); let parsed: PanelsContainer | null = null; - await this.add_check("plist_parse", "valid plist/XML", async () => { + await this.add_check('plist_parse', 'valid plist/XML', async () => { try { const str = decodeText(content); parsed = plist.parse(str) as PanelsContainer; @@ -100,17 +88,17 @@ export class ApplePanelsValidator extends BaseValidator { }); if (!parsed) { - return this.buildResult(filename, filesize, "applepanels"); + return this.buildResult(filename, filesize, 'applepanels'); } let panels: any[] = []; - await this.add_check("panels", "panels present", async () => { + await this.add_check('panels', 'panels present', async () => { if (Array.isArray(parsed?.panels)) { panels = parsed?.panels; - } else if (parsed?.Panels && typeof parsed.Panels === "object") { + } else if (parsed?.Panels && typeof parsed.Panels === 'object') { panels = Object.values(parsed.Panels); } else { - this.err("missing panels/PanelDefinitions content", true); + this.err('missing panels/PanelDefinitions content', true); } }); @@ -118,22 +106,20 @@ export class ApplePanelsValidator extends BaseValidator { const prefix = `panel[${idx}]`; this.add_check_sync(`${prefix}_id`, `${prefix} id`, () => { if (!panel?.ID && !panel?.id) { - this.err("panel missing ID"); + this.err('panel missing ID'); } }); this.add_check_sync(`${prefix}_buttons`, `${prefix} buttons`, () => { const buttons = Array.isArray(panel?.PanelObjects) - ? panel.PanelObjects.filter( - (obj: any) => obj?.PanelObjectType === "Button", - ) + ? panel.PanelObjects.filter((obj: any) => obj?.PanelObjectType === 'Button') : []; if (buttons.length === 0) { - this.warn("panel has no buttons"); + this.warn('panel has no buttons'); } }); }); - return this.buildResult(filename, filesize, "applepanels"); + return this.buildResult(filename, filesize, 'applepanels'); } } diff --git a/src/validation/astericsValidator.ts b/src/validation/astericsValidator.ts index 5b60930..e45851f 100644 --- a/src/validation/astericsValidator.ts +++ b/src/validation/astericsValidator.ts @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/require-await */ -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from "../utils/io"; +} from '../utils/io'; /** * Validator for Asterics Grid (.grd) JSON files @@ -18,10 +18,9 @@ export class AstericsGridValidator extends BaseValidator { */ static async validateFile( filePath: string, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { - const { readBinaryFromInput, getFileSize } = - fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; const validator = new AstericsGridValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); @@ -31,27 +30,21 @@ export class AstericsGridValidator extends BaseValidator { /** * Identify whether the content appears to be an Asterics .grd file */ - static async identifyFormat( - content: any, - filename: string, - ): Promise { + static async identifyFormat(content: any, filename: string): Promise { const name = filename.toLowerCase(); - if (name.endsWith(".grd")) { + if (name.endsWith('.grd')) { return true; } try { if ( - typeof content !== "string" && + typeof content !== 'string' && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const str = - typeof content === "string" - ? content - : decodeText(toUint8Array(content)); + const str = typeof content === 'string' ? content : decodeText(toUint8Array(content)); const json = JSON.parse(str); return Array.isArray(json?.grids); } catch { @@ -62,18 +55,18 @@ export class AstericsGridValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { this.reset(); - await this.add_check("filename", "file extension", async () => { - if (!filename.toLowerCase().endsWith(".grd")) { - this.warn("filename should end with .grd"); + await this.add_check('filename', 'file extension', async () => { + if (!filename.toLowerCase().endsWith('.grd')) { + this.warn('filename should end with .grd'); } }); let json: any = null; - await this.add_check("json_parse", "valid JSON", async () => { + await this.add_check('json_parse', 'valid JSON', async () => { try { let str = decodeText(content); if (str.charCodeAt(0) === 0xfeff) { @@ -86,12 +79,12 @@ export class AstericsGridValidator extends BaseValidator { }); if (!json) { - return this.buildResult(filename, filesize, "asterics"); + return this.buildResult(filename, filesize, 'asterics'); } - await this.add_check("grids", "grids array", async () => { + await this.add_check('grids', 'grids array', async () => { if (!Array.isArray(json.grids) || json.grids.length === 0) { - this.err("missing grids array in file", true); + this.err('missing grids array in file', true); } }); @@ -100,28 +93,28 @@ export class AstericsGridValidator extends BaseValidator { grids.forEach((grid: any, idx: number) => { const prefix = `grid[${idx}]`; this.add_check_sync(`${prefix}_id`, `${prefix} id`, () => { - if (!grid?.id || typeof grid.id !== "string") { - this.err("grid is missing an id"); + if (!grid?.id || typeof grid.id !== 'string') { + this.err('grid is missing an id'); } }); this.add_check_sync(`${prefix}_rows`, `${prefix} rowCount`, () => { - if (typeof grid?.rowCount !== "number" || grid.rowCount <= 0) { - this.err("rowCount must be a positive number"); + if (typeof grid?.rowCount !== 'number' || grid.rowCount <= 0) { + this.err('rowCount must be a positive number'); } }); this.add_check_sync(`${prefix}_elements`, `${prefix} elements`, () => { if (!Array.isArray(grid?.gridElements)) { - this.err("gridElements must be an array"); + this.err('gridElements must be an array'); return; } if (grid.gridElements.length === 0) { - this.warn("grid has no elements"); + this.warn('grid has no elements'); } }); }); - return this.buildResult(filename, filesize, "asterics"); + return this.buildResult(filename, filesize, 'asterics'); } } diff --git a/src/validation/baseValidator.ts b/src/validation/baseValidator.ts index 322b856..2c05fa8 100644 --- a/src/validation/baseValidator.ts +++ b/src/validation/baseValidator.ts @@ -1,12 +1,12 @@ -import { defaultFileAdapter } from "../utils/io"; -import { getZipAdapter } from "../utils/zip"; +import { defaultFileAdapter } from '../utils/io'; +import { getZipAdapter } from '../utils/zip'; import { ValidationError, ValidationResult, ValidationCheck, ValidationOptions, ValidationConfig, -} from "./validationTypes"; +} from './validationTypes'; /** * Base class for all format validators @@ -52,7 +52,7 @@ export abstract class BaseValidator { protected async add_check( type: string, description: string, - checkFn: () => Promise, + checkFn: () => Promise ): Promise { // Skip if blocked by a previous error if (this._blocked && this._options.stopOnBlocker) { @@ -86,11 +86,7 @@ export abstract class BaseValidator { /** * Add a synchronous validation check */ - protected add_check_sync( - type: string, - description: string, - checkFn: () => void, - ): void { + protected add_check_sync(type: string, description: string, checkFn: () => void): void { // Convert sync to async for consistency // eslint-disable-next-line @typescript-eslint/require-await void this.add_check(type, description, async () => checkFn()); @@ -160,11 +156,7 @@ export abstract class BaseValidator { /** * Build the final validation result */ - protected buildResult( - filename: string, - filesize: number, - format: string, - ): ValidationResult { + protected buildResult(filename: string, filesize: number, format: string): ValidationResult { return { filename, filesize, @@ -183,11 +175,7 @@ export abstract class BaseValidator { * @param filename - Name of the file being validated * @param filesize - Size of the file in bytes */ - abstract validate( - content: any, - filename: string, - filesize: number, - ): Promise; + abstract validate(content: any, filename: string, filesize: number): Promise; /** * Static helper to validate from file path @@ -195,17 +183,14 @@ export abstract class BaseValidator { */ // eslint-disable-next-line @typescript-eslint/require-await static async validateFile(_filePath: string): Promise { - throw new Error("validateFile must be implemented by subclass"); + throw new Error('validateFile must be implemented by subclass'); } /** * Static helper to identify if content is this validator's format */ // eslint-disable-next-line @typescript-eslint/require-await - static async identifyFormat( - _content: any, - _filename: string, - ): Promise { - throw new Error("identifyFormat must be implemented by subclass"); + static async identifyFormat(_content: any, _filename: string): Promise { + throw new Error('identifyFormat must be implemented by subclass'); } } diff --git a/src/validation/dotValidator.ts b/src/validation/dotValidator.ts index 24ea62a..ae29a50 100644 --- a/src/validation/dotValidator.ts +++ b/src/validation/dotValidator.ts @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/require-await */ -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from "../utils/io"; +} from '../utils/io'; /** * Validator for Graphviz DOT files @@ -15,36 +15,29 @@ import { export class DotValidator extends BaseValidator { static async validateFile( filePath: string, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { - const { readBinaryFromInput, getFileSize } = - fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; const validator = new DotValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); return validator.validate(content, getBasename(filePath), size); } - static async identifyFormat( - content: any, - filename: string, - ): Promise { + static async identifyFormat(content: any, filename: string): Promise { const name = filename.toLowerCase(); - if (name.endsWith(".dot")) return true; + if (name.endsWith('.dot')) return true; try { if ( - typeof content !== "string" && + typeof content !== 'string' && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const str = - typeof content === "string" - ? content - : decodeText(toUint8Array(content)); - return str.includes("digraph") || str.includes("->"); + const str = typeof content === 'string' ? content : decodeText(toUint8Array(content)); + return str.includes('digraph') || str.includes('->'); } catch { return false; } @@ -53,42 +46,41 @@ export class DotValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { this.reset(); - await this.add_check("filename", "file extension", async () => { - if (!filename.toLowerCase().endsWith(".dot")) { - this.warn("filename should end with .dot"); + await this.add_check('filename', 'file extension', async () => { + if (!filename.toLowerCase().endsWith('.dot')) { + this.warn('filename should end with .dot'); } }); - let text = ""; - await this.add_check("text", "text content", async () => { + let text = ''; + await this.add_check('text', 'text content', async () => { text = decodeText(content); if (!text.trim()) { - this.err("DOT file is empty", true); + this.err('DOT file is empty', true); } // Basic control character check const head = text.substring(0, 200); for (let i = 0; i < head.length; i++) { const code = head.charCodeAt(i); if (code === 0) { - this.err("DOT appears to be binary data", true); + this.err('DOT appears to be binary data', true); } } }); if (!text) { - return this.buildResult(filename, filesize, "dot"); + return this.buildResult(filename, filesize, 'dot'); } let nodes: Array<{ id: string; label: string }> = []; let edges: Array<{ from: string; to: string; label?: string }> = []; - await this.add_check("structure", "graph structure", async () => { - const edgeRegex = - /"?([^"\s]+)"?\s*->\s*"?([^"\s]+)"?(?:\s*\[label="([^"]+)"\])?/g; + await this.add_check('structure', 'graph structure', async () => { + const edgeRegex = /"?([^"\s]+)"?\s*->\s*"?([^"\s]+)"?(?:\s*\[label="([^"]+)"\])?/g; let maskedContent = text; let edgeMatch; edges = []; @@ -99,32 +91,29 @@ export class DotValidator extends BaseValidator { edges.push({ from, to, label }); nodeMap.set(from, { id: from, label: from }); nodeMap.set(to, { id: to, label: to }); - maskedContent = maskedContent.replace( - fullMatch, - " ".repeat(fullMatch.length), - ); + maskedContent = maskedContent.replace(fullMatch, ' '.repeat(fullMatch.length)); } const nodeRegex = /"?([^"\s]+)"?\s*\[label="((?:[^"\\]|\\.)*)"\]/g; let nodeMatch; while ((nodeMatch = nodeRegex.exec(maskedContent)) !== null) { const [, id, rawLabel] = nodeMatch; - const label = rawLabel.replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + const label = rawLabel.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); nodeMap.set(id, { id, label }); } nodes = Array.from(nodeMap.values()); if (nodes.length === 0 && edges.length === 0) { - this.err("no nodes or edges found in DOT content", true); + this.err('no nodes or edges found in DOT content', true); } }); - await this.add_check("connections", "navigation edges", async () => { + await this.add_check('connections', 'navigation edges', async () => { if (edges.length === 0) { - this.warn("graph contains no edges; navigation buttons may be missing"); + this.warn('graph contains no edges; navigation buttons may be missing'); } }); - return this.buildResult(filename, filesize, "dot"); + return this.buildResult(filename, filesize, 'dot'); } } diff --git a/src/validation/excelValidator.ts b/src/validation/excelValidator.ts index ba85978..f8f03d3 100644 --- a/src/validation/excelValidator.ts +++ b/src/validation/excelValidator.ts @@ -1,13 +1,8 @@ /* eslint-disable @typescript-eslint/require-await */ -import * as ExcelJS from "exceljs"; -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; -import { - defaultFileAdapter, - FileAdapter, - getBasename, - toArrayBuffer, -} from "../utils/io"; +import * as ExcelJS from 'exceljs'; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; +import { defaultFileAdapter, FileAdapter, getBasename, toArrayBuffer } from '../utils/io'; /** * Validator for Excel imports (.xlsx/.xls) @@ -15,57 +10,48 @@ import { export class ExcelValidator extends BaseValidator { static async validateFile( filePath: string, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { - const { readBinaryFromInput, getFileSize } = - fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; const validator = new ExcelValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); return validator.validate(content, getBasename(filePath), size); } - static async identifyFormat( - _content: any, - filename: string, - ): Promise { + static async identifyFormat(_content: any, filename: string): Promise { const name = filename.toLowerCase(); - return name.endsWith(".xlsx") || name.endsWith(".xls"); + return name.endsWith('.xlsx') || name.endsWith('.xls'); } async validate( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { this.reset(); - const ext = filename.toLowerCase().split(".").pop() || ""; + const ext = filename.toLowerCase().split('.').pop() || ''; - await this.add_check("filename", "file extension", async () => { - if (!["xlsx", "xls"].includes(ext)) { - this.warn("filename should end with .xlsx or .xls"); + await this.add_check('filename', 'file extension', async () => { + if (!['xlsx', 'xls'].includes(ext)) { + this.warn('filename should end with .xlsx or .xls'); } }); - if (ext === "xls") { + if (ext === 'xls') { // exceljs cannot parse legacy .xls files - await this.add_check("xls_support", "legacy Excel format", async () => { - this.err( - "legacy .xls files are not supported; please provide .xlsx", - true, - ); + await this.add_check('xls_support', 'legacy Excel format', async () => { + this.err('legacy .xls files are not supported; please provide .xlsx', true); }); - return this.buildResult(filename, filesize, "excel"); + return this.buildResult(filename, filesize, 'excel'); } const buffer = - typeof Buffer !== "undefined" && Buffer.isBuffer(content) - ? content - : toArrayBuffer(content); + typeof Buffer !== 'undefined' && Buffer.isBuffer(content) ? content : toArrayBuffer(content); const workbook = new ExcelJS.Workbook(); - await this.add_check("open", "open workbook", async () => { + await this.add_check('open', 'open workbook', async () => { try { await workbook.xlsx.load(buffer); } catch (e: any) { @@ -73,23 +59,23 @@ export class ExcelValidator extends BaseValidator { } }); - await this.add_check("worksheets", "worksheets exist", async () => { + await this.add_check('worksheets', 'worksheets exist', async () => { if (workbook.worksheets.length === 0) { - this.err("Excel workbook has no worksheets", true); + this.err('Excel workbook has no worksheets', true); } }); const firstSheet = workbook.worksheets[0]; if (firstSheet) { - await this.add_check("content", "worksheet has content", async () => { + await this.add_check('content', 'worksheet has content', async () => { const rows = firstSheet.actualRowCount || firstSheet.rowCount; const cols = firstSheet.columnCount; if (!rows || !cols) { - this.err("first worksheet is empty", true); + this.err('first worksheet is empty', true); } }); } - return this.buildResult(filename, filesize, "excel"); + return this.buildResult(filename, filesize, 'excel'); } } diff --git a/src/validation/gridsetValidator.ts b/src/validation/gridsetValidator.ts index f9e324b..338abe9 100644 --- a/src/validation/gridsetValidator.ts +++ b/src/validation/gridsetValidator.ts @@ -1,16 +1,16 @@ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-return */ -import * as xml2js from "xml2js"; -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; +import * as xml2js from 'xml2js'; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from "../utils/io"; +} from '../utils/io'; /** * Validator for Grid3/Smartbox Gridset files (.gridset, .gridsetx) @@ -25,10 +25,9 @@ export class GridsetValidator extends BaseValidator { */ static async validateFile( filePath: string, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { - const { readBinaryFromInput, getFileSize } = - fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; const validator = new GridsetValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); @@ -38,21 +37,15 @@ export class GridsetValidator extends BaseValidator { /** * Check if content is Gridset format */ - static async identifyFormat( - content: any, - filename: string, - ): Promise { + static async identifyFormat(content: any, filename: string): Promise { const name = filename.toLowerCase(); - if (name.endsWith(".gridset") || name.endsWith(".gridsetx")) { + if (name.endsWith('.gridset') || name.endsWith('.gridsetx')) { return true; } // Try to parse as XML and check for gridset structure try { - const contentStr = - typeof content === "string" - ? content - : decodeText(toUint8Array(content)); + const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content)); const parser = new xml2js.Parser(); const result = await parser.parseStringPromise(contentStr); return result && (result.gridset || result.Gridset); @@ -67,32 +60,26 @@ export class GridsetValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { this.reset(); - const isEncrypted = filename.toLowerCase().endsWith(".gridsetx"); + const isEncrypted = filename.toLowerCase().endsWith('.gridsetx'); // eslint-disable-next-line @typescript-eslint/require-await - await this.add_check("filename", "file extension", async () => { + await this.add_check('filename', 'file extension', async () => { if (!filename.match(/\.gridsetx?$/)) { - this.warn("filename should end with .gridset or .gridsetx"); + this.warn('filename should end with .gridset or .gridsetx'); } }); // For encrypted .gridsetx files, we can't validate the content if (isEncrypted) { // eslint-disable-next-line @typescript-eslint/require-await - await this.add_check( - "encrypted_format", - "encrypted gridsetx file", - async () => { - this.warn( - "gridsetx files are encrypted and cannot be fully validated", - ); - }, - ); - return this.buildResult(filename, filesize, "gridset"); + await this.add_check('encrypted_format', 'encrypted gridsetx file', async () => { + this.warn('gridsetx files are encrypted and cannot be fully validated'); + }); + return this.buildResult(filename, filesize, 'gridset'); } const isZip = this.isZip(content); @@ -103,7 +90,7 @@ export class GridsetValidator extends BaseValidator { await this.validateSingleXml(content, filename, filesize); } - return this.buildResult(filename, filesize, "gridset"); + return this.buildResult(filename, filesize, 'gridset'); } /** @@ -111,12 +98,7 @@ export class GridsetValidator extends BaseValidator { */ private isZip(content: Buffer | Uint8Array): boolean { if (content.length < 4) return false; - return ( - content[0] === 0x50 && - content[1] === 0x4b && - content[2] === 0x03 && - content[3] === 0x04 - ); + return content[0] === 0x50 && content[1] === 0x4b && content[2] === 0x03 && content[3] === 0x04; } /** @@ -125,10 +107,10 @@ export class GridsetValidator extends BaseValidator { private async validateSingleXml( content: Buffer | Uint8Array, filename: string, - _filesize: number, + _filesize: number ): Promise { let xmlObj: any = null; - await this.add_check("xml_parse", "valid XML", async () => { + await this.add_check('xml_parse', 'valid XML', async () => { try { const parser = new xml2js.Parser(); const contentStr = decodeText(content); @@ -140,9 +122,9 @@ export class GridsetValidator extends BaseValidator { if (!xmlObj) return; - await this.add_check("xml_structure", "gridset root element", async () => { + await this.add_check('xml_structure', 'gridset root element', async () => { if (!xmlObj.gridset && !xmlObj.Gridset) { - this.err("missing root gridset element", true); + this.err('missing root gridset element', true); } }); @@ -158,78 +140,58 @@ export class GridsetValidator extends BaseValidator { private async validateZipArchive( content: Buffer | Uint8Array, filename: string, - _filesize: number, + _filesize: number ): Promise { const zip = await this._options.zipAdapter(content); const entries = zip.listFiles(); // Check for gridset.xml (required) - await this.add_check( - "gridset_xml_presence", - "gridset.xml presence", - async () => { - const gridsetEntry = entries.find( - (e) => e.toLowerCase() === "gridset.xml", - ); - if (!gridsetEntry) { - this.err("Missing gridset.xml in archive", true); - } else { - try { - const gridsetXml = await zip.readFile(gridsetEntry); - const parser = new xml2js.Parser(); - const xmlObj = await parser.parseStringPromise(gridsetXml); - const gridset = xmlObj.gridset || xmlObj.Gridset; - if (!gridset) { - this.err("Invalid gridset.xml structure", true); - } else { - await this.validateGridsetStructure( - gridset, - filename, - new Uint8Array(), - ); - } - } catch (e: any) { - this.err(`Failed to parse gridset.xml: ${e.message}`, true); + await this.add_check('gridset_xml_presence', 'gridset.xml presence', async () => { + const gridsetEntry = entries.find((e) => e.toLowerCase() === 'gridset.xml'); + if (!gridsetEntry) { + this.err('Missing gridset.xml in archive', true); + } else { + try { + const gridsetXml = await zip.readFile(gridsetEntry); + const parser = new xml2js.Parser(); + const xmlObj = await parser.parseStringPromise(gridsetXml); + const gridset = xmlObj.gridset || xmlObj.Gridset; + if (!gridset) { + this.err('Invalid gridset.xml structure', true); + } else { + await this.validateGridsetStructure(gridset, filename, new Uint8Array()); } + } catch (e: any) { + this.err(`Failed to parse gridset.xml: ${e.message}`, true); } - }, - ); + } + }); // Check for settings.xml (highly recommended/required for metadata) - await this.add_check( - "settings_xml_presence", - "settings.xml presence", - async () => { - const settingsEntry = entries.find( - (e) => e.toLowerCase() === "settings.xml", - ); - if (!settingsEntry) { - this.warn( - "Missing settings.xml in archive (required for full metadata)", - ); - } else { - try { - const settingsXml = await zip.readFile(settingsEntry); - const parser = new xml2js.Parser(); - const xmlObj = await parser.parseStringPromise(settingsXml); - const settings = - xmlObj.GridSetSettings || - xmlObj.gridSetSettings || - xmlObj.GridsetSettings; - if (!settings) { - this.warn("Invalid settings.xml structure"); - } else { - // Basic validation of settings.xml - if (!settings.StartGrid && !settings.startGrid) { - this.warn("settings.xml missing StartGrid element"); - } + await this.add_check('settings_xml_presence', 'settings.xml presence', async () => { + const settingsEntry = entries.find((e) => e.toLowerCase() === 'settings.xml'); + if (!settingsEntry) { + this.warn('Missing settings.xml in archive (required for full metadata)'); + } else { + try { + const settingsXml = await zip.readFile(settingsEntry); + const parser = new xml2js.Parser(); + const xmlObj = await parser.parseStringPromise(settingsXml); + const settings = + xmlObj.GridSetSettings || xmlObj.gridSetSettings || xmlObj.GridsetSettings; + if (!settings) { + this.warn('Invalid settings.xml structure'); + } else { + // Basic validation of settings.xml + if (!settings.StartGrid && !settings.startGrid) { + this.warn('settings.xml missing StartGrid element'); } - } catch (e: any) { - this.warn(`Failed to parse settings.xml: ${e.message}`); } + } catch (e: any) { + this.warn(`Failed to parse settings.xml: ${e.message}`); } - }, - ); + } + }); } /** @@ -238,31 +200,31 @@ export class GridsetValidator extends BaseValidator { private async validateGridsetStructure( gridset: any, _filename: string, - _content: Buffer | Uint8Array, + _content: Buffer | Uint8Array ): Promise { // Check for required elements - await this.add_check("gridset_id", "gridset id", async () => { + await this.add_check('gridset_id', 'gridset id', async () => { const id = gridset.$.id || gridset.$.Id; if (!id) { - this.warn("gridset should have an id attribute"); + this.warn('gridset should have an id attribute'); } }); - await this.add_check("gridset_name", "gridset name", async () => { + await this.add_check('gridset_name', 'gridset name', async () => { const name = gridset.$.name || gridset.$.Name || gridset.name?.[0]; if (!name) { - this.warn("gridset should have a name attribute or element"); + this.warn('gridset should have a name attribute or element'); } }); // Check for pages - await this.add_check("pages", "pages element", async () => { + await this.add_check('pages', 'pages element', async () => { if (!gridset.pages && !gridset.Pages) { - this.err("gridset must have a pages element"); + this.err('gridset must have a pages element'); } else { const pages = gridset.pages || gridset.Pages; if (!pages[0] || !Array.isArray(pages[0].page)) { - this.warn("pages should contain at least one page element"); + this.warn('pages should contain at least one page element'); } } }); @@ -270,9 +232,9 @@ export class GridsetValidator extends BaseValidator { // Validate individual pages const pages = gridset.pages?.[0] || gridset.Pages?.[0]; if (pages && Array.isArray(pages.page)) { - await this.add_check("page_count", "page count", async () => { + await this.add_check('page_count', 'page count', async () => { if (pages.page.length === 0) { - this.err("gridset must contain at least one page"); + this.err('gridset must contain at least one page'); } }); @@ -284,41 +246,31 @@ export class GridsetValidator extends BaseValidator { } // Check for fixedCellSize - await this.add_check( - "fixed_cell_size", - "fixedCellSize element", - async () => { - const fixedSize = gridset.fixedCellSize || gridset.FixedCellSize; - if (!fixedSize) { - this.warn( - "gridset should have a fixedCellSize element for consistency", - ); - } else { - // Validate fixedCellSize structure - const size = fixedSize[0]; - if (size) { - const width = size.$.width || size.$.Width; - const height = size.$.height || size.$.Height; - - if (!width || !height) { - this.warn( - "fixedCellSize should have both width and height attributes", - ); - } else if (isNaN(parseInt(width)) || isNaN(parseInt(height))) { - this.err("fixedCellSize width and height must be valid numbers"); - } + await this.add_check('fixed_cell_size', 'fixedCellSize element', async () => { + const fixedSize = gridset.fixedCellSize || gridset.FixedCellSize; + if (!fixedSize) { + this.warn('gridset should have a fixedCellSize element for consistency'); + } else { + // Validate fixedCellSize structure + const size = fixedSize[0]; + if (size) { + const width = size.$.width || size.$.Width; + const height = size.$.height || size.$.Height; + + if (!width || !height) { + this.warn('fixedCellSize should have both width and height attributes'); + } else if (isNaN(parseInt(width)) || isNaN(parseInt(height))) { + this.err('fixedCellSize width and height must be valid numbers'); } } - }, - ); + } + }); // Check for styles - await this.add_check("styles", "styles element", async () => { + await this.add_check('styles', 'styles element', async () => { const styles = gridset.styles || gridset.Styles; if (!styles) { - this.warn( - "gridset should have a styles element for consistent formatting", - ); + this.warn('gridset should have a styles element for consistent formatting'); } }); } @@ -334,37 +286,25 @@ export class GridsetValidator extends BaseValidator { } }); - await this.add_check( - `page[${index}]_name`, - `page ${index} name`, - async () => { - const name = page.$.name || page.$.Name || page.name?.[0]; - if (!name) { - this.warn(`page ${index} should have a name`); - } - }, - ); + await this.add_check(`page[${index}]_name`, `page ${index} name`, async () => { + const name = page.$.name || page.$.Name || page.name?.[0]; + if (!name) { + this.warn(`page ${index} should have a name`); + } + }); // Check for cells - await this.add_check( - `page[${index}]_cells`, - `page ${index} cells`, - async () => { - const cells = page.cells || page.Cells; - if (!cells) { - this.warn(`page ${index} should have a cells element`); - } else { - const cellArray = cells[0]?.cell || cells[0]?.Cell; - if ( - !cellArray || - !Array.isArray(cellArray) || - cellArray.length === 0 - ) { - this.warn(`page ${index} should contain at least one cell`); - } + await this.add_check(`page[${index}]_cells`, `page ${index} cells`, async () => { + const cells = page.cells || page.Cells; + if (!cells) { + this.warn(`page ${index} should have a cells element`); + } else { + const cellArray = cells[0]?.cell || cells[0]?.Cell; + if (!cellArray || !Array.isArray(cellArray) || cellArray.length === 0) { + this.warn(`page ${index} should contain at least one cell`); } - }, - ); + } + }); // Validate cells if present const cells = page.cells?.[0] || page.Cells?.[0]; @@ -383,38 +323,22 @@ export class GridsetValidator extends BaseValidator { /** * Validate a single cell */ - private async validateCell( - cell: any, - pageIdx: number, - cellIdx: number, - ): Promise { - await this.add_check( - `page[${pageIdx}]_cell[${cellIdx}]_id`, - `cell id`, - async () => { - const id = cell.$.id || cell.$.Id; - if (!id) { - this.warn( - `cell ${cellIdx} on page ${pageIdx} is missing id attribute`, - ); - } - }, - ); + private async validateCell(cell: any, pageIdx: number, cellIdx: number): Promise { + await this.add_check(`page[${pageIdx}]_cell[${cellIdx}]_id`, `cell id`, async () => { + const id = cell.$.id || cell.$.Id; + if (!id) { + this.warn(`cell ${cellIdx} on page ${pageIdx} is missing id attribute`); + } + }); - await this.add_check( - `page[${pageIdx}]_cell[${cellIdx}]_content`, - `cell content`, - async () => { - const label = cell.$.label || cell.$.Label; - const image = cell.$.image || cell.$.Image; + await this.add_check(`page[${pageIdx}]_cell[${cellIdx}]_content`, `cell content`, async () => { + const label = cell.$.label || cell.$.Label; + const image = cell.$.image || cell.$.Image; - if (!label && !image) { - this.warn( - `cell ${cellIdx} on page ${pageIdx} should have a label or image`, - ); - } - }, - ); + if (!label && !image) { + this.warn(`cell ${cellIdx} on page ${pageIdx} should have a label or image`); + } + }); // Validate scan block number (Grid 3 attribute) await this.add_check( @@ -427,11 +351,11 @@ export class GridsetValidator extends BaseValidator { if (isNaN(blockNum) || blockNum < 1 || blockNum > 8) { this.err( `cell ${cellIdx} on page ${pageIdx} has invalid scanBlock value: ${scanBlock} (must be 1-8)`, - false, + false ); } } - }, + } ); // Check for color attributes @@ -448,7 +372,7 @@ export class GridsetValidator extends BaseValidator { if (backgroundColor.length === 0) { this.warn(`cell ${cellIdx} has empty background color`); } - }, + } ); } @@ -459,10 +383,10 @@ export class GridsetValidator extends BaseValidator { `page[${pageIdx}]_cell[${cellIdx}]_jump`, `cell jump reference`, async () => { - if (typeof jump !== "string" || jump.length === 0) { + if (typeof jump !== 'string' || jump.length === 0) { this.warn(`cell ${cellIdx} has invalid jump reference`); } - }, + } ); } } @@ -477,12 +401,12 @@ export class GridsetValidator extends BaseValidator { if (/^[a-zA-Z]+$/.test(color)) return true; // ARGB format: #AARRGGBB or #RRGGBB - if (color.startsWith("#")) { + if (color.startsWith('#')) { return color.length === 7 || color.length === 9; } // RGB format: rgb(r,g,b) or rgba(r,g,b,a) - if (color.startsWith("rgb")) { + if (color.startsWith('rgb')) { return true; // Simplified check } diff --git a/src/validation/index.ts b/src/validation/index.ts index 29d74f3..26b3266 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -11,38 +11,38 @@ export { ValidationRule, ValidationFailureError, buildValidationResultFromMessage, -} from "./validationTypes"; +} from './validationTypes'; -export { BaseValidator } from "./baseValidator"; +export { BaseValidator } from './baseValidator'; // Individual format validators -export { ObfValidator } from "./obfValidator"; -export { GridsetValidator } from "./gridsetValidator"; -export { SnapValidator } from "./snapValidator"; -export { TouchChatValidator } from "./touchChatValidator"; -export { AstericsGridValidator } from "./astericsValidator"; -export { ExcelValidator } from "./excelValidator"; -export { OpmlValidator } from "./opmlValidator"; -export { DotValidator } from "./dotValidator"; -export { ApplePanelsValidator } from "./applePanelsValidator"; -export { ObfsetValidator } from "./obfsetValidator"; +export { ObfValidator } from './obfValidator'; +export { GridsetValidator } from './gridsetValidator'; +export { SnapValidator } from './snapValidator'; +export { TouchChatValidator } from './touchChatValidator'; +export { AstericsGridValidator } from './astericsValidator'; +export { ExcelValidator } from './excelValidator'; +export { OpmlValidator } from './opmlValidator'; +export { DotValidator } from './dotValidator'; +export { ApplePanelsValidator } from './applePanelsValidator'; +export { ObfsetValidator } from './obfsetValidator'; /** * Main validator factory * Returns the appropriate validator for a given format */ -import { ObfValidator } from "./obfValidator"; -import { GridsetValidator } from "./gridsetValidator"; -import { SnapValidator } from "./snapValidator"; -import { TouchChatValidator } from "./touchChatValidator"; -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; -import { AstericsGridValidator } from "./astericsValidator"; -import { ExcelValidator } from "./excelValidator"; -import { OpmlValidator } from "./opmlValidator"; -import { DotValidator } from "./dotValidator"; -import { ApplePanelsValidator } from "./applePanelsValidator"; -import { ObfsetValidator } from "./obfsetValidator"; +import { ObfValidator } from './obfValidator'; +import { GridsetValidator } from './gridsetValidator'; +import { SnapValidator } from './snapValidator'; +import { TouchChatValidator } from './touchChatValidator'; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; +import { AstericsGridValidator } from './astericsValidator'; +import { ExcelValidator } from './excelValidator'; +import { OpmlValidator } from './opmlValidator'; +import { DotValidator } from './dotValidator'; +import { ApplePanelsValidator } from './applePanelsValidator'; +import { ObfsetValidator } from './obfsetValidator'; import { defaultFileAdapter, FileAdapter, @@ -50,39 +50,39 @@ import { isNodeRuntime, toUint8Array, type ProcessorInput, -} from "../utils/io"; +} from '../utils/io'; export function getValidatorForFormat(format: string): BaseValidator | null { switch (format.toLowerCase()) { - case "obf": - case "obz": + case 'obf': + case 'obz': return new ObfValidator(); - case "gridset": - case "gridsetx": + case 'gridset': + case 'gridsetx': return new GridsetValidator(); - case "snap": - case "spb": - case "sps": + case 'snap': + case 'spb': + case 'sps': return new SnapValidator(); - case "touchchat": - case "ce": + case 'touchchat': + case 'ce': return new TouchChatValidator(); - case "asterics": - case "grd": + case 'asterics': + case 'grd': return new AstericsGridValidator(); - case "excel": - case "xlsx": - case "xls": + case 'excel': + case 'xlsx': + case 'xls': return new ExcelValidator(); - case "opml": + case 'opml': return new OpmlValidator(); - case "dot": + case 'dot': return new DotValidator(); - case "applepanels": - case "plist": - case "ascconfig": + case 'applepanels': + case 'plist': + case 'ascconfig': return new ApplePanelsValidator(); - case "obfset": + case 'obfset': return new ObfsetValidator(); default: return null; @@ -90,34 +90,34 @@ export function getValidatorForFormat(format: string): BaseValidator | null { } export function getValidatorForFile(filename: string): BaseValidator | null { - const ext = filename.toLowerCase().split(".").pop(); + const ext = filename.toLowerCase().split('.').pop(); if (!ext) return null; switch (ext) { - case "obf": - case "obz": + case 'obf': + case 'obz': return new ObfValidator(); - case "gridset": - case "gridsetx": + case 'gridset': + case 'gridsetx': return new GridsetValidator(); - case "spb": - case "sps": + case 'spb': + case 'sps': return new SnapValidator(); - case "ce": + case 'ce': return new TouchChatValidator(); - case "grd": + case 'grd': return new AstericsGridValidator(); - case "xlsx": - case "xls": + case 'xlsx': + case 'xls': return new ExcelValidator(); - case "opml": + case 'opml': return new OpmlValidator(); - case "dot": + case 'dot': return new DotValidator(); - case "plist": - case "ascconfig": + case 'plist': + case 'ascconfig': return new ApplePanelsValidator(); - case "obfset": + case 'obfset': return new ObfsetValidator(); default: return null; @@ -132,11 +132,10 @@ export function getValidatorForFile(filename: string): BaseValidator | null { export async function validateFileOrBuffer( filePathOrBuffer: ProcessorInput, fileAdapter?: FileAdapter, - filenameHint?: string, + filenameHint?: string ): Promise { - const isPath = typeof filePathOrBuffer === "string"; - const name = - filenameHint || (isPath ? getBasename(filePathOrBuffer) : "upload"); + const isPath = typeof filePathOrBuffer === 'string'; + const name = filenameHint || (isPath ? getBasename(filePathOrBuffer) : 'upload'); const validator = getValidatorForFile(name) || getValidatorForFormat(name); const adapter = fileAdapter ?? defaultFileAdapter; @@ -146,15 +145,13 @@ export async function validateFileOrBuffer( if (isPath) { if (!isNodeRuntime()) { - throw new Error( - "File path validation is only supported in Node.js environments.", - ); + throw new Error('File path validation is only supported in Node.js environments.'); } const ctor = validator.constructor as typeof BaseValidator & { validateFile?: (filePath: string) => Promise; }; - if (typeof ctor.validateFile === "function") { + if (typeof ctor.validateFile === 'function') { return ctor.validateFile(filePathOrBuffer); } diff --git a/src/validation/obfValidator.ts b/src/validation/obfValidator.ts index e89b71c..571335e 100644 --- a/src/validation/obfValidator.ts +++ b/src/validation/obfValidator.ts @@ -3,18 +3,18 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ -import JSZip from "jszip"; -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; +import JSZip from 'jszip'; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from "../utils/io"; +} from '../utils/io'; -const OBF_FORMAT = "open-board-0.1"; +const OBF_FORMAT = 'open-board-0.1'; const OBF_FORMAT_CURRENT_VERSION = 0.1; /** @@ -30,10 +30,9 @@ export class ObfValidator extends BaseValidator { */ static async validateFile( filePath: string, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { - const { readBinaryFromInput, getFileSize } = - fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; const validator = new ObfValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); @@ -43,30 +42,24 @@ export class ObfValidator extends BaseValidator { /** * Check if content is OBF format */ - static async identifyFormat( - content: any, - filename: string, - ): Promise { + static async identifyFormat(content: any, filename: string): Promise { const name = filename.toLowerCase(); - if (name.endsWith(".obf") || name.endsWith(".obz")) { + if (name.endsWith('.obf') || name.endsWith('.obz')) { return true; } // Try to parse as JSON and check format try { if ( - typeof content !== "string" && + typeof content !== 'string' && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const contentStr = - typeof content === "string" - ? content - : decodeText(toUint8Array(content)); + const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content)); const json = JSON.parse(contentStr); - return json && json.format && json.format.startsWith("open-board-"); + return json && json.format && json.format.startsWith('open-board-'); } catch { return false; } @@ -78,12 +71,12 @@ export class ObfValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { this.reset(); // Determine if it's OBF or OBZ - const isObz = filename.toLowerCase().endsWith(".obz"); + const isObz = filename.toLowerCase().endsWith('.obz'); if (isObz) { return await this.validateObz(content, filename, filesize); @@ -98,16 +91,16 @@ export class ObfValidator extends BaseValidator { private async validateObf( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { - await this.add_check("filename", "file name", async () => { + await this.add_check('filename', 'file name', async () => { if (!filename.match(/\.obf$/)) { - this.warn("filename should end with .obf"); + this.warn('filename should end with .obf'); } }); let json: any = null; - await this.add_check("valid_json", "JSON file", async () => { + await this.add_check('valid_json', 'JSON file', async () => { try { json = JSON.parse(decodeText(content)); } catch { @@ -116,12 +109,12 @@ export class ObfValidator extends BaseValidator { }); if (!json) { - return this.buildResult(filename, filesize, "obf"); + return this.buildResult(filename, filesize, 'obf'); } await this.validateBoardStructure(json); - return this.buildResult(filename, filesize, "obf"); + return this.buildResult(filename, filesize, 'obf'); } /** @@ -130,23 +123,23 @@ export class ObfValidator extends BaseValidator { private async validateObz( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { - await this.add_check("filename", "file name", async () => { + await this.add_check('filename', 'file name', async () => { if (!filename.match(/\.obz$/)) { - this.warn("filename should end with .obz"); + this.warn('filename should end with .obz'); } }); let zip: JSZip | null = null; let validZip = false; - await this.add_check("zip", "valid zip", async () => { + await this.add_check('zip', 'valid zip', async () => { try { zip = await JSZip.loadAsync(content); validZip = true; } catch { - this.err("file is not a valid zip package"); + this.err('file is not a valid zip package'); } }); @@ -154,288 +147,250 @@ export class ObfValidator extends BaseValidator { await this.validateObzStructure(zip); } - return this.buildResult(filename, filesize, "obz"); + return this.buildResult(filename, filesize, 'obz'); } /** * Validate OBF board structure */ private async validateBoardStructure(board: any): Promise { - await this.add_check("format_version", "format version", async () => { + await this.add_check('format_version', 'format version', async () => { if (!board.format) { this.err(`format attribute is required, set to ${OBF_FORMAT}`); return; } - const version = parseFloat(board.format.split("-").pop() || "0"); + const version = parseFloat(board.format.split('-').pop() || '0'); if (version > OBF_FORMAT_CURRENT_VERSION) { this.err( - `format version (${version}) is invalid, current version is ${OBF_FORMAT_CURRENT_VERSION}`, + `format version (${version}) is invalid, current version is ${OBF_FORMAT_CURRENT_VERSION}` ); } else if (version < OBF_FORMAT_CURRENT_VERSION) { this.warn( - `format version (${version}) is old, consider updating to ${OBF_FORMAT_CURRENT_VERSION}`, + `format version (${version}) is old, consider updating to ${OBF_FORMAT_CURRENT_VERSION}` ); } }); - await this.add_check("id", "board ID", async () => { + await this.add_check('id', 'board ID', async () => { if (!board.id) { - this.err("id attribute is required"); + this.err('id attribute is required'); } }); - await this.add_check("locale", "locale", async () => { + await this.add_check('locale', 'locale', async () => { if (!board.locale) { - this.err( - 'locale attribute is required, please set to "en" for English', - ); + this.err('locale attribute is required, please set to "en" for English'); } }); - await this.add_check("extras", "extra attributes", async () => { + await this.add_check('extras', 'extra attributes', async () => { const attrs = [ - "format", - "id", - "locale", - "url", - "data_url", - "name", - "description_html", - "default_layout", - "buttons", - "images", - "sounds", - "grid", - "license", + 'format', + 'id', + 'locale', + 'url', + 'data_url', + 'name', + 'description_html', + 'default_layout', + 'buttons', + 'images', + 'sounds', + 'grid', + 'license', ]; Object.keys(board).forEach((key) => { - if (!attrs.includes(key) && !key.startsWith("ext_")) { + if (!attrs.includes(key) && !key.startsWith('ext_')) { this.warn( - `${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_`, + `${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_` ); } }); }); - await this.add_check("description", "descriptive attributes", async () => { + await this.add_check('description', 'descriptive attributes', async () => { if (!board.name) { - this.warn("name attribute is strongly recommended"); + this.warn('name attribute is strongly recommended'); } if (!board.description_html) { - this.warn("description_html attribute is recommended"); + this.warn('description_html attribute is recommended'); } }); - await this.add_check("background", "background attribute", async () => { - if (board.background && typeof board.background !== "object") { - this.err("background attribute must be a hash"); + await this.add_check('background', 'background attribute', async () => { + if (board.background && typeof board.background !== 'object') { + this.err('background attribute must be a hash'); } }); - await this.add_check("buttons", "buttons attribute", async () => { + await this.add_check('buttons', 'buttons attribute', async () => { if (!board.buttons) { - this.err("buttons attribute is required"); + this.err('buttons attribute is required'); } else if (!Array.isArray(board.buttons)) { - this.err("buttons attribute must be an array"); + this.err('buttons attribute must be an array'); } }); - await this.add_check("grid", "grid attribute", async () => { + await this.add_check('grid', 'grid attribute', async () => { if (!board.grid) { - this.err("grid attribute is required"); + this.err('grid attribute is required'); return; } - if (typeof board.grid !== "object") { - this.err("grid attribute must be a hash"); + if (typeof board.grid !== 'object') { + this.err('grid attribute must be a hash'); return; } - if (typeof board.grid.rows !== "number" || board.grid.rows < 1) { - this.err("grid.rows must be a positive number"); + if (typeof board.grid.rows !== 'number' || board.grid.rows < 1) { + this.err('grid.rows must be a positive number'); } - if (typeof board.grid.columns !== "number" || board.grid.columns < 1) { - this.err("grid.columns must be a positive number"); + if (typeof board.grid.columns !== 'number' || board.grid.columns < 1) { + this.err('grid.columns must be a positive number'); } if (!board.grid.order || !Array.isArray(board.grid.order)) { - this.err("grid.order must be an array of arrays"); + this.err('grid.order must be an array of arrays'); return; } if (board.grid.order.length !== board.grid.rows) { this.err( - `grid.order length (${board.grid.order.length}) must match grid.rows (${board.grid.rows})`, + `grid.order length (${board.grid.order.length}) must match grid.rows (${board.grid.rows})` ); } if ( - !board.grid.order.every( - (r: any) => Array.isArray(r) && r.length === board.grid.columns, - ) + !board.grid.order.every((r: any) => Array.isArray(r) && r.length === board.grid.columns) ) { this.err( - `grid.order must contain ${board.grid.rows} arrays each of size ${board.grid.columns}`, + `grid.order must contain ${board.grid.rows} arrays each of size ${board.grid.columns}` ); } }); - await this.add_check( - "grid_ids", - "button IDs in grid.order attribute", - async () => { - const buttonIds = (board.buttons || []).map((b: any) => b.id); - const usedButtonIds: string[] = []; - if (board.grid && board.grid.order) { - board.grid.order.forEach((row: any) => { - if (Array.isArray(row)) { - row.forEach((id: any) => { - if (id !== null && id !== undefined) { - usedButtonIds.push(id); - if (!buttonIds.includes(id)) { - this.err( - `grid.order references button with id ${id} but no button with that id found in buttons attribute`, - ); - } + await this.add_check('grid_ids', 'button IDs in grid.order attribute', async () => { + const buttonIds = (board.buttons || []).map((b: any) => b.id); + const usedButtonIds: string[] = []; + if (board.grid && board.grid.order) { + board.grid.order.forEach((row: any) => { + if (Array.isArray(row)) { + row.forEach((id: any) => { + if (id !== null && id !== undefined) { + usedButtonIds.push(id); + if (!buttonIds.includes(id)) { + this.err( + `grid.order references button with id ${id} but no button with that id found in buttons attribute` + ); } - }); - } - }); - } - if (usedButtonIds.length === 0) { - this.warn("board has no buttons defined in the grid"); - } + } + }); + } + }); + } + if (usedButtonIds.length === 0) { + this.warn('board has no buttons defined in the grid'); + } - const unusedIds = buttonIds.filter( - (id: any) => !usedButtonIds.includes(id), + const unusedIds = buttonIds.filter((id: any) => !usedButtonIds.includes(id)); + if (unusedIds.length > 0) { + this.warn( + `not all defined buttons were included in the grid order (${unusedIds.join(',')})` ); - if (unusedIds.length > 0) { - this.warn( - `not all defined buttons were included in the grid order (${unusedIds.join(",")})`, - ); - } - }, - ); + } + }); - await this.add_check("images", "images attribute", async () => { + await this.add_check('images', 'images attribute', async () => { if (!board.images) { - this.err("images attribute is required"); + this.err('images attribute is required'); } else if (!Array.isArray(board.images)) { - this.err("images attribute must be an array"); + this.err('images attribute must be an array'); } }); if (Array.isArray(board.images)) { for (let i = 0; i < board.images.length; i++) { const image = board.images[i]; - await this.add_check( - `image[${i}]`, - `image at images[${i}]`, - async () => { - if (typeof image !== "object") { - this.err("image must be a hash"); - return; - } - if (!image.id) { - this.err("image.id is required"); - } - if ( - !image.width || - typeof image.width !== "number" || - image.width < 1 - ) { - this.warn("image.width should be a valid positive number"); - } - if ( - !image.height || - typeof image.height !== "number" || - image.height < 1 - ) { - this.warn("image.height should be a valid positive number"); - } - if ( - !image.content_type || - !image.content_type.match(/^image\/.+$/) - ) { - this.err("image.content_type must be a valid image mime type"); - } - if (!image.url && !image.data && !image.symbol && !image.path) { - this.err( - "image must have data, url, path or symbol attribute defined", + await this.add_check(`image[${i}]`, `image at images[${i}]`, async () => { + if (typeof image !== 'object') { + this.err('image must be a hash'); + return; + } + if (!image.id) { + this.err('image.id is required'); + } + if (!image.width || typeof image.width !== 'number' || image.width < 1) { + this.warn('image.width should be a valid positive number'); + } + if (!image.height || typeof image.height !== 'number' || image.height < 1) { + this.warn('image.height should be a valid positive number'); + } + if (!image.content_type || !image.content_type.match(/^image\/.+$/)) { + this.err('image.content_type must be a valid image mime type'); + } + if (!image.url && !image.data && !image.symbol && !image.path) { + this.err('image must have data, url, path or symbol attribute defined'); + } + + const imageAttrs = [ + 'id', + 'width', + 'height', + 'content_type', + 'data', + 'url', + 'symbol', + 'path', + 'data_url', + 'license', + ]; + Object.keys(image).forEach((key) => { + if (!imageAttrs.includes(key) && !key.startsWith('ext_')) { + this.warn( + `image.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_` ); } - - const imageAttrs = [ - "id", - "width", - "height", - "content_type", - "data", - "url", - "symbol", - "path", - "data_url", - "license", - ]; - Object.keys(image).forEach((key) => { - if (!imageAttrs.includes(key) && !key.startsWith("ext_")) { - this.warn( - `image.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_`, - ); - } - }); - }, - ); + }); + }); } } - await this.add_check("sounds", "sounds attribute", async () => { + await this.add_check('sounds', 'sounds attribute', async () => { if (!board.sounds) { - this.err("sounds attribute is required"); + this.err('sounds attribute is required'); } else if (!Array.isArray(board.sounds)) { - this.err("sounds attribute must be an array"); + this.err('sounds attribute must be an array'); } }); if (Array.isArray(board.sounds)) { for (let i = 0; i < board.sounds.length; i++) { const sound = board.sounds[i]; - await this.add_check( - `sounds[${i}]`, - `sound at sounds[${i}]`, - async () => { - if (typeof sound !== "object") { - this.err("sound must be a hash"); - return; - } - if (!sound.id) { - this.err("sound.id is required"); - } - if ( - sound.duration !== undefined && - (typeof sound.duration !== "number" || sound.duration < 0) - ) { - this.err("sound.duration must be a valid positive number"); - } - if ( - !sound.content_type || - !sound.content_type.match(/^audio\/.+$/) - ) { - this.err("sound.content_type must be a valid audio mime type"); - } - if (!sound.url && !sound.data && !sound.path) { - this.err("sound must have data, url, or path attribute defined"); - } - }, - ); + await this.add_check(`sounds[${i}]`, `sound at sounds[${i}]`, async () => { + if (typeof sound !== 'object') { + this.err('sound must be a hash'); + return; + } + if (!sound.id) { + this.err('sound.id is required'); + } + if ( + sound.duration !== undefined && + (typeof sound.duration !== 'number' || sound.duration < 0) + ) { + this.err('sound.duration must be a valid positive number'); + } + if (!sound.content_type || !sound.content_type.match(/^audio\/.+$/)) { + this.err('sound.content_type must be a valid audio mime type'); + } + if (!sound.url && !sound.data && !sound.path) { + this.err('sound must have data, url, or path attribute defined'); + } + }); } } if (Array.isArray(board.buttons)) { for (let i = 0; i < board.buttons.length; i++) { const button = board.buttons[i]; - await this.add_check( - `buttons[${i}]`, - `button at buttons[${i}]`, - async () => { - await this.validateButton(button); - }, - ); + await this.add_check(`buttons[${i}]`, `button at buttons[${i}]`, async () => { + await this.validateButton(button); + }); } } } @@ -444,81 +399,72 @@ export class ObfValidator extends BaseValidator { * Validate a single button */ private async validateButton(button: any): Promise { - if (typeof button !== "object") { - this.err("button must be a hash"); + if (typeof button !== 'object') { + this.err('button must be a hash'); return; } if (!button.id) { - this.err("button.id is required"); + this.err('button.id is required'); } if (!button.label) { - this.err("button.label is required"); + this.err('button.label is required'); } - ["top", "left", "width", "height"].forEach((attr) => { - if ( - button[attr] !== undefined && - (typeof button[attr] !== "number" || button[attr] < 0) - ) { + ['top', 'left', 'width', 'height'].forEach((attr) => { + if (button[attr] !== undefined && (typeof button[attr] !== 'number' || button[attr] < 0)) { this.warn(`button.${attr} should be a positive number`); } }); - ["background_color", "border_color"].forEach((color) => { + ['background_color', 'border_color'].forEach((color) => { if (button[color]) { if ( - !button[color].match( - /^\s*rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(,\s*[01]?\.?\d*)?\)\s*/, - ) + !button[color].match(/^\s*rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(,\s*[01]?\.?\d*)?\)\s*/) ) { this.err( - `button.${color} must be a valid rgb or rgba value if defined ("${button[color]}" is invalid)`, + `button.${color} must be a valid rgb or rgba value if defined ("${button[color]}" is invalid)` ); } } }); - if (button.hidden !== undefined && typeof button.hidden !== "boolean") { - this.err("button.hidden must be a boolean if defined"); + if (button.hidden !== undefined && typeof button.hidden !== 'boolean') { + this.err('button.hidden must be a boolean if defined'); } if (!button.image_id) { - this.warn("button.image_id is recommended"); + this.warn('button.image_id is recommended'); } - if ( - button.action && - typeof button.action === "string" && - !button.action.match(/^(:|\+)/) - ) { - this.err("button.action must start with either : or + if defined"); + if (button.action && typeof button.action === 'string' && !button.action.match(/^(:|\+)/)) { + this.err('button.action must start with either : or + if defined'); } if (button.actions && !Array.isArray(button.actions)) { - this.err("button.actions must be an array of strings"); + this.err('button.actions must be an array of strings'); } const buttonAttrs = [ - "id", - "label", - "vocalization", - "image_id", - "sound_id", - "hidden", - "background_color", - "border_color", - "action", - "actions", - "load_board", - "top", - "left", - "width", - "height", + 'id', + 'label', + 'vocalization', + 'image_id', + 'sound_id', + 'hidden', + 'background_color', + 'border_color', + 'action', + 'actions', + 'load_board', + 'top', + 'left', + 'width', + 'height', ]; Object.keys(button).forEach((key) => { - if (!buttonAttrs.includes(key) && !key.startsWith("ext_")) { + if (!buttonAttrs.includes(key) && !key.startsWith('ext_')) { this.warn( - `button.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_`, + `button.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_` ); } }); @@ -530,22 +476,22 @@ export class ObfValidator extends BaseValidator { private async validateObzStructure(zip: JSZip): Promise { let json: any = null; - await this.add_check("manifest", "manifest.json", async () => { - const manifestFile = zip.file("manifest.json"); + await this.add_check('manifest', 'manifest.json', async () => { + const manifestFile = zip.file('manifest.json'); if (!manifestFile) { - this.err("manifest.json is required in the zip package"); + this.err('manifest.json is required in the zip package'); return; } try { - const manifestStr = await manifestFile.async("string"); + const manifestStr = await manifestFile.async('string'); json = JSON.parse(manifestStr); } catch { json = null; } if (!json) { - this.err("manifest.json must contain a valid JSON structure"); + this.err('manifest.json must contain a valid JSON structure'); } }); @@ -558,79 +504,60 @@ export class ObfValidator extends BaseValidator { * Validate manifest structure */ private async validateManifest(manifest: any, zip: JSZip): Promise { - await this.add_check( - "manifest_format", - "manifest.json format version", - async () => { - if (!manifest.format) { - this.err(`format attribute is required, set to ${OBF_FORMAT}`); - return; - } - const version = parseFloat(manifest.format.split("-").pop()); - if (version > OBF_FORMAT_CURRENT_VERSION) { - this.err( - `format version (${version}) is invalid, current version is ${OBF_FORMAT_CURRENT_VERSION}`, - ); - } else if (version < OBF_FORMAT_CURRENT_VERSION) { + await this.add_check('manifest_format', 'manifest.json format version', async () => { + if (!manifest.format) { + this.err(`format attribute is required, set to ${OBF_FORMAT}`); + return; + } + const version = parseFloat(manifest.format.split('-').pop()); + if (version > OBF_FORMAT_CURRENT_VERSION) { + this.err( + `format version (${version}) is invalid, current version is ${OBF_FORMAT_CURRENT_VERSION}` + ); + } else if (version < OBF_FORMAT_CURRENT_VERSION) { + this.warn( + `format version (${version}) is old, consider updating to ${OBF_FORMAT_CURRENT_VERSION}` + ); + } + }); + + await this.add_check('manifest_root', 'manifest.json root attribute', async () => { + if (!manifest.root) { + this.err('root attribute is required'); + } + if (!zip.file(manifest.root)) { + this.err('root attribute must reference a file in the package'); + } + }); + + await this.add_check('manifest_paths', 'manifest.json paths attribute', async () => { + if (!manifest.paths || typeof manifest.paths !== 'object') { + this.err('paths attribute must be a valid hash'); + } + if (!manifest.paths.boards || typeof manifest.paths.boards !== 'object') { + this.err('paths.boards must be a valid hash'); + } + }); + + await this.add_check('manifest_extras', 'manifest.json extra attributes', async () => { + const attrs = ['format', 'root', 'paths']; + Object.keys(manifest).forEach((key) => { + if (!attrs.includes(key) && !key.startsWith('ext_')) { this.warn( - `format version (${version}) is old, consider updating to ${OBF_FORMAT_CURRENT_VERSION}`, + `${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_` ); } - }, - ); - - await this.add_check( - "manifest_root", - "manifest.json root attribute", - async () => { - if (!manifest.root) { - this.err("root attribute is required"); - } - if (!zip.file(manifest.root)) { - this.err("root attribute must reference a file in the package"); - } - }, - ); - - await this.add_check( - "manifest_paths", - "manifest.json paths attribute", - async () => { - if (!manifest.paths || typeof manifest.paths !== "object") { - this.err("paths attribute must be a valid hash"); - } - if ( - !manifest.paths.boards || - typeof manifest.paths.boards !== "object" - ) { - this.err("paths.boards must be a valid hash"); - } - }, - ); - - await this.add_check( - "manifest_extras", - "manifest.json extra attributes", - async () => { - const attrs = ["format", "root", "paths"]; - Object.keys(manifest).forEach((key) => { - if (!attrs.includes(key) && !key.startsWith("ext_")) { - this.warn( - `${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_`, - ); - } - }); + }); - const pathAttrs = ["boards", "images", "sounds"]; - Object.keys(manifest.paths || {}).forEach((key) => { - if (!pathAttrs.includes(key) && !key.startsWith("ext_")) { - this.warn( - `paths.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_`, - ); - } - }); - }, - ); + const pathAttrs = ['boards', 'images', 'sounds']; + Object.keys(manifest.paths || {}).forEach((key) => { + if (!pathAttrs.includes(key) && !key.startsWith('ext_')) { + this.warn( + `paths.${key} attribute is not defined in the spec, should be prefixed with ext_yourapp_` + ); + } + }); + }); // Validate boards referenced in manifest if (manifest.paths && manifest.paths.boards) { @@ -641,24 +568,22 @@ export class ObfValidator extends BaseValidator { async () => { const bFile = zip.file(boardPath as string); if (!bFile) { - this.err( - `board path (${boardPath}) not found in the zip package`, - ); + this.err(`board path (${boardPath}) not found in the zip package`); return; } try { - const boardStr = await bFile.async("string"); + const boardStr = await bFile.async('string'); const boardJson = JSON.parse(boardStr); if (!boardJson || boardJson.id !== id) { - const boardId = (boardJson && boardJson.id) || "null"; + const boardId = (boardJson && boardJson.id) || 'null'; this.err( - `board at path (${boardPath}) defined in manifest with id "${id}" but actually has id "${boardId}"`, + `board at path (${boardPath}) defined in manifest with id "${id}" but actually has id "${boardId}"` ); } } catch { this.err(`could not parse board at path (${boardPath})`); } - }, + } ); } } @@ -673,7 +598,7 @@ export class ObfValidator extends BaseValidator { if (!zip.file(imgPath as string)) { this.err(`image path (${imgPath}) not found in the zip package`); } - }, + } ); } } @@ -686,11 +611,9 @@ export class ObfValidator extends BaseValidator { `manifest.json path.sounds.${id}`, async () => { if (!zip.file(soundPath as string)) { - this.err( - `sound path (${soundPath}) not found in the zip package`, - ); + this.err(`sound path (${soundPath}) not found in the zip package`); } - }, + } ); } } diff --git a/src/validation/obfsetValidator.ts b/src/validation/obfsetValidator.ts index 9c2863d..203d215 100644 --- a/src/validation/obfsetValidator.ts +++ b/src/validation/obfsetValidator.ts @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/require-await */ -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from "../utils/io"; +} from '../utils/io'; /** * Validator for OBF set bundles (.obfset) - JSON arrays of boards @@ -15,35 +15,28 @@ import { export class ObfsetValidator extends BaseValidator { static async validateFile( filePath: string, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { - const { readBinaryFromInput, getFileSize } = - fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; const validator = new ObfsetValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); return validator.validate(content, getBasename(filePath), size); } - static async identifyFormat( - content: any, - filename: string, - ): Promise { + static async identifyFormat(content: any, filename: string): Promise { const name = filename.toLowerCase(); - if (name.endsWith(".obfset")) return true; + if (name.endsWith('.obfset')) return true; try { if ( - typeof content !== "string" && + typeof content !== 'string' && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const str = - typeof content === "string" - ? content - : decodeText(toUint8Array(content)); + const str = typeof content === 'string' ? content : decodeText(toUint8Array(content)); const parsed = JSON.parse(str); return Array.isArray(parsed); } catch { @@ -54,23 +47,23 @@ export class ObfsetValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { this.reset(); - await this.add_check("filename", "file extension", async () => { - if (!filename.toLowerCase().endsWith(".obfset")) { - this.warn("filename should end with .obfset"); + await this.add_check('filename', 'file extension', async () => { + if (!filename.toLowerCase().endsWith('.obfset')) { + this.warn('filename should end with .obfset'); } }); let boards: any[] | null = null; - await this.add_check("json_parse", "valid JSON array", async () => { + await this.add_check('json_parse', 'valid JSON array', async () => { try { const str = decodeText(content); const parsed = JSON.parse(str); if (!Array.isArray(parsed)) { - this.err("root must be a JSON array of boards", true); + this.err('root must be a JSON array of boards', true); } else { boards = parsed; } @@ -80,7 +73,7 @@ export class ObfsetValidator extends BaseValidator { }); if (!boards) { - return this.buildResult(filename, filesize, "obfset"); + return this.buildResult(filename, filesize, 'obfset'); } const safeBoards = boards as any[]; @@ -88,26 +81,22 @@ export class ObfsetValidator extends BaseValidator { const prefix = `board[${idx}]`; this.add_check_sync(`${prefix}_id`, `${prefix} id`, () => { if (!board?.id) { - this.err("board is missing id"); + this.err('board is missing id'); } }); this.add_check_sync(`${prefix}_buttons`, `${prefix} buttons`, () => { if (!Array.isArray(board?.buttons)) { - this.warn("board has no buttons array"); + this.warn('board has no buttons array'); } }); this.add_check_sync(`${prefix}_grid`, `${prefix} grid definition`, () => { const grid = board?.grid; - if ( - !grid || - typeof grid.rows !== "number" || - typeof grid.columns !== "number" - ) { - this.warn("grid rows/columns missing; layout may be invalid"); + if (!grid || typeof grid.rows !== 'number' || typeof grid.columns !== 'number') { + this.warn('grid rows/columns missing; layout may be invalid'); } }); }); - return this.buildResult(filename, filesize, "obfset"); + return this.buildResult(filename, filesize, 'obfset'); } } diff --git a/src/validation/opmlValidator.ts b/src/validation/opmlValidator.ts index 80f3947..e029df5 100644 --- a/src/validation/opmlValidator.ts +++ b/src/validation/opmlValidator.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/require-await */ -import { XMLParser, XMLValidator } from "fast-xml-parser"; -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; +import { XMLParser, XMLValidator } from 'fast-xml-parser'; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; import { decodeText, defaultFileAdapter, FileAdapter, getBasename, toUint8Array, -} from "../utils/io"; +} from '../utils/io'; /** * Validator for OPML files @@ -16,37 +16,30 @@ import { export class OpmlValidator extends BaseValidator { static async validateFile( filePath: string, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { - const { readBinaryFromInput, getFileSize } = - fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; const validator = new OpmlValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); return validator.validate(content, getBasename(filePath), size); } - static async identifyFormat( - content: any, - filename: string, - ): Promise { + static async identifyFormat(content: any, filename: string): Promise { const name = filename.toLowerCase(); - if (name.endsWith(".opml")) { + if (name.endsWith('.opml')) { return true; } try { if ( - typeof content !== "string" && + typeof content !== 'string' && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array) ) { return false; } - const str = - typeof content === "string" - ? content - : decodeText(toUint8Array(content)); + const str = typeof content === 'string' ? content : decodeText(toUint8Array(content)); const validation = XMLValidator.validate(str); if (validation !== true) { return false; @@ -62,38 +55,38 @@ export class OpmlValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { this.reset(); const parser = new XMLParser({ ignoreAttributes: false }); - await this.add_check("filename", "file extension", async () => { - if (!filename.toLowerCase().endsWith(".opml")) { - this.warn("filename should end with .opml"); + await this.add_check('filename', 'file extension', async () => { + if (!filename.toLowerCase().endsWith('.opml')) { + this.warn('filename should end with .opml'); } }); - let text = ""; - await this.add_check("content", "non-empty content", async () => { + let text = ''; + await this.add_check('content', 'non-empty content', async () => { text = decodeText(content); if (!text.trim()) { - this.err("OPML file is empty", true); + this.err('OPML file is empty', true); } }); - await this.add_check("xml", "valid XML", async () => { + await this.add_check('xml', 'valid XML', async () => { const validation = XMLValidator.validate(text); if (validation !== true) { - const msg = String((validation as any)?.err?.msg || "Invalid OPML XML"); + const msg = String((validation as any)?.err?.msg || 'Invalid OPML XML'); this.err(msg, true); } }); let parsed: any = null; - await this.add_check("structure", "outline structure", async () => { + await this.add_check('structure', 'outline structure', async () => { parsed = parser.parse(text); if (!parsed?.opml?.body?.outline) { - this.err("missing body.outline", true); + this.err('missing body.outline', true); } }); @@ -101,21 +94,18 @@ export class OpmlValidator extends BaseValidator { const outlines = Array.isArray(parsed.opml.body.outline) ? parsed.opml.body.outline : [parsed.opml.body.outline]; - await this.add_check("outline_nodes", "outline nodes", async () => { + await this.add_check('outline_nodes', 'outline nodes', async () => { const hasText = outlines.some((node: any) => { const textValue = - node?.["@_text"] || - node?._attributes?.text || - node?.text || - node?.["@_title"]; - return typeof textValue === "string" && textValue.trim().length > 0; + node?.['@_text'] || node?._attributes?.text || node?.text || node?.['@_title']; + return typeof textValue === 'string' && textValue.trim().length > 0; }); if (!hasText) { - this.err("outline nodes missing text attributes"); + this.err('outline nodes missing text attributes'); } }); } - return this.buildResult(filename, filesize, "opml"); + return this.buildResult(filename, filesize, 'opml'); } } diff --git a/src/validation/snapValidator.ts b/src/validation/snapValidator.ts index 0dedbd1..a43a08c 100644 --- a/src/validation/snapValidator.ts +++ b/src/validation/snapValidator.ts @@ -1,16 +1,11 @@ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -import * as xml2js from "xml2js"; -import JSZip from "jszip"; -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; -import { - defaultFileAdapter, - FileAdapter, - getBasename, - toUint8Array, -} from "../utils/io"; -import { openSqliteDatabase } from "../utils/sqlite"; +import * as xml2js from 'xml2js'; +import JSZip from 'jszip'; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; +import { defaultFileAdapter, FileAdapter, getBasename, toUint8Array } from '../utils/io'; +import { openSqliteDatabase } from '../utils/sqlite'; /** * Validator for Snap files (.spb, .sps) @@ -26,10 +21,9 @@ export class SnapValidator extends BaseValidator { */ static async validateFile( filePath: string, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { - const { readBinaryFromInput, getFileSize } = - fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; const validator = new SnapValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); @@ -40,12 +34,9 @@ export class SnapValidator extends BaseValidator { * Check if content is Snap format */ // eslint-disable-next-line @typescript-eslint/require-await - static async identifyFormat( - content: any, - filename: string, - ): Promise { + static async identifyFormat(content: any, filename: string): Promise { const name = filename.toLowerCase(); - if (name.endsWith(".spb") || name.endsWith(".sps")) { + if (name.endsWith('.spb') || name.endsWith('.sps')) { return true; } @@ -54,8 +45,7 @@ export class SnapValidator extends BaseValidator { const zip = await JSZip.loadAsync(toUint8Array(content)); const entries = Object.values(zip.files).filter((entry) => !entry.dir); return entries.some( - (entry) => - entry.name.includes("settings") || entry.name.includes(".xml"), + (entry) => entry.name.includes('settings') || entry.name.includes('.xml') ); } catch { return false; @@ -68,25 +58,25 @@ export class SnapValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { this.reset(); - await this.add_check("filename", "file extension", async () => { + await this.add_check('filename', 'file extension', async () => { if (!filename.match(/\.(spb|sps)$/)) { - this.warn("filename should end with .spb or .sps"); + this.warn('filename should end with .spb or .sps'); } }); if (this.isSQLiteBuffer(content)) { await this.validateSqliteStructure(content, filename); - return this.buildResult(filename, filesize, "snap"); + return this.buildResult(filename, filesize, 'snap'); } let zip: JSZip | null = null; let validZip = false; - await this.add_check("zip", "valid zip package", async () => { + await this.add_check('zip', 'valid zip package', async () => { try { zip = await JSZip.loadAsync(toUint8Array(content)); const entries = Object.values(zip.files); @@ -97,50 +87,39 @@ export class SnapValidator extends BaseValidator { }); if (!validZip || !zip) { - return this.buildResult(filename, filesize, "snap"); + return this.buildResult(filename, filesize, 'snap'); } await this.validateSnapStructure(zip, filename); - return this.buildResult(filename, filesize, "snap"); + return this.buildResult(filename, filesize, 'snap'); } /** * Validate Snap package structure */ - private async validateSnapStructure( - zip: JSZip, - _filename: string, - ): Promise { + private async validateSnapStructure(zip: JSZip, _filename: string): Promise { // Check for required files - await this.add_check( - "required_files", - "required package files", - async () => { - const entries = Object.values(zip.files); - const entryNames = entries.map((e) => e.name); - - // Look for common Snap files - const hasSettings = entryNames.some((n) => - n.toLowerCase().includes("settings"), - ); - const hasXml = entryNames.some((n) => n.toLowerCase().endsWith(".xml")); - - if (!hasSettings && !hasXml) { - this.err( - "Snap package must contain settings.xml or similar configuration file", - ); - } + await this.add_check('required_files', 'required package files', async () => { + const entries = Object.values(zip.files); + const entryNames = entries.map((e) => e.name); - if (entries.length === 0) { - this.err("Snap package is empty"); - } - }, - ); + // Look for common Snap files + const hasSettings = entryNames.some((n) => n.toLowerCase().includes('settings')); + const hasXml = entryNames.some((n) => n.toLowerCase().endsWith('.xml')); + + if (!hasSettings && !hasXml) { + this.err('Snap package must contain settings.xml or similar configuration file'); + } + + if (entries.length === 0) { + this.err('Snap package is empty'); + } + }); // Try to parse and validate the main settings file const settingsEntry = Object.values(zip.files).find( - (entry) => !entry.dir && entry.name.toLowerCase().includes("settings"), + (entry) => !entry.dir && entry.name.toLowerCase().includes('settings') ); if (settingsEntry) { @@ -149,12 +128,12 @@ export class SnapValidator extends BaseValidator { // Check for pages const pageEntries = Object.values(zip.files).filter( - (entry) => !entry.dir && entry.name.toLowerCase().includes("page"), + (entry) => !entry.dir && entry.name.toLowerCase().includes('page') ); - await this.add_check("pages", "pages in package", async () => { + await this.add_check('pages', 'pages in package', async () => { if (pageEntries.length === 0) { - this.warn("Snap package should contain at least one page file"); + this.warn('Snap package should contain at least one page file'); } }); @@ -166,24 +145,21 @@ export class SnapValidator extends BaseValidator { // Check for images const imageEntries = Object.values(zip.files).filter( - (entry) => - !entry.dir && - entry.name.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp)$/i), + (entry) => !entry.dir && entry.name.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp)$/i) ); - await this.add_check("images", "image files", async () => { + await this.add_check('images', 'image files', async () => { if (imageEntries.length === 0) { - this.warn("Snap package should contain image files for buttons"); + this.warn('Snap package should contain image files for buttons'); } }); // Check for audio files const audioEntries = Object.values(zip.files).filter( - (entry) => - !entry.dir && entry.name.toLowerCase().match(/\.(wav|mp3|m4a|ogg)$/i), + (entry) => !entry.dir && entry.name.toLowerCase().match(/\.(wav|mp3|m4a|ogg)$/i) ); - await this.add_check("audio", "audio files", async () => { + await this.add_check('audio', 'audio files', async () => { // Audio files are optional, so just warn if missing if (audioEntries.length === 0) { // This is informational, not a warning @@ -191,39 +167,28 @@ export class SnapValidator extends BaseValidator { }); // Check for unexpected files - await this.add_check( - "unexpected_files", - "unexpected file types", - async () => { - const entries = Object.values(zip.files).filter((entry) => !entry.dir); - const unexpectedFiles = entries.filter((entry) => { - const name = entry.name.toLowerCase(); - // Skip common system files and directories - if (name.startsWith("__macosx") || name.startsWith(".ds_store")) { - return false; - } - // Allowed file types - return !name.match( - /\.(xml|png|jpg|jpeg|gif|bmp|wav|mp3|m4a|ogg|json)$/i, - ); - }); - - if (unexpectedFiles.length > 0) { - const unexpectedNames = unexpectedFiles - .map((f) => f.name) - .slice(0, 5); - this.warn( - `Package contains unexpected file types: ${unexpectedNames.join(", ")}`, - ); + await this.add_check('unexpected_files', 'unexpected file types', async () => { + const entries = Object.values(zip.files).filter((entry) => !entry.dir); + const unexpectedFiles = entries.filter((entry) => { + const name = entry.name.toLowerCase(); + // Skip common system files and directories + if (name.startsWith('__macosx') || name.startsWith('.ds_store')) { + return false; } - }, - ); + // Allowed file types + return !name.match(/\.(xml|png|jpg|jpeg|gif|bmp|wav|mp3|m4a|ogg|json)$/i); + }); + + if (unexpectedFiles.length > 0) { + const unexpectedNames = unexpectedFiles.map((f) => f.name).slice(0, 5); + this.warn(`Package contains unexpected file types: ${unexpectedNames.join(', ')}`); + } + }); } private isSQLiteBuffer(content: Buffer | Uint8Array): boolean { - const header = "SQLite format 3\u0000"; - const bytes = - content instanceof Uint8Array ? content : new Uint8Array(content); + const header = 'SQLite format 3\u0000'; + const bytes = content instanceof Uint8Array ? content : new Uint8Array(content); if (bytes.length < header.length) { return false; } @@ -237,9 +202,9 @@ export class SnapValidator extends BaseValidator { private async validateSqliteStructure( content: Buffer | Uint8Array, - _filename: string, + _filename: string ): Promise { - await this.add_check("sqlite", "valid SQLite database", async () => { + await this.add_check('sqlite', 'valid SQLite database', async () => { let cleanup: (() => Promise) | undefined; try { const result = await openSqliteDatabase(content, { @@ -250,65 +215,50 @@ export class SnapValidator extends BaseValidator { cleanup = result.cleanup; const tableRows = db - .prepare( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", - ) + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") .all() as Array<{ name: string }>; const tables = new Set(tableRows.map((row) => row.name)); const requiredTables = [ - "Page", - "Button", - "ElementReference", - "ElementPlacement", - "PageSetProperties", + 'Page', + 'Button', + 'ElementReference', + 'ElementPlacement', + 'PageSetProperties', ]; const missingTables = requiredTables.filter((t) => !tables.has(t)); if (missingTables.length > 0) { - this.err(`Missing required Snap tables: ${missingTables.join(", ")}`); + this.err(`Missing required Snap tables: ${missingTables.join(', ')}`); } - const pageColumns = db - .prepare("PRAGMA table_info(Page)") - .all() as Array<{ name: string }>; + const pageColumns = db.prepare('PRAGMA table_info(Page)').all() as Array<{ name: string }>; const pageColumnNames = new Set(pageColumns.map((c) => c.name)); - if (!pageColumnNames.has("UniqueId")) { - this.err("Page table missing UniqueId column"); + if (!pageColumnNames.has('UniqueId')) { + this.err('Page table missing UniqueId column'); } - if (!pageColumnNames.has("Name") && !pageColumnNames.has("Title")) { - this.err("Page table missing Name/Title columns"); + if (!pageColumnNames.has('Name') && !pageColumnNames.has('Title')) { + this.err('Page table missing Name/Title columns'); } - const buttonColumns = db - .prepare("PRAGMA table_info(Button)") - .all() as Array<{ + const buttonColumns = db.prepare('PRAGMA table_info(Button)').all() as Array<{ name: string; }>; const buttonColumnNames = new Set(buttonColumns.map((c) => c.name)); - if ( - !buttonColumnNames.has("Label") && - !buttonColumnNames.has("Message") - ) { - this.err("Button table missing Label/Message columns"); + if (!buttonColumnNames.has('Label') && !buttonColumnNames.has('Message')) { + this.err('Button table missing Label/Message columns'); } - const pageCount = db - .prepare("SELECT COUNT(*) as c FROM Page") - .get() as { c: number }; + const pageCount = db.prepare('SELECT COUNT(*) as c FROM Page').get() as { c: number }; if (!pageCount || pageCount.c === 0) { - this.warn("Snap database has no pages"); + this.warn('Snap database has no pages'); } - if (tables.has("PageSetData")) { - const dataCount = db - .prepare("SELECT COUNT(*) as c FROM PageSetData") - .get() as { + if (tables.has('PageSetData')) { + const dataCount = db.prepare('SELECT COUNT(*) as c FROM PageSetData').get() as { c: number; }; if (!dataCount || dataCount.c === 0) { - this.warn( - "Snap database has no PageSetData assets (images/audio may be missing)", - ); + this.warn('Snap database has no PageSetData assets (images/audio may be missing)'); } } } catch (e: any) { @@ -325,74 +275,63 @@ export class SnapValidator extends BaseValidator { * Validate the main settings file */ private async validateSettingsFile(entry: JSZip.JSZipObject): Promise { - await this.add_check( - "settings_format", - "settings file format", - async () => { - try { - const content = await entry.async("string"); - const parser = new xml2js.Parser(); - const xml = await parser.parseStringPromise(content); - - // Check for expected root element - if (!xml.settings && !xml.Settings && !xml.page && !xml.Page) { - this.warn("settings file does not contain expected root element"); - } + await this.add_check('settings_format', 'settings file format', async () => { + try { + const content = await entry.async('string'); + const parser = new xml2js.Parser(); + const xml = await parser.parseStringPromise(content); - // Check for required settings attributes if present - const settings = xml.settings || xml.Settings; - if (settings) { - const id = settings.$?.id || settings.$?.Id; - const name = settings.$?.name || settings.$?.Name; + // Check for expected root element + if (!xml.settings && !xml.Settings && !xml.page && !xml.Page) { + this.warn('settings file does not contain expected root element'); + } - if (!id && !name) { - this.warn("settings should have an id or name attribute"); - } + // Check for required settings attributes if present + const settings = xml.settings || xml.Settings; + if (settings) { + const id = settings.$?.id || settings.$?.Id; + const name = settings.$?.name || settings.$?.Name; + + if (!id && !name) { + this.warn('settings should have an id or name attribute'); } - } catch (e: any) { - this.err(`Failed to parse settings file: ${e.message}`); } - }, - ); + } catch (e: any) { + this.err(`Failed to parse settings file: ${e.message}`); + } + }); } /** * Validate a page file */ - private async validatePageFile( - entry: JSZip.JSZipObject, - index: number, - ): Promise { - await this.add_check( - `page[${index}]`, - `page file ${index}: ${entry.name}`, - async () => { - try { - const content = await entry.async("string"); - const parser = new xml2js.Parser(); - const xml = await parser.parseStringPromise(content); - - const page = xml.page || xml.Page; - if (!page) { - this.err(`Page file ${entry.name} does not contain a page element`); - return; - } + private async validatePageFile(entry: JSZip.JSZipObject, index: number): Promise { + await this.add_check(`page[${index}]`, `page file ${index}: ${entry.name}`, async () => { + try { + const content = await entry.async('string'); + const parser = new xml2js.Parser(); + const xml = await parser.parseStringPromise(content); + + const page = xml.page || xml.Page; + if (!page) { + this.err(`Page file ${entry.name} does not contain a page element`); + return; + } - // Check page attributes - const pageId = page.$?.id || page.$?.Id; - if (!pageId) { - this.warn(`Page ${entry.name} is missing an id attribute`); - } + // Check page attributes + const pageId = page.$?.id || page.$?.Id; + if (!pageId) { + this.warn(`Page ${entry.name} is missing an id attribute`); + } - // Check for cells/buttons - const cells = page.cells || page.Cells || page.button || page.Button; - if (!cells || (Array.isArray(cells) && cells.length === 0)) { - this.warn(`Page ${entry.name} has no cells or buttons`); - } - } catch (e: any) { - this.err(`Failed to parse page file ${entry.name}: ${e.message}`); + // Check for cells/buttons + const cells = page.cells || page.Cells || page.button || page.Button; + if (!cells || (Array.isArray(cells) && cells.length === 0)) { + this.warn(`Page ${entry.name} has no cells or buttons`); } - }, - ); + } catch (e: any) { + this.err(`Failed to parse page file ${entry.name}: ${e.message}`); + } + }); } } diff --git a/src/validation/touchChatValidator.ts b/src/validation/touchChatValidator.ts index b36854f..5835178 100644 --- a/src/validation/touchChatValidator.ts +++ b/src/validation/touchChatValidator.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-return */ -import * as xml2js from "xml2js"; -import { BaseValidator } from "./baseValidator"; -import { ValidationResult } from "./validationTypes"; +import * as xml2js from 'xml2js'; +import { BaseValidator } from './baseValidator'; +import { ValidationResult } from './validationTypes'; import { decodeText, defaultFileAdapter, @@ -11,9 +11,9 @@ import { getBasename, type ProcessorInput, toUint8Array, -} from "../utils/io"; -import { openSqliteDatabase } from "../utils/sqlite"; -import { getZipAdapter, ZipAdapter } from "../utils/zip"; +} from '../utils/io'; +import { openSqliteDatabase } from '../utils/sqlite'; +import { getZipAdapter, ZipAdapter } from '../utils/zip'; /** * Validator for TouchChat files (.ce) @@ -30,10 +30,9 @@ export class TouchChatValidator extends BaseValidator { */ static async validateFile( filePath: string, - fileAdapter?: FileAdapter, + fileAdapter?: FileAdapter ): Promise { - const { readBinaryFromInput, getFileSize } = - fileAdapter ?? defaultFileAdapter; + const { readBinaryFromInput, getFileSize } = fileAdapter ?? defaultFileAdapter; const validator = new TouchChatValidator(); const content = await readBinaryFromInput(filePath); const size = await getFileSize(filePath); @@ -47,10 +46,10 @@ export class TouchChatValidator extends BaseValidator { content: any, filename: string, fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise, + zipAdapter?: (input: ProcessorInput) => Promise ): Promise { const name = filename.toLowerCase(); - if (name.endsWith(".ce")) { + if (name.endsWith('.ce')) { return true; } @@ -60,7 +59,7 @@ export class TouchChatValidator extends BaseValidator { ? await zipAdapter(content) : await getZipAdapter(content, fileAdapter); const entries = zip.listFiles(); - if (entries.some((entry) => entry.toLowerCase().endsWith(".c4v"))) { + if (entries.some((entry) => entry.toLowerCase().endsWith('.c4v'))) { return true; } } catch { @@ -69,17 +68,11 @@ export class TouchChatValidator extends BaseValidator { // Try to parse as XML and check for TouchChat structure try { - const contentStr = - typeof content === "string" - ? content - : decodeText(toUint8Array(content)); + const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content)); const parser = new xml2js.Parser(); const result = await parser.parseStringPromise(contentStr); // TouchChat files typically have specific structure - return ( - result && - (result.PageSet || result.Pageset || result.page || result.Page) - ); + return result && (result.PageSet || result.Pageset || result.page || result.Page); } catch { return false; } @@ -91,13 +84,13 @@ export class TouchChatValidator extends BaseValidator { async validate( content: Buffer | Uint8Array, filename: string, - filesize: number, + filesize: number ): Promise { this.reset(); - await this.add_check("filename", "file extension", async () => { + await this.add_check('filename', 'file extension', async () => { if (!filename.match(/\.ce$/i)) { - this.warn("filename should end with .ce"); + this.warn('filename should end with .ce'); } }); @@ -107,11 +100,11 @@ export class TouchChatValidator extends BaseValidator { : await this.tryValidateZipSqlite( content, this._options.fileAdapter, - this._options.zipAdapter, + this._options.zipAdapter ); if (!zipped) { let xmlObj: any = null; - await this.add_check("xml_parse", "valid XML", async () => { + await this.add_check('xml_parse', 'valid XML', async () => { try { const parser = new xml2js.Parser(); const contentStr = decodeText(content); @@ -122,27 +115,23 @@ export class TouchChatValidator extends BaseValidator { }); if (!xmlObj) { - return this.buildResult(filename, filesize, "touchchat"); + return this.buildResult(filename, filesize, 'touchchat'); } - await this.add_check( - "xml_structure", - "TouchChat root element", - async () => { - // TouchChat can have different root elements - const hasValidRoot = - xmlObj.PageSet || - xmlObj.Pageset || - xmlObj.page || - xmlObj.Page || - xmlObj.pages || - xmlObj.Pages; - - if (!hasValidRoot) { - this.err("file does not contain a recognized TouchChat structure"); - } - }, - ); + await this.add_check('xml_structure', 'TouchChat root element', async () => { + // TouchChat can have different root elements + const hasValidRoot = + xmlObj.PageSet || + xmlObj.Pageset || + xmlObj.page || + xmlObj.Page || + xmlObj.pages || + xmlObj.Pages; + + if (!hasValidRoot) { + this.err('file does not contain a recognized TouchChat structure'); + } + }); const root = xmlObj.PageSet || @@ -156,7 +145,7 @@ export class TouchChatValidator extends BaseValidator { } } - return this.buildResult(filename, filesize, "touchchat"); + return this.buildResult(filename, filesize, 'touchchat'); } /** @@ -164,37 +153,37 @@ export class TouchChatValidator extends BaseValidator { */ private async validateTouchChatStructure(root: any): Promise { // Check for ID - await this.add_check("root_id", "root element ID", async () => { + await this.add_check('root_id', 'root element ID', async () => { const id = root.$?.id || root.$?.Id; if (!id) { - this.warn("root element should have an id attribute"); + this.warn('root element should have an id attribute'); } }); // Check for name - await this.add_check("root_name", "root element name", async () => { + await this.add_check('root_name', 'root element name', async () => { const name = root.$?.name || root.$?.Name || root.name?.[0]; if (!name) { - this.warn("root element should have a name"); + this.warn('root element should have a name'); } }); // Check for pages - await this.add_check("pages", "pages collection", async () => { + await this.add_check('pages', 'pages collection', async () => { const pages = root.page || root.Page || root.pages || root.Pages; if (!pages) { - this.err("TouchChat file must contain pages"); + this.err('TouchChat file must contain pages'); } else if (!Array.isArray(pages) || pages.length === 0) { - this.err("TouchChat file must contain at least one page"); + this.err('TouchChat file must contain at least one page'); } }); // Validate individual pages const pages = root.page || root.Page || root.pages || root.Pages; if (pages && Array.isArray(pages)) { - await this.add_check("page_count", "page count", async () => { + await this.add_check('page_count', 'page count', async () => { if (pages.length === 0) { - this.err("Must contain at least one page"); + this.err('Must contain at least one page'); } }); @@ -217,30 +206,22 @@ export class TouchChatValidator extends BaseValidator { } }); - await this.add_check( - `page[${index}]_name`, - `page ${index} name`, - async () => { - const name = page.$?.name || page.$?.Name || page.name?.[0]; - if (!name) { - this.warn(`page ${index} should have a name`); - } - }, - ); + await this.add_check(`page[${index}]_name`, `page ${index} name`, async () => { + const name = page.$?.name || page.$?.Name || page.name?.[0]; + if (!name) { + this.warn(`page ${index} should have a name`); + } + }); // Check for buttons/items - await this.add_check( - `page[${index}]_buttons`, - `page ${index} buttons`, - async () => { - const buttons = page.button || page.Button || page.item || page.Item; - if (!buttons) { - this.warn(`page ${index} has no buttons/items`); - } else if (Array.isArray(buttons) && buttons.length === 0) { - this.warn(`page ${index} should contain at least one button`); - } - }, - ); + await this.add_check(`page[${index}]_buttons`, `page ${index} buttons`, async () => { + const buttons = page.button || page.Button || page.item || page.Item; + if (!buttons) { + this.warn(`page ${index} has no buttons/items`); + } else if (Array.isArray(buttons) && buttons.length === 0) { + this.warn(`page ${index} should contain at least one button`); + } + }); // Validate button references const buttons = page.button || page.Button || page.item || page.Item; @@ -255,22 +236,16 @@ export class TouchChatValidator extends BaseValidator { /** * Validate a single button */ - private async validateButton( - button: any, - pageIdx: number, - buttonIdx: number, - ): Promise { + private async validateButton(button: any, pageIdx: number, buttonIdx: number): Promise { await this.add_check( `page[${pageIdx}]_button[${buttonIdx}]_label`, `button label`, async () => { const label = button.$?.label || button.$?.Label || button.label?.[0]; if (!label) { - this.warn( - `button ${buttonIdx} on page ${pageIdx} should have a label`, - ); + this.warn(`button ${buttonIdx} on page ${pageIdx} should have a label`); } - }, + } ); await this.add_check( @@ -278,13 +253,11 @@ export class TouchChatValidator extends BaseValidator { `button vocalization`, async () => { const vocalization = - button.$?.vocalization || - button.$?.Vocalization || - button.vocalization?.[0]; + button.$?.vocalization || button.$?.Vocalization || button.vocalization?.[0]; if (!vocalization) { // Vocalization is optional, so just info } - }, + } ); // Check for image reference @@ -294,11 +267,9 @@ export class TouchChatValidator extends BaseValidator { async () => { const image = button.$?.image || button.$?.Image || button.img?.[0]; if (!image) { - this.warn( - `button ${buttonIdx} on page ${pageIdx} should have an image reference`, - ); + this.warn(`button ${buttonIdx} on page ${pageIdx} should have an image reference`); } - }, + } ); // Check for link/action @@ -311,14 +282,13 @@ export class TouchChatValidator extends BaseValidator { if (!link && !action) { // Not all buttons need actions, they can just speak } - }, + } ); } private isSQLiteBuffer(content: Buffer | Uint8Array): boolean { - const header = "SQLite format 3\u0000"; - const bytes = - content instanceof Uint8Array ? content : new Uint8Array(content); + const header = 'SQLite format 3\u0000'; + const bytes = content instanceof Uint8Array ? content : new Uint8Array(content); if (bytes.length < header.length) { return false; } @@ -331,8 +301,7 @@ export class TouchChatValidator extends BaseValidator { } private isXmlBuffer(content: Buffer | Uint8Array): boolean { - const bytes = - content instanceof Uint8Array ? content : new Uint8Array(content); + const bytes = content instanceof Uint8Array ? content : new Uint8Array(content); const max = Math.min(bytes.length, 256); let start = 0; while (start < max) { @@ -352,121 +321,104 @@ export class TouchChatValidator extends BaseValidator { private async tryValidateZipSqlite( content: Buffer | Uint8Array, fileAdapter: FileAdapter = defaultFileAdapter, - zipAdapter?: (input: ProcessorInput) => Promise, + zipAdapter?: (input: ProcessorInput) => Promise ): Promise { let usedZip = false; - await this.add_check("zip", "TouchChat ZIP package", async () => { + await this.add_check('zip', 'TouchChat ZIP package', async () => { try { const zip = zipAdapter ? await zipAdapter(content) : await getZipAdapter(content, fileAdapter); const entries = zip.listFiles(); - const vocabEntry = entries.find((name) => - name.toLowerCase().endsWith(".c4v"), - ); + const vocabEntry = entries.find((name) => name.toLowerCase().endsWith('.c4v')); if (!vocabEntry) { - this.err("TouchChat package missing .c4v database", true); + this.err('TouchChat package missing .c4v database', true); return; } const dbBuffer = await zip.readFile(vocabEntry); if (!this.isSQLiteBuffer(dbBuffer)) { - this.err("TouchChat .c4v is not a valid SQLite database", true); + this.err('TouchChat .c4v is not a valid SQLite database', true); return; } usedZip = true; await this.validateSqliteStructure(dbBuffer); } catch (e: any) { - this.err( - `file is not a valid TouchChat ZIP package: ${e.message}`, - true, - ); + this.err(`file is not a valid TouchChat ZIP package: ${e.message}`, true); } }); return usedZip; } - private async validateSqliteStructure( - content: Buffer | Uint8Array, - ): Promise { - await this.add_check( - "sqlite", - "valid TouchChat SQLite database", - async () => { - let cleanup: (() => Promise) | undefined; - try { - const result = await openSqliteDatabase(content, { - readonly: true, - fileAdapter: this._options.fileAdapter, - }); - const db = result.db; - cleanup = result.cleanup; - - const tableRows = db - .prepare( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", - ) - .all() as Array<{ name: string }>; - const tables = new Set(tableRows.map((row) => row.name)); - - const requiredTables = [ - "resources", - "pages", - "buttons", - "button_boxes", - "button_box_cells", - "button_box_instances", - ]; - const missingTables = requiredTables.filter((t) => !tables.has(t)); - if (missingTables.length > 0) { - this.err( - `Missing required TouchChat tables: ${missingTables.join(", ")}`, - ); - } - - const resourcesCols = new Set( - db - .prepare("PRAGMA table_info(resources)") - .all() - .map((row: any) => row.name), - ); - if (!resourcesCols.has("id") || !resourcesCols.has("name")) { - this.err("resources table missing id/name columns"); - } - - const pagesCols = new Set( - db - .prepare("PRAGMA table_info(pages)") - .all() - .map((row: any) => row.name), - ); - if (!pagesCols.has("id") || !pagesCols.has("resource_id")) { - this.err("pages table missing id/resource_id columns"); - } - - const buttonsCols = new Set( - db - .prepare("PRAGMA table_info(buttons)") - .all() - .map((row: any) => row.name), - ); - if (!buttonsCols.has("id") || !buttonsCols.has("resource_id")) { - this.err("buttons table missing id/resource_id columns"); - } - - const pageCount = db - .prepare("SELECT COUNT(*) as c FROM pages") - .get() as { c: number }; - if (!pageCount || pageCount.c === 0) { - this.warn("TouchChat database has no pages"); - } - } catch (e: any) { - this.err(`TouchChat database validation failed: ${e.message}`, true); - } finally { - if (cleanup) { - await cleanup(); - } + private async validateSqliteStructure(content: Buffer | Uint8Array): Promise { + await this.add_check('sqlite', 'valid TouchChat SQLite database', async () => { + let cleanup: (() => Promise) | undefined; + try { + const result = await openSqliteDatabase(content, { + readonly: true, + fileAdapter: this._options.fileAdapter, + }); + const db = result.db; + cleanup = result.cleanup; + + const tableRows = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as Array<{ name: string }>; + const tables = new Set(tableRows.map((row) => row.name)); + + const requiredTables = [ + 'resources', + 'pages', + 'buttons', + 'button_boxes', + 'button_box_cells', + 'button_box_instances', + ]; + const missingTables = requiredTables.filter((t) => !tables.has(t)); + if (missingTables.length > 0) { + this.err(`Missing required TouchChat tables: ${missingTables.join(', ')}`); } - }, - ); + + const resourcesCols = new Set( + db + .prepare('PRAGMA table_info(resources)') + .all() + .map((row: any) => row.name) + ); + if (!resourcesCols.has('id') || !resourcesCols.has('name')) { + this.err('resources table missing id/name columns'); + } + + const pagesCols = new Set( + db + .prepare('PRAGMA table_info(pages)') + .all() + .map((row: any) => row.name) + ); + if (!pagesCols.has('id') || !pagesCols.has('resource_id')) { + this.err('pages table missing id/resource_id columns'); + } + + const buttonsCols = new Set( + db + .prepare('PRAGMA table_info(buttons)') + .all() + .map((row: any) => row.name) + ); + if (!buttonsCols.has('id') || !buttonsCols.has('resource_id')) { + this.err('buttons table missing id/resource_id columns'); + } + + const pageCount = db.prepare('SELECT COUNT(*) as c FROM pages').get() as { c: number }; + if (!pageCount || pageCount.c === 0) { + this.warn('TouchChat database has no pages'); + } + } catch (e: any) { + this.err(`TouchChat database validation failed: ${e.message}`, true); + } finally { + if (cleanup) { + await cleanup(); + } + } + }); } } diff --git a/src/validation/validationTypes.ts b/src/validation/validationTypes.ts index 2ca647f..26864e6 100644 --- a/src/validation/validationTypes.ts +++ b/src/validation/validationTypes.ts @@ -1,5 +1,5 @@ -import { FileAdapter, ProcessorInput } from "../utils/io"; -import { ZipAdapter } from "../utils/zip"; +import { FileAdapter, ProcessorInput } from '../utils/io'; +import { ZipAdapter } from '../utils/zip'; /** * Custom error class for validation errors @@ -10,7 +10,7 @@ export class ValidationError extends Error { constructor(message: string, blocker = false) { super(message); - this.name = "ValidationError"; + this.name = 'ValidationError'; this.blocker = blocker; } } @@ -88,13 +88,9 @@ export class ValidationFailureError extends Error { validationResult: ValidationResult; originalError?: unknown; - constructor( - message: string, - validationResult: ValidationResult, - originalError?: unknown, - ) { + constructor(message: string, validationResult: ValidationResult, originalError?: unknown) { super(message); - this.name = "ValidationFailureError"; + this.name = 'ValidationFailureError'; this.validationResult = validationResult; this.originalError = originalError; } @@ -122,8 +118,8 @@ export function buildValidationResultFromMessage(params: { warnings: 0, results: [ { - type: params.type || "parse", - description: params.description || "parse", + type: params.type || 'parse', + description: params.description || 'parse', valid: false, error: params.message, }, diff --git a/test/advancedScenarios.test.ts b/test/advancedScenarios.test.ts index dbb3d16..51c4794 100644 --- a/test/advancedScenarios.test.ts +++ b/test/advancedScenarios.test.ts @@ -1,47 +1,42 @@ // Advanced scenario testing for complex real-world use cases -import fs from "fs"; -import path from "path"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { OpmlProcessor } from "../src/processors/opmlProcessor"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; -import { getProcessor } from "../src/index"; -import { TreeFactory, PageFactory, TestDataUtils } from "./utils/testFactories"; -import { - TestEnvironmentManager, - PerformanceHelper, - AsyncTestHelper, -} from "./utils/testHelpers"; - -describe("Advanced Scenario Testing", () => { +import fs from 'fs'; +import path from 'path'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; +import { getProcessor } from '../src/index'; +import { TreeFactory, PageFactory, TestDataUtils } from './utils/testFactories'; +import { TestEnvironmentManager, PerformanceHelper, AsyncTestHelper } from './utils/testHelpers'; + +describe('Advanced Scenario Testing', () => { let testEnv: ReturnType; beforeAll(async () => { - testEnv = - TestEnvironmentManager.createTempEnvironment("advanced-scenarios"); + testEnv = TestEnvironmentManager.createTempEnvironment('advanced-scenarios'); }); afterAll(async () => { testEnv.cleanup(); }); - describe("Multi-Format Workflow Scenarios", () => { - it("should handle complete AAC development workflow", async () => { + describe('Multi-Format Workflow Scenarios', () => { + it('should handle complete AAC development workflow', async () => { // Scenario: Create AAC board in DOT, convert to multiple formats, translate, and verify consistency // Step 1: Create initial communication board in DOT format const initialTree = TreeFactory.createCommunicationBoard(); const dotProcessor = new DotProcessor(); - const dotPath = path.join(testEnv.tempDir, "initial.dot"); + const dotPath = path.join(testEnv.tempDir, 'initial.dot'); await dotProcessor.saveFromTree(initialTree, dotPath); expect(fs.existsSync(dotPath)).toBe(true); // Step 2: Convert to multiple formats const formats = [ - { ext: ".opml", processor: new OpmlProcessor() }, - { ext: ".obf", processor: new ObfProcessor() }, - { ext: ".plist", processor: new ApplePanelsProcessor() }, + { ext: '.opml', processor: new OpmlProcessor() }, + { ext: '.obf', processor: new ObfProcessor() }, + { ext: '.plist', processor: new ApplePanelsProcessor() }, ]; const convertedFiles: Record = {}; @@ -55,35 +50,28 @@ describe("Advanced Scenario Testing", () => { // Step 3: Extract texts from all formats const allTexts: Record = {}; - allTexts[".dot"] = await dotProcessor.extractTexts(dotPath); + allTexts['.dot'] = await dotProcessor.extractTexts(dotPath); for (const { ext, processor } of formats) { allTexts[ext] = await processor.extractTexts(convertedFiles[ext]); } // Step 4: Create translations - const originalTexts = allTexts[".dot"]; - const translations = TestDataUtils.createTranslationMap( - originalTexts, - "es", - ); + const originalTexts = allTexts['.dot']; + const translations = TestDataUtils.createTranslationMap(originalTexts, 'es'); // Step 5: Apply translations to all formats const translatedFiles: Record = {}; // Translate DOT - const translatedDotPath = path.join(testEnv.tempDir, "translated.dot"); + const translatedDotPath = path.join(testEnv.tempDir, 'translated.dot'); await dotProcessor.processTexts(dotPath, translations, translatedDotPath); - translatedFiles[".dot"] = translatedDotPath; + translatedFiles['.dot'] = translatedDotPath; // Translate other formats for (const { ext, processor } of formats) { const translatedPath = path.join(testEnv.tempDir, `translated${ext}`); - await processor.processTexts( - convertedFiles[ext], - translations, - translatedPath, - ); + await processor.processTexts(convertedFiles[ext], translations, translatedPath); translatedFiles[ext] = translatedPath; } @@ -92,11 +80,11 @@ describe("Advanced Scenario Testing", () => { expect(fs.existsSync(filePath)).toBe(true); const processor = - ext === ".dot" + ext === '.dot' ? dotProcessor - : ext === ".opml" + : ext === '.opml' ? new OpmlProcessor() - : ext === ".obf" + : ext === '.obf' ? new ObfProcessor() : new ApplePanelsProcessor(); @@ -104,19 +92,14 @@ describe("Advanced Scenario Testing", () => { // Should have some Spanish translations const hasSpanishContent = translatedTexts.some( - (text) => - text.includes("Hola") || - text.includes("Comida") || - text.includes("Casa"), + (text) => text.includes('Hola') || text.includes('Comida') || text.includes('Casa') ); if (translatedTexts.length > 0) { - if (ext === ".opml") { + if (ext === '.opml') { // OPML is lossy for SPEAK buttons (like Hello -> Hola), so we only check for page names // Home -> Casa should be present as it's the root page - const hasCasa = translatedTexts.some((text) => - text.includes("Casa"), - ); + const hasCasa = translatedTexts.some((text) => text.includes('Casa')); expect(hasCasa).toBe(true); } else { expect(hasSpanishContent).toBe(true); @@ -131,24 +114,24 @@ describe("Advanced Scenario Testing", () => { } }); - it("should handle collaborative editing scenario", async () => { + it('should handle collaborative editing scenario', async () => { // Scenario: Multiple users editing the same AAC board in different formats const baseTree = TreeFactory.createSimple(); // User 1: Works with DOT format const dotProcessor = new DotProcessor(); - const dotPath = path.join(testEnv.tempDir, "collaborative.dot"); + const dotPath = path.join(testEnv.tempDir, 'collaborative.dot'); await dotProcessor.saveFromTree(baseTree, dotPath); // User 2: Converts to OPML and adds content const opmlProcessor = new OpmlProcessor(); - const opmlPath = path.join(testEnv.tempDir, "collaborative.opml"); + const opmlPath = path.join(testEnv.tempDir, 'collaborative.opml'); await opmlProcessor.saveFromTree(baseTree, opmlPath); // User 3: Converts to OBF and modifies const obfProcessor = new ObfProcessor(); - const obfPath = path.join(testEnv.tempDir, "collaborative.obf"); + const obfPath = path.join(testEnv.tempDir, 'collaborative.obf'); await obfProcessor.saveFromTree(baseTree, obfPath); // Simulate concurrent modifications @@ -158,13 +141,13 @@ describe("Advanced Scenario Testing", () => { // DOT modification const tree = await dotProcessor.loadIntoTree(dotPath); const newPage = PageFactory.create({ - id: "dot_addition", - name: "DOT Addition", - buttons: [{ label: "DOT Button", type: "SPEAK" }], + id: 'dot_addition', + name: 'DOT Addition', + buttons: [{ label: 'DOT Button', type: 'SPEAK' }], }); tree.addPage(newPage); - const modifiedDotPath = path.join(testEnv.tempDir, "modified.dot"); + const modifiedDotPath = path.join(testEnv.tempDir, 'modified.dot'); await dotProcessor.saveFromTree(tree, modifiedDotPath); return modifiedDotPath; }, @@ -172,16 +155,13 @@ describe("Advanced Scenario Testing", () => { // OPML modification const tree = await opmlProcessor.loadIntoTree(opmlPath); const newPage = PageFactory.create({ - id: "opml_addition", - name: "OPML Addition", - buttons: [{ label: "OPML Button", type: "SPEAK" }], + id: 'opml_addition', + name: 'OPML Addition', + buttons: [{ label: 'OPML Button', type: 'SPEAK' }], }); tree.addPage(newPage); - const modifiedOpmlPath = path.join( - testEnv.tempDir, - "modified.opml", - ); + const modifiedOpmlPath = path.join(testEnv.tempDir, 'modified.opml'); await opmlProcessor.saveFromTree(tree, modifiedOpmlPath); return modifiedOpmlPath; }, @@ -189,18 +169,18 @@ describe("Advanced Scenario Testing", () => { // OBF modification const tree = await obfProcessor.loadIntoTree(obfPath); const newPage = PageFactory.create({ - id: "obf_addition", - name: "OBF Addition", - buttons: [{ label: "OBF Button", type: "SPEAK" }], + id: 'obf_addition', + name: 'OBF Addition', + buttons: [{ label: 'OBF Button', type: 'SPEAK' }], }); tree.addPage(newPage); - const modifiedObfPath = path.join(testEnv.tempDir, "modified.obf"); + const modifiedObfPath = path.join(testEnv.tempDir, 'modified.obf'); await obfProcessor.saveFromTree(tree, modifiedObfPath); return modifiedObfPath; }, ], - 3, + 3 ); // Verify all modifications were successful @@ -214,16 +194,14 @@ describe("Advanced Scenario Testing", () => { const opmlTree = await opmlProcessor.loadIntoTree(modifications[1]); const obfTree = await obfProcessor.loadIntoTree(modifications[2]); - expect(Object.keys(dotTree.pages).length).toBeGreaterThan( - Object.keys(baseTree.pages).length, - ); + expect(Object.keys(dotTree.pages).length).toBeGreaterThan(Object.keys(baseTree.pages).length); expect(Object.keys(opmlTree.pages).length).toBeGreaterThan(0); expect(Object.keys(obfTree.pages).length).toBeGreaterThan(0); }); }); - describe("Performance-Critical Scenarios", () => { - it("should handle high-volume batch processing", async () => { + describe('Performance-Critical Scenarios', () => { + it('should handle high-volume batch processing', async () => { // Scenario: Process 50 AAC boards simultaneously const batchSize = 20; // Reduced for CI stability @@ -234,34 +212,28 @@ describe("Advanced Scenario Testing", () => { new ApplePanelsProcessor(), ]; - const { result: batchResults, metrics } = - await PerformanceHelper.measureAsync(async () => { - const batchOperations = Array.from( - { length: batchSize }, - (_, i) => async () => { - const tree = TreeFactory.createLarge(5, 6); // 5 pages, 6 buttons each - const processor = processors[i % processors.length]; - const ext = [".dot", ".opml", ".obf", ".plist"][ - i % processors.length - ]; - - const filePath = path.join(testEnv.tempDir, `batch_${i}${ext}`); - await processor.saveFromTree(tree, filePath); - - const reloadedTree = await processor.loadIntoTree(filePath); - const texts = await processor.extractTexts(filePath); - - return { - index: i, - pageCount: Object.keys(reloadedTree.pages).length, - textCount: texts.length, - fileSize: fs.statSync(filePath).size, - }; - }, - ); + const { result: batchResults, metrics } = await PerformanceHelper.measureAsync(async () => { + const batchOperations = Array.from({ length: batchSize }, (_, i) => async () => { + const tree = TreeFactory.createLarge(5, 6); // 5 pages, 6 buttons each + const processor = processors[i % processors.length]; + const ext = ['.dot', '.opml', '.obf', '.plist'][i % processors.length]; + + const filePath = path.join(testEnv.tempDir, `batch_${i}${ext}`); + await processor.saveFromTree(tree, filePath); + + const reloadedTree = await processor.loadIntoTree(filePath); + const texts = await processor.extractTexts(filePath); + + return { + index: i, + pageCount: Object.keys(reloadedTree.pages).length, + textCount: texts.length, + fileSize: fs.statSync(filePath).size, + }; + }); - return AsyncTestHelper.runConcurrently(batchOperations, 5); - }, "Batch Processing"); + return AsyncTestHelper.runConcurrently(batchOperations, 5); + }, 'Batch Processing'); // Verify all operations completed successfully expect(batchResults).toHaveLength(batchSize); @@ -276,46 +248,37 @@ describe("Advanced Scenario Testing", () => { expect(metrics.memoryDelta.heapUsed / 1024 / 1024).toBeLessThan(200); // 200MB max }); - it("should handle streaming large file processing", async () => { + it('should handle streaming large file processing', async () => { // Scenario: Process very large AAC board (1000+ buttons) const largeTree = TreeFactory.createLarge(50, 20); // 50 pages, 20 buttons each = 1000 buttons const processor = new DotProcessor(); - const { result, metrics } = await PerformanceHelper.measureAsync( - async () => { - const largePath = path.join(testEnv.tempDir, "large_board.dot"); + const { result, metrics } = await PerformanceHelper.measureAsync(async () => { + const largePath = path.join(testEnv.tempDir, 'large_board.dot'); - // Save large tree - await processor.saveFromTree(largeTree, largePath); + // Save large tree + await processor.saveFromTree(largeTree, largePath); - // Load it back - const reloadedTree = await processor.loadIntoTree(largePath); + // Load it back + const reloadedTree = await processor.loadIntoTree(largePath); - // Extract texts - const texts = await processor.extractTexts(largePath); + // Extract texts + const texts = await processor.extractTexts(largePath); - // Apply translations - const translations = TestDataUtils.createTranslationMap( - texts.slice(0, 100), - "fr", - ); - const translatedPath = path.join( - testEnv.tempDir, - "large_translated.dot", - ); - await processor.processTexts(largePath, translations, translatedPath); + // Apply translations + const translations = TestDataUtils.createTranslationMap(texts.slice(0, 100), 'fr'); + const translatedPath = path.join(testEnv.tempDir, 'large_translated.dot'); + await processor.processTexts(largePath, translations, translatedPath); - return { - originalPages: Object.keys(largeTree.pages).length, - reloadedPages: Object.keys(reloadedTree.pages).length, - textCount: texts.length, - translationCount: translations.size, - fileSize: fs.statSync(largePath).size, - }; - }, - "Large File Processing", - ); + return { + originalPages: Object.keys(largeTree.pages).length, + reloadedPages: Object.keys(reloadedTree.pages).length, + textCount: texts.length, + translationCount: translations.size, + fileSize: fs.statSync(largePath).size, + }; + }, 'Large File Processing'); expect(result.originalPages).toBe(50); expect(result.reloadedPages).toBeGreaterThan(0); @@ -328,35 +291,35 @@ describe("Advanced Scenario Testing", () => { }); }); - describe("Error Recovery Scenarios", () => { - it("should handle partial file corruption gracefully", async () => { + describe('Error Recovery Scenarios', () => { + it('should handle partial file corruption gracefully', async () => { // Scenario: Process files with various types of corruption const validTree = TreeFactory.createSimple(); const processor = new DotProcessor(); // Create valid file first - const validPath = path.join(testEnv.tempDir, "valid.dot"); + const validPath = path.join(testEnv.tempDir, 'valid.dot'); await processor.saveFromTree(validTree, validPath); - const validContent = fs.readFileSync(validPath, "utf8"); + const validContent = fs.readFileSync(validPath, 'utf8'); // Test various corruption scenarios const corruptionTests = [ { - name: "Truncated file", + name: 'Truncated file', content: validContent.slice(0, validContent.length / 2), }, { - name: "Invalid characters", - content: validContent.replace(/digraph/g, "invalid\0\xFF"), + name: 'Invalid characters', + content: validContent.replace(/digraph/g, 'invalid\0\xFF'), }, { - name: "Malformed structure", - content: validContent.replace(/}/g, "").replace(/{/g, ""), + name: 'Malformed structure', + content: validContent.replace(/}/g, '').replace(/{/g, ''), }, { - name: "Mixed encoding", - content: validContent + "\xFF\xFE\x00\x00", + name: 'Mixed encoding', + content: validContent + '\xFF\xFE\x00\x00', }, ]; @@ -364,7 +327,7 @@ describe("Advanced Scenario Testing", () => { corruptionTests.map((test) => async () => { const corruptedPath = path.join( testEnv.tempDir, - `corrupted_${test.name.replace(/\s+/g, "_")}.dot`, + `corrupted_${test.name.replace(/\s+/g, '_')}.dot` ); fs.writeFileSync(corruptedPath, test.content); @@ -382,11 +345,11 @@ describe("Advanced Scenario Testing", () => { return { name: test.name, success: false, - error: error instanceof Error ? error.message : "Unknown error", + error: error instanceof Error ? error.message : 'Unknown error', }; } }), - 2, + 2 ); // Should handle corruption gracefully (either succeed with partial data or fail cleanly) @@ -399,49 +362,43 @@ describe("Advanced Scenario Testing", () => { } else { // If it fails, should have meaningful error expect(result.error).toBeDefined(); - expect(typeof result.error).toBe("string"); + expect(typeof result.error).toBe('string'); } }); }); - it("should handle resource exhaustion scenarios", async () => { + it('should handle resource exhaustion scenarios', async () => { // Scenario: Test behavior under resource constraints const processor = new DotProcessor(); // Test with many small operations (simulating memory pressure) - const smallOperations = Array.from( - { length: 100 }, - (_, i) => async () => { - const tree = TreeFactory.createMinimal(); - const tempPath = path.join(testEnv.tempDir, `small_${i}.dot`); + const smallOperations = Array.from({ length: 100 }, (_, i) => async () => { + const tree = TreeFactory.createMinimal(); + const tempPath = path.join(testEnv.tempDir, `small_${i}.dot`); - try { - await processor.saveFromTree(tree, tempPath); - const reloadedTree = await processor.loadIntoTree(tempPath); + try { + await processor.saveFromTree(tree, tempPath); + const reloadedTree = await processor.loadIntoTree(tempPath); - // Clean up immediately to simulate resource pressure - fs.unlinkSync(tempPath); + // Clean up immediately to simulate resource pressure + fs.unlinkSync(tempPath); - return { - index: i, - success: true, - pageCount: Object.keys(reloadedTree.pages).length, - }; - } catch (error) { - return { - index: i, - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } - }, - ); + return { + index: i, + success: true, + pageCount: Object.keys(reloadedTree.pages).length, + }; + } catch (error) { + return { + index: i, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); - const results = await AsyncTestHelper.runConcurrently( - smallOperations, - 10, - ); + const results = await AsyncTestHelper.runConcurrently(smallOperations, 10); // Most operations should succeed const successCount = results.filter((r) => r.success).length; @@ -459,21 +416,20 @@ describe("Advanced Scenario Testing", () => { }); }); - describe("Integration with External Systems", () => { - it("should handle processor factory with dynamic format detection", async () => { + describe('Integration with External Systems', () => { + it('should handle processor factory with dynamic format detection', async () => { // Scenario: Dynamically process files based on extension const testFiles = [ - { name: "test.dot", content: 'digraph G { test [label="Test"]; }' }, + { name: 'test.dot', content: 'digraph G { test [label="Test"]; }' }, { - name: "test.opml", + name: 'test.opml', content: '', }, { - name: "test.obf", - content: - '{"id": "test", "buttons": [{"id": "btn1", "label": "Test"}]}', + name: 'test.obf', + content: '{"id": "test", "buttons": [{"id": "btn1", "label": "Test"}]}', }, ]; @@ -498,7 +454,7 @@ describe("Advanced Scenario Testing", () => { results.push({ file: file.name, success: false, - error: error instanceof Error ? error.message : "Unknown error", + error: error instanceof Error ? error.message : 'Unknown error', }); } } @@ -514,13 +470,13 @@ describe("Advanced Scenario Testing", () => { }); // Verify correct processor types - const dotResult = results.find((r) => r.file === "test.dot"); - const opmlResult = results.find((r) => r.file === "test.opml"); - const obfResult = results.find((r) => r.file === "test.obf"); + const dotResult = results.find((r) => r.file === 'test.dot'); + const opmlResult = results.find((r) => r.file === 'test.opml'); + const obfResult = results.find((r) => r.file === 'test.obf'); - expect(dotResult?.processorType).toBe("DotProcessor"); - expect(opmlResult?.processorType).toBe("OpmlProcessor"); - expect(obfResult?.processorType).toBe("ObfProcessor"); + expect(dotResult?.processorType).toBe('DotProcessor'); + expect(opmlResult?.processorType).toBe('OpmlProcessor'); + expect(obfResult?.processorType).toBe('ObfProcessor'); }); }); }); diff --git a/test/aliasMethodsIntegration.test.ts b/test/aliasMethodsIntegration.test.ts index 9bf9962..d3e7315 100644 --- a/test/aliasMethodsIntegration.test.ts +++ b/test/aliasMethodsIntegration.test.ts @@ -1,24 +1,20 @@ // Integration tests for alias methods across all processors -import fs from "fs"; -import path from "path"; -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; -import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; -import { AstericsGridProcessor } from "../src/processors/astericsGridProcessor"; -import { ExcelProcessor } from "../src/processors/excelProcessor"; -import { OpmlProcessor } from "../src/processors/opmlProcessor"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { StringCasing } from "../src/core/stringCasing"; -import { - ExtractStringsResult, - TranslatedString, - SourceString, -} from "../src/core/baseProcessor"; - -describe("Alias Methods Integration", () => { - const tempDir = path.join(__dirname, "temp_alias_tests"); +import fs from 'fs'; +import path from 'path'; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; +import { AstericsGridProcessor } from '../src/processors/astericsGridProcessor'; +import { ExcelProcessor } from '../src/processors/excelProcessor'; +import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { StringCasing } from '../src/core/stringCasing'; +import { ExtractStringsResult, TranslatedString, SourceString } from '../src/core/baseProcessor'; + +describe('Alias Methods Integration', () => { + const tempDir = path.join(__dirname, 'temp_alias_tests'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -32,68 +28,65 @@ describe("Alias Methods Integration", () => { } }); - describe("TouchChatProcessor Alias Methods", () => { + describe('TouchChatProcessor Alias Methods', () => { const processor = new TouchChatProcessor(); - const exampleFile = path.join(__dirname, "assets/excel/example.ce"); + const exampleFile = path.join(__dirname, 'assets/excel/example.ce'); - it("should extract strings with metadata in expected format", async () => { + it('should extract strings with metadata in expected format', async () => { if (!fs.existsSync(exampleFile)) { - console.log("Skipping TouchChat test - example file not found"); + console.log('Skipping TouchChat test - example file not found'); return; } - const result: ExtractStringsResult = - await processor.extractStringsWithMetadata(exampleFile); + const result: ExtractStringsResult = await processor.extractStringsWithMetadata(exampleFile); - expect(result).toHaveProperty("errors"); - expect(result).toHaveProperty("extractedStrings"); + expect(result).toHaveProperty('errors'); + expect(result).toHaveProperty('extractedStrings'); expect(Array.isArray(result.errors)).toBe(true); expect(Array.isArray(result.extractedStrings)).toBe(true); if (result.extractedStrings.length > 0) { const firstString = result.extractedStrings[0]; - expect(firstString).toHaveProperty("string"); - expect(firstString).toHaveProperty("vocabPlacementMeta"); - expect(firstString.vocabPlacementMeta).toHaveProperty("vocabLocations"); - expect( - Array.isArray(firstString.vocabPlacementMeta.vocabLocations), - ).toBe(true); + expect(firstString).toHaveProperty('string'); + expect(firstString).toHaveProperty('vocabPlacementMeta'); + expect(firstString.vocabPlacementMeta).toHaveProperty('vocabLocations'); + expect(Array.isArray(firstString.vocabPlacementMeta.vocabLocations)).toBe(true); if (firstString.vocabPlacementMeta.vocabLocations.length > 0) { const location = firstString.vocabPlacementMeta.vocabLocations[0]; - expect(location).toHaveProperty("table"); - expect(location).toHaveProperty("id"); - expect(location).toHaveProperty("column"); - expect(location).toHaveProperty("casing"); + expect(location).toHaveProperty('table'); + expect(location).toHaveProperty('id'); + expect(location).toHaveProperty('column'); + expect(location).toHaveProperty('casing'); expect(Object.values(StringCasing)).toContain(location.casing); } } }); - it("should generate translated downloads", async () => { + it('should generate translated downloads', async () => { if (!fs.existsSync(exampleFile)) { - console.log("Skipping TouchChat test - example file not found"); + console.log('Skipping TouchChat test - example file not found'); return; } const mockTranslatedStrings: TranslatedString[] = [ { sourcestringid: 1, - overridestring: "", - translatedstring: "Translated Text", + overridestring: '', + translatedstring: 'Translated Text', }, ]; const mockSourceStrings: SourceString[] = [ { id: 1, - sourcestring: "Original Text", + sourcestring: 'Original Text', vocabplacementmetadata: { vocabLocations: [ { - table: "buttons", + table: 'buttons', id: 1, - column: "LABEL", + column: 'LABEL', casing: StringCasing.LOWER, }, ], @@ -104,7 +97,7 @@ describe("Alias Methods Integration", () => { const outputPath = await processor.generateTranslatedDownload( exampleFile, mockTranslatedStrings, - mockSourceStrings, + mockSourceStrings ); expect(outputPath).toMatch(/_translated\.ce$/); @@ -116,99 +109,96 @@ describe("Alias Methods Integration", () => { } }); - it("should handle errors gracefully", async () => { - const nonExistentFile = path.join(tempDir, "nonexistent.ce"); + it('should handle errors gracefully', async () => { + const nonExistentFile = path.join(tempDir, 'nonexistent.ce'); - const result = - await processor.extractStringsWithMetadata(nonExistentFile); + const result = await processor.extractStringsWithMetadata(nonExistentFile); expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors[0]).toHaveProperty("message"); - expect(result.errors[0]).toHaveProperty("step"); - expect(result.errors[0].step).toBe("EXTRACT"); + expect(result.errors[0]).toHaveProperty('message'); + expect(result.errors[0]).toHaveProperty('step'); + expect(result.errors[0].step).toBe('EXTRACT'); expect(result.extractedStrings).toEqual([]); }); }); - describe("ObfProcessor Alias Methods", () => { + describe('ObfProcessor Alias Methods', () => { const processor = new ObfProcessor(); - const exampleFile = path.join(__dirname, "assets/obf/example.obf"); + const exampleFile = path.join(__dirname, 'assets/obf/example.obf'); - it("should have alias methods available", async () => { - expect(typeof processor.extractStringsWithMetadata).toBe("function"); - expect(typeof processor.generateTranslatedDownload).toBe("function"); + it('should have alias methods available', async () => { + expect(typeof processor.extractStringsWithMetadata).toBe('function'); + expect(typeof processor.generateTranslatedDownload).toBe('function'); }); - it("should extract strings with metadata using generic implementation", async () => { + it('should extract strings with metadata using generic implementation', async () => { if (!fs.existsSync(exampleFile)) { - console.log("Skipping OBF test - example file not found"); + console.log('Skipping OBF test - example file not found'); return; } const result = await processor.extractStringsWithMetadata(exampleFile); - expect(result).toHaveProperty("errors"); - expect(result).toHaveProperty("extractedStrings"); + expect(result).toHaveProperty('errors'); + expect(result).toHaveProperty('extractedStrings'); expect(Array.isArray(result.errors)).toBe(true); expect(Array.isArray(result.extractedStrings)).toBe(true); }); }); - describe("SnapProcessor Alias Methods", () => { + describe('SnapProcessor Alias Methods', () => { const processor = new SnapProcessor(); - const exampleFile = path.join(__dirname, "assets/snap/example.spb"); + const exampleFile = path.join(__dirname, 'assets/snap/example.spb'); - it("should have alias methods available", async () => { - expect(typeof processor.extractStringsWithMetadata).toBe("function"); - expect(typeof processor.generateTranslatedDownload).toBe("function"); + it('should have alias methods available', async () => { + expect(typeof processor.extractStringsWithMetadata).toBe('function'); + expect(typeof processor.generateTranslatedDownload).toBe('function'); }); - it("should extract strings with metadata using generic implementation", async () => { + it('should extract strings with metadata using generic implementation', async () => { if (!fs.existsSync(exampleFile)) { - console.log("Skipping Snap test - example file not found"); + console.log('Skipping Snap test - example file not found'); return; } const result = await processor.extractStringsWithMetadata(exampleFile); - expect(result).toHaveProperty("errors"); - expect(result).toHaveProperty("extractedStrings"); + expect(result).toHaveProperty('errors'); + expect(result).toHaveProperty('extractedStrings'); expect(Array.isArray(result.errors)).toBe(true); expect(Array.isArray(result.extractedStrings)).toBe(true); }); }); - describe("Backward Compatibility", () => { - it("should maintain existing API methods", async () => { + describe('Backward Compatibility', () => { + it('should maintain existing API methods', async () => { const touchChatProcessor = new TouchChatProcessor(); const obfProcessor = new ObfProcessor(); const snapProcessor = new SnapProcessor(); // Verify existing methods still exist - expect(typeof touchChatProcessor.extractTexts).toBe("function"); - expect(typeof touchChatProcessor.loadIntoTree).toBe("function"); - expect(typeof touchChatProcessor.processTexts).toBe("function"); - expect(typeof touchChatProcessor.saveFromTree).toBe("function"); - - expect(typeof obfProcessor.extractTexts).toBe("function"); - expect(typeof obfProcessor.loadIntoTree).toBe("function"); - expect(typeof obfProcessor.processTexts).toBe("function"); - expect(typeof obfProcessor.saveFromTree).toBe("function"); - - expect(typeof snapProcessor.extractTexts).toBe("function"); - expect(typeof snapProcessor.loadIntoTree).toBe("function"); - expect(typeof snapProcessor.processTexts).toBe("function"); - expect(typeof snapProcessor.saveFromTree).toBe("function"); + expect(typeof touchChatProcessor.extractTexts).toBe('function'); + expect(typeof touchChatProcessor.loadIntoTree).toBe('function'); + expect(typeof touchChatProcessor.processTexts).toBe('function'); + expect(typeof touchChatProcessor.saveFromTree).toBe('function'); + + expect(typeof obfProcessor.extractTexts).toBe('function'); + expect(typeof obfProcessor.loadIntoTree).toBe('function'); + expect(typeof obfProcessor.processTexts).toBe('function'); + expect(typeof obfProcessor.saveFromTree).toBe('function'); + + expect(typeof snapProcessor.extractTexts).toBe('function'); + expect(typeof snapProcessor.loadIntoTree).toBe('function'); + expect(typeof snapProcessor.processTexts).toBe('function'); + expect(typeof snapProcessor.saveFromTree).toBe('function'); }); - it("should not break existing functionality", async () => { + it('should not break existing functionality', async () => { const processor = new TouchChatProcessor(); - const exampleFile = path.join(__dirname, "assets/excel/example.ce"); + const exampleFile = path.join(__dirname, 'assets/excel/example.ce'); if (!fs.existsSync(exampleFile)) { - console.log( - "Skipping backward compatibility test - example file not found", - ); + console.log('Skipping backward compatibility test - example file not found'); return; } @@ -221,8 +211,8 @@ describe("Alias Methods Integration", () => { }); }); - describe("Cross-Format Consistency", () => { - it("should provide consistent interface across all processors", async () => { + describe('Cross-Format Consistency', () => { + it('should provide consistent interface across all processors', async () => { const processors = [ new TouchChatProcessor(), new ObfProcessor(), @@ -237,14 +227,14 @@ describe("Alias Methods Integration", () => { processors.forEach((processor) => { // All processors should have the alias methods - expect(typeof processor.extractStringsWithMetadata).toBe("function"); - expect(typeof processor.generateTranslatedDownload).toBe("function"); + expect(typeof processor.extractStringsWithMetadata).toBe('function'); + expect(typeof processor.generateTranslatedDownload).toBe('function'); // All processors should have the standard methods - expect(typeof processor.extractTexts).toBe("function"); - expect(typeof processor.loadIntoTree).toBe("function"); - expect(typeof processor.processTexts).toBe("function"); - expect(typeof processor.saveFromTree).toBe("function"); + expect(typeof processor.extractTexts).toBe('function'); + expect(typeof processor.loadIntoTree).toBe('function'); + expect(typeof processor.processTexts).toBe('function'); + expect(typeof processor.saveFromTree).toBe('function'); }); }); }); diff --git a/test/applePanelsProcessor.roundtrip.test.ts b/test/applePanelsProcessor.roundtrip.test.ts index 98b5b75..02e4a5d 100644 --- a/test/applePanelsProcessor.roundtrip.test.ts +++ b/test/applePanelsProcessor.roundtrip.test.ts @@ -1,12 +1,12 @@ // Round-trip test for ApplePanelsProcessor: load, save, reload, and compare structure -import fs from "fs"; -import path from "path"; -import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; -import { ValidationFailureError } from "../src/validation"; +import fs from 'fs'; +import path from 'path'; +import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; +import { ValidationFailureError } from '../src/validation'; -describe("ApplePanelsProcessor round-trip", () => { - const outPath: string = path.join(__dirname, "out.applepanels"); +describe('ApplePanelsProcessor round-trip', () => { + const outPath: string = path.join(__dirname, 'out.applepanels'); afterAll(async () => { const asconfigPath = `${outPath}.ascconfig`; @@ -15,7 +15,7 @@ describe("ApplePanelsProcessor round-trip", () => { } }); - it("can save and load a constructed tree", async () => { + it('can save and load a constructed tree', async () => { const processor = new ApplePanelsProcessor(); // Create a simple tree programmatically @@ -23,24 +23,24 @@ describe("ApplePanelsProcessor round-trip", () => { // Create first panel const page1 = new AACPage({ - id: "panel1", - name: "Main Panel", + id: 'panel1', + name: 'Main Panel', buttons: [], }); const button1 = new AACButton({ - id: "btn1", - label: "Hello", - message: "Hello World", - type: "SPEAK", + id: 'btn1', + label: 'Hello', + message: 'Hello World', + type: 'SPEAK', }); const button2 = new AACButton({ - id: "btn2", - label: "Go to Panel 2", - message: "Navigate", - type: "NAVIGATE", - targetPageId: "panel2", + id: 'btn2', + label: 'Go to Panel 2', + message: 'Navigate', + type: 'NAVIGATE', + targetPageId: 'panel2', }); page1.addButton(button1); @@ -49,17 +49,17 @@ describe("ApplePanelsProcessor round-trip", () => { // Create second panel const page2 = new AACPage({ - id: "panel2", - name: "Second Panel", + id: 'panel2', + name: 'Second Panel', buttons: [], }); const button3 = new AACButton({ - id: "btn3", - label: "Back", - message: "Go back", - type: "NAVIGATE", - targetPageId: "panel1", + id: 'btn3', + label: 'Back', + message: 'Go back', + type: 'NAVIGATE', + targetPageId: 'panel1', }); page2.addButton(button3); @@ -75,25 +75,25 @@ describe("ApplePanelsProcessor round-trip", () => { // Verify structure expect(Object.keys(tree2.pages)).toHaveLength(2); - const reloadedPage1 = tree2.pages["panel1"]; + const reloadedPage1 = tree2.pages['panel1']; expect(reloadedPage1).toBeDefined(); - expect(reloadedPage1.name).toBe("Main Panel"); + expect(reloadedPage1.name).toBe('Main Panel'); expect(reloadedPage1.buttons).toHaveLength(2); - const reloadedPage2 = tree2.pages["panel2"]; + const reloadedPage2 = tree2.pages['panel2']; expect(reloadedPage2).toBeDefined(); - expect(reloadedPage2.name).toBe("Second Panel"); + expect(reloadedPage2.name).toBe('Second Panel'); expect(reloadedPage2.buttons).toHaveLength(1); // Check navigation - const navButton = reloadedPage1.buttons.find((b) => b.type === "NAVIGATE"); + const navButton = reloadedPage1.buttons.find((b) => b.type === 'NAVIGATE'); expect(navButton).toBeDefined(); if (navButton) { - expect(navButton.targetPageId).toBe("panel2"); + expect(navButton.targetPageId).toBe('panel2'); } }); - it("handles empty tree gracefully", async () => { + it('handles empty tree gracefully', async () => { const processor = new ApplePanelsProcessor(); const emptyTree = new AACTree(); @@ -101,8 +101,6 @@ describe("ApplePanelsProcessor round-trip", () => { const asconfigPath = `${outPath}.ascconfig`; expect(fs.existsSync(asconfigPath)).toBe(true); - await expect(processor.loadIntoTree(asconfigPath)).rejects.toThrow( - ValidationFailureError, - ); + await expect(processor.loadIntoTree(asconfigPath)).rejects.toThrow(ValidationFailureError); }); }); diff --git a/test/astericsColors.test.ts b/test/astericsColors.test.ts index 6515bdd..aa0ece9 100644 --- a/test/astericsColors.test.ts +++ b/test/astericsColors.test.ts @@ -2,51 +2,51 @@ import { normalizeHexColor, adjustHexColor, getContrastingTextColor, -} from "../src/processors/astericsGridProcessor"; +} from '../src/processors/astericsGridProcessor'; -describe("AstericsGrid Color Helpers", () => { - describe("normalizeHexColor", () => { - it("should normalize hex formats with # prefix", async () => { - expect(normalizeHexColor("#abc")).toBe("#aabbcc"); - expect(normalizeHexColor("#aabbcc")).toBe("#aabbcc"); +describe('AstericsGrid Color Helpers', () => { + describe('normalizeHexColor', () => { + it('should normalize hex formats with # prefix', async () => { + expect(normalizeHexColor('#abc')).toBe('#aabbcc'); + expect(normalizeHexColor('#aabbcc')).toBe('#aabbcc'); }); - it("should return null for hex without # prefix (strict)", async () => { - expect(normalizeHexColor("abc")).toBeNull(); - expect(normalizeHexColor("aabbcc")).toBeNull(); + it('should return null for hex without # prefix (strict)', async () => { + expect(normalizeHexColor('abc')).toBeNull(); + expect(normalizeHexColor('aabbcc')).toBeNull(); }); - it("should return null for invalid colors", async () => { - expect(normalizeHexColor("#zzzzzz")).toBeNull(); - expect(normalizeHexColor("")).toBeNull(); + it('should return null for invalid colors', async () => { + expect(normalizeHexColor('#zzzzzz')).toBeNull(); + expect(normalizeHexColor('')).toBeNull(); }); }); - describe("adjustHexColor", () => { - it("should lighten a color", async () => { + describe('adjustHexColor', () => { + it('should lighten a color', async () => { // #101010 + 10 -> #1a1a1a (16+10=26 -> 0x1a) - expect(adjustHexColor("#101010", 10)).toBe("#1a1a1a"); + expect(adjustHexColor('#101010', 10)).toBe('#1a1a1a'); }); - it("should darken a color", async () => { - expect(adjustHexColor("#aabbcc", -10)).toBe("#a0b1c2"); + it('should darken a color', async () => { + expect(adjustHexColor('#aabbcc', -10)).toBe('#a0b1c2'); }); - it("should clamp to 0 and 255", async () => { - expect(adjustHexColor("#000000", -100)).toBe("#000000"); - expect(adjustHexColor("#ffffff", 100)).toBe("#ffffff"); + it('should clamp to 0 and 255', async () => { + expect(adjustHexColor('#000000', -100)).toBe('#000000'); + expect(adjustHexColor('#ffffff', 100)).toBe('#ffffff'); }); }); - describe("getContrastingTextColor", () => { - it("should return white for dark backgrounds", async () => { - expect(getContrastingTextColor("#000000")).toBe("#FFFFFF"); - expect(getContrastingTextColor("#333333")).toBe("#FFFFFF"); + describe('getContrastingTextColor', () => { + it('should return white for dark backgrounds', async () => { + expect(getContrastingTextColor('#000000')).toBe('#FFFFFF'); + expect(getContrastingTextColor('#333333')).toBe('#FFFFFF'); }); - it("should return black for light backgrounds", async () => { - expect(getContrastingTextColor("#FFFFFF")).toBe("#000000"); - expect(getContrastingTextColor("#DDDDDD")).toBe("#000000"); + it('should return black for light backgrounds', async () => { + expect(getContrastingTextColor('#FFFFFF')).toBe('#000000'); + expect(getContrastingTextColor('#DDDDDD')).toBe('#000000'); }); }); }); diff --git a/test/astericsGridProcessor.test.ts b/test/astericsGridProcessor.test.ts index 70ce46d..952aff1 100644 --- a/test/astericsGridProcessor.test.ts +++ b/test/astericsGridProcessor.test.ts @@ -1,15 +1,11 @@ -import { AstericsGridProcessor } from "../src/processors/astericsGridProcessor"; -import { - AACTree, - AACButton, - AACSemanticCategory, -} from "../src/core/treeStructure"; -import path from "path"; -import fs from "fs"; - -describe("AstericsGridProcessor", () => { - const exampleGrdFile = path.join(__dirname, "assets/asterics/example2.grd"); - const tempOutputPath = path.join(__dirname, "temp_test.grd"); +import { AstericsGridProcessor } from '../src/processors/astericsGridProcessor'; +import { AACTree, AACButton, AACSemanticCategory } from '../src/core/treeStructure'; +import path from 'path'; +import fs from 'fs'; + +describe('AstericsGridProcessor', () => { + const exampleGrdFile = path.join(__dirname, 'assets/asterics/example2.grd'); + const tempOutputPath = path.join(__dirname, 'temp_test.grd'); afterEach(async () => { if (fs.existsSync(tempOutputPath)) { @@ -17,50 +13,44 @@ describe("AstericsGridProcessor", () => { } }); - it("should load an Asterics Grid file into an AACTree", async () => { + it('should load an Asterics Grid file into an AACTree', async () => { const processor = new AstericsGridProcessor(); const tree = await processor.loadIntoTree(exampleGrdFile); expect(tree).toBeInstanceOf(AACTree); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it("should extract texts from an Asterics Grid file", async () => { + it('should extract texts from an Asterics Grid file', async () => { const processor = new AstericsGridProcessor(); const texts = await processor.extractTexts(exampleGrdFile); expect(Array.isArray(texts)).toBe(true); expect(texts.length).toBeGreaterThan(0); - expect(texts).toContain("Change in element"); + expect(texts).toContain('Change in element'); }); - it("should process texts and save the changes", async () => { + it('should process texts and save the changes', async () => { const processor = new AstericsGridProcessor(); const translations = new Map(); - translations.set("Change in element", "Changed Element"); + translations.set('Change in element', 'Changed Element'); - const buffer = await processor.processTexts( - exampleGrdFile, - translations, - tempOutputPath, - ); + const buffer = await processor.processTexts(exampleGrdFile, translations, tempOutputPath); expect(Buffer.isBuffer(buffer)).toBe(true); const newTexts = await processor.extractTexts(tempOutputPath); - expect(newTexts).toContain("Changed Element"); + expect(newTexts).toContain('Changed Element'); }); - it("should perform a roundtrip (load -> save -> load)", async () => { + it('should perform a roundtrip (load -> save -> load)', async () => { const processor = new AstericsGridProcessor(); const initialTree = await processor.loadIntoTree(exampleGrdFile); await processor.saveFromTree(initialTree, tempOutputPath); const finalTree = await processor.loadIntoTree(tempOutputPath); - expect(Object.keys(finalTree.pages).length).toEqual( - Object.keys(initialTree.pages).length, - ); + expect(Object.keys(finalTree.pages).length).toEqual(Object.keys(initialTree.pages).length); // More detailed checks could be added here }); - it("should handle audio when the loadAudio option is true", async () => { + it('should handle audio when the loadAudio option is true', async () => { const processor = new AstericsGridProcessor({ loadAudio: true }); const tree = await processor.loadIntoTree(exampleGrdFile); @@ -77,28 +67,24 @@ describe("AstericsGridProcessor", () => { // This depends on the content of example2.grd having audio actions. // Based on the docs, GridActionAudio exists. We'll assume the example might have it. // If not, this test might need a dedicated test file with audio. - let content = fs.readFileSync(exampleGrdFile, "utf-8"); + let content = fs.readFileSync(exampleGrdFile, 'utf-8'); // Remove BOM if present if (content.charCodeAt(0) === 0xfeff) { content = content.slice(1); } const fileContent = JSON.parse(content); const hasAudioAction = fileContent.grids.some((g: any) => - g.gridElements.some((e: any) => - e.actions.some((a: any) => a.modelName === "GridActionAudio"), - ), + g.gridElements.some((e: any) => e.actions.some((a: any) => a.modelName === 'GridActionAudio')) ); if (hasAudioAction) { expect(foundAudioButton).toBe(true); } else { - console.warn( - "Test file does not contain audio actions, skipping audio assertion", - ); + console.warn('Test file does not contain audio actions, skipping audio assertion'); } }); - it("should extract comprehensive texts including multilingual labels", async () => { + it('should extract comprehensive texts including multilingual labels', async () => { const processor = new AstericsGridProcessor(); const texts = await processor.extractTexts(exampleGrdFile); @@ -106,13 +92,13 @@ describe("AstericsGridProcessor", () => { expect(texts.length).toBeGreaterThan(0); // Should contain various text elements from the example file - expect(texts).toContain("Change in element"); - expect(texts).toContain("Global grid"); - expect(texts).toContain("Next wordform"); - expect(texts).toContain("Home"); + expect(texts).toContain('Change in element'); + expect(texts).toContain('Global grid'); + expect(texts).toContain('Next wordform'); + expect(texts).toContain('Home'); }); - it("should handle multilingual content correctly", async () => { + it('should handle multilingual content correctly', async () => { const processor = new AstericsGridProcessor(); const tree = await processor.loadIntoTree(exampleGrdFile); @@ -125,7 +111,7 @@ describe("AstericsGridProcessor", () => { expect(pageNames.some((name) => name && name.length > 0)).toBe(true); }); - it("should handle navigation relationships correctly", async () => { + it('should handle navigation relationships correctly', async () => { const processor = new AstericsGridProcessor(); const tree = await processor.loadIntoTree(exampleGrdFile); @@ -149,7 +135,7 @@ describe("AstericsGridProcessor", () => { expect(foundNavigationButton).toBe(true); }); - it("should support audio enhancement methods", async () => { + it('should support audio enhancement methods', async () => { const processor = new AstericsGridProcessor(); // Test getElementIds method @@ -159,24 +145,21 @@ describe("AstericsGridProcessor", () => { // Test hasAudioRecording method const firstElementId = elementIds[0]; - const hasAudio = await processor.hasAudioRecording( - exampleGrdFile, - firstElementId, - ); - expect(typeof hasAudio).toBe("boolean"); + const hasAudio = await processor.hasAudioRecording(exampleGrdFile, firstElementId); + expect(typeof hasAudio).toBe('boolean'); }); - it("should handle word forms and advanced features", async () => { + it('should handle word forms and advanced features', async () => { const processor = new AstericsGridProcessor(); const texts = await processor.extractTexts(exampleGrdFile); // The example file contains word forms like "sein", "bin", "bist", etc. - expect(texts).toContain("sein"); - expect(texts).toContain("bin"); - expect(texts).toContain("am"); + expect(texts).toContain('sein'); + expect(texts).toContain('bin'); + expect(texts).toContain('am'); }); - it("should create proper AACButton objects with correct properties", async () => { + it('should create proper AACButton objects with correct properties', async () => { const processor = new AstericsGridProcessor(); const tree = await processor.loadIntoTree(exampleGrdFile); @@ -185,9 +168,9 @@ describe("AstericsGridProcessor", () => { page.buttons.forEach((button) => { foundButtons = true; expect(button).toBeInstanceOf(AACButton); - expect(typeof button.id).toBe("string"); - expect(typeof button.label).toBe("string"); - expect(typeof button.message).toBe("string"); + expect(typeof button.id).toBe('string'); + expect(typeof button.label).toBe('string'); + expect(typeof button.message).toBe('string'); // Check semantic action is present (modern approach, not button.type) expect(button.semanticAction).toBeDefined(); expect(button.semanticAction?.category).toBeDefined(); @@ -198,7 +181,7 @@ describe("AstericsGridProcessor", () => { expect(foundButtons).toBe(true); }); - it("should handle buffer input correctly", async () => { + it('should handle buffer input correctly', async () => { const processor = new AstericsGridProcessor(); const fileBuffer = fs.readFileSync(exampleGrdFile); @@ -211,35 +194,31 @@ describe("AstericsGridProcessor", () => { expect(texts.length).toBeGreaterThan(0); }); - it("should handle comprehensive translation processing", async () => { + it('should handle comprehensive translation processing', async () => { const processor = new AstericsGridProcessor(); const translations = new Map(); - translations.set("Change in element", "Elemento Cambiado"); - translations.set("Global grid", "Cuadrícula Global"); - translations.set("Home", "Inicio"); - - const buffer = await processor.processTexts( - exampleGrdFile, - translations, - tempOutputPath, - ); + translations.set('Change in element', 'Elemento Cambiado'); + translations.set('Global grid', 'Cuadrícula Global'); + translations.set('Home', 'Inicio'); + + const buffer = await processor.processTexts(exampleGrdFile, translations, tempOutputPath); expect(Buffer.isBuffer(buffer)).toBe(true); // Verify translations were applied const translatedTexts = await processor.extractTexts(tempOutputPath); - expect(translatedTexts).toContain("Elemento Cambiado"); - expect(translatedTexts).toContain("Cuadrícula Global"); - expect(translatedTexts).toContain("Inicio"); + expect(translatedTexts).toContain('Elemento Cambiado'); + expect(translatedTexts).toContain('Cuadrícula Global'); + expect(translatedTexts).toContain('Inicio'); }); - it("should preserve home page (tree.rootId) through roundtrip", async () => { + it('should preserve home page (tree.rootId) through roundtrip', async () => { const processor = new AstericsGridProcessor(); // Load the file and check if it has a rootId const initialTree = await processor.loadIntoTree(exampleGrdFile); // Read the original file to check if it has homeGridId in metadata - let content = fs.readFileSync(exampleGrdFile, "utf-8"); + let content = fs.readFileSync(exampleGrdFile, 'utf-8'); if (content.charCodeAt(0) === 0xfeff) { content = content.slice(1); } @@ -268,7 +247,7 @@ describe("AstericsGridProcessor", () => { expect(finalTree.rootId).toBe(initialTree.rootId); // Verify the saved file has homeGridId in metadata - let savedContent = fs.readFileSync(tempOutputPath, "utf-8"); + let savedContent = fs.readFileSync(tempOutputPath, 'utf-8'); if (savedContent.charCodeAt(0) === 0xfeff) { savedContent = savedContent.slice(1); } @@ -282,7 +261,7 @@ describe("AstericsGridProcessor", () => { } }); - it("should extract locale and supported languages into metadata", async () => { + it('should extract locale and supported languages into metadata', async () => { const processor = new AstericsGridProcessor(); const tree = await processor.loadIntoTree(exampleGrdFile); @@ -290,7 +269,7 @@ describe("AstericsGridProcessor", () => { expect(Array.isArray(tree.metadata.languages)).toBe(true); expect(tree.metadata.languages?.length).toBeGreaterThan(0); // At least English should be present in our example file - expect(tree.metadata.languages).toContain("en"); + expect(tree.metadata.languages).toContain('en'); // locale should be one of the languages expect(tree.metadata.languages).toContain(tree.metadata.locale); }); diff --git a/test/audit-images.test.ts b/test/audit-images.test.ts index 81350c9..7e989db 100644 --- a/test/audit-images.test.ts +++ b/test/audit-images.test.ts @@ -5,8 +5,8 @@ * are being resolved correctly by the processor. */ -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; -import path from "node:path"; +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import path from 'node:path'; interface AuditResult { totalCells: number; @@ -28,17 +28,9 @@ interface AuditResult { function countImageFilesInZip(entries: string[]): string[] { // Count all image files in the ZIP (excluding XML files) - const imageExtensions = [ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".bmp", - ".svg", - ".webp", - ]; + const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg', '.webp']; return entries.filter((entry) => { - const ext = entry.toLowerCase().split(".").pop(); + const ext = entry.toLowerCase().split('.').pop(); return ext && imageExtensions.includes(`.${ext}`); }); } @@ -51,14 +43,14 @@ async function auditGridsetImages(gridsetPath: string): Promise { // Get all entries from the ZIP for manual inspection // We need to access the internal ZIP entries - const AdmZip = (await import("adm-zip")).default; + const AdmZip = (await import('adm-zip')).default; const zip = new AdmZip(gridsetPath); const allEntries = zip.getEntries().map((e: any) => e.entryName); const imageFilesInZip = countImageFilesInZip(allEntries); const resolvedImagePaths = new Set(); - const unresolvedCells: AuditResult["unresolvedCells"] = []; + const unresolvedCells: AuditResult['unresolvedCells'] = []; let totalCells = 0; let cellsWithDeclaredImages = 0; @@ -93,17 +85,14 @@ async function auditGridsetImages(gridsetPath: string): Promise { // Check for resolved images that aren't actually in the ZIP const resolvedImagesNotInZip = Array.from(resolvedImagePaths).filter( - (img) => - !allEntries.includes(img) && - !allEntries.includes(img.replace(/^Grids\//, "")), + (img) => !allEntries.includes(img) && !allEntries.includes(img.replace(/^Grids\//, '')) ); return { totalCells, cellsWithDeclaredImages, cellsWithResolvedImages, - cellsWithoutResolvedImages: - cellsWithDeclaredImages - cellsWithResolvedImages, + cellsWithoutResolvedImages: cellsWithDeclaredImages - cellsWithResolvedImages, actualImageFilesInZip: imageFilesInZip.length, resolvedImagePaths: Array.from(resolvedImagePaths), unresolvedCells, @@ -112,49 +101,36 @@ async function auditGridsetImages(gridsetPath: string): Promise { }; } -describe("Gridset Image Audit", () => { - const exampleGridset = path.join( - process.cwd(), - "examples/example-images.gridset", - ); +describe('Gridset Image Audit', () => { + const exampleGridset = path.join(process.cwd(), 'examples/example-images.gridset'); - test("should resolve all images that exist in the ZIP", async () => { + test('should resolve all images that exist in the ZIP', async () => { const audit = await auditGridsetImages(exampleGridset); - console.log("\n=== Gridset Image Audit ==="); + console.log('\n=== Gridset Image Audit ==='); console.log(`Total cells: ${audit.totalCells}`); console.log(`Cells with declared images: ${audit.cellsWithDeclaredImages}`); console.log(`Cells with resolved images: ${audit.cellsWithResolvedImages}`); - console.log( - `Cells without resolved images: ${audit.cellsWithoutResolvedImages}`, - ); + console.log(`Cells without resolved images: ${audit.cellsWithoutResolvedImages}`); console.log(`Actual image files in ZIP: ${audit.actualImageFilesInZip}`); - console.log( - `Unique resolved image paths: ${audit.resolvedImagePaths.length}`, - ); - console.log( - `Resolved images not found in ZIP: ${audit.resolvedImagesNotInZip.length}`, - ); + console.log(`Unique resolved image paths: ${audit.resolvedImagePaths.length}`); + console.log(`Resolved images not found in ZIP: ${audit.resolvedImagesNotInZip.length}`); if (audit.unresolvedCells.length > 0) { console.log(`\nUnresolved cells (${audit.unresolvedCells.length}):`); audit.unresolvedCells.forEach((cell) => { - console.log( - ` - "${cell.label}" at (${cell.x}, ${cell.y}): ${cell.imageName}`, - ); + console.log(` - "${cell.label}" at (${cell.x}, ${cell.y}): ${cell.imageName}`); }); } if (audit.resolvedImagesNotInZip.length > 0) { - console.log( - `\nResolved images not in ZIP (${audit.resolvedImagesNotInZip.length}):`, - ); + console.log(`\nResolved images not in ZIP (${audit.resolvedImagesNotInZip.length}):`); audit.resolvedImagesNotInZip.forEach((img) => { console.log(` - ${img}`); }); } - console.log("\n=== Sample resolved images ==="); + console.log('\n=== Sample resolved images ==='); audit.resolvedImagePaths.slice(0, 10).forEach((img) => { console.log(` - ${img}`); }); @@ -162,7 +138,7 @@ describe("Gridset Image Audit", () => { console.log(` ... and ${audit.resolvedImagePaths.length - 10} more`); } - console.log("\n=== Sample image files in ZIP ==="); + console.log('\n=== Sample image files in ZIP ==='); audit.imageFilesInZip.slice(0, 10).forEach((img) => { console.log(` - ${img}`); }); @@ -174,15 +150,13 @@ describe("Gridset Image Audit", () => { expect(audit.resolvedImagesNotInZip.length).toBe(0); // Log the summary - console.log("\n=== Summary ==="); - console.log( - `✓ All ${audit.cellsWithResolvedImages} resolved images exist in the ZIP`, - ); + console.log('\n=== Summary ==='); + console.log(`✓ All ${audit.cellsWithResolvedImages} resolved images exist in the ZIP`); console.log( - `✓ ${audit.cellsWithDeclaredImages - audit.cellsWithResolvedImages} cells could not be resolved`, + `✓ ${audit.cellsWithDeclaredImages - audit.cellsWithResolvedImages} cells could not be resolved` ); console.log( - `✓ ${audit.actualImageFilesInZip - audit.resolvedImagePaths.length} images in ZIP are not referenced by cells`, + `✓ ${audit.actualImageFilesInZip - audit.resolvedImagePaths.length} images in ZIP are not referenced by cells` ); }, 30000); }); diff --git a/test/browserBundle.output.test.ts b/test/browserBundle.output.test.ts index c590dc5..aac27d3 100644 --- a/test/browserBundle.output.test.ts +++ b/test/browserBundle.output.test.ts @@ -1,30 +1,30 @@ -import fs from "fs"; -import path from "path"; +import fs from 'fs'; +import path from 'path'; type PatternCheck = { pattern: RegExp; label: string }; -describe("Browser bundle output", () => { - it("should not include Node.js module references", () => { - const distDir = path.join(__dirname, "..", "dist", "browser"); +describe('Browser bundle output', () => { + it('should not include Node.js module references', () => { + const distDir = path.join(__dirname, '..', 'dist', 'browser'); expect(fs.existsSync(distDir)).toBe(true); const patterns: PatternCheck[] = [ - { pattern: /__vite-browser-external/, label: "__vite-browser-external" }, + { pattern: /__vite-browser-external/, label: '__vite-browser-external' }, { pattern: /require\(['"]fs['"]\)/, label: 'require("fs")' }, - { pattern: /from ['"]fs['"]/, label: "import fs" }, + { pattern: /from ['"]fs['"]/, label: 'import fs' }, { pattern: /require\(['"]path['"]\)/, label: 'require("path")' }, - { pattern: /from ['"]path['"]/, label: "import path" }, + { pattern: /from ['"]path['"]/, label: 'import path' }, ]; const targetFiles = [ - path.join(distDir, "processors/gridset/symbols.js"), - path.join(distDir, "processors/gridset/password.js"), - path.join(distDir, "validation/gridsetValidator.js"), + path.join(distDir, 'processors/gridset/symbols.js'), + path.join(distDir, 'processors/gridset/password.js'), + path.join(distDir, 'validation/gridsetValidator.js'), ].filter((filePath) => fs.existsSync(filePath)); const offenders: string[] = []; for (const file of targetFiles) { - const content = fs.readFileSync(file, "utf8"); + const content = fs.readFileSync(file, 'utf8'); for (const { pattern, label } of patterns) { if (pattern.test(content)) { offenders.push(`${file}: ${label}`); diff --git a/test/browserCompatibility.test.ts b/test/browserCompatibility.test.ts index 6ca04ca..af7e824 100644 --- a/test/browserCompatibility.test.ts +++ b/test/browserCompatibility.test.ts @@ -5,22 +5,22 @@ * as they would be used in a browser environment (no file paths, only buffers). */ -import { readFileSync } from "fs"; -import path from "path"; +import { readFileSync } from 'fs'; +import path from 'path'; import { DotProcessor, OpmlProcessor, ObfProcessor, GridsetProcessor, AstericsGridProcessor, -} from "../src/index"; -import { AACTree } from "../src/core/treeStructure"; +} from '../src/index'; +import { AACTree } from '../src/core/treeStructure'; -describe("Browser Compatibility", () => { - describe("DotProcessor with buffers", () => { - const examplePath = path.join(__dirname, "assets/dot/example.dot"); +describe('Browser Compatibility', () => { + describe('DotProcessor with buffers', () => { + const examplePath = path.join(__dirname, 'assets/dot/example.dot'); - it("should load from Buffer", async () => { + it('should load from Buffer', async () => { const processor = new DotProcessor(); const buffer = readFileSync(examplePath); const tree: AACTree = await processor.loadIntoTree(buffer); @@ -29,7 +29,7 @@ describe("Browser Compatibility", () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it("should load from Uint8Array", async () => { + it('should load from Uint8Array', async () => { const processor = new DotProcessor(); const buffer = readFileSync(examplePath); const uint8Array = new Uint8Array(buffer); @@ -39,7 +39,7 @@ describe("Browser Compatibility", () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it("should extract texts from Buffer", async () => { + it('should extract texts from Buffer', async () => { const processor = new DotProcessor(); const buffer = readFileSync(examplePath); const texts = await processor.extractTexts(buffer); @@ -49,10 +49,10 @@ describe("Browser Compatibility", () => { }); }); - describe("OpmlProcessor with buffers", () => { - const examplePath = path.join(__dirname, "assets/opml/example.opml"); + describe('OpmlProcessor with buffers', () => { + const examplePath = path.join(__dirname, 'assets/opml/example.opml'); - it("should load from Buffer", async () => { + it('should load from Buffer', async () => { const processor = new OpmlProcessor(); const buffer = readFileSync(examplePath); const tree: AACTree = await processor.loadIntoTree(buffer); @@ -61,7 +61,7 @@ describe("Browser Compatibility", () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it("should load from Uint8Array", async () => { + it('should load from Uint8Array', async () => { const processor = new OpmlProcessor(); const buffer = readFileSync(examplePath); const uint8Array = new Uint8Array(buffer); @@ -72,10 +72,10 @@ describe("Browser Compatibility", () => { }); }); - describe("ObfProcessor with buffers", () => { - const examplePath = path.join(__dirname, "assets/obf/simple.obf"); + describe('ObfProcessor with buffers', () => { + const examplePath = path.join(__dirname, 'assets/obf/simple.obf'); - it("should load OBF from Buffer", async () => { + it('should load OBF from Buffer', async () => { const processor = new ObfProcessor(); const buffer = readFileSync(examplePath); const tree: AACTree = await processor.loadIntoTree(buffer); @@ -84,12 +84,12 @@ describe("Browser Compatibility", () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it("should load OBF from ArrayBuffer", async () => { + it('should load OBF from ArrayBuffer', async () => { const processor = new ObfProcessor(); const buffer = readFileSync(examplePath); const arrayBuffer = buffer.buffer.slice( buffer.byteOffset, - buffer.byteOffset + buffer.byteLength, + buffer.byteOffset + buffer.byteLength ); const tree: AACTree = await processor.loadIntoTree(arrayBuffer); @@ -98,10 +98,10 @@ describe("Browser Compatibility", () => { }); }); - describe("GridsetProcessor with buffers", () => { - const examplePath = path.join(__dirname, "assets/gridset/example.gridset"); + describe('GridsetProcessor with buffers', () => { + const examplePath = path.join(__dirname, 'assets/gridset/example.gridset'); - it("should load Gridset from Buffer", async () => { + it('should load Gridset from Buffer', async () => { const processor = new GridsetProcessor(); const buffer = readFileSync(examplePath); const tree: AACTree = await processor.loadIntoTree(buffer); @@ -110,7 +110,7 @@ describe("Browser Compatibility", () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it("should load Gridset from Uint8Array", async () => { + it('should load Gridset from Uint8Array', async () => { const processor = new GridsetProcessor(); const buffer = readFileSync(examplePath); const uint8Array = new Uint8Array(buffer); @@ -121,18 +121,18 @@ describe("Browser Compatibility", () => { }); }); - describe("ApplePanelsProcessor with buffers", () => { - it("should load from Buffer - skipped (no test asset)", async () => { + describe('ApplePanelsProcessor with buffers', () => { + it('should load from Buffer - skipped (no test asset)', async () => { // ApplePanels tests create data programmatically // See test/applePanelsProcessor.roundtrip.test.ts for ApplePanels tests expect(true).toBe(true); }); }); - describe("AstericsGridProcessor with buffers", () => { - const examplePath = path.join(__dirname, "assets/asterics/example.grd"); + describe('AstericsGridProcessor with buffers', () => { + const examplePath = path.join(__dirname, 'assets/asterics/example.grd'); - it("should load from Buffer", async () => { + it('should load from Buffer', async () => { const processor = new AstericsGridProcessor(); const buffer = readFileSync(examplePath); const tree: AACTree = await processor.loadIntoTree(buffer); @@ -141,34 +141,34 @@ describe("Browser Compatibility", () => { }); }); - describe("Browser factory function", () => { - it("getProcessor should work with extensions", async () => { - const { getProcessor } = await import("../src/index.browser"); + describe('Browser factory function', () => { + it('getProcessor should work with extensions', async () => { + const { getProcessor } = await import('../src/index.browser'); - const dotProcessor = getProcessor(".dot"); + const dotProcessor = getProcessor('.dot'); expect(dotProcessor).toBeInstanceOf(DotProcessor); - const opmlProcessor = getProcessor(".opml"); + const opmlProcessor = getProcessor('.opml'); expect(opmlProcessor).toBeInstanceOf(OpmlProcessor); - const obfProcessor = getProcessor(".obf"); + const obfProcessor = getProcessor('.obf'); expect(obfProcessor).toBeInstanceOf(ObfProcessor); - const gridsetProcessor = getProcessor(".gridset"); + const gridsetProcessor = getProcessor('.gridset'); expect(gridsetProcessor).toBeInstanceOf(GridsetProcessor); }); - it("getSupportedExtensions should return browser-supported extensions", async () => { - const { getSupportedExtensions } = await import("../src/index.browser"); + it('getSupportedExtensions should return browser-supported extensions', async () => { + const { getSupportedExtensions } = await import('../src/index.browser'); const extensions = getSupportedExtensions(); - expect(extensions).toContain(".dot"); - expect(extensions).toContain(".opml"); - expect(extensions).toContain(".obf"); - expect(extensions).toContain(".obz"); - expect(extensions).toContain(".gridset"); - expect(extensions).toContain(".plist"); - expect(extensions).toContain(".grd"); + expect(extensions).toContain('.dot'); + expect(extensions).toContain('.opml'); + expect(extensions).toContain('.obf'); + expect(extensions).toContain('.obz'); + expect(extensions).toContain('.gridset'); + expect(extensions).toContain('.plist'); + expect(extensions).toContain('.grd'); }); }); }); diff --git a/test/cli.comprehensive.test.ts b/test/cli.comprehensive.test.ts index 8f1c4a0..7560574 100644 --- a/test/cli.comprehensive.test.ts +++ b/test/cli.comprehensive.test.ts @@ -1,14 +1,14 @@ // Comprehensive CLI tests to achieve 90%+ coverage -import { execSync } from "child_process"; -import fs from "fs"; -import path from "path"; -import { TreeFactory } from "./utils/testFactories"; -import { DotProcessor } from "../src/processors/dotProcessor"; +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { TreeFactory } from './utils/testFactories'; +import { DotProcessor } from '../src/processors/dotProcessor'; -describe("CLI Comprehensive Tests", () => { - const tempDir = path.join(__dirname, "temp_cli"); - const cliPath = path.join(__dirname, "../dist/cli/index.js"); - const examplesDir = path.join(__dirname, "../examples"); +describe('CLI Comprehensive Tests', () => { + const tempDir = path.join(__dirname, 'temp_cli'); + const cliPath = path.join(__dirname, '../dist/cli/index.js'); + const examplesDir = path.join(__dirname, '../examples'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -17,7 +17,7 @@ describe("CLI Comprehensive Tests", () => { if (!fs.existsSync(cliPath)) { throw new Error( - "dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.", + 'dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.' ); } }); @@ -28,79 +28,76 @@ describe("CLI Comprehensive Tests", () => { } }); - describe("Command Parsing Tests", () => { - it("should parse extract command correctly", async () => { + describe('Command Parsing Tests', () => { + it('should parse extract command correctly', async () => { // Create a test DOT file const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const testFile = path.join(tempDir, "test.dot"); + const testFile = path.join(tempDir, 'test.dot'); await processor.saveFromTree(tree, testFile); const result = execSync(`node ${cliPath} extract ${testFile}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); // DOT processor only extracts navigation relationships and page names - expect(result).toContain("Home"); - expect(result).toContain("More"); // Navigation button label - expect(result.trim().split("\n").length).toBeGreaterThan(0); + expect(result).toContain('Home'); + expect(result).toContain('More'); // Navigation button label + expect(result.trim().split('\n').length).toBeGreaterThan(0); }); - it("should parse convert command with all options", async () => { + it('should parse convert command with all options', async () => { const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const inputFile = path.join(tempDir, "input.dot"); - const outputFile = path.join(tempDir, "output.opml"); + const inputFile = path.join(tempDir, 'input.dot'); + const outputFile = path.join(tempDir, 'output.opml'); await processor.saveFromTree(tree, inputFile); - const result = execSync( - `node ${cliPath} convert ${inputFile} ${outputFile} --format opml`, - { - encoding: "utf8", - cwd: tempDir, - }, - ); + const result = execSync(`node ${cliPath} convert ${inputFile} ${outputFile} --format opml`, { + encoding: 'utf8', + cwd: tempDir, + }); expect(fs.existsSync(outputFile)).toBe(true); - expect(result).toContain("converted"); + expect(result).toContain('converted'); }); - it("should handle invalid command arguments gracefully", async () => { + it('should handle invalid command arguments gracefully', async () => { expect(() => { execSync(`node ${cliPath} invalidcommand`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, - stdio: "pipe", + stdio: 'pipe', }); }).toThrow(); }); - it("should show help when no arguments provided", async () => { + it('should show help when no arguments provided', async () => { const result = execSync(`node ${cliPath}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); - expect(result).toContain("Usage:"); - expect(result).toContain("extract"); - expect(result).toContain("convert"); + expect(result).toContain('Usage:'); + expect(result).toContain('extract'); + expect(result).toContain('convert'); }); - it("should show help with --help flag", async () => { + it('should show help with --help flag', async () => { const result = execSync(`node ${cliPath} --help`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); - expect(result).toContain("Usage:"); - expect(result).toContain("Commands:"); + expect(result).toContain('Usage:'); + expect(result).toContain('Commands:'); }); - it("should show version with --version flag", async () => { + it('should show version with --version flag', async () => { const result = execSync(`node ${cliPath} --version`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); @@ -108,285 +105,273 @@ describe("CLI Comprehensive Tests", () => { }); }); - describe("File Processing Tests", () => { - it("should extract text from DOT format via CLI", async () => { + describe('File Processing Tests', () => { + it('should extract text from DOT format via CLI', async () => { const tree = TreeFactory.createCommunicationBoard(); const processor = new DotProcessor(); - const testFile = path.join(tempDir, "communication.dot"); + const testFile = path.join(tempDir, 'communication.dot'); await processor.saveFromTree(tree, testFile); const result = execSync(`node ${cliPath} extract ${testFile}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); // DOT processor extracts page names and navigation button labels - expect(result).toContain("Home"); - expect(result).toContain("Food"); // Page name, not button label - expect(result).toContain("Activities"); // Page name + expect(result).toContain('Home'); + expect(result).toContain('Food'); // Page name, not button label + expect(result).toContain('Activities'); // Page name }); - it("should extract text from OPML format via CLI", async () => { + it('should extract text from OPML format via CLI', async () => { // Create an OPML file first const tree = TreeFactory.createSimple(); const dotProcessor = new DotProcessor(); - const dotFile = path.join(tempDir, "temp.dot"); + const dotFile = path.join(tempDir, 'temp.dot'); await dotProcessor.saveFromTree(tree, dotFile); // Convert to OPML - const opmlFile = path.join(tempDir, "test.opml"); + const opmlFile = path.join(tempDir, 'test.opml'); execSync(`node ${cliPath} convert ${dotFile} ${opmlFile} --format opml`, { cwd: tempDir, - stdio: "pipe", + stdio: 'pipe', }); // Extract from OPML const result = execSync(`node ${cliPath} extract ${opmlFile}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); - expect(result).toContain("Home"); + expect(result).toContain('Home'); }); - it("should convert DOT to OPML format", async () => { + it('should convert DOT to OPML format', async () => { const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const inputFile = path.join(tempDir, "dot_to_opml.dot"); - const outputFile = path.join(tempDir, "dot_to_opml.opml"); + const inputFile = path.join(tempDir, 'dot_to_opml.dot'); + const outputFile = path.join(tempDir, 'dot_to_opml.opml'); await processor.saveFromTree(tree, inputFile); - execSync( - `node ${cliPath} convert ${inputFile} ${outputFile} --format opml`, - { - cwd: tempDir, - stdio: "pipe", - }, - ); + execSync(`node ${cliPath} convert ${inputFile} ${outputFile} --format opml`, { + cwd: tempDir, + stdio: 'pipe', + }); expect(fs.existsSync(outputFile)).toBe(true); - const content = fs.readFileSync(outputFile, "utf8"); - expect(content).toContain(" { + it('should convert OPML to DOT format', async () => { // First create an OPML file const tree = TreeFactory.createSimple(); const dotProcessor = new DotProcessor(); - const tempDotFile = path.join(tempDir, "temp_for_opml.dot"); - const opmlFile = path.join(tempDir, "opml_to_dot.opml"); - const finalDotFile = path.join(tempDir, "opml_to_dot.dot"); + const tempDotFile = path.join(tempDir, 'temp_for_opml.dot'); + const opmlFile = path.join(tempDir, 'opml_to_dot.opml'); + const finalDotFile = path.join(tempDir, 'opml_to_dot.dot'); await dotProcessor.saveFromTree(tree, tempDotFile); // Convert to OPML first - execSync( - `node ${cliPath} convert ${tempDotFile} ${opmlFile} --format opml`, - { - cwd: tempDir, - stdio: "pipe", - }, - ); + execSync(`node ${cliPath} convert ${tempDotFile} ${opmlFile} --format opml`, { + cwd: tempDir, + stdio: 'pipe', + }); // Convert back to DOT - execSync( - `node ${cliPath} convert ${opmlFile} ${finalDotFile} --format dot`, - { - cwd: tempDir, - stdio: "pipe", - }, - ); + execSync(`node ${cliPath} convert ${opmlFile} ${finalDotFile} --format dot`, { + cwd: tempDir, + stdio: 'pipe', + }); expect(fs.existsSync(finalDotFile)).toBe(true); - const content = fs.readFileSync(finalDotFile, "utf8"); - expect(content).toContain("digraph"); + const content = fs.readFileSync(finalDotFile, 'utf8'); + expect(content).toContain('digraph'); }); - it("should handle file not found errors", async () => { - const nonExistentFile = path.join(tempDir, "does_not_exist.dot"); + it('should handle file not found errors', async () => { + const nonExistentFile = path.join(tempDir, 'does_not_exist.dot'); expect(() => { execSync(`node ${cliPath} extract ${nonExistentFile}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, - stdio: "pipe", + stdio: 'pipe', }); }).toThrow(); }); - it("should handle unsupported file formats", async () => { - const unsupportedFile = path.join(tempDir, "unsupported.xyz"); - fs.writeFileSync(unsupportedFile, "unsupported content"); + it('should handle unsupported file formats', async () => { + const unsupportedFile = path.join(tempDir, 'unsupported.xyz'); + fs.writeFileSync(unsupportedFile, 'unsupported content'); expect(() => { execSync(`node ${cliPath} extract ${unsupportedFile}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, - stdio: "pipe", + stdio: 'pipe', }); }).toThrow(); }); }); - describe("Output Formatting Tests", () => { - it("should format output correctly for different formats", async () => { + describe('Output Formatting Tests', () => { + it('should format output correctly for different formats', async () => { const tree = TreeFactory.createCommunicationBoard(); const processor = new DotProcessor(); - const testFile = path.join(tempDir, "format_test.dot"); + const testFile = path.join(tempDir, 'format_test.dot'); await processor.saveFromTree(tree, testFile); // Test default format const defaultResult = execSync(`node ${cliPath} extract ${testFile}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); - expect(defaultResult).toContain("Home"); - expect(typeof defaultResult).toBe("string"); + expect(defaultResult).toContain('Home'); + expect(typeof defaultResult).toBe('string'); }); - it("should handle verbose output mode", async () => { + it('should handle verbose output mode', async () => { const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const testFile = path.join(tempDir, "verbose_test.dot"); + const testFile = path.join(tempDir, 'verbose_test.dot'); await processor.saveFromTree(tree, testFile); const result = execSync(`node ${cliPath} extract ${testFile} --verbose`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); - expect(result).toContain("Home"); + expect(result).toContain('Home'); // Verbose mode might include additional information }); - it("should handle quiet output mode", async () => { + it('should handle quiet output mode', async () => { const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const testFile = path.join(tempDir, "quiet_test.dot"); + const testFile = path.join(tempDir, 'quiet_test.dot'); await processor.saveFromTree(tree, testFile); const result = execSync(`node ${cliPath} extract ${testFile} --quiet`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); // Quiet mode should still return the extracted text - expect(result).toContain("Home"); + expect(result).toContain('Home'); }); - it("should display help information correctly", async () => { + it('should display help information correctly', async () => { const helpResult = execSync(`node ${cliPath} help`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); - expect(helpResult).toContain("Usage:"); - expect(helpResult).toContain("extract"); - expect(helpResult).toContain("convert"); - expect(helpResult).toContain("Options:"); + expect(helpResult).toContain('Usage:'); + expect(helpResult).toContain('extract'); + expect(helpResult).toContain('convert'); + expect(helpResult).toContain('Options:'); }); - it("should display command-specific help", async () => { + it('should display command-specific help', async () => { const extractHelp = execSync(`node ${cliPath} help extract`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); - expect(extractHelp).toContain("extract"); - expect(extractHelp).toContain("file"); + expect(extractHelp).toContain('extract'); + expect(extractHelp).toContain('file'); }); }); - describe("Integration Tests", () => { - it("should process example.dot file correctly", async () => { - const exampleDotFile = path.join(examplesDir, "example.dot"); + describe('Integration Tests', () => { + it('should process example.dot file correctly', async () => { + const exampleDotFile = path.join(examplesDir, 'example.dot'); if (fs.existsSync(exampleDotFile)) { const result = execSync(`node ${cliPath} extract ${exampleDotFile}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); expect(result).toBeDefined(); expect(result.length).toBeGreaterThan(0); } else { - console.log("Skipping test - example.dot not found"); + console.log('Skipping test - example.dot not found'); } }); - it("should convert example.obf to dot format", async () => { - const exampleObfFile = path.join(examplesDir, "example.obf"); + it('should convert example.obf to dot format', async () => { + const exampleObfFile = path.join(examplesDir, 'example.obf'); if (fs.existsSync(exampleObfFile)) { - const outputFile = path.join(tempDir, "converted_example.dot"); + const outputFile = path.join(tempDir, 'converted_example.dot'); - execSync( - `node ${cliPath} convert ${exampleObfFile} ${outputFile} --format dot`, - { - cwd: tempDir, - stdio: "pipe", - }, - ); + execSync(`node ${cliPath} convert ${exampleObfFile} ${outputFile} --format dot`, { + cwd: tempDir, + stdio: 'pipe', + }); expect(fs.existsSync(outputFile)).toBe(true); } else { - console.log("Skipping test - example.obf not found"); + console.log('Skipping test - example.obf not found'); } }); - it("should handle batch processing of multiple files", async () => { + it('should handle batch processing of multiple files', async () => { // Create multiple test files const tree1 = TreeFactory.createSimple(); const tree2 = TreeFactory.createCommunicationBoard(); const processor = new DotProcessor(); - const file1 = path.join(tempDir, "batch1.dot"); - const file2 = path.join(tempDir, "batch2.dot"); + const file1 = path.join(tempDir, 'batch1.dot'); + const file2 = path.join(tempDir, 'batch2.dot'); await processor.saveFromTree(tree1, file1); await processor.saveFromTree(tree2, file2); // Process each file const result1 = execSync(`node ${cliPath} extract ${file1}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); const result2 = execSync(`node ${cliPath} extract ${file2}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, }); - expect(result1).toContain("Home"); - expect(result2).toContain("Home"); - expect(result2).toContain("Food"); + expect(result1).toContain('Home'); + expect(result2).toContain('Home'); + expect(result2).toContain('Food'); }); }); - describe("Error Handling Tests", () => { - it("should display helpful error messages for invalid files", async () => { - const invalidFile = path.join(tempDir, "invalid.dot"); - fs.writeFileSync(invalidFile, "invalid dot content"); + describe('Error Handling Tests', () => { + it('should display helpful error messages for invalid files', async () => { + const invalidFile = path.join(tempDir, 'invalid.dot'); + fs.writeFileSync(invalidFile, 'invalid dot content'); try { execSync(`node ${cliPath} extract ${invalidFile}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, - stdio: "pipe", + stdio: 'pipe', }); } catch (error: any) { - expect(error.message).toContain("Command failed"); + expect(error.message).toContain('Command failed'); } }); - it("should handle permission errors gracefully", async () => { + it('should handle permission errors gracefully', async () => { // Create a file and remove read permissions (on Unix systems) - const restrictedFile = path.join(tempDir, "restricted.dot"); + const restrictedFile = path.join(tempDir, 'restricted.dot'); const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); await processor.saveFromTree(tree, restrictedFile); @@ -397,18 +382,16 @@ describe("CLI Comprehensive Tests", () => { try { execSync(`node ${cliPath} extract ${restrictedFile}`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, - stdio: "pipe", + stdio: 'pipe', }); } catch (error: any) { - expect(error.message).toContain("Command failed"); + expect(error.message).toContain('Command failed'); } } catch (_permissionError) { // If we can't change permissions, skip this test - console.log( - "Skipping permission test - unable to change file permissions", - ); + console.log('Skipping permission test - unable to change file permissions'); } finally { // Restore permissions for cleanup try { @@ -419,50 +402,47 @@ describe("CLI Comprehensive Tests", () => { } }); - it("should provide usage help for incorrect commands", async () => { + it('should provide usage help for incorrect commands', async () => { try { execSync(`node ${cliPath} wrongcommand`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, - stdio: "pipe", + stdio: 'pipe', }); } catch (error: any) { - expect(error.message).toContain("Command failed"); + expect(error.message).toContain('Command failed'); } }); - it("should handle missing required arguments", async () => { + it('should handle missing required arguments', async () => { try { execSync(`node ${cliPath} extract`, { - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, - stdio: "pipe", + stdio: 'pipe', }); } catch (error: any) { - expect(error.message).toContain("Command failed"); + expect(error.message).toContain('Command failed'); } }); - it("should handle invalid output paths for convert command", async () => { + it('should handle invalid output paths for convert command', async () => { const tree = TreeFactory.createSimple(); const processor = new DotProcessor(); - const inputFile = path.join(tempDir, "valid_input.dot"); + const inputFile = path.join(tempDir, 'valid_input.dot'); await processor.saveFromTree(tree, inputFile); // Try to write to an invalid path - const invalidOutputPath = "/invalid/path/output.opml"; + const invalidOutputPath = '/invalid/path/output.opml'; try { - execSync( - `node ${cliPath} convert ${inputFile} ${invalidOutputPath} --format opml`, - { - encoding: "utf8", - cwd: tempDir, - stdio: "pipe", - }, - ); + execSync(`node ${cliPath} convert ${inputFile} ${invalidOutputPath} --format opml`, { + encoding: 'utf8', + cwd: tempDir, + stdio: 'pipe', + }); } catch (error: any) { - expect(error.message).toContain("Command failed"); + expect(error.message).toContain('Command failed'); } }); }); diff --git a/test/cli/cli.dot.integration.test.js b/test/cli/cli.dot.integration.test.js index 977e783..cec1846 100644 --- a/test/cli/cli.dot.integration.test.js +++ b/test/cli/cli.dot.integration.test.js @@ -1,27 +1,25 @@ // CLI integration test for DOT -const path = require("path"); -const { execSync } = require("child_process"); -const fs = require("fs"); +const path = require('path'); +const { execSync } = require('child_process'); +const fs = require('fs'); -describe("aac-processors CLI (DOT)", () => { +describe('aac-processors CLI (DOT)', () => { // Ensure build exists before running CLI tests beforeAll(() => { - const cliPath = path.join(__dirname, "../../dist/cli/index.js"); + const cliPath = path.join(__dirname, '../../dist/cli/index.js'); if (!fs.existsSync(cliPath)) { throw new Error( - "dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.", + 'dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.' ); } }); - const cliPath = path.join(__dirname, "../../dist/cli/index.js"); - const dotExample = path.join(__dirname, "../assets/dot/example.dot"); + const cliPath = path.join(__dirname, '../../dist/cli/index.js'); + const dotExample = path.join(__dirname, '../assets/dot/example.dot'); - it("extracts texts from a dot file", () => { - const result = execSync( - `node ${cliPath} extract ${dotExample} --format dot`, - ).toString(); + it('extracts texts from a dot file', () => { + const result = execSync(`node ${cliPath} extract ${dotExample} --format dot`).toString(); // Should contain actual text content from the dot file expect(result.length).toBeGreaterThan(10); // Should have some text output - expect(result.trim()).not.toBe(""); // Should not be empty + expect(result.trim()).not.toBe(''); // Should not be empty }); }); diff --git a/test/cli/cli.integration.test.js b/test/cli/cli.integration.test.js index fde274a..573bd8b 100644 --- a/test/cli/cli.integration.test.js +++ b/test/cli/cli.integration.test.js @@ -1,57 +1,54 @@ -const { execSync } = require("child_process"); -const path = require("path"); -const fs = require("fs"); +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); -describe("aac-processors CLI", () => { +describe('aac-processors CLI', () => { // Ensure build exists before running CLI tests beforeAll(() => { - const cliPath = path.join(__dirname, "../../dist/cli/index.js"); + const cliPath = path.join(__dirname, '../../dist/cli/index.js'); if (!fs.existsSync(cliPath)) { throw new Error( - "dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.", + 'dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.' ); } }); - const cliPath = path.join(__dirname, "../../dist/cli/index.js"); - const gridsetExample = path.join( - __dirname, - "../assets/gridset/example.gridset", - ); - const touchchatExample = path.join(__dirname, "../assets/excel/example.ce"); + const cliPath = path.join(__dirname, '../../dist/cli/index.js'); + const gridsetExample = path.join(__dirname, '../assets/gridset/example.gridset'); + const touchchatExample = path.join(__dirname, '../assets/excel/example.ce'); - it("extracts texts from a gridset file", () => { + it('extracts texts from a gridset file', () => { const result = execSync( - `node ${cliPath} extract ${gridsetExample} --format gridset`, + `node ${cliPath} extract ${gridsetExample} --format gridset` ).toString(); // Should contain actual text content from the gridset - expect(result).toContain("Food"); + expect(result).toContain('Food'); expect(result.length).toBeGreaterThan(50); // Should have substantial text output }); - it("extracts texts from a touchchat file", () => { + it('extracts texts from a touchchat file', () => { const result = execSync( - `node ${cliPath} extract ${touchchatExample} --format touchchat`, + `node ${cliPath} extract ${touchchatExample} --format touchchat` ).toString(); // Should contain actual text content from the touchchat file expect(result.length).toBeGreaterThan(10); // Should have some text output - expect(result.trim()).not.toBe(""); // Should not be empty + expect(result.trim()).not.toBe(''); // Should not be empty }); - it("pretty prints analyze for gridset", () => { + it('pretty prints analyze for gridset', () => { const result = execSync( - `node ${cliPath} analyze ${gridsetExample} --format gridset --pretty`, + `node ${cliPath} analyze ${gridsetExample} --format gridset --pretty` ).toString(); - expect(result).toContain("Page:"); + expect(result).toContain('Page:'); // The gridset should have buttons, but if parsing is still being fixed, // we'll accept either buttons or a reasonable page structure expect(result.length).toBeGreaterThan(100); // Should have substantial output }); - it("pretty prints analyze for touchchat", () => { + it('pretty prints analyze for touchchat', () => { const result = execSync( - `node ${cliPath} analyze ${touchchatExample} --format touchchat --pretty`, + `node ${cliPath} analyze ${touchchatExample} --format touchchat --pretty` ).toString(); - expect(result).toContain("Page:"); - expect(result).toContain("- Button:"); + expect(result).toContain('Page:'); + expect(result).toContain('- Button:'); }); }); diff --git a/test/cli/cli.obf.integration.test.js b/test/cli/cli.obf.integration.test.js index 1f4c43f..339caa4 100644 --- a/test/cli/cli.obf.integration.test.js +++ b/test/cli/cli.obf.integration.test.js @@ -1,36 +1,32 @@ // CLI integration test for OBF/OBZ -const path = require("path"); -const { execSync } = require("child_process"); -const fs = require("fs"); +const path = require('path'); +const { execSync } = require('child_process'); +const fs = require('fs'); -describe("aac-processors CLI (OBF/OBZ)", () => { +describe('aac-processors CLI (OBF/OBZ)', () => { // Ensure build exists before running CLI tests beforeAll(() => { - const cliPath = path.join(__dirname, "../../dist/cli/index.js"); + const cliPath = path.join(__dirname, '../../dist/cli/index.js'); if (!fs.existsSync(cliPath)) { throw new Error( - "dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.", + 'dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.' ); } }); - const cliPath = path.join(__dirname, "../../dist/cli/index.js"); - const obfExample = path.join(__dirname, "../assets/obf/example.obf"); - const obzExample = path.join(__dirname, "../assets/obz/example.obz"); + const cliPath = path.join(__dirname, '../../dist/cli/index.js'); + const obfExample = path.join(__dirname, '../assets/obf/example.obf'); + const obzExample = path.join(__dirname, '../assets/obz/example.obz'); - it("extracts texts from an obf file", () => { - const result = execSync( - `node ${cliPath} extract ${obfExample} --format obf`, - ).toString(); + it('extracts texts from an obf file', () => { + const result = execSync(`node ${cliPath} extract ${obfExample} --format obf`).toString(); // Should contain actual text content from the obf file expect(result.length).toBeGreaterThan(10); // Should have some text output - expect(result.trim()).not.toBe(""); // Should not be empty + expect(result.trim()).not.toBe(''); // Should not be empty }); - it("extracts texts from an obz file", () => { - const result = execSync( - `node ${cliPath} extract ${obzExample} --format obf`, - ).toString(); + it('extracts texts from an obz file', () => { + const result = execSync(`node ${cliPath} extract ${obzExample} --format obf`).toString(); expect(result.length).toBeGreaterThan(10); // Should have some text output - expect(result.trim()).not.toBe(""); // Should not be empty + expect(result.trim()).not.toBe(''); // Should not be empty }); }); diff --git a/test/cli/cli.opml.integration.test.js b/test/cli/cli.opml.integration.test.js index 3c67494..5c0920e 100644 --- a/test/cli/cli.opml.integration.test.js +++ b/test/cli/cli.opml.integration.test.js @@ -1,27 +1,25 @@ // CLI integration test for OPML -const path = require("path"); -const { execSync } = require("child_process"); -const fs = require("fs"); +const path = require('path'); +const { execSync } = require('child_process'); +const fs = require('fs'); -describe("aac-processors CLI (OPML)", () => { +describe('aac-processors CLI (OPML)', () => { // Ensure build exists before running CLI tests beforeAll(() => { - const cliPath = path.join(__dirname, "../../dist/cli/index.js"); + const cliPath = path.join(__dirname, '../../dist/cli/index.js'); if (!fs.existsSync(cliPath)) { throw new Error( - "dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.", + 'dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.' ); } }); - const cliPath = path.join(__dirname, "../../dist/cli/index.js"); - const opmlExample = path.join(__dirname, "../assets/opml/example.opml"); + const cliPath = path.join(__dirname, '../../dist/cli/index.js'); + const opmlExample = path.join(__dirname, '../assets/opml/example.opml'); - it("extracts texts from an opml file", () => { - const result = execSync( - `node ${cliPath} extract ${opmlExample} --format opml`, - ).toString(); + it('extracts texts from an opml file', () => { + const result = execSync(`node ${cliPath} extract ${opmlExample} --format opml`).toString(); // Should contain actual text content from the opml file expect(result.length).toBeGreaterThan(10); // Should have some text output - expect(result.trim()).not.toBe(""); // Should not be empty + expect(result.trim()).not.toBe(''); // Should not be empty }); }); diff --git a/test/cli/cli.snap.integration.test.js b/test/cli/cli.snap.integration.test.js index 464f077..2a39a33 100644 --- a/test/cli/cli.snap.integration.test.js +++ b/test/cli/cli.snap.integration.test.js @@ -1,35 +1,32 @@ -const { execSync } = require("child_process"); -const path = require("path"); -const fs = require("fs"); +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); -describe("aac-processors CLI (Snap)", () => { +describe('aac-processors CLI (Snap)', () => { // Ensure build exists before running CLI tests beforeAll(() => { - const cliPath = path.join(__dirname, "../../dist/cli/index.js"); + const cliPath = path.join(__dirname, '../../dist/cli/index.js'); if (!fs.existsSync(cliPath)) { throw new Error( - "dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.", + 'dist/cli/index.js is missing – run `npm run build` before executing the CLI tests.' ); } }); - const cliPath = path.join(__dirname, "../../dist/cli/index.js"); - const snapExample = path.join(__dirname, "../assets/snap/example.sps"); + const cliPath = path.join(__dirname, '../../dist/cli/index.js'); + const snapExample = path.join(__dirname, '../assets/snap/example.sps'); - it("extracts texts from a snap file", () => { + it('extracts texts from a snap file', () => { try { - const result = execSync( - `node ${cliPath} extract ${snapExample} --format snap`, - { - maxBuffer: 1024 * 1024 * 10, // 10MB buffer - timeout: 30000, // 30 second timeout - }, - ).toString(); + const result = execSync(`node ${cliPath} extract ${snapExample} --format snap`, { + maxBuffer: 1024 * 1024 * 10, // 10MB buffer + timeout: 30000, // 30 second timeout + }).toString(); expect(result.length).toBeGreaterThan(10); // Should have some text output - expect(result.trim()).not.toBe(""); // Should not be empty + expect(result.trim()).not.toBe(''); // Should not be empty } catch (error) { // If the command fails due to buffer issues, skip the test - if (error.code === "ENOBUFS" || error.status !== 0) { - console.warn("Snap CLI test skipped due to output buffer issues"); + if (error.code === 'ENOBUFS' || error.status !== 0) { + console.warn('Snap CLI test skipped due to output buffer issues'); expect(true).toBe(true); // Pass the test } else { throw error; @@ -37,21 +34,18 @@ describe("aac-processors CLI (Snap)", () => { } }); - it("pretty prints analyze for snap", () => { + it('pretty prints analyze for snap', () => { try { - const result = execSync( - `node ${cliPath} analyze ${snapExample} --format snap --pretty`, - { - maxBuffer: 1024 * 1024 * 10, // 10MB buffer - timeout: 30000, // 30 second timeout - }, - ).toString(); - expect(result).toContain("Page:"); - expect(result).toContain("- Button:"); + const result = execSync(`node ${cliPath} analyze ${snapExample} --format snap --pretty`, { + maxBuffer: 1024 * 1024 * 10, // 10MB buffer + timeout: 30000, // 30 second timeout + }).toString(); + expect(result).toContain('Page:'); + expect(result).toContain('- Button:'); } catch (error) { // If the command fails due to buffer issues, skip the test - if (error.code === "ENOBUFS" || error.status !== 0) { - console.warn("Snap CLI test skipped due to output buffer issues"); + if (error.code === 'ENOBUFS' || error.status !== 0) { + console.warn('Snap CLI test skipped due to output buffer issues'); expect(true).toBe(true); // Pass the test } else { throw error; diff --git a/test/cli/prettyPrint.test.js b/test/cli/prettyPrint.test.js index 0c648f2..8771193 100644 --- a/test/cli/prettyPrint.test.js +++ b/test/cli/prettyPrint.test.js @@ -1,29 +1,29 @@ -const { prettyPrintTree } = require("../../dist/cli/prettyPrint"); +const { prettyPrintTree } = require('../../dist/cli/prettyPrint'); -describe("prettyPrintTree", () => { - it("prints a simple tree with one page and buttons", () => { +describe('prettyPrintTree', () => { + it('prints a simple tree with one page and buttons', () => { const tree = { pages: { 1: { - id: "1", - name: "Home", + id: '1', + name: 'Home', buttons: [ - { label: "Hello", type: "SPEAK" }, - { label: "Go", type: "NAVIGATE", targetPageId: "2" }, + { label: 'Hello', type: 'SPEAK' }, + { label: 'Go', type: 'NAVIGATE', targetPageId: '2' }, ], }, 2: { - id: "2", - name: "Second", + id: '2', + name: 'Second', buttons: [], }, }, }; const output = prettyPrintTree(tree); - expect(output).toContain("Page: Home"); + expect(output).toContain('Page: Home'); expect(output).toContain('- Button: "Hello"'); - expect(output).toContain("[NAVIGATE to page: 2]"); - expect(output).toContain("Page: Second"); - expect(output).toContain("(no buttons)"); + expect(output).toContain('[NAVIGATE to page: 2]'); + expect(output).toContain('Page: Second'); + expect(output).toContain('(no buttons)'); }); }); diff --git a/test/colorUtils.test.ts b/test/colorUtils.test.ts index f0bd027..58b931f 100644 --- a/test/colorUtils.test.ts +++ b/test/colorUtils.test.ts @@ -8,42 +8,42 @@ import { darkenColor, normalizeColor, ensureAlphaChannel, -} from "../src/processors/gridset/colorUtils"; +} from '../src/processors/gridset/colorUtils'; -describe("Color Utilities", () => { - describe("getNamedColor", () => { - it("returns RGB values for valid CSS color names", async () => { - expect(getNamedColor("red")).toEqual([255, 0, 0]); - expect(getNamedColor("blue")).toEqual([0, 0, 255]); - expect(getNamedColor("green")).toEqual([0, 128, 0]); - expect(getNamedColor("white")).toEqual([255, 255, 255]); - expect(getNamedColor("black")).toEqual([0, 0, 0]); +describe('Color Utilities', () => { + describe('getNamedColor', () => { + it('returns RGB values for valid CSS color names', async () => { + expect(getNamedColor('red')).toEqual([255, 0, 0]); + expect(getNamedColor('blue')).toEqual([0, 0, 255]); + expect(getNamedColor('green')).toEqual([0, 128, 0]); + expect(getNamedColor('white')).toEqual([255, 255, 255]); + expect(getNamedColor('black')).toEqual([0, 0, 0]); }); - it("is case-insensitive", async () => { - expect(getNamedColor("RED")).toEqual([255, 0, 0]); - expect(getNamedColor("Red")).toEqual([255, 0, 0]); - expect(getNamedColor("cornflowerblue")).toEqual([100, 149, 237]); - expect(getNamedColor("CORNFLOWERBLUE")).toEqual([100, 149, 237]); + it('is case-insensitive', async () => { + expect(getNamedColor('RED')).toEqual([255, 0, 0]); + expect(getNamedColor('Red')).toEqual([255, 0, 0]); + expect(getNamedColor('cornflowerblue')).toEqual([100, 149, 237]); + expect(getNamedColor('CORNFLOWERBLUE')).toEqual([100, 149, 237]); }); - it("returns undefined for invalid color names", async () => { - expect(getNamedColor("notacolor")).toBeUndefined(); - expect(getNamedColor("xyz")).toBeUndefined(); + it('returns undefined for invalid color names', async () => { + expect(getNamedColor('notacolor')).toBeUndefined(); + expect(getNamedColor('xyz')).toBeUndefined(); }); - it("supports all 147 CSS color names", async () => { + it('supports all 147 CSS color names', async () => { const colors = [ - "aliceblue", - "antiquewhite", - "aqua", - "aquamarine", - "azure", - "rebeccapurple", - "yellowgreen", - "whitesmoke", - "wheat", - "white", + 'aliceblue', + 'antiquewhite', + 'aqua', + 'aquamarine', + 'azure', + 'rebeccapurple', + 'yellowgreen', + 'whitesmoke', + 'wheat', + 'white', ]; colors.forEach((color) => { expect(getNamedColor(color)).toBeDefined(); @@ -51,27 +51,27 @@ describe("Color Utilities", () => { }); }); - describe("channelToHex", () => { - it("converts channel values to hex", async () => { - expect(channelToHex(0)).toBe("00"); - expect(channelToHex(255)).toBe("FF"); - expect(channelToHex(128)).toBe("80"); - expect(channelToHex(16)).toBe("10"); + describe('channelToHex', () => { + it('converts channel values to hex', async () => { + expect(channelToHex(0)).toBe('00'); + expect(channelToHex(255)).toBe('FF'); + expect(channelToHex(128)).toBe('80'); + expect(channelToHex(16)).toBe('10'); }); - it("clamps values to 0-255 range", async () => { - expect(channelToHex(-10)).toBe("00"); - expect(channelToHex(300)).toBe("FF"); + it('clamps values to 0-255 range', async () => { + expect(channelToHex(-10)).toBe('00'); + expect(channelToHex(300)).toBe('FF'); }); - it("rounds decimal values", async () => { - expect(channelToHex(127.5)).toBe("80"); - expect(channelToHex(127.4)).toBe("7F"); + it('rounds decimal values', async () => { + expect(channelToHex(127.5)).toBe('80'); + expect(channelToHex(127.4)).toBe('7F'); }); }); - describe("clampColorChannel", () => { - it("clamps values to 0-255 range", async () => { + describe('clampColorChannel', () => { + it('clamps values to 0-255 range', async () => { expect(clampColorChannel(0)).toBe(0); expect(clampColorChannel(255)).toBe(255); expect(clampColorChannel(128)).toBe(128); @@ -79,13 +79,13 @@ describe("Color Utilities", () => { expect(clampColorChannel(300)).toBe(255); }); - it("returns 0 for NaN", async () => { + it('returns 0 for NaN', async () => { expect(clampColorChannel(NaN)).toBe(0); }); }); - describe("clampAlpha", () => { - it("clamps values to 0-1 range", async () => { + describe('clampAlpha', () => { + it('clamps values to 0-1 range', async () => { expect(clampAlpha(0)).toBe(0); expect(clampAlpha(1)).toBe(1); expect(clampAlpha(0.5)).toBe(0.5); @@ -93,138 +93,138 @@ describe("Color Utilities", () => { expect(clampAlpha(1.5)).toBe(1); }); - it("returns 1 for NaN", async () => { + it('returns 1 for NaN', async () => { expect(clampAlpha(NaN)).toBe(1); }); }); - describe("rgbaToHex", () => { - it("converts RGBA to hex format", async () => { - expect(rgbaToHex(255, 0, 0, 1)).toBe("#FF0000FF"); - expect(rgbaToHex(0, 255, 0, 1)).toBe("#00FF00FF"); - expect(rgbaToHex(0, 0, 255, 1)).toBe("#0000FFFF"); + describe('rgbaToHex', () => { + it('converts RGBA to hex format', async () => { + expect(rgbaToHex(255, 0, 0, 1)).toBe('#FF0000FF'); + expect(rgbaToHex(0, 255, 0, 1)).toBe('#00FF00FF'); + expect(rgbaToHex(0, 0, 255, 1)).toBe('#0000FFFF'); }); - it("handles alpha channel correctly", async () => { - expect(rgbaToHex(255, 0, 0, 0.5)).toBe("#FF000080"); - expect(rgbaToHex(255, 0, 0, 0)).toBe("#FF000000"); - expect(rgbaToHex(255, 0, 0, 1)).toBe("#FF0000FF"); + it('handles alpha channel correctly', async () => { + expect(rgbaToHex(255, 0, 0, 0.5)).toBe('#FF000080'); + expect(rgbaToHex(255, 0, 0, 0)).toBe('#FF000000'); + expect(rgbaToHex(255, 0, 0, 1)).toBe('#FF0000FF'); }); - it("clamps values to valid ranges", async () => { - expect(rgbaToHex(300, -10, 128, 1.5)).toBe("#FF0080FF"); + it('clamps values to valid ranges', async () => { + expect(rgbaToHex(300, -10, 128, 1.5)).toBe('#FF0080FF'); }); }); - describe("toHexColor", () => { - it("converts hex colors", async () => { - expect(toHexColor("#FF0000")).toBe("#FF0000"); - expect(toHexColor("#F00")).toBe("#FF0000"); - expect(toHexColor("#FF0000FF")).toBe("#FF0000FF"); + describe('toHexColor', () => { + it('converts hex colors', async () => { + expect(toHexColor('#FF0000')).toBe('#FF0000'); + expect(toHexColor('#F00')).toBe('#FF0000'); + expect(toHexColor('#FF0000FF')).toBe('#FF0000FF'); }); - it("converts RGB colors", async () => { - expect(toHexColor("rgb(255, 0, 0)")).toBe("#FF0000FF"); - expect(toHexColor("rgb(0, 255, 0)")).toBe("#00FF00FF"); + it('converts RGB colors', async () => { + expect(toHexColor('rgb(255, 0, 0)')).toBe('#FF0000FF'); + expect(toHexColor('rgb(0, 255, 0)')).toBe('#00FF00FF'); }); - it("converts RGBA colors", async () => { - expect(toHexColor("rgba(255, 0, 0, 1)")).toBe("#FF0000FF"); - expect(toHexColor("rgba(255, 0, 0, 0.5)")).toBe("#FF000080"); + it('converts RGBA colors', async () => { + expect(toHexColor('rgba(255, 0, 0, 1)')).toBe('#FF0000FF'); + expect(toHexColor('rgba(255, 0, 0, 0.5)')).toBe('#FF000080'); }); - it("converts CSS color names", async () => { - expect(toHexColor("red")).toBe("#FF0000FF"); - expect(toHexColor("blue")).toBe("#0000FFFF"); - expect(toHexColor("cornflowerblue")).toBe("#6495EDFF"); + it('converts CSS color names', async () => { + expect(toHexColor('red')).toBe('#FF0000FF'); + expect(toHexColor('blue')).toBe('#0000FFFF'); + expect(toHexColor('cornflowerblue')).toBe('#6495EDFF'); }); - it("returns undefined for invalid colors", async () => { - expect(toHexColor("notacolor")).toBeUndefined(); - expect(toHexColor("rgb(999, 999, 999)")).toBeDefined(); // Clamped + it('returns undefined for invalid colors', async () => { + expect(toHexColor('notacolor')).toBeUndefined(); + expect(toHexColor('rgb(999, 999, 999)')).toBeDefined(); // Clamped }); - it("is case-insensitive for hex and named colors", async () => { - expect(toHexColor("#ff0000")).toBe("#ff0000"); - expect(toHexColor("RED")).toBe("#FF0000FF"); + it('is case-insensitive for hex and named colors', async () => { + expect(toHexColor('#ff0000')).toBe('#ff0000'); + expect(toHexColor('RED')).toBe('#FF0000FF'); }); }); - describe("darkenColor", () => { - it("darkens colors by specified amount", async () => { - const result = darkenColor("#FF0000FF", 50); - expect(result).toBe("#CD0000FF"); + describe('darkenColor', () => { + it('darkens colors by specified amount', async () => { + const result = darkenColor('#FF0000FF', 50); + expect(result).toBe('#CD0000FF'); }); - it("clamps darkened values to 0", async () => { - const result = darkenColor("#0F0F0FFF", 50); - expect(result).toBe("#000000FF"); + it('clamps darkened values to 0', async () => { + const result = darkenColor('#0F0F0FFF', 50); + expect(result).toBe('#000000FF'); }); - it("preserves alpha channel", async () => { - const result = darkenColor("#FF000080", 50); - expect(result).toBe("#CD000080"); + it('preserves alpha channel', async () => { + const result = darkenColor('#FF000080', 50); + expect(result).toBe('#CD000080'); }); - it("handles colors without alpha channel", async () => { - const result = darkenColor("#FF0000", 50); - expect(result).toBe("#CD0000FF"); + it('handles colors without alpha channel', async () => { + const result = darkenColor('#FF0000', 50); + expect(result).toBe('#CD0000FF'); }); }); - describe("normalizeColor", () => { - it("normalizes hex colors to 8-digit format", async () => { - expect(normalizeColor("#FF0000")).toBe("#FF0000FF"); - expect(normalizeColor("#F00")).toBe("#FF0000FF"); + describe('normalizeColor', () => { + it('normalizes hex colors to 8-digit format', async () => { + expect(normalizeColor('#FF0000')).toBe('#FF0000FF'); + expect(normalizeColor('#F00')).toBe('#FF0000FF'); }); - it("normalizes RGB colors", async () => { - expect(normalizeColor("rgb(255, 0, 0)")).toBe("#FF0000FF"); + it('normalizes RGB colors', async () => { + expect(normalizeColor('rgb(255, 0, 0)')).toBe('#FF0000FF'); }); - it("normalizes CSS color names", async () => { - expect(normalizeColor("red")).toBe("#FF0000FF"); + it('normalizes CSS color names', async () => { + expect(normalizeColor('red')).toBe('#FF0000FF'); }); - it("returns fallback for invalid colors", async () => { - expect(normalizeColor("notacolor")).toBe("#FFFFFFFF"); - expect(normalizeColor("notacolor", "#000000FF")).toBe("#000000FF"); + it('returns fallback for invalid colors', async () => { + expect(normalizeColor('notacolor')).toBe('#FFFFFFFF'); + expect(normalizeColor('notacolor', '#000000FF')).toBe('#000000FF'); }); - it("returns fallback for empty strings", async () => { - expect(normalizeColor("")).toBe("#FFFFFFFF"); - expect(normalizeColor(" ")).toBe("#FFFFFFFF"); + it('returns fallback for empty strings', async () => { + expect(normalizeColor('')).toBe('#FFFFFFFF'); + expect(normalizeColor(' ')).toBe('#FFFFFFFF'); }); - it("is case-insensitive", async () => { - expect(normalizeColor("RED")).toBe("#FF0000FF"); - expect(normalizeColor("#ff0000")).toBe("#FF0000FF"); + it('is case-insensitive', async () => { + expect(normalizeColor('RED')).toBe('#FF0000FF'); + expect(normalizeColor('#ff0000')).toBe('#FF0000FF'); }); }); - describe("ensureAlphaChannel", () => { - it("adds alpha channel to 6-digit hex", async () => { - expect(ensureAlphaChannel("#FF0000")).toBe("#FF0000FF"); + describe('ensureAlphaChannel', () => { + it('adds alpha channel to 6-digit hex', async () => { + expect(ensureAlphaChannel('#FF0000')).toBe('#FF0000FF'); }); - it("expands 3-digit hex to 8-digit", async () => { - expect(ensureAlphaChannel("#F00")).toBe("#FF0000FF"); + it('expands 3-digit hex to 8-digit', async () => { + expect(ensureAlphaChannel('#F00')).toBe('#FF0000FF'); }); - it("preserves 8-digit hex", async () => { - expect(ensureAlphaChannel("#FF0000FF")).toBe("#FF0000FF"); + it('preserves 8-digit hex', async () => { + expect(ensureAlphaChannel('#FF0000FF')).toBe('#FF0000FF'); }); - it("returns white for undefined", async () => { - expect(ensureAlphaChannel(undefined)).toBe("#FFFFFFFF"); + it('returns white for undefined', async () => { + expect(ensureAlphaChannel(undefined)).toBe('#FFFFFFFF'); }); - it("returns white for invalid format", async () => { - expect(ensureAlphaChannel("notahex")).toBe("#FFFFFFFF"); + it('returns white for invalid format', async () => { + expect(ensureAlphaChannel('notahex')).toBe('#FFFFFFFF'); }); - it("is case-insensitive", async () => { - expect(ensureAlphaChannel("#ff0000")).toBe("#ff0000FF"); + it('is case-insensitive', async () => { + expect(ensureAlphaChannel('#ff0000')).toBe('#ff0000FF'); }); }); }); diff --git a/test/concurrency.test.ts b/test/concurrency.test.ts index b89659a..e3623b4 100644 --- a/test/concurrency.test.ts +++ b/test/concurrency.test.ts @@ -1,10 +1,10 @@ // Concurrent access and thread safety tests -import fs from "fs"; -import path from "path"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; +import fs from 'fs'; +import path from 'path'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; const runDelayed = (delayMs: number, task: () => Promise): Promise => new Promise((resolve, reject) => { @@ -13,8 +13,8 @@ const runDelayed = (delayMs: number, task: () => Promise): Promise => }, delayMs); }); -describe("Concurrency and Thread Safety Tests", () => { - const tempDir = path.join(__dirname, "temp_concurrency"); +describe('Concurrency and Thread Safety Tests', () => { + const tempDir = path.join(__dirname, 'temp_concurrency'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -28,8 +28,8 @@ describe("Concurrency and Thread Safety Tests", () => { } }); - describe("Concurrent File Access", () => { - it("should handle multiple processors reading the same file simultaneously", async () => { + describe('Concurrent File Access', () => { + it('should handle multiple processors reading the same file simultaneously', async () => { const testContent = ` digraph G { home [label="Home"]; @@ -40,7 +40,7 @@ describe("Concurrency and Thread Safety Tests", () => { } `; - const testFile = path.join(tempDir, "concurrent_read.dot"); + const testFile = path.join(tempDir, 'concurrent_read.dot'); fs.writeFileSync(testFile, testContent); // Create multiple processors @@ -58,7 +58,7 @@ describe("Concurrency and Thread Safety Tests", () => { pageCount: Object.keys(tree.pages).length, textCount: texts.length, }; - }), + }) ); const results = await Promise.all(readPromises); @@ -78,7 +78,7 @@ describe("Concurrency and Thread Safety Tests", () => { }); }); - it("should handle concurrent write operations safely", async () => { + it('should handle concurrent write operations safely', async () => { const processor = new DotProcessor(); // Create test trees @@ -96,7 +96,7 @@ describe("Concurrency and Thread Safety Tests", () => { id: `btn_${index}`, label: `Button ${index}`, message: `Message ${index}`, - type: "SPEAK", + type: 'SPEAK', }); page.addButton(button); @@ -128,15 +128,15 @@ describe("Concurrency and Thread Safety Tests", () => { }); }); - describe("Database Concurrency", () => { - it("should handle concurrent SQLite database access", async () => { + describe('Database Concurrency', () => { + it('should handle concurrent SQLite database access', async () => { const processor = new SnapProcessor(); // Create a test database const tree = new AACTree(); const page = new AACPage({ - id: "test_page", - name: "Test Page", + id: 'test_page', + name: 'Test Page', buttons: [], }); @@ -145,14 +145,14 @@ describe("Concurrency and Thread Safety Tests", () => { id: `btn_${i}`, label: `Button ${i}`, message: `Message ${i}`, - type: "SPEAK", + type: 'SPEAK', }); page.addButton(button); } tree.addPage(page); - const dbPath = path.join(tempDir, "concurrent_test.spb"); + const dbPath = path.join(tempDir, 'concurrent_test.spb'); await processor.saveFromTree(tree, dbPath); // Read from the same database concurrently @@ -169,7 +169,7 @@ describe("Concurrency and Thread Safety Tests", () => { pageCount: Object.keys(loadedTree.pages).length, textCount: texts.length, }; - }), + }) ); const results = await Promise.all(readPromises); @@ -182,7 +182,7 @@ describe("Concurrency and Thread Safety Tests", () => { }); }); - it("should handle database creation race conditions", async () => { + it('should handle database creation race conditions', async () => { const createPromises = Array(3) .fill(0) .map((_, index) => @@ -199,7 +199,7 @@ describe("Concurrency and Thread Safety Tests", () => { id: `race_btn_${index}`, label: `Race Button ${index}`, message: `Race Message ${index}`, - type: "SPEAK", + type: 'SPEAK', }); page.addButton(button); @@ -213,7 +213,7 @@ describe("Concurrency and Thread Safety Tests", () => { dbPath, exists: fs.existsSync(dbPath), }; - }), + }) ); const results = await Promise.all(createPromises); @@ -226,8 +226,8 @@ describe("Concurrency and Thread Safety Tests", () => { }); }); - describe("Resource Contention", () => { - it("should handle high-frequency operations without resource exhaustion", async () => { + describe('Resource Contention', () => { + it('should handle high-frequency operations without resource exhaustion', async () => { const processor = new DotProcessor(); const testContent = 'digraph G { test [label="High Frequency Test"]; }'; @@ -236,9 +236,7 @@ describe("Concurrency and Thread Safety Tests", () => { .map((_, index) => runDelayed(index * 10, async () => { const tree = await processor.loadIntoTree(Buffer.from(testContent)); - const texts = await processor.extractTexts( - Buffer.from(testContent), - ); + const texts = await processor.extractTexts(Buffer.from(testContent)); const outputPath = path.join(tempDir, `high_freq_${index}.dot`); await processor.saveFromTree(tree, outputPath); @@ -249,7 +247,7 @@ describe("Concurrency and Thread Safety Tests", () => { pageCount: Object.keys(tree.pages).length, textCount: texts.length, }; - }), + }) ); const results = await Promise.all(operations); @@ -261,10 +259,10 @@ describe("Concurrency and Thread Safety Tests", () => { }); }); - it("should handle mixed read/write operations", async () => { + it('should handle mixed read/write operations', async () => { const processor = new DotProcessor(); const baseContent = 'digraph G { base [label="Base Content"]; }'; - const baseFile = path.join(tempDir, "mixed_base.dot"); + const baseFile = path.join(tempDir, 'mixed_base.dot'); fs.writeFileSync(baseFile, baseContent); @@ -278,7 +276,7 @@ describe("Concurrency and Thread Safety Tests", () => { return { index, - operation: "read", + operation: 'read', pageCount: Object.keys(tree.pages).length, textCount: texts.length, }; @@ -295,7 +293,7 @@ describe("Concurrency and Thread Safety Tests", () => { id: `mixed_btn_${index}`, label: `Mixed Button ${index}`, message: `Mixed Message ${index}`, - type: "SPEAK", + type: 'SPEAK', }); page.addButton(button); @@ -306,19 +304,19 @@ describe("Concurrency and Thread Safety Tests", () => { return { index, - operation: "write", + operation: 'write', outputPath, exists: fs.existsSync(outputPath), }; - }), + }) ); const results = await Promise.all(mixedOperations); expect(results).toHaveLength(10); - const readResults = results.filter((r: any) => r.operation === "read"); - const writeResults = results.filter((r: any) => r.operation === "write"); + const readResults = results.filter((r: any) => r.operation === 'read'); + const writeResults = results.filter((r: any) => r.operation === 'write'); expect(readResults.length).toBe(5); expect(writeResults.length).toBe(5); @@ -334,8 +332,8 @@ describe("Concurrency and Thread Safety Tests", () => { }); }); - describe("Error Handling Under Concurrency", () => { - it("should handle concurrent errors gracefully", async () => { + describe('Error Handling Under Concurrency', () => { + it('should handle concurrent errors gracefully', async () => { const processor = new ObfProcessor(); // Mix of valid and invalid operations @@ -346,9 +344,7 @@ describe("Concurrency and Thread Safety Tests", () => { try { if (index % 2 === 0) { const validContent = '{"id": "test", "buttons": []}'; - const tree = await processor.loadIntoTree( - Buffer.from(validContent), - ); + const tree = await processor.loadIntoTree(Buffer.from(validContent)); return { index, success: true, @@ -367,10 +363,10 @@ describe("Concurrency and Thread Safety Tests", () => { return { index, success: false, - error: error instanceof Error ? error.message : "Unknown error", + error: error instanceof Error ? error.message : 'Unknown error', }; } - }), + }) ); const results = await Promise.all(operations); @@ -386,11 +382,11 @@ describe("Concurrency and Thread Safety Tests", () => { // Errors should be handled gracefully errorResults.forEach((result: any) => { expect(result.error).toBeDefined(); - expect(typeof result.error).toBe("string"); + expect(typeof result.error).toBe('string'); }); }); - it("should maintain data integrity under concurrent stress", async () => { + it('should maintain data integrity under concurrent stress', async () => { const processor = new DotProcessor(); // Create a reference file @@ -404,7 +400,7 @@ describe("Concurrency and Thread Safety Tests", () => { } `; - const referenceFile = path.join(tempDir, "integrity_reference.dot"); + const referenceFile = path.join(tempDir, 'integrity_reference.dot'); fs.writeFileSync(referenceFile, referenceContent); // Get reference data @@ -420,8 +416,7 @@ describe("Concurrency and Thread Safety Tests", () => { const texts = await processor.extractTexts(referenceFile); const pageCountMatch = - Object.keys(tree.pages).length === - Object.keys(referenceTree.pages).length; + Object.keys(tree.pages).length === Object.keys(referenceTree.pages).length; const textCountMatch = texts.length === referenceTexts.length; return { @@ -430,7 +425,7 @@ describe("Concurrency and Thread Safety Tests", () => { textCountMatch, integrity: pageCountMatch && textCountMatch, }; - }), + }) ); const results = await Promise.all(integrityChecks); diff --git a/test/core/analyze.test.ts b/test/core/analyze.test.ts index 790da9f..7ab3bef 100644 --- a/test/core/analyze.test.ts +++ b/test/core/analyze.test.ts @@ -1,4 +1,4 @@ -import { getProcessor, analyze } from "../../src/core/analyze"; +import { getProcessor, analyze } from '../../src/core/analyze'; import { DotProcessor, OpmlProcessor, @@ -8,92 +8,92 @@ import { AstericsGridProcessor, TouchChatProcessor, ApplePanelsProcessor, -} from "../../src/index"; -import { TreeFactory } from "../utils/testFactories"; -import path from "path"; -import fs from "fs"; -import os from "os"; - -describe("analyze", () => { - describe("getProcessor", () => { +} from '../../src/index'; +import { TreeFactory } from '../utils/testFactories'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; + +describe('analyze', () => { + describe('getProcessor', () => { it('should return a DotProcessor for "dot"', async () => { - expect(getProcessor("dot")).toBeInstanceOf(DotProcessor); + expect(getProcessor('dot')).toBeInstanceOf(DotProcessor); }); it('should return a OpmlProcessor for "opml"', async () => { - expect(getProcessor("opml")).toBeInstanceOf(OpmlProcessor); + expect(getProcessor('opml')).toBeInstanceOf(OpmlProcessor); }); it('should return a ObfProcessor for "obf"', async () => { - expect(getProcessor("obf")).toBeInstanceOf(ObfProcessor); + expect(getProcessor('obf')).toBeInstanceOf(ObfProcessor); }); it('should return a SnapProcessor for "snap"', async () => { - expect(getProcessor("snap")).toBeInstanceOf(SnapProcessor); + expect(getProcessor('snap')).toBeInstanceOf(SnapProcessor); }); it('should return a SnapProcessor for "sps" extension', async () => { - expect(getProcessor("sps")).toBeInstanceOf(SnapProcessor); + expect(getProcessor('sps')).toBeInstanceOf(SnapProcessor); }); it('should return a SnapProcessor for "spb" extension', async () => { - expect(getProcessor("spb")).toBeInstanceOf(SnapProcessor); + expect(getProcessor('spb')).toBeInstanceOf(SnapProcessor); }); it('should return a GridsetProcessor for "gridset"', async () => { - expect(getProcessor("gridset")).toBeInstanceOf(GridsetProcessor); + expect(getProcessor('gridset')).toBeInstanceOf(GridsetProcessor); }); it('should return a GridsetProcessor for "gridsetx"', async () => { - expect(getProcessor("gridsetx")).toBeInstanceOf(GridsetProcessor); + expect(getProcessor('gridsetx')).toBeInstanceOf(GridsetProcessor); }); it('should return an AstericsGridProcessor for "grd" extension', async () => { - expect(getProcessor("grd")).toBeInstanceOf(AstericsGridProcessor); + expect(getProcessor('grd')).toBeInstanceOf(AstericsGridProcessor); }); it('should return a TouchChatProcessor for "touchchat"', async () => { - expect(getProcessor("touchchat")).toBeInstanceOf(TouchChatProcessor); + expect(getProcessor('touchchat')).toBeInstanceOf(TouchChatProcessor); }); it('should return a TouchChatProcessor for "ce" extension', async () => { - expect(getProcessor("ce")).toBeInstanceOf(TouchChatProcessor); + expect(getProcessor('ce')).toBeInstanceOf(TouchChatProcessor); }); it('should return a ApplePanelsProcessor for "applepanels"', async () => { - expect(getProcessor("applepanels")).toBeInstanceOf(ApplePanelsProcessor); + expect(getProcessor('applepanels')).toBeInstanceOf(ApplePanelsProcessor); }); it('should return a ApplePanelsProcessor for "panels"', async () => { - expect(getProcessor("panels")).toBeInstanceOf(ApplePanelsProcessor); + expect(getProcessor('panels')).toBeInstanceOf(ApplePanelsProcessor); }); - it("should be case-insensitive", async () => { - expect(getProcessor("DOT")).toBeInstanceOf(DotProcessor); - expect(getProcessor("OPML")).toBeInstanceOf(OpmlProcessor); - expect(getProcessor("SNAP")).toBeInstanceOf(SnapProcessor); + it('should be case-insensitive', async () => { + expect(getProcessor('DOT')).toBeInstanceOf(DotProcessor); + expect(getProcessor('OPML')).toBeInstanceOf(OpmlProcessor); + expect(getProcessor('SNAP')).toBeInstanceOf(SnapProcessor); }); - it("should handle empty string format", async () => { - expect(() => getProcessor("")).toThrow("Unknown format: "); + it('should handle empty string format', async () => { + expect(() => getProcessor('')).toThrow('Unknown format: '); }); - it("should handle null/undefined format", async () => { - expect(() => getProcessor(null as any)).toThrow("Unknown format: "); - expect(() => getProcessor(undefined as any)).toThrow("Unknown format: "); + it('should handle null/undefined format', async () => { + expect(() => getProcessor(null as any)).toThrow('Unknown format: '); + expect(() => getProcessor(undefined as any)).toThrow('Unknown format: '); }); - it("should throw an error for an unknown format", async () => { - expect(() => getProcessor("unknown")).toThrow("Unknown format: unknown"); - expect(() => getProcessor("xyz")).toThrow("Unknown format: xyz"); + it('should throw an error for an unknown format', async () => { + expect(() => getProcessor('unknown')).toThrow('Unknown format: unknown'); + expect(() => getProcessor('xyz')).toThrow('Unknown format: xyz'); }); }); - describe("analyze", () => { + describe('analyze', () => { let tempDir: string; beforeEach(async () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "analyze-test-")); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'analyze-test-')); }); afterEach(async () => { @@ -102,74 +102,72 @@ describe("analyze", () => { } }); - it("should analyze a DOT file and return a tree", async () => { - const tempFile = path.join(tempDir, "test.dot"); + it('should analyze a DOT file and return a tree', async () => { + const tempFile = path.join(tempDir, 'test.dot'); fs.writeFileSync(tempFile, 'digraph G { "Home" -> "Food"; }'); - const { tree } = await analyze(tempFile, "dot"); + const { tree } = await analyze(tempFile, 'dot'); expect(tree).toBeDefined(); expect(tree.pages).toBeDefined(); }); - it("should analyze an OPML file and return a tree", async () => { + it('should analyze an OPML file and return a tree', async () => { // Create a test OPML file using TreeFactory const tree = TreeFactory.createSimple(); const processor = new OpmlProcessor(); - const tempFile = path.join(tempDir, "test.opml"); + const tempFile = path.join(tempDir, 'test.opml'); await processor.saveFromTree(tree, tempFile); - const { tree: analyzedTree } = await analyze(tempFile, "opml"); + const { tree: analyzedTree } = await analyze(tempFile, 'opml'); expect(analyzedTree).toBeDefined(); expect(analyzedTree.pages).toBeDefined(); // OPML processor may create additional pages for circular references expect(Object.keys(analyzedTree.pages).length).toBeGreaterThanOrEqual(2); }); - it("should handle file reading errors", async () => { - const nonExistentFile = path.join(tempDir, "nonexistent.opml"); + it('should handle file reading errors', async () => { + const nonExistentFile = path.join(tempDir, 'nonexistent.opml'); - await expect(analyze(nonExistentFile, "opml")).rejects.toThrow(); + await expect(analyze(nonExistentFile, 'opml')).rejects.toThrow(); }); - it("should handle invalid format in analyze", async () => { + it('should handle invalid format in analyze', async () => { // Create a dummy file - const tempFile = path.join(tempDir, "test.txt"); - fs.writeFileSync(tempFile, "dummy content"); + const tempFile = path.join(tempDir, 'test.txt'); + fs.writeFileSync(tempFile, 'dummy content'); - await expect(analyze(tempFile, "invalid")).rejects.toThrow( - "Unknown format: invalid", - ); + await expect(analyze(tempFile, 'invalid')).rejects.toThrow('Unknown format: invalid'); }); - it("should work with different file formats", async () => { + it('should work with different file formats', async () => { const tree = TreeFactory.createSimple(); // Test DOT format const dotProcessor = new DotProcessor(); - const dotFile = path.join(tempDir, "test.dot"); + const dotFile = path.join(tempDir, 'test.dot'); await dotProcessor.saveFromTree(tree, dotFile); - const dotResult = await analyze(dotFile, "dot"); - expect(dotResult).toHaveProperty("tree"); + const dotResult = await analyze(dotFile, 'dot'); + expect(dotResult).toHaveProperty('tree'); expect(dotResult.tree).toBeDefined(); // Test OPML format const opmlProcessor = new OpmlProcessor(); - const opmlFile = path.join(tempDir, "test.opml"); + const opmlFile = path.join(tempDir, 'test.opml'); await opmlProcessor.saveFromTree(tree, opmlFile); - const opmlResult = await analyze(opmlFile, "opml"); - expect(opmlResult).toHaveProperty("tree"); + const opmlResult = await analyze(opmlFile, 'opml'); + expect(opmlResult).toHaveProperty('tree'); expect(opmlResult.tree).toBeDefined(); }); - it("should return tree with correct structure", async () => { + it('should return tree with correct structure', async () => { const tree = TreeFactory.createCommunicationBoard(); const processor = new OpmlProcessor(); - const tempFile = path.join(tempDir, "communication.opml"); + const tempFile = path.join(tempDir, 'communication.opml'); await processor.saveFromTree(tree, tempFile); - const { tree: analyzedTree } = await analyze(tempFile, "opml"); + const { tree: analyzedTree } = await analyze(tempFile, 'opml'); expect(analyzedTree).toBeDefined(); expect(analyzedTree.pages).toBeDefined(); expect(Object.keys(analyzedTree.pages).length).toBeGreaterThan(0); diff --git a/test/core/baseConfig.test.ts b/test/core/baseConfig.test.ts index e547a43..85df7d4 100644 --- a/test/core/baseConfig.test.ts +++ b/test/core/baseConfig.test.ts @@ -1,7 +1,7 @@ -import AdmZip from "adm-zip"; -import { BaseProcessor } from "../../src/core/baseProcessor"; -import { BaseValidator } from "../../src/validation/baseValidator"; -import { ValidationResult } from "../../src/validation/validationTypes"; +import AdmZip from 'adm-zip'; +import { BaseProcessor } from '../../src/core/baseProcessor'; +import { BaseValidator } from '../../src/validation/baseValidator'; +import { ValidationResult } from '../../src/validation/validationTypes'; class TestProcessor extends BaseProcessor { async extractTexts(): Promise { @@ -22,37 +22,31 @@ class TestProcessor extends BaseProcessor { } class TestValidator extends BaseValidator { - async validate( - _content: any, - _filename: string, - _filesize: number, - ): Promise { - return this.buildResult("file", 0, "test"); + async validate(_content: any, _filename: string, _filesize: number): Promise { + return this.buildResult('file', 0, 'test'); } } -describe("base defaults", () => { - it("BaseProcessor provides a default zipAdapter", async () => { +describe('base defaults', () => { + it('BaseProcessor provides a default zipAdapter', async () => { const processor = new TestProcessor(); const zip = new AdmZip(); - zip.addFile("hello.txt", Buffer.from("hello", "utf8")); + zip.addFile('hello.txt', Buffer.from('hello', 'utf8')); const adapter = await (processor as any).options.zipAdapter(zip.toBuffer()); - expect(adapter.listFiles()).toContain("hello.txt"); - const contents = await adapter.readFile("hello.txt"); - expect(Buffer.from(contents).toString("utf8")).toBe("hello"); + expect(adapter.listFiles()).toContain('hello.txt'); + const contents = await adapter.readFile('hello.txt'); + expect(Buffer.from(contents).toString('utf8')).toBe('hello'); }); - it("BaseValidator provides a default zipAdapter", async () => { + it('BaseValidator provides a default zipAdapter', async () => { const validator = new TestValidator(); const zip = new AdmZip(); - zip.addFile("world.txt", Buffer.from("world", "utf8")); - const adapter = await (validator as any)._options.zipAdapter( - zip.toBuffer(), - ); - - expect(adapter.listFiles()).toContain("world.txt"); - const contents = await adapter.readFile("world.txt"); - expect(Buffer.from(contents).toString("utf8")).toBe("world"); + zip.addFile('world.txt', Buffer.from('world', 'utf8')); + const adapter = await (validator as any)._options.zipAdapter(zip.toBuffer()); + + expect(adapter.listFiles()).toContain('world.txt'); + const contents = await adapter.readFile('world.txt'); + expect(Buffer.from(contents).toString('utf8')).toBe('world'); }); }); diff --git a/test/core/baseProcessor.generic.test.ts b/test/core/baseProcessor.generic.test.ts index 1e81ca2..7e5870c 100644 --- a/test/core/baseProcessor.generic.test.ts +++ b/test/core/baseProcessor.generic.test.ts @@ -4,14 +4,14 @@ import { type TranslatedString, type SourceString, type ProcessorOptions, -} from "../../src/core/baseProcessor"; +} from '../../src/core/baseProcessor'; import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent, -} from "../../src/core/treeStructure"; +} from '../../src/core/treeStructure'; class DummyProcessor extends BaseProcessor { private tree: AACTree; @@ -34,7 +34,7 @@ class DummyProcessor extends BaseProcessor { async processTexts( _filePathOrBuffer: string, translations: Map, - outputPath: string, + outputPath: string ): Promise { this.lastTranslations = translations; this.lastOutputPath = outputPath; @@ -49,22 +49,16 @@ class DummyProcessor extends BaseProcessor { return this.filterPageButtons(buttons); } - public extractStringsGeneric( - filePath: string, - ): Promise { + public extractStringsGeneric(filePath: string): Promise { return this.extractStringsWithMetadataGeneric(filePath); } public generateTranslatedGeneric( filePath: string, translatedStrings: TranslatedString[], - sourceStrings: SourceString[], + sourceStrings: SourceString[] ): Promise { - return this.generateTranslatedDownloadGeneric( - filePath, - translatedStrings, - sourceStrings, - ); + return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings); } public outputPathFor(filePath: string): string { @@ -74,57 +68,57 @@ class DummyProcessor extends BaseProcessor { function createTree(): AACTree { const tree = new AACTree(); - const page = new AACPage({ id: "page-1", name: "Home" }); + const page = new AACPage({ id: 'page-1', name: 'Home' }); const yesButton = new AACButton({ - id: "btn-1", - label: "Yes", - message: "Yes", + id: 'btn-1', + label: 'Yes', + message: 'Yes', }); - const noButton = new AACButton({ id: "btn-2", label: "No", message: "Nope" }); + const noButton = new AACButton({ id: 'btn-2', label: 'No', message: 'Nope' }); page.buttons.push(yesButton, noButton); tree.addPage(page); tree.rootId = page.id; return tree; } -describe("BaseProcessor generic helpers", () => { - it("filters navigation/system buttons by default", () => { +describe('BaseProcessor generic helpers', () => { + it('filters navigation/system buttons by default', () => { const tree = createTree(); const processor = new DummyProcessor(tree); const buttons = [ new AACButton({ - id: "nav", - label: "Back", - message: "", + id: 'nav', + label: 'Back', + message: '', semanticAction: { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.GO_BACK, }, }), new AACButton({ - id: "sys", - label: "Clear", - message: "", + id: 'sys', + label: 'Clear', + message: '', semanticAction: { category: AACSemanticCategory.TEXT_EDITING, intent: AACSemanticIntent.CLEAR_TEXT, }, }), - new AACButton({ id: "keep", label: "Hello", message: "Hello" }), + new AACButton({ id: 'keep', label: 'Hello', message: 'Hello' }), ]; const filtered = processor.filterButtons(buttons); - expect(filtered.map((b) => b.id)).toEqual(["keep"]); + expect(filtered.map((b) => b.id)).toEqual(['keep']); }); - it("preserves all buttons when preserveAllButtons is set", () => { + it('preserves all buttons when preserveAllButtons is set', () => { const tree = createTree(); const processor = new DummyProcessor(tree, { preserveAllButtons: true }); const buttons = [ new AACButton({ - id: "nav", - label: "Back", - message: "", + id: 'nav', + label: 'Back', + message: '', semanticAction: { category: AACSemanticCategory.NAVIGATION, intent: AACSemanticIntent.GO_BACK, @@ -135,67 +129,62 @@ describe("BaseProcessor generic helpers", () => { expect(processor.filterButtons(buttons).length).toBe(1); }); - it("applies a custom button filter", () => { + it('applies a custom button filter', () => { const tree = createTree(); const processor = new DummyProcessor(tree, { - customButtonFilter: (button) => - !button.label?.toLowerCase().includes("skip"), + customButtonFilter: (button) => !button.label?.toLowerCase().includes('skip'), }); const buttons = [ - new AACButton({ id: "skip", label: "Skip Me", message: "" }), - new AACButton({ id: "keep", label: "Keep", message: "" }), + new AACButton({ id: 'skip', label: 'Skip Me', message: '' }), + new AACButton({ id: 'keep', label: 'Keep', message: '' }), ]; - expect(processor.filterButtons(buttons).map((b) => b.id)).toEqual(["keep"]); + expect(processor.filterButtons(buttons).map((b) => b.id)).toEqual(['keep']); }); - it("extracts strings with metadata and deduplicates", async () => { + it('extracts strings with metadata and deduplicates', async () => { const tree = createTree(); const processor = new DummyProcessor(tree); - const result = await processor.extractStringsGeneric("dummy.path"); + const result = await processor.extractStringsGeneric('dummy.path'); const labels = result.extractedStrings.map((entry) => entry.string).sort(); - expect(labels).toEqual(["Home", "No", "Nope", "Yes"]); + expect(labels).toEqual(['Home', 'No', 'Nope', 'Yes']); - const yesEntry = result.extractedStrings.find( - (entry) => entry.string === "Yes", - ); + const yesEntry = result.extractedStrings.find((entry) => entry.string === 'Yes'); expect(yesEntry?.vocabPlacementMeta.vocabLocations.length).toBe(1); }); - it("builds translations and output paths for generic downloads", async () => { + it('builds translations and output paths for generic downloads', async () => { const tree = createTree(); const processor = new DummyProcessor(tree); const sourceStrings: SourceString[] = [ { id: 1, - sourcestring: "Hello", + sourcestring: 'Hello', vocabplacementmetadata: { vocabLocations: [] }, }, ]; const translatedStrings: TranslatedString[] = [ { sourcestringid: 1, - overridestring: "Hola", - translatedstring: "Bonjour", + overridestring: 'Hola', + translatedstring: 'Bonjour', }, ]; const outputPath = await processor.generateTranslatedGeneric( - "/tmp/example.obf", + '/tmp/example.obf', translatedStrings, - sourceStrings, + sourceStrings ); - expect(outputPath).toBe("/tmp/example_translated.obf"); - expect(processor.lastTranslations?.get("Hello")).toBe("Hola"); + expect(outputPath).toBe('/tmp/example_translated.obf'); + expect(processor.lastTranslations?.get('Hello')).toBe('Hola'); }); - it("generates translated output paths without extensions", () => { + it('generates translated output paths without extensions', () => { const tree = createTree(); const processor = new DummyProcessor(tree); - expect(processor.outputPathFor("/tmp/example")).toBe( - "/tmp/example_translated", - ); + expect(processor.outputPathFor('/tmp/example')).toBe('/tmp/example_translated'); }); }); diff --git a/test/core/coverageBoost.test.ts b/test/core/coverageBoost.test.ts index e179be6..0cdbd54 100644 --- a/test/core/coverageBoost.test.ts +++ b/test/core/coverageBoost.test.ts @@ -4,115 +4,113 @@ import { AACButton, AACSemanticCategory, AACSemanticIntent, -} from "../../src/core/treeStructure"; -import { BaseProcessor } from "../../src/core/baseProcessor"; +} from '../../src/core/treeStructure'; +import { BaseProcessor } from '../../src/core/baseProcessor'; -describe("src/core Coverage Boost", () => { - describe("AACButton constructor legacy mappings", () => { - it("should map legacy NAVIGATE type", async () => { +describe('src/core Coverage Boost', () => { + describe('AACButton constructor legacy mappings', () => { + it('should map legacy NAVIGATE type', async () => { const button = new AACButton({ - id: "btn1", - type: "NAVIGATE", - targetPageId: "page2", + id: 'btn1', + type: 'NAVIGATE', + targetPageId: 'page2', }); expect(button.semanticAction?.intent).toBe(AACSemanticIntent.NAVIGATE_TO); - expect(button.type).toBe("NAVIGATE"); + expect(button.type).toBe('NAVIGATE'); }); - it("should map legacy SPEAK type", async () => { + it('should map legacy SPEAK type', async () => { const button = new AACButton({ - id: "btn1", - type: "SPEAK", - message: "hello", + id: 'btn1', + type: 'SPEAK', + message: 'hello', }); expect(button.semanticAction?.intent).toBe(AACSemanticIntent.SPEAK_TEXT); - expect(button.type).toBe("SPEAK"); + expect(button.type).toBe('SPEAK'); }); - it("should map legacy ACTION type", async () => { + it('should map legacy ACTION type', async () => { const button = new AACButton({ - id: "btn1", - type: "ACTION", + id: 'btn1', + type: 'ACTION', }); - expect(button.semanticAction?.intent).toBe( - AACSemanticIntent.PLATFORM_SPECIFIC, - ); - expect(button.type).toBe("ACTION"); + expect(button.semanticAction?.intent).toBe(AACSemanticIntent.PLATFORM_SPECIFIC); + expect(button.type).toBe('ACTION'); }); - it("should map legacy action object (NAVIGATE)", async () => { + it('should map legacy action object (NAVIGATE)', async () => { const button = new AACButton({ - id: "btn1", - action: { type: "NAVIGATE", targetPageId: "page2" }, + id: 'btn1', + action: { type: 'NAVIGATE', targetPageId: 'page2' }, }); - expect(button.type).toBe("NAVIGATE"); + expect(button.type).toBe('NAVIGATE'); }); - it("should map legacy action object (SPEAK)", async () => { + it('should map legacy action object (SPEAK)', async () => { const button = new AACButton({ - id: "btn1", - action: { type: "SPEAK", message: "test" }, + id: 'btn1', + action: { type: 'SPEAK', message: 'test' }, }); - expect(button.type).toBe("SPEAK"); - expect(button.message).toBe("test"); + expect(button.type).toBe('SPEAK'); + expect(button.message).toBe('test'); }); - it("should map legacy action object (ACTION)", async () => { + it('should map legacy action object (ACTION)', async () => { const button = new AACButton({ - id: "btn1", - action: { type: "ACTION" }, + id: 'btn1', + action: { type: 'ACTION' }, }); - expect(button.type).toBe("ACTION"); + expect(button.type).toBe('ACTION'); }); }); - describe("AACButton getters", () => { - it("should return SPEAK for SPEAK_IMMEDIATE intent", async () => { + describe('AACButton getters', () => { + it('should return SPEAK for SPEAK_IMMEDIATE intent', async () => { const button = new AACButton({ - id: "1", + id: '1', semanticAction: { intent: AACSemanticIntent.SPEAK_IMMEDIATE }, }); - expect(button.type).toBe("SPEAK"); + expect(button.type).toBe('SPEAK'); }); - it("should return null for empty SPEAK button action", async () => { - const button = new AACButton({ id: "1" }); + it('should return null for empty SPEAK button action', async () => { + const button = new AACButton({ id: '1' }); // In constructor, default type is SPEAK, but message/label are empty expect(button.action).toBeNull(); }); - it("should handle NAVIGATE type from targetPageId fallback", async () => { - const button = new AACButton({ id: "1", targetPageId: "p2" }); - expect(button.type).toBe("NAVIGATE"); - expect(button.action?.type).toBe("NAVIGATE"); + it('should handle NAVIGATE type from targetPageId fallback', async () => { + const button = new AACButton({ id: '1', targetPageId: 'p2' }); + expect(button.type).toBe('NAVIGATE'); + expect(button.action?.type).toBe('NAVIGATE'); }); - it("should handle SPEAK type from message fallback", async () => { - const button = new AACButton({ id: "1", message: "hello" }); - expect(button.type).toBe("SPEAK"); - expect(button.action?.type).toBe("SPEAK"); + it('should handle SPEAK type from message fallback', async () => { + const button = new AACButton({ id: '1', message: 'hello' }); + expect(button.type).toBe('SPEAK'); + expect(button.action?.type).toBe('SPEAK'); }); }); - describe("AACTree extra properties", () => { - it("should handle rootId getter/setter", async () => { + describe('AACTree extra properties', () => { + it('should handle rootId getter/setter', async () => { const tree = new AACTree(); - tree.rootId = "root1"; - expect(tree.rootId).toBe("root1"); - expect(tree.metadata.defaultHomePageId).toBe("root1"); + tree.rootId = 'root1'; + expect(tree.rootId).toBe('root1'); + expect(tree.metadata.defaultHomePageId).toBe('root1'); tree.rootId = null; expect(tree.rootId).toBeNull(); expect(tree.metadata.defaultHomePageId).toBeUndefined(); }); - it("should handle toolbarId and dashboardId", async () => { + it('should handle toolbarId and dashboardId', async () => { const tree = new AACTree(); - tree.toolbarId = "tb1"; - tree.dashboardId = "db1"; - expect(tree.toolbarId).toBe("tb1"); - expect(tree.dashboardId).toBe("db1"); - expect(tree.metadata.toolbarId).toBe("tb1"); - expect(tree.metadata.dashboardId).toBe("db1"); + tree.toolbarId = 'tb1'; + tree.dashboardId = 'db1'; + expect(tree.toolbarId).toBe('tb1'); + expect(tree.dashboardId).toBe('db1'); + expect(tree.metadata.toolbarId).toBe('tb1'); + expect(tree.metadata.dashboardId).toBe('db1'); tree.toolbarId = null; tree.dashboardId = null; @@ -121,10 +119,10 @@ describe("src/core Coverage Boost", () => { }); }); - describe("AACPage grid constructor", () => { - it("should create empty grid for columns/rows object", async () => { + describe('AACPage grid constructor', () => { + it('should create empty grid for columns/rows object', async () => { const page = new AACPage({ - id: "p1", + id: 'p1', grid: { columns: 2, rows: 3 }, }); expect(page.grid).toHaveLength(3); @@ -132,13 +130,13 @@ describe("src/core Coverage Boost", () => { expect(page.grid[0][0]).toBeNull(); }); - it("should default to empty grid if no grid provided", async () => { - const page = new AACPage({ id: "p1" }); + it('should default to empty grid if no grid provided', async () => { + const page = new AACPage({ id: 'p1' }); expect(page.grid).toEqual([]); }); }); - describe("BaseProcessor features", () => { + describe('BaseProcessor features', () => { class MockProcessor extends BaseProcessor { async extractTexts() { return []; @@ -162,10 +160,10 @@ describe("src/core Coverage Boost", () => { } } - it("should filter GO_BACK / GO_HOME navigation buttons", async () => { + it('should filter GO_BACK / GO_HOME navigation buttons', async () => { const processor = new MockProcessor({ excludeNavigationButtons: true }); const backBtn = new AACButton({ - id: "back", + id: 'back', semanticAction: { intent: AACSemanticIntent.GO_BACK, category: AACSemanticCategory.NAVIGATION, @@ -174,22 +172,22 @@ describe("src/core Coverage Boost", () => { expect(processor.callShouldFilter(backBtn)).toBe(true); }); - it("should filter text editing category", async () => { + it('should filter text editing category', async () => { const processor = new MockProcessor({ excludeSystemButtons: true }); const editBtn = new AACButton({ - id: "edit", + id: 'edit', semanticAction: { - intent: "ANY", + intent: 'ANY', category: AACSemanticCategory.TEXT_EDITING, }, }); expect(processor.callShouldFilter(editBtn)).toBe(true); }); - it("should filter specific system intents", async () => { + it('should filter specific system intents', async () => { const processor = new MockProcessor({ excludeSystemButtons: true }); const deleteBtn = new AACButton({ - id: "del", + id: 'del', semanticAction: { intent: AACSemanticIntent.DELETE_WORD, category: AACSemanticCategory.SYSTEM_CONTROL, @@ -198,40 +196,38 @@ describe("src/core Coverage Boost", () => { expect(processor.callShouldFilter(deleteBtn)).toBe(true); }); - it("should handle output path without extension", async () => { + it('should handle output path without extension', async () => { const processor = new MockProcessor(); - expect(processor.callGenerateOutputPath("myfile")).toBe( - "myfile_translated", - ); + expect(processor.callGenerateOutputPath('myfile')).toBe('myfile_translated'); }); - it("should handle custom button filter", async () => { + it('should handle custom button filter', async () => { const processor = new MockProcessor({ - customButtonFilter: (btn) => btn.label !== "Secret", + customButtonFilter: (btn) => btn.label !== 'Secret', }); - const secretBtn = new AACButton({ id: "1", label: "Secret" }); - const normalBtn = new AACButton({ id: "2", label: "Normal" }); + const secretBtn = new AACButton({ id: '1', label: 'Secret' }); + const normalBtn = new AACButton({ id: '2', label: 'Normal' }); expect(processor.callShouldFilter(secretBtn)).toBe(true); expect(processor.callShouldFilter(normalBtn)).toBe(false); }); - it("should handle addToExtractedMap with existing key", async () => { + it('should handle addToExtractedMap with existing key', async () => { const processor = new MockProcessor(); const extractedMap = new Map(); - (processor as any).addToExtractedMap(extractedMap, "test", "Test", { - table: "b", + (processor as any).addToExtractedMap(extractedMap, 'test', 'Test', { + table: 'b', id: 1, - column: "L", - casing: "capitalized", + column: 'L', + casing: 'capitalized', }); - (processor as any).addToExtractedMap(extractedMap, "test", "Test2", { - table: "b", + (processor as any).addToExtractedMap(extractedMap, 'test', 'Test2', { + table: 'b', id: 2, - column: "L", - casing: "capitalized", + column: 'L', + casing: 'capitalized', }); - const item = extractedMap.get("test"); + const item = extractedMap.get('test'); expect(item.vocabPlacementMeta.vocabLocations).toHaveLength(2); }); }); diff --git a/test/core/treeStructure.test.ts b/test/core/treeStructure.test.ts index 96c9bc3..3f5a0af 100644 --- a/test/core/treeStructure.test.ts +++ b/test/core/treeStructure.test.ts @@ -1,74 +1,74 @@ -import { AACTree, AACPage, AACButton } from "../../src/index"; - -describe("AACButton", () => { - it("should create a button with default values", async () => { - const button = new AACButton({ id: "btn1" }); - expect(button.id).toBe("btn1"); - expect(button.label).toBe(""); - expect(button.message).toBe(""); - expect(button.type).toBe("SPEAK"); +import { AACTree, AACPage, AACButton } from '../../src/index'; + +describe('AACButton', () => { + it('should create a button with default values', async () => { + const button = new AACButton({ id: 'btn1' }); + expect(button.id).toBe('btn1'); + expect(button.label).toBe(''); + expect(button.message).toBe(''); + expect(button.type).toBe('SPEAK'); expect(button.action).toBeNull(); expect(button.targetPageId).toBeUndefined(); }); - it("should create a navigation button", async () => { + it('should create a navigation button', async () => { const button = new AACButton({ - id: "nav1", - label: "Go to Page 2", - type: "NAVIGATE", - targetPageId: "page2", - action: { type: "NAVIGATE", targetPageId: "page2" }, + id: 'nav1', + label: 'Go to Page 2', + type: 'NAVIGATE', + targetPageId: 'page2', + action: { type: 'NAVIGATE', targetPageId: 'page2' }, }); - expect(button.type).toBe("NAVIGATE"); - expect(button.targetPageId).toBe("page2"); - expect(button.action?.type).toBe("NAVIGATE"); - expect(button.action?.targetPageId).toBe("page2"); + expect(button.type).toBe('NAVIGATE'); + expect(button.targetPageId).toBe('page2'); + expect(button.action?.type).toBe('NAVIGATE'); + expect(button.action?.targetPageId).toBe('page2'); }); - it("should create a button with audio recording", async () => { - const audioData = Buffer.from("audio data"); + it('should create a button with audio recording', async () => { + const audioData = Buffer.from('audio data'); const button = new AACButton({ - id: "audio1", - label: "Hello", + id: 'audio1', + label: 'Hello', audioRecording: { id: 123, data: audioData, - identifier: "SND:hello", - metadata: "test metadata", + identifier: 'SND:hello', + metadata: 'test metadata', }, }); expect(button.audioRecording?.id).toBe(123); expect(button.audioRecording?.data).toBe(audioData); - expect(button.audioRecording?.identifier).toBe("SND:hello"); - expect(button.audioRecording?.metadata).toBe("test metadata"); + expect(button.audioRecording?.identifier).toBe('SND:hello'); + expect(button.audioRecording?.metadata).toBe('test metadata'); }); }); -describe("AACPage", () => { - it("should create a page with default values", async () => { - const page = new AACPage({ id: "page1" }); - expect(page.id).toBe("page1"); - expect(page.name).toBe(""); +describe('AACPage', () => { + it('should create a page with default values', async () => { + const page = new AACPage({ id: 'page1' }); + expect(page.id).toBe('page1'); + expect(page.name).toBe(''); expect(page.grid).toEqual([]); expect(page.buttons).toEqual([]); expect(page.parentId).toBeNull(); }); - it("should create a page with custom values", async () => { + it('should create a page with custom values', async () => { const page = new AACPage({ - id: "page2", - name: "Main Page", - parentId: "parent1", + id: 'page2', + name: 'Main Page', + parentId: 'parent1', }); - expect(page.id).toBe("page2"); - expect(page.name).toBe("Main Page"); - expect(page.parentId).toBe("parent1"); + expect(page.id).toBe('page2'); + expect(page.name).toBe('Main Page'); + expect(page.parentId).toBe('parent1'); }); - it("should add buttons to a page", async () => { - const page = new AACPage({ id: "page1" }); - const button1 = new AACButton({ id: "btn1", label: "Button 1" }); - const button2 = new AACButton({ id: "btn2", label: "Button 2" }); + it('should add buttons to a page', async () => { + const page = new AACPage({ id: 'page1' }); + const button1 = new AACButton({ id: 'btn1', label: 'Button 1' }); + const button2 = new AACButton({ id: 'btn2', label: 'Button 2' }); page.addButton(button1); page.addButton(button2); @@ -79,60 +79,60 @@ describe("AACPage", () => { }); }); -describe("AACTree", () => { - it("should create an empty tree", async () => { +describe('AACTree', () => { + it('should create an empty tree', async () => { const tree = new AACTree(); expect(tree.pages).toEqual({}); expect(tree.rootId).toBeNull(); }); - it("should add pages to the tree", async () => { + it('should add pages to the tree', async () => { const tree = new AACTree(); - const page1 = new AACPage({ id: "page1", name: "First Page" }); - const page2 = new AACPage({ id: "page2", name: "Second Page" }); + const page1 = new AACPage({ id: 'page1', name: 'First Page' }); + const page2 = new AACPage({ id: 'page2', name: 'Second Page' }); tree.addPage(page1); tree.addPage(page2); expect(Object.keys(tree.pages)).toHaveLength(2); - expect(tree.pages["page1"]).toBe(page1); - expect(tree.pages["page2"]).toBe(page2); - expect(tree.rootId).toBe("page1"); // First page becomes root + expect(tree.pages['page1']).toBe(page1); + expect(tree.pages['page2']).toBe(page2); + expect(tree.rootId).toBe('page1'); // First page becomes root }); - it("should get pages by id", async () => { + it('should get pages by id', async () => { const tree = new AACTree(); - const page = new AACPage({ id: "test-page", name: "Test Page" }); + const page = new AACPage({ id: 'test-page', name: 'Test Page' }); tree.addPage(page); - const retrievedPage = tree.getPage("test-page"); + const retrievedPage = tree.getPage('test-page'); expect(retrievedPage).toBe(page); }); - it("should return undefined for non-existent page", async () => { + it('should return undefined for non-existent page', async () => { const tree = new AACTree(); - const retrievedPage = tree.getPage("non-existent"); + const retrievedPage = tree.getPage('non-existent'); expect(retrievedPage).toBeUndefined(); }); - it("should traverse all pages", async () => { + it('should traverse all pages', async () => { const tree = new AACTree(); - const page1 = new AACPage({ id: "page1", name: "Page 1" }); - const page2 = new AACPage({ id: "page2", name: "Page 2" }); - const page3 = new AACPage({ id: "page3", name: "Page 3" }); + const page1 = new AACPage({ id: 'page1', name: 'Page 1' }); + const page2 = new AACPage({ id: 'page2', name: 'Page 2' }); + const page3 = new AACPage({ id: 'page3', name: 'Page 3' }); // Add navigation buttons const navButton = new AACButton({ - id: "nav1", - type: "NAVIGATE", - targetPageId: "page2", + id: 'nav1', + type: 'NAVIGATE', + targetPageId: 'page2', }); page1.addButton(navButton); const navButton2 = new AACButton({ - id: "nav2", - type: "NAVIGATE", - targetPageId: "page3", + id: 'nav2', + type: 'NAVIGATE', + targetPageId: 'page3', }); page2.addButton(navButton2); @@ -145,27 +145,27 @@ describe("AACTree", () => { visitedPages.push(page.id); }); - expect(visitedPages).toContain("page1"); - expect(visitedPages).toContain("page2"); - expect(visitedPages).toContain("page3"); + expect(visitedPages).toContain('page1'); + expect(visitedPages).toContain('page2'); + expect(visitedPages).toContain('page3'); expect(visitedPages).toHaveLength(3); }); - it("should handle circular navigation in traverse", async () => { + it('should handle circular navigation in traverse', async () => { const tree = new AACTree(); - const page1 = new AACPage({ id: "page1" }); - const page2 = new AACPage({ id: "page2" }); + const page1 = new AACPage({ id: 'page1' }); + const page2 = new AACPage({ id: 'page2' }); // Create circular navigation const nav1 = new AACButton({ - id: "nav1", - type: "NAVIGATE", - targetPageId: "page2", + id: 'nav1', + type: 'NAVIGATE', + targetPageId: 'page2', }); const nav2 = new AACButton({ - id: "nav2", - type: "NAVIGATE", - targetPageId: "page1", + id: 'nav2', + type: 'NAVIGATE', + targetPageId: 'page1', }); page1.addButton(nav1); @@ -181,7 +181,7 @@ describe("AACTree", () => { // Should visit each page only once despite circular references expect(visitedPages).toHaveLength(2); - expect(visitedPages).toContain("page1"); - expect(visitedPages).toContain("page2"); + expect(visitedPages).toContain('page1'); + expect(visitedPages).toContain('page2'); }); }); diff --git a/test/dotProcessor.export.test.js b/test/dotProcessor.export.test.js index 7820ffd..fb08942 100644 --- a/test/dotProcessor.export.test.js +++ b/test/dotProcessor.export.test.js @@ -1,20 +1,20 @@ // Test DotProcessor export/saveFromTree -const fs = require("fs"); -const path = require("path"); -const { DotProcessor } = require("../dist/processors/dotProcessor"); -describe("DotProcessor.saveFromTree", () => { - const dotPath = path.join(__dirname, "assets/dot/example.dot"); - const outPath = path.join(__dirname, "out.dot"); +const fs = require('fs'); +const path = require('path'); +const { DotProcessor } = require('../dist/processors/dotProcessor'); +describe('DotProcessor.saveFromTree', () => { + const dotPath = path.join(__dirname, 'assets/dot/example.dot'); + const outPath = path.join(__dirname, 'out.dot'); afterAll(() => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("exports tree to DOT format", async () => { + it('exports tree to DOT format', async () => { const processor = new DotProcessor(); const tree = await processor.loadIntoTree(dotPath); await processor.saveFromTree(tree, outPath); - const exported = fs.readFileSync(outPath, "utf8"); + const exported = fs.readFileSync(outPath, 'utf8'); expect(exported).toContain('digraph "AACBoard"'); - expect(exported).toContain("["); - expect(exported).toContain("->"); + expect(exported).toContain('['); + expect(exported).toContain('->'); }); }); diff --git a/test/dotProcessor.roundtrip.test.ts b/test/dotProcessor.roundtrip.test.ts index c489070..f6de0ba 100644 --- a/test/dotProcessor.roundtrip.test.ts +++ b/test/dotProcessor.roundtrip.test.ts @@ -1,21 +1,19 @@ -import fs from "fs"; -import path from "path"; -import { DotProcessor } from "../src/processors/dotProcessor"; -describe("DotProcessor round-trip", () => { - const dotPath = path.join(__dirname, "assets/dot/example.dot"); - const outPath = path.join(__dirname, "out.dot"); +import fs from 'fs'; +import path from 'path'; +import { DotProcessor } from '../src/processors/dotProcessor'; +describe('DotProcessor round-trip', () => { + const dotPath = path.join(__dirname, 'assets/dot/example.dot'); + const outPath = path.join(__dirname, 'out.dot'); afterAll(async () => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("round-trips DOT file without losing pages or navigation", async () => { + it('round-trips DOT file without losing pages or navigation', async () => { const processor = new DotProcessor(); const tree1 = await processor.loadIntoTree(dotPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); // Compare page IDs and navigation - expect(Object.keys(tree1.pages).sort()).toEqual( - Object.keys(tree2.pages).sort(), - ); + expect(Object.keys(tree1.pages).sort()).toEqual(Object.keys(tree2.pages).sort()); for (const pid in tree1.pages) { expect(tree2.pages).toHaveProperty(pid); const btnLabels1 = tree1.pages[pid].buttons.map((b) => b.label).sort(); diff --git a/test/dotProcessor.test.ts b/test/dotProcessor.test.ts index c1521a3..da063ff 100644 --- a/test/dotProcessor.test.ts +++ b/test/dotProcessor.test.ts @@ -1,12 +1,12 @@ // Unit test for DotProcessor -import path from "path"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { AACTree } from "../src/core/treeStructure"; +import path from 'path'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { AACTree } from '../src/core/treeStructure'; -describe("DotProcessor", () => { - const dotPath: string = path.join(__dirname, "assets/dot/example.dot"); +describe('DotProcessor', () => { + const dotPath: string = path.join(__dirname, 'assets/dot/example.dot'); - it("can process .dot files and build a navigation tree", async () => { + it('can process .dot files and build a navigation tree', async () => { const processor = new DotProcessor(); const tree: AACTree = await processor.loadIntoTree(dotPath); expect(tree).toBeInstanceOf(AACTree); @@ -25,42 +25,38 @@ describe("DotProcessor", () => { } expect(rootPage.buttons.length).toBeGreaterThan(0); // Should have navigation buttons - const navButtons = rootPage.buttons.filter((b) => b.type === "NAVIGATE"); + const navButtons = rootPage.buttons.filter((b) => b.type === 'NAVIGATE'); expect(navButtons.length).toBeGreaterThan(0); navButtons.forEach((btn) => { - expect(btn.type).toBe("NAVIGATE"); + expect(btn.type).toBe('NAVIGATE'); expect(btn.targetPageId).toBeTruthy(); }); }); - describe("Error Handling", () => { - it("should throw error for non-existent file", async () => { + describe('Error Handling', () => { + it('should throw error for non-existent file', async () => { const processor = new DotProcessor(); - await expect( - processor.loadIntoTree("/non/existent/file.dot"), - ).rejects.toThrow(); + await expect(processor.loadIntoTree('/non/existent/file.dot')).rejects.toThrow(); }); - it("should handle malformed dot content gracefully", async () => { + it('should handle malformed dot content gracefully', async () => { const processor = new DotProcessor(); - const malformedContent = Buffer.from("invalid dot content"); + const malformedContent = Buffer.from('invalid dot content'); const tree = await processor.loadIntoTree(malformedContent); expect(tree).toBeInstanceOf(AACTree); expect(Object.keys(tree.pages)).toHaveLength(0); }); - it("should handle empty file gracefully", async () => { + it('should handle empty file gracefully', async () => { const processor = new DotProcessor(); - const emptyContent = Buffer.from(""); + const emptyContent = Buffer.from(''); await expect(processor.loadIntoTree(emptyContent)).rejects.toThrow(); }); - it("should handle content with only comments", async () => { + it('should handle content with only comments', async () => { const processor = new DotProcessor(); - const commentContent = Buffer.from( - "// This is a comment\n// Another comment", - ); + const commentContent = Buffer.from('// This is a comment\n// Another comment'); const tree = await processor.loadIntoTree(commentContent); expect(tree).toBeInstanceOf(AACTree); expect(Object.keys(tree.pages)).toHaveLength(0); diff --git a/test/edgeCases.test.ts b/test/edgeCases.test.ts index 3031ba2..c1b05be 100644 --- a/test/edgeCases.test.ts +++ b/test/edgeCases.test.ts @@ -1,15 +1,15 @@ // Edge case tests for all processors -import fs from "fs"; -import path from "path"; -import os from "os"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { OpmlProcessor } from "../src/processors/opmlProcessor"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { AACTree } from "../src/core/treeStructure"; - -describe("Edge Case Tests", () => { - const tempDir = path.join(__dirname, "temp_edge_cases"); +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { AACTree } from '../src/core/treeStructure'; + +describe('Edge Case Tests', () => { + const tempDir = path.join(__dirname, 'temp_edge_cases'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -23,12 +23,12 @@ describe("Edge Case Tests", () => { } }); - describe("Empty and Minimal Content", () => { - it("should handle completely empty files", async () => { + describe('Empty and Minimal Content', () => { + it('should handle completely empty files', async () => { const processors = [ - { name: "DOT", processor: new DotProcessor(), testBuffer: true }, - { name: "OPML", processor: new OpmlProcessor(), testBuffer: true }, - { name: "OBF", processor: new ObfProcessor(), testBuffer: true }, + { name: 'DOT', processor: new DotProcessor(), testBuffer: true }, + { name: 'OPML', processor: new OpmlProcessor(), testBuffer: true }, + { name: 'OBF', processor: new ObfProcessor(), testBuffer: true }, ]; for (const { processor, testBuffer } of processors) { @@ -38,21 +38,21 @@ describe("Edge Case Tests", () => { } }); - it("should handle minimal valid content", async () => { + it('should handle minimal valid content', async () => { const testCases = [ { - name: "DOT", + name: 'DOT', processor: new DotProcessor(), - content: "digraph G { }", + content: 'digraph G { }', }, { - name: "OPML", + name: 'OPML', processor: new OpmlProcessor(), content: '', }, { - name: "OBF", + name: 'OBF', processor: new ObfProcessor(), content: '{"id": "test", "buttons": []}', }, @@ -61,61 +61,57 @@ describe("Edge Case Tests", () => { for (const { name, processor, content } of testCases) { const tree = await processor.loadIntoTree(Buffer.from(content)); expect(tree).toBeInstanceOf(AACTree); - console.log( - `${name} minimal content: ${Object.keys(tree.pages).length} pages`, - ); + console.log(`${name} minimal content: ${Object.keys(tree.pages).length} pages`); } }); - it("should handle single-element content", async () => { + it('should handle single-element content', async () => { const dotProcessor = new DotProcessor(); const singleNodeContent = 'digraph G { single [label="Only Node"]; }'; - const tree = await dotProcessor.loadIntoTree( - Buffer.from(singleNodeContent), - ); + const tree = await dotProcessor.loadIntoTree(Buffer.from(singleNodeContent)); expect(Object.keys(tree.pages)).toHaveLength(1); const page = Object.values(tree.pages)[0]; expect(page.buttons).toHaveLength(1); - expect(page.buttons[0].label).toBe("Only Node"); + expect(page.buttons[0].label).toBe('Only Node'); }); }); - describe("Unusual Characters and Encoding", () => { - it("should handle Unicode characters correctly", async () => { + describe('Unusual Characters and Encoding', () => { + it('should handle Unicode characters correctly', async () => { const unicodeTestCases = [ { - name: "Emoji", + name: 'Emoji', content: 'digraph G { emoji [label="😀🎉🌟"]; }', - expectedLabel: "😀🎉🌟", + expectedLabel: '😀🎉🌟', }, { - name: "Chinese", + name: 'Chinese', content: 'digraph G { chinese [label="你好世界"]; }', - expectedLabel: "你好世界", + expectedLabel: '你好世界', }, { - name: "Arabic", + name: 'Arabic', content: 'digraph G { arabic [label="مرحبا بالعالم"]; }', - expectedLabel: "مرحبا بالعالم", + expectedLabel: 'مرحبا بالعالم', }, { - name: "Accented", + name: 'Accented', content: 'digraph G { accented [label="Café, naïve, résumé"]; }', - expectedLabel: "Café, naïve, résumé", + expectedLabel: 'Café, naïve, résumé', }, { - name: "Mathematical", + name: 'Mathematical', content: 'digraph G { math [label="∑∞≠≤≥±"]; }', - expectedLabel: "∑∞≠≤≥±", + expectedLabel: '∑∞≠≤≥±', }, ]; const processor = new DotProcessor(); for (const { name, content, expectedLabel } of unicodeTestCases) { - const tree = await processor.loadIntoTree(Buffer.from(content, "utf8")); + const tree = await processor.loadIntoTree(Buffer.from(content, 'utf8')); const page = Object.values(tree.pages)[0]; expect(page.buttons).toHaveLength(1); @@ -124,7 +120,7 @@ describe("Edge Case Tests", () => { } }); - it("should handle special characters in file paths and content", async () => { + it('should handle special characters in file paths and content', async () => { const processor = new DotProcessor(); const specialContent = ` digraph G { @@ -140,18 +136,16 @@ describe("Edge Case Tests", () => { const tree = await processor.loadIntoTree(Buffer.from(specialContent)); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); - const allButtons = Object.values(tree.pages).flatMap( - (page) => page.buttons, - ); + const allButtons = Object.values(tree.pages).flatMap((page) => page.buttons); expect(allButtons.length).toBe(6); const labels = allButtons.map((btn) => btn.label); - expect(labels).toContain("Label with spaces"); - expect(labels).toContain("Label-with-dashes"); - expect(labels).toContain("Label@with@symbols"); + expect(labels).toContain('Label with spaces'); + expect(labels).toContain('Label-with-dashes'); + expect(labels).toContain('Label@with@symbols'); }); - it("should handle escaped characters correctly", async () => { + it('should handle escaped characters correctly', async () => { const processor = new DotProcessor(); const escapedContent = ` digraph G { @@ -162,25 +156,21 @@ describe("Edge Case Tests", () => { `; const tree = await processor.loadIntoTree(Buffer.from(escapedContent)); - const allButtons = Object.values(tree.pages).flatMap( - (page) => page.buttons, - ); + const allButtons = Object.values(tree.pages).flatMap((page) => page.buttons); expect(allButtons.length).toBe(3); - const escapedButton = allButtons.find((btn) => - btn.label.includes("Line 1"), - ); + const escapedButton = allButtons.find((btn) => btn.label.includes('Line 1')); expect(escapedButton).toBeDefined(); }); }); - describe("Boundary Conditions", () => { - it("should handle maximum reasonable content sizes", async () => { + describe('Boundary Conditions', () => { + it('should handle maximum reasonable content sizes', async () => { const processor = new DotProcessor(); // Test very long labels - const longLabel = "A".repeat(1000); + const longLabel = 'A'.repeat(1000); const longLabelContent = `digraph G { long [label="${longLabel}"]; }`; const tree = await processor.loadIntoTree(Buffer.from(longLabelContent)); @@ -188,30 +178,28 @@ describe("Edge Case Tests", () => { expect(page.buttons[0].label).toBe(longLabel); // Test many nodes - const manyNodesLines = ["digraph G {"]; + const manyNodesLines = ['digraph G {']; for (let i = 0; i < 100; i++) { manyNodesLines.push(` node${i} [label="Node ${i}"];`); } - manyNodesLines.push("}"); + manyNodesLines.push('}'); - const manyNodesContent = manyNodesLines.join("\n"); - const manyNodesTree = await processor.loadIntoTree( - Buffer.from(manyNodesContent), - ); + const manyNodesContent = manyNodesLines.join('\n'); + const manyNodesTree = await processor.loadIntoTree(Buffer.from(manyNodesContent)); const totalButtons = Object.values(manyNodesTree.pages).reduce( (sum, page) => sum + page.buttons.length, - 0, + 0 ); expect(totalButtons).toBe(100); }); - it("should handle deeply nested structures", async () => { + it('should handle deeply nested structures', async () => { const processor = new OpmlProcessor(); // Create deeply nested OPML let nestedContent = ''; - let currentLevel = ""; + let currentLevel = ''; for (let i = 0; i < 10; i++) { currentLevel += ''; @@ -220,16 +208,16 @@ describe("Edge Case Tests", () => { nestedContent += currentLevel; for (let i = 9; i >= 0; i--) { - nestedContent += ""; + nestedContent += ''; } - nestedContent += ""; + nestedContent += '
'; const tree = await processor.loadIntoTree(Buffer.from(nestedContent)); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it("should handle circular references gracefully", async () => { + it('should handle circular references gracefully', async () => { const processor = new DotProcessor(); const circularContent = ` digraph G { @@ -256,8 +244,8 @@ describe("Edge Case Tests", () => { }); }); - describe("Corrupted and Malformed Content", () => { - it("should handle partially corrupted JSON", async () => { + describe('Corrupted and Malformed Content', () => { + it('should handle partially corrupted JSON', async () => { const processor = new ObfProcessor(); const corruptedJsonCases = [ @@ -269,14 +257,12 @@ describe("Edge Case Tests", () => { ]; for (const [index, corruptedJson] of corruptedJsonCases.entries()) { - await expect( - processor.loadIntoTree(Buffer.from(corruptedJson)), - ).rejects.toThrow(); + await expect(processor.loadIntoTree(Buffer.from(corruptedJson))).rejects.toThrow(); console.log(`Corrupted JSON case ${index + 1} handled correctly`); } }); - it("should handle malformed XML", async () => { + it('should handle malformed XML', async () => { const processor = new OpmlProcessor(); const malformedXmlCases = [ @@ -296,20 +282,18 @@ describe("Edge Case Tests", () => { } }); - it("should handle binary data as text input", async () => { + it('should handle binary data as text input', async () => { const processor = new DotProcessor(); // Create some binary data - const binaryData = Buffer.from([ - 0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd, - ]); + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd]); await expect(processor.loadIntoTree(binaryData)).rejects.toThrow(); }); }); - describe("Resource Limits and Cleanup", () => { - it("should clean up temporary files on errors", async () => { + describe('Resource Limits and Cleanup', () => { + it('should clean up temporary files on errors', async () => { const processor = new SnapProcessor(); const tempFilesBefore = fs.readdirSync(os.tmpdir()).length; @@ -327,10 +311,10 @@ describe("Edge Case Tests", () => { }, 100); }); - it("should handle concurrent access to same file", async () => { + it('should handle concurrent access to same file', async () => { const processor = new DotProcessor(); const testContent = 'digraph G { test [label="Concurrent Test"]; }'; - const testFile = path.join(tempDir, "concurrent_test.dot"); + const testFile = path.join(tempDir, 'concurrent_test.dot'); fs.writeFileSync(testFile, testContent); @@ -350,14 +334,14 @@ describe("Edge Case Tests", () => { }); }); - it("should handle very long file paths", async () => { + it('should handle very long file paths', async () => { const processor = new DotProcessor(); // Create a very long but valid path - const longDir = path.join(tempDir, "a".repeat(100), "b".repeat(100)); + const longDir = path.join(tempDir, 'a'.repeat(100), 'b'.repeat(100)); fs.mkdirSync(longDir, { recursive: true }); - const longFilePath = path.join(longDir, "test.dot"); + const longFilePath = path.join(longDir, 'test.dot'); const testContent = 'digraph G { test [label="Long Path Test"]; }'; fs.writeFileSync(longFilePath, testContent); @@ -368,54 +352,44 @@ describe("Edge Case Tests", () => { }); }); - describe("Translation Edge Cases", () => { - it("should handle empty translation maps", async () => { + describe('Translation Edge Cases', () => { + it('should handle empty translation maps', async () => { const processor = new DotProcessor(); const content = 'digraph G { test [label="Test"]; }'; - const outputPath = path.join(tempDir, "empty_translation.dot"); + const outputPath = path.join(tempDir, 'empty_translation.dot'); const emptyTranslations = new Map(); await expect( - processor.processTexts( - Buffer.from(content), - emptyTranslations, - outputPath, - ), + processor.processTexts(Buffer.from(content), emptyTranslations, outputPath) ).resolves.not.toThrow(); expect(fs.existsSync(outputPath)).toBe(true); }); - it("should handle translations with special regex characters", async () => { + it('should handle translations with special regex characters', async () => { const processor = new DotProcessor(); const content = 'digraph G { test [label="$pecial [chars] (here)"]; }'; - const outputPath = path.join(tempDir, "special_chars_translation.dot"); + const outputPath = path.join(tempDir, 'special_chars_translation.dot'); - const translations = new Map([ - ["$pecial [chars] (here)", "Caracteres especiales aquí"], - ]); + const translations = new Map([['$pecial [chars] (here)', 'Caracteres especiales aquí']]); - const result = await processor.processTexts( - Buffer.from(content), - translations, - outputPath, - ); - const translatedContent = Buffer.from(result).toString("utf8"); + const result = await processor.processTexts(Buffer.from(content), translations, outputPath); + const translatedContent = Buffer.from(result).toString('utf8'); - expect(translatedContent).toContain("Caracteres especiales aquí"); + expect(translatedContent).toContain('Caracteres especiales aquí'); }); - it("should handle very large translation maps", async () => { + it('should handle very large translation maps', async () => { const processor = new DotProcessor(); // Create content with many translatable items - const lines = ["digraph G {"]; + const lines = ['digraph G {']; for (let i = 0; i < 100; i++) { lines.push(` node${i} [label="Text ${i}"];`); } - lines.push("}"); - const content = lines.join("\n"); + lines.push('}'); + const content = lines.join('\n'); // Create large translation map const translations = new Map(); @@ -423,19 +397,15 @@ describe("Edge Case Tests", () => { translations.set(`Text ${i}`, `Texto ${i}`); } - const outputPath = path.join(tempDir, "large_translation.dot"); - const result = await processor.processTexts( - Buffer.from(content), - translations, - outputPath, - ); + const outputPath = path.join(tempDir, 'large_translation.dot'); + const result = await processor.processTexts(Buffer.from(content), translations, outputPath); expect(Buffer.from(result)).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); - const translatedContent = Buffer.from(result).toString("utf8"); - expect(translatedContent).toContain("Texto 0"); - expect(translatedContent).toContain("Texto 99"); + const translatedContent = Buffer.from(result).toString('utf8'); + expect(translatedContent).toContain('Texto 0'); + expect(translatedContent).toContain('Texto 99'); }); }); }); diff --git a/test/errorHandling.test.ts b/test/errorHandling.test.ts index 72706fa..c94ed53 100644 --- a/test/errorHandling.test.ts +++ b/test/errorHandling.test.ts @@ -1,16 +1,16 @@ // Comprehensive error handling tests for all processors -import fs from "fs"; -import path from "path"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { OpmlProcessor } from "../src/processors/opmlProcessor"; -import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; - -describe("Error Handling", () => { - const tempDir = path.join(__dirname, "temp_error"); +import fs from 'fs'; +import path from 'path'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; + +describe('Error Handling', () => { + const tempDir = path.join(__dirname, 'temp_error'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -24,8 +24,8 @@ describe("Error Handling", () => { } }); - describe("File I/O Error Handling", () => { - it("should handle non-existent files gracefully", async () => { + describe('File I/O Error Handling', () => { + it('should handle non-existent files gracefully', async () => { const processors = [ new SnapProcessor(), new TouchChatProcessor(), @@ -35,16 +35,14 @@ describe("Error Handling", () => { ]; for (const processor of processors) { - await expect( - processor.loadIntoTree("/non/existent/file.ext"), - ).rejects.toThrow(); + await expect(processor.loadIntoTree('/non/existent/file.ext')).rejects.toThrow(); } }); - it("should handle permission denied errors", async () => { + it('should handle permission denied errors', async () => { // Create a file with no read permissions (if possible on this system) - const restrictedFile = path.join(tempDir, "restricted.txt"); - fs.writeFileSync(restrictedFile, "test content"); + const restrictedFile = path.join(tempDir, 'restricted.txt'); + fs.writeFileSync(restrictedFile, 'test content'); try { fs.chmodSync(restrictedFile, 0o000); // No permissions @@ -53,7 +51,7 @@ describe("Error Handling", () => { await expect(processor.loadIntoTree(restrictedFile)).rejects.toThrow(); } catch (_e) { // chmod might not work on all systems, skip this test - console.log("Skipping permission test - chmod not supported"); + console.log('Skipping permission test - chmod not supported'); } finally { try { fs.chmodSync(restrictedFile, 0o644); // Restore permissions for cleanup @@ -65,38 +63,38 @@ describe("Error Handling", () => { }); }); - describe("Malformed Content Error Handling", () => { - it("should handle invalid JSON in OBF files", async () => { + describe('Malformed Content Error Handling', () => { + it('should handle invalid JSON in OBF files', async () => { const processor = new ObfProcessor(); - const invalidJson = Buffer.from("{ invalid json content }"); + const invalidJson = Buffer.from('{ invalid json content }'); await expect(processor.loadIntoTree(invalidJson)).rejects.toThrow(); }); - it("should handle invalid XML in OPML files", async () => { + it('should handle invalid XML in OPML files', async () => { const processor = new OpmlProcessor(); - const invalidXml = Buffer.from("xml"); + const invalidXml = Buffer.from('xml'); await expect(processor.loadIntoTree(invalidXml)).rejects.toThrow(); }); - it("should handle invalid XML in GridSet files", async () => { + it('should handle invalid XML in GridSet files', async () => { const processor = new GridsetProcessor(); - const invalidZip = Buffer.from("not a zip file"); + const invalidZip = Buffer.from('not a zip file'); await expect(processor.loadIntoTree(invalidZip)).rejects.toThrow(); }); - it("should handle corrupted SQLite databases", async () => { + it('should handle corrupted SQLite databases', async () => { const processor = new SnapProcessor(); - const corruptedDb = Buffer.from("SQLite format 3\x00but corrupted data"); + const corruptedDb = Buffer.from('SQLite format 3\x00but corrupted data'); await expect(processor.loadIntoTree(corruptedDb)).rejects.toThrow(); }); }); - describe("Empty Content Error Handling", () => { - it("should handle empty files gracefully", async () => { + describe('Empty Content Error Handling', () => { + it('should handle empty files gracefully', async () => { const emptyBuffer = Buffer.alloc(0); // Processors should throw meaningful errors @@ -107,92 +105,86 @@ describe("Error Handling", () => { await expect(snapProcessor.loadIntoTree(emptyBuffer)).rejects.toThrow(); }); - it("should handle files with only whitespace", async () => { - const whitespaceBuffer = Buffer.from(" \n\t \n "); + it('should handle files with only whitespace', async () => { + const whitespaceBuffer = Buffer.from(' \n\t \n '); const dotProcessor = new DotProcessor(); - await expect( - dotProcessor.loadIntoTree(whitespaceBuffer), - ).rejects.toThrow(); + await expect(dotProcessor.loadIntoTree(whitespaceBuffer)).rejects.toThrow(); }); }); - describe("Memory and Resource Error Handling", () => { - it("should handle very large files gracefully", async () => { + describe('Memory and Resource Error Handling', () => { + it('should handle very large files gracefully', async () => { // Create a large but valid DOT file const largeDotContent = - "digraph G {\n" + + 'digraph G {\n' + Array(1000) .fill(0) .map((_, i) => ` node${i} [label="Node ${i}"];`) - .join("\n") + - "\n}"; + .join('\n') + + '\n}'; const processor = new DotProcessor(); const result = await processor.loadIntoTree(Buffer.from(largeDotContent)); expect(Object.keys(result.pages).length).toBeGreaterThan(0); }); - it("should clean up temporary files on error", async () => { + it('should clean up temporary files on error', async () => { const processor = new SnapProcessor(); - const invalidData = Buffer.from("invalid sqlite data"); + const invalidData = Buffer.from('invalid sqlite data'); // eslint-disable-next-line @typescript-eslint/no-var-requires - const tempFilesBefore = fs.readdirSync(require("os").tmpdir()).length; + const tempFilesBefore = fs.readdirSync(require('os').tmpdir()).length; await expect(processor.loadIntoTree(invalidData)).rejects.toThrow(); // Give some time for cleanup setTimeout(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires - const tempFilesAfter = fs.readdirSync(require("os").tmpdir()).length; + const tempFilesAfter = fs.readdirSync(require('os').tmpdir()).length; const allowedDelta = 5; // Allow a handful of transient temp files created by other processes - expect(tempFilesAfter).toBeLessThanOrEqual( - tempFilesBefore + allowedDelta, - ); + expect(tempFilesAfter).toBeLessThanOrEqual(tempFilesBefore + allowedDelta); }, 100); }); }); - describe("Translation Error Handling", () => { - it("should handle invalid translation maps", async () => { + describe('Translation Error Handling', () => { + it('should handle invalid translation maps', async () => { const processor = new DotProcessor(); const validContent = Buffer.from('digraph G { node1 [label="test"]; }'); - const outputPath = path.join(tempDir, "output.dot"); + const outputPath = path.join(tempDir, 'output.dot'); // Test with null/undefined values in translation map const invalidTranslations = new Map([ - ["test", null as any], - [undefined as any, "replacement"], - ["valid", "válido"], + ['test', null as any], + [undefined as any, 'replacement'], + ['valid', 'válido'], ]); await expect( - processor.processTexts(validContent, invalidTranslations, outputPath), + processor.processTexts(validContent, invalidTranslations, outputPath) ).resolves.not.toThrow(); }); - it("should handle circular references in translation maps", async () => { + it('should handle circular references in translation maps', async () => { const processor = new DotProcessor(); - const validContent = Buffer.from( - 'digraph G { node1 [label="A"]; node2 [label="B"]; }', - ); - const outputPath = path.join(tempDir, "circular.dot"); + const validContent = Buffer.from('digraph G { node1 [label="A"]; node2 [label="B"]; }'); + const outputPath = path.join(tempDir, 'circular.dot'); const circularTranslations = new Map([ - ["A", "B"], - ["B", "A"], + ['A', 'B'], + ['B', 'A'], ]); await expect( - processor.processTexts(validContent, circularTranslations, outputPath), + processor.processTexts(validContent, circularTranslations, outputPath) ).resolves.not.toThrow(); }); }); - describe("Save Operation Error Handling", () => { - it("should handle read-only output directories", async () => { - const readOnlyDir = path.join(tempDir, "readonly"); + describe('Save Operation Error Handling', () => { + it('should handle read-only output directories', async () => { + const readOnlyDir = path.join(tempDir, 'readonly'); fs.mkdirSync(readOnlyDir, { recursive: true }); try { @@ -200,16 +192,14 @@ describe("Error Handling", () => { const processor = new DotProcessor(); const tree = await processor.loadIntoTree( - Buffer.from('digraph G { node1 [label="test"]; }'), + Buffer.from('digraph G { node1 [label="test"]; }') ); - const outputPath = path.join(readOnlyDir, "output.dot"); + const outputPath = path.join(readOnlyDir, 'output.dot'); - await expect( - processor.saveFromTree(tree, outputPath), - ).rejects.toThrow(); + await expect(processor.saveFromTree(tree, outputPath)).rejects.toThrow(); } catch (_e) { // chmod might not work on all systems - console.log("Skipping read-only directory test - chmod not supported"); + console.log('Skipping read-only directory test - chmod not supported'); } finally { try { fs.chmodSync(readOnlyDir, 0o755); // Restore permissions @@ -220,20 +210,15 @@ describe("Error Handling", () => { } }); - it("should handle disk space errors gracefully", async () => { + it('should handle disk space errors gracefully', async () => { // This is hard to test reliably, but we can at least ensure // the error handling code paths exist const processor = new DotProcessor(); - const tree = await processor.loadIntoTree( - Buffer.from('digraph G { node1 [label="test"]; }'), - ); + const tree = await processor.loadIntoTree(Buffer.from('digraph G { node1 [label="test"]; }')); // Try to save to an invalid path await expect( - processor.saveFromTree( - tree, - "/invalid/path/that/does/not/exist/output.dot", - ), + processor.saveFromTree(tree, '/invalid/path/that/does/not/exist/output.dot') ).rejects.toThrow(); }); }); diff --git a/test/grid3VerbsParser.test.ts b/test/grid3VerbsParser.test.ts index 635a2aa..b4d9d72 100644 --- a/test/grid3VerbsParser.test.ts +++ b/test/grid3VerbsParser.test.ts @@ -1,24 +1,22 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { Grid3VerbsParser } from "../src/utilities/analytics/morphology/grid3VerbsParser"; -import { join } from "path"; +import { Grid3VerbsParser } from '../src/utilities/analytics/morphology/grid3VerbsParser'; +import { join } from 'path'; -const SYNTHETIC_XML = join(__dirname, "assets", "grid3", "synthetic-verbs.xml"); +const SYNTHETIC_XML = join(__dirname, 'assets', 'grid3', 'synthetic-verbs.xml'); // Users can set GRID3_MORPHOLOGY_DIR to point to their own copy of // Grid 3's Locale directory (e.g. copied from another machine). // Example: GRID3_MORPHOLOGY_DIR=/path/to/Grid3/Locale npm test const MORPHOLOGY_DIR = process.env.GRID3_MORPHOLOGY_DIR || - (process.platform === "win32" - ? "C:\\Program Files (x86)\\Smartbox\\Grid 3\\Locale" - : ""); + (process.platform === 'win32' ? 'C:\\Program Files (x86)\\Smartbox\\Grid 3\\Locale' : ''); const parser = new Grid3VerbsParser(); function fileExists(p: string): boolean { try { // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require("fs"); + const fs = require('fs'); fs.accessSync(p, fs.constants.R_OK); return true; } catch { @@ -27,140 +25,137 @@ function fileExists(p: string): boolean { } function getMorphZip(locale: string): string { - return join(MORPHOLOGY_DIR, locale, "verbs", "verbs.zip"); + return join(MORPHOLOGY_DIR, locale, 'verbs', 'verbs.zip'); } // eslint-disable-next-line @typescript-eslint/no-var-requires -const fs = require("fs"); +const fs = require('fs'); -describe("Grid3VerbsParser - Synthetic XML (always runs)", () => { - test("synthetic fixture exists", () => { +describe('Grid3VerbsParser - Synthetic XML (always runs)', () => { + test('synthetic fixture exists', () => { expect(fileExists(SYNTHETIC_XML)).toBe(true); }); - describe("parseXml (flat forms)", () => { + describe('parseXml (flat forms)', () => { let forms: Map; let locale: string; beforeAll(() => { - const xml = fs.readFileSync(SYNTHETIC_XML, "utf-8"); + const xml = fs.readFileSync(SYNTHETIC_XML, 'utf-8'); const result = parser.parseXml(xml); locale = result.locale; forms = result.verbs; }); - test("detects locale test-XX", () => { - expect(locale).toBe("test-XX"); + test('detects locale test-XX', () => { + expect(locale).toBe('test-XX'); }); - test("parses 3 verbs", () => { + test('parses 3 verbs', () => { expect(forms.size).toBe(3); }); - test("regular verb walk -> walks, walked, walking", () => { - const walkForms = forms.get("walk"); + test('regular verb walk -> walks, walked, walking', () => { + const walkForms = forms.get('walk'); expect(walkForms).toBeDefined(); - expect(walkForms).toContain("walks"); - expect(walkForms).toContain("walked"); - expect(walkForms).toContain("walking"); + expect(walkForms).toContain('walks'); + expect(walkForms).toContain('walked'); + expect(walkForms).toContain('walking'); }); - test("irregular verb go -> goes, went, going, gone", () => { - const goForms = forms.get("go"); + test('irregular verb go -> goes, went, going, gone', () => { + const goForms = forms.get('go'); expect(goForms).toBeDefined(); - expect(goForms).toContain("goes"); - expect(goForms).toContain("went"); - expect(goForms).toContain("going"); - expect(goForms).toContain("gone"); + expect(goForms).toContain('goes'); + expect(goForms).toContain('went'); + expect(goForms).toContain('going'); + expect(goForms).toContain('gone'); }); - test("default-rule verb jump -> jumps, jumped, jumping", () => { - const jumpForms = forms.get("jump"); + test('default-rule verb jump -> jumps, jumped, jumping', () => { + const jumpForms = forms.get('jump'); expect(jumpForms).toBeDefined(); - expect(jumpForms).toContain("jumps"); - expect(jumpForms).toContain("jumped"); - expect(jumpForms).toContain("jumping"); + expect(jumpForms).toContain('jumps'); + expect(jumpForms).toContain('jumped'); + expect(jumpForms).toContain('jumping'); }); - test("no compound forms", () => { + test('no compound forms', () => { for (const [, wordForms] of forms) { for (const f of wordForms) { - expect(f).not.toContain(" "); + expect(f).not.toContain(' '); } } }); }); - describe("parseXmlDetailed (forms with conditions)", () => { - let detailed: Map< - string, - Array<{ value: string; conditions: Map }> - >; + describe('parseXmlDetailed (forms with conditions)', () => { + let detailed: Map }>>; beforeAll(() => { const result = parser.parseXmlFileDetailed(SYNTHETIC_XML); detailed = result.verbs; }); - test("parses 3 verbs with conditions", () => { + test('parses 3 verbs with conditions', () => { expect(detailed.size).toBe(3); }); test('walk has "walks" with person=third, time=present', () => { - const walkForms = detailed.get("walk"); + const walkForms = detailed.get('walk'); expect(walkForms).toBeDefined(); - const walksForm = walkForms!.find((f) => f.value === "walks"); + const walksForm = walkForms!.find((f) => f.value === 'walks'); expect(walksForm).toBeDefined(); - expect(walksForm!.conditions.get("person")).toBe("third"); - expect(walksForm!.conditions.get("time")).toBe("present"); + expect(walksForm!.conditions.get('person')).toBe('third'); + expect(walksForm!.conditions.get('time')).toBe('present'); }); test('walk has "walked" with time=past', () => { - const walkForms = detailed.get("walk"); - const walkedForm = walkForms!.find((f) => f.value === "walked"); + const walkForms = detailed.get('walk'); + const walkedForm = walkForms!.find((f) => f.value === 'walked'); expect(walkedForm).toBeDefined(); - expect(walkedForm!.conditions.get("time")).toBe("past"); + expect(walkedForm!.conditions.get('time')).toBe('past'); }); test('go has "went" with time=past', () => { - const goForms = detailed.get("go"); - const wentForm = goForms!.find((f) => f.value === "went"); + const goForms = detailed.get('go'); + const wentForm = goForms!.find((f) => f.value === 'went'); expect(wentForm).toBeDefined(); - expect(wentForm!.conditions.get("time")).toBe("past"); + expect(wentForm!.conditions.get('time')).toBe('past'); }); test('go has "gone" with participleType=pastparticiple', () => { - const goForms = detailed.get("go"); - const goneForm = goForms!.find((f) => f.value === "gone"); + const goForms = detailed.get('go'); + const goneForm = goForms!.find((f) => f.value === 'gone'); expect(goneForm).toBeDefined(); - expect(goneForm!.conditions.get("participleType")).toBe("pastparticiple"); + expect(goneForm!.conditions.get('participleType')).toBe('pastparticiple'); }); test('go has "goes" with person=third', () => { - const goForms = detailed.get("go"); - const goesForm = goForms!.find((f) => f.value === "goes"); + const goForms = detailed.get('go'); + const goesForm = goForms!.find((f) => f.value === 'goes'); expect(goesForm).toBeDefined(); - expect(goesForm!.conditions.get("person")).toBe("third"); + expect(goesForm!.conditions.get('person')).toBe('third'); }); }); - describe("parseZip with synthetic data", () => { - test("can parse a zip file containing verbs.xml", () => { + describe('parseZip with synthetic data', () => { + test('can parse a zip file containing verbs.xml', () => { // Create a temporary zip with our synthetic XML // eslint-disable-next-line @typescript-eslint/no-var-requires - const AdmZip = require("adm-zip"); - const tmpDir = join(__dirname, "assets", "grid3", "_tmp"); - const zipPath = join(tmpDir, "verbs.zip"); + const AdmZip = require('adm-zip'); + const tmpDir = join(__dirname, 'assets', 'grid3', '_tmp'); + const zipPath = join(tmpDir, 'verbs.zip'); try { fs.mkdirSync(tmpDir, { recursive: true }); const zip = new AdmZip(); - zip.addFile("verbs.xml", fs.readFileSync(SYNTHETIC_XML)); + zip.addFile('verbs.xml', fs.readFileSync(SYNTHETIC_XML)); zip.writeZip(zipPath); const result = parser.parseZip(zipPath); expect(result.verbs.size).toBe(3); - expect(result.verbs.get("go")).toContain("went"); + expect(result.verbs.get('go')).toContain('went'); } finally { try { fs.unlinkSync(zipPath); @@ -173,42 +168,42 @@ describe("Grid3VerbsParser - Synthetic XML (always runs)", () => { }); }); -describe("Grid3VerbsParser - External morphology data", () => { +describe('Grid3VerbsParser - External morphology data', () => { // These tests run when GRID3_MORPHOLOGY_DIR points to a valid // Grid 3 Locale directory (or a directory someone has copied // the verbs.zip files into). On CI without Grid 3, these skip. - test("parses en-GB verbs.zip", () => { - const zipPath = getMorphZip("en-GB"); + test('parses en-GB verbs.zip', () => { + const zipPath = getMorphZip('en-GB'); if (!fileExists(zipPath)) return; const result = parser.parseZip(zipPath); expect(result.verbs.size).toBeGreaterThan(100); }); - test("en-GB go -> goes, going, gone, went", () => { - const zipPath = getMorphZip("en-GB"); + test('en-GB go -> goes, going, gone, went', () => { + const zipPath = getMorphZip('en-GB'); if (!fileExists(zipPath)) return; const result = parser.parseZip(zipPath); - const goForms = result.verbs.get("go"); + const goForms = result.verbs.get('go'); expect(goForms).toBeDefined(); - expect(goForms).toContain("goes"); - expect(goForms).toContain("going"); - expect(goForms).toContain("gone"); - expect(goForms).toContain("went"); + expect(goForms).toContain('goes'); + expect(goForms).toContain('going'); + expect(goForms).toContain('gone'); + expect(goForms).toContain('went'); }); - test("en-GB detailed has conditions", () => { - const zipPath = getMorphZip("en-GB"); + test('en-GB detailed has conditions', () => { + const zipPath = getMorphZip('en-GB'); if (!fileExists(zipPath)) return; const result = parser.parseZipDetailed(zipPath); - const goForms = result.verbs.get("go"); + const goForms = result.verbs.get('go'); expect(goForms).toBeDefined(); expect(goForms!.length).toBeGreaterThan(0); - const went = goForms!.find((f) => f.value === "went"); + const went = goForms!.find((f) => f.value === 'went'); expect(went).toBeDefined(); }); - test("parses nb-NO verbs.zip", () => { - const zipPath = getMorphZip("nb-NO"); + test('parses nb-NO verbs.zip', () => { + const zipPath = getMorphZip('nb-NO'); if (!fileExists(zipPath)) return; const result = parser.parseZip(zipPath); expect(result.verbs.size).toBeGreaterThan(100); diff --git a/test/gridsetHelpers.misc.test.ts b/test/gridsetHelpers.misc.test.ts index a85e364..9b571d7 100644 --- a/test/gridsetHelpers.misc.test.ts +++ b/test/gridsetHelpers.misc.test.ts @@ -1,37 +1,35 @@ -import { describe, expect, it } from "@jest/globals"; +import { describe, expect, it } from '@jest/globals'; import { createFileMapXml, createSettingsXml, generateGrid3Guid, -} from "../src/processors/gridset/helpers"; +} from '../src/processors/gridset/helpers'; -describe("Gridset helper misc utilities", () => { - it("generates a GUID-like value", async () => { +describe('Gridset helper misc utilities', () => { + it('generates a GUID-like value', async () => { const guid = generateGrid3Guid(); - expect(guid).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); + expect(guid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); }); - it("builds settings XML with overrides", async () => { - const xml = createSettingsXml("Home", { + it('builds settings XML with overrides', async () => { + const xml = createSettingsXml('Home', { scanEnabled: true, hoverTimeoutMs: 1500, - language: "en-GB", + language: 'en-GB', }); - expect(xml).toContain("Home"); - expect(xml).toContain("true"); - expect(xml).toContain("1500"); - expect(xml).toContain("en-GB"); + expect(xml).toContain('Home'); + expect(xml).toContain('true'); + expect(xml).toContain('1500'); + expect(xml).toContain('en-GB'); }); - it("builds file map XML for multiple grids", async () => { + it('builds file map XML for multiple grids', async () => { const xml = createFileMapXml([ - { name: "Main", path: "main.gridset" }, - { name: "Alt", path: "alt.gridset", dynamicFiles: ["dyn1"] }, + { name: 'Main', path: 'main.gridset' }, + { name: 'Alt', path: 'alt.gridset', dynamicFiles: ['dyn1'] }, ]); - expect(xml).toContain("main.gridset"); - expect(xml).toContain("alt.gridset"); - expect(xml).toContain(""); + expect(xml).toContain('main.gridset'); + expect(xml).toContain('alt.gridset'); + expect(xml).toContain(''); }); }); diff --git a/test/gridsetHelpers.test.ts b/test/gridsetHelpers.test.ts index d497107..372f38c 100644 --- a/test/gridsetHelpers.test.ts +++ b/test/gridsetHelpers.test.ts @@ -1,12 +1,12 @@ -import AdmZip from "adm-zip"; -import { AACTree, AACPage, AACButton, Gridset } from "../src/index"; +import AdmZip from 'adm-zip'; +import { AACTree, AACPage, AACButton, Gridset } from '../src/index'; -describe("Gridset helper APIs", () => { - it("getPageTokenImageMap returns button.id to resolvedImageEntry map for a page", async () => { +describe('Gridset helper APIs', () => { + it('getPageTokenImageMap returns button.id to resolvedImageEntry map for a page', async () => { const tree = new AACTree(); const page = new AACPage({ - id: "p1", - name: "Page 1", + id: 'p1', + name: 'Page 1', grid: { columns: 2, rows: 2 }, buttons: [], }); @@ -14,38 +14,38 @@ describe("Gridset helper APIs", () => { page.addButton( new AACButton({ - id: "b1", - label: "A", - message: "A", - resolvedImageEntry: "Grids/Home/Images/a.png", - }), + id: 'b1', + label: 'A', + message: 'A', + resolvedImageEntry: 'Grids/Home/Images/a.png', + }) ); page.addButton( new AACButton({ - id: "b2", - label: "B", - message: "B", - resolvedImageEntry: "Grids/Home/1-1.jpeg", - }), + id: 'b2', + label: 'B', + message: 'B', + resolvedImageEntry: 'Grids/Home/1-1.jpeg', + }) ); - const map = Gridset.getPageTokenImageMap(tree, "p1"); - expect(map.get("b1")).toBe("Grids/Home/Images/a.png"); - expect(map.get("b2")).toBe("Grids/Home/1-1.jpeg"); + const map = Gridset.getPageTokenImageMap(tree, 'p1'); + expect(map.get('b1')).toBe('Grids/Home/Images/a.png'); + expect(map.get('b2')).toBe('Grids/Home/1-1.jpeg'); expect(map.size).toBe(2); }); - it("getAllowedImageEntries aggregates unique image entries across pages", async () => { + it('getAllowedImageEntries aggregates unique image entries across pages', async () => { const tree = new AACTree(); const p1 = new AACPage({ - id: "p1", - name: "P1", + id: 'p1', + name: 'P1', grid: { columns: 1, rows: 1 }, buttons: [], }); const p2 = new AACPage({ - id: "p2", - name: "P2", + id: 'p2', + name: 'P2', grid: { columns: 1, rows: 1 }, buttons: [], }); @@ -54,58 +54,57 @@ describe("Gridset helper APIs", () => { p1.addButton( new AACButton({ - id: "b1", - label: "A", - message: "A", - resolvedImageEntry: "X/Y/a.png", - }), + id: 'b1', + label: 'A', + message: 'A', + resolvedImageEntry: 'X/Y/a.png', + }) ); p1.addButton( new AACButton({ - id: "b2", - label: "B", - message: "B", - resolvedImageEntry: "X/Y/a.png", - }), + id: 'b2', + label: 'B', + message: 'B', + resolvedImageEntry: 'X/Y/a.png', + }) ); p2.addButton( new AACButton({ - id: "b3", - label: "C", - message: "C", - resolvedImageEntry: "X/Z/c.png", - }), + id: 'b3', + label: 'C', + message: 'C', + resolvedImageEntry: 'X/Z/c.png', + }) ); const set = Gridset.getAllowedImageEntries(tree); - expect(set.has("X/Y/a.png")).toBe(true); - expect(set.has("X/Z/c.png")).toBe(true); + expect(set.has('X/Y/a.png')).toBe(true); + expect(set.has('X/Z/c.png')).toBe(true); expect(set.size).toBe(2); }); - it("openImage reads a specific entry from a gridset buffer", async () => { + it('openImage reads a specific entry from a gridset buffer', async () => { const zip = new AdmZip(); - zip.addFile("Grids/Home/Images/dog.png", Buffer.from("DOGDATA")); + zip.addFile('Grids/Home/Images/dog.png', Buffer.from('DOGDATA')); const buf = zip.toBuffer(); - const data = await Gridset.openImage(buf, "Grids/Home/Images/dog.png"); - expect(Buffer.from(data || []).toString("utf8")).toBe("DOGDATA"); + const data = await Gridset.openImage(buf, 'Grids/Home/Images/dog.png'); + expect(Buffer.from(data || []).toString('utf8')).toBe('DOGDATA'); - const missing = await Gridset.openImage(buf, "Grids/Home/Images/cat.png"); + const missing = await Gridset.openImage(buf, 'Grids/Home/Images/cat.png'); expect(missing).toBeNull(); }); }); -describe("Grid3 GUID Generation", () => { - it("generateGrid3Guid generates a valid GUID format", async () => { +describe('Grid3 GUID Generation', () => { + it('generateGrid3Guid generates a valid GUID format', async () => { const guid = Gridset.generateGrid3Guid(); // Check format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx - const guidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; expect(guid).toMatch(guidRegex); }); - it("generateGrid3Guid generates unique GUIDs", async () => { + it('generateGrid3Guid generates unique GUIDs', async () => { const guid1 = Gridset.generateGrid3Guid(); const guid2 = Gridset.generateGrid3Guid(); const guid3 = Gridset.generateGrid3Guid(); @@ -114,130 +113,122 @@ describe("Grid3 GUID Generation", () => { expect(guid1).not.toBe(guid3); }); - it("generateGrid3Guid generates GUIDs with correct version and variant", async () => { + it('generateGrid3Guid generates GUIDs with correct version and variant', async () => { // Generate multiple GUIDs and check they all have version 4 and variant 1 for (let i = 0; i < 10; i++) { const guid = Gridset.generateGrid3Guid(); - const parts = guid.split("-"); + const parts = guid.split('-'); // Version 4 is in the first character of the 3rd group - expect(parts[2][0]).toBe("4"); + expect(parts[2][0]).toBe('4'); // Variant 1 is in the first character of the 4th group (should be 8, 9, a, or b) - expect(["8", "9", "a", "b"]).toContain(parts[3][0].toLowerCase()); + expect(['8', '9', 'a', 'b']).toContain(parts[3][0].toLowerCase()); } }); }); -describe("Grid3 Settings XML Builder", () => { - it("createSettingsXml creates valid XML with default options", async () => { - const xml = Gridset.createSettingsXml("Home"); - expect(xml).toContain("Home"); - expect(xml).toContain("false"); - expect(xml).toContain("false"); - expect(xml).toContain("true"); - expect(xml).toContain("en-US"); +describe('Grid3 Settings XML Builder', () => { + it('createSettingsXml creates valid XML with default options', async () => { + const xml = Gridset.createSettingsXml('Home'); + expect(xml).toContain('Home'); + expect(xml).toContain('false'); + expect(xml).toContain('false'); + expect(xml).toContain('true'); + expect(xml).toContain('en-US'); }); - it("createSettingsXml respects custom options", async () => { - const xml = Gridset.createSettingsXml("MainMenu", { + it('createSettingsXml respects custom options', async () => { + const xml = Gridset.createSettingsXml('MainMenu', { scanEnabled: true, scanTimeoutMs: 3000, hoverEnabled: true, hoverTimeoutMs: 1500, mouseclickEnabled: false, - language: "fr-FR", + language: 'fr-FR', }); - expect(xml).toContain("MainMenu"); - expect(xml).toContain("true"); - expect(xml).toContain("3000"); - expect(xml).toContain("true"); - expect(xml).toContain("1500"); - expect(xml).toContain("false"); - expect(xml).toContain("fr-FR"); - }); - - it("createSettingsXml includes XML namespace", async () => { - const xml = Gridset.createSettingsXml("Home"); - expect(xml).toContain( - 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', - ); + expect(xml).toContain('MainMenu'); + expect(xml).toContain('true'); + expect(xml).toContain('3000'); + expect(xml).toContain('true'); + expect(xml).toContain('1500'); + expect(xml).toContain('false'); + expect(xml).toContain('fr-FR'); + }); + + it('createSettingsXml includes XML namespace', async () => { + const xml = Gridset.createSettingsXml('Home'); + expect(xml).toContain('xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'); }); - it("createSettingsXml handles partial options", async () => { - const xml = Gridset.createSettingsXml("Home", { + it('createSettingsXml handles partial options', async () => { + const xml = Gridset.createSettingsXml('Home', { scanEnabled: true, - language: "de-DE", + language: 'de-DE', }); - expect(xml).toContain("true"); - expect(xml).toContain("de-DE"); + expect(xml).toContain('true'); + expect(xml).toContain('de-DE'); // Should still have defaults for unspecified options - expect(xml).toContain("false"); - expect(xml).toContain("true"); + expect(xml).toContain('false'); + expect(xml).toContain('true'); }); }); -describe("Grid3 FileMap XML Builder", () => { - it("createFileMapXml creates valid XML with single grid", async () => { - const xml = Gridset.createFileMapXml([ - { name: "Home", path: "Grids\\Home\\grid.xml" }, - ]); - expect(xml).toContain(""); - expect(xml).toContain(" { + it('createFileMapXml creates valid XML with single grid', async () => { + const xml = Gridset.createFileMapXml([{ name: 'Home', path: 'Grids\\Home\\grid.xml' }]); + expect(xml).toContain(''); + expect(xml).toContain(' { + it('createFileMapXml creates valid XML with multiple grids', async () => { const xml = Gridset.createFileMapXml([ - { name: "Home", path: "Grids\\Home\\grid.xml" }, - { name: "Menu", path: "Grids\\Menu\\grid.xml" }, - { name: "Settings", path: "Grids\\Settings\\grid.xml" }, + { name: 'Home', path: 'Grids\\Home\\grid.xml' }, + { name: 'Menu', path: 'Grids\\Menu\\grid.xml' }, + { name: 'Settings', path: 'Grids\\Settings\\grid.xml' }, ]); expect(xml).toContain('StaticFile="Grids\\Home\\grid.xml"'); expect(xml).toContain('StaticFile="Grids\\Menu\\grid.xml"'); expect(xml).toContain('StaticFile="Grids\\Settings\\grid.xml"'); }); - it("createFileMapXml includes dynamic files when provided", async () => { + it('createFileMapXml includes dynamic files when provided', async () => { const xml = Gridset.createFileMapXml([ { - name: "Home", - path: "Grids\\Home\\grid.xml", - dynamicFiles: ["dynamic1.xml", "dynamic2.xml"], + name: 'Home', + path: 'Grids\\Home\\grid.xml', + dynamicFiles: ['dynamic1.xml', 'dynamic2.xml'], }, ]); - expect(xml).toContain(""); - expect(xml).toContain("dynamic1.xml"); - expect(xml).toContain("dynamic2.xml"); + expect(xml).toContain(''); + expect(xml).toContain('dynamic1.xml'); + expect(xml).toContain('dynamic2.xml'); }); - it("createFileMapXml omits DynamicFiles when empty", async () => { + it('createFileMapXml omits DynamicFiles when empty', async () => { const xml = Gridset.createFileMapXml([ - { name: "Home", path: "Grids\\Home\\grid.xml", dynamicFiles: [] }, + { name: 'Home', path: 'Grids\\Home\\grid.xml', dynamicFiles: [] }, ]); - expect(xml).not.toContain(""); + expect(xml).not.toContain(''); }); - it("createFileMapXml includes XML namespace", async () => { - const xml = Gridset.createFileMapXml([ - { name: "Home", path: "Grids\\Home\\grid.xml" }, - ]); - expect(xml).toContain( - 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', - ); + it('createFileMapXml includes XML namespace', async () => { + const xml = Gridset.createFileMapXml([{ name: 'Home', path: 'Grids\\Home\\grid.xml' }]); + expect(xml).toContain('xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'); }); - it("createFileMapXml handles mixed grids with and without dynamic files", async () => { + it('createFileMapXml handles mixed grids with and without dynamic files', async () => { const xml = Gridset.createFileMapXml([ - { name: "Home", path: "Grids\\Home\\grid.xml" }, + { name: 'Home', path: 'Grids\\Home\\grid.xml' }, { - name: "Menu", - path: "Grids\\Menu\\grid.xml", - dynamicFiles: ["menu_dynamic.xml"], + name: 'Menu', + path: 'Grids\\Menu\\grid.xml', + dynamicFiles: ['menu_dynamic.xml'], }, ]); expect(xml).toContain('StaticFile="Grids\\Home\\grid.xml"'); expect(xml).toContain('StaticFile="Grids\\Menu\\grid.xml"'); - expect(xml).toContain("menu_dynamic.xml"); + expect(xml).toContain('menu_dynamic.xml'); }); }); diff --git a/test/gridsetImageDebug.test.ts b/test/gridsetImageDebug.test.ts index 21d326e..aa6c8ce 100644 --- a/test/gridsetImageDebug.test.ts +++ b/test/gridsetImageDebug.test.ts @@ -1,25 +1,22 @@ -import AdmZip from "adm-zip"; -import { - auditGridsetImages, - formatImageAuditSummary, -} from "../src/processors/gridset/imageDebug"; +import AdmZip from 'adm-zip'; +import { auditGridsetImages, formatImageAuditSummary } from '../src/processors/gridset/imageDebug'; -describe("Image Debugging Utilities", () => { +describe('Image Debugging Utilities', () => { function createMinimalGridset( options: { includeImages?: boolean; includeBrokenImages?: boolean; includeSymbolLibrary?: boolean; - } = {}, + } = {} ): Buffer { const zip = new AdmZip(); // Add Settings zip.addFile( - "Settings0/settings.xml", + 'Settings0/settings.xml', Buffer.from( - '', - ), + '' + ) ); // Create a simple grid with images @@ -68,24 +65,24 @@ describe("Image Debugging Utilities", () => {
`; - zip.addFile("Grids/Test Grid/grid.xml", Buffer.from(gridXml)); + zip.addFile('Grids/Test Grid/grid.xml', Buffer.from(gridXml)); // Add image files if (options.includeImages) { - zip.addFile("Grids/Test Grid/test-image.png", Buffer.from("PNG-DATA")); - zip.addFile("Grids/Test Grid/another-image.png", Buffer.from("PNG-DATA")); + zip.addFile('Grids/Test Grid/test-image.png', Buffer.from('PNG-DATA')); + zip.addFile('Grids/Test Grid/another-image.png', Buffer.from('PNG-DATA')); } if (options.includeBrokenImages) { // Add image with coordinate prefix - zip.addFile("Grids/Test Grid/1-1-0-text-0.png", Buffer.from("PNG-DATA")); + zip.addFile('Grids/Test Grid/1-1-0-text-0.png', Buffer.from('PNG-DATA')); } return zip.toBuffer(); } - describe("auditGridsetImages", () => { - it("should audit gridset with all images resolved", async () => { + describe('auditGridsetImages', () => { + it('should audit gridset with all images resolved', async () => { const buffer = createMinimalGridset({ includeImages: true }); const audit = await auditGridsetImages(buffer); @@ -97,7 +94,7 @@ describe("Image Debugging Utilities", () => { expect(audit.availableImages.length).toBeGreaterThan(0); }); - it("should detect broken image references", async () => { + it('should detect broken image references', async () => { const buffer = createMinimalGridset({ includeBrokenImages: true }); const audit = await auditGridsetImages(buffer); @@ -105,64 +102,60 @@ describe("Image Debugging Utilities", () => { expect(audit.issues.length).toBeGreaterThan(0); const issue = audit.issues[0]; - expect(issue.issue).toBe("not_found"); - expect(issue.gridName).toBe("Test Grid"); + expect(issue.issue).toBe('not_found'); + expect(issue.gridName).toBe('Test Grid'); expect(issue.cellX).toBe(1); expect(issue.cellY).toBe(1); }); - it("should identify symbol library references", async () => { + it('should identify symbol library references', async () => { const buffer = createMinimalGridset({ includeSymbolLibrary: true }); const audit = await auditGridsetImages(buffer); expect(audit.unresolvedImages).toBeGreaterThan(0); - const issue = audit.issues.find( - (i: { issue: string }) => i.issue === "symbol_library", - ); + const issue = audit.issues.find((i: { issue: string }) => i.issue === 'symbol_library'); expect(issue).toBeDefined(); - expect(issue?.declaredImage).toContain("widgit"); - expect(issue?.suggestion).toContain("symbol library"); + expect(issue?.declaredImage).toContain('widgit'); + expect(issue?.suggestion).toContain('symbol library'); }); - it("should provide available images list", async () => { + it('should provide available images list', async () => { const buffer = createMinimalGridset({ includeImages: true }); const audit = await auditGridsetImages(buffer); - expect(audit.availableImages).toContain("Grids/Test Grid/test-image.png"); - expect(audit.availableImages).toContain( - "Grids/Test Grid/another-image.png", - ); + expect(audit.availableImages).toContain('Grids/Test Grid/test-image.png'); + expect(audit.availableImages).toContain('Grids/Test Grid/another-image.png'); }); }); - describe("formatImageAuditSummary", () => { - it("should format audit results as readable text", async () => { + describe('formatImageAuditSummary', () => { + it('should format audit results as readable text', async () => { const buffer = createMinimalGridset({ includeImages: true }); const audit = await auditGridsetImages(buffer); const summary = formatImageAuditSummary(audit); - expect(summary).toContain("Grid3 Image Audit Summary"); - expect(summary).toContain("Total cells: 2"); - expect(summary).toContain("Resolved images: 2"); - expect(summary).toContain("Unresolved images: 0"); + expect(summary).toContain('Grid3 Image Audit Summary'); + expect(summary).toContain('Total cells: 2'); + expect(summary).toContain('Resolved images: 2'); + expect(summary).toContain('Unresolved images: 0'); }); - it("should include issue details when problems exist", async () => { + it('should include issue details when problems exist', async () => { const buffer = createMinimalGridset({ includeBrokenImages: true }); const audit = await auditGridsetImages(buffer); const summary = formatImageAuditSummary(audit); - expect(summary).toContain("Image Issues"); - expect(summary).toContain("NOT_FOUND"); + expect(summary).toContain('Image Issues'); + expect(summary).toContain('NOT_FOUND'); }); - it("should group issues by type", async () => { + it('should group issues by type', async () => { const buffer = createMinimalGridset({ includeSymbolLibrary: true }); const audit = await auditGridsetImages(buffer); const summary = formatImageAuditSummary(audit); - expect(summary).toContain("SYMBOL_LIBRARY"); + expect(summary).toContain('SYMBOL_LIBRARY'); }); }); }); diff --git a/test/gridsetPluginTypes.test.ts b/test/gridsetPluginTypes.test.ts index dd447f5..b8937fc 100644 --- a/test/gridsetPluginTypes.test.ts +++ b/test/gridsetPluginTypes.test.ts @@ -8,47 +8,47 @@ import { isLiveCell, isAutoContentCell, isRegularCell, -} from "../src/processors/gridset/pluginTypes"; +} from '../src/processors/gridset/pluginTypes'; -describe("Grid 3 Plugin Type Detection", () => { - describe("Workspace Detection", () => { - it("should detect Workspace cell from ContentType", async () => { +describe('Grid 3 Plugin Type Detection', () => { + describe('Workspace Detection', () => { + it('should detect Workspace cell from ContentType', async () => { const content = { - ContentType: "Workspace", - ContentSubType: "Chat", + ContentType: 'Workspace', + ContentSubType: 'Chat', }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.Workspace); - expect(metadata.subType).toBe("Chat"); - expect(metadata.pluginId).toBe("Grid3.Chat"); + expect(metadata.subType).toBe('Chat'); + expect(metadata.pluginId).toBe('Grid3.Chat'); }); - it("should detect Workspace cell from Style", async () => { + it('should detect Workspace cell from Style', async () => { const content = { Style: { - BasedOnStyle: "Workspace", + BasedOnStyle: 'Workspace', }, - ContentSubType: "Email", + ContentSubType: 'Email', }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.Workspace); - expect(metadata.subType).toBe("Email"); + expect(metadata.subType).toBe('Email'); }); - it("should infer correct plugin IDs for various workspaces", async () => { + it('should infer correct plugin IDs for various workspaces', async () => { const workspaces = [ - { sub: WORKSPACE_TYPES.EMAIL, expected: "Grid3.Email" }, + { sub: WORKSPACE_TYPES.EMAIL, expected: 'Grid3.Email' }, { sub: WORKSPACE_TYPES.WORD_PROCESSOR, - expected: "Grid3.WordProcessor", + expected: 'Grid3.WordProcessor', }, - { sub: WORKSPACE_TYPES.WEB_BROWSER, expected: "Grid3.WebBrowser" }, - { sub: WORKSPACE_TYPES.SETTINGS, expected: "Grid3.Settings" }, + { sub: WORKSPACE_TYPES.WEB_BROWSER, expected: 'Grid3.WebBrowser' }, + { sub: WORKSPACE_TYPES.SETTINGS, expected: 'Grid3.Settings' }, ]; workspaces.forEach(({ sub, expected }) => { const metadata = detectPluginCellType({ - ContentType: "Workspace", + ContentType: 'Workspace', ContentSubType: sub, }); expect(metadata.pluginId).toBe(expected); @@ -56,71 +56,71 @@ describe("Grid 3 Plugin Type Detection", () => { }); }); - describe("LiveCell Detection", () => { - it("should detect LiveCell from ContentType", async () => { + describe('LiveCell Detection', () => { + it('should detect LiveCell from ContentType', async () => { const content = { - ContentType: "LiveCell", - ContentSubType: "DigitalClock", + ContentType: 'LiveCell', + ContentSubType: 'DigitalClock', }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.LiveCell); - expect(metadata.liveCellType).toBe("DigitalClock"); - expect(metadata.pluginId).toBe("Grid3.Clock"); + expect(metadata.liveCellType).toBe('DigitalClock'); + expect(metadata.pluginId).toBe('Grid3.Clock'); }); - it("should infer correct plugin IDs for live cells", async () => { + it('should infer correct plugin IDs for live cells', async () => { expect( detectPluginCellType({ - ContentType: "LiveCell", + ContentType: 'LiveCell', ContentSubType: LIVECELL_TYPES.BATTERY, - }).pluginId, - ).toBe("Grid3.Battery"); + }).pluginId + ).toBe('Grid3.Battery'); expect( detectPluginCellType({ - ContentType: "LiveCell", + ContentType: 'LiveCell', ContentSubType: LIVECELL_TYPES.WIFI_STRENGTH, - }).pluginId, - ).toBe("Grid3.Wifi"); + }).pluginId + ).toBe('Grid3.Wifi'); }); }); - describe("AutoContent Detection", () => { - it("should detect AutoContent from ContentType", async () => { + describe('AutoContent Detection', () => { + it('should detect AutoContent from ContentType', async () => { const content = { - ContentType: "AutoContent", + ContentType: 'AutoContent', Commands: { Command: [ { - "@_ID": "AutoContent.Activate", - Parameter: { "@_Key": "autocontenttype", "#text": "Prediction" }, + '@_ID': 'AutoContent.Activate', + Parameter: { '@_Key': 'autocontenttype', '#text': 'Prediction' }, }, ], }, }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.AutoContent); - expect(metadata.autoContentType).toBe("Prediction"); - expect(metadata.pluginId).toBe("Grid3.Prediction"); + expect(metadata.autoContentType).toBe('Prediction'); + expect(metadata.pluginId).toBe('Grid3.Prediction'); }); - it("should detect AutoContent from Style", async () => { + it('should detect AutoContent from Style', async () => { const content = { - Style: { BasedOnStyle: "AutoContent" }, + Style: { BasedOnStyle: 'AutoContent' }, Commands: { Command: { - "@_ID": "AutoContent.Activate", - Parameter: { "@_Key": "autocontenttype", "#text": "Grammar" }, + '@_ID': 'AutoContent.Activate', + Parameter: { '@_Key': 'autocontenttype', '#text': 'Grammar' }, }, }, }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.AutoContent); - expect(metadata.autoContentType).toBe("Grammar"); + expect(metadata.autoContentType).toBe('Grammar'); }); - it("should return undefined pluginId for unknown types", async () => { + it('should return undefined pluginId for unknown types', async () => { const metadata = detectPluginCellType({ - ContentType: "AutoContent", + ContentType: 'AutoContent', Commands: {}, }); expect(metadata.cellType).toBe(Grid3CellType.AutoContent); @@ -128,25 +128,23 @@ describe("Grid 3 Plugin Type Detection", () => { }); }); - describe("Regular Cell Detection", () => { - it("should detect regular cells", async () => { - const content = { Label: "Hello" }; + describe('Regular Cell Detection', () => { + it('should detect regular cells', async () => { + const content = { Label: 'Hello' }; const metadata = detectPluginCellType(content); expect(metadata.cellType).toBe(Grid3CellType.Regular); }); }); - describe("Utility Functions", () => { - it("getCellTypeDisplayName should return correct names", async () => { - expect(getCellTypeDisplayName(Grid3CellType.Workspace)).toBe("Workspace"); - expect(getCellTypeDisplayName(Grid3CellType.LiveCell)).toBe("Live Cell"); - expect(getCellTypeDisplayName(Grid3CellType.AutoContent)).toBe( - "Auto Content", - ); - expect(getCellTypeDisplayName(Grid3CellType.Regular)).toBe("Regular"); + describe('Utility Functions', () => { + it('getCellTypeDisplayName should return correct names', async () => { + expect(getCellTypeDisplayName(Grid3CellType.Workspace)).toBe('Workspace'); + expect(getCellTypeDisplayName(Grid3CellType.LiveCell)).toBe('Live Cell'); + expect(getCellTypeDisplayName(Grid3CellType.AutoContent)).toBe('Auto Content'); + expect(getCellTypeDisplayName(Grid3CellType.Regular)).toBe('Regular'); }); - it("type checking functions should work", async () => { + it('type checking functions should work', async () => { const workspace = { cellType: Grid3CellType.Workspace }; const live = { cellType: Grid3CellType.LiveCell }; const auto = { cellType: Grid3CellType.AutoContent }; diff --git a/test/gridsetProcessor.coverage.test.ts b/test/gridsetProcessor.coverage.test.ts index d1aa042..df7d5ae 100644 --- a/test/gridsetProcessor.coverage.test.ts +++ b/test/gridsetProcessor.coverage.test.ts @@ -1,35 +1,35 @@ -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; -import AdmZip from "adm-zip"; -import { XMLBuilder } from "fast-xml-parser"; +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import AdmZip from 'adm-zip'; +import { XMLBuilder } from 'fast-xml-parser'; -describe("GridsetProcessor Coverage Tests", () => { - describe("Metadata Extraction", () => { - it("should extract metadata from settings.xml", async () => { +describe('GridsetProcessor Coverage Tests', () => { + describe('Metadata Extraction', () => { + it('should extract metadata from settings.xml', async () => { const zip = new AdmZip(); // Create settings.xml with full metadata const settingsData = { - "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, + '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, GridSetSettings: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - Name: "Test Gridset", - Description: "Test Description", - Author: "Test Author", - PrimaryLanguage: "en-US", - StartGrid: "home", - KeyboardGrid: "keyboard", - DocumentationUrl: "https://example.com/docs", - DocumentationSlug: "test-gridset", - Thumbnail: "[grid3x]thumbnail.wmf", - ThumbnailBackground: "#FF0000FF", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + Name: 'Test Gridset', + Description: 'Test Description', + Author: 'Test Author', + PrimaryLanguage: 'en-US', + StartGrid: 'home', + KeyboardGrid: 'keyboard', + DocumentationUrl: 'https://example.com/docs', + DocumentationSlug: 'test-gridset', + Thumbnail: '[grid3x]thumbnail.wmf', + ThumbnailBackground: '#FF0000FF', PictureSearch: { PictureSearchKeys: { - PictureSearchKey: ["widgit", "sstix"], + PictureSearchKey: ['widgit', 'sstix'], }, }, Appearance: { - TextAtTop: "1", - ComputerControlCellSize: "0.4", + TextAtTop: '1', + ComputerControlCellSize: '0.4', }, }, }; @@ -40,24 +40,24 @@ describe("GridsetProcessor Coverage Tests", () => { suppressEmptyNode: true, }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); + zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); // Create a minimal grid const gridData = { Grid: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - GridGuid: "home-guid", - Name: "home", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + GridGuid: 'home-guid', + Name: 'home', ColumnDefinitions: { ColumnDefinition: [{}, {}, {}] }, RowDefinitions: { RowDefinition: [{}, {}] }, Cells: { Cell: [ { - "@_X": 1, - "@_Y": 1, + '@_X': 1, + '@_Y': 1, Content: { CaptionAndImage: { - Caption: "Test", + Caption: 'Test', }, }, }, @@ -71,7 +71,7 @@ describe("GridsetProcessor Coverage Tests", () => { format: true, }); const gridXml = gridBuilder.build(gridData); - zip.addFile("Grids\\home\\grid.xml", Buffer.from(gridXml, "utf8")); + zip.addFile('Grids\\home\\grid.xml', Buffer.from(gridXml, 'utf8')); const buffer = zip.toBuffer(); @@ -79,41 +79,41 @@ describe("GridsetProcessor Coverage Tests", () => { const tree = await processor.loadIntoTree(buffer); expect(tree.metadata).toBeDefined(); - expect(tree.metadata.format).toBe("gridset"); - expect(tree.metadata.name).toBe("Test Gridset"); - expect(tree.metadata.description).toBe("Test Description"); - expect(tree.metadata.author).toBe("Test Author"); - expect(tree.metadata.locale).toBe("en-US"); - expect(tree.metadata.homepageUrl).toBe("https://example.com/docs"); - expect(tree.metadata.documentationUrl).toBe("https://example.com/docs"); - expect(tree.metadata.documentationSlug).toBe("test-gridset"); - expect(tree.metadata.pictureSearchKeys).toEqual(["widgit", "sstix"]); + expect(tree.metadata.format).toBe('gridset'); + expect(tree.metadata.name).toBe('Test Gridset'); + expect(tree.metadata.description).toBe('Test Description'); + expect(tree.metadata.author).toBe('Test Author'); + expect(tree.metadata.locale).toBe('en-US'); + expect(tree.metadata.homepageUrl).toBe('https://example.com/docs'); + expect(tree.metadata.documentationUrl).toBe('https://example.com/docs'); + expect(tree.metadata.documentationSlug).toBe('test-gridset'); + expect(tree.metadata.pictureSearchKeys).toEqual(['widgit', 'sstix']); expect(tree.metadata.appearance).toBeDefined(); expect(tree.metadata.appearance?.textAtTop).toBe(true); expect(tree.metadata.appearance?.computerControlCellSize).toBe(0.4); - expect(tree.metadata.thumbnail).toBe("[grid3x]thumbnail.wmf"); - expect(tree.metadata.thumbnailBackground).toBe("#FF0000FF"); + expect(tree.metadata.thumbnail).toBe('[grid3x]thumbnail.wmf'); + expect(tree.metadata.thumbnailBackground).toBe('#FF0000FF'); }); - it("should handle missing optional metadata fields", async () => { + it('should handle missing optional metadata fields', async () => { const zip = new AdmZip(); // Minimal settings.xml const settingsData = { GridSetSettings: { - Name: "Minimal Gridset", + Name: 'Minimal Gridset', }, }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); + zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); // Create a minimal grid const gridData = { Grid: { - GridGuid: "grid-guid", - Name: "grid1", + GridGuid: 'grid-guid', + Name: 'grid1', ColumnDefinitions: { ColumnDefinition: [{}, {}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [] }, @@ -122,7 +122,7 @@ describe("GridsetProcessor Coverage Tests", () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile("Grids\\grid1\\grid.xml", Buffer.from(gridXml, "utf8")); + zip.addFile('Grids\\grid1\\grid.xml', Buffer.from(gridXml, 'utf8')); const buffer = zip.toBuffer(); @@ -130,54 +130,54 @@ describe("GridsetProcessor Coverage Tests", () => { const tree = await processor.loadIntoTree(buffer); expect(tree.metadata).toBeDefined(); - expect(tree.metadata.format).toBe("gridset"); - expect(tree.metadata.name).toBe("Minimal Gridset"); + expect(tree.metadata.format).toBe('gridset'); + expect(tree.metadata.name).toBe('Minimal Gridset'); // Optional fields should be undefined expect(tree.metadata.description).toBeUndefined(); expect(tree.metadata.author).toBeUndefined(); }); }); - describe("Grid Cell Parsing", () => { - it("should parse cell with all attributes", async () => { + describe('Grid Cell Parsing', () => { + it('should parse cell with all attributes', async () => { const zip = new AdmZip(); const gridData = { Grid: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - GridGuid: "test-guid", - Name: "test", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + GridGuid: 'test-guid', + Name: 'test', ColumnDefinitions: { ColumnDefinition: [{}, {}, {}] }, RowDefinitions: { RowDefinition: [{}, {}] }, Cells: { Cell: [ { - "@_X": 1, - "@_Y": 1, - "@_ColumnSpan": 2, - "@_RowSpan": 2, - "@_ScanBlock": 3, - "@_StyleID": "style1", - "@_BackColour": "#FF0000FF", - "@_FontColour": "#000000FF", - Visibility: "Visible", + '@_X': 1, + '@_Y': 1, + '@_ColumnSpan': 2, + '@_RowSpan': 2, + '@_ScanBlock': 3, + '@_StyleID': 'style1', + '@_BackColour': '#FF0000FF', + '@_FontColour': '#000000FF', + Visibility: 'Visible', Content: { CaptionAndImage: { - Caption: "Test Button", - Image: "test.png", + Caption: 'Test Button', + Image: 'test.png', }, - ContentType: "Normal", + ContentType: 'Normal', Style: { - BasedOnStyle: "style1", - FontName: "Arial", - FontSize: "16", + BasedOnStyle: 'style1', + FontName: 'Arial', + FontSize: '16', }, Commands: { Command: { - "@_ID": "Action.InsertText", + '@_ID': 'Action.InsertText', Parameter: { - "@_Key": "text", - "#text": "Hello", + '@_Key': 'text', + '#text': 'Hello', }, }, }, @@ -190,13 +190,13 @@ describe("GridsetProcessor Coverage Tests", () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile("Grids\\test\\grid.xml", Buffer.from(gridXml, "utf8")); + zip.addFile('Grids\\test\\grid.xml', Buffer.from(gridXml, 'utf8')); // Add minimal settings - const settingsData = { GridSetSettings: { Name: "Test" } }; + const settingsData = { GridSetSettings: { Name: 'Test' } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); + zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); const buffer = zip.toBuffer(); @@ -211,48 +211,48 @@ describe("GridsetProcessor Coverage Tests", () => { const button = page.buttons[0]; expect(button).toBeDefined(); - expect(button.label).toBe("Test Button"); + expect(button.label).toBe('Test Button'); expect(button.x).toBe(1); // Grid 3 XML coordinates are already 0-based expect(button.y).toBe(1); expect(button.columnSpan).toBe(2); expect(button.rowSpan).toBe(2); expect(button.scanBlock).toBe(3); - expect(button.visibility).toBe("Visible"); - expect(button.style?.backgroundColor).toBe("#FF0000FF"); - expect(button.style?.fontColor).toBe("#000000FF"); - expect(button.style?.fontFamily).toBe("Arial"); + expect(button.visibility).toBe('Visible'); + expect(button.style?.backgroundColor).toBe('#FF0000FF'); + expect(button.style?.fontColor).toBe('#000000FF'); + expect(button.style?.fontFamily).toBe('Arial'); expect(button.style?.fontSize).toBe(16); - expect(button.image).toBe("test.png"); + expect(button.image).toBe('test.png'); }); - it("should parse cell with prediction wordlist", async () => { + it('should parse cell with prediction wordlist', async () => { const zip = new AdmZip(); const gridData = { Grid: { - GridGuid: "test-guid", - Name: "test", + GridGuid: 'test-guid', + Name: 'test', ColumnDefinitions: { ColumnDefinition: [{}, {}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [ { - "@_X": 1, - "@_Y": 1, + '@_X': 1, + '@_Y': 1, Content: { - CaptionAndImage: { Caption: "Predict" }, + CaptionAndImage: { Caption: 'Predict' }, Commands: { Command: { - "@_ID": "Prediction.PredictThis", + '@_ID': 'Prediction.PredictThis', Parameter: [ { - "@_Key": "wordlist", + '@_Key': 'wordlist', WordList: { Items: { WordListItem: [ - { Text: "word1" }, - { Text: "word2" }, - { Text: "word3" }, + { Text: 'word1' }, + { Text: 'word2' }, + { Text: 'word3' }, ], }, }, @@ -269,12 +269,12 @@ describe("GridsetProcessor Coverage Tests", () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile("Grids\\test\\grid.xml", Buffer.from(gridXml, "utf8")); + zip.addFile('Grids\\test\\grid.xml', Buffer.from(gridXml, 'utf8')); - const settingsData = { GridSetSettings: { Name: "Test" } }; + const settingsData = { GridSetSettings: { Name: 'Test' } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); + zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); const buffer = zip.toBuffer(); @@ -286,35 +286,37 @@ describe("GridsetProcessor Coverage Tests", () => { // Should have 1 button plus virtual buttons for predicted words expect(page.buttons.length).toBeGreaterThanOrEqual(1); const button = page.buttons[0]; - expect(button.label).toBe("Predict"); + expect(button.label).toBe('Predict'); expect(button.semanticAction).toBeDefined(); - expect( - button.semanticAction?.platformData?.grid3?.parameters?.wordlist, - ).toEqual(["word1", "word2", "word3"]); + expect(button.semanticAction?.platformData?.grid3?.parameters?.wordlist).toEqual([ + 'word1', + 'word2', + 'word3', + ]); }); - it("should parse navigation commands", async () => { + it('should parse navigation commands', async () => { const zip = new AdmZip(); const gridData = { Grid: { - GridGuid: "home-guid", - Name: "home", + GridGuid: 'home-guid', + Name: 'home', ColumnDefinitions: { ColumnDefinition: [{}, {}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [ { - "@_X": 1, - "@_Y": 1, + '@_X': 1, + '@_Y': 1, Content: { - CaptionAndImage: { Caption: "Go to page" }, + CaptionAndImage: { Caption: 'Go to page' }, Commands: { Command: { - "@_ID": "Jump.To", + '@_ID': 'Jump.To', Parameter: { - "@_Key": "grid", - "#text": "other", + '@_Key': 'grid', + '#text': 'other', }, }, }, @@ -327,80 +329,80 @@ describe("GridsetProcessor Coverage Tests", () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile("Grids\\home\\grid.xml", Buffer.from(gridXml, "utf8")); + zip.addFile('Grids\\home\\grid.xml', Buffer.from(gridXml, 'utf8')); // Add target page const targetGridData = { Grid: { - GridGuid: "other-guid", - Name: "other", + GridGuid: 'other-guid', + Name: 'other', ColumnDefinitions: { ColumnDefinition: [{}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [] }, }, }; const targetGridXml = gridBuilder.build(targetGridData); - zip.addFile("Grids\\other\\grid.xml", Buffer.from(targetGridXml, "utf8")); + zip.addFile('Grids\\other\\grid.xml', Buffer.from(targetGridXml, 'utf8')); - const settingsData = { GridSetSettings: { Name: "Test" } }; + const settingsData = { GridSetSettings: { Name: 'Test' } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); + zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); const buffer = zip.toBuffer(); const processor = new GridsetProcessor(); const tree = await processor.loadIntoTree(buffer); - const homePage = tree.pages["home-guid"]; + const homePage = tree.pages['home-guid']; expect(homePage).toBeDefined(); const navButton = homePage.buttons[0]; - expect(navButton.targetPageId).toBe("other-guid"); + expect(navButton.targetPageId).toBe('other-guid'); - const otherPage = tree.pages["other-guid"]; - expect(otherPage.parentId).toBe("home-guid"); + const otherPage = tree.pages['other-guid']; + expect(otherPage.parentId).toBe('home-guid'); }); - it("should handle different visibility values", async () => { + it('should handle different visibility values', async () => { const zip = new AdmZip(); const gridData = { Grid: { - GridGuid: "test-guid", - Name: "test", + GridGuid: 'test-guid', + Name: 'test', ColumnDefinitions: { ColumnDefinition: [{}, {}, {}, {}, {}] }, RowDefinitions: { RowDefinition: [{}, {}, {}, {}, {}] }, Cells: { Cell: [ { - "@_X": 1, - "@_Y": 1, - Visibility: "Hidden", - Content: { CaptionAndImage: { Caption: "Hidden" } }, + '@_X': 1, + '@_Y': 1, + Visibility: 'Hidden', + Content: { CaptionAndImage: { Caption: 'Hidden' } }, }, { - "@_X": 2, - "@_Y": 1, - Visibility: "Disabled", - Content: { CaptionAndImage: { Caption: "Disabled" } }, + '@_X': 2, + '@_Y': 1, + Visibility: 'Disabled', + Content: { CaptionAndImage: { Caption: 'Disabled' } }, }, { - "@_X": 3, - "@_Y": 1, - Visibility: "PointerAndTouchOnly", - Content: { CaptionAndImage: { Caption: "TouchOnly" } }, + '@_X': 3, + '@_Y': 1, + Visibility: 'PointerAndTouchOnly', + Content: { CaptionAndImage: { Caption: 'TouchOnly' } }, }, { - "@_X": 4, - "@_Y": 1, - Visibility: "TouchOnly", - Content: { CaptionAndImage: { Caption: "RealTouchOnly" } }, + '@_X': 4, + '@_Y': 1, + Visibility: 'TouchOnly', + Content: { CaptionAndImage: { Caption: 'RealTouchOnly' } }, }, { - "@_X": 5, - "@_Y": 1, - Visibility: "PointerOnly", - Content: { CaptionAndImage: { Caption: "PointerOnly" } }, + '@_X': 5, + '@_Y': 1, + Visibility: 'PointerOnly', + Content: { CaptionAndImage: { Caption: 'PointerOnly' } }, }, ], }, @@ -409,12 +411,12 @@ describe("GridsetProcessor Coverage Tests", () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile("Grids\\test\\grid.xml", Buffer.from(gridXml, "utf8")); + zip.addFile('Grids\\test\\grid.xml', Buffer.from(gridXml, 'utf8')); - const settingsData = { GridSetSettings: { Name: "Test" } }; + const settingsData = { GridSetSettings: { Name: 'Test' } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); + zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); const buffer = zip.toBuffer(); @@ -424,31 +426,28 @@ describe("GridsetProcessor Coverage Tests", () => { const page = Object.values(tree.pages)[0]; expect(page.buttons.length).toBe(5); - expect(page.buttons[0].visibility).toBe("Hidden"); - expect(page.buttons[1].visibility).toBe("Disabled"); - expect(page.buttons[2].visibility).toBe("PointerAndTouchOnly"); - expect(page.buttons[3].visibility).toBe("PointerAndTouchOnly"); // TouchOnly maps to this - expect(page.buttons[4].visibility).toBe("PointerAndTouchOnly"); // PointerOnly maps to this + expect(page.buttons[0].visibility).toBe('Hidden'); + expect(page.buttons[1].visibility).toBe('Disabled'); + expect(page.buttons[2].visibility).toBe('PointerAndTouchOnly'); + expect(page.buttons[3].visibility).toBe('PointerAndTouchOnly'); // TouchOnly maps to this + expect(page.buttons[4].visibility).toBe('PointerAndTouchOnly'); // PointerOnly maps to this }); }); - describe("FileMap Support", () => { - it("should parse FileMap.xml", async () => { + describe('FileMap Support', () => { + it('should parse FileMap.xml', async () => { const zip = new AdmZip(); const fileMapData = { - "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, + '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, FileMap: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', Entries: { Entry: [ { - "@_StaticFile": "Grids\\page1\\grid.xml", + '@_StaticFile': 'Grids\\page1\\grid.xml', DynamicFiles: { - File: [ - "Grids\\page1\\1-1-0-text-0.png", - "Grids\\page1\\1-2-0-text-0.png", - ], + File: ['Grids\\page1\\1-1-0-text-0.png', 'Grids\\page1\\1-2-0-text-0.png'], }, }, ], @@ -458,13 +457,13 @@ describe("GridsetProcessor Coverage Tests", () => { const fileMapBuilder = new XMLBuilder({ ignoreAttributes: false }); const fileMapXml = fileMapBuilder.build(fileMapData); - zip.addFile("FileMap.xml", Buffer.from(fileMapXml, "utf8")); + zip.addFile('FileMap.xml', Buffer.from(fileMapXml, 'utf8')); // Add minimal grid const gridData = { Grid: { - GridGuid: "page1-guid", - Name: "page1", + GridGuid: 'page1-guid', + Name: 'page1', ColumnDefinitions: { ColumnDefinition: [{}, {}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [] }, @@ -473,12 +472,12 @@ describe("GridsetProcessor Coverage Tests", () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile("Grids\\page1\\grid.xml", Buffer.from(gridXml, "utf8")); + zip.addFile('Grids\\page1\\grid.xml', Buffer.from(gridXml, 'utf8')); - const settingsData = { GridSetSettings: { Name: "Test" } }; + const settingsData = { GridSetSettings: { Name: 'Test' } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); + zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); const buffer = zip.toBuffer(); @@ -491,28 +490,28 @@ describe("GridsetProcessor Coverage Tests", () => { }); }); - describe("Styles Support", () => { - it("should parse styles.xml", async () => { + describe('Styles Support', () => { + it('should parse styles.xml', async () => { const zip = new AdmZip(); const stylesData = { - "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" }, + '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, StyleData: { - "@_xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', Styles: { Style: [ { - "@_Key": "style1", - BackColour: "#FF0000FF", - BorderColour: "#000000FF", - FontColour: "#FFFFFFFF", - FontName: "Arial", - FontSize: "16", + '@_Key': 'style1', + BackColour: '#FF0000FF', + BorderColour: '#000000FF', + FontColour: '#FFFFFFFF', + FontName: 'Arial', + FontSize: '16', }, { - "@_Key": "style2", - BackColour: "#00FF00FF", - FontColour: "#000000FF", + '@_Key': 'style2', + BackColour: '#00FF00FF', + FontColour: '#000000FF', }, ], }, @@ -521,25 +520,22 @@ describe("GridsetProcessor Coverage Tests", () => { const stylesBuilder = new XMLBuilder({ ignoreAttributes: false }); const stylesXml = stylesBuilder.build(stylesData); - zip.addFile( - "Settings0/Styles/styles.xml", - Buffer.from(stylesXml, "utf8"), - ); + zip.addFile('Settings0/Styles/styles.xml', Buffer.from(stylesXml, 'utf8')); // Add grid with styled cell const gridData = { Grid: { - GridGuid: "test-guid", - Name: "test", + GridGuid: 'test-guid', + Name: 'test', ColumnDefinitions: { ColumnDefinition: [{}] }, RowDefinitions: { RowDefinition: [{}] }, Cells: { Cell: [ { - "@_X": 1, - "@_Y": 1, - "@_StyleID": "style1", - Content: { CaptionAndImage: { Caption: "Styled" } }, + '@_X': 1, + '@_Y': 1, + '@_StyleID': 'style1', + Content: { CaptionAndImage: { Caption: 'Styled' } }, }, ], }, @@ -548,12 +544,12 @@ describe("GridsetProcessor Coverage Tests", () => { const gridBuilder = new XMLBuilder({ ignoreAttributes: false }); const gridXml = gridBuilder.build(gridData); - zip.addFile("Grids\\test\\grid.xml", Buffer.from(gridXml, "utf8")); + zip.addFile('Grids\\test\\grid.xml', Buffer.from(gridXml, 'utf8')); - const settingsData = { GridSetSettings: { Name: "Test" } }; + const settingsData = { GridSetSettings: { Name: 'Test' } }; const settingsBuilder = new XMLBuilder({ ignoreAttributes: false }); const settingsXml = settingsBuilder.build(settingsData); - zip.addFile("Settings0/settings.xml", Buffer.from(settingsXml, "utf8")); + zip.addFile('Settings0/settings.xml', Buffer.from(settingsXml, 'utf8')); const buffer = zip.toBuffer(); @@ -562,10 +558,10 @@ describe("GridsetProcessor Coverage Tests", () => { const page = Object.values(tree.pages)[0]; const button = page.buttons[0]; - expect(button.style?.backgroundColor).toBe("#FF0000FF"); - expect(button.style?.borderColor).toBe("#000000FF"); - expect(button.style?.fontColor).toBe("#FFFFFFFF"); - expect(button.style?.fontFamily).toBe("Arial"); + expect(button.style?.backgroundColor).toBe('#FF0000FF'); + expect(button.style?.borderColor).toBe('#000000FF'); + expect(button.style?.fontColor).toBe('#FFFFFFFF'); + expect(button.style?.fontFamily).toBe('Arial'); expect(button.style?.fontSize).toBe(16); }); }); diff --git a/test/gridsetProcessor.export.test.js b/test/gridsetProcessor.export.test.js index 0121dc2..6b360ec 100644 --- a/test/gridsetProcessor.export.test.js +++ b/test/gridsetProcessor.export.test.js @@ -1,28 +1,23 @@ // Test GridsetProcessor export/saveFromTree -const fs = require("fs"); -const path = require("path"); -const GridsetProcessor = require("../src/processors/gridsetProcessor"); -describe("GridsetProcessor.saveFromTree", () => { - const gsPath = path.join(__dirname, "assets/gridset/example.gridset.json"); - const outPath = path.join(__dirname, "out.gridset.json"); +const fs = require('fs'); +const path = require('path'); +const GridsetProcessor = require('../src/processors/gridsetProcessor'); +describe('GridsetProcessor.saveFromTree', () => { + const gsPath = path.join(__dirname, 'assets/gridset/example.gridset.json'); + const outPath = path.join(__dirname, 'out.gridset.json'); afterAll(() => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("exports tree to Gridset JSON", () => { + it('exports tree to Gridset JSON', () => { // If no example.gridset.json, skip if (!fs.existsSync(gsPath)) return; - const tree = - require("../src/processors/gridsetProcessor").prototype.loadIntoTree.call( - GridsetProcessor, - gsPath, - ); - GridsetProcessor.prototype.saveFromTree.call( + const tree = require('../src/processors/gridsetProcessor').prototype.loadIntoTree.call( GridsetProcessor, - tree, - outPath, + gsPath ); - const exported = fs.readFileSync(outPath, "utf8"); - expect(exported).toContain("pages"); - expect(exported).toContain("rootId"); + GridsetProcessor.prototype.saveFromTree.call(GridsetProcessor, tree, outPath); + const exported = fs.readFileSync(outPath, 'utf8'); + expect(exported).toContain('pages'); + expect(exported).toContain('rootId'); }); }); diff --git a/test/gridsetProcessor.roundtrip.test.legacy.ts b/test/gridsetProcessor.roundtrip.test.legacy.ts index 3d47ae9..83fbde7 100644 --- a/test/gridsetProcessor.roundtrip.test.legacy.ts +++ b/test/gridsetProcessor.roundtrip.test.legacy.ts @@ -1,23 +1,21 @@ -import fs from "fs"; -import path from "path"; -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; +import fs from 'fs'; +import path from 'path'; +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; // import { AACTree } from '../src/core/treeStructure'; // Unused import -describe("GridsetProcessor round-trip", () => { - const gsPath = path.join(__dirname, "assets/gridset/example.gridset.json"); - const outPath = path.join(__dirname, "out.gridset.json"); +describe('GridsetProcessor round-trip', () => { + const gsPath = path.join(__dirname, 'assets/gridset/example.gridset.json'); + const outPath = path.join(__dirname, 'out.gridset.json'); afterAll(() => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("round-trips Gridset JSON without losing pages or navigation", async () => { + it('round-trips Gridset JSON without losing pages or navigation', async () => { if (!fs.existsSync(gsPath)) return; const processor = new GridsetProcessor(); const tree1 = await processor.loadIntoTree(gsPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); - expect(Object.keys(tree1.pages).sort()).toEqual( - Object.keys(tree2.pages).sort(), - ); + expect(Object.keys(tree1.pages).sort()).toEqual(Object.keys(tree2.pages).sort()); for (const pid in tree1.pages) { expect(tree2.pages).toHaveProperty(pid); const btnLabels1 = tree1.pages[pid].buttons.map((b) => b.label).sort(); diff --git a/test/gridsetProcessor.roundtrip.test.ts b/test/gridsetProcessor.roundtrip.test.ts index 07836b6..7c6f36b 100644 --- a/test/gridsetProcessor.roundtrip.test.ts +++ b/test/gridsetProcessor.roundtrip.test.ts @@ -1,23 +1,20 @@ // Round-trip test for GridsetProcessor: load, save, reload, and compare structure -import fs from "fs"; -import path from "path"; -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; - -describe("GridsetProcessor round-trip", () => { - const exampleFile: string = path.join( - __dirname, - "assets/gridset/example.gridset", - ); - const outPath: string = path.join(__dirname, "out.gridset"); +import fs from 'fs'; +import path from 'path'; +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; + +describe('GridsetProcessor round-trip', () => { + const exampleFile: string = path.join(__dirname, 'assets/gridset/example.gridset'); + const outPath: string = path.join(__dirname, 'out.gridset'); afterAll(async () => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("round-trips gridset files without losing structure", async () => { + it('round-trips gridset files without losing structure', async () => { if (!fs.existsSync(exampleFile)) { - console.log("Skipping gridset round-trip test - example file not found"); + console.log('Skipping gridset round-trip test - example file not found'); return; } @@ -33,15 +30,11 @@ describe("GridsetProcessor round-trip", () => { // Compare basic structure expect(Object.keys(tree2.pages).length).toBeGreaterThan(0); - expect(Object.keys(tree1.pages).length).toBe( - Object.keys(tree2.pages).length, - ); + expect(Object.keys(tree1.pages).length).toBe(Object.keys(tree2.pages).length); // Compare metadata expect(tree2.metadata.name).toBe(tree1.metadata.name); - expect(tree2.metadata.description?.trim()).toBe( - tree1.metadata.description?.trim(), - ); + expect(tree2.metadata.description?.trim()).toBe(tree1.metadata.description?.trim()); if (tree1.metadata.locale) { expect(tree2.metadata.locale).toBe(tree1.metadata.locale); } @@ -69,31 +62,31 @@ describe("GridsetProcessor round-trip", () => { } }); - it("can save and load a constructed tree", async () => { + it('can save and load a constructed tree', async () => { const processor = new GridsetProcessor({ preserveAllButtons: true }); // Create a simple tree programmatically const tree1 = new AACTree(); const page = new AACPage({ - id: "grid1", - name: "Test Grid", + id: 'grid1', + name: 'Test Grid', buttons: [], }); const speakButton = new AACButton({ - id: "cell1", - label: "Hello", - message: "Hello World", - type: "SPEAK", + id: 'cell1', + label: 'Hello', + message: 'Hello World', + type: 'SPEAK', }); const navButton = new AACButton({ - id: "cell2", - label: "Next Grid", - message: "Navigate", - type: "NAVIGATE", - targetPageId: "grid2", + id: 'cell2', + label: 'Next Grid', + message: 'Navigate', + type: 'NAVIGATE', + targetPageId: 'grid2', }); page.addButton(speakButton); @@ -109,23 +102,23 @@ describe("GridsetProcessor round-trip", () => { // Verify structure expect(Object.keys(tree2.pages)).toHaveLength(1); - const reloadedPage = tree2.pages["grid1"]; + const reloadedPage = tree2.pages['grid1']; expect(reloadedPage).toBeDefined(); - expect(reloadedPage.name).toBe("Test Grid"); + expect(reloadedPage.name).toBe('Test Grid'); // We expect exactly 2 buttons - zero injection of mandatory workspace cells expect(reloadedPage.buttons).toHaveLength(2); // Check that we have buttons with the expected labels const buttonLabels = reloadedPage.buttons.map((b) => b.label).sort(); - expect(buttonLabels).toContain("Hello"); - expect(buttonLabels).toContain("Next Grid"); + expect(buttonLabels).toContain('Hello'); + expect(buttonLabels).toContain('Next Grid'); // Check that at least one button has the expected properties - const helloBtn = reloadedPage.buttons.find((b) => b.label === "Hello"); + const helloBtn = reloadedPage.buttons.find((b) => b.label === 'Hello'); expect(helloBtn).toBeDefined(); }); - it("handles empty tree gracefully", async () => { + it('handles empty tree gracefully', async () => { const processor = new GridsetProcessor(); const emptyTree = new AACTree(); diff --git a/test/gridsetProcessor.test.ts b/test/gridsetProcessor.test.ts index c27b86b..e7c3b50 100644 --- a/test/gridsetProcessor.test.ts +++ b/test/gridsetProcessor.test.ts @@ -1,16 +1,13 @@ // Unit tests for GridsetProcessor -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; -import { AACTree } from "../src/core/treeStructure"; -import path from "path"; -import fs from "fs"; - -describe("GridsetProcessor", () => { - const exampleFile: string = path.join( - __dirname, - "assets/gridset/example.gridset", - ); - - it("should load a .gridset file into a tree", async () => { +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import { AACTree } from '../src/core/treeStructure'; +import path from 'path'; +import fs from 'fs'; + +describe('GridsetProcessor', () => { + const exampleFile: string = path.join(__dirname, 'assets/gridset/example.gridset'); + + it('should load a .gridset file into a tree', async () => { const processor = new GridsetProcessor(); const fileBuffer = fs.readFileSync(exampleFile); const tree: AACTree = await processor.loadIntoTree(fileBuffer); @@ -18,7 +15,7 @@ describe("GridsetProcessor", () => { expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it("should extract all texts from a .gridset file", async () => { + it('should extract all texts from a .gridset file', async () => { const processor = new GridsetProcessor(); const fileBuffer = fs.readFileSync(exampleFile); const texts: string[] = await processor.extractTexts(fileBuffer); @@ -26,28 +23,28 @@ describe("GridsetProcessor", () => { expect(texts.length).toBeGreaterThan(0); }); - describe("Error Handling", () => { - it("should throw error for non-existent file", async () => { + describe('Error Handling', () => { + it('should throw error for non-existent file', async () => { expect(() => { - fs.readFileSync("/non/existent/file.gridset"); + fs.readFileSync('/non/existent/file.gridset'); }).toThrow(); }); - it("should handle invalid zip content", async () => { + it('should handle invalid zip content', async () => { const processor = new GridsetProcessor(); - const invalidBuffer = Buffer.from("not a zip file"); + const invalidBuffer = Buffer.from('not a zip file'); await expect(processor.loadIntoTree(invalidBuffer)).rejects.toThrow(); }); - it("should handle empty buffer", async () => { + it('should handle empty buffer', async () => { const processor = new GridsetProcessor(); const emptyBuffer = Buffer.alloc(0); await expect(processor.loadIntoTree(emptyBuffer)).rejects.toThrow(); }); }); - describe("Home Page Preservation", () => { - const tempOutputPath = path.join(__dirname, "temp_gridset_test.gridset"); + describe('Home Page Preservation', () => { + const tempOutputPath = path.join(__dirname, 'temp_gridset_test.gridset'); afterEach(async () => { if (fs.existsSync(tempOutputPath)) { @@ -55,7 +52,7 @@ describe("GridsetProcessor", () => { } }); - it("should preserve home page (tree.rootId) through roundtrip", async () => { + it('should preserve home page (tree.rootId) through roundtrip', async () => { const processor = new GridsetProcessor(); // Load the original file @@ -87,15 +84,9 @@ describe("GridsetProcessor", () => { }); }); - describe("saveModifiedTree", () => { - const tempOutputPath = path.join( - __dirname, - "temp_gridset_modified.gridset", - ); - const tempSaveFromTreePath = path.join( - __dirname, - "temp_gridset_saveFromTree.gridset", - ); + describe('saveModifiedTree', () => { + const tempOutputPath = path.join(__dirname, 'temp_gridset_modified.gridset'); + const tempSaveFromTreePath = path.join(__dirname, 'temp_gridset_saveFromTree.gridset'); afterEach(async () => { if (fs.existsSync(tempOutputPath)) { @@ -106,7 +97,7 @@ describe("GridsetProcessor", () => { } }); - it("should preserve original file size better than saveFromTree", async () => { + it('should preserve original file size better than saveFromTree', async () => { const processor = new GridsetProcessor(); // Load the original file @@ -129,7 +120,7 @@ describe("GridsetProcessor", () => { expect(modifiedSize / originalSize).toBeGreaterThan(0.8); }); - it("should produce a valid loadable gridset", async () => { + it('should produce a valid loadable gridset', async () => { const processor = new GridsetProcessor(); // Load the original file @@ -149,7 +140,7 @@ describe("GridsetProcessor", () => { expect(savedTree.rootId).toBe(tree.rootId); }); - it("should handle empty tree by copying original", async () => { + it('should handle empty tree by copying original', async () => { const processor = new GridsetProcessor(); // Create an empty tree @@ -160,7 +151,7 @@ describe("GridsetProcessor", () => { dashboardId: null, metadata: {}, addPage() { - throw new Error("Not implemented"); + throw new Error('Not implemented'); }, getPage() { return undefined; diff --git a/test/gridsetResolver.test.ts b/test/gridsetResolver.test.ts index 7096ff9..70dd835 100644 --- a/test/gridsetResolver.test.ts +++ b/test/gridsetResolver.test.ts @@ -1,7 +1,7 @@ -import AdmZip from "adm-zip"; -import { resolveGrid3CellImage } from "../src/processors/gridset/resolver"; +import AdmZip from 'adm-zip'; +import { resolveGrid3CellImage } from '../src/processors/gridset/resolver'; -describe("resolveGrid3CellImage", () => { +describe('resolveGrid3CellImage', () => { function mkZip(entries: Record): AdmZip { const zip = new AdmZip(); for (const [name, data] of Object.entries(entries)) { @@ -10,99 +10,99 @@ describe("resolveGrid3CellImage", () => { return zip; } - it("resolves declared image in Images/ subfolder", async () => { + it('resolves declared image in Images/ subfolder', async () => { const zip = mkZip({ - "Grids/Home/Images/dog.png": "PNGDATA", + 'Grids/Home/Images/dog.png': 'PNGDATA', }); const p = resolveGrid3CellImage(zip, { - baseDir: "Grids/Home/", - imageName: "dog.png", + baseDir: 'Grids/Home/', + imageName: 'dog.png', }); - expect(p).toBe("Grids/Home/Images/dog.png"); + expect(p).toBe('Grids/Home/Images/dog.png'); }); - it("uses FileMap dynamic files with coordinate prefix", async () => { + it('uses FileMap dynamic files with coordinate prefix', async () => { const zip = mkZip({ - "Grids/Home/1-5-0-text-0.jpeg": "IMG", - "Grids/Home/1-5.jpeg": "ALT", + 'Grids/Home/1-5-0-text-0.jpeg': 'IMG', + 'Grids/Home/1-5.jpeg': 'ALT', }); const p = resolveGrid3CellImage(zip, { - baseDir: "Grids/Home/", + baseDir: 'Grids/Home/', x: 1, y: 5, - dynamicFiles: ["Grids/Home/1-5-0-text-0.jpeg"], + dynamicFiles: ['Grids/Home/1-5-0-text-0.jpeg'], }); - expect(p).toBe("Grids/Home/1-5-0-text-0.jpeg"); + expect(p).toBe('Grids/Home/1-5-0-text-0.jpeg'); }); - it("falls back to coordinate guesses when no name or map", async () => { + it('falls back to coordinate guesses when no name or map', async () => { const zip = mkZip({ - "Grids/Home/1-1.jpeg": "IMG", + 'Grids/Home/1-1.jpeg': 'IMG', }); const p = resolveGrid3CellImage(zip, { - baseDir: "Grids/Home/", + baseDir: 'Grids/Home/', x: 1, y: 1, }); - expect(p).toBe("Grids/Home/1-1.jpeg"); + expect(p).toBe('Grids/Home/1-1.jpeg'); }); - it("treats built-in [grid3x] names as non-zip assets unless mapped", async () => { + it('treats built-in [grid3x] names as non-zip assets unless mapped', async () => { const zip = mkZip({}); const p1 = resolveGrid3CellImage(zip, { - baseDir: "Grids/Home/", - imageName: "[grid3x]Home", + baseDir: 'Grids/Home/', + imageName: '[grid3x]Home', }); expect(p1).toBeNull(); const p2 = resolveGrid3CellImage(zip, { - baseDir: "Grids/Home/", - imageName: "[grid3x]Home", - builtinHandler: () => "builtin://home", + baseDir: 'Grids/Home/', + imageName: '[grid3x]Home', + builtinHandler: () => 'builtin://home', }); - expect(p2).toBe("builtin://home"); + expect(p2).toBe('builtin://home'); }); it('resolves coordinate-prefixed image names starting with "-"', async () => { const zip = mkZip({ - "Grids/Home/1-4-0-text-0.jpeg": "IMG", - "Grids/Home/2-3-0-text-0.png": "PNG", + 'Grids/Home/1-4-0-text-0.jpeg': 'IMG', + 'Grids/Home/2-3-0-text-0.png': 'PNG', }); const p1 = resolveGrid3CellImage(zip, { - baseDir: "Grids/Home/", - imageName: "-0-text-0.jpeg", + baseDir: 'Grids/Home/', + imageName: '-0-text-0.jpeg', x: 1, y: 4, }); - expect(p1).toBe("Grids/Home/1-4-0-text-0.jpeg"); + expect(p1).toBe('Grids/Home/1-4-0-text-0.jpeg'); const p2 = resolveGrid3CellImage(zip, { - baseDir: "Grids/Home/", - imageName: "-0-text-0.png", + baseDir: 'Grids/Home/', + imageName: '-0-text-0.png', x: 2, y: 3, }); - expect(p2).toBe("Grids/Home/2-3-0-text-0.png"); + expect(p2).toBe('Grids/Home/2-3-0-text-0.png'); }); - it("returns null for coordinate-prefixed names when file does not exist", async () => { + it('returns null for coordinate-prefixed names when file does not exist', async () => { const zip = mkZip({}); const p = resolveGrid3CellImage(zip, { - baseDir: "Grids/Home/", - imageName: "-0-text-0.png", + baseDir: 'Grids/Home/', + imageName: '-0-text-0.png', x: 1, y: 4, }); expect(p).toBeNull(); }); - it("returns null for coordinate-prefixed names when coordinates are missing", async () => { + it('returns null for coordinate-prefixed names when coordinates are missing', async () => { const zip = mkZip({ - "Grids/Home/1-4-0-text-0.jpeg": "IMG", + 'Grids/Home/1-4-0-text-0.jpeg': 'IMG', }); const p = resolveGrid3CellImage(zip, { - baseDir: "Grids/Home/", - imageName: "-0-text-0.jpeg", + baseDir: 'Grids/Home/', + imageName: '-0-text-0.jpeg', // No x, y provided }); expect(p).toBeNull(); diff --git a/test/gridsetWordlistHelpers.test.ts b/test/gridsetWordlistHelpers.test.ts index d5939bd..8b81dba 100644 --- a/test/gridsetWordlistHelpers.test.ts +++ b/test/gridsetWordlistHelpers.test.ts @@ -1,4 +1,4 @@ -import AdmZip from "adm-zip"; +import AdmZip from 'adm-zip'; import { createWordlist, extractWordlists, @@ -6,120 +6,120 @@ import { wordlistToXml, WordList, WordListItem, -} from "../src/processors/gridset/wordlistHelpers"; +} from '../src/processors/gridset/wordlistHelpers'; -describe("Grid3 Wordlist Helpers", () => { - describe("createWordlist", () => { - it("creates wordlist from simple string array", async () => { - const input = ["hello", "goodbye", "thank you"]; +describe('Grid3 Wordlist Helpers', () => { + describe('createWordlist', () => { + it('creates wordlist from simple string array', async () => { + const input = ['hello', 'goodbye', 'thank you']; const wordlist = createWordlist(input); expect(wordlist.items).toHaveLength(3); - expect(wordlist.items[0].text).toBe("hello"); - expect(wordlist.items[1].text).toBe("goodbye"); - expect(wordlist.items[2].text).toBe("thank you"); + expect(wordlist.items[0].text).toBe('hello'); + expect(wordlist.items[1].text).toBe('goodbye'); + expect(wordlist.items[2].text).toBe('thank you'); }); - it("creates wordlist from array of WordListItem objects", async () => { + it('creates wordlist from array of WordListItem objects', async () => { const input: WordListItem[] = [ { - text: "hello", - image: "[WIDGIT]greetings/hello.emf", - partOfSpeech: "Interjection", + text: 'hello', + image: '[WIDGIT]greetings/hello.emf', + partOfSpeech: 'Interjection', }, { - text: "goodbye", - image: "[WIDGIT]greetings/goodbye.emf", - partOfSpeech: "Interjection", + text: 'goodbye', + image: '[WIDGIT]greetings/goodbye.emf', + partOfSpeech: 'Interjection', }, ]; const wordlist = createWordlist(input); expect(wordlist.items).toHaveLength(2); - expect(wordlist.items[0].text).toBe("hello"); - expect(wordlist.items[0].image).toBe("[WIDGIT]greetings/hello.emf"); - expect(wordlist.items[0].partOfSpeech).toBe("Interjection"); + expect(wordlist.items[0].text).toBe('hello'); + expect(wordlist.items[0].image).toBe('[WIDGIT]greetings/hello.emf'); + expect(wordlist.items[0].partOfSpeech).toBe('Interjection'); }); - it("creates wordlist from dictionary of strings", async () => { + it('creates wordlist from dictionary of strings', async () => { const input = { - greeting: "hello", - farewell: "goodbye", - gratitude: "thank you", + greeting: 'hello', + farewell: 'goodbye', + gratitude: 'thank you', }; const wordlist = createWordlist(input); expect(wordlist.items).toHaveLength(3); - expect(wordlist.items.map((i) => i.text)).toContain("hello"); - expect(wordlist.items.map((i) => i.text)).toContain("goodbye"); + expect(wordlist.items.map((i) => i.text)).toContain('hello'); + expect(wordlist.items.map((i) => i.text)).toContain('goodbye'); }); - it("creates wordlist from dictionary of objects", async () => { + it('creates wordlist from dictionary of objects', async () => { const input: Record = { - greeting: { text: "hello", partOfSpeech: "Interjection" }, - farewell: { text: "goodbye", partOfSpeech: "Interjection" }, + greeting: { text: 'hello', partOfSpeech: 'Interjection' }, + farewell: { text: 'goodbye', partOfSpeech: 'Interjection' }, }; const wordlist = createWordlist(input); expect(wordlist.items).toHaveLength(2); - expect(wordlist.items[0].partOfSpeech).toBe("Interjection"); + expect(wordlist.items[0].partOfSpeech).toBe('Interjection'); }); - it("handles empty array", async () => { + it('handles empty array', async () => { const wordlist = createWordlist([]); expect(wordlist.items).toHaveLength(0); }); - it("handles empty object", async () => { + it('handles empty object', async () => { const wordlist = createWordlist({}); expect(wordlist.items).toHaveLength(0); }); }); - describe("wordlistToXml", () => { - it("converts wordlist to valid XML", async () => { + describe('wordlistToXml', () => { + it('converts wordlist to valid XML', async () => { const wordlist: WordList = { items: [ { - text: "hello", - image: "[WIDGIT]hello.emf", - partOfSpeech: "Interjection", + text: 'hello', + image: '[WIDGIT]hello.emf', + partOfSpeech: 'Interjection', }, - { text: "goodbye", partOfSpeech: "Interjection" }, + { text: 'goodbye', partOfSpeech: 'Interjection' }, ], }; const xml = wordlistToXml(wordlist); - expect(xml).toContain(""); - expect(xml).toContain(""); - expect(xml).toContain(""); - expect(xml).toContain("hello"); - expect(xml).toContain("goodbye"); - expect(xml).toContain("[WIDGIT]hello.emf"); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain('hello'); + expect(xml).toContain('goodbye'); + expect(xml).toContain('[WIDGIT]hello.emf'); }); - it("handles single item wordlist", async () => { + it('handles single item wordlist', async () => { const wordlist: WordList = { - items: [{ text: "hello" }], + items: [{ text: 'hello' }], }; const xml = wordlistToXml(wordlist); - expect(xml).toContain("hello"); - expect(xml).toContain(""); + expect(xml).toContain('hello'); + expect(xml).toContain(''); }); - it("includes PartOfSpeech as Unknown when not specified", async () => { + it('includes PartOfSpeech as Unknown when not specified', async () => { const wordlist: WordList = { - items: [{ text: "hello" }], + items: [{ text: 'hello' }], }; const xml = wordlistToXml(wordlist); - expect(xml).toContain("Unknown"); + expect(xml).toContain('Unknown'); }); }); - describe("extractWordlists", () => { + describe('extractWordlists', () => { function createTestGridset(gridName: string, wordlistXml: string): Buffer { const zip = new AdmZip(); @@ -145,11 +145,11 @@ describe("Grid3 Wordlist Helpers", () => { ${wordlistXml} `; - zip.addFile(`Grids/${gridName}/grid.xml`, Buffer.from(gridXml, "utf8")); + zip.addFile(`Grids/${gridName}/grid.xml`, Buffer.from(gridXml, 'utf8')); return zip.toBuffer(); } - it("extracts wordlist from gridset", async () => { + it('extracts wordlist from gridset', async () => { const wordlistXml = ` @@ -165,24 +165,24 @@ describe("Grid3 Wordlist Helpers", () => { `; - const gridset = createTestGridset("Greetings", wordlistXml); + const gridset = createTestGridset('Greetings', wordlistXml); const wordlists = await extractWordlists(gridset); expect(wordlists.size).toBe(1); - expect(wordlists.has("Greetings")).toBe(true); + expect(wordlists.has('Greetings')).toBe(true); - const wordlist = wordlists.get("Greetings"); + const wordlist = wordlists.get('Greetings'); expect(wordlist).toBeDefined(); if (!wordlist) { return; } expect(wordlist.items).toHaveLength(2); - expect(wordlist.items[0].text).toBe("hello"); - expect(wordlist.items[0].image).toBe("[WIDGIT]hello.emf"); - expect(wordlist.items[1].text).toBe("goodbye"); + expect(wordlist.items[0].text).toBe('hello'); + expect(wordlist.items[0].image).toBe('[WIDGIT]hello.emf'); + expect(wordlist.items[1].text).toBe('goodbye'); }); - it("returns empty map for gridset without wordlists", async () => { + it('returns empty map for gridset without wordlists', async () => { const zip = new AdmZip(); const gridXml = ` @@ -190,13 +190,13 @@ describe("Grid3 Wordlist Helpers", () => { `; - zip.addFile("Grids/Home/grid.xml", Buffer.from(gridXml, "utf8")); + zip.addFile('Grids/Home/grid.xml', Buffer.from(gridXml, 'utf8')); const wordlists = await extractWordlists(zip.toBuffer()); expect(wordlists.size).toBe(0); }); - it("handles multiple grids with wordlists", async () => { + it('handles multiple grids with wordlists', async () => { const zip = new AdmZip(); const createGrid = (name: string, items: string[]) => { @@ -206,9 +206,9 @@ describe("Grid3 Wordlist Helpers", () => { ${item} Unknown - `, + ` ) - .join(""); + .join(''); return ` @@ -222,29 +222,29 @@ describe("Grid3 Wordlist Helpers", () => { }; zip.addFile( - "Grids/Greetings/grid.xml", - Buffer.from(createGrid("Greetings", ["hello", "hi"]), "utf8"), + 'Grids/Greetings/grid.xml', + Buffer.from(createGrid('Greetings', ['hello', 'hi']), 'utf8') ); zip.addFile( - "Grids/Farewells/grid.xml", - Buffer.from(createGrid("Farewells", ["goodbye", "bye"]), "utf8"), + 'Grids/Farewells/grid.xml', + Buffer.from(createGrid('Farewells', ['goodbye', 'bye']), 'utf8') ); const wordlists = await extractWordlists(zip.toBuffer()); expect(wordlists.size).toBe(2); - expect(wordlists.get("Greetings")?.items).toHaveLength(2); - expect(wordlists.get("Farewells")?.items).toHaveLength(2); + expect(wordlists.get('Greetings')?.items).toHaveLength(2); + expect(wordlists.get('Farewells')?.items).toHaveLength(2); }); - it("throws error for invalid gridset buffer", async () => { - const invalidBuffer = Buffer.from("not a zip file"); + it('throws error for invalid gridset buffer', async () => { + const invalidBuffer = Buffer.from('not a zip file'); await expect(async () => { await extractWordlists(invalidBuffer); }).rejects.toThrow(); }); - it("skips grids with malformed wordlist XML", async () => { + it('skips grids with malformed wordlist XML', async () => { const zip = new AdmZip(); const gridXml = ` @@ -255,7 +255,7 @@ describe("Grid3 Wordlist Helpers", () => { `; - zip.addFile("Grids/Test/grid.xml", Buffer.from(gridXml, "utf8")); + zip.addFile('Grids/Test/grid.xml', Buffer.from(gridXml, 'utf8')); const wordlists = await extractWordlists(zip.toBuffer()); // Should not throw, just skip the malformed grid @@ -263,11 +263,8 @@ describe("Grid3 Wordlist Helpers", () => { }); }); - describe("updateWordlist", () => { - function createTestGridset( - gridName: string, - initialWordlistXml?: string, - ): Buffer { + describe('updateWordlist', () => { + function createTestGridset(gridName: string, initialWordlistXml?: string): Buffer { const zip = new AdmZip(); const wordlistSection = @@ -302,91 +299,89 @@ describe("Grid3 Wordlist Helpers", () => { ${wordlistSection} `; - zip.addFile(`Grids/${gridName}/grid.xml`, Buffer.from(gridXml, "utf8")); + zip.addFile(`Grids/${gridName}/grid.xml`, Buffer.from(gridXml, 'utf8')); return zip.toBuffer(); } - it("updates wordlist in existing grid", async () => { - const gridset = createTestGridset("Greetings"); - const newWordlist = createWordlist(["hello", "hi", "hey"]); + it('updates wordlist in existing grid', async () => { + const gridset = createTestGridset('Greetings'); + const newWordlist = createWordlist(['hello', 'hi', 'hey']); - const updated = await updateWordlist(gridset, "Greetings", newWordlist); + const updated = await updateWordlist(gridset, 'Greetings', newWordlist); const wordlists = await extractWordlists(updated); - expect(wordlists.has("Greetings")).toBe(true); - const wordlist = wordlists.get("Greetings"); + expect(wordlists.has('Greetings')).toBe(true); + const wordlist = wordlists.get('Greetings'); expect(wordlist).toBeDefined(); if (!wordlist) { return; } expect(wordlist.items).toHaveLength(3); - expect(wordlist.items.map((i) => i.text)).toEqual(["hello", "hi", "hey"]); + expect(wordlist.items.map((i) => i.text)).toEqual(['hello', 'hi', 'hey']); }); - it("updates wordlist with metadata", async () => { - const gridset = createTestGridset("Greetings"); + it('updates wordlist with metadata', async () => { + const gridset = createTestGridset('Greetings'); const newWordlist = createWordlist([ { - text: "hello", - image: "[WIDGIT]hello.emf", - partOfSpeech: "Interjection", + text: 'hello', + image: '[WIDGIT]hello.emf', + partOfSpeech: 'Interjection', }, { - text: "goodbye", - image: "[WIDGIT]goodbye.emf", - partOfSpeech: "Interjection", + text: 'goodbye', + image: '[WIDGIT]goodbye.emf', + partOfSpeech: 'Interjection', }, ]); - const updated = await updateWordlist(gridset, "Greetings", newWordlist); + const updated = await updateWordlist(gridset, 'Greetings', newWordlist); const wordlists = await extractWordlists(updated); - const wordlist = wordlists.get("Greetings"); + const wordlist = wordlists.get('Greetings'); expect(wordlist).toBeDefined(); if (!wordlist) { return; } - expect(wordlist.items[0].image).toBe("[WIDGIT]hello.emf"); - expect(wordlist.items[0].partOfSpeech).toBe("Interjection"); + expect(wordlist.items[0].image).toBe('[WIDGIT]hello.emf'); + expect(wordlist.items[0].partOfSpeech).toBe('Interjection'); }); - it("replaces existing wordlist completely", async () => { - const gridset = createTestGridset("Greetings"); + it('replaces existing wordlist completely', async () => { + const gridset = createTestGridset('Greetings'); const extracted1 = await extractWordlists(gridset); - expect(extracted1.get("Greetings")?.items[0].text).toBe("old"); + expect(extracted1.get('Greetings')?.items[0].text).toBe('old'); - const newWordlist = createWordlist(["new1", "new2"]); - const updated = await updateWordlist(gridset, "Greetings", newWordlist); + const newWordlist = createWordlist(['new1', 'new2']); + const updated = await updateWordlist(gridset, 'Greetings', newWordlist); const extracted2 = await extractWordlists(updated); - expect(extracted2.get("Greetings")?.items).toHaveLength(2); - expect(extracted2.get("Greetings")?.items[0].text).toBe("new1"); + expect(extracted2.get('Greetings')?.items).toHaveLength(2); + expect(extracted2.get('Greetings')?.items[0].text).toBe('new1'); }); - it("throws error for non-existent grid", async () => { - const gridset = createTestGridset("Greetings"); - const newWordlist = createWordlist(["hello"]); + it('throws error for non-existent grid', async () => { + const gridset = createTestGridset('Greetings'); + const newWordlist = createWordlist(['hello']); await expect(async () => { - await updateWordlist(gridset, "NonExistent", newWordlist); + await updateWordlist(gridset, 'NonExistent', newWordlist); }).rejects.toThrow('Grid "NonExistent" not found in gridset'); }); - it("throws error for invalid gridset buffer", async () => { - const invalidBuffer = Buffer.from("not a zip file"); - const newWordlist = createWordlist(["hello"]); + it('throws error for invalid gridset buffer', async () => { + const invalidBuffer = Buffer.from('not a zip file'); + const newWordlist = createWordlist(['hello']); await expect(async () => { - await updateWordlist(invalidBuffer, "Greetings", newWordlist); + await updateWordlist(invalidBuffer, 'Greetings', newWordlist); }).rejects.toThrow(); }); - it("preserves other grids when updating one", async () => { + it('preserves other grids when updating one', async () => { const zip = new AdmZip(); - const createGrid = ( - name: string, - ) => ` + const createGrid = (name: string) => ` ${name}-id @@ -400,25 +395,15 @@ describe("Grid3 Wordlist Helpers", () => { `; - zip.addFile( - "Grids/Greetings/grid.xml", - Buffer.from(createGrid("Greetings"), "utf8"), - ); - zip.addFile( - "Grids/Farewells/grid.xml", - Buffer.from(createGrid("Farewells"), "utf8"), - ); + zip.addFile('Grids/Greetings/grid.xml', Buffer.from(createGrid('Greetings'), 'utf8')); + zip.addFile('Grids/Farewells/grid.xml', Buffer.from(createGrid('Farewells'), 'utf8')); - const newWordlist = createWordlist(["updated"]); - const updated = await updateWordlist( - zip.toBuffer(), - "Greetings", - newWordlist, - ); + const newWordlist = createWordlist(['updated']); + const updated = await updateWordlist(zip.toBuffer(), 'Greetings', newWordlist); const wordlists = await extractWordlists(updated); - expect(wordlists.get("Greetings")?.items[0].text).toBe("updated"); - expect(wordlists.get("Farewells")?.items[0].text).toBe("Farewells-item"); + expect(wordlists.get('Greetings')?.items[0].text).toBe('updated'); + expect(wordlists.get('Farewells')?.items[0].text).toBe('Farewells-item'); }); }); }); diff --git a/test/history.analytics.test.ts b/test/history.analytics.test.ts index cdea6d8..c2cf95a 100644 --- a/test/history.analytics.test.ts +++ b/test/history.analytics.test.ts @@ -1,88 +1,83 @@ -import { describe, expect, it, jest } from "@jest/globals"; +import { describe, expect, it, jest } from '@jest/globals'; -describe("History analytics wrappers (mocked)", () => { +describe('History analytics wrappers (mocked)', () => { afterEach(async () => { jest.resetModules(); jest.clearAllMocks(); }); - it("wraps platform helpers and unifies histories", async () => { + it('wraps platform helpers and unifies histories', async () => { jest.isolateModules(async () => { - jest.doMock("../src/processors/gridset/helpers", () => ({ + jest.doMock('../src/processors/gridset/helpers', () => ({ readGrid3History: jest.fn(() => [ { - id: "g1", - content: "grid single", + id: 'g1', + content: 'grid single', occurrences: [{ timestamp: new Date() }], }, ]), readGrid3HistoryForUser: jest.fn(() => [ { - id: "g-user", - content: "grid user", + id: 'g-user', + content: 'grid user', occurrences: [{ timestamp: new Date() }], }, ]), readAllGrid3History: jest.fn(() => [ { - id: "g-all", - content: "grid all", + id: 'g-all', + content: 'grid all', occurrences: [{ timestamp: new Date() }], }, ]), findGrid3Users: jest.fn(() => [ { - userName: "alice", - langCode: "en", - basePath: "p", - historyDbPath: "p/db", + userName: 'alice', + langCode: 'en', + basePath: 'p', + historyDbPath: 'p/db', }, ]), })); - jest.doMock("../src/processors/snap/helpers", () => ({ + jest.doMock('../src/processors/snap/helpers', () => ({ readSnapUsage: jest.fn(() => [ { - id: "s1", - content: "snap single", + id: 's1', + content: 'snap single', occurrences: [{ timestamp: new Date() }], - platform: { buttonId: "b1" }, + platform: { buttonId: 'b1' }, }, ]), readSnapUsageForUser: jest.fn(() => [ { - id: "s-user", - content: "snap user", + id: 's-user', + content: 'snap user', occurrences: [{ timestamp: new Date() }], }, ]), - findSnapUsers: jest.fn(() => [ - { userId: "u1", userPath: "p", vocabPaths: [] }, - ]), + findSnapUsers: jest.fn(() => [{ userId: 'u1', userPath: 'p', vocabPaths: [] }]), })); // Import after mocks are in place // eslint-disable-next-line @typescript-eslint/no-var-requires - const history = require("../src/utilities/analytics/history"); // eslint-disable-line @typescript-eslint/no-var-requires + const history = require('../src/utilities/analytics/history'); // eslint-disable-line @typescript-eslint/no-var-requires - const gridUserEntries = await history.readGrid3HistoryForUser("alice"); - expect(gridUserEntries[0].source).toBe("Grid"); - expect(gridUserEntries[0].content).toBe("grid user"); + const gridUserEntries = await history.readGrid3HistoryForUser('alice'); + expect(gridUserEntries[0].source).toBe('Grid'); + expect(gridUserEntries[0].content).toBe('grid user'); const gridAllEntries = await history.readAllGrid3History(); - expect(gridAllEntries[0].source).toBe("Grid"); + expect(gridAllEntries[0].source).toBe('Grid'); - const snapEntries = await history.readSnapUsageForUser("u1"); - expect(snapEntries[0].source).toBe("Snap"); + const snapEntries = await history.readSnapUsageForUser('u1'); + expect(snapEntries[0].source).toBe('Snap'); expect(await history.listGrid3Users()).toHaveLength(1); expect(await history.listSnapUsers()).toHaveLength(1); const unified = await history.collectUnifiedHistory(); - expect(unified.map((e: any) => e.source).sort()).toEqual([ - "Grid", - "Snap", - ]); + expect(unified.map((e: any) => e.source).sort()).toEqual(['Grid', 'Snap']); }); }); }); diff --git a/test/history.test.ts b/test/history.test.ts index 61421b9..5a97942 100644 --- a/test/history.test.ts +++ b/test/history.test.ts @@ -1,8 +1,8 @@ -import fs from "fs"; -import os from "os"; -import path from "path"; -import Database from "better-sqlite3"; -import { Analytics } from "../src/index"; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import Database from 'better-sqlite3'; +import { Analytics } from '../src/index'; const EPOCH_TICKS = 621355968000000000n; const TICKS_PER_MS = 10000n; @@ -11,8 +11,8 @@ function dateToTicks(date: Date): bigint { return BigInt(date.getTime()) * TICKS_PER_MS + EPOCH_TICKS; } -describe("History analytics", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "history-test-")); +describe('History analytics', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'history-test-')); afterAll(async () => { try { @@ -22,15 +22,15 @@ describe("History analytics", () => { } }); - it("converts .NET ticks to Date", async () => { - const now = new Date("2024-01-01T00:00:00Z"); + it('converts .NET ticks to Date', async () => { + const now = new Date('2024-01-01T00:00:00Z'); const ticks = dateToTicks(now); const converted = Analytics.dotNetTicksToDate(ticks); expect(converted.toISOString()).toBe(now.toISOString()); }); - it("reads Grid 3 history from sqlite", async () => { - const dbPath = path.join(tempDir, "grid3-history.sqlite"); + it('reads Grid 3 history from sqlite', async () => { + const dbPath = path.join(tempDir, 'grid3-history.sqlite'); const db = new Database(dbPath); db.exec(` CREATE TABLE Phrases (Id INTEGER PRIMARY KEY AUTOINCREMENT, Text TEXT NOT NULL, Content TEXT NOT NULL); @@ -45,29 +45,29 @@ describe("History analytics", () => { `); const phraseId = db - .prepare("INSERT INTO Phrases (Text, Content) VALUES (?, ?)") + .prepare('INSERT INTO Phrases (Text, Content) VALUES (?, ?)') .run( - "hello world", - "

Helloworld

", + 'hello world', + '

Helloworld

' ).lastInsertRowid as number; - const ts = dateToTicks(new Date("2024-02-02T10:00:00Z")); + const ts = dateToTicks(new Date('2024-02-02T10:00:00Z')); db.prepare( - "INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)", + 'INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)' ).run(phraseId, ts, 51.5, -1.2); const history = await Analytics.readGrid3History(dbPath); expect(history).toHaveLength(1); const entry = history[0] as Analytics.HistoryEntry; - expect(entry.source).toBe("Grid"); - expect(entry.content).toBe("Hello world"); + expect(entry.source).toBe('Grid'); + expect(entry.content).toBe('Hello world'); expect(entry.occurrences).toHaveLength(1); expect(entry.occurrences[0].latitude).toBeCloseTo(51.5); expect(entry.occurrences[0].longitude).toBeCloseTo(-1.2); }); - it("skips Grid 3 history rows without text and falls back to plain text when XML is missing", async () => { - const dbPath = path.join(tempDir, "grid3-history-missing.sqlite"); + it('skips Grid 3 history rows without text and falls back to plain text when XML is missing', async () => { + const dbPath = path.join(tempDir, 'grid3-history-missing.sqlite'); const db = new Database(dbPath); db.exec(` CREATE TABLE Phrases (Id INTEGER PRIMARY KEY AUTOINCREMENT, Text TEXT, Content TEXT); @@ -82,30 +82,30 @@ describe("History analytics", () => { `); const missingId = db - .prepare("INSERT INTO Phrases (Text, Content) VALUES (?, ?)") + .prepare('INSERT INTO Phrases (Text, Content) VALUES (?, ?)') .run(null, null).lastInsertRowid as number; const fallbackId = db - .prepare("INSERT INTO Phrases (Text, Content) VALUES (?, ?)") - .run("plain text only", "").lastInsertRowid as number; + .prepare('INSERT INTO Phrases (Text, Content) VALUES (?, ?)') + .run('plain text only', '').lastInsertRowid as number; - const ts1 = dateToTicks(new Date("2024-04-04T00:00:00Z")); - const ts2 = dateToTicks(new Date("2024-04-04T00:01:00Z")); + const ts1 = dateToTicks(new Date('2024-04-04T00:00:00Z')); + const ts2 = dateToTicks(new Date('2024-04-04T00:01:00Z')); db.prepare( - "INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)", + 'INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)' ).run(missingId, ts1, null, null); db.prepare( - "INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)", + 'INSERT INTO PhraseHistory (PhraseId, Timestamp, Latitude, Longitude) VALUES (?, ?, ?, ?)' ).run(fallbackId, ts2, null, null); const history = await Analytics.readGrid3History(dbPath); expect(history).toHaveLength(1); - expect(history[0].content).toBe("plain text only"); + expect(history[0].content).toBe('plain text only'); expect(history[0].occurrences).toHaveLength(1); }); - it("reads Snap usage from pageset sqlite", async () => { - const pagesetPath = path.join(tempDir, "snap.sps"); + it('reads Snap usage from pageset sqlite', async () => { + const pagesetPath = path.join(tempDir, 'snap.sps'); const db = new Database(pagesetPath); db.exec(` CREATE TABLE Button ( @@ -124,22 +124,24 @@ describe("History analytics", () => { ); `); - const buttonId = "btn-1"; - db.prepare( - "INSERT INTO Button (Label, Message, UniqueId) VALUES (?, ?, ?)", - ).run("Hello", "Hello there", buttonId); + const buttonId = 'btn-1'; + db.prepare('INSERT INTO Button (Label, Message, UniqueId) VALUES (?, ?, ?)').run( + 'Hello', + 'Hello there', + buttonId + ); - const ts = dateToTicks(new Date("2024-03-03T12:00:00Z")); + const ts = dateToTicks(new Date('2024-03-03T12:00:00Z')); db.prepare( - "INSERT INTO ButtonUsage (Timestamp, ButtonUniqueId, Modeling, AccessMethod, BlockId) VALUES (?, ?, ?, ?, ?)", + 'INSERT INTO ButtonUsage (Timestamp, ButtonUniqueId, Modeling, AccessMethod, BlockId) VALUES (?, ?, ?, ?, ?)' ).run(ts, buttonId, 0, 2, 1); const history = await Analytics.readSnapUsage(pagesetPath); expect(history).toHaveLength(1); const entry = history[0] as Analytics.HistoryEntry; - expect(entry.source).toBe("Snap"); + expect(entry.source).toBe('Snap'); expect(entry.platform?.buttonId).toBe(buttonId); - expect(entry.content).toContain("Hello"); + expect(entry.content).toContain('Hello'); expect(entry.occurrences[0].modeling).toBe(false); expect(entry.occurrences[0].accessMethod).toBe(2); }); diff --git a/test/index.entrypoints.test.ts b/test/index.entrypoints.test.ts index 1f03ed4..fc3cc90 100644 --- a/test/index.entrypoints.test.ts +++ b/test/index.entrypoints.test.ts @@ -1,16 +1,16 @@ -import * as browserEntry from "../src/index.browser"; -import * as nodeEntry from "../src/index.node"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import * as browserEntry from '../src/index.browser'; +import * as nodeEntry from '../src/index.node'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -describe("entrypoint exports", () => { - it("browser entry resolves supported processors", () => { - const dot = browserEntry.getProcessor(".dot"); - const grid = browserEntry.getProcessor(".gridset"); - const snap = browserEntry.getProcessor(".sps"); - const touch = browserEntry.getProcessor(".ce"); +describe('entrypoint exports', () => { + it('browser entry resolves supported processors', () => { + const dot = browserEntry.getProcessor('.dot'); + const grid = browserEntry.getProcessor('.gridset'); + const snap = browserEntry.getProcessor('.sps'); + const touch = browserEntry.getProcessor('.ce'); expect(dot).toBeInstanceOf(DotProcessor); expect(grid).toBeInstanceOf(GridsetProcessor); @@ -18,43 +18,28 @@ describe("entrypoint exports", () => { expect(touch).toBeInstanceOf(TouchChatProcessor); const extensions = browserEntry.getSupportedExtensions(); - expect(browserEntry.isExtensionSupported(".dot")).toBe(true); - expect(browserEntry.isExtensionSupported(".ce")).toBe(true); + expect(browserEntry.isExtensionSupported('.dot')).toBe(true); + expect(browserEntry.isExtensionSupported('.ce')).toBe(true); expect(extensions).toEqual( - expect.arrayContaining([ - ".dot", - ".gridset", - ".sps", - ".spb", - ".ce", - ".plist", - ".grd", - ]), + expect.arrayContaining(['.dot', '.gridset', '.sps', '.spb', '.ce', '.plist', '.grd']) ); - expect(() => browserEntry.getProcessor(".unknown")).toThrow(); + expect(() => browserEntry.getProcessor('.unknown')).toThrow(); }); - it("node entry resolves supported processors", () => { - const snap = nodeEntry.getProcessor(".sps"); - const touch = nodeEntry.getProcessor(".ce"); - const grid = nodeEntry.getProcessor(".gridsetx"); + it('node entry resolves supported processors', () => { + const snap = nodeEntry.getProcessor('.sps'); + const touch = nodeEntry.getProcessor('.ce'); + const grid = nodeEntry.getProcessor('.gridsetx'); expect(snap).toBeInstanceOf(SnapProcessor); expect(touch).toBeInstanceOf(TouchChatProcessor); expect(grid).toBeInstanceOf(GridsetProcessor); const extensions = nodeEntry.getSupportedExtensions(); - expect(nodeEntry.isExtensionSupported(".gridsetx")).toBe(true); + expect(nodeEntry.isExtensionSupported('.gridsetx')).toBe(true); expect(extensions).toEqual( - expect.arrayContaining([ - ".gridsetx", - ".sps", - ".spb", - ".ce", - ".obf", - ".obz", - ]), + expect.arrayContaining(['.gridsetx', '.sps', '.spb', '.ce', '.obf', '.obz']) ); - expect(() => nodeEntry.getProcessor(".unknown")).toThrow(); + expect(() => nodeEntry.getProcessor('.unknown')).toThrow(); }); }); diff --git a/test/integration.test.ts b/test/integration.test.ts index 652eab8..096c477 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,21 +1,21 @@ // Integration tests for CLI, processor factory, and cross-format compatibility -import fs from "fs"; -import path from "path"; -import { execSync } from "child_process"; -import { getProcessor } from "../src/index"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { ExcelProcessor } from "../src/processors/excelProcessor"; -import { OpmlProcessor } from "../src/processors/opmlProcessor"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; -import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; -import { AstericsGridProcessor } from "../src/processors/astericsGridProcessor"; - -describe("Integration Tests", () => { - const tempDir = path.join(__dirname, "temp_integration"); - const examplesDir = path.join(__dirname, "../examples"); +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; +import { getProcessor } from '../src/index'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { ExcelProcessor } from '../src/processors/excelProcessor'; +import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; +import { AstericsGridProcessor } from '../src/processors/astericsGridProcessor'; + +describe('Integration Tests', () => { + const tempDir = path.join(__dirname, 'temp_integration'); + const examplesDir = path.join(__dirname, '../examples'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -29,101 +29,98 @@ describe("Integration Tests", () => { } }); - describe("CLI Integration", () => { - const cliPath = path.join(__dirname, "../dist/cli.js"); + describe('CLI Integration', () => { + const cliPath = path.join(__dirname, '../dist/cli.js'); let cliAvailable = false; beforeAll(async () => { // Check if CLI is available cliAvailable = fs.existsSync(cliPath); if (!cliAvailable) { - console.log("CLI not available, skipping CLI tests"); + console.log('CLI not available, skipping CLI tests'); } }); - it("should display help when no arguments provided", async () => { + it('should display help when no arguments provided', async () => { if (!cliAvailable) { - console.log("Skipping CLI test - CLI not available"); + console.log('Skipping CLI test - CLI not available'); return; } try { const result = execSync(`node ${cliPath}`, { - encoding: "utf8", - stdio: "pipe", + encoding: 'utf8', + stdio: 'pipe', }); - expect(result).toContain("Usage:"); + expect(result).toContain('Usage:'); } catch (error: any) { // CLI might exit with non-zero code when showing help - expect(error.stdout || error.stderr).toContain("Usage:"); + expect(error.stdout || error.stderr).toContain('Usage:'); } }); - it("should process DOT files via CLI", async () => { - const dotFile = path.join(examplesDir, "example.dot"); + it('should process DOT files via CLI', async () => { + const dotFile = path.join(examplesDir, 'example.dot'); if (!cliAvailable || !fs.existsSync(dotFile)) { - console.log("Skipping CLI DOT test - files not available"); + console.log('Skipping CLI DOT test - files not available'); return; } - const outputFile = path.join(tempDir, "cli_output.json"); + const outputFile = path.join(tempDir, 'cli_output.json'); try { - const _result = execSync( - `node ${cliPath} extract-texts ${dotFile} ${outputFile}`, - { - encoding: "utf8", - stdio: "pipe", - }, - ); + const _result = execSync(`node ${cliPath} extract-texts ${dotFile} ${outputFile}`, { + encoding: 'utf8', + stdio: 'pipe', + }); expect(fs.existsSync(outputFile)).toBe(true); - const outputContent = JSON.parse(fs.readFileSync(outputFile, "utf8")); + const outputContent = JSON.parse(fs.readFileSync(outputFile, 'utf8')); expect(Array.isArray(outputContent)).toBe(true); expect(outputContent.length).toBeGreaterThan(0); } catch (error: any) { - console.log("CLI test failed:", error.message); + console.log('CLI test failed:', error.message); // CLI might not be fully implemented yet } }); - it("should handle invalid file formats gracefully via CLI", async () => { + it('should handle invalid file formats gracefully via CLI', async () => { if (!cliAvailable) { - console.log("Skipping CLI error test - CLI not available"); + console.log('Skipping CLI error test - CLI not available'); return; } - const invalidFile = path.join(tempDir, "invalid.xyz"); - fs.writeFileSync(invalidFile, "invalid content"); + const invalidFile = path.join(tempDir, 'invalid.xyz'); + fs.writeFileSync(invalidFile, 'invalid content'); try { execSync(`node ${cliPath} extract-texts ${invalidFile}`, { - encoding: "utf8", - stdio: "pipe", + encoding: 'utf8', + stdio: 'pipe', }); } catch (error: any) { // Should fail gracefully with meaningful error expect(error.status).not.toBe(0); - expect(error.stderr || error.stdout).toContain("error"); + expect(error.stderr || error.stdout).toContain('error'); } }); }); - describe("Processor Factory Integration", () => { - it("should return correct processor for each file extension", async () => { + describe('Processor Factory Integration', () => { + it('should return correct processor for each file extension', async () => { const testCases = [ - { ext: ".dot", expectedType: DotProcessor }, - { ext: ".xlsx", expectedType: ExcelProcessor }, - { ext: ".opml", expectedType: OpmlProcessor }, - { ext: ".obf", expectedType: ObfProcessor }, - { ext: ".obz", expectedType: ObfProcessor }, - { ext: ".gridset", expectedType: GridsetProcessor }, - { ext: ".gridsetx", expectedType: GridsetProcessor }, - { ext: ".spb", expectedType: SnapProcessor }, - { ext: ".sps", expectedType: SnapProcessor }, - { ext: ".ce", expectedType: TouchChatProcessor }, - { ext: ".plist", expectedType: ApplePanelsProcessor }, - { ext: ".grd", expectedType: AstericsGridProcessor }, + { ext: '.dot', expectedType: DotProcessor }, + { ext: '.xlsx', expectedType: ExcelProcessor }, + { ext: '.opml', expectedType: OpmlProcessor }, + { ext: '.obf', expectedType: ObfProcessor }, + { ext: '.obz', expectedType: ObfProcessor }, + { ext: '.gridset', expectedType: GridsetProcessor }, + { ext: '.gridsetx', expectedType: GridsetProcessor }, + { ext: '.spb', expectedType: SnapProcessor }, + { ext: '.sps', expectedType: SnapProcessor }, + { ext: '.ce', expectedType: TouchChatProcessor }, + { ext: '.plist', expectedType: ApplePanelsProcessor }, + { ext: '.grd', expectedType: AstericsGridProcessor }, ]; for (const { ext, expectedType } of testCases) { @@ -132,22 +129,22 @@ describe("Integration Tests", () => { } }); - it("should handle unknown file extensions", async () => { + it('should handle unknown file extensions', async () => { expect(() => { - getProcessor(".unknown"); + getProcessor('.unknown'); }).toThrow(); expect(() => { - getProcessor(".xyz"); + getProcessor('.xyz'); }).toThrow(); }); - it("should work with full file paths", async () => { + it('should work with full file paths', async () => { const testPaths = [ - "/path/to/file.dot", - "relative/path/file.opml", - "file.gridset", - "/complex/path/with.multiple.dots.obf", + '/path/to/file.dot', + 'relative/path/file.opml', + 'file.gridset', + '/complex/path/with.multiple.dots.obf', ]; for (const filePath of testPaths) { @@ -159,8 +156,8 @@ describe("Integration Tests", () => { }); }); - describe("Cross-Format Compatibility", () => { - it("should convert between compatible formats", async () => { + describe('Cross-Format Compatibility', () => { + it('should convert between compatible formats', async () => { // Create a simple tree structure const dotProcessor = new DotProcessor(); const opmlProcessor = new OpmlProcessor(); @@ -178,31 +175,26 @@ describe("Integration Tests", () => { // Load from DOT const tree = await dotProcessor.loadIntoTree(Buffer.from(dotContent)); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); - console.log("Original DOT tree pages:", Object.keys(tree.pages).length); + console.log('Original DOT tree pages:', Object.keys(tree.pages).length); // Save as OPML - const opmlPath = path.join(tempDir, "converted.opml"); + const opmlPath = path.join(tempDir, 'converted.opml'); await opmlProcessor.saveFromTree(tree, opmlPath); expect(fs.existsSync(opmlPath)).toBe(true); // Load back from OPML const reloadedTree = await opmlProcessor.loadIntoTree(opmlPath); - console.log( - "Reloaded OPML tree pages:", - Object.keys(reloadedTree.pages).length, - ); + console.log('Reloaded OPML tree pages:', Object.keys(reloadedTree.pages).length); // The page count might differ due to format differences, but should have at least some pages expect(Object.keys(reloadedTree.pages).length).toBeGreaterThan(0); // Verify content preservation - const originalTexts = await dotProcessor.extractTexts( - Buffer.from(dotContent), - ); + const originalTexts = await dotProcessor.extractTexts(Buffer.from(dotContent)); const convertedTexts = await opmlProcessor.extractTexts(opmlPath); - console.log("Original texts:", originalTexts); - console.log("Converted texts:", convertedTexts); + console.log('Original texts:', originalTexts); + console.log('Converted texts:', convertedTexts); // Should have some text content expect(originalTexts.length).toBeGreaterThan(0); @@ -213,42 +205,42 @@ describe("Integration Tests", () => { convertedTexts.some( (convertedText) => originalText.toLowerCase().includes(convertedText.toLowerCase()) || - convertedText.toLowerCase().includes(originalText.toLowerCase()), - ), + convertedText.toLowerCase().includes(originalText.toLowerCase()) + ) ); expect(hasCommonContent).toBe(true); }); - it("should preserve navigation structure across formats", async () => { + it('should preserve navigation structure across formats', async () => { const obfProcessor = new ObfProcessor(); const applePanelsProcessor = new ApplePanelsProcessor(); // Create OBF content with navigation const obfContent = { - id: "main", - name: "Main Board", + id: 'main', + name: 'Main Board', buttons: [ { - id: "btn1", - label: "Hello", - vocalization: "Hello World", + id: 'btn1', + label: 'Hello', + vocalization: 'Hello World', }, { - id: "btn2", - label: "Go Home", - load_board: { path: "home" }, + id: 'btn2', + label: 'Go Home', + load_board: { path: 'home' }, }, ], }; - const obfPath = path.join(tempDir, "nav_test.obf"); + const obfPath = path.join(tempDir, 'nav_test.obf'); fs.writeFileSync(obfPath, JSON.stringify(obfContent, null, 2)); // Load from OBF const tree = await obfProcessor.loadIntoTree(obfPath); // Convert to Apple Panels - const applePath = path.join(tempDir, "nav_test.plist"); + const applePath = path.join(tempDir, 'nav_test.plist'); await applePanelsProcessor.saveFromTree(tree, applePath); // Load back and verify navigation is preserved @@ -259,12 +251,12 @@ describe("Integration Tests", () => { expect(mainPage.buttons.length).toBe(2); const navButton = mainPage.buttons.find( - (btn) => btn.semanticAction?.intent === "NAVIGATE_TO", + (btn) => btn.semanticAction?.intent === 'NAVIGATE_TO' ); expect(navButton).toBeDefined(); }); - it("should handle translation workflows across formats", async () => { + it('should handle translation workflows across formats', async () => { const dotProcessor = new DotProcessor(); const gridsetProcessor = new GridsetProcessor(); @@ -277,37 +269,34 @@ describe("Integration Tests", () => { `; // Extract texts from DOT - const originalTexts = await dotProcessor.extractTexts( - Buffer.from(dotContent), - ); + const originalTexts = await dotProcessor.extractTexts(Buffer.from(dotContent)); expect(originalTexts.length).toBeGreaterThan(0); // Create translations const translations = new Map(); for (const text of originalTexts) { - if (text.toLowerCase().includes("hello")) { - translations.set(text, text.replace(/hello/gi, "hola")); + if (text.toLowerCase().includes('hello')) { + translations.set(text, text.replace(/hello/gi, 'hola')); } - if (text.toLowerCase().includes("world")) { - translations.set(text, text.replace(/world/gi, "mundo")); + if (text.toLowerCase().includes('world')) { + translations.set(text, text.replace(/world/gi, 'mundo')); } } if (translations.size > 0) { // Apply translations in DOT format - const translatedDotPath = path.join(tempDir, "translated.dot"); + const translatedDotPath = path.join(tempDir, 'translated.dot'); const _translatedDotResult = await dotProcessor.processTexts( Buffer.from(dotContent), translations, - translatedDotPath, + translatedDotPath ); expect(fs.existsSync(translatedDotPath)).toBe(true); // Load translated DOT and convert to GridSet - const translatedTree = - await dotProcessor.loadIntoTree(translatedDotPath); - const gridsetPath = path.join(tempDir, "translated.gridset"); + const translatedTree = await dotProcessor.loadIntoTree(translatedDotPath); + const gridsetPath = path.join(tempDir, 'translated.gridset'); try { await gridsetProcessor.saveFromTree(translatedTree, gridsetPath); @@ -315,22 +304,21 @@ describe("Integration Tests", () => { // Verify translations are preserved in GridSet format const gridsetBuffer = fs.readFileSync(gridsetPath); - const gridsetTexts = - await gridsetProcessor.extractTexts(gridsetBuffer); + const gridsetTexts = await gridsetProcessor.extractTexts(gridsetBuffer); const hasTranslations = gridsetTexts.some( - (text) => text.includes("hola") || text.includes("mundo"), + (text) => text.includes('hola') || text.includes('mundo') ); expect(hasTranslations).toBe(true); } catch (error) { - console.log("GridSet conversion test skipped due to:", error); + console.log('GridSet conversion test skipped due to:', error); } } }); }); - describe("End-to-End Workflows", () => { - it("should support complete AAC workflow: load -> extract -> translate -> save", async () => { + describe('End-to-End Workflows', () => { + it('should support complete AAC workflow: load -> extract -> translate -> save', async () => { const processor = new DotProcessor(); const originalContent = ` @@ -356,51 +344,41 @@ describe("Integration Tests", () => { // Step 3: Create translations (simulate translation service) const translations = new Map(); for (const text of texts) { - if (text.includes("Home")) - translations.set(text, text.replace("Home", "Casa")); - if (text.includes("Food")) - translations.set(text, text.replace("Food", "Comida")); - if (text.includes("Drink")) - translations.set(text, text.replace("Drink", "Bebida")); - if (text.includes("More")) - translations.set(text, text.replace("More", "Más")); - if (text.includes("want")) - translations.set(text, text.replace("want", "quiero")); + if (text.includes('Home')) translations.set(text, text.replace('Home', 'Casa')); + if (text.includes('Food')) translations.set(text, text.replace('Food', 'Comida')); + if (text.includes('Drink')) translations.set(text, text.replace('Drink', 'Bebida')); + if (text.includes('More')) translations.set(text, text.replace('More', 'Más')); + if (text.includes('want')) translations.set(text, text.replace('want', 'quiero')); } // Step 4: Apply translations - const translatedPath = path.join(tempDir, "workflow_translated.dot"); + const translatedPath = path.join(tempDir, 'workflow_translated.dot'); const _translatedResult = await processor.processTexts( Buffer.from(originalContent), translations, - translatedPath, + translatedPath ); expect(fs.existsSync(translatedPath)).toBe(true); // Step 5: Verify final result const finalTree = await processor.loadIntoTree(translatedPath); - expect(Object.keys(finalTree.pages).length).toBe( - Object.keys(tree.pages).length, - ); + expect(Object.keys(finalTree.pages).length).toBe(Object.keys(tree.pages).length); const finalTexts = await processor.extractTexts(translatedPath); const hasSpanishContent = finalTexts.some( - (text) => - text.includes("Casa") || - text.includes("Comida") || - text.includes("quiero"), + (text) => text.includes('Casa') || text.includes('Comida') || text.includes('quiero') ); expect(hasSpanishContent).toBe(true); }); - it("should handle batch processing of multiple files", async () => { + it('should handle batch processing of multiple files', async () => { const processor = new DotProcessor(); const testFiles = [ - { name: "test1.dot", content: 'digraph G { a [label="Test 1"]; }' }, - { name: "test2.dot", content: 'digraph G { b [label="Test 2"]; }' }, - { name: "test3.dot", content: 'digraph G { c [label="Test 3"]; }' }, + { name: 'test1.dot', content: 'digraph G { a [label="Test 1"]; }' }, + { name: 'test2.dot', content: 'digraph G { b [label="Test 2"]; }' }, + { name: 'test3.dot', content: 'digraph G { c [label="Test 3"]; }' }, ]; const results: any[] = []; diff --git a/test/memoryLeaks.test.ts b/test/memoryLeaks.test.ts index 060cd21..aec1df7 100644 --- a/test/memoryLeaks.test.ts +++ b/test/memoryLeaks.test.ts @@ -1,17 +1,17 @@ // Memory leak detection tests -import fs from "fs"; -import path from "path"; -import { performance } from "perf_hooks"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; - -describe("Memory Leak Detection Tests", () => { - const tempDir = path.join(__dirname, "temp_memory"); +import fs from 'fs'; +import path from 'path'; +import { performance } from 'perf_hooks'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; + +describe('Memory Leak Detection Tests', () => { + const tempDir = path.join(__dirname, 'temp_memory'); let warnSpy: jest.SpyInstance; beforeAll(async () => { - warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } @@ -43,10 +43,7 @@ describe("Memory Leak Detection Tests", () => { } // Helper function to create test data - function createTestTree( - pageCount: number = 5, - buttonsPerPage: number = 10, - ): AACTree { + function createTestTree(pageCount: number = 5, buttonsPerPage: number = 10): AACTree { const tree = new AACTree(); for (let p = 0; p < pageCount; p++) { @@ -61,11 +58,9 @@ describe("Memory Leak Detection Tests", () => { id: `btn_${p}_${b}`, label: `Button ${b} on Page ${p}`, message: `Message for button ${b} on page ${p}`, - type: Math.random() > 0.5 ? "SPEAK" : "NAVIGATE", + type: Math.random() > 0.5 ? 'SPEAK' : 'NAVIGATE', targetPageId: - Math.random() > 0.7 - ? `page_${Math.floor(Math.random() * pageCount)}` - : undefined, + Math.random() > 0.7 ? `page_${Math.floor(Math.random() * pageCount)}` : undefined, }); page.addButton(button); } @@ -76,8 +71,8 @@ describe("Memory Leak Detection Tests", () => { return tree; } - describe("Repeated Operations Memory Tests", () => { - it("should not leak memory during repeated loadIntoTree operations", async () => { + describe('Repeated Operations Memory Tests', () => { + it('should not leak memory during repeated loadIntoTree operations', async () => { const processor = new DotProcessor(); const testContent = ` digraph G { @@ -90,7 +85,7 @@ describe("Memory Leak Detection Tests", () => { `; const memBefore = getMemoryUsage(); - console.log("Memory before repeated loads:", memBefore); + console.log('Memory before repeated loads:', memBefore); // Perform many load operations for (let i = 0; i < 50; i++) { @@ -105,7 +100,7 @@ describe("Memory Leak Detection Tests", () => { forceGC(); const memAfter = getMemoryUsage(); - console.log("Memory after repeated loads:", memAfter); + console.log('Memory after repeated loads:', memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Memory increase: ${memoryIncrease}MB`); @@ -114,12 +109,12 @@ describe("Memory Leak Detection Tests", () => { expect(memoryIncrease).toBeLessThan(20); // Less than 20MB increase }); - it("should not leak memory during repeated saveFromTree operations", async () => { + it('should not leak memory during repeated saveFromTree operations', async () => { const processor = new DotProcessor(); const testTree = createTestTree(3, 5); const memBefore = getMemoryUsage(); - console.log("Memory before repeated saves:", memBefore); + console.log('Memory before repeated saves:', memBefore); // Perform many save operations for (let i = 0; i < 30; i++) { @@ -137,7 +132,7 @@ describe("Memory Leak Detection Tests", () => { forceGC(); const memAfter = getMemoryUsage(); - console.log("Memory after repeated saves:", memAfter); + console.log('Memory after repeated saves:', memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Memory increase: ${memoryIncrease}MB`); @@ -145,7 +140,7 @@ describe("Memory Leak Detection Tests", () => { expect(memoryIncrease).toBeLessThan(15); // Less than 15MB increase }); - it("should not leak memory during repeated translation operations", async () => { + it('should not leak memory during repeated translation operations', async () => { const processor = new DotProcessor(); const testContent = ` digraph G { @@ -157,14 +152,14 @@ describe("Memory Leak Detection Tests", () => { `; const translations = new Map([ - ["Hello", "Hola"], - ["World", "Mundo"], - ["Test", "Prueba"], - ["Go", "Ir"], + ['Hello', 'Hola'], + ['World', 'Mundo'], + ['Test', 'Prueba'], + ['Go', 'Ir'], ]); const memBefore = getMemoryUsage(); - console.log("Memory before repeated translations:", memBefore); + console.log('Memory before repeated translations:', memBefore); // Perform many translation operations for (let i = 0; i < 25; i++) { @@ -172,7 +167,7 @@ describe("Memory Leak Detection Tests", () => { const result = await processor.processTexts( Buffer.from(testContent), translations, - outputPath, + outputPath ); expect(result).toBeInstanceOf(Buffer); @@ -188,7 +183,7 @@ describe("Memory Leak Detection Tests", () => { forceGC(); const memAfter = getMemoryUsage(); - console.log("Memory after repeated translations:", memAfter); + console.log('Memory after repeated translations:', memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Memory increase: ${memoryIncrease}MB`); @@ -197,13 +192,13 @@ describe("Memory Leak Detection Tests", () => { }); }); - describe("Database Connection Memory Tests", () => { - it("should not leak memory with repeated database operations", async () => { + describe('Database Connection Memory Tests', () => { + it('should not leak memory with repeated database operations', async () => { const processor = new SnapProcessor(); const testTree = createTestTree(2, 8); const memBefore = getMemoryUsage(); - console.log("Memory before repeated DB operations:", memBefore); + console.log('Memory before repeated DB operations:', memBefore); // Perform many database operations for (let i = 0; i < 20; i++) { @@ -215,9 +210,7 @@ describe("Memory Leak Detection Tests", () => { // Load from database const loadedTree = await processor.loadIntoTree(dbPath); - expect(Object.keys(loadedTree.pages).length).toBe( - Object.keys(testTree.pages).length, - ); + expect(Object.keys(loadedTree.pages).length).toBe(Object.keys(testTree.pages).length); // Extract texts const texts = await processor.extractTexts(dbPath); @@ -233,7 +226,7 @@ describe("Memory Leak Detection Tests", () => { forceGC(); const memAfter = getMemoryUsage(); - console.log("Memory after repeated DB operations:", memAfter); + console.log('Memory after repeated DB operations:', memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Memory increase: ${memoryIncrease}MB`); @@ -241,7 +234,7 @@ describe("Memory Leak Detection Tests", () => { expect(memoryIncrease).toBeLessThan(25); // Less than 25MB increase }); - it("should properly close database connections", async () => { + it('should properly close database connections', async () => { const processor = new SnapProcessor(); const testTree = createTestTree(1, 5); @@ -277,12 +270,12 @@ describe("Memory Leak Detection Tests", () => { }); }); - describe("Large Data Memory Tests", () => { - it("should handle large trees without excessive memory retention", async () => { + describe('Large Data Memory Tests', () => { + it('should handle large trees without excessive memory retention', async () => { const processor = new DotProcessor(); const memBefore = getMemoryUsage(); - console.log("Memory before large tree test:", memBefore); + console.log('Memory before large tree test:', memBefore); // Create and process large trees for (let i = 0; i < 5; i++) { @@ -302,7 +295,7 @@ describe("Memory Leak Detection Tests", () => { } const memAfter = getMemoryUsage(); - console.log("Memory after large tree test:", memAfter); + console.log('Memory after large tree test:', memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Large tree memory increase: ${memoryIncrease}MB`); @@ -310,16 +303,16 @@ describe("Memory Leak Detection Tests", () => { expect(memoryIncrease).toBeLessThan(30); // Less than 30MB increase }); - it("should handle large translation maps without memory leaks", async () => { + it('should handle large translation maps without memory leaks', async () => { const processor = new DotProcessor(); // Create content with many nodes - const lines = ["digraph G {"]; + const lines = ['digraph G {']; for (let i = 0; i < 200; i++) { lines.push(` node${i} [label="Text ${i}"];`); } - lines.push("}"); - const largeContent = lines.join("\n"); + lines.push('}'); + const largeContent = lines.join('\n'); // Create large translation map const largeTranslations = new Map(); @@ -328,7 +321,7 @@ describe("Memory Leak Detection Tests", () => { } const memBefore = getMemoryUsage(); - console.log("Memory before large translation test:", memBefore); + console.log('Memory before large translation test:', memBefore); // Perform translation multiple times for (let i = 0; i < 5; i++) { @@ -336,16 +329,16 @@ describe("Memory Leak Detection Tests", () => { const result = await processor.processTexts( Buffer.from(largeContent), largeTranslations, - outputPath, + outputPath ); expect(Buffer.from(result)).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); // Verify some translations - const translatedContent = Buffer.from(result).toString("utf8"); - expect(translatedContent).toContain("Texto 0"); - expect(translatedContent).toContain("Texto 199"); + const translatedContent = Buffer.from(result).toString('utf8'); + expect(translatedContent).toContain('Texto 0'); + expect(translatedContent).toContain('Texto 199'); // Clean up fs.unlinkSync(outputPath); @@ -354,7 +347,7 @@ describe("Memory Leak Detection Tests", () => { } const memAfter = getMemoryUsage(); - console.log("Memory after large translation test:", memAfter); + console.log('Memory after large translation test:', memAfter); const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log(`Large translation memory increase: ${memoryIncrease}MB`); @@ -363,8 +356,8 @@ describe("Memory Leak Detection Tests", () => { }); }); - describe("Long-Running Operation Memory Tests", () => { - it("should maintain stable memory during extended operations", async () => { + describe('Long-Running Operation Memory Tests', () => { + it('should maintain stable memory during extended operations', async () => { const processor = new DotProcessor(); const testContent = 'digraph G { test [label="Extended Test"]; }'; @@ -396,30 +389,26 @@ describe("Memory Leak Detection Tests", () => { const endTime = performance.now(); const totalTime = endTime - startTime; - console.log( - `Completed ${operationCount} operations in ${totalTime.toFixed(2)}ms`, - ); - console.log("Memory snapshots:", memorySnapshots); + console.log(`Completed ${operationCount} operations in ${totalTime.toFixed(2)}ms`); + console.log('Memory snapshots:', memorySnapshots); // Memory should remain relatively stable const maxMemory = Math.max(...memorySnapshots); const minMemory = Math.min(...memorySnapshots); const memoryVariation = maxMemory - minMemory; - console.log( - `Memory variation: ${memoryVariation}MB (${minMemory}MB - ${maxMemory}MB)`, - ); + console.log(`Memory variation: ${memoryVariation}MB (${minMemory}MB - ${maxMemory}MB)`); // Memory variation should be reasonable expect(memoryVariation).toBeLessThan(50); // Allow variance on CI }); - it("should clean up temporary resources properly", async () => { + it('should clean up temporary resources properly', async () => { const processor = new SnapProcessor(); const memBefore = getMemoryUsage(); // eslint-disable-next-line @typescript-eslint/no-var-requires - const tempFilesBefore = fs.readdirSync(require("os").tmpdir()).length; + const tempFilesBefore = fs.readdirSync(require('os').tmpdir()).length; // Perform operations that create temporary files for (let i = 0; i < 10; i++) { @@ -448,13 +437,13 @@ describe("Memory Leak Detection Tests", () => { setTimeout(() => { const memAfter = getMemoryUsage(); // eslint-disable-next-line @typescript-eslint/no-var-requires - const tempFilesAfter = fs.readdirSync(require("os").tmpdir()).length; + const tempFilesAfter = fs.readdirSync(require('os').tmpdir()).length; const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; const tempFileIncrease = tempFilesAfter - tempFilesBefore; console.log( - `Temp cleanup - Memory: +${memoryIncrease}MB, Temp files: +${tempFileIncrease}`, + `Temp cleanup - Memory: +${memoryIncrease}MB, Temp files: +${tempFileIncrease}` ); expect(memoryIncrease).toBeLessThan(20); diff --git a/test/morphology.test.ts b/test/morphology.test.ts index 2d7df53..5641604 100644 --- a/test/morphology.test.ts +++ b/test/morphology.test.ts @@ -1,302 +1,302 @@ -import { MorphologyEngine } from "../src/utilities/analytics/morphology/engine"; -import { MorphRuleSet } from "../src/utilities/analytics/morphology/types"; +import { MorphologyEngine } from '../src/utilities/analytics/morphology/engine'; +import { MorphRuleSet } from '../src/utilities/analytics/morphology/types'; -describe("MorphologyEngine", () => { - describe("built-in English rules", () => { +describe('MorphologyEngine', () => { + describe('built-in English rules', () => { let engine: MorphologyEngine; beforeEach(() => { - engine = new MorphologyEngine("en-gb"); + engine = new MorphologyEngine('en-gb'); }); - describe("irregular verbs", () => { - test("go -> goes, going, gone, went", () => { - const forms = engine.inflect("go", "Verb"); - expect(forms).toContain("goes"); - expect(forms).toContain("going"); - expect(forms).toContain("gone"); - expect(forms).toContain("went"); - expect(forms).not.toContain("goed"); + describe('irregular verbs', () => { + test('go -> goes, going, gone, went', () => { + const forms = engine.inflect('go', 'Verb'); + expect(forms).toContain('goes'); + expect(forms).toContain('going'); + expect(forms).toContain('gone'); + expect(forms).toContain('went'); + expect(forms).not.toContain('goed'); }); - test("be -> is, am, are, was, were, been, being", () => { - const forms = engine.inflect("be", "Verb"); - expect(forms).toContain("is"); - expect(forms).toContain("am"); - expect(forms).toContain("are"); - expect(forms).toContain("was"); - expect(forms).toContain("were"); - expect(forms).toContain("been"); - expect(forms).toContain("being"); + test('be -> is, am, are, was, were, been, being', () => { + const forms = engine.inflect('be', 'Verb'); + expect(forms).toContain('is'); + expect(forms).toContain('am'); + expect(forms).toContain('are'); + expect(forms).toContain('was'); + expect(forms).toContain('were'); + expect(forms).toContain('been'); + expect(forms).toContain('being'); }); - test("have -> has, had, having", () => { - const forms = engine.inflect("have", "Verb"); - expect(forms).toContain("has"); - expect(forms).toContain("had"); - expect(forms).toContain("having"); + test('have -> has, had, having', () => { + const forms = engine.inflect('have', 'Verb'); + expect(forms).toContain('has'); + expect(forms).toContain('had'); + expect(forms).toContain('having'); }); - test("do -> does, did, done, doing", () => { - const forms = engine.inflect("do", "Verb"); - expect(forms).toContain("does"); - expect(forms).toContain("did"); - expect(forms).toContain("done"); - expect(forms).toContain("doing"); + test('do -> does, did, done, doing', () => { + const forms = engine.inflect('do', 'Verb'); + expect(forms).toContain('does'); + expect(forms).toContain('did'); + expect(forms).toContain('done'); + expect(forms).toContain('doing'); }); - test("say -> says, said, saying", () => { - const forms = engine.inflect("say", "Verb"); - expect(forms).toContain("says"); - expect(forms).toContain("said"); - expect(forms).toContain("saying"); + test('say -> says, said, saying', () => { + const forms = engine.inflect('say', 'Verb'); + expect(forms).toContain('says'); + expect(forms).toContain('said'); + expect(forms).toContain('saying'); }); - test("get -> gets, got, getting", () => { - const forms = engine.inflect("get", "Verb"); - expect(forms).toContain("gets"); - expect(forms).toContain("got"); - expect(forms).toContain("getting"); + test('get -> gets, got, getting', () => { + const forms = engine.inflect('get', 'Verb'); + expect(forms).toContain('gets'); + expect(forms).toContain('got'); + expect(forms).toContain('getting'); }); - test("take -> takes, took, taken, taking", () => { - const forms = engine.inflect("take", "Verb"); - expect(forms).toContain("takes"); - expect(forms).toContain("took"); - expect(forms).toContain("taken"); - expect(forms).toContain("taking"); + test('take -> takes, took, taken, taking', () => { + const forms = engine.inflect('take', 'Verb'); + expect(forms).toContain('takes'); + expect(forms).toContain('took'); + expect(forms).toContain('taken'); + expect(forms).toContain('taking'); }); - test("come -> comes, came, coming", () => { - const forms = engine.inflect("come", "Verb"); - expect(forms).toContain("comes"); - expect(forms).toContain("came"); - expect(forms).toContain("coming"); + test('come -> comes, came, coming', () => { + const forms = engine.inflect('come', 'Verb'); + expect(forms).toContain('comes'); + expect(forms).toContain('came'); + expect(forms).toContain('coming'); }); }); - describe("regular verbs", () => { - test("walk -> walks, walked, walking", () => { - const forms = engine.inflect("walk", "Verb"); - expect(forms).toContain("walks"); - expect(forms).toContain("walked"); - expect(forms).toContain("walking"); + describe('regular verbs', () => { + test('walk -> walks, walked, walking', () => { + const forms = engine.inflect('walk', 'Verb'); + expect(forms).toContain('walks'); + expect(forms).toContain('walked'); + expect(forms).toContain('walking'); }); - test("watch -> watches, watched, watching", () => { - const forms = engine.inflect("watch", "Verb"); - expect(forms).toContain("watches"); - expect(forms).toContain("watched"); - expect(forms).toContain("watching"); + test('watch -> watches, watched, watching', () => { + const forms = engine.inflect('watch', 'Verb'); + expect(forms).toContain('watches'); + expect(forms).toContain('watched'); + expect(forms).toContain('watching'); }); - test("carry -> carries, carried, carrying", () => { - const forms = engine.inflect("carry", "Verb"); - expect(forms).toContain("carries"); - expect(forms).toContain("carried"); - expect(forms).toContain("carrying"); + test('carry -> carries, carried, carrying', () => { + const forms = engine.inflect('carry', 'Verb'); + expect(forms).toContain('carries'); + expect(forms).toContain('carried'); + expect(forms).toContain('carrying'); }); - test("like -> likes, liked, liking", () => { - const forms = engine.inflect("like", "Verb"); - expect(forms).toContain("likes"); - expect(forms).toContain("liked"); - expect(forms).toContain("liking"); + test('like -> likes, liked, liking', () => { + const forms = engine.inflect('like', 'Verb'); + expect(forms).toContain('likes'); + expect(forms).toContain('liked'); + expect(forms).toContain('liking'); }); }); - describe("irregular nouns", () => { - test("child -> children", () => { - const forms = engine.inflect("child", "Noun"); - expect(forms).toContain("children"); + describe('irregular nouns', () => { + test('child -> children', () => { + const forms = engine.inflect('child', 'Noun'); + expect(forms).toContain('children'); }); - test("person -> people", () => { - const forms = engine.inflect("person", "Noun"); - expect(forms).toContain("people"); + test('person -> people', () => { + const forms = engine.inflect('person', 'Noun'); + expect(forms).toContain('people'); }); - test("mouse -> mice", () => { - const forms = engine.inflect("mouse", "Noun"); - expect(forms).toContain("mice"); + test('mouse -> mice', () => { + const forms = engine.inflect('mouse', 'Noun'); + expect(forms).toContain('mice'); }); - test("foot -> feet", () => { - const forms = engine.inflect("foot", "Noun"); - expect(forms).toContain("feet"); + test('foot -> feet', () => { + const forms = engine.inflect('foot', 'Noun'); + expect(forms).toContain('feet'); }); - test("sheep -> sheep (no change)", () => { - const forms = engine.inflect("sheep", "Noun"); - expect(forms).toContain("sheep"); + test('sheep -> sheep (no change)', () => { + const forms = engine.inflect('sheep', 'Noun'); + expect(forms).toContain('sheep'); expect(forms.length).toBe(1); }); }); - describe("regular nouns", () => { - test("book -> books", () => { - const forms = engine.inflect("book", "Noun"); - expect(forms).toContain("books"); + describe('regular nouns', () => { + test('book -> books', () => { + const forms = engine.inflect('book', 'Noun'); + expect(forms).toContain('books'); }); - test("thing -> things", () => { - const forms = engine.inflect("thing", "Noun"); - expect(forms).toContain("things"); + test('thing -> things', () => { + const forms = engine.inflect('thing', 'Noun'); + expect(forms).toContain('things'); }); - test("story -> stories", () => { - const forms = engine.inflect("story", "Noun"); - expect(forms).toContain("stories"); + test('story -> stories', () => { + const forms = engine.inflect('story', 'Noun'); + expect(forms).toContain('stories'); }); - test("bus -> buses", () => { - const forms = engine.inflect("bus", "Noun"); - expect(forms).toContain("buses"); + test('bus -> buses', () => { + const forms = engine.inflect('bus', 'Noun'); + expect(forms).toContain('buses'); }); }); - describe("adjectives", () => { - test("good -> better, best", () => { - const forms = engine.inflect("good", "Adjective"); - expect(forms).toContain("better"); - expect(forms).toContain("best"); + describe('adjectives', () => { + test('good -> better, best', () => { + const forms = engine.inflect('good', 'Adjective'); + expect(forms).toContain('better'); + expect(forms).toContain('best'); }); - test("bad -> worse, worst", () => { - const forms = engine.inflect("bad", "Adjective"); - expect(forms).toContain("worse"); - expect(forms).toContain("worst"); + test('bad -> worse, worst', () => { + const forms = engine.inflect('bad', 'Adjective'); + expect(forms).toContain('worse'); + expect(forms).toContain('worst'); }); - test("big -> bigger, biggest", () => { - const forms = engine.inflect("big", "Adjective"); - expect(forms).toContain("bigger"); - expect(forms).toContain("biggest"); + test('big -> bigger, biggest', () => { + const forms = engine.inflect('big', 'Adjective'); + expect(forms).toContain('bigger'); + expect(forms).toContain('biggest'); }); - test("happy -> happier, happiest", () => { - const forms = engine.inflect("happy", "Adjective"); - expect(forms).toContain("happier"); - expect(forms).toContain("happiest"); + test('happy -> happier, happiest', () => { + const forms = engine.inflect('happy', 'Adjective'); + expect(forms).toContain('happier'); + expect(forms).toContain('happiest'); }); }); - describe("pronouns", () => { - test("I -> me, my, mine", () => { - const forms = engine.inflect("I", "Pronoun"); - expect(forms).toContain("me"); - expect(forms).toContain("my"); - expect(forms).toContain("mine"); + describe('pronouns', () => { + test('I -> me, my, mine', () => { + const forms = engine.inflect('I', 'Pronoun'); + expect(forms).toContain('me'); + expect(forms).toContain('my'); + expect(forms).toContain('mine'); }); - test("they -> them, their, theirs", () => { - const forms = engine.inflect("they", "Pronoun"); - expect(forms).toContain("them"); - expect(forms).toContain("their"); - expect(forms).toContain("theirs"); + test('they -> them, their, theirs', () => { + const forms = engine.inflect('they', 'Pronoun'); + expect(forms).toContain('them'); + expect(forms).toContain('their'); + expect(forms).toContain('theirs'); }); }); }); - describe("isFormOf", () => { + describe('isFormOf', () => { let engine: MorphologyEngine; beforeEach(() => { - engine = new MorphologyEngine("en-gb"); + engine = new MorphologyEngine('en-gb'); }); test('detects "went" as form of "go"', () => { - expect(engine.isFormOf("went", "go", "Verb")).toBe(true); + expect(engine.isFormOf('went', 'go', 'Verb')).toBe(true); }); test('detects "going" as form of "go"', () => { - expect(engine.isFormOf("going", "go", "Verb")).toBe(true); + expect(engine.isFormOf('going', 'go', 'Verb')).toBe(true); }); test('detects "children" as form of "child"', () => { - expect(engine.isFormOf("children", "child", "Noun")).toBe(true); + expect(engine.isFormOf('children', 'child', 'Noun')).toBe(true); }); - test("does not match unrelated words", () => { - expect(engine.isFormOf("running", "go", "Verb")).toBe(false); + test('does not match unrelated words', () => { + expect(engine.isFormOf('running', 'go', 'Verb')).toBe(false); }); - test("case insensitive", () => { - expect(engine.isFormOf("Went", "Go", "Verb")).toBe(true); - expect(engine.isFormOf("WENT", "go", "Verb")).toBe(true); + test('case insensitive', () => { + expect(engine.isFormOf('Went', 'Go', 'Verb')).toBe(true); + expect(engine.isFormOf('WENT', 'go', 'Verb')).toBe(true); }); }); - describe("expandVocabulary", () => { + describe('expandVocabulary', () => { let engine: MorphologyEngine; beforeEach(() => { - engine = new MorphologyEngine("en-gb"); + engine = new MorphologyEngine('en-gb'); }); - test("expands verb buttons", () => { + test('expands verb buttons', () => { const buttons = [ - { label: "go", pos: "Verb" }, - { label: "book", pos: "Noun" }, + { label: 'go', pos: 'Verb' }, + { label: 'book', pos: 'Noun' }, ]; const result = engine.expandVocabulary(buttons); - expect(result.get("go")).toContain("goes"); - expect(result.get("go")).toContain("going"); - expect(result.get("go")).toContain("went"); - expect(result.get("go")).toContain("gone"); - expect(result.get("book")).toContain("books"); + expect(result.get('go')).toContain('goes'); + expect(result.get('go')).toContain('going'); + expect(result.get('go')).toContain('went'); + expect(result.get('go')).toContain('gone'); + expect(result.get('book')).toContain('books'); }); - test("skips Unknown and Ignore POS", () => { + test('skips Unknown and Ignore POS', () => { const buttons = [ - { label: "hello", pos: "Unknown" }, - { label: "world", pos: "Ignore" }, + { label: 'hello', pos: 'Unknown' }, + { label: 'world', pos: 'Ignore' }, ]; const result = engine.expandVocabulary(buttons); - expect(result.has("hello")).toBe(false); - expect(result.has("world")).toBe(false); + expect(result.has('hello')).toBe(false); + expect(result.has('world')).toBe(false); }); - test("skips buttons without POS", () => { - const buttons = [{ label: "hello" }]; + test('skips buttons without POS', () => { + const buttons = [{ label: 'hello' }]; const result = engine.expandVocabulary(buttons); - expect(result.has("hello")).toBe(false); + expect(result.has('hello')).toBe(false); }); }); - describe("custom rule set", () => { - test("accepts custom MorphRuleSet", () => { + describe('custom rule set', () => { + test('accepts custom MorphRuleSet', () => { const customRules: MorphRuleSet = { - locale: "test", + locale: 'test', version: 1, irregular: {}, regular: { Verb: { - past: [{ match: "$", replace: "ed" }], + past: [{ match: '$', replace: 'ed' }], }, }, }; const engine = new MorphologyEngine(customRules); - const forms = engine.inflect("walk", "Verb"); - expect(forms).toContain("walked"); + const forms = engine.inflect('walk', 'Verb'); + expect(forms).toContain('walked'); }); }); - describe("unknown locale", () => { - test("returns empty for unsupported locale", () => { - const engine = new MorphologyEngine("xx-xx"); - const forms = engine.inflect("go", "Verb"); + describe('unknown locale', () => { + test('returns empty for unsupported locale', () => { + const engine = new MorphologyEngine('xx-xx'); + const forms = engine.inflect('go', 'Verb'); expect(forms).toEqual([]); }); }); - describe("caching", () => { - test("caches results for same base+pos", () => { - const engine = new MorphologyEngine("en-gb"); - const first = engine.inflect("go", "Verb"); - const second = engine.inflect("go", "Verb"); + describe('caching', () => { + test('caches results for same base+pos', () => { + const engine = new MorphologyEngine('en-gb'); + const first = engine.inflect('go', 'Verb'); + const second = engine.inflect('go', 'Verb'); expect(first).toBe(second); }); }); diff --git a/test/obfProcessor.roundtrip.test.ts b/test/obfProcessor.roundtrip.test.ts index b63aa01..7350d29 100644 --- a/test/obfProcessor.roundtrip.test.ts +++ b/test/obfProcessor.roundtrip.test.ts @@ -1,16 +1,16 @@ // Round-trip test for OBFProcessor: load, save, reload, and compare structure -import fs from "fs"; -import path from "path"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; +import fs from 'fs'; +import path from 'path'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; -jest.setTimeout(process.platform === "win32" ? 60000 : 30000); +jest.setTimeout(process.platform === 'win32' ? 60000 : 30000); -describe("OBFProcessor round-trip", () => { - const obfPath: string = path.join(__dirname, "assets/obf/example.obf"); - const obzPath: string = path.join(__dirname, "assets/obz/example.obz"); - const outObfPath: string = path.join(__dirname, "out.obf"); - const outObzPath: string = path.join(__dirname, "out.obz"); +describe('OBFProcessor round-trip', () => { + const obfPath: string = path.join(__dirname, 'assets/obf/example.obf'); + const obzPath: string = path.join(__dirname, 'assets/obz/example.obz'); + const outObfPath: string = path.join(__dirname, 'out.obf'); + const outObzPath: string = path.join(__dirname, 'out.obz'); afterAll(async () => { [outObfPath, outObzPath].forEach((file) => { @@ -18,9 +18,9 @@ describe("OBFProcessor round-trip", () => { }); }); - it("round-trips OBF JSON without losing pages or navigation", async () => { + it('round-trips OBF JSON without losing pages or navigation', async () => { if (!fs.existsSync(obfPath)) { - console.log("Skipping OBF test - example file not found"); + console.log('Skipping OBF test - example file not found'); return; } @@ -33,9 +33,7 @@ describe("OBFProcessor round-trip", () => { const tree2: AACTree = await processor.loadIntoTree(outObfPath); // Compare basic structure - expect(Object.keys(tree1.pages).length).toBe( - Object.keys(tree2.pages).length, - ); + expect(Object.keys(tree1.pages).length).toBe(Object.keys(tree2.pages).length); // Compare metadata expect(tree2.metadata.name).toBe(tree1.metadata.name); @@ -59,9 +57,9 @@ describe("OBFProcessor round-trip", () => { } }); - it("round-trips OBZ (zip) format without losing data", async () => { + it('round-trips OBZ (zip) format without losing data', async () => { if (!fs.existsSync(obzPath)) { - console.log("Skipping OBZ test - example file not found"); + console.log('Skipping OBZ test - example file not found'); return; } @@ -75,31 +73,29 @@ describe("OBFProcessor round-trip", () => { // Compare structure expect(Object.keys(tree2.pages).length).toBeGreaterThan(0); - expect(Object.keys(tree1.pages).length).toBe( - Object.keys(tree2.pages).length, - ); + expect(Object.keys(tree1.pages).length).toBe(Object.keys(tree2.pages).length); // Compare metadata from root board expect(tree2.metadata.name).toBe(tree1.metadata.name); expect(tree2.metadata.locale).toBe(tree1.metadata.locale); }); - it("can save and load a simple constructed tree", async () => { + it('can save and load a simple constructed tree', async () => { const processor = new ObfProcessor(); // Create a simple tree programmatically const tree1 = new AACTree(); const page = new AACPage({ - id: "test-page", - name: "Test Page", + id: 'test-page', + name: 'Test Page', buttons: [], }); const button = new AACButton({ - id: "test-button", - label: "Test Button", - message: "Hello World", - type: "SPEAK", + id: 'test-button', + label: 'Test Button', + message: 'Hello World', + type: 'SPEAK', }); page.addButton(button); @@ -111,37 +107,37 @@ describe("OBFProcessor round-trip", () => { // Verify structure expect(Object.keys(tree2.pages)).toHaveLength(1); - const reloadedPage = tree2.pages["test-page"]; + const reloadedPage = tree2.pages['test-page']; expect(reloadedPage).toBeDefined(); - expect(reloadedPage.name).toBe("Test Page"); + expect(reloadedPage.name).toBe('Test Page'); expect(reloadedPage.buttons).toHaveLength(1); - expect(reloadedPage.buttons[0].label).toBe("Test Button"); + expect(reloadedPage.buttons[0].label).toBe('Test Button'); }); - it("includes required OBF metadata fields when saving a tree", async () => { + it('includes required OBF metadata fields when saving a tree', async () => { const processor = new ObfProcessor(); const tree = new AACTree(); const page = new AACPage({ - id: "meta-page", - name: "Meta Page", + id: 'meta-page', + name: 'Meta Page', grid: [ [null, null], [null, null], ], - locale: "en", + locale: 'en', }); const AACButtonCtor = AACButton; const buttonA = new AACButtonCtor({ - id: "btn-a", - label: "A", - message: "A", + id: 'btn-a', + label: 'A', + message: 'A', }); const buttonB = new AACButtonCtor({ - id: "btn-b", - label: "B", - message: "B", + id: 'btn-b', + label: 'B', + message: 'B', }); page.addButton(buttonA); @@ -155,16 +151,16 @@ describe("OBFProcessor round-trip", () => { tree.rootId = page.id; await processor.saveFromTree(tree, outObfPath); - const savedObf = JSON.parse(fs.readFileSync(outObfPath, "utf8")); + const savedObf = JSON.parse(fs.readFileSync(outObfPath, 'utf8')); - expect(savedObf.format).toBe("open-board-0.1"); - expect(savedObf.description_html).toBe("Meta Page"); - expect(savedObf.locale).toBe("en"); + expect(savedObf.format).toBe('open-board-0.1'); + expect(savedObf.description_html).toBe('Meta Page'); + expect(savedObf.locale).toBe('en'); expect(savedObf.grid).toEqual({ rows: 2, columns: 2, order: [ - ["btn-a", "btn-b"], + ['btn-a', 'btn-b'], [null, null], ], }); diff --git a/test/obfProcessor.test.ts b/test/obfProcessor.test.ts index 6ae8e53..7a25646 100644 --- a/test/obfProcessor.test.ts +++ b/test/obfProcessor.test.ts @@ -1,15 +1,15 @@ // Test for OBFProcessor (Open Board Format/Zip) // Test for OBFProcessor (Open Board Format/Zip) -import path from "path"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { AACTree } from "../src/core/treeStructure"; +import path from 'path'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { AACTree } from '../src/core/treeStructure'; jest.setTimeout(30000); -describe("OBFProcessor", () => { - const obzPath: string = path.join(__dirname, "assets/obz/example.obz"); +describe('OBFProcessor', () => { + const obzPath: string = path.join(__dirname, 'assets/obz/example.obz'); - it("can process .obz (zip) files with manifest", async () => { + it('can process .obz (zip) files with manifest', async () => { const processor = new ObfProcessor(); const tree: AACTree = await processor.loadIntoTree(obzPath); expect(tree).toBeInstanceOf(AACTree); @@ -19,7 +19,7 @@ describe("OBFProcessor", () => { let navFound = false; tree.traverse((page) => { page.buttons.forEach((btn) => { - if (btn.type === "NAVIGATE" && btn.targetPageId) navFound = true; + if (btn.type === 'NAVIGATE' && btn.targetPageId) navFound = true; }); }); expect(navFound).toBe(true); @@ -30,7 +30,7 @@ describe("OBFProcessor", () => { const imgBtn = rootPage.buttons.find((b: any) => b.image); if (imgBtn) { // Image should now be a data URL string (from embedded OBZ images) - expect(typeof imgBtn.image).toBe("string"); + expect(typeof imgBtn.image).toBe('string'); expect(imgBtn.image).toMatch(/^data:image\//); // resolvedImageEntry should also be set expect(imgBtn.resolvedImageEntry).toBe(imgBtn.image); @@ -38,15 +38,12 @@ describe("OBFProcessor", () => { } }); - describe("saveModifiedTree", () => { - const tempOutputPath = path.join(__dirname, "temp_obz_modified.obz"); - const tempSaveFromTreePath = path.join( - __dirname, - "temp_obz_saveFromTree.obz", - ); + describe('saveModifiedTree', () => { + const tempOutputPath = path.join(__dirname, 'temp_obz_modified.obz'); + const tempSaveFromTreePath = path.join(__dirname, 'temp_obz_saveFromTree.obz'); afterEach(async () => { - const fs = await import("fs"); + const fs = await import('fs'); if (fs.existsSync(tempOutputPath)) { fs.unlinkSync(tempOutputPath); } @@ -55,9 +52,9 @@ describe("OBFProcessor", () => { } }); - it("should preserve original file size better than saveFromTree for OBZ files", async () => { + it('should preserve original file size better than saveFromTree for OBZ files', async () => { const processor = new ObfProcessor(); - const fs = await import("fs"); + const fs = await import('fs'); // Load the original file const tree = await processor.loadIntoTree(obzPath); @@ -78,7 +75,7 @@ describe("OBFProcessor", () => { expect(modifiedSize / originalSize).toBeGreaterThan(0.8); }); - it("should produce a valid loadable OBZ file", async () => { + it('should produce a valid loadable OBZ file', async () => { const processor = new ObfProcessor(); // Load the original file @@ -96,9 +93,9 @@ describe("OBFProcessor", () => { expect(savedTree.rootId).toBe(tree.rootId); }); - it("should handle empty tree by copying original", async () => { + it('should handle empty tree by copying original', async () => { const processor = new ObfProcessor(); - const fs = await import("fs"); + const fs = await import('fs'); // Create an empty tree const emptyTree: AACTree = { @@ -108,7 +105,7 @@ describe("OBFProcessor", () => { dashboardId: null, metadata: {}, addPage() { - throw new Error("Not implemented"); + throw new Error('Not implemented'); }, getPage() { return undefined; diff --git a/test/obfsetProcessor.test.ts b/test/obfsetProcessor.test.ts index 6b87f4e..b35c0f7 100644 --- a/test/obfsetProcessor.test.ts +++ b/test/obfsetProcessor.test.ts @@ -1,70 +1,70 @@ -import path from "path"; -import fs from "fs"; -import { ObfsetProcessor } from "../src/processors/obfsetProcessor"; -import { AACTree } from "../src/core/treeStructure"; +import path from 'path'; +import fs from 'fs'; +import { ObfsetProcessor } from '../src/processors/obfsetProcessor'; +import { AACTree } from '../src/core/treeStructure'; -describe("ObfsetProcessor", () => { - const obfsetPath = path.join(__dirname, "fixtures/example.obfset"); +describe('ObfsetProcessor', () => { + const obfsetPath = path.join(__dirname, 'fixtures/example.obfset'); - it("can load .obfset files into a tree", async () => { + it('can load .obfset files into a tree', async () => { const processor = new ObfsetProcessor(); const tree = await processor.loadIntoTree(obfsetPath); expect(tree).toBeInstanceOf(AACTree); - expect(tree.rootId).toBe("root"); + expect(tree.rootId).toBe('root'); expect(Object.keys(tree.pages).length).toBe(2); - const rootPage = tree.getPage("root"); + const rootPage = tree.getPage('root'); if (!rootPage) { - throw new Error("Expected root page to exist"); + throw new Error('Expected root page to exist'); } - expect(rootPage.name).toBe("Home"); - expect(rootPage.grid[0][0]?.label).toBe("Hello"); - expect(rootPage.grid[0][0]?.semantic_id).toBe("greeting-1"); + expect(rootPage.name).toBe('Home'); + expect(rootPage.grid[0][0]?.label).toBe('Hello'); + expect(rootPage.grid[0][0]?.semantic_id).toBe('greeting-1'); expect(rootPage.buttons.length).toBe(2); - const page2 = tree.getPage("page2"); + const page2 = tree.getPage('page2'); if (!page2) { - throw new Error("Expected page2 to exist"); + throw new Error('Expected page2 to exist'); } - expect(page2.parentId).toBe("root"); - expect(page2.grid[0][0]?.label).toBe("World"); - expect(page2.grid[0][0]?.clone_id).toBe("world-1"); + expect(page2.parentId).toBe('root'); + expect(page2.grid[0][0]?.label).toBe('World'); + expect(page2.grid[0][0]?.clone_id).toBe('world-1'); }); - it("can load .obfset from a Buffer", async () => { + it('can load .obfset from a Buffer', async () => { const processor = new ObfsetProcessor(); const buffer = fs.readFileSync(obfsetPath); const tree = await processor.loadIntoTree(buffer); expect(tree).toBeInstanceOf(AACTree); - expect(tree.rootId).toBe("root"); + expect(tree.rootId).toBe('root'); }); - it("can extract texts from .obfset", async () => { + it('can extract texts from .obfset', async () => { const processor = new ObfsetProcessor(); const texts = await processor.extractTexts(obfsetPath); - expect(texts).toContain("Hello"); - expect(texts).toContain("Go To Page 2"); - expect(texts).toContain("World"); - expect(texts).toContain("Home"); - expect(texts).toContain("Page 2"); + expect(texts).toContain('Hello'); + expect(texts).toContain('Go To Page 2'); + expect(texts).toContain('World'); + expect(texts).toContain('Home'); + expect(texts).toContain('Page 2'); }); - it("throws error for unsupported operations", async () => { + it('throws error for unsupported operations', async () => { const processor = new ObfsetProcessor(); await expect(async () => { - await processor.processTexts(obfsetPath, new Map(), "out.obfset"); + await processor.processTexts(obfsetPath, new Map(), 'out.obfset'); }).rejects.toThrow(); await expect(async () => { - await processor.saveFromTree(new AACTree(), "out.obfset"); + await processor.saveFromTree(new AACTree(), 'out.obfset'); }).rejects.toThrow(); }); - it("correctly reports supported extension", async () => { + it('correctly reports supported extension', async () => { const processor = new ObfsetProcessor(); - expect(processor.supportsExtension(".obfset")).toBe(true); - expect(processor.supportsExtension(".obf")).toBe(false); + expect(processor.supportsExtension('.obfset')).toBe(true); + expect(processor.supportsExtension('.obf')).toBe(false); }); }); diff --git a/test/obl.test.ts b/test/obl.test.ts index 8de8a31..7f513a3 100644 --- a/test/obl.test.ts +++ b/test/obl.test.ts @@ -1,139 +1,128 @@ -import { OblUtil, OblAnonymizer } from "../src/utilities/analytics/index"; -import * as fs from "fs"; -import * as path from "path"; -import { - AACSemanticIntent, - AACSemanticCategory, -} from "../src/core/treeStructure"; - -describe("OBL Support", () => { +import { OblUtil, OblAnonymizer } from '../src/utilities/analytics/index'; +import * as fs from 'fs'; +import * as path from 'path'; +import { AACSemanticIntent, AACSemanticCategory } from '../src/core/treeStructure'; + +describe('OBL Support', () => { const sampleOBL = { - format: "open-board-log-0.1", - user_id: "test-user", - source: "test-source", + format: 'open-board-log-0.1', + user_id: 'test-user', + source: 'test-source', sessions: [ { - id: "session-1", - type: "log" as const, - started: "2023-01-01T10:00:00.000Z", - ended: "2023-01-01T10:05:00.000Z", + id: 'session-1', + type: 'log' as const, + started: '2023-01-01T10:00:00.000Z', + ended: '2023-01-01T10:05:00.000Z', events: [ { - id: "event-1", - type: "button" as const, - timestamp: "2023-01-01T10:01:00.000Z", - label: "Hello", - vocalization: "Hello there", - board_id: "board-main", + id: 'event-1', + type: 'button' as const, + timestamp: '2023-01-01T10:01:00.000Z', + label: 'Hello', + vocalization: 'Hello there', + board_id: 'board-main', }, { - id: "event-2", - type: "action" as const, - timestamp: "2023-01-01T10:02:00.000Z", - action: ":open_board", - destination_board_id: "board-food", + id: 'event-2', + type: 'action' as const, + timestamp: '2023-01-01T10:02:00.000Z', + action: ':open_board', + destination_board_id: 'board-food', }, { - id: "event-3", - type: "utterance" as const, - timestamp: "2023-01-01T10:03:00.000Z", - text: "I want apple", + id: 'event-3', + type: 'utterance' as const, + timestamp: '2023-01-01T10:03:00.000Z', + text: 'I want apple', }, ], }, ], }; - test("should parse OBL JSON with notice comment", () => { + test('should parse OBL JSON with notice comment', () => { const json = `/* This is a notice */\n${JSON.stringify(sampleOBL)}`; const parsed = OblUtil.parse(json); - expect(parsed.user_id).toBe("test-user"); + expect(parsed.user_id).toBe('test-user'); expect(parsed.sessions[0].events).toHaveLength(3); }); - test("should stringify OBL with notice comment", () => { + test('should stringify OBL with notice comment', () => { const json = OblUtil.stringify(sampleOBL as any); - expect(json).toContain("/* NOTICE:"); + expect(json).toContain('/* NOTICE:'); expect(json).toContain('"user_id": "test-user"'); }); - test("should convert OBL to HistoryEntries", () => { + test('should convert OBL to HistoryEntries', () => { const entries = OblUtil.toHistoryEntries(sampleOBL as any); // Should have entries for 'Hello there', ':open_board', and 'I want apple' expect(entries).toHaveLength(3); - const helloEntry = entries.find((e) => e.content === "Hello there"); + const helloEntry = entries.find((e) => e.content === 'Hello there'); expect(helloEntry).toBeDefined(); - expect(helloEntry?.occurrences[0].type).toBe("button"); - expect(helloEntry?.occurrences[0].pageId).toBe("board-main"); + expect(helloEntry?.occurrences[0].type).toBe('button'); + expect(helloEntry?.occurrences[0].pageId).toBe('board-main'); - const uttEntry = entries.find((e) => e.content === "I want apple"); + const uttEntry = entries.find((e) => e.content === 'I want apple'); expect(uttEntry).toBeDefined(); - expect(uttEntry?.occurrences[0].type).toBe("utterance"); + expect(uttEntry?.occurrences[0].type).toBe('utterance'); }); - test("should maintain bidirectional mapping (OBL -> History -> OBL)", () => { + test('should maintain bidirectional mapping (OBL -> History -> OBL)', () => { const originalOBL = sampleOBL as any; const entries = OblUtil.toHistoryEntries(originalOBL); - const roundTripOBL = OblUtil.fromHistoryEntries( - entries, - "test-user", - "test-source", - ); + const roundTripOBL = OblUtil.fromHistoryEntries(entries, 'test-user', 'test-source'); - expect(roundTripOBL.user_id).toBe("test-user"); + expect(roundTripOBL.user_id).toBe('test-user'); expect(roundTripOBL.sessions[0].events).toHaveLength(3); // Check if the utterance was preserved - const uttEvent = roundTripOBL.sessions[0].events.find( - (e) => e.type === "utterance", - ); + const uttEvent = roundTripOBL.sessions[0].events.find((e) => e.type === 'utterance'); expect(uttEvent).toBeDefined(); - expect((uttEvent as any).text).toBe("I want apple"); + expect((uttEvent as any).text).toBe('I want apple'); // Check if the action was preserved and mapped back to :open_board // (HistoryEntry stores ':open_board' as content, fromHistoryEntries should see it) - const actionEvent = roundTripOBL.sessions[0].events.find( - (e) => e.type === "action", - ); + const actionEvent = roundTripOBL.sessions[0].events.find((e) => e.type === 'action'); expect(actionEvent).toBeDefined(); - expect((actionEvent as any).action).toBe(":open_board"); + expect((actionEvent as any).action).toBe(':open_board'); }); - test("should use semantic intents for mapping", () => { + test('should use semantic intents for mapping', () => { const entries = [ { - id: "1", - source: "Grid", - content: "Home", + id: '1', + source: 'Grid', + content: 'Home', occurrences: [ { - timestamp: new Date("2023-01-01T12:00:00Z"), + timestamp: new Date('2023-01-01T12:00:00Z'), intent: AACSemanticIntent.GO_HOME, category: AACSemanticCategory.NAVIGATION, - type: "button" as const, + type: 'button' as const, }, ], }, ]; - const obl = OblUtil.fromHistoryEntries(entries as any, "user1"); + const obl = OblUtil.fromHistoryEntries(entries as any, 'user1'); const event = obl.sessions[0].events[0] as any; - expect(event.type).toBe("action"); - expect(event.action).toBe(":home"); + expect(event.type).toBe('action'); + expect(event.action).toBe(':home'); }); - test("should anonymize data correctly", () => { + test('should anonymize data correctly', () => { const obl = JSON.parse(JSON.stringify(sampleOBL)) as any; - obl.user_name = "Will Wade"; + obl.user_name = 'Will Wade'; obl.sessions[0].events[0].geo = [51.5, -0.1]; const anonymized = OblAnonymizer.anonymize(obl, [ - "timestamp_shift", - "geolocation_masking", - "name_masking", + 'timestamp_shift', + 'geolocation_masking', + 'name_masking', ]); expect(anonymized.anonymized).toBe(true); @@ -144,37 +133,35 @@ describe("OBL Support", () => { const originalDate = new Date(obl.sessions[0].started).getTime(); const shiftedDate = new Date(anonymized.sessions[0].started).getTime(); expect(shiftedDate).not.toBe(originalDate); - expect(anonymized.sessions[0].started).toBe("2000-01-01T00:00:00.000Z"); + expect(anonymized.sessions[0].started).toBe('2000-01-01T00:00:00.000Z'); }); - test("should parse real OBLA data from dataset", () => { + test('should parse real OBLA data from dataset', () => { // Inlined sample data to ensure tests pass in CI even without extra files const oblaSample = { - format: "open-board-log-0.1", - user_id: "test-real-user", - source: "coughdrop", + format: 'open-board-log-0.1', + user_id: 'test-real-user', + source: 'coughdrop', sessions: [ { - id: "session-1", - type: "log", - started: "2000-01-24T01:15:27Z", - ended: "2000-01-24T01:15:32Z", + id: 'session-1', + type: 'log', + started: '2000-01-24T01:15:27Z', + ended: '2000-01-24T01:15:32Z', events: [ { - id: "event-1", - timestamp: "2000-01-24T01:15:26Z", - type: "action", - action: ":clear", + id: 'event-1', + timestamp: '2000-01-24T01:15:26Z', + type: 'action', + action: ':clear', }, { - id: "event-2", - timestamp: "2000-05-15T00:30:52Z", - type: "button", - label: "Hello", - vocalization: "Hello", - actions: [ - { action: ":open_board", destination_board_id: "board-2" }, - ], + id: 'event-2', + timestamp: '2000-05-15T00:30:52Z', + type: 'button', + label: 'Hello', + vocalization: 'Hello', + actions: [{ action: ':open_board', destination_board_id: 'board-2' }], }, ], anonymized: true, @@ -186,38 +173,29 @@ describe("OBL Support", () => { const content = JSON.stringify(oblaSample); const parsed = OblUtil.parse(content); - expect(parsed.format).toContain("open-board-log"); + expect(parsed.format).toContain('open-board-log'); expect(parsed.sessions.length).toBeGreaterThan(0); const history = OblUtil.toHistoryEntries(parsed); - const totalOriginalEvents = parsed.sessions.reduce( - (acc, s) => acc + s.events.length, - 0, - ); - const roundTrip = OblUtil.fromHistoryEntries( - history, - parsed.user_id, - parsed.source, - ); + const totalOriginalEvents = parsed.sessions.reduce((acc, s) => acc + s.events.length, 0); + const roundTrip = OblUtil.fromHistoryEntries(history, parsed.user_id, parsed.source); expect(roundTrip.sessions[0].events.length).toBe(totalOriginalEvents); }); - test("bulk test real OBLA files (first 10)", () => { - const oblaDir = path.join(__dirname, "assets/obla"); + test('bulk test real OBLA files (first 10)', () => { + const oblaDir = path.join(__dirname, 'assets/obla'); if (!fs.existsSync(oblaDir)) { - console.warn( - "Skipping bulk OBLA test - test/assets/obla directory not found", - ); + console.warn('Skipping bulk OBLA test - test/assets/obla directory not found'); return; } const files = fs .readdirSync(oblaDir) - .filter((f) => f.endsWith(".obla")) + .filter((f) => f.endsWith('.obla')) .slice(0, 10); for (const file of files) { - const content = fs.readFileSync(path.join(oblaDir, file), "utf8"); + const content = fs.readFileSync(path.join(oblaDir, file), 'utf8'); const parsed = OblUtil.parse(content); expect(parsed.sessions).toBeDefined(); diff --git a/test/opmlProcessor.export.test.js b/test/opmlProcessor.export.test.js index fbf5d6d..b5dfad3 100644 --- a/test/opmlProcessor.export.test.js +++ b/test/opmlProcessor.export.test.js @@ -1,20 +1,20 @@ // Test OPMLProcessor export/saveFromTree -const fs = require("fs"); -const path = require("path"); -const { OpmlProcessor } = require("../dist/processors/opmlProcessor"); -describe("OPMLProcessor.saveFromTree", () => { - const opmlPath = path.join(__dirname, "assets/opml/example.opml"); - const outPath = path.join(__dirname, "out.opml"); +const fs = require('fs'); +const path = require('path'); +const { OpmlProcessor } = require('../dist/processors/opmlProcessor'); +describe('OPMLProcessor.saveFromTree', () => { + const opmlPath = path.join(__dirname, 'assets/opml/example.opml'); + const outPath = path.join(__dirname, 'out.opml'); afterAll(() => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("exports tree to OPML XML", async () => { + it('exports tree to OPML XML', async () => { const processor = new OpmlProcessor(); const tree = await processor.loadIntoTree(opmlPath); await processor.saveFromTree(tree, outPath); - const exported = fs.readFileSync(outPath, "utf8"); - expect(exported).toContain(" { - const opmlPath = path.join(__dirname, "assets/opml/example.opml"); +describe('OpmlProcessor round-trip', () => { + const opmlPath = path.join(__dirname, 'assets/opml/example.opml'); afterAll(async () => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("round-trips OPML file without losing pages", async () => { + it('round-trips OPML file without losing pages', async () => { const processor = new OpmlProcessor(); const tree1 = await processor.loadIntoTree(opmlPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); // Compare set of page names (labels) const filterArtificial = (arr: any[]) => - arr.filter((n: any) => n !== "Super Root" && n !== "Root").sort(); - const names1 = filterArtificial( - Object.values(tree1.pages).map((p) => p.name), - ); - const names2 = filterArtificial( - Object.values(tree2.pages).map((p) => p.name), - ); + arr.filter((n: any) => n !== 'Super Root' && n !== 'Root').sort(); + const names1 = filterArtificial(Object.values(tree1.pages).map((p) => p.name)); + const names2 = filterArtificial(Object.values(tree2.pages).map((p) => p.name)); expect(names2).toEqual(names1); // Compare root names if (tree2.rootId && tree1.rootId) { - expect(tree2.getPage(tree2.rootId)?.name).toEqual( - tree1.getPage(tree1.rootId)?.name, - ); + expect(tree2.getPage(tree2.rootId)?.name).toEqual(tree1.getPage(tree1.rootId)?.name); } }); }); diff --git a/test/opmlProcessor.test.ts b/test/opmlProcessor.test.ts index 4afe13a..5d6f0c5 100644 --- a/test/opmlProcessor.test.ts +++ b/test/opmlProcessor.test.ts @@ -1,12 +1,12 @@ // Unit test for OPMLProcessor -import path from "path"; -import { OpmlProcessor } from "../src/processors/opmlProcessor"; -import { AACTree } from "../src/core/treeStructure"; +import path from 'path'; +import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import { AACTree } from '../src/core/treeStructure'; -describe("OPMLProcessor", () => { - const opmlPath: string = path.join(__dirname, "assets/opml/example.opml"); +describe('OPMLProcessor', () => { + const opmlPath: string = path.join(__dirname, 'assets/opml/example.opml'); - it("can process .opml files and build a navigation tree", async () => { + it('can process .opml files and build a navigation tree', async () => { const processor = new OpmlProcessor(); const tree: AACTree = await processor.loadIntoTree(opmlPath); expect(tree).toBeInstanceOf(AACTree); @@ -21,7 +21,7 @@ describe("OPMLProcessor", () => { let navFound = false; tree.traverse((page) => { page.buttons.forEach((btn) => { - if (btn.type === "NAVIGATE" && btn.targetPageId) navFound = true; + if (btn.type === 'NAVIGATE' && btn.targetPageId) navFound = true; }); }); expect(navFound).toBe(true); diff --git a/test/performance.memory.test.ts b/test/performance.memory.test.ts index 44dc93a..d9c3178 100644 --- a/test/performance.memory.test.ts +++ b/test/performance.memory.test.ts @@ -1,16 +1,16 @@ // Memory performance tests for large communication boards -import fs from "fs"; -import path from "path"; -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { TreeFactory } from "./utils/testFactories"; +import fs from 'fs'; +import path from 'path'; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { TreeFactory } from './utils/testFactories'; // Skip memory intensive tests in CI environment const describeIfLocal = process.env.CI ? describe.skip : describe; -describeIfLocal("Memory Performance Tests", () => { - const tempDir = path.join(__dirname, "temp_performance_memory"); +describeIfLocal('Memory Performance Tests', () => { + const tempDir = path.join(__dirname, 'temp_performance_memory'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -32,7 +32,7 @@ describeIfLocal("Memory Performance Tests", () => { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (e) { - console.warn("Failed to clean up temp dir:", e); + console.warn('Failed to clean up temp dir:', e); } } resolve(); @@ -77,9 +77,7 @@ describeIfLocal("Memory Performance Tests", () => { }; } - async function measureMemoryUsageAsync( - operation: () => Promise, - ): Promise<{ + async function measureMemoryUsageAsync(operation: () => Promise): Promise<{ result: T; memoryUsedMB: number; peakMemoryMB: number; @@ -116,8 +114,8 @@ describeIfLocal("Memory Performance Tests", () => { }; } - describe("TouchChatProcessor Memory Tests", () => { - it("should process 1000+ button boards under 50MB memory", async () => { + describe('TouchChatProcessor Memory Tests', () => { + it('should process 1000+ button boards under 50MB memory', async () => { const processor = new TouchChatProcessor(); const { @@ -128,37 +126,34 @@ describeIfLocal("Memory Performance Tests", () => { return TreeFactory.createLarge(10, 100); // 10 pages, 100 buttons each = 1000 buttons }); - const outputPath = path.join(tempDir, "large_touchchat.ce"); + const outputPath = path.join(tempDir, 'large_touchchat.ce'); const { memoryUsedMB: saveMemoryMB } = await measureMemoryUsageAsync(() => - processor.saveFromTree(tree, outputPath), + processor.saveFromTree(tree, outputPath) ); - const { result: loadedTree, memoryUsedMB: loadMemoryMB } = - await measureMemoryUsageAsync(() => processor.loadIntoTree(outputPath)); + const { result: loadedTree, memoryUsedMB: loadMemoryMB } = await measureMemoryUsageAsync(() => + processor.loadIntoTree(outputPath) + ); expect(loadedTree).toBeDefined(); expect(Object.keys(loadedTree.pages)).toHaveLength(10); // Memory usage should be under 50MB for the entire operation - const totalMemoryUsed = Math.max( - memoryUsedMB, - saveMemoryMB, - loadMemoryMB, - ); + const totalMemoryUsed = Math.max(memoryUsedMB, saveMemoryMB, loadMemoryMB); expect(totalMemoryUsed).toBeLessThan(50); expect(peakMemoryMB).toBeLessThan(50); console.log( - `TouchChat 1000+ buttons - Memory used: ${totalMemoryUsed.toFixed(2)}MB, Peak: ${peakMemoryMB.toFixed(2)}MB`, + `TouchChat 1000+ buttons - Memory used: ${totalMemoryUsed.toFixed(2)}MB, Peak: ${peakMemoryMB.toFixed(2)}MB` ); }); - it("should handle streaming large files efficiently", async () => { + it('should handle streaming large files efficiently', async () => { const processor = new TouchChatProcessor(); const tree = TreeFactory.createLarge(50, 50); // 2500 buttons - const outputPath = path.join(tempDir, "streaming_touchchat.ce"); + const outputPath = path.join(tempDir, 'streaming_touchchat.ce'); const { memoryUsedMB } = await measureMemoryUsageAsync(async () => { await processor.saveFromTree(tree, outputPath); @@ -166,12 +161,10 @@ describeIfLocal("Memory Performance Tests", () => { }); expect(memoryUsedMB).toBeLessThan(75); // Slightly higher limit for larger dataset - console.log( - `TouchChat streaming - Memory used: ${memoryUsedMB.toFixed(2)}MB`, - ); + console.log(`TouchChat streaming - Memory used: ${memoryUsedMB.toFixed(2)}MB`); }); - it("should garbage collect properly after processing", async () => { + it('should garbage collect properly after processing', async () => { const processor = new TouchChatProcessor(); // Force garbage collection if available @@ -204,14 +197,12 @@ describeIfLocal("Memory Performance Tests", () => { // Memory increase should be minimal after garbage collection // Without --expose-gc, we can't guarantee cleanup, so we use a higher threshold expect(memoryIncrease).toBeLessThan(100); - console.log( - `TouchChat GC test - Memory increase: ${memoryIncrease.toFixed(2)}MB`, - ); + console.log(`TouchChat GC test - Memory increase: ${memoryIncrease.toFixed(2)}MB`); }); }); - describe("SnapProcessor Memory Tests", () => { - it("should process 1000+ button boards under 50MB memory", async () => { + describe('SnapProcessor Memory Tests', () => { + it('should process 1000+ button boards under 50MB memory', async () => { const processor = new SnapProcessor(); const { @@ -222,32 +213,29 @@ describeIfLocal("Memory Performance Tests", () => { return TreeFactory.createLarge(10, 100); // 1000 buttons }); - const outputPath = path.join(tempDir, "large_snap.sps"); + const outputPath = path.join(tempDir, 'large_snap.sps'); const { memoryUsedMB: saveMemoryMB } = await measureMemoryUsageAsync(() => - processor.saveFromTree(tree, outputPath), + processor.saveFromTree(tree, outputPath) ); - const { result: loadedTree, memoryUsedMB: loadMemoryMB } = - await measureMemoryUsageAsync(() => processor.loadIntoTree(outputPath)); + const { result: loadedTree, memoryUsedMB: loadMemoryMB } = await measureMemoryUsageAsync(() => + processor.loadIntoTree(outputPath) + ); expect(loadedTree).toBeDefined(); expect(Object.keys(loadedTree.pages)).toHaveLength(10); - const totalMemoryUsed = Math.max( - memoryUsedMB, - saveMemoryMB, - loadMemoryMB, - ); + const totalMemoryUsed = Math.max(memoryUsedMB, saveMemoryMB, loadMemoryMB); expect(totalMemoryUsed).toBeLessThan(50); expect(peakMemoryMB).toBeLessThan(50); console.log( - `Snap 1000+ buttons - Memory used: ${totalMemoryUsed.toFixed(2)}MB, Peak: ${peakMemoryMB.toFixed(2)}MB`, + `Snap 1000+ buttons - Memory used: ${totalMemoryUsed.toFixed(2)}MB, Peak: ${peakMemoryMB.toFixed(2)}MB` ); }); - it("should handle large audio content efficiently", async () => { + it('should handle large audio content efficiently', async () => { const processor = new SnapProcessor(); const { result: tree, memoryUsedMB } = measureMemoryUsage(() => { @@ -260,7 +248,7 @@ describeIfLocal("Memory Performance Tests", () => { id: pageIndex * 100 + buttonIndex, data: Buffer.alloc(8192, 0x41), // 8KB audio per button identifier: `audio_${pageIndex}_${buttonIndex}`, - metadata: "Performance test audio", + metadata: 'Performance test audio', }; }); }); @@ -268,142 +256,117 @@ describeIfLocal("Memory Performance Tests", () => { return tree; }); - const outputPath = path.join(tempDir, "audio_heavy_snap.sps"); + const outputPath = path.join(tempDir, 'audio_heavy_snap.sps'); const { memoryUsedMB: saveMemoryMB } = await measureMemoryUsageAsync(() => - processor.saveFromTree(tree, outputPath), + processor.saveFromTree(tree, outputPath) ); - const { result: loadedTree, memoryUsedMB: loadMemoryMB } = - await measureMemoryUsageAsync(() => processor.loadIntoTree(outputPath)); + const { result: loadedTree, memoryUsedMB: loadMemoryMB } = await measureMemoryUsageAsync(() => + processor.loadIntoTree(outputPath) + ); expect(loadedTree).toBeDefined(); // With audio content, allow slightly higher memory usage - const totalMemoryUsed = Math.max( - memoryUsedMB, - saveMemoryMB, - loadMemoryMB, - ); + const totalMemoryUsed = Math.max(memoryUsedMB, saveMemoryMB, loadMemoryMB); expect(totalMemoryUsed).toBeLessThan(100); - console.log( - `Snap with audio - Memory used: ${totalMemoryUsed.toFixed(2)}MB`, - ); + console.log(`Snap with audio - Memory used: ${totalMemoryUsed.toFixed(2)}MB`); }); - it("should maintain memory usage under 100MB for large files", async () => { + it('should maintain memory usage under 100MB for large files', async () => { const processor = new SnapProcessor(); - const { result: tree, memoryUsedMB: _memoryUsedMB } = measureMemoryUsage( - () => { - const tree = TreeFactory.createLarge(100, 20); // 2000 buttons - - // Add moderate audio content - Object.values(tree.pages).forEach((page, pageIndex) => { - page.buttons.forEach((button, buttonIndex) => { - if (buttonIndex % 3 === 0) { - // Every 3rd button has audio - button.audioRecording = { - id: pageIndex * 100 + buttonIndex, - data: Buffer.alloc(4096, 0x42), // 4KB audio - identifier: `audio_${pageIndex}_${buttonIndex}`, - metadata: "Large file test audio", - }; - } - }); + const { result: tree, memoryUsedMB: _memoryUsedMB } = measureMemoryUsage(() => { + const tree = TreeFactory.createLarge(100, 20); // 2000 buttons + + // Add moderate audio content + Object.values(tree.pages).forEach((page, pageIndex) => { + page.buttons.forEach((button, buttonIndex) => { + if (buttonIndex % 3 === 0) { + // Every 3rd button has audio + button.audioRecording = { + id: pageIndex * 100 + buttonIndex, + data: Buffer.alloc(4096, 0x42), // 4KB audio + identifier: `audio_${pageIndex}_${buttonIndex}`, + metadata: 'Large file test audio', + }; + } }); + }); - return tree; - }, - ); + return tree; + }); - const outputPath = path.join(tempDir, "very_large_snap.sps"); + const outputPath = path.join(tempDir, 'very_large_snap.sps'); - const { memoryUsedMB: totalMemoryMB } = await measureMemoryUsageAsync( - async () => { - await processor.saveFromTree(tree, outputPath); - return await processor.loadIntoTree(outputPath); - }, - ); + const { memoryUsedMB: totalMemoryMB } = await measureMemoryUsageAsync(async () => { + await processor.saveFromTree(tree, outputPath); + return await processor.loadIntoTree(outputPath); + }); expect(totalMemoryMB).toBeLessThan(100); - console.log( - `Snap very large file - Memory used: ${totalMemoryMB.toFixed(2)}MB`, - ); + console.log(`Snap very large file - Memory used: ${totalMemoryMB.toFixed(2)}MB`); }); }); - describe("DotProcessor Memory Tests", () => { - it("should handle very large hierarchies efficiently", async () => { + describe('DotProcessor Memory Tests', () => { + it('should handle very large hierarchies efficiently', async () => { const processor = new DotProcessor(); - const { result: tree, memoryUsedMB: _memoryUsedMB } = measureMemoryUsage( - () => { - return TreeFactory.createLarge(200, 10); // 200 pages, 10 buttons each - }, - ); + const { result: tree, memoryUsedMB: _memoryUsedMB } = measureMemoryUsage(() => { + return TreeFactory.createLarge(200, 10); // 200 pages, 10 buttons each + }); - const outputPath = path.join(tempDir, "large_hierarchy.dot"); + const outputPath = path.join(tempDir, 'large_hierarchy.dot'); - const { memoryUsedMB: totalMemoryMB } = await measureMemoryUsageAsync( - async () => { - await processor.saveFromTree(tree, outputPath); - return await processor.loadIntoTree(outputPath); - }, - ); + const { memoryUsedMB: totalMemoryMB } = await measureMemoryUsageAsync(async () => { + await processor.saveFromTree(tree, outputPath); + return await processor.loadIntoTree(outputPath); + }); expect(totalMemoryMB).toBeLessThan(40); // DOT format should be very efficient - console.log( - `DOT large hierarchy - Memory used: ${totalMemoryMB.toFixed(2)}MB`, - ); + console.log(`DOT large hierarchy - Memory used: ${totalMemoryMB.toFixed(2)}MB`); }); }); - describe("Cross-Processor Memory Comparison", () => { - it("should compare memory usage across all processors", async () => { + describe('Cross-Processor Memory Comparison', () => { + it('should compare memory usage across all processors', async () => { const tree = TreeFactory.createLarge(50, 20); // 1000 buttons const results: { [key: string]: number } = {}; // Test TouchChatProcessor const touchChatProcessor = new TouchChatProcessor(); - const touchChatPath = path.join(tempDir, "comparison_touchchat.ce"); - const { memoryUsedMB: touchChatMemory } = await measureMemoryUsageAsync( - async () => { - await touchChatProcessor.saveFromTree(tree, touchChatPath); - return await touchChatProcessor.loadIntoTree(touchChatPath); - }, - ); - results["TouchChat"] = touchChatMemory; + const touchChatPath = path.join(tempDir, 'comparison_touchchat.ce'); + const { memoryUsedMB: touchChatMemory } = await measureMemoryUsageAsync(async () => { + await touchChatProcessor.saveFromTree(tree, touchChatPath); + return await touchChatProcessor.loadIntoTree(touchChatPath); + }); + results['TouchChat'] = touchChatMemory; // Test SnapProcessor const snapProcessor = new SnapProcessor(); - const snapPath = path.join(tempDir, "comparison_snap.sps"); - const { memoryUsedMB: snapMemory } = await measureMemoryUsageAsync( - async () => { - await snapProcessor.saveFromTree(tree, snapPath); - return await snapProcessor.loadIntoTree(snapPath); - }, - ); - results["Snap"] = snapMemory; + const snapPath = path.join(tempDir, 'comparison_snap.sps'); + const { memoryUsedMB: snapMemory } = await measureMemoryUsageAsync(async () => { + await snapProcessor.saveFromTree(tree, snapPath); + return await snapProcessor.loadIntoTree(snapPath); + }); + results['Snap'] = snapMemory; // Test DotProcessor const dotProcessor = new DotProcessor(); - const dotPath = path.join(tempDir, "comparison_dot.dot"); - const { memoryUsedMB: dotMemory } = await measureMemoryUsageAsync( - async () => { - await dotProcessor.saveFromTree(tree, dotPath); - return await dotProcessor.loadIntoTree(dotPath); - }, - ); - results["DOT"] = dotMemory; + const dotPath = path.join(tempDir, 'comparison_dot.dot'); + const { memoryUsedMB: dotMemory } = await measureMemoryUsageAsync(async () => { + await dotProcessor.saveFromTree(tree, dotPath); + return await dotProcessor.loadIntoTree(dotPath); + }); + results['DOT'] = dotMemory; // All should be under reasonable limits Object.entries(results).forEach(([processor, memory]) => { expect(memory).toBeLessThan(50); - console.log( - `${processor} processor - Memory used: ${memory.toFixed(2)}MB`, - ); + console.log(`${processor} processor - Memory used: ${memory.toFixed(2)}MB`); }); // DOT should be efficient, but relative comparisons are flaky without --expose-gc @@ -412,8 +375,8 @@ describeIfLocal("Memory Performance Tests", () => { }); }); - describe("Memory Leak Detection", () => { - it("should not leak memory during repeated operations", async () => { + describe('Memory Leak Detection', () => { + it('should not leak memory during repeated operations', async () => { const processor = new DotProcessor(); if (global.gc) { @@ -445,21 +408,15 @@ describeIfLocal("Memory Performance Tests", () => { const firstHalf = memoryReadings.slice(0, 5); const secondHalf = memoryReadings.slice(5); - const firstHalfAvg = - firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length; - const secondHalfAvg = - secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length; + const firstHalfAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length; + const secondHalfAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length; // Second half should not be significantly higher than first half const memoryIncrease = secondHalfAvg - firstHalfAvg; expect(memoryIncrease).toBeLessThan(5); // Less than 5MB increase - console.log( - `Memory leak test - Average increase: ${memoryIncrease.toFixed(2)}MB`, - ); - console.log( - `Memory readings: ${memoryReadings.map((m) => m.toFixed(1)).join(", ")}MB`, - ); + console.log(`Memory leak test - Average increase: ${memoryIncrease.toFixed(2)}MB`); + console.log(`Memory readings: ${memoryReadings.map((m) => m.toFixed(1)).join(', ')}MB`); }); }); }); diff --git a/test/performance.test.ts b/test/performance.test.ts index 7435099..592a85d 100644 --- a/test/performance.test.ts +++ b/test/performance.test.ts @@ -1,17 +1,17 @@ // Performance tests for all processors -import fs from "fs"; -import path from "path"; -import { performance } from "perf_hooks"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; - -describe("Performance Tests", () => { - const tempDir = path.join(__dirname, "temp_performance"); +import fs from 'fs'; +import path from 'path'; +import { performance } from 'perf_hooks'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; + +describe('Performance Tests', () => { + const tempDir = path.join(__dirname, 'temp_performance'); let warnSpy: jest.SpyInstance; beforeAll(async () => { - warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } @@ -37,13 +37,11 @@ describe("Performance Tests", () => { // Helper function to create large test data function createLargeDotFile(nodeCount: number): string { - const lines = ["digraph G {"]; + const lines = ['digraph G {']; // Add nodes for (let i = 0; i < nodeCount; i++) { - lines.push( - ` node${i} [label="Node ${i} with some longer text content"];`, - ); + lines.push(` node${i} [label="Node ${i} with some longer text content"];`); } // Add edges (create a connected graph) @@ -60,8 +58,8 @@ describe("Performance Tests", () => { } } - lines.push("}"); - return lines.join("\n"); + lines.push('}'); + return lines.join('\n'); } function createLargeTree(pageCount: number, buttonsPerPage: number): AACTree { @@ -79,11 +77,9 @@ describe("Performance Tests", () => { id: `btn_${p}_${b}`, label: `Button ${b} on Page ${p}`, message: `This is button ${b} on page ${p} with some longer message content`, - type: Math.random() > 0.7 ? "NAVIGATE" : "SPEAK", + type: Math.random() > 0.7 ? 'NAVIGATE' : 'SPEAK', targetPageId: - Math.random() > 0.7 - ? `page_${Math.floor(Math.random() * pageCount)}` - : undefined, + Math.random() > 0.7 ? `page_${Math.floor(Math.random() * pageCount)}` : undefined, }); page.addButton(button); } @@ -94,8 +90,8 @@ describe("Performance Tests", () => { return tree; } - describe("Large File Processing", () => { - it("should handle large DOT files efficiently", async () => { + describe('Large File Processing', () => { + it('should handle large DOT files efficiently', async () => { const processor = new DotProcessor(); const largeContent = createLargeDotFile(1000); // 1000 nodes @@ -110,9 +106,7 @@ describe("Performance Tests", () => { const processingTime = endTime - startTime; const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; - console.log( - `DOT Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`, - ); + console.log(`DOT Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`); expect(tree).toBeDefined(); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); @@ -120,11 +114,11 @@ describe("Performance Tests", () => { expect(memoryIncrease).toBeLessThan(100); // Should not use more than 100MB extra }); - it("should handle large trees in saveFromTree operations", async () => { + it('should handle large trees in saveFromTree operations', async () => { const processor = new DotProcessor(); const largeTree = createLargeTree(50, 20); // 50 pages, 20 buttons each - const outputPath = path.join(tempDir, "large_output.dot"); + const outputPath = path.join(tempDir, 'large_output.dot'); const memBefore = getMemoryUsage(); const startTime = performance.now(); @@ -137,7 +131,7 @@ describe("Performance Tests", () => { const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log( - `DOT Save Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`, + `DOT Save Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB` ); expect(fs.existsSync(outputPath)).toBe(true); @@ -145,7 +139,7 @@ describe("Performance Tests", () => { expect(memoryIncrease).toBeLessThan(50); // Should not use more than 50MB extra }); - it("should handle large translation operations efficiently", async () => { + it('should handle large translation operations efficiently', async () => { const processor = new DotProcessor(); const largeContent = createLargeDotFile(500); @@ -156,14 +150,14 @@ describe("Performance Tests", () => { translations.set(`Edge ${i}`, `Borde ${i}`); } - const outputPath = path.join(tempDir, "large_translated.dot"); + const outputPath = path.join(tempDir, 'large_translated.dot'); const memBefore = getMemoryUsage(); const startTime = performance.now(); const result = await processor.processTexts( Buffer.from(largeContent), translations, - outputPath, + outputPath ); const endTime = performance.now(); @@ -173,7 +167,7 @@ describe("Performance Tests", () => { const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log( - `DOT Translation Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`, + `DOT Translation Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB` ); expect(result).toBeInstanceOf(Buffer); @@ -182,8 +176,8 @@ describe("Performance Tests", () => { }); }); - describe("Memory Usage Patterns", () => { - it("should not leak memory during repeated operations", async () => { + describe('Memory Usage Patterns', () => { + it('should not leak memory during repeated operations', async () => { const processor = new DotProcessor(); const testContent = createLargeDotFile(100); @@ -209,7 +203,7 @@ describe("Performance Tests", () => { expect(memoryIncrease).toBeLessThan(30); // Allow small variance on CI }); - it("should handle concurrent processing efficiently", async () => { + it('should handle concurrent processing efficiently', async () => { const processor = new DotProcessor(); const testContent = createLargeDotFile(200); @@ -235,7 +229,7 @@ describe("Performance Tests", () => { const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log( - `Concurrent Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`, + `Concurrent Performance: ${processingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB` ); expect(results).toHaveLength(5); @@ -248,12 +242,12 @@ describe("Performance Tests", () => { }); }); - describe("Database Performance", () => { - it("should handle large Snap databases efficiently", async () => { + describe('Database Performance', () => { + it('should handle large Snap databases efficiently', async () => { const processor = new SnapProcessor(); const largeTree = createLargeTree(20, 15); // 20 pages, 15 buttons each - const outputPath = path.join(tempDir, "large_snap.spb"); + const outputPath = path.join(tempDir, 'large_snap.spb'); const memBefore = getMemoryUsage(); const startTime = performance.now(); @@ -272,21 +266,19 @@ describe("Performance Tests", () => { const memoryIncrease = memAfter.heapUsed - memBefore.heapUsed; console.log( - `Snap DB Performance: Save ${saveProcessingTime.toFixed(2)}ms, Load ${loadProcessingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB`, + `Snap DB Performance: Save ${saveProcessingTime.toFixed(2)}ms, Load ${loadProcessingTime.toFixed(2)}ms, Memory: +${memoryIncrease}MB` ); expect(loadedTree).toBeDefined(); - expect(Object.keys(loadedTree.pages).length).toBe( - Object.keys(largeTree.pages).length, - ); + expect(Object.keys(loadedTree.pages).length).toBe(Object.keys(largeTree.pages).length); expect(saveProcessingTime).toBeLessThan(25000); // Save should complete in under 25 seconds on slower disks expect(loadProcessingTime).toBeLessThan(15000); // Load should complete in under 15 seconds expect(memoryIncrease).toBeLessThan(100); // Should not use excessive memory }); }); - describe("Timeout Handling", () => { - it("should handle slow operations gracefully", async () => { + describe('Timeout Handling', () => { + it('should handle slow operations gracefully', async () => { const processor = new DotProcessor(); // Create a very large file that might be slow to process @@ -295,21 +287,17 @@ describe("Performance Tests", () => { const startTime = performance.now(); try { - const tree = await processor.loadIntoTree( - Buffer.from(veryLargeContent), - ); + const tree = await processor.loadIntoTree(Buffer.from(veryLargeContent)); const endTime = performance.now(); const processingTime = endTime - startTime; - console.log( - `Very large file processing: ${processingTime.toFixed(2)}ms`, - ); + console.log(`Very large file processing: ${processingTime.toFixed(2)}ms`); expect(tree).toBeDefined(); expect(processingTime).toBeLessThan(30000); // Should complete within 30 seconds } catch (error) { // If it fails due to memory or timeout, that's acceptable for very large files - console.log("Very large file processing failed (acceptable):", error); + console.log('Very large file processing failed (acceptable):', error); } }); }); diff --git a/test/platformPaths.test.ts b/test/platformPaths.test.ts index 2f2ddd0..bda54ce 100644 --- a/test/platformPaths.test.ts +++ b/test/platformPaths.test.ts @@ -1,139 +1,123 @@ -import { - describe, - it, - expect, - beforeEach, - afterEach, - jest, -} from "@jest/globals"; -import * as fs from "fs"; -import * as path from "path"; -import { execSync } from "child_process"; +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; import { getCommonDocumentsPath, findGrid3UserPaths, findGrid3HistoryDatabases, findGrid3Vocabularies, findGrid3UserHistory, -} from "../src/processors/gridset/helpers"; +} from '../src/processors/gridset/helpers'; import { findSnapPackages as findSnapPackagesFromSnap, findSnapPackagePath as findSnapPackagePathFromSnap, findSnapUsers, findSnapUserVocabularies, findSnapUserHistory, -} from "../src/processors/snap/helpers"; -import { defaultFileAdapter } from "../src/utils/io"; +} from '../src/processors/snap/helpers'; +import { defaultFileAdapter } from '../src/utils/io'; // Mock modules -jest.mock("fs"); -jest.mock("child_process"); +jest.mock('fs'); +jest.mock('child_process'); const mockFs = fs as jest.Mocked; const mockExecSync = execSync as jest.MockedFunction; -describe("Grid3 Path Discovery", () => { +describe('Grid3 Path Discovery', () => { const originalPlatform = process.platform; beforeEach(async () => { jest.clearAllMocks(); // Mock Windows platform - Object.defineProperty(process, "platform", { - value: "win32", + Object.defineProperty(process, 'platform', { + value: 'win32', configurable: true, }); }); afterEach(async () => { // Restore original platform - Object.defineProperty(process, "platform", { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true, }); }); - describe("getCommonDocumentsPath", () => { - it("should return path from registry on Windows", async () => { - const expectedPath = "C:\\Users\\Public\\Documents"; - mockExecSync.mockReturnValue( - `Common Documents REG_SZ ${expectedPath}\r\n` as any, - ); + describe('getCommonDocumentsPath', () => { + it('should return path from registry on Windows', async () => { + const expectedPath = 'C:\\Users\\Public\\Documents'; + mockExecSync.mockReturnValue(`Common Documents REG_SZ ${expectedPath}\r\n` as any); const result = getCommonDocumentsPath(); expect(result).toBe(expectedPath); expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining("REG.EXE QUERY"), - expect.objectContaining({ encoding: "utf-8", windowsHide: true }), + expect.stringContaining('REG.EXE QUERY'), + expect.objectContaining({ encoding: 'utf-8', windowsHide: true }) ); }); - it("should return default path if registry access fails", async () => { + it('should return default path if registry access fails', async () => { mockExecSync.mockImplementation(() => { - throw new Error("Registry access failed"); + throw new Error('Registry access failed'); }); const result = getCommonDocumentsPath(); - expect(result).toBe("C:\\Users\\Public\\Documents"); + expect(result).toBe('C:\\Users\\Public\\Documents'); }); - it("should return empty string on non-Windows platforms", async () => { - Object.defineProperty(process, "platform", { - value: "darwin", + it('should return empty string on non-Windows platforms', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', configurable: true, }); const result = getCommonDocumentsPath(); - expect(result).toBe(""); + expect(result).toBe(''); expect(mockExecSync).not.toHaveBeenCalled(); }); }); - describe("findGrid3UserPaths", () => { - it("should find Grid3 user paths with history databases", async () => { - const mockCommonDocs = "C:\\Users\\Public\\Documents"; - mockExecSync.mockReturnValue( - `Common Documents REG_SZ ${mockCommonDocs}\r\n` as any, - ); + describe('findGrid3UserPaths', () => { + it('should find Grid3 user paths with history databases', async () => { + const mockCommonDocs = 'C:\\Users\\Public\\Documents'; + mockExecSync.mockReturnValue(`Common Documents REG_SZ ${mockCommonDocs}\r\n` as any); - const grid3BasePath = path.win32.join( - mockCommonDocs, - "Smartbox", - "Grid 3", - "Users", - ); + const grid3BasePath = path.win32.join(mockCommonDocs, 'Smartbox', 'Grid 3', 'Users'); const result = await findGrid3UserPaths({ ...defaultFileAdapter, listDir: async (pathStr) => { - if (pathStr === grid3BasePath) return ["TestUser"]; - if (pathStr.includes("TestUser")) return ["en-gb"]; + if (pathStr === grid3BasePath) return ['TestUser']; + if (pathStr.includes('TestUser')) return ['en-gb']; return []; }, isDirectory: async (pathStr) => { - return pathStr.endsWith("TestUser") || pathStr.endsWith("en-gb"); + return pathStr.endsWith('TestUser') || pathStr.endsWith('en-gb'); }, pathExists: async (pathStr) => { if (pathStr === grid3BasePath) return true; - if (pathStr.includes("history.sqlite")) return true; + if (pathStr.includes('history.sqlite')) return true; return false; }, }); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ - userName: "TestUser", - langCode: "en-gb", - basePath: expect.stringContaining("TestUser\\en-gb"), - historyDbPath: expect.stringContaining("history.sqlite"), + userName: 'TestUser', + langCode: 'en-gb', + basePath: expect.stringContaining('TestUser\\en-gb'), + historyDbPath: expect.stringContaining('history.sqlite'), }); }); - it("should return empty array if Grid3 directory does not exist", async () => { + it('should return empty array if Grid3 directory does not exist', async () => { mockExecSync.mockReturnValue( - "Common Documents REG_SZ C:\\Users\\Public\\Documents\r\n" as any, + 'Common Documents REG_SZ C:\\Users\\Public\\Documents\r\n' as any ); mockFs.existsSync.mockReturnValue(false); @@ -142,9 +126,9 @@ describe("Grid3 Path Discovery", () => { expect(result).toEqual([]); }); - it("should return empty array on non-Windows platforms", async () => { - Object.defineProperty(process, "platform", { - value: "linux", + it('should return empty array on non-Windows platforms', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', configurable: true, }); @@ -155,174 +139,145 @@ describe("Grid3 Path Discovery", () => { }); }); - describe("findGrid3HistoryDatabases", () => { - it("should return array of history database paths", async () => { - const mockCommonDocs = "C:\\Users\\Public\\Documents"; - mockExecSync.mockReturnValue( - `Common Documents REG_SZ ${mockCommonDocs}\r\n` as any, - ); + describe('findGrid3HistoryDatabases', () => { + it('should return array of history database paths', async () => { + const mockCommonDocs = 'C:\\Users\\Public\\Documents'; + mockExecSync.mockReturnValue(`Common Documents REG_SZ ${mockCommonDocs}\r\n` as any); - const grid3BasePath = path.win32.join( - mockCommonDocs, - "Smartbox", - "Grid 3", - "Users", - ); + const grid3BasePath = path.win32.join(mockCommonDocs, 'Smartbox', 'Grid 3', 'Users'); const result = await findGrid3HistoryDatabases({ ...defaultFileAdapter, pathExists: async () => true, listDir: async (pathStr) => { - if (pathStr === grid3BasePath) return ["User1"]; - return ["en-us"]; + if (pathStr === grid3BasePath) return ['User1']; + return ['en-us']; }, isDirectory: async (pathStr) => { - return pathStr.endsWith("User1") || pathStr.endsWith("en-us"); + return pathStr.endsWith('User1') || pathStr.endsWith('en-us'); }, }); expect(result).toHaveLength(1); - expect(result[0]).toContain("history.sqlite"); + expect(result[0]).toContain('history.sqlite'); }); }); - describe("findGrid3Vocabularies", () => { - it("should list gridset files per user", async () => { - const mockCommonDocs = "C:\\Users\\Public\\Documents"; - const grid3BasePath = path.win32.join( - mockCommonDocs, - "Smartbox", - "Grid 3", - "Users", - ); - const gridSetsDir = path.win32.join(grid3BasePath, "User1", "Grid Sets"); + describe('findGrid3Vocabularies', () => { + it('should list gridset files per user', async () => { + const mockCommonDocs = 'C:\\Users\\Public\\Documents'; + const grid3BasePath = path.win32.join(mockCommonDocs, 'Smartbox', 'Grid 3', 'Users'); + const gridSetsDir = path.win32.join(grid3BasePath, 'User1', 'Grid Sets'); - mockExecSync.mockReturnValue( - `Common Documents REG_SZ ${mockCommonDocs}\r\n` as any, - ); + mockExecSync.mockReturnValue(`Common Documents REG_SZ ${mockCommonDocs}\r\n` as any); const result = await findGrid3Vocabularies(undefined, { ...defaultFileAdapter, pathExists: async (pathStr) => - pathStr === grid3BasePath || - pathStr === gridSetsDir || - pathStr.endsWith("Test.gridset"), + pathStr === grid3BasePath || pathStr === gridSetsDir || pathStr.endsWith('Test.gridset'), listDir: async (pathStr) => { - if (pathStr === grid3BasePath) return ["User1"]; - if (pathStr === gridSetsDir) return ["Test.gridset"]; + if (pathStr === grid3BasePath) return ['User1']; + if (pathStr === gridSetsDir) return ['Test.gridset']; return []; }, isDirectory: async (pathStr) => - pathStr === grid3BasePath || - pathStr === gridSetsDir || - pathStr.endsWith("User1"), + pathStr === grid3BasePath || pathStr === gridSetsDir || pathStr.endsWith('User1'), }); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ - userName: "User1", - gridsetPath: path.win32.join(gridSetsDir, "Test.gridset"), + userName: 'User1', + gridsetPath: path.win32.join(gridSetsDir, 'Test.gridset'), }); }); }); - describe("findGrid3UserHistory", () => { - it("should return history path for specific user", async () => { - const mockCommonDocs = "C:\\Users\\Public\\Documents"; - mockExecSync.mockReturnValue( - `Common Documents REG_SZ ${mockCommonDocs}\r\n` as any, - ); + describe('findGrid3UserHistory', () => { + it('should return history path for specific user', async () => { + const mockCommonDocs = 'C:\\Users\\Public\\Documents'; + mockExecSync.mockReturnValue(`Common Documents REG_SZ ${mockCommonDocs}\r\n` as any); - const grid3BasePath = path.win32.join( - mockCommonDocs, - "Smartbox", - "Grid 3", - "Users", - ); + const grid3BasePath = path.win32.join(mockCommonDocs, 'Smartbox', 'Grid 3', 'Users'); - const result = await findGrid3UserHistory("User1", "en-gb", { + const result = await findGrid3UserHistory('User1', 'en-gb', { ...defaultFileAdapter, pathExists: async (pathStr) => { if (pathStr === grid3BasePath) return true; - if (pathStr.includes("history.sqlite")) return true; + if (pathStr.includes('history.sqlite')) return true; return false; }, listDir: async (pathStr) => { - if (pathStr === grid3BasePath) return ["User1"]; - if (pathStr.includes("User1")) return ["en-gb"]; + if (pathStr === grid3BasePath) return ['User1']; + if (pathStr.includes('User1')) return ['en-gb']; return []; }, - isDirectory: async (pathStr) => - pathStr.endsWith("User1") || pathStr.endsWith("en-gb"), + isDirectory: async (pathStr) => pathStr.endsWith('User1') || pathStr.endsWith('en-gb'), }); - expect(result).toContain("history.sqlite"); + expect(result).toContain('history.sqlite'); }); }); }); -describe("Snap Path Discovery", () => { +describe('Snap Path Discovery', () => { const originalPlatform = process.platform; const originalEnv = process.env; beforeEach(async () => { jest.clearAllMocks(); // Mock Windows platform - Object.defineProperty(process, "platform", { - value: "win32", + Object.defineProperty(process, 'platform', { + value: 'win32', configurable: true, }); // Mock environment process.env = { ...originalEnv, - LOCALAPPDATA: "C:\\Users\\TestUser\\AppData\\Local", + LOCALAPPDATA: 'C:\\Users\\TestUser\\AppData\\Local', }; }); afterEach(async () => { // Restore original platform and environment - Object.defineProperty(process, "platform", { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true, }); process.env = originalEnv; }); - describe("findSnapPackages", () => { - it("should find Snap packages matching pattern", async () => { + describe('findSnapPackages', () => { + it('should find Snap packages matching pattern', async () => { const result = await findSnapPackagesFromSnap(undefined, { ...defaultFileAdapter, pathExists: async () => true, listDir: async () => [ - "TobiiDynavox.Snap_abc123", - "TobiiDynavox.Communicator_def456", - "Microsoft.WindowsStore_xyz789", + 'TobiiDynavox.Snap_abc123', + 'TobiiDynavox.Communicator_def456', + 'Microsoft.WindowsStore_xyz789', ], isDirectory: async () => true, }); expect(result).toHaveLength(2); - expect(result[0].packageName).toBe("TobiiDynavox.Snap_abc123"); - expect(result[0].packagePath).toContain("TobiiDynavox.Snap_abc123"); - expect(result[1].packageName).toBe("TobiiDynavox.Communicator_def456"); + expect(result[0].packageName).toBe('TobiiDynavox.Snap_abc123'); + expect(result[0].packagePath).toContain('TobiiDynavox.Snap_abc123'); + expect(result[1].packageName).toBe('TobiiDynavox.Communicator_def456'); }); - it("should filter by custom pattern", async () => { - const result = await findSnapPackagesFromSnap("CustomApp", { + it('should filter by custom pattern', async () => { + const result = await findSnapPackagesFromSnap('CustomApp', { ...defaultFileAdapter, pathExists: async () => true, - listDir: async () => [ - "TobiiDynavox.Snap_abc123", - "CustomApp.Package_xyz", - ], + listDir: async () => ['TobiiDynavox.Snap_abc123', 'CustomApp.Package_xyz'], isDirectory: async () => true, }); expect(result).toHaveLength(1); - expect(result[0].packageName).toBe("CustomApp.Package_xyz"); + expect(result[0].packageName).toBe('CustomApp.Package_xyz'); }); - it("should return empty array if Packages directory does not exist", async () => { + it('should return empty array if Packages directory does not exist', async () => { mockFs.existsSync.mockReturnValue(false); const result = await findSnapPackagesFromSnap(); @@ -330,7 +285,7 @@ describe("Snap Path Discovery", () => { expect(result).toEqual([]); }); - it("should return empty array if LOCALAPPDATA is not set", async () => { + it('should return empty array if LOCALAPPDATA is not set', async () => { delete process.env.LOCALAPPDATA; const result = await findSnapPackagesFromSnap(); @@ -339,9 +294,9 @@ describe("Snap Path Discovery", () => { expect(mockFs.existsSync).not.toHaveBeenCalled(); }); - it("should return empty array on non-Windows platforms", async () => { - Object.defineProperty(process, "platform", { - value: "darwin", + it('should return empty array on non-Windows platforms', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', configurable: true, }); @@ -352,19 +307,19 @@ describe("Snap Path Discovery", () => { }); }); - describe("findSnapPackagePath", () => { - it("should return first matching package path", async () => { + describe('findSnapPackagePath', () => { + it('should return first matching package path', async () => { const result = await findSnapPackagePathFromSnap(undefined, { ...defaultFileAdapter, pathExists: async () => true, - listDir: async () => ["TobiiDynavox.Snap_abc123"], + listDir: async () => ['TobiiDynavox.Snap_abc123'], isDirectory: async () => true, }); - expect(result).toContain("TobiiDynavox.Snap_abc123"); + expect(result).toContain('TobiiDynavox.Snap_abc123'); }); - it("should return null if no packages found", async () => { + it('should return null if no packages found', async () => { mockFs.existsSync.mockReturnValue(true); mockFs.readdirSync.mockReturnValue([] as any); @@ -374,93 +329,85 @@ describe("Snap Path Discovery", () => { }); }); - describe("findSnapUsers", () => { - it("should list Snap users and vocab files", async () => { - const localAppData = process.env.LOCALAPPDATA ?? ""; - const packagesPath = path.join(localAppData, "Packages"); - const packagePath = path.join(packagesPath, "TobiiDynavox.Snap_abc123"); - const usersRoot = path.join(packagePath, "LocalState", "Users"); - const userPath = path.join(usersRoot, "user1"); - const vocabPath = path.join(userPath, "board.sps"); + describe('findSnapUsers', () => { + it('should list Snap users and vocab files', async () => { + const localAppData = process.env.LOCALAPPDATA ?? ''; + const packagesPath = path.join(localAppData, 'Packages'); + const packagePath = path.join(packagesPath, 'TobiiDynavox.Snap_abc123'); + const usersRoot = path.join(packagePath, 'LocalState', 'Users'); + const userPath = path.join(usersRoot, 'user1'); + const vocabPath = path.join(userPath, 'board.sps'); - const users = await findSnapUsers("TobiiDynavox", { + const users = await findSnapUsers('TobiiDynavox', { ...defaultFileAdapter, pathExists: async (pathStr) => - pathStr === packagesPath || - pathStr === usersRoot || - pathStr === userPath, + pathStr === packagesPath || pathStr === usersRoot || pathStr === userPath, listDir: async (pathStr) => { - if (pathStr === packagesPath) return ["TobiiDynavox.Snap_abc123"]; - if (pathStr === usersRoot) return ["user1", "SwiftKeyStaticModels"]; - if (pathStr === userPath) return ["board.sps", "notes.txt"]; + if (pathStr === packagesPath) return ['TobiiDynavox.Snap_abc123']; + if (pathStr === usersRoot) return ['user1', 'SwiftKeyStaticModels']; + if (pathStr === userPath) return ['board.sps', 'notes.txt']; return []; }, isDirectory: async (pathStr) => - pathStr.endsWith("TobiiDynavox.Snap_abc123") || - pathStr.endsWith("user1") || - pathStr.endsWith("SwiftKeyStaticModels"), + pathStr.endsWith('TobiiDynavox.Snap_abc123') || + pathStr.endsWith('user1') || + pathStr.endsWith('SwiftKeyStaticModels'), }); expect(users).toHaveLength(1); - expect(users[0]).toMatchObject({ userId: "user1" }); + expect(users[0]).toMatchObject({ userId: 'user1' }); expect(users[0].vocabPaths).toContain(vocabPath); }); }); - describe("findSnapUserVocabularies", () => { - it("should return vocab paths for a specific user", async () => { - const localAppData = process.env.LOCALAPPDATA ?? ""; - const packagesPath = path.join(localAppData, "Packages"); - const packagePath = path.join(packagesPath, "TobiiDynavox.Snap_abc123"); - const usersRoot = path.join(packagePath, "LocalState", "Users"); - const userPath = path.join(usersRoot, "user1"); - const vocabPath = path.join(userPath, "board.sps"); + describe('findSnapUserVocabularies', () => { + it('should return vocab paths for a specific user', async () => { + const localAppData = process.env.LOCALAPPDATA ?? ''; + const packagesPath = path.join(localAppData, 'Packages'); + const packagePath = path.join(packagesPath, 'TobiiDynavox.Snap_abc123'); + const usersRoot = path.join(packagePath, 'LocalState', 'Users'); + const userPath = path.join(usersRoot, 'user1'); + const vocabPath = path.join(userPath, 'board.sps'); - const result = await findSnapUserVocabularies("user1", "TobiiDynavox", { + const result = await findSnapUserVocabularies('user1', 'TobiiDynavox', { ...defaultFileAdapter, pathExists: async (pathStr) => - pathStr === packagesPath || - pathStr === usersRoot || - pathStr === userPath, + pathStr === packagesPath || pathStr === usersRoot || pathStr === userPath, listDir: async (pathStr) => { - if (pathStr === packagesPath) return ["TobiiDynavox.Snap_abc123"]; - if (pathStr === usersRoot) return ["user1"]; - if (pathStr === userPath) return ["board.sps"]; + if (pathStr === packagesPath) return ['TobiiDynavox.Snap_abc123']; + if (pathStr === usersRoot) return ['user1']; + if (pathStr === userPath) return ['board.sps']; return []; }, isDirectory: async (pathStr) => - pathStr.endsWith("TobiiDynavox.Snap_abc123") || - pathStr.endsWith("user1"), + pathStr.endsWith('TobiiDynavox.Snap_abc123') || pathStr.endsWith('user1'), }); expect(result).toContain(vocabPath); }); }); - describe("findSnapUserHistory", () => { - it("should find history-like files for a user", async () => { - const localAppData = process.env.LOCALAPPDATA ?? ""; - const packagesPath = path.join(localAppData, "Packages"); - const packagePath = path.join(packagesPath, "TobiiDynavox.Snap_abc123"); - const usersRoot = path.join(packagePath, "LocalState", "Users"); - const userPath = path.join(usersRoot, "user1"); - const historyPath = path.join(userPath, "history.db"); + describe('findSnapUserHistory', () => { + it('should find history-like files for a user', async () => { + const localAppData = process.env.LOCALAPPDATA ?? ''; + const packagesPath = path.join(localAppData, 'Packages'); + const packagePath = path.join(packagesPath, 'TobiiDynavox.Snap_abc123'); + const usersRoot = path.join(packagePath, 'LocalState', 'Users'); + const userPath = path.join(usersRoot, 'user1'); + const historyPath = path.join(userPath, 'history.db'); - const result = await findSnapUserHistory("user1", "TobiiDynavox", { + const result = await findSnapUserHistory('user1', 'TobiiDynavox', { ...defaultFileAdapter, pathExists: async (pathStr) => - pathStr === packagesPath || - pathStr === usersRoot || - pathStr === userPath, + pathStr === packagesPath || pathStr === usersRoot || pathStr === userPath, listDir: async (pathStr) => { - if (pathStr === packagesPath) return ["TobiiDynavox.Snap_abc123"]; - if (pathStr === usersRoot) return ["user1"]; - if (pathStr === userPath) return ["history.db"]; + if (pathStr === packagesPath) return ['TobiiDynavox.Snap_abc123']; + if (pathStr === usersRoot) return ['user1']; + if (pathStr === userPath) return ['history.db']; return []; }, isDirectory: async (pathStr) => - pathStr.endsWith("TobiiDynavox.Snap_abc123") || - pathStr.endsWith("user1"), + pathStr.endsWith('TobiiDynavox.Snap_abc123') || pathStr.endsWith('user1'), }); expect(result).toContain(historyPath); diff --git a/test/processTexts.realworld.test.ts b/test/processTexts.realworld.test.ts index 44a9d05..1727dc8 100644 --- a/test/processTexts.realworld.test.ts +++ b/test/processTexts.realworld.test.ts @@ -1,18 +1,18 @@ // Real-world processTexts tests using actual example files -import fs from "fs"; -import path from "path"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { OpmlProcessor } from "../src/processors/opmlProcessor"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import fs from 'fs'; +import path from 'path'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -jest.setTimeout(process.platform === "win32" ? 60000 : 30000); +jest.setTimeout(process.platform === 'win32' ? 60000 : 30000); -describe("ProcessTexts with Real-World Data", () => { - const examplesDir = path.join(__dirname, "../examples"); - const tempDir = path.join(__dirname, "temp_realworld"); +describe('ProcessTexts with Real-World Data', () => { + const examplesDir = path.join(__dirname, '../examples'); + const tempDir = path.join(__dirname, 'temp_realworld'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -30,13 +30,13 @@ describe("ProcessTexts with Real-World Data", () => { } }); - describe("DOT Processor with Real Data", () => { - const dotFile = path.join(examplesDir, "example.dot"); - const communikateDotFile = path.join(examplesDir, "communikate.dot"); + describe('DOT Processor with Real Data', () => { + const dotFile = path.join(examplesDir, 'example.dot'); + const communikateDotFile = path.join(examplesDir, 'communikate.dot'); - it("should extract and translate texts from example.dot", async () => { + it('should extract and translate texts from example.dot', async () => { if (!fs.existsSync(dotFile)) { - console.log("Skipping DOT test - example.dot not found"); + console.log('Skipping DOT test - example.dot not found'); return; } @@ -45,35 +45,31 @@ describe("ProcessTexts with Real-World Data", () => { // First extract all texts to see what we're working with const originalTexts = await processor.extractTexts(dotFile); expect(originalTexts.length).toBeGreaterThan(0); - console.log("DOT original texts:", originalTexts.slice(0, 5)); // Show first 5 + console.log('DOT original texts:', originalTexts.slice(0, 5)); // Show first 5 // Create translations for some common words const translations = new Map(); originalTexts.forEach((text) => { - if (text.toLowerCase().includes("hello")) { - translations.set(text, text.replace(/hello/gi, "hola")); + if (text.toLowerCase().includes('hello')) { + translations.set(text, text.replace(/hello/gi, 'hola')); } - if (text.toLowerCase().includes("home")) { - translations.set(text, text.replace(/home/gi, "casa")); + if (text.toLowerCase().includes('home')) { + translations.set(text, text.replace(/home/gi, 'casa')); } - if (text.toLowerCase().includes("food")) { - translations.set(text, text.replace(/food/gi, "comida")); + if (text.toLowerCase().includes('food')) { + translations.set(text, text.replace(/food/gi, 'comida')); } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, "translated_example.dot"); - const result = await processor.processTexts( - dotFile, - translations, - outputPath, - ); + const outputPath = path.join(tempDir, 'translated_example.dot'); + const result = await processor.processTexts(dotFile, translations, outputPath); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); // Verify translations were applied - const translatedContent = Buffer.from(result).toString("utf8"); + const translatedContent = Buffer.from(result).toString('utf8'); translations.forEach((translation, original) => { if (original !== translation) { expect(translatedContent).toContain(translation); @@ -82,9 +78,9 @@ describe("ProcessTexts with Real-World Data", () => { } }); - it("should handle communikate.dot file", async () => { + it('should handle communikate.dot file', async () => { if (!fs.existsSync(communikateDotFile)) { - console.log("Skipping communikate DOT test - file not found"); + console.log('Skipping communikate DOT test - file not found'); return; } @@ -93,21 +89,21 @@ describe("ProcessTexts with Real-World Data", () => { expect(texts.length).toBeGreaterThan(0); // Test with a simple translation - const translations = new Map([["Core", "Núcleo"]]); - const outputPath = path.join(tempDir, "translated_communikate.dot"); + const translations = new Map([['Core', 'Núcleo']]); + const outputPath = path.join(tempDir, 'translated_communikate.dot'); await expect( - processor.processTexts(communikateDotFile, translations, outputPath), + processor.processTexts(communikateDotFile, translations, outputPath) ).resolves.toBeInstanceOf(Uint8Array); }); }); - describe("OPML Processor with Real Data", () => { - const opmlFile = path.join(examplesDir, "example.opml"); + describe('OPML Processor with Real Data', () => { + const opmlFile = path.join(examplesDir, 'example.opml'); - it("should extract and translate texts from example.opml", async () => { + it('should extract and translate texts from example.opml', async () => { if (!fs.existsSync(opmlFile)) { - console.log("Skipping OPML test - example.opml not found"); + console.log('Skipping OPML test - example.opml not found'); return; } @@ -116,36 +112,32 @@ describe("ProcessTexts with Real-World Data", () => { // Extract texts to see the structure const originalTexts = await processor.extractTexts(opmlFile); expect(originalTexts.length).toBeGreaterThan(0); - console.log("OPML original texts:", originalTexts.slice(0, 5)); + console.log('OPML original texts:', originalTexts.slice(0, 5)); // Create translations based on actual content const translations = new Map(); originalTexts.forEach((text) => { - if (text.toLowerCase().includes("home")) { - translations.set(text, text.replace(/home/gi, "casa")); + if (text.toLowerCase().includes('home')) { + translations.set(text, text.replace(/home/gi, 'casa')); } - if (text.toLowerCase().includes("food")) { - translations.set(text, text.replace(/food/gi, "comida")); + if (text.toLowerCase().includes('food')) { + translations.set(text, text.replace(/food/gi, 'comida')); } - if (text.toLowerCase().includes("drink")) { - translations.set(text, text.replace(/drink/gi, "bebida")); + if (text.toLowerCase().includes('drink')) { + translations.set(text, text.replace(/drink/gi, 'bebida')); } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, "translated_example.opml"); - const result = await processor.processTexts( - opmlFile, - translations, - outputPath, - ); + const outputPath = path.join(tempDir, 'translated_example.opml'); + const result = await processor.processTexts(opmlFile, translations, outputPath); expect(result).toBeInstanceOf(Buffer); // Verify the XML structure is maintained and translations applied - const translatedContent = Buffer.from(result).toString("utf8"); - expect(translatedContent).toContain(" { if (original !== translation) { @@ -156,13 +148,13 @@ describe("ProcessTexts with Real-World Data", () => { }); }); - describe("OBF Processor with Real Data", () => { - const obfFile = path.join(examplesDir, "example.obf"); - const obzFile = path.join(examplesDir, "example.obz"); + describe('OBF Processor with Real Data', () => { + const obfFile = path.join(examplesDir, 'example.obf'); + const obzFile = path.join(examplesDir, 'example.obz'); - it("should extract and translate texts from example.obf", async () => { + it('should extract and translate texts from example.obf', async () => { if (!fs.existsSync(obfFile)) { - console.log("Skipping OBF test - example.obf not found"); + console.log('Skipping OBF test - example.obf not found'); return; } @@ -171,31 +163,27 @@ describe("ProcessTexts with Real-World Data", () => { // Extract texts to understand the content const originalTexts = await processor.extractTexts(obfFile); expect(originalTexts.length).toBeGreaterThan(0); - console.log("OBF original texts:", originalTexts.slice(0, 5)); + console.log('OBF original texts:', originalTexts.slice(0, 5)); // Create meaningful translations const translations = new Map(); originalTexts.forEach((text) => { - if (text && typeof text === "string") { - if (text.toLowerCase().includes("hello")) { - translations.set(text, text.replace(/hello/gi, "hola")); + if (text && typeof text === 'string') { + if (text.toLowerCase().includes('hello')) { + translations.set(text, text.replace(/hello/gi, 'hola')); } - if (text.toLowerCase().includes("yes")) { - translations.set(text, text.replace(/yes/gi, "sí")); + if (text.toLowerCase().includes('yes')) { + translations.set(text, text.replace(/yes/gi, 'sí')); } - if (text.toLowerCase().includes("no")) { - translations.set(text, text.replace(/no/gi, "no")); + if (text.toLowerCase().includes('no')) { + translations.set(text, text.replace(/no/gi, 'no')); } } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, "translated_example.obf"); - const result = await processor.processTexts( - obfFile, - translations, - outputPath, - ); + const outputPath = path.join(tempDir, 'translated_example.obf'); + const result = await processor.processTexts(obfFile, translations, outputPath); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); @@ -206,9 +194,9 @@ describe("ProcessTexts with Real-World Data", () => { } }); - it("should handle OBZ (zip) files", async () => { + it('should handle OBZ (zip) files', async () => { if (!fs.existsSync(obzFile)) { - console.log("Skipping OBZ test - example.obz not found"); + console.log('Skipping OBZ test - example.obz not found'); return; } @@ -217,21 +205,21 @@ describe("ProcessTexts with Real-World Data", () => { expect(texts.length).toBeGreaterThan(0); // Test with simple translation - const translations = new Map([["home", "casa"]]); - const outputPath = path.join(tempDir, "translated_example.obz"); + const translations = new Map([['home', 'casa']]); + const outputPath = path.join(tempDir, 'translated_example.obz'); await expect( - processor.processTexts(obzFile, translations, outputPath), + processor.processTexts(obzFile, translations, outputPath) ).resolves.toBeInstanceOf(Uint8Array); }); }); - describe("GridSet Processor with Real Data", () => { - const gridsetFile = path.join(examplesDir, "example.gridset"); + describe('GridSet Processor with Real Data', () => { + const gridsetFile = path.join(examplesDir, 'example.gridset'); - it("should extract and translate texts from example.gridset", async () => { + it('should extract and translate texts from example.gridset', async () => { if (!fs.existsSync(gridsetFile)) { - console.log("Skipping GridSet test - example.gridset not found"); + console.log('Skipping GridSet test - example.gridset not found'); return; } @@ -241,32 +229,28 @@ describe("ProcessTexts with Real-World Data", () => { const fileBuffer = fs.readFileSync(gridsetFile); const originalTexts = await processor.extractTexts(fileBuffer); expect(originalTexts.length).toBeGreaterThan(0); - console.log("GridSet original texts:", originalTexts.slice(0, 5)); + console.log('GridSet original texts:', originalTexts.slice(0, 5)); // Create translations based on Grid3 format expectations const translations = new Map(); originalTexts.forEach((text) => { - if (text && typeof text === "string") { + if (text && typeof text === 'string') { // Common AAC words that might be in a gridset - if (text.toLowerCase().includes("i")) { - translations.set(text, text.replace(/\bi\b/gi, "yo")); + if (text.toLowerCase().includes('i')) { + translations.set(text, text.replace(/\bi\b/gi, 'yo')); } - if (text.toLowerCase().includes("want")) { - translations.set(text, text.replace(/want/gi, "quiero")); + if (text.toLowerCase().includes('want')) { + translations.set(text, text.replace(/want/gi, 'quiero')); } - if (text.toLowerCase().includes("more")) { - translations.set(text, text.replace(/more/gi, "más")); + if (text.toLowerCase().includes('more')) { + translations.set(text, text.replace(/more/gi, 'más')); } } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, "translated_example.gridset"); - const result = await processor.processTexts( - fileBuffer, - translations, - outputPath, - ); + const outputPath = path.join(tempDir, 'translated_example.gridset'); + const result = await processor.processTexts(fileBuffer, translations, outputPath); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); @@ -279,13 +263,13 @@ describe("ProcessTexts with Real-World Data", () => { }); }); - describe("Snap Processor with Real Data", () => { - const spbFile = path.join(examplesDir, "example.spb"); - const spsFile = path.join(examplesDir, "example.sps"); + describe('Snap Processor with Real Data', () => { + const spbFile = path.join(examplesDir, 'example.spb'); + const spsFile = path.join(examplesDir, 'example.sps'); - it("should extract and translate texts from example.spb", async () => { + it('should extract and translate texts from example.spb', async () => { if (!fs.existsSync(spbFile)) { - console.log("Skipping SPB test - example.spb not found"); + console.log('Skipping SPB test - example.spb not found'); return; } @@ -294,37 +278,33 @@ describe("ProcessTexts with Real-World Data", () => { // Extract texts from real Snap database const originalTexts = await processor.extractTexts(spbFile); expect(originalTexts.length).toBeGreaterThan(0); - console.log("Snap SPB original texts:", originalTexts.slice(0, 5)); + console.log('Snap SPB original texts:', originalTexts.slice(0, 5)); // Create translations for common AAC vocabulary const translations = new Map(); originalTexts.forEach((text) => { - if (text && typeof text === "string") { - if (text.toLowerCase().includes("hello")) { - translations.set(text, text.replace(/hello/gi, "hola")); + if (text && typeof text === 'string') { + if (text.toLowerCase().includes('hello')) { + translations.set(text, text.replace(/hello/gi, 'hola')); } - if (text.toLowerCase().includes("thank")) { - translations.set(text, text.replace(/thank/gi, "gracias")); + if (text.toLowerCase().includes('thank')) { + translations.set(text, text.replace(/thank/gi, 'gracias')); } } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, "translated_example.spb"); - const result = await processor.processTexts( - spbFile, - translations, - outputPath, - ); + const outputPath = path.join(tempDir, 'translated_example.spb'); + const result = await processor.processTexts(spbFile, translations, outputPath); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); } }); - it("should handle SPS files", async () => { + it('should handle SPS files', async () => { if (!fs.existsSync(spsFile)) { - console.log("Skipping SPS test - example.sps not found"); + console.log('Skipping SPS test - example.sps not found'); return; } @@ -333,21 +313,21 @@ describe("ProcessTexts with Real-World Data", () => { expect(texts.length).toBeGreaterThan(0); // Test basic translation functionality - const translations = new Map([["home", "casa"]]); - const outputPath = path.join(tempDir, "translated_example.sps"); + const translations = new Map([['home', 'casa']]); + const outputPath = path.join(tempDir, 'translated_example.sps'); await expect( - processor.processTexts(spsFile, translations, outputPath), + processor.processTexts(spsFile, translations, outputPath) ).resolves.toBeInstanceOf(Uint8Array); }); }); - describe("TouchChat Processor with Real Data", () => { - const ceFile = path.join(examplesDir, "example.ce"); + describe('TouchChat Processor with Real Data', () => { + const ceFile = path.join(examplesDir, 'example.ce'); - it("should extract and translate texts from example.ce", async () => { + it('should extract and translate texts from example.ce', async () => { if (!fs.existsSync(ceFile)) { - console.log("Skipping TouchChat test - example.ce not found"); + console.log('Skipping TouchChat test - example.ce not found'); return; } @@ -356,28 +336,24 @@ describe("ProcessTexts with Real-World Data", () => { // Extract texts from real TouchChat file const originalTexts = await processor.extractTexts(ceFile); expect(originalTexts.length).toBeGreaterThan(0); - console.log("TouchChat original texts:", originalTexts.slice(0, 5)); + console.log('TouchChat original texts:', originalTexts.slice(0, 5)); // Create translations for TouchChat vocabulary const translations = new Map(); originalTexts.forEach((text) => { - if (text && typeof text === "string") { - if (text.toLowerCase().includes("hello")) { - translations.set(text, text.replace(/hello/gi, "hola")); + if (text && typeof text === 'string') { + if (text.toLowerCase().includes('hello')) { + translations.set(text, text.replace(/hello/gi, 'hola')); } - if (text.toLowerCase().includes("goodbye")) { - translations.set(text, text.replace(/goodbye/gi, "adiós")); + if (text.toLowerCase().includes('goodbye')) { + translations.set(text, text.replace(/goodbye/gi, 'adiós')); } } }); if (translations.size > 0) { - const outputPath = path.join(tempDir, "translated_example.ce"); - const result = await processor.processTexts( - ceFile, - translations, - outputPath, - ); + const outputPath = path.join(tempDir, 'translated_example.ce'); + const result = await processor.processTexts(ceFile, translations, outputPath); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); diff --git a/test/processTexts.test.ts b/test/processTexts.test.ts index f4176d7..dd5afad 100644 --- a/test/processTexts.test.ts +++ b/test/processTexts.test.ts @@ -1,17 +1,17 @@ // Tests for processTexts functionality across all processors -import fs from "fs"; -import path from "path"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { OpmlProcessor } from "../src/processors/opmlProcessor"; -import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; -import { ObfProcessor } from "../src/processors/obfProcessor"; +import fs from 'fs'; +import path from 'path'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; +import { ObfProcessor } from '../src/processors/obfProcessor'; // import { GridsetProcessor } from '../src/processors/gridsetProcessor'; // import { SnapProcessor } from '../src/processors/snapProcessor'; // import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; -describe("ProcessTexts functionality", () => { - const tempDir = path.join(__dirname, "temp"); +describe('ProcessTexts functionality', () => { + const tempDir = path.join(__dirname, 'temp'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -25,8 +25,8 @@ describe("ProcessTexts functionality", () => { } }); - describe("DotProcessor processTexts", () => { - it("should apply translations to dot file content", async () => { + describe('DotProcessor processTexts', () => { + it('should apply translations to dot file content', async () => { const processor = new DotProcessor(); const dotContent = ` digraph G { @@ -37,27 +37,27 @@ describe("ProcessTexts functionality", () => { `; const translations = new Map([ - ["Hello", "Hola"], - ["World", "Mundo"], - ["Go", "Ir"], + ['Hello', 'Hola'], + ['World', 'Mundo'], + ['Go', 'Ir'], ]); - const outputPath = path.join(tempDir, "translated.dot"); + const outputPath = path.join(tempDir, 'translated.dot'); const result = await processor.processTexts( Buffer.from(dotContent), translations, - outputPath, + outputPath ); - const translatedContent = Buffer.from(result).toString("utf8"); + const translatedContent = Buffer.from(result).toString('utf8'); expect(translatedContent).toContain('label="Hola"'); expect(translatedContent).toContain('label="Mundo"'); expect(translatedContent).toContain('label="Ir"'); }); }); - describe("OpmlProcessor processTexts", () => { - it("should apply translations to OPML text attributes", async () => { + describe('OpmlProcessor processTexts', () => { + it('should apply translations to OPML text attributes', async () => { const processor = new OpmlProcessor(); const opmlContent = ` @@ -71,26 +71,26 @@ describe("ProcessTexts functionality", () => { `; const translations = new Map([ - ["Home", "Casa"], - ["Food", "Comida"], - ["Drinks", "Bebidas"], + ['Home', 'Casa'], + ['Food', 'Comida'], + ['Drinks', 'Bebidas'], ]); - const outputPath = path.join(tempDir, "translated.opml"); + const outputPath = path.join(tempDir, 'translated.opml'); const result = await processor.processTexts( Buffer.from(opmlContent), translations, - outputPath, + outputPath ); - const translatedContent = Buffer.from(result).toString("utf8"); + const translatedContent = Buffer.from(result).toString('utf8'); expect(translatedContent).toContain('text="Casa"'); expect(translatedContent).toContain('text="Comida"'); expect(translatedContent).toContain('text="Bebidas"'); }); }); - describe("Tree-based processors processTexts", () => { + describe('Tree-based processors processTexts', () => { let testTree: AACTree; beforeEach(async () => { @@ -98,24 +98,24 @@ describe("ProcessTexts functionality", () => { testTree = new AACTree(); const page1 = new AACPage({ - id: "page1", - name: "Main Page", + id: 'page1', + name: 'Main Page', buttons: [], }); const button1 = new AACButton({ - id: "btn1", - label: "Hello", - message: "Hello World", - type: "SPEAK", + id: 'btn1', + label: 'Hello', + message: 'Hello World', + type: 'SPEAK', }); const button2 = new AACButton({ - id: "btn2", - label: "Go Home", - message: "Navigate to home", - type: "NAVIGATE", - targetPageId: "page2", + id: 'btn2', + label: 'Go Home', + message: 'Navigate to home', + type: 'NAVIGATE', + targetPageId: 'page2', }); page1.addButton(button1); @@ -123,35 +123,31 @@ describe("ProcessTexts functionality", () => { testTree.addPage(page1); const page2 = new AACPage({ - id: "page2", - name: "Home Page", + id: 'page2', + name: 'Home Page', buttons: [], }); testTree.addPage(page2); }); - it("should translate ApplePanels content", async () => { + it('should translate ApplePanels content', async () => { const processor = new ApplePanelsProcessor(); - const outputPath = path.join(tempDir, "test.applepanels.plist"); + const outputPath = path.join(tempDir, 'test.applepanels.plist'); // First save the test tree await processor.saveFromTree(testTree, outputPath); const translations = new Map([ - ["Main Page", "Página Principal"], - ["Hello", "Hola"], - ["Hello World", "Hola Mundo"], - ["Go Home", "Ir a Casa"], - ["Home Page", "Página de Inicio"], + ['Main Page', 'Página Principal'], + ['Hello', 'Hola'], + ['Hello World', 'Hola Mundo'], + ['Go Home', 'Ir a Casa'], + ['Home Page', 'Página de Inicio'], ]); - const translatedPath = path.join(tempDir, "translated.applepanels.plist"); - const result = await processor.processTexts( - outputPath, - translations, - translatedPath, - ); + const translatedPath = path.join(tempDir, 'translated.applepanels.plist'); + const result = await processor.processTexts(outputPath, translations, translatedPath); expect(result).toBeInstanceOf(Uint8Array); expect(fs.existsSync(translatedPath)).toBe(true); @@ -162,61 +158,54 @@ describe("ProcessTexts functionality", () => { expect(pages.length).toBeGreaterThan(0); // Find the main page (might have different ID after round-trip) - const mainPage = pages.find((p) => p.name === "Página Principal"); + const mainPage = pages.find((p) => p.name === 'Página Principal'); expect(mainPage).toBeDefined(); if (!mainPage) { return; } - expect(mainPage.name).toBe("Página Principal"); + expect(mainPage.name).toBe('Página Principal'); // Find the hello button by label - const helloButton = mainPage.buttons.find((b) => b.label === "Hola"); + const helloButton = mainPage.buttons.find((b) => b.label === 'Hola'); expect(helloButton).toBeDefined(); if (!helloButton) { return; } - expect(helloButton.label).toBe("Hola"); - expect(helloButton.message).toBe("Hola Mundo"); + expect(helloButton.label).toBe('Hola'); + expect(helloButton.message).toBe('Hola Mundo'); }); - it("should translate OBF content", async () => { + it('should translate OBF content', async () => { const processor = new ObfProcessor(); - const outputPath = path.join(tempDir, "test.obf"); + const outputPath = path.join(tempDir, 'test.obf'); // First save the test tree await processor.saveFromTree(testTree, outputPath); const translations = new Map([ - ["Main Page", "Página Principal"], - ["Hello", "Hola"], - ["Hello World", "Hola Mundo"], + ['Main Page', 'Página Principal'], + ['Hello', 'Hola'], + ['Hello World', 'Hola Mundo'], ]); - const translatedPath = path.join(tempDir, "translated.obf"); - const result = await processor.processTexts( - outputPath, - translations, - translatedPath, - ); + const translatedPath = path.join(tempDir, 'translated.obf'); + const result = await processor.processTexts(outputPath, translations, translatedPath); expect(result).toBeInstanceOf(Uint8Array); expect(fs.existsSync(translatedPath)).toBe(true); }); - it("should handle empty translations gracefully", async () => { + it('should handle empty translations gracefully', async () => { const processor = new ApplePanelsProcessor(); - const outputPath = path.join(tempDir, "test_empty.applepanels.plist"); + const outputPath = path.join(tempDir, 'test_empty.applepanels.plist'); await processor.saveFromTree(testTree, outputPath); const emptyTranslations = new Map(); - const translatedPath = path.join( - tempDir, - "empty_translated.applepanels.plist", - ); + const translatedPath = path.join(tempDir, 'empty_translated.applepanels.plist'); await expect( - processor.processTexts(outputPath, emptyTranslations, translatedPath), + processor.processTexts(outputPath, emptyTranslations, translatedPath) ).resolves.not.toThrow(); expect(fs.existsSync(translatedPath)).toBe(true); diff --git a/test/processors/excelProcessor.test.ts b/test/processors/excelProcessor.test.ts index 8f585e3..6894a96 100644 --- a/test/processors/excelProcessor.test.ts +++ b/test/processors/excelProcessor.test.ts @@ -1,21 +1,21 @@ -import fs from "fs"; -import path from "path"; -import { ExcelProcessor } from "../../src/index"; -import { AACTree, AACPage, AACButton } from "../../src/index"; -import { AACSemanticIntent } from "../../src/index"; +import fs from 'fs'; +import path from 'path'; +import { ExcelProcessor } from '../../src/index'; +import { AACTree, AACPage, AACButton } from '../../src/index'; +import { AACSemanticIntent } from '../../src/index'; -describe("ExcelProcessor", () => { +describe('ExcelProcessor', () => { let processor: ExcelProcessor; let tempDir: string; let warnSpy: jest.SpyInstance; beforeAll(async () => { - warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); }); beforeEach(async () => { processor = new ExcelProcessor(); - tempDir = fs.mkdtempSync(path.join(__dirname, "temp-excel-")); + tempDir = fs.mkdtempSync(path.join(__dirname, 'temp-excel-')); }); afterEach(async () => { @@ -29,56 +29,56 @@ describe("ExcelProcessor", () => { warnSpy.mockRestore(); }); - describe("Basic Functionality", () => { - it("should create an instance", async () => { + describe('Basic Functionality', () => { + it('should create an instance', async () => { expect(processor).toBeInstanceOf(ExcelProcessor); }); - it("should handle empty tree", async () => { + it('should handle empty tree', async () => { const tree = new AACTree(); - const outputPath = path.join(tempDir, "empty.xlsx"); + const outputPath = path.join(tempDir, 'empty.xlsx'); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); }); - it("should extract texts from non-existent file", async () => { - const texts = await processor.extractTexts("non-existent.xlsx"); + it('should extract texts from non-existent file', async () => { + const texts = await processor.extractTexts('non-existent.xlsx'); expect(texts).toEqual([]); }); - it("should return empty tree for loadIntoTree", async () => { - const tree = await processor.loadIntoTree("any-file.xlsx"); + it('should return empty tree for loadIntoTree', async () => { + const tree = await processor.loadIntoTree('any-file.xlsx'); expect(tree).toBeInstanceOf(AACTree); expect(Object.keys(tree.pages)).toHaveLength(0); }); }); - describe("Tree to Excel Conversion", () => { - it("should convert simple AAC tree to Excel", async () => { + describe('Tree to Excel Conversion', () => { + it('should convert simple AAC tree to Excel', async () => { const tree = new AACTree(); // Create a simple page with buttons const page = new AACPage({ - id: "home", - name: "Home Page", + id: 'home', + name: 'Home Page', buttons: [ new AACButton({ - id: "btn1", - label: "Hello", - message: "Hello there!", + id: 'btn1', + label: 'Hello', + message: 'Hello there!', }), new AACButton({ - id: "btn2", - label: "Goodbye", - message: "See you later!", + id: 'btn2', + label: 'Goodbye', + message: 'See you later!', }), ], }); tree.addPage(page); - const outputPath = path.join(tempDir, "simple.xlsx"); + const outputPath = path.join(tempDir, 'simple.xlsx'); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); @@ -87,58 +87,58 @@ describe("ExcelProcessor", () => { // In a real test, we'd need to wait for the async operation }); - it("should handle buttons with styling", async () => { + it('should handle buttons with styling', async () => { const tree = new AACTree(); const styledButton = new AACButton({ - id: "styled", - label: "Styled Button", - message: "I have style!", + id: 'styled', + label: 'Styled Button', + message: 'I have style!', style: { - backgroundColor: "#FF0000", - fontColor: "#FFFFFF", + backgroundColor: '#FF0000', + fontColor: '#FFFFFF', fontSize: 16, - fontWeight: "bold", + fontWeight: 'bold', }, }); const page = new AACPage({ - id: "styled-page", - name: "Styled Page", + id: 'styled-page', + name: 'Styled Page', buttons: [styledButton], }); tree.addPage(page); - const outputPath = path.join(tempDir, "styled.xlsx"); + const outputPath = path.join(tempDir, 'styled.xlsx'); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); }); - it("should handle navigation buttons", async () => { + it('should handle navigation buttons', async () => { const tree = new AACTree(); // Create home page const homePage = new AACPage({ - id: "home", - name: "Home", + id: 'home', + name: 'Home', buttons: [], }); // Create food page with navigation back to home const foodPage = new AACPage({ - id: "food", - name: "Food", + id: 'food', + name: 'Food', buttons: [ new AACButton({ - id: "nav-home", - label: "Home", - message: "", + id: 'nav-home', + label: 'Home', + message: '', semanticAction: { intent: AACSemanticIntent.NAVIGATE_TO, parameters: {}, }, - targetPageId: "home", + targetPageId: 'home', }), ], }); @@ -146,49 +146,49 @@ describe("ExcelProcessor", () => { // Add navigation button from home to food homePage.addButton( new AACButton({ - id: "nav-food", - label: "Food", - message: "", + id: 'nav-food', + label: 'Food', + message: '', semanticAction: { intent: AACSemanticIntent.NAVIGATE_TO, parameters: {}, }, - targetPageId: "food", - }), + targetPageId: 'food', + }) ); tree.addPage(homePage); tree.addPage(foodPage); - tree.rootId = "home"; + tree.rootId = 'home'; - const outputPath = path.join(tempDir, "navigation.xlsx"); + const outputPath = path.join(tempDir, 'navigation.xlsx'); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); }); - it("should handle grid layout", async () => { + it('should handle grid layout', async () => { const tree = new AACTree(); // Create buttons for grid const btn1 = new AACButton({ - id: "1", - label: "Button 1", - message: "One", + id: '1', + label: 'Button 1', + message: 'One', }); const btn2 = new AACButton({ - id: "2", - label: "Button 2", - message: "Two", + id: '2', + label: 'Button 2', + message: 'Two', }); const btn3 = new AACButton({ - id: "3", - label: "Button 3", - message: "Three", + id: '3', + label: 'Button 3', + message: 'Three', }); const btn4 = new AACButton({ - id: "4", - label: "Button 4", - message: "Four", + id: '4', + label: 'Button 4', + message: 'Four', }); // Create 2x2 grid @@ -198,52 +198,50 @@ describe("ExcelProcessor", () => { ]; const page = new AACPage({ - id: "grid-page", - name: "Grid Layout", + id: 'grid-page', + name: 'Grid Layout', grid: grid, buttons: [btn1, btn2, btn3, btn4], }); tree.addPage(page); - const outputPath = path.join(tempDir, "grid.xlsx"); + const outputPath = path.join(tempDir, 'grid.xlsx'); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); }); }); - describe("Utility Methods", () => { - it("should sanitize worksheet names", async () => { + describe('Utility Methods', () => { + it('should sanitize worksheet names', async () => { // Access private method through any cast for testing const sanitize = (processor as any).sanitizeWorksheetName; - expect(sanitize("Normal Name")).toBe("Normal Name"); - expect(sanitize("Name/With\\Invalid:Chars")).toBe( - "Name_With_Invalid_Chars", + expect(sanitize('Normal Name')).toBe('Normal Name'); + expect(sanitize('Name/With\\Invalid:Chars')).toBe('Name_With_Invalid_Chars'); + expect(sanitize('')).toBe('Sheet1'); + expect(sanitize('Very Long Name That Exceeds Thirty One Characters')).toBe( + 'Very Long Name That Exceeds Thi' ); - expect(sanitize("")).toBe("Sheet1"); - expect( - sanitize("Very Long Name That Exceeds Thirty One Characters"), - ).toBe("Very Long Name That Exceeds Thi"); }); - it("should convert colors to ARGB", async () => { + it('should convert colors to ARGB', async () => { const convert = (processor as any).convertColorToArgb; - expect(convert("#FF0000")).toBe("FFFF0000"); - expect(convert("rgb(255, 0, 0)")).toBe("FFFF0000"); - expect(convert("rgba(255, 0, 0, 0.5)")).toBe("80FF0000"); - expect(convert("")).toBe("FFFFFFFF"); - expect(convert("invalid")).toBe("FFFFFFFF"); + expect(convert('#FF0000')).toBe('FFFF0000'); + expect(convert('rgb(255, 0, 0)')).toBe('FFFF0000'); + expect(convert('rgba(255, 0, 0, 0.5)')).toBe('80FF0000'); + expect(convert('')).toBe('FFFFFFFF'); + expect(convert('invalid')).toBe('FFFFFFFF'); }); }); - describe("Error Handling", () => { - it("should handle processTexts gracefully", async () => { - const translations = new Map([["Hello", "Hola"]]); + describe('Error Handling', () => { + it('should handle processTexts gracefully', async () => { + const translations = new Map([['Hello', 'Hola']]); await expect( - processor.processTexts("test.xlsx", translations, "output.xlsx"), + processor.processTexts('test.xlsx', translations, 'output.xlsx') ).resolves.not.toThrow(); }); }); diff --git a/test/processors/gridset/symbols.test.ts b/test/processors/gridset/symbols.test.ts index f5ecc5a..a9f388f 100644 --- a/test/processors/gridset/symbols.test.ts +++ b/test/processors/gridset/symbols.test.ts @@ -9,67 +9,60 @@ import { isSymbolReference, parseSymbolReference, symbolReferenceToFilename, -} from "../../../src/processors/gridset/symbols"; +} from '../../../src/processors/gridset/symbols'; -describe("gridset symbols utilities", () => { - it("parses and formats symbol references", () => { - const parsed = parseSymbolReference("[Widgit]/food/apple.png"); +describe('gridset symbols utilities', () => { + it('parses and formats symbol references', () => { + const parsed = parseSymbolReference('[Widgit]/food/apple.png'); expect(parsed.isValid).toBe(true); - expect(parsed.library).toBe("widgit"); - expect(parsed.path).toBe("/food/apple.png"); + expect(parsed.library).toBe('widgit'); + expect(parsed.path).toBe('/food/apple.png'); - expect(isSymbolReference("[widgit]/food/apple.png")).toBe(true); - expect(isSymbolReference("plain-text")).toBe(false); + expect(isSymbolReference('[widgit]/food/apple.png')).toBe(true); + expect(isSymbolReference('plain-text')).toBe(false); - const created = createSymbolReference("Widgit", "/food/apple.png"); - expect(created).toBe("[widgit]/food/apple.png"); + const created = createSymbolReference('Widgit', '/food/apple.png'); + expect(created).toBe('[widgit]/food/apple.png'); - expect(getSymbolLibraryName(created)).toBe("widgit"); - expect(getSymbolPath(created)).toBe("/food/apple.png"); + expect(getSymbolLibraryName(created)).toBe('widgit'); + expect(getSymbolPath(created)).toBe('/food/apple.png'); }); - it("detects known libraries and display names", () => { - expect(isKnownSymbolLibrary("[grid3x]")).toBe(true); - expect(isKnownSymbolLibrary("unknownlib")).toBe(false); + it('detects known libraries and display names', () => { + expect(isKnownSymbolLibrary('[grid3x]')).toBe(true); + expect(isKnownSymbolLibrary('unknownlib')).toBe(false); - expect(getSymbolLibraryDisplayName("widgit")).toBe("Widgit Symbols"); - expect(getSymbolLibraryDisplayName("custom")).toBe("Custom"); + expect(getSymbolLibraryDisplayName('widgit')).toBe('Widgit Symbols'); + expect(getSymbolLibraryDisplayName('custom')).toBe('Custom'); }); - it("extracts and analyzes symbol usage from a tree", () => { + it('extracts and analyzes symbol usage from a tree', () => { const tree = { pages: { one: { buttons: [ - { image: "[widgit]/food/apple.png" }, - { symbolLibrary: "tawasl", symbolPath: "/animals/cat.png" }, + { image: '[widgit]/food/apple.png' }, + { symbolLibrary: 'tawasl', symbolPath: '/animals/cat.png' }, ], }, two: { - buttons: [{ image: "[widgit]/food/apple.png" }], + buttons: [{ image: '[widgit]/food/apple.png' }], }, }, }; const refs = extractSymbolReferences(tree); - expect(refs).toEqual([ - "[tawasl]/animals/cat.png", - "[widgit]/food/apple.png", - ]); + expect(refs).toEqual(['[tawasl]/animals/cat.png', '[widgit]/food/apple.png']); const usage = analyzeSymbolUsage(tree); expect(usage.totalSymbols).toBe(2); expect(usage.byLibrary.widgit).toBe(1); expect(usage.byLibrary.tawasl).toBe(1); - expect(usage.librariesUsed).toEqual(["tawasl", "widgit"]); + expect(usage.librariesUsed).toEqual(['tawasl', 'widgit']); }); - it("creates embedded filenames for symbol references", () => { - expect(symbolReferenceToFilename("[widgit]/food/apple.png", 2, 3)).toBe( - "2-3-0-text-0.png", - ); - expect(symbolReferenceToFilename("[widgit]/food/apple", 1, 1)).toBe( - "1-1-0-text-0.png", - ); + it('creates embedded filenames for symbol references', () => { + expect(symbolReferenceToFilename('[widgit]/food/apple.png', 2, 3)).toBe('2-3-0-text-0.png'); + expect(symbolReferenceToFilename('[widgit]/food/apple', 1, 1)).toBe('1-1-0-text-0.png'); }); }); diff --git a/test/propertyBased.test.ts b/test/propertyBased.test.ts index e03dd21..fcf5fdd 100644 --- a/test/propertyBased.test.ts +++ b/test/propertyBased.test.ts @@ -1,15 +1,15 @@ // Property-based testing using fast-check -import fc from "fast-check"; -import fs from "fs"; -import path from "path"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { OpmlProcessor } from "../src/processors/opmlProcessor"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; - -describe("Property-Based Testing", () => { - const tempDir = path.join(__dirname, "temp_property"); +import fc from 'fast-check'; +import fs from 'fs'; +import path from 'path'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; + +describe('Property-Based Testing', () => { + const tempDir = path.join(__dirname, 'temp_property'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -28,10 +28,10 @@ describe("Property-Based Testing", () => { const validLabelGenerator = fc .string({ minLength: 1, maxLength: 100 }) .filter((s) => s.trim().length > 0) - .map((s) => s.trim() || "DefaultLabel"); + .map((s) => s.trim() || 'DefaultLabel'); const validMessageGenerator = fc.string({ maxLength: 500 }); - const buttonTypeGenerator = fc.constantFrom("SPEAK", "NAVIGATE"); + const buttonTypeGenerator = fc.constantFrom('SPEAK', 'NAVIGATE'); const aacButtonGenerator = fc .record({ @@ -88,7 +88,7 @@ describe("Property-Based Testing", () => { if (allPageIds.length > 1) { Object.values(tree.pages).forEach((page) => { page.buttons.forEach((button) => { - if (button.type === "NAVIGATE") { + if (button.type === 'NAVIGATE') { const randomIndex = Math.floor(Math.random() * allPageIds.length); button.targetPageId = allPageIds[randomIndex]; } @@ -99,18 +99,15 @@ describe("Property-Based Testing", () => { return tree; }); - describe("Round-Trip Property Tests", () => { - it("DOT processor should preserve tree structure through round-trip", async () => { + describe('Round-Trip Property Tests', () => { + it('DOT processor should preserve tree structure through round-trip', async () => { await fc.assert( fc.asyncProperty(aacTreeGenerator, async (originalTree) => { const processor = new DotProcessor(); try { // Save tree to DOT format - const outputPath = path.join( - tempDir, - `roundtrip_${Date.now()}_${Math.random()}.dot`, - ); + const outputPath = path.join(tempDir, `roundtrip_${Date.now()}_${Math.random()}.dot`); await processor.saveFromTree(originalTree, outputPath); // Load it back @@ -137,26 +134,22 @@ describe("Property-Based Testing", () => { // At least some page names should be preserved const commonNames = originalPageNames.filter((name) => reloadedPageNames.some( - (reloadedName) => - reloadedName.includes(name) || name.includes(reloadedName), - ), + (reloadedName) => reloadedName.includes(name) || name.includes(reloadedName) + ) ); return commonNames.length > 0; } catch (error) { // If the test fails due to invalid data, that's acceptable - console.log( - "Round-trip test failed (acceptable for some data):", - error, - ); + console.log('Round-trip test failed (acceptable for some data):', error); return true; } }), - { numRuns: 20 }, + { numRuns: 20 } ); }); - it("OPML processor should preserve hierarchical structure", async () => { + it('OPML processor should preserve hierarchical structure', async () => { await fc.assert( fc.asyncProperty(aacTreeGenerator, async (originalTree) => { const processor = new OpmlProcessor(); @@ -164,7 +157,7 @@ describe("Property-Based Testing", () => { try { const outputPath = path.join( tempDir, - `opml_roundtrip_${Date.now()}_${Math.random()}.opml`, + `opml_roundtrip_${Date.now()}_${Math.random()}.opml` ); await processor.saveFromTree(originalTree, outputPath); @@ -179,27 +172,23 @@ describe("Property-Based Testing", () => { return reloadedPageCount > 0; } catch (error) { - console.log("OPML round-trip test failed (acceptable):", error); + console.log('OPML round-trip test failed (acceptable):', error); return true; } }), - { numRuns: 15 }, + { numRuns: 15 } ); }); - it("OBF processor should preserve button structure", async () => { + it('OBF processor should preserve button structure', async () => { await fc.assert( fc.asyncProperty(aacTreeGenerator, async (originalTree) => { const processor = new ObfProcessor(); try { // Skip trees with invalid button configurations - const hasInvalidButtons = Object.values(originalTree.pages).some( - (page) => - page.buttons.some( - (button) => - button.type === "NAVIGATE" && !button.targetPageId, - ), + const hasInvalidButtons = Object.values(originalTree.pages).some((page) => + page.buttons.some((button) => button.type === 'NAVIGATE' && !button.targetPageId) ); if (hasInvalidButtons) { @@ -208,7 +197,7 @@ describe("Property-Based Testing", () => { const outputPath = path.join( tempDir, - `obf_roundtrip_${Date.now()}_${Math.random()}.obz`, + `obf_roundtrip_${Date.now()}_${Math.random()}.obz` ); await processor.saveFromTree(originalTree, outputPath); @@ -218,31 +207,33 @@ describe("Property-Based Testing", () => { fs.unlinkSync(outputPath); // Should preserve button information - const originalButtonCount = Object.values( - originalTree.pages, - ).reduce((sum, page) => sum + page.buttons.length, 0); - const reloadedButtonCount = Object.values( - reloadedTree.pages, - ).reduce((sum, page) => sum + page.buttons.length, 0); + const originalButtonCount = Object.values(originalTree.pages).reduce( + (sum, page) => sum + page.buttons.length, + 0 + ); + const reloadedButtonCount = Object.values(reloadedTree.pages).reduce( + (sum, page) => sum + page.buttons.length, + 0 + ); // Should have some buttons if original had buttons return originalButtonCount === 0 || reloadedButtonCount > 0; } catch (error) { - console.log("OBF round-trip test failed (acceptable):", error); + console.log('OBF round-trip test failed (acceptable):', error); return true; } }), - { numRuns: 15 }, + { numRuns: 15 } ); }); }); - describe("Translation Invariant Tests", () => { + describe('Translation Invariant Tests', () => { const translationMapGenerator = fc .dictionary(validLabelGenerator, validLabelGenerator, { maxKeys: 10 }) .map((dict) => new Map(Object.entries(dict))); - it("Translation should preserve text count invariant", async () => { + it('Translation should preserve text count invariant', async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 10, maxLength: 1000 }), @@ -253,19 +244,19 @@ describe("Property-Based Testing", () => { try { // Create DOT-like content const dotContent = `digraph G {\n${content - .split(" ") + .split(' ') .slice(0, 5) .map((word, i) => ` node${i} [label="${word}"];`) - .join("\n")}\n}`; + .join('\n')}\n}`; const outputPath = path.join( tempDir, - `translation_test_${Date.now()}_${Math.random()}.dot`, + `translation_test_${Date.now()}_${Math.random()}.dot` ); const result = await processor.processTexts( Buffer.from(dotContent), translations, - outputPath, + outputPath ); // Clean up @@ -273,69 +264,63 @@ describe("Property-Based Testing", () => { fs.unlinkSync(outputPath); } - const translatedContent = Buffer.from(result).toString("utf8"); + const translatedContent = Buffer.from(result).toString('utf8'); // Should still be valid content expect(translatedContent.length).toBeGreaterThan(0); - expect(translatedContent).toContain("digraph"); + expect(translatedContent).toContain('digraph'); return true; } catch (error) { - console.log("Translation test failed (acceptable):", error); + console.log('Translation test failed (acceptable):', error); return true; } - }, + } ), - { numRuns: 20 }, + { numRuns: 20 } ); }); - it("Empty translation map should not change content", async () => { + it('Empty translation map should not change content', async () => { await fc.assert( - fc.asyncProperty( - fc.string({ minLength: 10, maxLength: 200 }), - async (content) => { - const processor = new DotProcessor(); - const emptyTranslations = new Map(); + fc.asyncProperty(fc.string({ minLength: 10, maxLength: 200 }), async (content) => { + const processor = new DotProcessor(); + const emptyTranslations = new Map(); - try { - const dotContent = `digraph G {\n test [label="${content.slice(0, 50)}"];\n}`; - const outputPath = path.join( - tempDir, - `empty_translation_${Date.now()}_${Math.random()}.dot`, - ); + try { + const dotContent = `digraph G {\n test [label="${content.slice(0, 50)}"];\n}`; + const outputPath = path.join( + tempDir, + `empty_translation_${Date.now()}_${Math.random()}.dot` + ); - const result = await processor.processTexts( - Buffer.from(dotContent), - emptyTranslations, - outputPath, - ); + const result = await processor.processTexts( + Buffer.from(dotContent), + emptyTranslations, + outputPath + ); - // Clean up - if (fs.existsSync(outputPath)) { - fs.unlinkSync(outputPath); - } + // Clean up + if (fs.existsSync(outputPath)) { + fs.unlinkSync(outputPath); + } - const translatedContent = Buffer.from(result).toString("utf8"); + const translatedContent = Buffer.from(result).toString('utf8'); - // Content should be essentially unchanged - return ( - translatedContent.includes(content.slice(0, 50)) || - translatedContent.length > 0 - ); - } catch (error) { - console.log("Empty translation test failed (acceptable):", error); - return true; - } - }, - ), - { numRuns: 15 }, + // Content should be essentially unchanged + return translatedContent.includes(content.slice(0, 50)) || translatedContent.length > 0; + } catch (error) { + console.log('Empty translation test failed (acceptable):', error); + return true; + } + }), + { numRuns: 15 } ); }); }); - describe("Data Structure Invariants", () => { - it("AACTree should maintain page uniqueness", async () => { + describe('Data Structure Invariants', () => { + it('AACTree should maintain page uniqueness', async () => { fc.assert( fc.property(aacTreeGenerator, (tree) => { const pageIds = Object.keys(tree.pages); @@ -344,11 +329,11 @@ describe("Property-Based Testing", () => { // All page IDs should be unique return pageIds.length === uniqueIds.size; }), - { numRuns: 50 }, + { numRuns: 50 } ); }); - it("AACPage should maintain button ID uniqueness within page", async () => { + it('AACPage should maintain button ID uniqueness within page', async () => { fc.assert( fc.property(aacPageGenerator, (page) => { const buttonIds = page.buttons.map((b) => b.id); @@ -357,18 +342,18 @@ describe("Property-Based Testing", () => { // All button IDs within a page should be unique return buttonIds.length === uniqueIds.size; }), - { numRuns: 50 }, + { numRuns: 50 } ); }); - it("Navigation buttons should have valid target page IDs", async () => { + it('Navigation buttons should have valid target page IDs', async () => { fc.assert( fc.property(aacTreeGenerator, (tree) => { const pageIds = new Set(Object.keys(tree.pages)); for (const page of Object.values(tree.pages)) { for (const button of page.buttons) { - if (button.type === "NAVIGATE" && button.targetPageId) { + if (button.type === 'NAVIGATE' && button.targetPageId) { // Navigation buttons should either have valid targets or be acceptable as invalid // (since we're testing with generated data, some invalid references are expected) if (!pageIds.has(button.targetPageId)) { @@ -381,13 +366,13 @@ describe("Property-Based Testing", () => { return true; // Always pass as we're testing the structure, not the validity }), - { numRuns: 30 }, + { numRuns: 30 } ); }); }); - describe("Text Extraction Properties", () => { - it("Extracted texts should be non-empty strings", async () => { + describe('Text Extraction Properties', () => { + it('Extracted texts should be non-empty strings', async () => { await fc.assert( fc.asyncProperty(aacTreeGenerator, async (tree) => { const processor = new DotProcessor(); @@ -398,10 +383,8 @@ describe("Property-Based Testing", () => { (page) => page.name.trim().length > 0 || page.buttons.some( - (button) => - button.label.trim().length > 0 || - button.message.trim().length > 0, - ), + (button) => button.label.trim().length > 0 || button.message.trim().length > 0 + ) ); if (!hasContent) { @@ -410,7 +393,7 @@ describe("Property-Based Testing", () => { const outputPath = path.join( tempDir, - `text_extraction_${Date.now()}_${Math.random()}.dot`, + `text_extraction_${Date.now()}_${Math.random()}.dot` ); await processor.saveFromTree(tree, outputPath); @@ -420,27 +403,23 @@ describe("Property-Based Testing", () => { fs.unlinkSync(outputPath); // All extracted texts should be strings - const allStrings = extractedTexts.every( - (text) => typeof text === "string", - ); + const allStrings = extractedTexts.every((text) => typeof text === 'string'); // If we have content, we should extract some non-empty texts - const nonEmptyTexts = extractedTexts.filter( - (text) => text.trim().length > 0, - ); + const nonEmptyTexts = extractedTexts.filter((text) => text.trim().length > 0); const hasNonEmptyTexts = nonEmptyTexts.length > 0; return allStrings && hasNonEmptyTexts; } catch (error) { - console.log("Text extraction test failed (acceptable):", error); + console.log('Text extraction test failed (acceptable):', error); return true; } }), - { numRuns: 20 }, + { numRuns: 20 } ); }); - it("Text extraction should be deterministic", async () => { + it('Text extraction should be deterministic', async () => { await fc.assert( fc.asyncProperty(aacTreeGenerator, async (tree) => { const processor = new DotProcessor(); @@ -448,7 +427,7 @@ describe("Property-Based Testing", () => { try { const outputPath = path.join( tempDir, - `deterministic_${Date.now()}_${Math.random()}.dot`, + `deterministic_${Date.now()}_${Math.random()}.dot` ); await processor.saveFromTree(tree, outputPath); @@ -462,84 +441,71 @@ describe("Property-Based Testing", () => { // Results should be identical return JSON.stringify(texts1) === JSON.stringify(texts2); } catch (error) { - console.log("Deterministic test failed (acceptable):", error); + console.log('Deterministic test failed (acceptable):', error); return true; } }), - { numRuns: 15 }, + { numRuns: 15 } ); }); }); - describe("Error Handling Properties", () => { - it("Invalid input should not crash processors", async () => { + describe('Error Handling Properties', () => { + it('Invalid input should not crash processors', async () => { await fc.assert( - fc.asyncProperty( - fc.uint8Array({ minLength: 0, maxLength: 1000 }), - async (randomBytes) => { - const processors = [ - new DotProcessor(), - new OpmlProcessor(), - new ObfProcessor(), - new ApplePanelsProcessor(), - ]; - - for (const processor of processors) { - try { - const result = await processor.loadIntoTree( - Buffer.from(randomBytes), - ); - // Should return a valid AACTree (might be empty) - expect(result).toBeInstanceOf(AACTree); - } catch (error) { - // Throwing an error is also acceptable - expect(error).toBeInstanceOf(Error); - } + fc.asyncProperty(fc.uint8Array({ minLength: 0, maxLength: 1000 }), async (randomBytes) => { + const processors = [ + new DotProcessor(), + new OpmlProcessor(), + new ObfProcessor(), + new ApplePanelsProcessor(), + ]; + + for (const processor of processors) { + try { + const result = await processor.loadIntoTree(Buffer.from(randomBytes)); + // Should return a valid AACTree (might be empty) + expect(result).toBeInstanceOf(AACTree); + } catch (error) { + // Throwing an error is also acceptable + expect(error).toBeInstanceOf(Error); } + } - return true; - }, - ), - { numRuns: 30 }, + return true; + }), + { numRuns: 30 } ); }); - it("Processors should handle extremely large valid inputs gracefully", async () => { + it('Processors should handle extremely large valid inputs gracefully', async () => { await fc.assert( - fc.asyncProperty( - fc.integer({ min: 100, max: 1000 }), - async (nodeCount) => { - const processor = new DotProcessor(); + fc.asyncProperty(fc.integer({ min: 100, max: 1000 }), async (nodeCount) => { + const processor = new DotProcessor(); - try { - // Generate large but valid DOT content - const lines = ["digraph G {"]; - for (let i = 0; i < nodeCount; i++) { - lines.push(` node${i} [label="Node ${i}"];`); - } - lines.push("}"); + try { + // Generate large but valid DOT content + const lines = ['digraph G {']; + for (let i = 0; i < nodeCount; i++) { + lines.push(` node${i} [label="Node ${i}"];`); + } + lines.push('}'); - const largeContent = lines.join("\n"); - const tree = await processor.loadIntoTree( - Buffer.from(largeContent), - ); + const largeContent = lines.join('\n'); + const tree = await processor.loadIntoTree(Buffer.from(largeContent)); - // Should handle large input without crashing - expect(tree).toBeInstanceOf(AACTree); - expect(Object.keys(tree.pages).length).toBeGreaterThan(0); + // Should handle large input without crashing + expect(tree).toBeInstanceOf(AACTree); + expect(Object.keys(tree.pages).length).toBeGreaterThan(0); - return true; - } catch (error) { - // If it fails due to memory/performance limits, that's acceptable - console.log( - `Large input test failed for ${nodeCount} nodes (acceptable):`, - error, - ); - return true; - } - }, - ), - { numRuns: 10 }, + return true; + } catch (error) { + // If it fails due to memory/performance limits, that's acceptable + console.log(`Large input test failed for ${nodeCount} nodes (acceptable):`, error); + return true; + } + }), + { numRuns: 10 } ); }); }); diff --git a/test/scanningMetrics.test.ts b/test/scanningMetrics.test.ts index 33ad55a..d826173 100644 --- a/test/scanningMetrics.test.ts +++ b/test/scanningMetrics.test.ts @@ -1,41 +1,36 @@ -import { describe, expect, it } from "@jest/globals"; -import { - AACTree, - AACPage, - AACButton, - AACScanType, -} from "../src/core/treeStructure"; -import { MetricsCalculator } from "../src/utilities/analytics/metrics/core"; - -describe("Scanning Metrics", () => { - it("calculates linear scanning effort correctly", async () => { +import { describe, expect, it } from '@jest/globals'; +import { AACTree, AACPage, AACButton, AACScanType } from '../src/core/treeStructure'; +import { MetricsCalculator } from '../src/utilities/analytics/metrics/core'; + +describe('Scanning Metrics', () => { + it('calculates linear scanning effort correctly', async () => { const tree = new AACTree(); const page = new AACPage({ - id: "root", - name: "Home", + id: 'root', + name: 'Home', grid: { columns: 2, rows: 2 }, scanType: AACScanType.LINEAR, }); // Target button is #3 (linear index 2) const btn1 = new AACButton({ - id: "btn1", - label: "1", - type: "SPEAK", + id: 'btn1', + label: '1', + type: 'SPEAK', x: 0, y: 0, }); const btn2 = new AACButton({ - id: "btn2", - label: "2", - type: "SPEAK", + id: 'btn2', + label: '2', + type: 'SPEAK', x: 1, y: 0, }); const btn3 = new AACButton({ - id: "btn3", - label: "3", - type: "SPEAK", + id: 'btn3', + label: '3', + type: 'SPEAK', x: 0, y: 1, }); // Target @@ -49,12 +44,12 @@ describe("Scanning Metrics", () => { page.addButton(btn3); tree.addPage(page); - tree.rootId = "root"; + tree.rootId = 'root'; const calculator = new MetricsCalculator(); const result = calculator.analyze(tree); - const btn3Metrics = result.buttons.find((b) => b.label === "3"); + const btn3Metrics = result.buttons.find((b) => b.label === '3'); // Steps = 3 (buttons 1, 2, 3), Selections = 1 // Scan Effort = 3 * 0.015 + 1 * 0.1 = 0.045 + 0.1 = 0.145 // baseBoardEffort(2, 2, 4): @@ -65,20 +60,20 @@ describe("Scanning Metrics", () => { expect(btn3Metrics?.effort).toBeCloseTo(0.345, 4); }); - it("calculates row-column scanning effort correctly", async () => { + it('calculates row-column scanning effort correctly', async () => { const tree = new AACTree(); const page = new AACPage({ - id: "root", - name: "Home", + id: 'root', + name: 'Home', grid: { columns: 5, rows: 5 }, scanType: AACScanType.ROW_COLUMN, }); // Target button at row 3 (index 2), col 4 (index 3) const btn = new AACButton({ - id: "target", - label: "Target", - type: "SPEAK", + id: 'target', + label: 'Target', + type: 'SPEAK', x: 3, y: 2, }); @@ -90,7 +85,7 @@ describe("Scanning Metrics", () => { const calculator = new MetricsCalculator(); const result = calculator.analyze(tree); - const metrics = result.buttons.find((b) => b.label === "Target"); + const metrics = result.buttons.find((b) => b.label === 'Target'); // Steps = (rowIndex + 1) + (colIndex + 1) = (2 + 1) + (3 + 1) = 7 steps // Selections = 2 // Scan Effort = 7 * 0.015 + 2 * 0.1 = 0.105 + 0.2 = 0.305 @@ -102,37 +97,37 @@ describe("Scanning Metrics", () => { expect(metrics?.effort).toBeCloseTo(0.88, 4); }); - it("calculates block scanning effort correctly", async () => { + it('calculates block scanning effort correctly', async () => { const tree = new AACTree(); const page = new AACPage({ - id: "root", - name: "Home", + id: 'root', + name: 'Home', grid: { columns: 4, rows: 4 }, scanType: AACScanType.BLOCK_ROW_COLUMN, scanBlocksConfig: [ - { id: 1, name: "Block A", order: 1 }, - { id: 2, name: "Block B", order: 2 }, + { id: 1, name: 'Block A', order: 1 }, + { id: 2, name: 'Block B', order: 2 }, ], }); // Target button in Block B, at some position const btnA = new AACButton({ - id: "a", - label: "A", + id: 'a', + label: 'A', scanBlocks: [1], - type: "SPEAK", + type: 'SPEAK', }); const btnB1 = new AACButton({ - id: "b1", - label: "B1", + id: 'b1', + label: 'B1', scanBlocks: [2], - type: "SPEAK", + type: 'SPEAK', }); const btnB2 = new AACButton({ - id: "b2", - label: "B2", + id: 'b2', + label: 'B2', scanBlocks: [2], - type: "SPEAK", + type: 'SPEAK', }); // Target page.grid[0][0] = btnA; @@ -148,7 +143,7 @@ describe("Scanning Metrics", () => { const calculator = new MetricsCalculator(); const result = calculator.analyze(tree); - const metrics = result.buttons.find((b) => b.label === "B2"); + const metrics = result.buttons.find((b) => b.label === 'B2'); // Steps = blockOrder (2) + btnInBlockIndex (1) + 1 = 4 steps // Selections = 2 // Scan Effort = 4 * 0.015 + 2 * 0.1 = 0.06 + 0.2 = 0.26 @@ -159,11 +154,11 @@ describe("Scanning Metrics", () => { expect(metrics?.effort).toBeCloseTo(0.7, 4); }); - it("calculates error correction effort correctly", async () => { + it('calculates error correction effort correctly', async () => { const tree = new AACTree(); const page = new AACPage({ - id: "root", - name: "Home", + id: 'root', + name: 'Home', grid: { columns: 10, rows: 1 }, scanningConfig: { errorCorrectionEnabled: true, @@ -171,7 +166,7 @@ describe("Scanning Metrics", () => { }, }); - const btn1 = new AACButton({ id: "btn1", label: "B1", type: "SPEAK" }); + const btn1 = new AACButton({ id: 'btn1', label: 'B1', type: 'SPEAK' }); page.grid[0][0] = btn1; page.addButton(btn1); tree.addPage(page); @@ -179,7 +174,7 @@ describe("Scanning Metrics", () => { const calculator = new MetricsCalculator(); const result = calculator.analyze(tree); - const metrics = result.buttons.find((b) => b.label === "B1"); + const metrics = result.buttons.find((b) => b.label === 'B1'); // B1 is at root, index 0. // Steps = 1, Selections = 1 // Ideal Scan Effort = 1 * 0.015 + 1 * 0.1 = 0.115 diff --git a/test/snapProcessor.audio.comprehensive.test.ts b/test/snapProcessor.audio.comprehensive.test.ts index 5b4ae87..ff16661 100644 --- a/test/snapProcessor.audio.comprehensive.test.ts +++ b/test/snapProcessor.audio.comprehensive.test.ts @@ -1,18 +1,18 @@ // Comprehensive tests for SnapProcessor to improve coverage from 67.11% to 85%+ -import fs from "fs"; -import path from "path"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { AACTree } from "../src/core/treeStructure"; -import { PageFactory, ButtonFactory } from "./utils/testFactories"; +import fs from 'fs'; +import path from 'path'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { AACTree } from '../src/core/treeStructure'; +import { PageFactory, ButtonFactory } from './utils/testFactories'; -describe("SnapProcessor - Comprehensive Coverage Tests", () => { +describe('SnapProcessor - Comprehensive Coverage Tests', () => { let processor: SnapProcessor; - const tempDir = path.join(__dirname, "temp_snap"); - const _exampleFile = path.join(__dirname, "assets/snap/example.sps"); + const tempDir = path.join(__dirname, 'temp_snap'); + const _exampleFile = path.join(__dirname, 'assets/snap/example.sps'); let warnSpy: jest.SpyInstance; beforeAll(async () => { - warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } @@ -29,88 +29,86 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { warnSpy.mockRestore(); }); - describe("Audio Handling Tests", () => { - it("should load audio recordings from SPS database", async () => { + describe('Audio Handling Tests', () => { + it('should load audio recordings from SPS database', async () => { // Create a button with audio recording const button = ButtonFactory.create({ - label: "Audio Button", - message: "I have audio", - type: "SPEAK", + label: 'Audio Button', + message: 'I have audio', + type: 'SPEAK', }); // Add audio recording button.audioRecording = { id: 1, - data: Buffer.from("fake audio data for testing"), - identifier: "audio_1", - metadata: "Test audio recording", + data: Buffer.from('fake audio data for testing'), + identifier: 'audio_1', + metadata: 'Test audio recording', }; const page = PageFactory.create({ - id: "audio_page", - name: "Audio Test Page", + id: 'audio_page', + name: 'Audio Test Page', }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, "audio_test.sps"); + const outputPath = path.join(tempDir, 'audio_test.sps'); await processor.saveFromTree(tree, outputPath); expect(fs.existsSync(outputPath)).toBe(true); // Load and verify audio is preserved const loadedTree = await processor.loadIntoTree(outputPath); - const loadedPage = loadedTree.getPage("audio_page"); + const loadedPage = loadedTree.getPage('audio_page'); expect(loadedPage).toBeDefined(); if (!loadedPage) { return; } expect(loadedPage.buttons).toHaveLength(1); - expect(loadedPage.buttons[0].label).toBe("Audio Button"); + expect(loadedPage.buttons[0].label).toBe('Audio Button'); }); - it("should handle missing audio files gracefully", async () => { + it('should handle missing audio files gracefully', async () => { // Create a button that references non-existent audio const button = ButtonFactory.create({ - label: "Missing Audio Button", - message: "No audio here", - type: "SPEAK", + label: 'Missing Audio Button', + message: 'No audio here', + type: 'SPEAK', }); // Set audio recording with invalid data button.audioRecording = { id: 999, data: Buffer.alloc(0), // Empty buffer - identifier: "missing_audio", - metadata: "Non-existent audio", + identifier: 'missing_audio', + metadata: 'Non-existent audio', }; const page = PageFactory.create({ - id: "missing_audio_page", - name: "Missing Audio Page", + id: 'missing_audio_page', + name: 'Missing Audio Page', }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, "missing_audio.sps"); + const outputPath = path.join(tempDir, 'missing_audio.sps'); - await expect( - processor.saveFromTree(tree, outputPath), - ).resolves.not.toThrow(); + await expect(processor.saveFromTree(tree, outputPath)).resolves.not.toThrow(); const loadedTree = await processor.loadIntoTree(outputPath); expect(loadedTree).toBeDefined(); }); - it("should process different audio formats (WAV, MP3, AAC)", async () => { + it('should process different audio formats (WAV, MP3, AAC)', async () => { const audioFormats = [ - { format: "WAV", data: Buffer.from("RIFF....WAVE"), extension: ".wav" }, - { format: "MP3", data: Buffer.from("ID3...."), extension: ".mp3" }, - { format: "AAC", data: Buffer.from("ADTS...."), extension: ".aac" }, + { format: 'WAV', data: Buffer.from('RIFF....WAVE'), extension: '.wav' }, + { format: 'MP3', data: Buffer.from('ID3....'), extension: '.mp3' }, + { format: 'AAC', data: Buffer.from('ADTS....'), extension: '.aac' }, ]; const tree = new AACTree(); @@ -119,7 +117,7 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { const button = ButtonFactory.create({ label: `${format.format} Button`, message: `Audio in ${format.format}`, - type: "SPEAK", + type: 'SPEAK', }); button.audioRecording = { @@ -137,24 +135,24 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { tree.addPage(page); }); - const outputPath = path.join(tempDir, "multi_format_audio.sps"); + const outputPath = path.join(tempDir, 'multi_format_audio.sps'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); expect(Object.keys(loadedTree.pages)).toHaveLength(3); }); - it("should add new audio recordings to buttons", async () => { + it('should add new audio recordings to buttons', async () => { // Start with a button without audio const button = ButtonFactory.create({ - label: "No Audio Button", - message: "Initially no audio", - type: "SPEAK", + label: 'No Audio Button', + message: 'Initially no audio', + type: 'SPEAK', }); const page = PageFactory.create({ - id: "add_audio_page", - name: "Add Audio Page", + id: 'add_audio_page', + name: 'Add Audio Page', }); page.addButton(button); @@ -162,12 +160,12 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { tree.addPage(page); // Save initial version - const outputPath = path.join(tempDir, "add_audio.sps"); + const outputPath = path.join(tempDir, 'add_audio.sps'); await processor.saveFromTree(tree, outputPath); // Load and add audio const loadedTree = await processor.loadIntoTree(outputPath); - const loadedPage = loadedTree.getPage("add_audio_page"); + const loadedPage = loadedTree.getPage('add_audio_page'); expect(loadedPage).toBeDefined(); if (!loadedPage) { return; @@ -177,18 +175,18 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { // Add audio recording loadedButton.audioRecording = { id: 1, - data: Buffer.from("newly added audio data"), - identifier: "new_audio", - metadata: "Newly added audio", + data: Buffer.from('newly added audio data'), + identifier: 'new_audio', + metadata: 'Newly added audio', }; // Save with audio - const updatedPath = path.join(tempDir, "add_audio_updated.sps"); + const updatedPath = path.join(tempDir, 'add_audio_updated.sps'); await processor.saveFromTree(loadedTree, updatedPath); // Verify audio was added const finalTree = await processor.loadIntoTree(updatedPath); - const finalPage = finalTree.getPage("add_audio_page"); + const finalPage = finalTree.getPage('add_audio_page'); expect(finalPage).toBeDefined(); if (!finalPage) { return; @@ -196,39 +194,39 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { const finalButton = finalPage.buttons[0]; expect(finalButton.audioRecording).toBeDefined(); - expect(finalButton.audioRecording?.identifier).toBe("new_audio"); + expect(finalButton.audioRecording?.identifier).toBe('new_audio'); }); - it("should update existing audio recordings", async () => { + it('should update existing audio recordings', async () => { // Create button with initial audio const button = ButtonFactory.create({ - label: "Update Audio Button", - message: "Audio will be updated", - type: "SPEAK", + label: 'Update Audio Button', + message: 'Audio will be updated', + type: 'SPEAK', }); button.audioRecording = { id: 1, - data: Buffer.from("original audio data"), - identifier: "original_audio", - metadata: "Original audio", + data: Buffer.from('original audio data'), + identifier: 'original_audio', + metadata: 'Original audio', }; const page = PageFactory.create({ - id: "update_audio_page", - name: "Update Audio Page", + id: 'update_audio_page', + name: 'Update Audio Page', }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, "update_audio.sps"); + const outputPath = path.join(tempDir, 'update_audio.sps'); await processor.saveFromTree(tree, outputPath); // Load and update audio const loadedTree = await processor.loadIntoTree(outputPath); - const updatePage = loadedTree.getPage("update_audio_page"); + const updatePage = loadedTree.getPage('update_audio_page'); expect(updatePage).toBeDefined(); if (!updatePage) { return; @@ -238,57 +236,57 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { // Update audio recording loadedButton.audioRecording = { id: 1, - data: Buffer.from("updated audio data"), - identifier: "updated_audio", - metadata: "Updated audio", + data: Buffer.from('updated audio data'), + identifier: 'updated_audio', + metadata: 'Updated audio', }; - const updatedPath = path.join(tempDir, "update_audio_final.sps"); + const updatedPath = path.join(tempDir, 'update_audio_final.sps'); await processor.saveFromTree(loadedTree, updatedPath); // Verify audio was updated const finalTree = await processor.loadIntoTree(updatedPath); - const finalPage = finalTree.getPage("update_audio_page"); + const finalPage = finalTree.getPage('update_audio_page'); expect(finalPage).toBeDefined(); if (!finalPage) { return; } const finalButton = finalPage.buttons[0]; - expect(finalButton.audioRecording?.identifier).toBe("updated_audio"); - expect(finalButton.audioRecording?.metadata).toBe("Updated audio"); + expect(finalButton.audioRecording?.identifier).toBe('updated_audio'); + expect(finalButton.audioRecording?.metadata).toBe('Updated audio'); }); - it("should remove audio recordings from buttons", async () => { + it('should remove audio recordings from buttons', async () => { // Create button with audio const button = ButtonFactory.create({ - label: "Remove Audio Button", - message: "Audio will be removed", - type: "SPEAK", + label: 'Remove Audio Button', + message: 'Audio will be removed', + type: 'SPEAK', }); button.audioRecording = { id: 1, - data: Buffer.from("audio to be removed"), - identifier: "removable_audio", - metadata: "Audio to be removed", + data: Buffer.from('audio to be removed'), + identifier: 'removable_audio', + metadata: 'Audio to be removed', }; const page = PageFactory.create({ - id: "remove_audio_page", - name: "Remove Audio Page", + id: 'remove_audio_page', + name: 'Remove Audio Page', }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, "remove_audio.sps"); + const outputPath = path.join(tempDir, 'remove_audio.sps'); await processor.saveFromTree(tree, outputPath); // Load and remove audio const loadedTree = await processor.loadIntoTree(outputPath); - const removePage = loadedTree.getPage("remove_audio_page"); + const removePage = loadedTree.getPage('remove_audio_page'); expect(removePage).toBeDefined(); if (!removePage) { return; @@ -298,12 +296,12 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { // Remove audio recording loadedButton.audioRecording = undefined; - const updatedPath = path.join(tempDir, "remove_audio_final.sps"); + const updatedPath = path.join(tempDir, 'remove_audio_final.sps'); await processor.saveFromTree(loadedTree, updatedPath); // Verify audio was removed const finalTree = await processor.loadIntoTree(updatedPath); - const finalPage = finalTree.getPage("remove_audio_page"); + const finalPage = finalTree.getPage('remove_audio_page'); expect(finalPage).toBeDefined(); if (!finalPage) { return; @@ -312,11 +310,11 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { expect(finalButton.audioRecording).toBeUndefined(); }); - it("should preserve audio metadata during processing", async () => { + it('should preserve audio metadata during processing', async () => { const button = ButtonFactory.create({ - label: "Metadata Button", - message: "Audio with metadata", - type: "SPEAK", + label: 'Metadata Button', + message: 'Audio with metadata', + type: 'SPEAK', }); const complexMetadata = JSON.stringify({ @@ -324,31 +322,31 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { bitDepth: 16, channels: 2, duration: 2.5, - format: "WAV", + format: 'WAV', created: new Date().toISOString(), }); button.audioRecording = { id: 1, - data: Buffer.from("audio with complex metadata"), - identifier: "metadata_audio", + data: Buffer.from('audio with complex metadata'), + identifier: 'metadata_audio', metadata: complexMetadata, }; const page = PageFactory.create({ - id: "metadata_page", - name: "Metadata Page", + id: 'metadata_page', + name: 'Metadata Page', }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, "metadata_test.sps"); + const outputPath = path.join(tempDir, 'metadata_test.sps'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); - const loadedPage = loadedTree.getPage("metadata_page"); + const loadedPage = loadedTree.getPage('metadata_page'); expect(loadedPage).toBeDefined(); if (!loadedPage) { return; @@ -359,14 +357,12 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { expect(loadedButton.audioRecording?.metadata).toBe(complexMetadata); // Verify metadata can be parsed back - const parsedMetadata = JSON.parse( - loadedButton.audioRecording?.metadata || "{}", - ); + const parsedMetadata = JSON.parse(loadedButton.audioRecording?.metadata || '{}'); expect(parsedMetadata.sampleRate).toBe(44100); - expect(parsedMetadata.format).toBe("WAV"); + expect(parsedMetadata.format).toBe('WAV'); }); - it("should handle audio with different sample rates", async () => { + it('should handle audio with different sample rates', async () => { const sampleRates = [8000, 16000, 22050, 44100, 48000, 96000]; const tree = new AACTree(); @@ -374,7 +370,7 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { const button = ButtonFactory.create({ label: `${rate}Hz Button`, message: `Audio at ${rate}Hz`, - type: "SPEAK", + type: 'SPEAK', }); button.audioRecording = { @@ -392,7 +388,7 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { tree.addPage(page); }); - const outputPath = path.join(tempDir, "sample_rates.sps"); + const outputPath = path.join(tempDir, 'sample_rates.sps'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -409,14 +405,12 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { expect(page.buttons.length).toBeGreaterThan(0); expect(page.buttons[0].audioRecording).toBeDefined(); - const metadata = JSON.parse( - page.buttons[0].audioRecording?.metadata || "{}", - ); + const metadata = JSON.parse(page.buttons[0].audioRecording?.metadata || '{}'); expect(metadata.sampleRate).toBe(rate); }); }); - it("should process audio with various bit depths", async () => { + it('should process audio with various bit depths', async () => { const bitDepths = [8, 16, 24, 32]; const tree = new AACTree(); @@ -424,7 +418,7 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { const button = ButtonFactory.create({ label: `${depth}-bit Button`, message: `Audio at ${depth}-bit`, - type: "SPEAK", + type: 'SPEAK', }); button.audioRecording = { @@ -442,7 +436,7 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { tree.addPage(page); }); - const outputPath = path.join(tempDir, "bit_depths.sps"); + const outputPath = path.join(tempDir, 'bit_depths.sps'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -459,9 +453,7 @@ describe("SnapProcessor - Comprehensive Coverage Tests", () => { expect(page.buttons.length).toBeGreaterThan(0); expect(page.buttons[0].audioRecording).toBeDefined(); - const metadata = JSON.parse( - page.buttons[0].audioRecording?.metadata || "{}", - ); + const metadata = JSON.parse(page.buttons[0].audioRecording?.metadata || '{}'); expect(metadata.bitDepth).toBe(depth); }); }); diff --git a/test/snapProcessor.audio.test.ts b/test/snapProcessor.audio.test.ts index e3a67be..b6eed5d 100644 --- a/test/snapProcessor.audio.test.ts +++ b/test/snapProcessor.audio.test.ts @@ -1,21 +1,21 @@ -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { AACTree, AACPage } from "../src/core/treeStructure"; -import path from "path"; -import fs from "fs"; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { AACTree, AACPage } from '../src/core/treeStructure'; +import path from 'path'; +import fs from 'fs'; -describe("SnapProcessor Audio Support", () => { +describe('SnapProcessor Audio Support', () => { const exampleSPSFile: string = path.join( __dirname, - "assets/snap/Aphasia Page Set With Sound.sps", + 'assets/snap/Aphasia Page Set With Sound.sps' ); const enhancedSPSFile: string = path.join( __dirname, - "assets/snap/Aphasia_Page_Set_With_Punjabi_Audio.sps", + 'assets/snap/Aphasia_Page_Set_With_Punjabi_Audio.sps' ); - it("should load pageset without audio by default", async () => { + it('should load pageset without audio by default', async () => { if (!fs.existsSync(exampleSPSFile)) { - console.log("Skipping test - audio example file not found"); + console.log('Skipping test - audio example file not found'); return; } @@ -36,9 +36,9 @@ describe("SnapProcessor Audio Support", () => { } }); - it("should load pageset with audio when requested", async () => { + it('should load pageset with audio when requested', async () => { if (!fs.existsSync(exampleSPSFile)) { - console.log("Skipping test - audio example file not found"); + console.log('Skipping test - audio example file not found'); return; } @@ -65,9 +65,9 @@ describe("SnapProcessor Audio Support", () => { expect(foundAudioButton).toBe(true); }); - it("should extract buttons for audio processing", async () => { + it('should extract buttons for audio processing', async () => { if (!fs.existsSync(exampleSPSFile)) { - console.log("Skipping test - audio example file not found"); + console.log('Skipping test - audio example file not found'); return; } @@ -84,55 +84,53 @@ describe("SnapProcessor Audio Support", () => { if (pageWithButtons) { const buttons = (processor as any).extractButtonsForAudio( exampleSPSFile, - pageWithButtons.id, + pageWithButtons.id ); expect(Array.isArray(buttons)).toBe(true); if (buttons.length > 0) { const firstButton = buttons[0]; - expect(firstButton).toHaveProperty("id"); - expect(firstButton).toHaveProperty("label"); - expect(firstButton).toHaveProperty("message"); - expect(firstButton).toHaveProperty("hasAudio"); - expect(typeof firstButton.hasAudio).toBe("boolean"); + expect(firstButton).toHaveProperty('id'); + expect(firstButton).toHaveProperty('label'); + expect(firstButton).toHaveProperty('message'); + expect(firstButton).toHaveProperty('hasAudio'); + expect(typeof firstButton.hasAudio).toBe('boolean'); } } } } catch (error: any) { - console.log("Could not test button extraction:", error.message); + console.log('Could not test button extraction:', error.message); } }); - it("should add audio to buttons", async () => { + it('should add audio to buttons', async () => { if (!fs.existsSync(exampleSPSFile)) { - console.log("Skipping test - audio example file not found"); + console.log('Skipping test - audio example file not found'); return; } const processor = new SnapProcessor(); - const testDbPath: string = path.join(__dirname, "test_audio_temp.sps"); + const testDbPath: string = path.join(__dirname, 'test_audio_temp.sps'); try { // Copy the example file for testing fs.copyFileSync(exampleSPSFile, testDbPath); // Create some test audio data - const testAudioData: Uint8Array = new Uint8Array( - Buffer.from("RIFF....WAVE....", "ascii"), - ); // Minimal WAV-like data + const testAudioData: Uint8Array = new Uint8Array(Buffer.from('RIFF....WAVE....', 'ascii')); // Minimal WAV-like data // Add audio to a button (using button ID 1 as a test) const audioId: number = await processor.addAudioToButton( testDbPath, 1, testAudioData, - "Test Audio", + 'Test Audio' ); - expect(typeof audioId).toBe("number"); + expect(typeof audioId).toBe('number'); expect(audioId).toBeGreaterThan(0); } catch (error: any) { - console.log("Could not test audio addition:", error.message); + console.log('Could not test audio addition:', error.message); } finally { // Clean up if (fs.existsSync(testDbPath)) { @@ -141,9 +139,9 @@ describe("SnapProcessor Audio Support", () => { } }); - it("should load enhanced pageset with Punjabi audio", async () => { + it('should load enhanced pageset with Punjabi audio', async () => { if (!fs.existsSync(enhancedSPSFile)) { - console.log("Skipping test - enhanced pageset not found"); + console.log('Skipping test - enhanced pageset not found'); return; } @@ -155,17 +153,15 @@ describe("SnapProcessor Audio Support", () => { // Look for the QuickFires page const quickFiresPage = Object.values(tree.pages).find( - (page) => page.name && page.name.includes("QuickFires"), + (page) => page.name && page.name.includes('QuickFires') ); if (quickFiresPage) { - console.log( - `Found QuickFires page with ${quickFiresPage.buttons.length} buttons`, - ); + console.log(`Found QuickFires page with ${quickFiresPage.buttons.length} buttons`); // Count buttons with audio const buttonsWithAudio = quickFiresPage.buttons.filter( - (button) => button.audioRecording && button.audioRecording.data, + (button) => button.audioRecording && button.audioRecording.data ); console.log(`Buttons with audio: ${buttonsWithAudio.length}`); @@ -188,47 +184,37 @@ describe("SnapProcessor Audio Support", () => { } }); } else { - console.log("QuickFires page not found in enhanced pageset"); + console.log('QuickFires page not found in enhanced pageset'); } }); }); -describe("SnapProcessor Audio Integration", () => { - it("should demonstrate complete audio workflow", async () => { - console.log("\n=== SnapProcessor Audio Integration Demo ==="); - console.log("1. Basic usage (no audio):"); - console.log(" const processor = new SnapProcessor();"); +describe('SnapProcessor Audio Integration', () => { + it('should demonstrate complete audio workflow', async () => { + console.log('\n=== SnapProcessor Audio Integration Demo ==='); + console.log('1. Basic usage (no audio):'); + console.log(' const processor = new SnapProcessor();'); console.log(' const tree = await processor.loadIntoTree("pageset.sps");'); - console.log("\n2. With audio support:"); - console.log( - " const processor = new SnapProcessor(null, { loadAudio: true });", - ); + console.log('\n2. With audio support:'); + console.log(' const processor = new SnapProcessor(null, { loadAudio: true });'); console.log(' const tree = await processor.loadIntoTree("pageset.sps");'); - console.log(" // Buttons will have audioRecording property if available"); + console.log(' // Buttons will have audioRecording property if available'); - console.log("\n3. Adding audio to buttons:"); + console.log('\n3. Adding audio to buttons:'); console.log(' const audioData = fs.readFileSync("audio.wav");'); console.log( - ' const audioId = processor.addAudioToButton(dbPath, buttonId, audioData, "metadata");', + ' const audioId = processor.addAudioToButton(dbPath, buttonId, audioData, "metadata");' ); - console.log("\n4. Creating enhanced pageset:"); - console.log(" const audioMappings = new Map();"); - console.log( - ' audioMappings.set(buttonId, { audioData, metadata: "Punjabi audio" });', - ); - console.log( - " processor.createAudioEnhancedPageset(source, target, audioMappings);", - ); + console.log('\n4. Creating enhanced pageset:'); + console.log(' const audioMappings = new Map();'); + console.log(' audioMappings.set(buttonId, { audioData, metadata: "Punjabi audio" });'); + console.log(' processor.createAudioEnhancedPageset(source, target, audioMappings);'); - console.log("\n5. Extracting buttons for processing:"); - console.log( - " const buttons = processor.extractButtonsForAudio(dbPath, pageUniqueId);", - ); - console.log( - " // Returns array with id, label, message, hasAudio properties", - ); + console.log('\n5. Extracting buttons for processing:'); + console.log(' const buttons = processor.extractButtonsForAudio(dbPath, pageUniqueId);'); + console.log(' // Returns array with id, label, message, hasAudio properties'); expect(true).toBe(true); // This is just a demo test }); diff --git a/test/snapProcessor.corruption.performance.test.ts b/test/snapProcessor.corruption.performance.test.ts index 58158ea..322991c 100644 --- a/test/snapProcessor.corruption.performance.test.ts +++ b/test/snapProcessor.corruption.performance.test.ts @@ -1,13 +1,13 @@ // Database corruption and performance tests for SnapProcessor -import fs from "fs"; -import path from "path"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { AACTree } from "../src/core/treeStructure"; -import { TreeFactory, PageFactory, ButtonFactory } from "./utils/testFactories"; +import fs from 'fs'; +import path from 'path'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { AACTree } from '../src/core/treeStructure'; +import { TreeFactory, PageFactory, ButtonFactory } from './utils/testFactories'; -describe("SnapProcessor - Database Corruption & Performance Tests", () => { +describe('SnapProcessor - Database Corruption & Performance Tests', () => { let processor: SnapProcessor; - const tempDir = path.join(__dirname, "temp_snap_corruption"); + const tempDir = path.join(__dirname, 'temp_snap_corruption'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -25,11 +25,11 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { } }); - describe("Database Corruption Handling", () => { - it("should handle partially corrupted SPS files", async () => { + describe('Database Corruption Handling', () => { + it('should handle partially corrupted SPS files', async () => { // Create a valid SPS file first const tree = TreeFactory.createSimple(); - const validPath = path.join(tempDir, "valid.sps"); + const validPath = path.join(tempDir, 'valid.sps'); await processor.saveFromTree(tree, validPath); // Read the valid file and corrupt part of it @@ -43,38 +43,38 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { corruptedData[i] = Math.floor(Math.random() * 256); } - const corruptedPath = path.join(tempDir, "partially_corrupted.sps"); + const corruptedPath = path.join(tempDir, 'partially_corrupted.sps'); fs.writeFileSync(corruptedPath, corruptedData); // Should handle corruption gracefully await expect(processor.loadIntoTree(corruptedPath)).rejects.toThrow(); }); - it("should recover from corrupted audio blob data", async () => { + it('should recover from corrupted audio blob data', async () => { // Create a file with audio data const button = ButtonFactory.create({ - label: "Audio Button", - message: "Has audio", - type: "SPEAK", + label: 'Audio Button', + message: 'Has audio', + type: 'SPEAK', }); button.audioRecording = { id: 1, - data: Buffer.from("valid audio data"), - identifier: "audio_1", - metadata: "Valid audio", + data: Buffer.from('valid audio data'), + identifier: 'audio_1', + metadata: 'Valid audio', }; const page = PageFactory.create({ - id: "audio_page", - name: "Audio Page", + id: 'audio_page', + name: 'Audio Page', }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, "audio_corruption.sps"); + const outputPath = path.join(tempDir, 'audio_corruption.sps'); await processor.saveFromTree(tree, outputPath); // Verify the file was created successfully @@ -85,88 +85,81 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { expect(loadedTree).toBeDefined(); }); - it("should handle missing database tables gracefully", async () => { + it('should handle missing database tables gracefully', async () => { // Create a zip file with missing required tables // eslint-disable-next-line @typescript-eslint/no-var-requires - const AdmZip = require("adm-zip"); + const AdmZip = require('adm-zip'); const zip = new AdmZip(); // Add some files but not the required database structure - zip.addFile("readme.txt", Buffer.from("This is not a proper SPS file")); - zip.addFile("config.json", Buffer.from('{"version": "1.0"}')); + zip.addFile('readme.txt', Buffer.from('This is not a proper SPS file')); + zip.addFile('config.json', Buffer.from('{"version": "1.0"}')); - const invalidPath = path.join(tempDir, "missing_tables.sps"); + const invalidPath = path.join(tempDir, 'missing_tables.sps'); zip.writeZip(invalidPath); await expect(processor.loadIntoTree(invalidPath)).rejects.toThrow(); }); - it("should process files with invalid foreign keys", async () => { + it('should process files with invalid foreign keys', async () => { // Create a valid tree first const tree = TreeFactory.createCommunicationBoard(); - const outputPath = path.join(tempDir, "foreign_keys.sps"); + const outputPath = path.join(tempDir, 'foreign_keys.sps'); // This should work with proper relationships - await expect( - processor.saveFromTree(tree, outputPath), - ).resolves.not.toThrow(); + await expect(processor.saveFromTree(tree, outputPath)).resolves.not.toThrow(); const loadedTree = await processor.loadIntoTree(outputPath); expect(loadedTree).toBeDefined(); }); - it("should handle truncated database files", async () => { + it('should handle truncated database files', async () => { // Create a valid file const tree = TreeFactory.createSimple(); - const validPath = path.join(tempDir, "valid_for_truncation.sps"); + const validPath = path.join(tempDir, 'valid_for_truncation.sps'); await processor.saveFromTree(tree, validPath); // Read and truncate the file const validData = fs.readFileSync(validPath); - const truncatedData = validData.slice( - 0, - Math.floor(validData.length / 2), - ); + const truncatedData = validData.slice(0, Math.floor(validData.length / 2)); - const truncatedPath = path.join(tempDir, "truncated.sps"); + const truncatedPath = path.join(tempDir, 'truncated.sps'); fs.writeFileSync(truncatedPath, truncatedData); await expect(processor.loadIntoTree(truncatedPath)).rejects.toThrow(); }); - it("should handle completely invalid file formats", async () => { - const invalidPath = path.join(tempDir, "not_a_zip.sps"); - fs.writeFileSync(invalidPath, "This is just plain text, not a zip file"); + it('should handle completely invalid file formats', async () => { + const invalidPath = path.join(tempDir, 'not_a_zip.sps'); + fs.writeFileSync(invalidPath, 'This is just plain text, not a zip file'); await expect(processor.loadIntoTree(invalidPath)).rejects.toThrow(); }); - it("should handle empty files", async () => { - const emptyPath = path.join(tempDir, "empty.sps"); - fs.writeFileSync(emptyPath, ""); + it('should handle empty files', async () => { + const emptyPath = path.join(tempDir, 'empty.sps'); + fs.writeFileSync(emptyPath, ''); await expect(processor.loadIntoTree(emptyPath)).rejects.toThrow(); }); - it("should handle files with invalid zip structure", async () => { - const invalidZipPath = path.join(tempDir, "invalid_zip.sps"); + it('should handle files with invalid zip structure', async () => { + const invalidZipPath = path.join(tempDir, 'invalid_zip.sps'); // Write some bytes that look like they might be a zip but aren't - const fakeZipData = Buffer.from( - "PK\x03\x04\x14\x00\x00\x00invalid zip data", - ); + const fakeZipData = Buffer.from('PK\x03\x04\x14\x00\x00\x00invalid zip data'); fs.writeFileSync(invalidZipPath, fakeZipData); await expect(processor.loadIntoTree(invalidZipPath)).rejects.toThrow(); }); }); - describe("Performance Tests", () => { - it("should process large pagesets (500+ pages) efficiently", async () => { + describe('Performance Tests', () => { + it('should process large pagesets (500+ pages) efficiently', async () => { const startTime = Date.now(); // Create a very large tree const tree = TreeFactory.createLarge(500, 5); // 500 pages, 5 buttons each - const outputPath = path.join(tempDir, "large_pageset.sps"); + const outputPath = path.join(tempDir, 'large_pageset.sps'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -181,7 +174,7 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { console.log(`Large pageset processing time: ${processingTime}ms`); }); - it("should handle pagesets with extensive audio content", async () => { + it('should handle pagesets with extensive audio content', async () => { const startTime = Date.now(); // Create tree with many audio recordings @@ -199,7 +192,7 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { const button = ButtonFactory.create({ label: `Audio Button ${buttonIndex}`, message: `Audio message ${buttonIndex}`, - type: "SPEAK", + type: 'SPEAK', }); const audioSize = audioSizes[buttonIndex % audioSizes.length]; @@ -220,7 +213,7 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { tree.addPage(page); } - const outputPath = path.join(tempDir, "extensive_audio.sps"); + const outputPath = path.join(tempDir, 'extensive_audio.sps'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -233,7 +226,7 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { expect(processingTime).toBeLessThan(80000); // Allow headroom on slower machines // Verify audio data integrity - const firstPage = loadedTree.getPage("audio_page_0"); + const firstPage = loadedTree.getPage('audio_page_0'); expect(firstPage).toBeDefined(); if (!firstPage) { return; @@ -244,7 +237,7 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { console.log(`Extensive audio processing time: ${processingTime}ms`); }); - it("should maintain memory usage under 100MB for large files", async () => { + it('should maintain memory usage under 100MB for large files', async () => { // Monitor memory usage during processing const initialMemory = process.memoryUsage(); @@ -260,13 +253,13 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { id: pageIndex * 100 + buttonIndex, data: Buffer.alloc(4096, 0x42), // 4KB audio data identifier: `audio_${pageIndex}_${buttonIndex}`, - metadata: "Performance test audio", + metadata: 'Performance test audio', }; } }); }); - const outputPath = path.join(tempDir, "memory_test.sps"); + const outputPath = path.join(tempDir, 'memory_test.sps'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -281,7 +274,7 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { console.log(`Memory increase: ${memoryIncreaseMB.toFixed(2)}MB`); }); - it("should handle concurrent processing efficiently", async () => { + it('should handle concurrent processing efficiently', async () => { // Test processing multiple files concurrently const trees = [ TreeFactory.createSimple(), @@ -312,11 +305,11 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { console.log(`Concurrent processing time: ${processingTime}ms`); }); - it("should handle streaming large files efficiently", async () => { + it('should handle streaming large files efficiently', async () => { // Test with a very large tree that would benefit from streaming const tree = TreeFactory.createLarge(200, 10); // 200 pages, 10 buttons each - const outputPath = path.join(tempDir, "streaming_test.sps"); + const outputPath = path.join(tempDir, 'streaming_test.sps'); const startTime = Date.now(); await processor.saveFromTree(tree, outputPath); @@ -336,15 +329,15 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { expect(processingTime).toBeLessThan(80000); // Should complete in under ~80 seconds console.log( - `Streaming test - File size: ${fileSizeMB.toFixed(2)}MB, Processing time: ${processingTime}ms`, + `Streaming test - File size: ${fileSizeMB.toFixed(2)}MB, Processing time: ${processingTime}ms` ); }); }); - describe("Text Processing Methods", () => { - it("should extract all texts from large databases", async () => { + describe('Text Processing Methods', () => { + it('should extract all texts from large databases', async () => { const tree = TreeFactory.createLarge(50, 10); - const outputPath = path.join(tempDir, "text_extraction.sps"); + const outputPath = path.join(tempDir, 'text_extraction.sps'); await processor.saveFromTree(tree, outputPath); const texts = await processor.extractTexts(outputPath); @@ -356,10 +349,10 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { expect(texts.length).toBeGreaterThanOrEqual(expectedTextCount); }); - it("should process texts with translations efficiently", async () => { + it('should process texts with translations efficiently', async () => { const tree = TreeFactory.createCommunicationBoard(); - const inputPath = path.join(tempDir, "input_for_translation.sps"); - const outputPath = path.join(tempDir, "translation_performance.sps"); + const inputPath = path.join(tempDir, 'input_for_translation.sps'); + const outputPath = path.join(tempDir, 'translation_performance.sps'); // Save the tree first await processor.saveFromTree(tree, inputPath); @@ -371,11 +364,7 @@ describe("SnapProcessor - Database Corruption & Performance Tests", () => { } const startTime = Date.now(); - const result = await processor.processTexts( - inputPath, - translations, - outputPath, - ); + const result = await processor.processTexts(inputPath, translations, outputPath); const endTime = Date.now(); expect(result).toBeInstanceOf(Buffer); diff --git a/test/snapProcessor.coverage.test.ts b/test/snapProcessor.coverage.test.ts index a569ded..4ef5693 100644 --- a/test/snapProcessor.coverage.test.ts +++ b/test/snapProcessor.coverage.test.ts @@ -1,12 +1,12 @@ -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { TreeFactory } from "./utils/testFactories"; -import path from "path"; -import fs from "fs"; -import Database from "better-sqlite3"; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { TreeFactory } from './utils/testFactories'; +import path from 'path'; +import fs from 'fs'; +import Database from 'better-sqlite3'; -describe("SnapProcessor Coverage", () => { - const exampleFile: string = path.join(__dirname, "assets/snap/example.sps"); - const tempDbPath = path.join(__dirname, "temp_snap.db"); +describe('SnapProcessor Coverage', () => { + const exampleFile: string = path.join(__dirname, 'assets/snap/example.sps'); + const tempDbPath = path.join(__dirname, 'temp_snap.db'); beforeEach(async () => { if (fs.existsSync(tempDbPath)) { @@ -20,103 +20,85 @@ describe("SnapProcessor Coverage", () => { } }); - describe("Audio Handling", () => { - it("should load audio data when loadAudio is true", async () => { + describe('Audio Handling', () => { + it('should load audio data when loadAudio is true', async () => { const saveProcessor = new SnapProcessor(); const tree = TreeFactory.createSimple(); await saveProcessor.saveFromTree(tree, tempDbPath); const db = new Database(tempDbPath); - const firstButton = db - .prepare("SELECT Id FROM Button ORDER BY Id LIMIT 1") - .get() as { + const firstButton = db.prepare('SELECT Id FROM Button ORDER BY Id LIMIT 1').get() as { Id: number; }; db.close(); - const audioData = new Uint8Array(Buffer.from("audio data")); - await saveProcessor.addAudioToButton( - tempDbPath, - firstButton.Id, - audioData, - "test.wav", - ); + const audioData = new Uint8Array(Buffer.from('audio data')); + await saveProcessor.addAudioToButton(tempDbPath, firstButton.Id, audioData, 'test.wav'); const processor = new SnapProcessor(null, { loadAudio: true }); const loadedTree = await processor.loadIntoTree(tempDbPath); const page = Object.values(loadedTree.pages)[0]; expect(page).toBeDefined(); - const buttonWithAudio = page?.buttons.find( - (button) => button.audioRecording, - ); + const buttonWithAudio = page?.buttons.find((button) => button.audioRecording); expect(buttonWithAudio).toBeDefined(); expect(Buffer.from(buttonWithAudio?.audioRecording?.data || [])).toEqual( - Buffer.from(audioData), + Buffer.from(audioData) ); }); - it("should add audio to a button", async () => { + it('should add audio to a button', async () => { // Use a real file to test against fs.copyFileSync(exampleFile, tempDbPath); const processor = new SnapProcessor(); - const audioData = new Uint8Array(Buffer.from("new audio data")); - await processor.addAudioToButton(tempDbPath, 1, audioData, "test.wav"); + const audioData = new Uint8Array(Buffer.from('new audio data')); + await processor.addAudioToButton(tempDbPath, 1, audioData, 'test.wav'); const db = new Database(tempDbPath); - const row = db.prepare("SELECT * FROM Button WHERE Id = ?").get(1) as any; + const row = db.prepare('SELECT * FROM Button WHERE Id = ?').get(1) as any; expect(row.MessageRecordingId).toBeGreaterThan(0); const audioRow = db - .prepare("SELECT * FROM PageSetData WHERE Id = ?") + .prepare('SELECT * FROM PageSetData WHERE Id = ?') .get(row.MessageRecordingId) as any; expect(Buffer.from(audioRow.Data)).toEqual(Buffer.from(audioData)); db.close(); }); - it("should create an audio-enhanced pageset", async () => { - const enhancedDbPath = path.join(__dirname, "enhanced.db"); + it('should create an audio-enhanced pageset', async () => { + const enhancedDbPath = path.join(__dirname, 'enhanced.db'); if (fs.existsSync(enhancedDbPath)) { fs.unlinkSync(enhancedDbPath); } const processor = new SnapProcessor(); - const audioMappings = new Map< - number, - { audioData: Uint8Array; metadata?: string } - >(); + const audioMappings = new Map(); audioMappings.set(1, { - audioData: new Uint8Array(Buffer.from("new audio")), + audioData: new Uint8Array(Buffer.from('new audio')), }); - await processor.createAudioEnhancedPageset( - exampleFile, - enhancedDbPath, - audioMappings, - ); + await processor.createAudioEnhancedPageset(exampleFile, enhancedDbPath, audioMappings); expect(fs.existsSync(enhancedDbPath)).toBe(true); const db = new Database(enhancedDbPath); - const row = db.prepare("SELECT * FROM Button WHERE Id = ?").get(1) as any; + const row = db.prepare('SELECT * FROM Button WHERE Id = ?').get(1) as any; expect(row.MessageRecordingId).toBeGreaterThan(0); db.close(); fs.unlinkSync(enhancedDbPath); }); }); - describe("Database Corruption and Schema", () => { - it("should throw an error for a corrupted database file", async () => { - fs.writeFileSync(tempDbPath, "not a database"); + describe('Database Corruption and Schema', () => { + it('should throw an error for a corrupted database file', async () => { + fs.writeFileSync(tempDbPath, 'not a database'); const processor = new SnapProcessor(); await expect(processor.loadIntoTree(tempDbPath)).rejects.toThrow( - "Invalid SQLite database file", + 'Invalid SQLite database file' ); }); - it("should handle missing tables gracefully", async () => { + it('should handle missing tables gracefully', async () => { const db = new Database(tempDbPath); - db.exec( - "CREATE TABLE Page (Id INTEGER PRIMARY KEY, UniqueId TEXT, Name TEXT);", - ); + db.exec('CREATE TABLE Page (Id INTEGER PRIMARY KEY, UniqueId TEXT, Name TEXT);'); db.close(); const processor = new SnapProcessor(); diff --git a/test/snapProcessor.export.test.js b/test/snapProcessor.export.test.js index 12ee42a..34d3041 100644 --- a/test/snapProcessor.export.test.js +++ b/test/snapProcessor.export.test.js @@ -1,26 +1,26 @@ // Test SnapProcessor export/saveFromTree -const fs = require("fs"); -const path = require("path"); -const { SnapProcessor } = require("../dist/processors/snapProcessor"); -describe("SnapProcessor.saveFromTree", () => { - const snapPath = path.join(__dirname, "assets/snap/example.snap.json"); - const spsPath = path.join(__dirname, "assets/snap/example.sps"); - const outPath = path.join(__dirname, "out.snap.json"); +const fs = require('fs'); +const path = require('path'); +const { SnapProcessor } = require('../dist/processors/snapProcessor'); +describe('SnapProcessor.saveFromTree', () => { + const snapPath = path.join(__dirname, 'assets/snap/example.snap.json'); + const spsPath = path.join(__dirname, 'assets/snap/example.sps'); + const outPath = path.join(__dirname, 'out.snap.json'); afterAll(() => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("exports tree to Snap JSON", async () => { + it('exports tree to Snap JSON', async () => { // If no example.snap.json, skip if (!fs.existsSync(snapPath)) return; const processor = new SnapProcessor(); const tree = await processor.loadIntoTree(snapPath); await processor.saveFromTree(tree, outPath); - const exported = fs.readFileSync(outPath, "utf8"); - expect(exported).toContain("pages"); - expect(exported).toContain("rootId"); + const exported = fs.readFileSync(outPath, 'utf8'); + expect(exported).toContain('pages'); + expect(exported).toContain('rootId'); }); - it("loads tree from .sps file and returns pages", async () => { + it('loads tree from .sps file and returns pages', async () => { if (!fs.existsSync(spsPath)) return; const processor = new SnapProcessor(); const tree = await processor.loadIntoTree(spsPath); diff --git a/test/snapProcessor.roundtrip.test.ts b/test/snapProcessor.roundtrip.test.ts index 7a84f43..de353ac 100644 --- a/test/snapProcessor.roundtrip.test.ts +++ b/test/snapProcessor.roundtrip.test.ts @@ -1,23 +1,21 @@ -import fs from "fs"; -import path from "path"; -import { SnapProcessor } from "../src/processors/snapProcessor"; +import fs from 'fs'; +import path from 'path'; +import { SnapProcessor } from '../src/processors/snapProcessor'; // import { AACTree } from '../src/core/treeStructure'; // Unused import -describe("SnapProcessor round-trip", () => { - const snapPath = path.join(__dirname, "assets/snap/example.snap.json"); - const spsPath = path.join(__dirname, "assets/snap/example.sps"); - const outPath = path.join(__dirname, "out.snap.json"); +describe('SnapProcessor round-trip', () => { + const snapPath = path.join(__dirname, 'assets/snap/example.snap.json'); + const spsPath = path.join(__dirname, 'assets/snap/example.sps'); + const outPath = path.join(__dirname, 'out.snap.json'); afterAll(async () => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("round-trips Snap JSON without losing pages or navigation", async () => { + it('round-trips Snap JSON without losing pages or navigation', async () => { if (!fs.existsSync(snapPath)) return; const processor = new SnapProcessor(); const tree1 = await processor.loadIntoTree(snapPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); - expect(Object.keys(tree1.pages).sort()).toEqual( - Object.keys(tree2.pages).sort(), - ); + expect(Object.keys(tree1.pages).sort()).toEqual(Object.keys(tree2.pages).sort()); for (const pid in tree1.pages) { expect(tree2.pages).toHaveProperty(pid); const btnLabels1 = tree1.pages[pid].buttons.map((b) => b.label).sort(); @@ -30,14 +28,12 @@ describe("SnapProcessor round-trip", () => { expect(tree2.metadata.locale).toBe(tree1.metadata.locale); }); - it.skip("round-trips .sps file without losing pages (saveFromTree not implemented)", async () => { + it.skip('round-trips .sps file without losing pages (saveFromTree not implemented)', async () => { if (!fs.existsSync(spsPath)) return; const processor = new SnapProcessor(); const tree1 = await processor.loadIntoTree(spsPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); - expect(Object.keys(tree1.pages).sort()).toEqual( - Object.keys(tree2.pages).sort(), - ); + expect(Object.keys(tree1.pages).sort()).toEqual(Object.keys(tree2.pages).sort()); }); }); diff --git a/test/snapProcessor.test.ts b/test/snapProcessor.test.ts index 7e7f628..f792035 100644 --- a/test/snapProcessor.test.ts +++ b/test/snapProcessor.test.ts @@ -1,35 +1,29 @@ -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { AACTree } from "../src/core/treeStructure"; -import path from "path"; -import fs from "fs"; -import AdmZip from "adm-zip"; - -describe("SnapProcessor", () => { - const exampleFile: string = path.join(__dirname, "assets/snap/example.spb"); - const exampleSPSFile: string = path.join( - __dirname, - "assets/snap/example.sps", - ); - const exampleSubZipFile: string = path.join( - __dirname, - "assets/snap/example.sub.zip", - ); - - it("should extract all texts from a .spb file", async () => { +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { AACTree } from '../src/core/treeStructure'; +import path from 'path'; +import fs from 'fs'; +import AdmZip from 'adm-zip'; + +describe('SnapProcessor', () => { + const exampleFile: string = path.join(__dirname, 'assets/snap/example.spb'); + const exampleSPSFile: string = path.join(__dirname, 'assets/snap/example.sps'); + const exampleSubZipFile: string = path.join(__dirname, 'assets/snap/example.sub.zip'); + + it('should extract all texts from a .spb file', async () => { const processor = new SnapProcessor(); const texts: string[] = await processor.extractTexts(exampleFile); expect(Array.isArray(texts)).toBe(true); expect(texts.length).toBeGreaterThan(0); }); - it("should extract all texts from a .sps file", async () => { + it('should extract all texts from a .sps file', async () => { const processor = new SnapProcessor(); const texts: string[] = await processor.extractTexts(exampleSPSFile); expect(Array.isArray(texts)).toBe(true); expect(texts.length).toBeGreaterThan(0); }); - it("should load the tree structure from a .spb file and use UniqueId for page ids", async () => { + it('should load the tree structure from a .spb file and use UniqueId for page ids', async () => { const processor = new SnapProcessor(); const tree: AACTree = await processor.loadIntoTree(exampleFile); expect(tree).toBeTruthy(); @@ -41,7 +35,7 @@ describe("SnapProcessor", () => { }); }); - it("should load the tree structure from a .sps file and use UniqueId for page ids", async () => { + it('should load the tree structure from a .sps file and use UniqueId for page ids', async () => { const processor = new SnapProcessor(); const tree: AACTree = await processor.loadIntoTree(exampleSPSFile); expect(tree).toBeTruthy(); @@ -50,7 +44,7 @@ describe("SnapProcessor", () => { // All page ids should be UUID-like (contain hyphens) pageIds.forEach((id) => { - expect(typeof id).toBe("string"); + expect(typeof id).toBe('string'); expect(id.length).toBeGreaterThan(10); expect(id).toMatch(/-/); }); @@ -59,15 +53,15 @@ describe("SnapProcessor", () => { for (const pageId of pageIds) { const page = tree.pages[pageId]; for (const btn of page.buttons) { - if (btn.type === "NAVIGATE") { - expect(typeof btn.targetPageId).toBe("string"); + if (btn.type === 'NAVIGATE') { + expect(typeof btn.targetPageId).toBe('string'); expect(btn.targetPageId).toMatch(/-/); } } } }); - it("should handle .sub.zip files by extracting and processing the embedded .sps file", async () => { + it('should handle .sub.zip files by extracting and processing the embedded .sps file', async () => { const processor = new SnapProcessor(); const tree: AACTree = await processor.loadIntoTree(exampleSubZipFile); expect(tree).toBeTruthy(); @@ -75,61 +69,57 @@ describe("SnapProcessor", () => { expect(pageIds.length).toBeGreaterThan(0); }); - describe("Error Handling", () => { - it("should throw error for non-existent file", async () => { + describe('Error Handling', () => { + it('should throw error for non-existent file', async () => { const processor = new SnapProcessor(); - await expect( - processor.loadIntoTree("/non/existent/file.spb"), - ).rejects.toThrow(); + await expect(processor.loadIntoTree('/non/existent/file.spb')).rejects.toThrow(); }); - it("should handle invalid buffer input", async () => { + it('should handle invalid buffer input', async () => { const processor = new SnapProcessor(); - const invalidBuffer = Buffer.from("not a database file"); + const invalidBuffer = Buffer.from('not a database file'); await expect(processor.loadIntoTree(invalidBuffer)).rejects.toThrow(); }); - it("should handle empty file path", async () => { + it('should handle empty file path', async () => { const processor = new SnapProcessor(); - await expect(processor.loadIntoTree("")).rejects.toThrow(); + await expect(processor.loadIntoTree('')).rejects.toThrow(); }); - it("should throw error for .sub.zip file without .sps file inside", async () => { + it('should throw error for .sub.zip file without .sps file inside', async () => { const processor = new SnapProcessor(); const zip = new AdmZip(); - zip.addFile("not-an-sps.txt", Buffer.from("Not a pageset")); - const invalidSubZipPath = path.join(__dirname, "invalid.sub.zip"); + zip.addFile('not-an-sps.txt', Buffer.from('Not a pageset')); + const invalidSubZipPath = path.join(__dirname, 'invalid.sub.zip'); zip.writeZip(invalidSubZipPath); await expect(processor.loadIntoTree(invalidSubZipPath)).rejects.toThrow( - "No .sps file found in .sub.zip archive", + 'No .sps file found in .sub.zip archive' ); // Cleanup fs.unlinkSync(invalidSubZipPath); }); - it("should throw error for .sub.zip file that does not exist", async () => { + it('should throw error for .sub.zip file that does not exist', async () => { const processor = new SnapProcessor(); - await expect( - processor.loadIntoTree("non-existent.sub.zip"), - ).rejects.toThrow(); + await expect(processor.loadIntoTree('non-existent.sub.zip')).rejects.toThrow(); }); }); - describe("Audio Options", () => { - it("should create processor with audio loading disabled by default", async () => { + describe('Audio Options', () => { + it('should create processor with audio loading disabled by default', async () => { const processor = new SnapProcessor(); expect(processor).toBeDefined(); // Audio loading is private, but we can test the behavior }); - it("should create processor with audio loading enabled", async () => { + it('should create processor with audio loading enabled', async () => { const processor = new SnapProcessor(null, { loadAudio: true }); expect(processor).toBeDefined(); }); - it("should create processor with symbol resolver", async () => { + it('should create processor with symbol resolver', async () => { const mockResolver = { resolve: jest.fn() }; const processor = new SnapProcessor(mockResolver); expect(processor).toBeDefined(); diff --git a/test/stringCasing.test.ts b/test/stringCasing.test.ts index 546c7ee..262b089 100644 --- a/test/stringCasing.test.ts +++ b/test/stringCasing.test.ts @@ -4,159 +4,139 @@ import { detectCasing, convertCasing, isNumericOrEmpty, -} from "../src/core/stringCasing"; +} from '../src/core/stringCasing'; -describe("StringCasing", () => { - describe("detectCasing", () => { - it("should detect lowercase", async () => { - expect(detectCasing("hello world")).toBe(StringCasing.LOWER); - expect(detectCasing("test")).toBe(StringCasing.LOWER); +describe('StringCasing', () => { + describe('detectCasing', () => { + it('should detect lowercase', async () => { + expect(detectCasing('hello world')).toBe(StringCasing.LOWER); + expect(detectCasing('test')).toBe(StringCasing.LOWER); }); - it("should detect uppercase", async () => { - expect(detectCasing("HELLO WORLD")).toBe(StringCasing.UPPER); - expect(detectCasing("TEST")).toBe(StringCasing.UPPER); + it('should detect uppercase', async () => { + expect(detectCasing('HELLO WORLD')).toBe(StringCasing.UPPER); + expect(detectCasing('TEST')).toBe(StringCasing.UPPER); }); - it("should detect sentence case", async () => { - expect(detectCasing("Hello world")).toBe(StringCasing.SENTENCE); - expect(detectCasing("Test sentence")).toBe(StringCasing.SENTENCE); + it('should detect sentence case', async () => { + expect(detectCasing('Hello world')).toBe(StringCasing.SENTENCE); + expect(detectCasing('Test sentence')).toBe(StringCasing.SENTENCE); }); - it("should detect title case", async () => { - expect(detectCasing("Hello World")).toBe(StringCasing.TITLE); - expect(detectCasing("Test Title Case")).toBe(StringCasing.TITLE); + it('should detect title case', async () => { + expect(detectCasing('Hello World')).toBe(StringCasing.TITLE); + expect(detectCasing('Test Title Case')).toBe(StringCasing.TITLE); }); - it("should detect camelCase", async () => { - expect(detectCasing("helloWorld")).toBe(StringCasing.CAMEL); - expect(detectCasing("testCamelCase")).toBe(StringCasing.CAMEL); + it('should detect camelCase', async () => { + expect(detectCasing('helloWorld')).toBe(StringCasing.CAMEL); + expect(detectCasing('testCamelCase')).toBe(StringCasing.CAMEL); }); - it("should detect PascalCase", async () => { - expect(detectCasing("HelloWorld")).toBe(StringCasing.PASCAL); - expect(detectCasing("TestPascalCase")).toBe(StringCasing.PASCAL); + it('should detect PascalCase', async () => { + expect(detectCasing('HelloWorld')).toBe(StringCasing.PASCAL); + expect(detectCasing('TestPascalCase')).toBe(StringCasing.PASCAL); }); - it("should detect snake_case", async () => { - expect(detectCasing("hello_world")).toBe(StringCasing.SNAKE); - expect(detectCasing("test_snake_case")).toBe(StringCasing.SNAKE); + it('should detect snake_case', async () => { + expect(detectCasing('hello_world')).toBe(StringCasing.SNAKE); + expect(detectCasing('test_snake_case')).toBe(StringCasing.SNAKE); }); - it("should detect CONSTANT_CASE", async () => { - expect(detectCasing("HELLO_WORLD")).toBe(StringCasing.CONSTANT); - expect(detectCasing("TEST_CONSTANT_CASE")).toBe(StringCasing.CONSTANT); + it('should detect CONSTANT_CASE', async () => { + expect(detectCasing('HELLO_WORLD')).toBe(StringCasing.CONSTANT); + expect(detectCasing('TEST_CONSTANT_CASE')).toBe(StringCasing.CONSTANT); }); - it("should detect kebab-case", async () => { - expect(detectCasing("hello-world")).toBe(StringCasing.KEBAB); - expect(detectCasing("test-kebab-case")).toBe(StringCasing.KEBAB); + it('should detect kebab-case', async () => { + expect(detectCasing('hello-world')).toBe(StringCasing.KEBAB); + expect(detectCasing('test-kebab-case')).toBe(StringCasing.KEBAB); }); - it("should detect Header-Case", async () => { - expect(detectCasing("Hello-World")).toBe(StringCasing.HEADER); - expect(detectCasing("Test-Header-Case")).toBe(StringCasing.HEADER); + it('should detect Header-Case', async () => { + expect(detectCasing('Hello-World')).toBe(StringCasing.HEADER); + expect(detectCasing('Test-Header-Case')).toBe(StringCasing.HEADER); }); - it("should handle edge cases", async () => { - expect(detectCasing("")).toBe(StringCasing.LOWER); - expect(detectCasing(" ")).toBe(StringCasing.LOWER); - expect(detectCasing("A")).toBe(StringCasing.CAPITAL); - expect(detectCasing("a")).toBe(StringCasing.LOWER); + it('should handle edge cases', async () => { + expect(detectCasing('')).toBe(StringCasing.LOWER); + expect(detectCasing(' ')).toBe(StringCasing.LOWER); + expect(detectCasing('A')).toBe(StringCasing.CAPITAL); + expect(detectCasing('a')).toBe(StringCasing.LOWER); }); }); - describe("convertCasing", () => { - const testText = "Hello World Test"; + describe('convertCasing', () => { + const testText = 'Hello World Test'; - it("should convert to lowercase", async () => { - expect(convertCasing(testText, StringCasing.LOWER)).toBe( - "hello world test", - ); + it('should convert to lowercase', async () => { + expect(convertCasing(testText, StringCasing.LOWER)).toBe('hello world test'); }); - it("should convert to uppercase", async () => { - expect(convertCasing(testText, StringCasing.UPPER)).toBe( - "HELLO WORLD TEST", - ); + it('should convert to uppercase', async () => { + expect(convertCasing(testText, StringCasing.UPPER)).toBe('HELLO WORLD TEST'); }); - it("should convert to sentence case", async () => { - expect(convertCasing(testText, StringCasing.SENTENCE)).toBe( - "Hello world test", - ); + it('should convert to sentence case', async () => { + expect(convertCasing(testText, StringCasing.SENTENCE)).toBe('Hello world test'); }); - it("should convert to title case", async () => { - expect(convertCasing(testText, StringCasing.TITLE)).toBe( - "Hello World Test", - ); + it('should convert to title case', async () => { + expect(convertCasing(testText, StringCasing.TITLE)).toBe('Hello World Test'); }); - it("should convert to camelCase", async () => { - expect(convertCasing(testText, StringCasing.CAMEL)).toBe( - "helloWorldTest", - ); + it('should convert to camelCase', async () => { + expect(convertCasing(testText, StringCasing.CAMEL)).toBe('helloWorldTest'); }); - it("should convert to PascalCase", async () => { - expect(convertCasing(testText, StringCasing.PASCAL)).toBe( - "HelloWorldTest", - ); + it('should convert to PascalCase', async () => { + expect(convertCasing(testText, StringCasing.PASCAL)).toBe('HelloWorldTest'); }); - it("should convert to snake_case", async () => { - expect(convertCasing(testText, StringCasing.SNAKE)).toBe( - "hello_world_test", - ); + it('should convert to snake_case', async () => { + expect(convertCasing(testText, StringCasing.SNAKE)).toBe('hello_world_test'); }); - it("should convert to CONSTANT_CASE", async () => { - expect(convertCasing(testText, StringCasing.CONSTANT)).toBe( - "HELLO_WORLD_TEST", - ); + it('should convert to CONSTANT_CASE', async () => { + expect(convertCasing(testText, StringCasing.CONSTANT)).toBe('HELLO_WORLD_TEST'); }); - it("should convert to kebab-case", async () => { - expect(convertCasing(testText, StringCasing.KEBAB)).toBe( - "hello-world-test", - ); + it('should convert to kebab-case', async () => { + expect(convertCasing(testText, StringCasing.KEBAB)).toBe('hello-world-test'); }); - it("should convert to Header-Case", async () => { - expect(convertCasing(testText, StringCasing.HEADER)).toBe( - "Hello-World-Test", - ); + it('should convert to Header-Case', async () => { + expect(convertCasing(testText, StringCasing.HEADER)).toBe('Hello-World-Test'); }); - it("should handle empty strings", async () => { - expect(convertCasing("", StringCasing.UPPER)).toBe(""); - expect(convertCasing(" ", StringCasing.LOWER)).toBe(" "); + it('should handle empty strings', async () => { + expect(convertCasing('', StringCasing.UPPER)).toBe(''); + expect(convertCasing(' ', StringCasing.LOWER)).toBe(' '); }); }); - describe("isNumericOrEmpty", () => { - it("should identify numeric strings", async () => { - expect(isNumericOrEmpty("123")).toBe(true); - expect(isNumericOrEmpty("0")).toBe(true); - expect(isNumericOrEmpty("-5")).toBe(true); + describe('isNumericOrEmpty', () => { + it('should identify numeric strings', async () => { + expect(isNumericOrEmpty('123')).toBe(true); + expect(isNumericOrEmpty('0')).toBe(true); + expect(isNumericOrEmpty('-5')).toBe(true); }); - it("should identify empty or short strings", async () => { - expect(isNumericOrEmpty("")).toBe(true); - expect(isNumericOrEmpty(" ")).toBe(true); - expect(isNumericOrEmpty("a")).toBe(true); + it('should identify empty or short strings', async () => { + expect(isNumericOrEmpty('')).toBe(true); + expect(isNumericOrEmpty(' ')).toBe(true); + expect(isNumericOrEmpty('a')).toBe(true); }); - it("should identify meaningful text", async () => { - expect(isNumericOrEmpty("hello")).toBe(false); - expect(isNumericOrEmpty("test word")).toBe(false); - expect(isNumericOrEmpty("abc")).toBe(false); + it('should identify meaningful text', async () => { + expect(isNumericOrEmpty('hello')).toBe(false); + expect(isNumericOrEmpty('test word')).toBe(false); + expect(isNumericOrEmpty('abc')).toBe(false); }); - it("should handle mixed content", async () => { - expect(isNumericOrEmpty("123abc")).toBe(false); - expect(isNumericOrEmpty("hello123")).toBe(false); + it('should handle mixed content', async () => { + expect(isNumericOrEmpty('123abc')).toBe(false); + expect(isNumericOrEmpty('hello123')).toBe(false); }); }); }); diff --git a/test/styling.test.ts b/test/styling.test.ts index 6abd683..a83bdcb 100644 --- a/test/styling.test.ts +++ b/test/styling.test.ts @@ -1,20 +1,20 @@ -import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; -import fs from "fs"; -import path from "path"; -import os from "os"; -import { ObfProcessor } from "../src/processors/obfProcessor"; -import { SnapProcessor } from "../src/processors/snapProcessor"; -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; -import { AstericsGridProcessor } from "../src/processors/astericsGridProcessor"; -import { GridsetProcessor } from "../src/processors/gridsetProcessor"; -import { ApplePanelsProcessor } from "../src/processors/applePanelsProcessor"; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; - -describe("Styling Support Tests", () => { +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { SnapProcessor } from '../src/processors/snapProcessor'; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import { AstericsGridProcessor } from '../src/processors/astericsGridProcessor'; +import { GridsetProcessor } from '../src/processors/gridsetProcessor'; +import { ApplePanelsProcessor } from '../src/processors/applePanelsProcessor'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; + +describe('Styling Support Tests', () => { let tempDir: string; beforeEach(async () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "styling-test-")); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'styling-test-')); }); afterEach(async () => { @@ -28,34 +28,34 @@ describe("Styling Support Tests", () => { const tree = new AACTree(); const page = new AACPage({ - id: "test-page-1", - name: "Test Page", + id: 'test-page-1', + name: 'Test Page', grid: [], buttons: [], parentId: null, style: { - backgroundColor: "#f0f0f0", - borderColor: "#cccccc", - fontFamily: "Arial", + backgroundColor: '#f0f0f0', + borderColor: '#cccccc', + fontFamily: 'Arial', fontSize: 16, }, }); const button1 = new AACButton({ - id: "btn-1", - label: "Hello", - message: "Hello World", - type: "SPEAK", + id: 'btn-1', + label: 'Hello', + message: 'Hello World', + type: 'SPEAK', action: null, style: { - backgroundColor: "#ff0000", - fontColor: "#ffffff", - borderColor: "#990000", + backgroundColor: '#ff0000', + fontColor: '#ffffff', + borderColor: '#990000', borderWidth: 2, fontSize: 18, - fontFamily: "Helvetica", - fontWeight: "bold", - fontStyle: "normal", + fontFamily: 'Helvetica', + fontWeight: 'bold', + fontStyle: 'normal', textUnderline: false, labelOnTop: true, transparent: false, @@ -63,24 +63,24 @@ describe("Styling Support Tests", () => { }); const button2 = new AACButton({ - id: "btn-2", - label: "Navigate", - message: "Go to page 2", - type: "NAVIGATE", - targetPageId: "test-page-2", + id: 'btn-2', + label: 'Navigate', + message: 'Go to page 2', + type: 'NAVIGATE', + targetPageId: 'test-page-2', action: { - type: "NAVIGATE", - targetPageId: "test-page-2", + type: 'NAVIGATE', + targetPageId: 'test-page-2', }, style: { - backgroundColor: "#00ff00", - fontColor: "#000000", - borderColor: "#009900", + backgroundColor: '#00ff00', + fontColor: '#000000', + borderColor: '#009900', borderWidth: 1, fontSize: 14, - fontFamily: "Times", - fontWeight: "normal", - fontStyle: "italic", + fontFamily: 'Times', + fontWeight: 'normal', + fontStyle: 'italic', textUnderline: true, labelOnTop: false, transparent: true, @@ -94,11 +94,11 @@ describe("Styling Support Tests", () => { return tree; }; - describe("OBF Processor Styling", () => { - it("should preserve background and border colors in round-trip", async () => { + describe('OBF Processor Styling', () => { + it('should preserve background and border colors in round-trip', async () => { const processor = new ObfProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, "test.obf"); + const outputPath = path.join(tempDir, 'test.obf'); // Save tree to OBF await processor.saveFromTree(tree, outputPath); @@ -110,16 +110,16 @@ describe("Styling Support Tests", () => { const loadedButton = loadedPage.buttons[0]; // Verify styling is preserved - expect(loadedButton.style?.backgroundColor).toBe("#ff0000"); - expect(loadedButton.style?.borderColor).toBe("#990000"); + expect(loadedButton.style?.backgroundColor).toBe('#ff0000'); + expect(loadedButton.style?.borderColor).toBe('#990000'); }); }); - describe("Snap Processor Styling", () => { - it("should preserve comprehensive styling in round-trip", async () => { + describe('Snap Processor Styling', () => { + it('should preserve comprehensive styling in round-trip', async () => { const processor = new SnapProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, "test.spb"); + const outputPath = path.join(tempDir, 'test.spb'); // Save tree to Snap await processor.saveFromTree(tree, outputPath); @@ -131,21 +131,21 @@ describe("Styling Support Tests", () => { const loadedButton = loadedPage.buttons[0]; // Verify comprehensive styling is preserved - expect(loadedButton.style?.backgroundColor).toBe("#ff0000"); - expect(loadedButton.style?.fontColor).toBe("#ffffff"); - expect(loadedButton.style?.borderColor).toBe("#990000"); + expect(loadedButton.style?.backgroundColor).toBe('#ff0000'); + expect(loadedButton.style?.fontColor).toBe('#ffffff'); + expect(loadedButton.style?.borderColor).toBe('#990000'); expect(loadedButton.style?.borderWidth).toBe(2); expect(loadedButton.style?.fontSize).toBe(18); - expect(loadedButton.style?.fontFamily).toBe("Helvetica"); - expect(loadedPage.style?.backgroundColor).toBe("#f0f0f0"); + expect(loadedButton.style?.fontFamily).toBe('Helvetica'); + expect(loadedPage.style?.backgroundColor).toBe('#f0f0f0'); }); }); - describe("TouchChat Processor Styling", () => { - it("should preserve button and page styles in round-trip", async () => { + describe('TouchChat Processor Styling', () => { + it('should preserve button and page styles in round-trip', async () => { const processor = new TouchChatProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, "test.ce"); + const outputPath = path.join(tempDir, 'test.ce'); // Save tree to TouchChat await processor.saveFromTree(tree, outputPath); @@ -166,11 +166,11 @@ describe("Styling Support Tests", () => { }); }); - describe("Asterics Grid Processor Styling", () => { - it("should preserve background colors and metadata styling", async () => { + describe('Asterics Grid Processor Styling', () => { + it('should preserve background colors and metadata styling', async () => { const processor = new AstericsGridProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, "test.grd"); + const outputPath = path.join(tempDir, 'test.grd'); // Save tree to Asterics Grid await processor.saveFromTree(tree, outputPath); @@ -187,11 +187,11 @@ describe("Styling Support Tests", () => { }); }); - describe("Grid 3 Processor Styling", () => { - it("should create and reference styles correctly", async () => { + describe('Grid 3 Processor Styling', () => { + it('should create and reference styles correctly', async () => { const processor = new GridsetProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, "test.gridset"); + const outputPath = path.join(tempDir, 'test.gridset'); // Save tree to Grid 3 await processor.saveFromTree(tree, outputPath); @@ -199,23 +199,22 @@ describe("Styling Support Tests", () => { // Verify the zip contains style.xml // eslint-disable-next-line @typescript-eslint/no-var-requires - const AdmZip = require("adm-zip"); + const AdmZip = require('adm-zip'); const zip = new AdmZip(outputPath); const entries = zip.getEntries(); const hasStyleXml = entries.some( (entry: any) => - entry.entryName.endsWith("styles.xml") || - entry.entryName.endsWith("style.xml"), + entry.entryName.endsWith('styles.xml') || entry.entryName.endsWith('style.xml') ); expect(hasStyleXml).toBe(true); }); }); - describe("Apple Panels Processor Styling", () => { - it("should preserve DisplayColor, FontSize, and DisplayImageWeight", async () => { + describe('Apple Panels Processor Styling', () => { + it('should preserve DisplayColor, FontSize, and DisplayImageWeight', async () => { const processor = new ApplePanelsProcessor(); const tree = createStyledTestTree(); - const outputPath = path.join(tempDir, "test.ascconfig"); + const outputPath = path.join(tempDir, 'test.ascconfig'); // Save tree to Apple Panels await processor.saveFromTree(tree, outputPath); @@ -233,19 +232,19 @@ describe("Styling Support Tests", () => { }); }); - describe("Cross-Format Styling Compatibility", () => { - it("should maintain basic styling when converting between formats", async () => { + describe('Cross-Format Styling Compatibility', () => { + it('should maintain basic styling when converting between formats', async () => { const obfProcessor = new ObfProcessor(); const snapProcessor = new SnapProcessor(); const tree = createStyledTestTree(); // Save as OBF - const obfPath = path.join(tempDir, "test.obf"); + const obfPath = path.join(tempDir, 'test.obf'); await obfProcessor.saveFromTree(tree, obfPath); // Load from OBF and save as Snap const loadedFromObf = await obfProcessor.loadIntoTree(obfPath); - const snapPath = path.join(tempDir, "test.spb"); + const snapPath = path.join(tempDir, 'test.spb'); await snapProcessor.saveFromTree(loadedFromObf, snapPath); // Load from Snap and verify styling is maintained diff --git a/test/suggestWordsEffort.test.ts b/test/suggestWordsEffort.test.ts index dad0f4b..f3a8934 100644 --- a/test/suggestWordsEffort.test.ts +++ b/test/suggestWordsEffort.test.ts @@ -1,147 +1,121 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { describe, expect, it } from "@jest/globals"; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; -import { MetricsCalculator } from "../src/utilities/analytics/metrics/core"; -import { - EFFORT_CONSTANTS, - visualScanEffort, -} from "../src/utilities/analytics/metrics/effort"; +import { describe, expect, it } from '@jest/globals'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; +import { MetricsCalculator } from '../src/utilities/analytics/metrics/core'; +import { EFFORT_CONSTANTS, visualScanEffort } from '../src/utilities/analytics/metrics/effort'; function buildTreeWithPredictions( predictions: string[], parametersPredictions?: string[], - pos?: string, + pos?: string ): { tree: AACTree; btnId: string } { const tree = new AACTree(); const page = new AACPage({ - id: "root", - name: "Home", + id: 'root', + name: 'Home', grid: { columns: 2, rows: 2 }, }); const btn = new AACButton({ - id: "some_btn", - label: "some", - type: "SPEAK", + id: 'some_btn', + label: 'some', + type: 'SPEAK', x: 0, y: 0, predictions, pos, - parameters: parametersPredictions - ? { predictions: parametersPredictions } - : undefined, + parameters: parametersPredictions ? { predictions: parametersPredictions } : undefined, }); page.grid[0][0] = btn; page.addButton(btn); tree.addPage(page); - tree.rootId = "root"; + tree.rootId = 'root'; return { tree, btnId: btn.id }; } -describe("Suggest Words effort cost", () => { - it("adds confirmation cost to Suggest Words word forms", () => { - const suggestWords = ["something", "someone", "somewhere"]; +describe('Suggest Words effort cost', () => { + it('adds confirmation cost to Suggest Words word forms', () => { + const suggestWords = ['something', 'someone', 'somewhere']; const { tree } = buildTreeWithPredictions(suggestWords, suggestWords); const calculator = new MetricsCalculator(); const result = calculator.analyze(tree, { useSmartGrammar: true }); - const parentBtn = result.buttons.find((b) => b.label === "some"); + const parentBtn = result.buttons.find((b) => b.label === 'some'); expect(parentBtn).toBeDefined(); - const something = result.buttons.find((b) => b.label === "something"); + const something = result.buttons.find((b) => b.label === 'something'); expect(something).toBeDefined(); expect(something!.is_word_form).toBe(true); expect(something!.is_suggest_words).toBe(true); const expectedEffort = - parentBtn!.effort + - visualScanEffort(0) + - EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT; + parentBtn!.effort + visualScanEffort(0) + EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT; expect(something!.effort).toBeCloseTo(expectedEffort, 4); }); - it("does not add confirmation cost to morphology word forms", () => { - const predictions = ["goes", "going", "went"]; - const { tree } = buildTreeWithPredictions(predictions, undefined, "Verb"); + it('does not add confirmation cost to morphology word forms', () => { + const predictions = ['goes', 'going', 'went']; + const { tree } = buildTreeWithPredictions(predictions, undefined, 'Verb'); const calculator = new MetricsCalculator(); const result = calculator.analyze(tree, { useSmartGrammar: true }); - const parentBtn = result.buttons.find((b) => b.label === "some"); + const parentBtn = result.buttons.find((b) => b.label === 'some'); expect(parentBtn).toBeDefined(); - const goes = result.buttons.find((b) => b.label === "goes"); + const goes = result.buttons.find((b) => b.label === 'goes'); expect(goes).toBeDefined(); expect(goes!.is_word_form).toBe(true); expect(goes!.is_suggest_words).toBeUndefined(); - expect(goes!.effort).toBeCloseTo( - parentBtn!.effort + visualScanEffort(0), - 4, - ); + expect(goes!.effort).toBeCloseTo(parentBtn!.effort + visualScanEffort(0), 4); }); - it("only adds confirmation to Suggest Words forms when predictions are mixed", () => { - const suggestWordsOriginals = ["something", "someone"]; - const allPredictions = ["something", "someone", "somes"]; - const { tree } = buildTreeWithPredictions( - allPredictions, - suggestWordsOriginals, - "Noun", - ); + it('only adds confirmation to Suggest Words forms when predictions are mixed', () => { + const suggestWordsOriginals = ['something', 'someone']; + const allPredictions = ['something', 'someone', 'somes']; + const { tree } = buildTreeWithPredictions(allPredictions, suggestWordsOriginals, 'Noun'); const calculator = new MetricsCalculator(); const result = calculator.analyze(tree, { useSmartGrammar: true }); - const parentBtn = result.buttons.find((b) => b.label === "some"); + const parentBtn = result.buttons.find((b) => b.label === 'some'); - const something = result.buttons.find((b) => b.label === "something"); + const something = result.buttons.find((b) => b.label === 'something'); expect(something).toBeDefined(); expect(something!.is_suggest_words).toBe(true); expect(something!.effort).toBeCloseTo( - parentBtn!.effort + - visualScanEffort(0) + - EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT, - 4, + parentBtn!.effort + visualScanEffort(0) + EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT, + 4 ); // "somes" is at index 2 → predictionPriorItems = 2 - const somes = result.buttons.find((b) => b.label === "somes"); + const somes = result.buttons.find((b) => b.label === 'somes'); expect(somes).toBeDefined(); expect(somes!.is_suggest_words).toBeUndefined(); - expect(somes!.effort).toBeCloseTo( - parentBtn!.effort + visualScanEffort(2), - 4, - ); + expect(somes!.effort).toBeCloseTo(parentBtn!.effort + visualScanEffort(2), 4); }); - it("has no confirmation when parameters.predictions is absent", () => { - const predictions = ["something", "someone"]; - const { tree } = buildTreeWithPredictions(predictions, undefined, "Noun"); + it('has no confirmation when parameters.predictions is absent', () => { + const predictions = ['something', 'someone']; + const { tree } = buildTreeWithPredictions(predictions, undefined, 'Noun'); const calculator = new MetricsCalculator(); const result = calculator.analyze(tree, { useSmartGrammar: true }); - const parentBtn = result.buttons.find((b) => b.label === "some"); + const parentBtn = result.buttons.find((b) => b.label === 'some'); - const something = result.buttons.find((b) => b.label === "something"); + const something = result.buttons.find((b) => b.label === 'something'); expect(something).toBeDefined(); expect(something!.is_suggest_words).toBeUndefined(); - expect(something!.effort).toBeCloseTo( - parentBtn!.effort + visualScanEffort(0), - 4, - ); + expect(something!.effort).toBeCloseTo(parentBtn!.effort + visualScanEffort(0), 4); }); - it("SUGGEST_WORDS_SELECTION_EFFORT is between 0.5 and 1.0", () => { - expect( - EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT, - ).toBeGreaterThanOrEqual(0.5); - expect(EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT).toBeLessThanOrEqual( - 1.0, - ); + it('SUGGEST_WORDS_SELECTION_EFFORT is between 0.5 and 1.0', () => { + expect(EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT).toBeGreaterThanOrEqual(0.5); + expect(EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT).toBeLessThanOrEqual(1.0); }); }); diff --git a/test/symbolAlignment.test.ts b/test/symbolAlignment.test.ts index c79898a..87a584c 100644 --- a/test/symbolAlignment.test.ts +++ b/test/symbolAlignment.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "@jest/globals"; +import { describe, it, expect } from '@jest/globals'; import { parseMessageWithSymbols, alignWords, @@ -6,148 +6,137 @@ import { translateWithSymbols, extractSymbolsFromButton, type ParsedMessage, -} from "../src/processors/gridset/symbolAlignment"; +} from '../src/processors/gridset/symbolAlignment'; -describe("Symbol Alignment Utilities", () => { - describe("parseMessageWithSymbols", () => { - it("should parse plain text without symbols", async () => { - const result = parseMessageWithSymbols("Hello world"); +describe('Symbol Alignment Utilities', () => { + describe('parseMessageWithSymbols', () => { + it('should parse plain text without symbols', async () => { + const result = parseMessageWithSymbols('Hello world'); - expect(result.text).toBe("Hello world"); - expect(result.words).toEqual(["Hello", "world"]); + expect(result.text).toBe('Hello world'); + expect(result.words).toEqual(['Hello', 'world']); expect(result.symbols).toEqual([]); }); - it("should parse text with richText symbols attached", async () => { + it('should parse text with richText symbols attached', async () => { const richTextSymbols = [ - { text: "apple", image: "[widgit]/food/apple.png" }, - { text: "juice", image: "[widgit]/food/juice.png" }, + { text: 'apple', image: '[widgit]/food/apple.png' }, + { text: 'juice', image: '[widgit]/food/juice.png' }, ]; - const result = parseMessageWithSymbols( - "I want apple juice", - richTextSymbols, - ); + const result = parseMessageWithSymbols('I want apple juice', richTextSymbols); - expect(result.text).toBe("I want apple juice"); - expect(result.words).toEqual(["I", "want", "apple", "juice"]); + expect(result.text).toBe('I want apple juice'); + expect(result.words).toEqual(['I', 'want', 'apple', 'juice']); expect(result.symbols).toHaveLength(2); // Check apple symbol - const appleSymbol = result.symbols.find( - (s) => s.originalWord === "apple", - ); + const appleSymbol = result.symbols.find((s) => s.originalWord === 'apple'); expect(appleSymbol).toBeDefined(); expect(appleSymbol?.wordIndex).toBe(2); - expect(appleSymbol?.symbolRef).toBe("[widgit]/food/apple.png"); + expect(appleSymbol?.symbolRef).toBe('[widgit]/food/apple.png'); // Check juice symbol - const juiceSymbol = result.symbols.find( - (s) => s.originalWord === "juice", - ); + const juiceSymbol = result.symbols.find((s) => s.originalWord === 'juice'); expect(juiceSymbol).toBeDefined(); expect(juiceSymbol?.wordIndex).toBe(3); - expect(juiceSymbol?.symbolRef).toBe("[widgit]/food/juice.png"); + expect(juiceSymbol?.symbolRef).toBe('[widgit]/food/juice.png'); }); - it("should handle fuzzy matching for case differences", async () => { - const richTextSymbols = [ - { text: "Apple", image: "[widgit]/food/apple.png" }, - ]; + it('should handle fuzzy matching for case differences', async () => { + const richTextSymbols = [{ text: 'Apple', image: '[widgit]/food/apple.png' }]; - const result = parseMessageWithSymbols("I want apple", richTextSymbols); + const result = parseMessageWithSymbols('I want apple', richTextSymbols); expect(result.symbols).toHaveLength(1); - expect(result.symbols[0].originalWord).toBe("apple"); + expect(result.symbols[0].originalWord).toBe('apple'); expect(result.symbols[0].wordIndex).toBe(2); }); - it("should normalize whitespace", async () => { - const result = parseMessageWithSymbols("I want apple"); + it('should normalize whitespace', async () => { + const result = parseMessageWithSymbols('I want apple'); - expect(result.text).toBe("I want apple"); - expect(result.words).toEqual(["I", "want", "apple"]); + expect(result.text).toBe('I want apple'); + expect(result.words).toEqual(['I', 'want', 'apple']); }); - it("should handle empty message", async () => { - const result = parseMessageWithSymbols(""); + it('should handle empty message', async () => { + const result = parseMessageWithSymbols(''); - expect(result.text).toBe(""); + expect(result.text).toBe(''); expect(result.words).toEqual([]); expect(result.symbols).toEqual([]); }); }); - describe("alignWords", () => { - it("should align identical words (cognates)", async () => { - const originalWords = ["I", "want", "apple", "juice"]; - const translatedWords = ["Yo", "quiero", "apple", "jugo"]; + describe('alignWords', () => { + it('should align identical words (cognates)', async () => { + const originalWords = ['I', 'want', 'apple', 'juice']; + const translatedWords = ['Yo', 'quiero', 'apple', 'jugo']; const alignment = alignWords(originalWords, translatedWords); expect(alignment).toHaveLength(4); // Check apple alignment (identical word) - const appleAlignment = alignment.find((a) => a.originalWord === "apple"); + const appleAlignment = alignment.find((a) => a.originalWord === 'apple'); expect(appleAlignment).toBeDefined(); - expect(appleAlignment?.translatedWord).toBe("apple"); + expect(appleAlignment?.translatedWord).toBe('apple'); expect(appleAlignment?.originalIndex).toBe(2); expect(appleAlignment?.translatedIndex).toBe(2); }); - it("should use positional alignment for non-matching words", async () => { - const originalWords = ["I", "want", "apple"]; - const translatedWords = ["Yo", "quiero", "manzana"]; + it('should use positional alignment for non-matching words', async () => { + const originalWords = ['I', 'want', 'apple']; + const translatedWords = ['Yo', 'quiero', 'manzana']; const alignment = alignWords(originalWords, translatedWords); expect(alignment).toHaveLength(3); // First word should align with first word - expect(alignment[0].originalWord).toBe("I"); - expect(alignment[0].translatedWord).toBe("Yo"); + expect(alignment[0].originalWord).toBe('I'); + expect(alignment[0].translatedWord).toBe('Yo'); // Last word should align with last word - expect(alignment[2].originalWord).toBe("apple"); - expect(alignment[2].translatedWord).toBe("manzana"); + expect(alignment[2].originalWord).toBe('apple'); + expect(alignment[2].translatedWord).toBe('manzana'); }); - it("should handle different length sentences", async () => { - const originalWords = ["Hello", "world"]; - const translatedWords = ["Hola", "mundo", "amigo"]; + it('should handle different length sentences', async () => { + const originalWords = ['Hello', 'world']; + const translatedWords = ['Hola', 'mundo', 'amigo']; const alignment = alignWords(originalWords, translatedWords); expect(alignment.length).toBeGreaterThan(0); // All original words should be aligned - expect( - alignment.filter((a) => a.originalIndex !== undefined), - ).toHaveLength(2); + expect(alignment.filter((a) => a.originalIndex !== undefined)).toHaveLength(2); }); - it("should handle numbers and punctuation", async () => { - const originalWords = ["I", "want", "2", "apples"]; - const translatedWords = ["Quiero", "2", "manzanas"]; + it('should handle numbers and punctuation', async () => { + const originalWords = ['I', 'want', '2', 'apples']; + const translatedWords = ['Quiero', '2', 'manzanas']; const alignment = alignWords(originalWords, translatedWords); // Number '2' should align exactly - const numberAlignment = alignment.find((a) => a.originalWord === "2"); + const numberAlignment = alignment.find((a) => a.originalWord === '2'); expect(numberAlignment).toBeDefined(); - expect(numberAlignment?.translatedWord).toBe("2"); + expect(numberAlignment?.translatedWord).toBe('2'); }); }); - describe("reattachSymbols", () => { - it("should reattach symbols to translated words based on alignment", async () => { + describe('reattachSymbols', () => { + it('should reattach symbols to translated words based on alignment', async () => { const originalParsed: ParsedMessage = { - text: "I want apple", - words: ["I", "want", "apple"], + text: 'I want apple', + words: ['I', 'want', 'apple'], symbols: [ { - symbolRef: "[widgit]/food/apple.png", + symbolRef: '[widgit]/food/apple.png', wordIndex: 2, - originalWord: "apple", + originalWord: 'apple', startPos: 7, endPos: 12, }, @@ -156,53 +145,49 @@ describe("Symbol Alignment Utilities", () => { const alignment = [ { - originalWord: "I", - translatedWord: "Yo", + originalWord: 'I', + translatedWord: 'Yo', originalIndex: 0, translatedIndex: 0, }, { - originalWord: "want", - translatedWord: "quiero", + originalWord: 'want', + translatedWord: 'quiero', originalIndex: 1, translatedIndex: 1, }, { - originalWord: "apple", - translatedWord: "manzana", + originalWord: 'apple', + translatedWord: 'manzana', originalIndex: 2, translatedIndex: 2, }, ]; - const result = reattachSymbols( - "Yo quiero manzana", - originalParsed, - alignment, - ); + const result = reattachSymbols('Yo quiero manzana', originalParsed, alignment); - expect(result.text).toBe("Yo quiero manzana"); + expect(result.text).toBe('Yo quiero manzana'); expect(result.richTextSymbols).toHaveLength(1); - expect(result.richTextSymbols[0].text).toBe("manzana"); - expect(result.richTextSymbols[0].image).toBe("[widgit]/food/apple.png"); + expect(result.richTextSymbols[0].text).toBe('manzana'); + expect(result.richTextSymbols[0].image).toBe('[widgit]/food/apple.png'); }); - it("should handle multiple symbols", async () => { + it('should handle multiple symbols', async () => { const originalParsed: ParsedMessage = { - text: "I want apple juice", - words: ["I", "want", "apple", "juice"], + text: 'I want apple juice', + words: ['I', 'want', 'apple', 'juice'], symbols: [ { - symbolRef: "[widgit]/food/apple.png", + symbolRef: '[widgit]/food/apple.png', wordIndex: 2, - originalWord: "apple", + originalWord: 'apple', startPos: 7, endPos: 12, }, { - symbolRef: "[widgit]/food/juice.png", + symbolRef: '[widgit]/food/juice.png', wordIndex: 3, - originalWord: "juice", + originalWord: 'juice', startPos: 13, endPos: 18, }, @@ -211,51 +196,47 @@ describe("Symbol Alignment Utilities", () => { const alignment = [ { - originalWord: "I", - translatedWord: "Yo", + originalWord: 'I', + translatedWord: 'Yo', originalIndex: 0, translatedIndex: 0, }, { - originalWord: "want", - translatedWord: "quiero", + originalWord: 'want', + translatedWord: 'quiero', originalIndex: 1, translatedIndex: 1, }, { - originalWord: "apple", - translatedWord: "manzana", + originalWord: 'apple', + translatedWord: 'manzana', originalIndex: 2, translatedIndex: 2, }, { - originalWord: "juice", - translatedWord: "jugo", + originalWord: 'juice', + translatedWord: 'jugo', originalIndex: 3, translatedIndex: 3, }, ]; - const result = reattachSymbols( - "Yo quiero manzana jugo", - originalParsed, - alignment, - ); + const result = reattachSymbols('Yo quiero manzana jugo', originalParsed, alignment); expect(result.richTextSymbols).toHaveLength(2); - expect(result.richTextSymbols[0].text).toBe("manzana"); - expect(result.richTextSymbols[1].text).toBe("jugo"); + expect(result.richTextSymbols[0].text).toBe('manzana'); + expect(result.richTextSymbols[1].text).toBe('jugo'); }); - it("should fallback to original word if alignment not found", async () => { + it('should fallback to original word if alignment not found', async () => { const originalParsed: ParsedMessage = { - text: "I want apple", - words: ["I", "want", "apple"], + text: 'I want apple', + words: ['I', 'want', 'apple'], symbols: [ { - symbolRef: "[widgit]/food/apple.png", + symbolRef: '[widgit]/food/apple.png', wordIndex: 2, - originalWord: "apple", + originalWord: 'apple', startPos: 7, endPos: 12, }, @@ -264,163 +245,137 @@ describe("Symbol Alignment Utilities", () => { const alignment = [ { - originalWord: "I", - translatedWord: "Yo", + originalWord: 'I', + translatedWord: 'Yo', originalIndex: 0, translatedIndex: 0, }, { - originalWord: "want", - translatedWord: "quiero", + originalWord: 'want', + translatedWord: 'quiero', originalIndex: 1, translatedIndex: 1, }, // No alignment for 'apple' ]; - const result = reattachSymbols( - "Yo quiero fruta", - originalParsed, - alignment, - ); + const result = reattachSymbols('Yo quiero fruta', originalParsed, alignment); expect(result.richTextSymbols).toHaveLength(1); - expect(result.richTextSymbols[0].text).toBe("apple"); // Fallback to original - expect(result.richTextSymbols[0].image).toBe("[widgit]/food/apple.png"); + expect(result.richTextSymbols[0].text).toBe('apple'); // Fallback to original + expect(result.richTextSymbols[0].image).toBe('[widgit]/food/apple.png'); }); }); - describe("translateWithSymbols (integration)", () => { - it("should complete the full pipeline", async () => { - const originalMessage = "I want apple juice"; - const translatedText = "Yo quiero jugo de manzana"; + describe('translateWithSymbols (integration)', () => { + it('should complete the full pipeline', async () => { + const originalMessage = 'I want apple juice'; + const translatedText = 'Yo quiero jugo de manzana'; const richTextSymbols = [ - { text: "apple", image: "[widgit]/food/apple.png" }, - { text: "juice", image: "[widgit]/food/juice.png" }, + { text: 'apple', image: '[widgit]/food/apple.png' }, + { text: 'juice', image: '[widgit]/food/juice.png' }, ]; - const result = translateWithSymbols( - originalMessage, - translatedText, - richTextSymbols, - ); + const result = translateWithSymbols(originalMessage, translatedText, richTextSymbols); - expect(result.text).toBe("Yo quiero jugo de manzana"); + expect(result.text).toBe('Yo quiero jugo de manzana'); expect(result.richTextSymbols).toHaveLength(2); // Symbols should be reattached to translated words - const appleSymbol = result.richTextSymbols.find((s) => - s.image?.includes("apple"), - ); + const appleSymbol = result.richTextSymbols.find((s) => s.image?.includes('apple')); expect(appleSymbol).toBeDefined(); - expect(appleSymbol?.text).not.toBe("apple"); // Should be a translated word + expect(appleSymbol?.text).not.toBe('apple'); // Should be a translated word - const juiceSymbol = result.richTextSymbols.find((s) => - s.image?.includes("juice"), - ); + const juiceSymbol = result.richTextSymbols.find((s) => s.image?.includes('juice')); expect(juiceSymbol).toBeDefined(); }); - it("should handle messages without symbols", async () => { - const result = translateWithSymbols("Hello", "Hola"); + it('should handle messages without symbols', async () => { + const result = translateWithSymbols('Hello', 'Hola'); - expect(result.text).toBe("Hola"); + expect(result.text).toBe('Hola'); expect(result.richTextSymbols).toEqual([]); }); - it("should handle English to Spanish translation", async () => { - const originalMessage = "I want water"; - const translatedText = "Yo quiero agua"; - const richTextSymbols = [ - { text: "water", image: "[widgit]/food/water.png" }, - ]; + it('should handle English to Spanish translation', async () => { + const originalMessage = 'I want water'; + const translatedText = 'Yo quiero agua'; + const richTextSymbols = [{ text: 'water', image: '[widgit]/food/water.png' }]; - const result = translateWithSymbols( - originalMessage, - translatedText, - richTextSymbols, - ); + const result = translateWithSymbols(originalMessage, translatedText, richTextSymbols); - expect(result.text).toBe("Yo quiero agua"); + expect(result.text).toBe('Yo quiero agua'); expect(result.richTextSymbols).toHaveLength(1); - expect(result.richTextSymbols[0].image).toBe("[widgit]/food/water.png"); + expect(result.richTextSymbols[0].image).toBe('[widgit]/food/water.png'); // The symbol should be attached to 'agua' (the translation of 'water') expect(result.richTextSymbols[0].text).toBeTruthy(); }); - it("should handle symbol library references", async () => { - const originalMessage = "home"; - const translatedText = "casa"; - const richTextSymbols = [ - { text: "home", image: "[widgit]/places/home.png" }, - ]; + it('should handle symbol library references', async () => { + const originalMessage = 'home'; + const translatedText = 'casa'; + const richTextSymbols = [{ text: 'home', image: '[widgit]/places/home.png' }]; - const result = translateWithSymbols( - originalMessage, - translatedText, - richTextSymbols, - ); + const result = translateWithSymbols(originalMessage, translatedText, richTextSymbols); - expect(result.richTextSymbols[0].image).toBe("[widgit]/places/home.png"); + expect(result.richTextSymbols[0].image).toBe('[widgit]/places/home.png'); }); }); - describe("extractSymbolsFromButton", () => { - it("should extract symbols from semanticAction.richText.symbols", async () => { + describe('extractSymbolsFromButton', () => { + it('should extract symbols from semanticAction.richText.symbols', async () => { const button = { - label: "apple", - message: "I want apple", + label: 'apple', + message: 'I want apple', semanticAction: { richText: { - text: "I want apple", - symbols: [{ text: "apple", image: "[widgit]/food/apple.png" }], + text: 'I want apple', + symbols: [{ text: 'apple', image: '[widgit]/food/apple.png' }], }, }, }; const symbols = extractSymbolsFromButton(button); - expect(symbols).toEqual([ - { text: "apple", image: "[widgit]/food/apple.png" }, - ]); + expect(symbols).toEqual([{ text: 'apple', image: '[widgit]/food/apple.png' }]); }); - it("should extract symbols from symbolLibrary and symbolPath", async () => { + it('should extract symbols from symbolLibrary and symbolPath', async () => { const button = { - label: "apple", - message: "apple", - symbolLibrary: "widgit", - symbolPath: "/food/apple.png", + label: 'apple', + message: 'apple', + symbolLibrary: 'widgit', + symbolPath: '/food/apple.png', }; const symbols = extractSymbolsFromButton(button); expect(symbols).toBeDefined(); expect(symbols).toHaveLength(1); - expect(symbols?.[0].text).toBe("apple"); - expect(symbols?.[0].image).toBe("[widgit]/food/apple.png"); + expect(symbols?.[0].text).toBe('apple'); + expect(symbols?.[0].image).toBe('[widgit]/food/apple.png'); }); - it("should extract symbols from image field if it is a symbol reference", async () => { + it('should extract symbols from image field if it is a symbol reference', async () => { const button = { - label: "home", - message: "home", - image: "[widgit]/places/home.png", + label: 'home', + message: 'home', + image: '[widgit]/places/home.png', }; const symbols = extractSymbolsFromButton(button); expect(symbols).toBeDefined(); expect(symbols).toHaveLength(1); - expect(symbols?.[0].text).toBe("home"); - expect(symbols?.[0].image).toBe("[widgit]/places/home.png"); + expect(symbols?.[0].text).toBe('home'); + expect(symbols?.[0].image).toBe('[widgit]/places/home.png'); }); - it("should return undefined for regular image paths (not symbol references)", async () => { + it('should return undefined for regular image paths (not symbol references)', async () => { const button = { - label: "photo", - message: "photo", - image: "images/photo.png", + label: 'photo', + message: 'photo', + image: 'images/photo.png', }; const symbols = extractSymbolsFromButton(button); @@ -428,10 +383,10 @@ describe("Symbol Alignment Utilities", () => { expect(symbols).toBeUndefined(); }); - it("should return undefined for buttons without symbols", async () => { + it('should return undefined for buttons without symbols', async () => { const button = { - label: "hello", - message: "hello", + label: 'hello', + message: 'hello', }; const symbols = extractSymbolsFromButton(button); @@ -439,10 +394,10 @@ describe("Symbol Alignment Utilities", () => { expect(symbols).toBeUndefined(); }); - it("should handle empty label and message", async () => { + it('should handle empty label and message', async () => { const button = { - symbolLibrary: "widgit", - symbolPath: "/food/apple.png", + symbolLibrary: 'widgit', + symbolPath: '/food/apple.png', }; const symbols = extractSymbolsFromButton(button); @@ -451,76 +406,58 @@ describe("Symbol Alignment Utilities", () => { }); }); - describe("Real-world scenarios", () => { - it("should handle AAC gridset button translation", async () => { + describe('Real-world scenarios', () => { + it('should handle AAC gridset button translation', async () => { // Simulate a real AAC button with a symbol const button = { - id: "btn1", - label: "apple", - message: "I want apple", + id: 'btn1', + label: 'apple', + message: 'I want apple', semanticAction: { richText: { - text: "I want apple", - symbols: [{ text: "apple", image: "[widgit]/food/apple.png" }], + text: 'I want apple', + symbols: [{ text: 'apple', image: '[widgit]/food/apple.png' }], }, }, }; const originalMessage = button.message; - const translatedText = "Yo quiero manzana"; + const translatedText = 'Yo quiero manzana'; const symbols = extractSymbolsFromButton(button); - const result = translateWithSymbols( - originalMessage, - translatedText, - symbols, - ); + const result = translateWithSymbols(originalMessage, translatedText, symbols); - expect(result.text).toBe("Yo quiero manzana"); + expect(result.text).toBe('Yo quiero manzana'); expect(result.richTextSymbols).toHaveLength(1); - expect(result.richTextSymbols[0].image).toBe("[widgit]/food/apple.png"); + expect(result.richTextSymbols[0].image).toBe('[widgit]/food/apple.png'); // Symbol should be attached to the translation of 'apple' (which is 'manzana') - expect(["manzana", "quiero"]).toContain(result.richTextSymbols[0].text); + expect(['manzana', 'quiero']).toContain(result.richTextSymbols[0].text); }); - it("should handle multi-word phrases with symbols", async () => { - const originalMessage = "I want to go home"; - const translatedText = "Quiero ir a casa"; - const richTextSymbols = [ - { text: "home", image: "[widgit]/places/home.png" }, - ]; + it('should handle multi-word phrases with symbols', async () => { + const originalMessage = 'I want to go home'; + const translatedText = 'Quiero ir a casa'; + const richTextSymbols = [{ text: 'home', image: '[widgit]/places/home.png' }]; - const result = translateWithSymbols( - originalMessage, - translatedText, - richTextSymbols, - ); + const result = translateWithSymbols(originalMessage, translatedText, richTextSymbols); expect(result.richTextSymbols).toHaveLength(1); - expect(result.richTextSymbols[0].image).toBe("[widgit]/places/home.png"); + expect(result.richTextSymbols[0].image).toBe('[widgit]/places/home.png'); }); - it("should preserve all symbols in a sentence with multiple symbols", async () => { - const originalMessage = "I eat apple and banana"; - const translatedText = "Como manzana y plátano"; + it('should preserve all symbols in a sentence with multiple symbols', async () => { + const originalMessage = 'I eat apple and banana'; + const translatedText = 'Como manzana y plátano'; const richTextSymbols = [ - { text: "apple", image: "[widgit]/food/apple.png" }, - { text: "banana", image: "[widgit]/food/banana.png" }, + { text: 'apple', image: '[widgit]/food/apple.png' }, + { text: 'banana', image: '[widgit]/food/banana.png' }, ]; - const result = translateWithSymbols( - originalMessage, - translatedText, - richTextSymbols, - ); + const result = translateWithSymbols(originalMessage, translatedText, richTextSymbols); expect(result.richTextSymbols).toHaveLength(2); - expect( - result.richTextSymbols.some((s) => s.image?.includes("apple")), - ).toBe(true); - expect( - result.richTextSymbols.some((s) => s.image?.includes("banana")), - ).toBe(true); + expect(result.richTextSymbols.some((s) => s.image?.includes('apple'))).toBe(true); + expect(result.richTextSymbols.some((s) => s.image?.includes('banana'))).toBe(true); }); }); }); diff --git a/test/touchchatHelpers.test.ts b/test/touchchatHelpers.test.ts index 0422ed1..512a414 100644 --- a/test/touchchatHelpers.test.ts +++ b/test/touchchatHelpers.test.ts @@ -1,24 +1,24 @@ -import { AACTree, AACPage, TouchChat } from "../src/index"; +import { AACTree, AACPage, TouchChat } from '../src/index'; -describe("TouchChat helpers", () => { - it("maps page buttons with resolved images", async () => { +describe('TouchChat helpers', () => { + it('maps page buttons with resolved images', async () => { const tree = new AACTree(); const page = new AACPage({ - id: "page1", - buttons: [{ id: "btn1", resolvedImageEntry: "img.png" } as any], + id: 'page1', + buttons: [{ id: 'btn1', resolvedImageEntry: 'img.png' } as any], }); tree.addPage(page); - const map = TouchChat.getPageTokenImageMap(tree, "page1"); - expect(map.get("btn1")).toBe("img.png"); + const map = TouchChat.getPageTokenImageMap(tree, 'page1'); + expect(map.get('btn1')).toBe('img.png'); - const empty = TouchChat.getPageTokenImageMap(tree, "missing"); + const empty = TouchChat.getPageTokenImageMap(tree, 'missing'); expect(empty.size).toBe(0); }); - it("returns empty image sets/placeholders", async () => { + it('returns empty image sets/placeholders', async () => { const tree = new AACTree(); expect(TouchChat.getAllowedImageEntries(tree).size).toBe(0); - expect(TouchChat.openImage("ce", "entry")).toBeNull(); + expect(TouchChat.openImage('ce', 'entry')).toBeNull(); }); }); diff --git a/test/touchchatProcessor.comprehensive.test.ts b/test/touchchatProcessor.comprehensive.test.ts index f643fb4..3ed854e 100644 --- a/test/touchchatProcessor.comprehensive.test.ts +++ b/test/touchchatProcessor.comprehensive.test.ts @@ -1,14 +1,14 @@ // Comprehensive tests for TouchChatProcessor to improve coverage from 57.62% to 85%+ -import fs from "fs"; -import path from "path"; -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; -import { AACTree } from "../src/core/treeStructure"; -import { TreeFactory, PageFactory, ButtonFactory } from "./utils/testFactories"; +import fs from 'fs'; +import path from 'path'; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import { AACTree } from '../src/core/treeStructure'; +import { TreeFactory, PageFactory, ButtonFactory } from './utils/testFactories'; -describe("TouchChatProcessor - Comprehensive Coverage Tests", () => { +describe('TouchChatProcessor - Comprehensive Coverage Tests', () => { let processor: TouchChatProcessor; - const tempDir = path.join(__dirname, "temp_touchchat"); - const exampleFile = path.join(__dirname, "assets/excel/example.ce"); + const tempDir = path.join(__dirname, 'temp_touchchat'); + const exampleFile = path.join(__dirname, 'assets/excel/example.ce'); beforeAll(async () => { if (!fs.existsSync(tempDir)) { @@ -26,15 +26,13 @@ describe("TouchChatProcessor - Comprehensive Coverage Tests", () => { } }); - describe("SQLite Schema Tests", () => { - it("should handle TouchChat v1.x database schema", async () => { + describe('SQLite Schema Tests', () => { + it('should handle TouchChat v1.x database schema', async () => { // Test with minimal valid TouchChat database structure const tree = TreeFactory.createSimple(); - const outputPath = path.join(tempDir, "v1_test.ce"); + const outputPath = path.join(tempDir, 'v1_test.ce'); - await expect( - processor.saveFromTree(tree, outputPath), - ).resolves.not.toThrow(); + await expect(processor.saveFromTree(tree, outputPath)).resolves.not.toThrow(); expect(fs.existsSync(outputPath)).toBe(true); @@ -44,24 +42,22 @@ describe("TouchChatProcessor - Comprehensive Coverage Tests", () => { expect(Object.keys(loadedTree.pages).length).toBeGreaterThan(0); }); - it("should handle TouchChat v2.x database schema", async () => { + it('should handle TouchChat v2.x database schema', async () => { // Test with more complex button configurations const tree = TreeFactory.createCommunicationBoard(); - const outputPath = path.join(tempDir, "v2_test.ce"); + const outputPath = path.join(tempDir, 'v2_test.ce'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); expect(loadedTree).toBeDefined(); - expect(Object.keys(loadedTree.pages).length).toBe( - Object.keys(tree.pages).length, - ); + expect(Object.keys(loadedTree.pages).length).toBe(Object.keys(tree.pages).length); }); - it("should handle TouchChat v3.x database schema", async () => { + it('should handle TouchChat v3.x database schema', async () => { // Test with large dataset const tree = TreeFactory.createLarge(5, 10); - const outputPath = path.join(tempDir, "v3_test.ce"); + const outputPath = path.join(tempDir, 'v3_test.ce'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -70,191 +66,189 @@ describe("TouchChatProcessor - Comprehensive Coverage Tests", () => { expect(Object.keys(loadedTree.pages).length).toBe(5); }); - it("should process buttons with custom actions", async () => { + it('should process buttons with custom actions', async () => { const page = PageFactory.create({ - id: "custom_actions", - name: "Custom Actions Page", + id: 'custom_actions', + name: 'Custom Actions Page', buttons: [ - { label: "Speak Button", message: "Hello World", type: "SPEAK" }, + { label: 'Speak Button', message: 'Hello World', type: 'SPEAK' }, { - label: "Nav Button", - message: "Navigate", - type: "NAVIGATE", - targetPageId: "target", + label: 'Nav Button', + message: 'Navigate', + type: 'NAVIGATE', + targetPageId: 'target', }, ], }); const tree = new AACTree(); tree.addPage(page); - tree.addPage(PageFactory.create({ id: "target", name: "Target Page" })); + tree.addPage(PageFactory.create({ id: 'target', name: 'Target Page' })); - const outputPath = path.join(tempDir, "custom_actions.ce"); + const outputPath = path.join(tempDir, 'custom_actions.ce'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); - const loadedPage = loadedTree.getPage("custom_actions"); + const loadedPage = loadedTree.getPage('custom_actions'); expect(loadedPage).toBeDefined(); if (!loadedPage) { return; } expect(loadedPage.buttons).toHaveLength(2); - expect(loadedPage.buttons[0].type).toBe("SPEAK"); - expect(loadedPage.buttons[1].type).toBe("NAVIGATE"); - expect(loadedPage.buttons[1].targetPageId).toBe("target"); + expect(loadedPage.buttons[0].type).toBe('SPEAK'); + expect(loadedPage.buttons[1].type).toBe('NAVIGATE'); + expect(loadedPage.buttons[1].targetPageId).toBe('target'); }); - it("should handle buttons with multiple audio recordings", async () => { + it('should handle buttons with multiple audio recordings', async () => { const button = ButtonFactory.create({ - label: "Audio Button", - message: "I have audio", - type: "SPEAK", + label: 'Audio Button', + message: 'I have audio', + type: 'SPEAK', }); // Add audio recording button.audioRecording = { id: 1, - data: Buffer.from("fake audio data"), - identifier: "audio_1", - metadata: "Test audio recording", + data: Buffer.from('fake audio data'), + identifier: 'audio_1', + metadata: 'Test audio recording', }; const page = PageFactory.create({ - id: "audio_page", - name: "Audio Page", + id: 'audio_page', + name: 'Audio Page', }); page.addButton(button); const tree = new AACTree(); tree.addPage(page); - const outputPath = path.join(tempDir, "audio_test.ce"); + const outputPath = path.join(tempDir, 'audio_test.ce'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); - const loadedPage = loadedTree.getPage("audio_page"); + const loadedPage = loadedTree.getPage('audio_page'); expect(loadedPage).toBeDefined(); if (!loadedPage) { return; } - expect(loadedPage.buttons[0].label).toBe("Audio Button"); + expect(loadedPage.buttons[0].label).toBe('Audio Button'); }); - it("should process navigation buttons with complex targets", async () => { + it('should process navigation buttons with complex targets', async () => { // Create a complex navigation hierarchy - const homePage = PageFactory.create({ id: "home", name: "Home" }); + const homePage = PageFactory.create({ id: 'home', name: 'Home' }); const categoryPage = PageFactory.create({ - id: "category", - name: "Category", - parentId: "home", + id: 'category', + name: 'Category', + parentId: 'home', }); const subPage = PageFactory.create({ - id: "sub", - name: "Sub Page", - parentId: "category", + id: 'sub', + name: 'Sub Page', + parentId: 'category', }); // Add navigation buttons homePage.addButton( ButtonFactory.create({ - label: "Go to Category", - type: "NAVIGATE", - targetPageId: "category", - }), + label: 'Go to Category', + type: 'NAVIGATE', + targetPageId: 'category', + }) ); categoryPage.addButton( ButtonFactory.create({ - label: "Go to Sub", - type: "NAVIGATE", - targetPageId: "sub", - }), + label: 'Go to Sub', + type: 'NAVIGATE', + targetPageId: 'sub', + }) ); categoryPage.addButton( ButtonFactory.create({ - label: "Back to Home", - type: "NAVIGATE", - targetPageId: "home", - }), + label: 'Back to Home', + type: 'NAVIGATE', + targetPageId: 'home', + }) ); const tree = new AACTree(); tree.addPage(homePage); tree.addPage(categoryPage); tree.addPage(subPage); - tree.rootId = "home"; + tree.rootId = 'home'; - const outputPath = path.join(tempDir, "navigation_test.ce"); + const outputPath = path.join(tempDir, 'navigation_test.ce'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); - expect(loadedTree.rootId).toBe("home"); + expect(loadedTree.rootId).toBe('home'); expect(Object.keys(loadedTree.pages)).toHaveLength(3); - const loadedHome = loadedTree.getPage("home"); + const loadedHome = loadedTree.getPage('home'); expect(loadedHome).toBeDefined(); if (!loadedHome) { return; } - expect(loadedHome.buttons[0].targetPageId).toBe("category"); + expect(loadedHome.buttons[0].targetPageId).toBe('category'); - const loadedCategory = loadedTree.getPage("category"); + const loadedCategory = loadedTree.getPage('category'); expect(loadedCategory).toBeDefined(); if (!loadedCategory) { return; } expect(loadedCategory.buttons).toHaveLength(2); - expect(loadedCategory.buttons[0].targetPageId).toBe("sub"); - expect(loadedCategory.buttons[1].targetPageId).toBe("home"); + expect(loadedCategory.buttons[0].targetPageId).toBe('sub'); + expect(loadedCategory.buttons[1].targetPageId).toBe('home'); }); }); - describe("Database Connection Edge Cases", () => { - it("should handle corrupted SQLite databases gracefully", async () => { - const corruptedPath = path.join(tempDir, "corrupted.ce"); - fs.writeFileSync(corruptedPath, "This is not a valid zip file"); + describe('Database Connection Edge Cases', () => { + it('should handle corrupted SQLite databases gracefully', async () => { + const corruptedPath = path.join(tempDir, 'corrupted.ce'); + fs.writeFileSync(corruptedPath, 'This is not a valid zip file'); await expect(processor.loadIntoTree(corruptedPath)).rejects.toThrow(); }); - it("should process databases with missing required tables", async () => { + it('should process databases with missing required tables', async () => { // Create a minimal zip file without proper database structure // eslint-disable-next-line @typescript-eslint/no-var-requires - const AdmZip = require("adm-zip"); + const AdmZip = require('adm-zip'); const zip = new AdmZip(); - zip.addFile("empty.txt", Buffer.from("empty")); + zip.addFile('empty.txt', Buffer.from('empty')); - const invalidPath = path.join(tempDir, "invalid.ce"); + const invalidPath = path.join(tempDir, 'invalid.ce'); zip.writeZip(invalidPath); await expect(processor.loadIntoTree(invalidPath)).rejects.toThrow(); }); - it("should handle databases with foreign key constraints", async () => { + it('should handle databases with foreign key constraints', async () => { // Test with a valid tree that has proper relationships const tree = TreeFactory.createCommunicationBoard(); - const outputPath = path.join(tempDir, "fk_test.ce"); + const outputPath = path.join(tempDir, 'fk_test.ce'); - await expect( - processor.saveFromTree(tree, outputPath), - ).resolves.not.toThrow(); + await expect(processor.saveFromTree(tree, outputPath)).resolves.not.toThrow(); const loadedTree = await processor.loadIntoTree(outputPath); expect(loadedTree).toBeDefined(); }); }); - describe("Large Dataset Performance", () => { - it("should process databases with 1000+ buttons efficiently", async () => { + describe('Large Dataset Performance', () => { + it('should process databases with 1000+ buttons efficiently', async () => { const startTime = Date.now(); // Create a large tree with many buttons const tree = TreeFactory.createLarge(10, 100); // 10 pages, 100 buttons each = 1000 buttons - const outputPath = path.join(tempDir, "large_test.ce"); + const outputPath = path.join(tempDir, 'large_test.ce'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -274,10 +268,10 @@ describe("TouchChatProcessor - Comprehensive Coverage Tests", () => { expect(totalButtons).toBe(1000); }); - it("should handle databases with complex page hierarchies", async () => { + it('should handle databases with complex page hierarchies', async () => { // Create a deep hierarchy const tree = new AACTree(); - let currentParent = "root"; + let currentParent = 'root'; // Create 5 levels deep for (let level = 0; level < 5; level++) { @@ -296,9 +290,9 @@ describe("TouchChatProcessor - Comprehensive Coverage Tests", () => { page.addButton( ButtonFactory.create({ label: `Go to ${targetId}`, - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId: targetId, - }), + }) ); } } @@ -311,7 +305,7 @@ describe("TouchChatProcessor - Comprehensive Coverage Tests", () => { } } - const outputPath = path.join(tempDir, "hierarchy_test.ce"); + const outputPath = path.join(tempDir, 'hierarchy_test.ce'); await processor.saveFromTree(tree, outputPath); const loadedTree = await processor.loadIntoTree(outputPath); @@ -320,10 +314,10 @@ describe("TouchChatProcessor - Comprehensive Coverage Tests", () => { }); }); - describe("Text Processing Methods", () => { - it("should extract all texts from complex database", async () => { + describe('Text Processing Methods', () => { + it('should extract all texts from complex database', async () => { if (!fs.existsSync(exampleFile)) { - console.log("Skipping test - example file not found"); + console.log('Skipping test - example file not found'); return; } @@ -333,38 +327,34 @@ describe("TouchChatProcessor - Comprehensive Coverage Tests", () => { // Verify texts are non-empty strings texts.forEach((text) => { - expect(typeof text).toBe("string"); + expect(typeof text).toBe('string'); expect(text.length).toBeGreaterThan(0); }); }); - it("should process texts with translations", async () => { + it('should process texts with translations', async () => { const tree = TreeFactory.createSimple(); - const inputPath = path.join(tempDir, "input_for_translation.ce"); - const outputPath = path.join(tempDir, "translation_test.ce"); + const inputPath = path.join(tempDir, 'input_for_translation.ce'); + const outputPath = path.join(tempDir, 'translation_test.ce'); // Save the tree first await processor.saveFromTree(tree, inputPath); // Create translation map const translations = new Map(); - translations.set("Hello", "Hola"); - translations.set("Food", "Comida"); - translations.set("Home", "Casa"); - - const result = await processor.processTexts( - inputPath, - translations, - outputPath, - ); + translations.set('Hello', 'Hola'); + translations.set('Food', 'Comida'); + translations.set('Home', 'Casa'); + + const result = await processor.processTexts(inputPath, translations, outputPath); expect(result).toBeInstanceOf(Buffer); expect(fs.existsSync(outputPath)).toBe(true); // Load and verify translations were applied const translatedTree = await processor.loadIntoTree(outputPath); - const homePage = translatedTree.getPage("home"); + const homePage = translatedTree.getPage('home'); expect(homePage).toBeDefined(); - expect(homePage?.name).toBe("Casa"); + expect(homePage?.name).toBe('Casa'); }); }); }); diff --git a/test/touchchatProcessor.coverage.test.ts b/test/touchchatProcessor.coverage.test.ts index a0fa199..9236924 100644 --- a/test/touchchatProcessor.coverage.test.ts +++ b/test/touchchatProcessor.coverage.test.ts @@ -1,16 +1,16 @@ -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; -import { AACTree, AACPage, AACButton } from "../src/core/treeStructure"; -import path from "path"; -import fs from "fs"; -import AdmZip from "adm-zip"; -import os from "os"; -import Database from "better-sqlite3"; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import { AACTree, AACPage, AACButton } from '../src/core/treeStructure'; +import path from 'path'; +import fs from 'fs'; +import AdmZip from 'adm-zip'; +import os from 'os'; +import Database from 'better-sqlite3'; -describe("TouchChatProcessor Coverage", () => { - const _exampleFile: string = path.join(__dirname, "assets/excel/example.ce"); - const tempDir = path.join(os.tmpdir(), "touchchat-test"); - const tempDbPath = path.join(tempDir, "vocab.c4v"); - const tempZipPath = path.join(__dirname, "temp.ce"); +describe('TouchChatProcessor Coverage', () => { + const _exampleFile: string = path.join(__dirname, 'assets/excel/example.ce'); + const tempDir = path.join(os.tmpdir(), 'touchchat-test'); + const tempDbPath = path.join(tempDir, 'vocab.c4v'); + const tempZipPath = path.join(__dirname, 'temp.ce'); beforeEach(async () => { if (fs.existsSync(tempDir)) { @@ -31,38 +31,38 @@ describe("TouchChatProcessor Coverage", () => { } }); - describe("File Handling", () => { - it("should throw an error if no .c4v file is found in the archive", async () => { + describe('File Handling', () => { + it('should throw an error if no .c4v file is found in the archive', async () => { const zip = new AdmZip(); - zip.addFile("test.txt", Buffer.from("hello")); + zip.addFile('test.txt', Buffer.from('hello')); zip.writeZip(tempZipPath); const processor = new TouchChatProcessor(); await expect(processor.loadIntoTree(tempZipPath)).rejects.toThrow( - "No .c4v vocab DB found in TouchChat export", + 'No .c4v vocab DB found in TouchChat export' ); }); }); - describe("Save and Load with UNIQUE constraints", () => { - it("should save and reload a tree without UNIQUE constraint violations", async () => { + describe('Save and Load with UNIQUE constraints', () => { + it('should save and reload a tree without UNIQUE constraint violations', async () => { const processor = new TouchChatProcessor(); const tree = new AACTree(); const originalPage1 = new AACPage({ - id: "1", - name: "Page 1", + id: '1', + name: 'Page 1', buttons: [], }); - const button1 = new AACButton({ id: "101", label: "Button 1" }); + const button1 = new AACButton({ id: '101', label: 'Button 1' }); originalPage1.addButton(button1); tree.addPage(originalPage1); const originalPage2 = new AACPage({ - id: "2", - name: "Page 2", + id: '2', + name: 'Page 2', buttons: [], }); - const button2 = new AACButton({ id: "102", label: "Button 2" }); + const button2 = new AACButton({ id: '102', label: 'Button 2' }); originalPage2.addButton(button2); tree.addPage(originalPage2); @@ -72,8 +72,8 @@ describe("TouchChatProcessor Coverage", () => { const newTree = await newProcessor.loadIntoTree(tempZipPath); expect(Object.keys(newTree.pages).length).toBe(2); - const loadedPage1 = newTree.getPage("1"); - const loadedPage2 = newTree.getPage("2"); + const loadedPage1 = newTree.getPage('1'); + const loadedPage2 = newTree.getPage('2'); expect(loadedPage1).toBeDefined(); expect(loadedPage2).toBeDefined(); if (loadedPage1) { @@ -85,18 +85,15 @@ describe("TouchChatProcessor Coverage", () => { }); }); - describe("Schema Variations", () => { - it("should handle different table schemas gracefully", async () => { + describe('Schema Variations', () => { + it('should handle different table schemas gracefully', async () => { const db = new Database(tempDbPath); db.exec(` CREATE TABLE resources (id INTEGER PRIMARY KEY, name TEXT); CREATE TABLE pages (id INTEGER PRIMARY KEY, resource_id INTEGER); `); - db.prepare("INSERT INTO resources (id, name) VALUES (?, ?)").run( - 1, - "Page 1", - ); - db.prepare("INSERT INTO pages (id, resource_id) VALUES (?, ?)").run(1, 1); + db.prepare('INSERT INTO resources (id, name) VALUES (?, ?)').run(1, 'Page 1'); + db.prepare('INSERT INTO pages (id, resource_id) VALUES (?, ?)').run(1, 1); db.close(); const zip = new AdmZip(); @@ -106,7 +103,7 @@ describe("TouchChatProcessor Coverage", () => { const processor = new TouchChatProcessor(); const tree = await processor.loadIntoTree(tempZipPath); expect(Object.keys(tree.pages).length).toBe(1); - const testPage = tree.getPage("1"); + const testPage = tree.getPage('1'); expect(testPage).toBeDefined(); expect(testPage?.buttons.length).toBe(0); // No buttons table }); diff --git a/test/touchchatProcessor.export.test.js b/test/touchchatProcessor.export.test.js index 8e9e335..65f2651 100644 --- a/test/touchchatProcessor.export.test.js +++ b/test/touchchatProcessor.export.test.js @@ -1,28 +1,23 @@ // Test TouchChatProcessor export/saveFromTree -const fs = require("fs"); -const path = require("path"); -const TouchChatProcessor = require("../src/processors/touchchatProcessor"); -describe("TouchChatProcessor.saveFromTree", () => { - const tcPath = path.join(__dirname, "assets/excel/example.touchchat.json"); - const outPath = path.join(__dirname, "out.touchchat.json"); +const fs = require('fs'); +const path = require('path'); +const TouchChatProcessor = require('../src/processors/touchchatProcessor'); +describe('TouchChatProcessor.saveFromTree', () => { + const tcPath = path.join(__dirname, 'assets/excel/example.touchchat.json'); + const outPath = path.join(__dirname, 'out.touchchat.json'); afterAll(() => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("exports tree to TouchChat JSON", () => { + it('exports tree to TouchChat JSON', () => { // If no example.touchchat.json, skip if (!fs.existsSync(tcPath)) return; - const tree = - require("../src/processors/touchchatProcessor").prototype.loadIntoTree.call( - TouchChatProcessor, - tcPath, - ); - TouchChatProcessor.prototype.saveFromTree.call( + const tree = require('../src/processors/touchchatProcessor').prototype.loadIntoTree.call( TouchChatProcessor, - tree, - outPath, + tcPath ); - const exported = fs.readFileSync(outPath, "utf8"); - expect(exported).toContain("pages"); - expect(exported).toContain("rootId"); + TouchChatProcessor.prototype.saveFromTree.call(TouchChatProcessor, tree, outPath); + const exported = fs.readFileSync(outPath, 'utf8'); + expect(exported).toContain('pages'); + expect(exported).toContain('rootId'); }); }); diff --git a/test/touchchatProcessor.roundtrip.test.ts b/test/touchchatProcessor.roundtrip.test.ts index 6f29b45..7fe7dfb 100644 --- a/test/touchchatProcessor.roundtrip.test.ts +++ b/test/touchchatProcessor.roundtrip.test.ts @@ -1,22 +1,20 @@ -import fs from "fs"; -import path from "path"; -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; +import fs from 'fs'; +import path from 'path'; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; // import { AACTree } from '../src/core/treeStructure'; // Unused import -describe("TouchChatProcessor round-trip", () => { - const tcPath = path.join(__dirname, "assets/excel/example.touchchat.json"); - const outPath = path.join(__dirname, "out.touchchat.json"); +describe('TouchChatProcessor round-trip', () => { + const tcPath = path.join(__dirname, 'assets/excel/example.touchchat.json'); + const outPath = path.join(__dirname, 'out.touchchat.json'); afterAll(async () => { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); }); - it("round-trips TouchChat JSON without losing pages or navigation", async () => { + it('round-trips TouchChat JSON without losing pages or navigation', async () => { if (!fs.existsSync(tcPath)) return; const processor = new TouchChatProcessor(); const tree1 = await processor.loadIntoTree(tcPath); await processor.saveFromTree(tree1, outPath); const tree2 = await processor.loadIntoTree(outPath); - expect(Object.keys(tree1.pages).sort()).toEqual( - Object.keys(tree2.pages).sort(), - ); + expect(Object.keys(tree1.pages).sort()).toEqual(Object.keys(tree2.pages).sort()); for (const pid in tree1.pages) { expect(tree2.pages).toHaveProperty(pid); const btnLabels1 = tree1.pages[pid].buttons.map((b) => b.label).sort(); diff --git a/test/touchchatProcessor.test.ts b/test/touchchatProcessor.test.ts index c3b0764..8021f3c 100644 --- a/test/touchchatProcessor.test.ts +++ b/test/touchchatProcessor.test.ts @@ -1,19 +1,19 @@ // Unit tests for TouchChatProcessor -import { TouchChatProcessor } from "../src/processors/touchchatProcessor"; -import { AACTree } from "../src/core/treeStructure"; -import path from "path"; +import { TouchChatProcessor } from '../src/processors/touchchatProcessor'; +import { AACTree } from '../src/core/treeStructure'; +import path from 'path'; -describe("TouchChatProcessor", () => { - const exampleFile: string = path.join(__dirname, "assets/excel/example.ce"); +describe('TouchChatProcessor', () => { + const exampleFile: string = path.join(__dirname, 'assets/excel/example.ce'); - it("should load a .ce file into a tree", async () => { + it('should load a .ce file into a tree', async () => { const processor = new TouchChatProcessor(); const tree: AACTree = await processor.loadIntoTree(exampleFile); expect(tree).toBeDefined(); expect(Object.keys(tree.pages).length).toBeGreaterThan(0); }); - it("should extract all texts from a .ce file", async () => { + it('should extract all texts from a .ce file', async () => { const processor = new TouchChatProcessor(); const texts: string[] = await processor.extractTexts(exampleFile); expect(Array.isArray(texts)).toBe(true); diff --git a/test/utils/ioHelpers.test.ts b/test/utils/ioHelpers.test.ts index 1d5bb31..d612fcb 100644 --- a/test/utils/ioHelpers.test.ts +++ b/test/utils/ioHelpers.test.ts @@ -1,33 +1,29 @@ -import fs from "fs"; -import os from "os"; -import path from "path"; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { decodeText, defaultFileAdapter, encodeBase64, encodeText, getBasename, -} from "../../src/utils/io"; +} from '../../src/utils/io'; function createTempDir(prefix: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } -describe("io helpers", () => { - it("reads and writes text and binary files", async () => { - const tempDir = createTempDir("aac-io-test-"); - const textPath = path.join(tempDir, "note.txt"); - const binPath = path.join(tempDir, "data.bin"); - const { - writeTextToPath, - readTextFromInput, - writeBinaryToPath, - readBinaryFromInput, - } = defaultFileAdapter; +describe('io helpers', () => { + it('reads and writes text and binary files', async () => { + const tempDir = createTempDir('aac-io-test-'); + const textPath = path.join(tempDir, 'note.txt'); + const binPath = path.join(tempDir, 'data.bin'); + const { writeTextToPath, readTextFromInput, writeBinaryToPath, readBinaryFromInput } = + defaultFileAdapter; try { - await writeTextToPath(textPath, "hello"); - expect(await readTextFromInput(textPath)).toBe("hello"); + await writeTextToPath(textPath, 'hello'); + expect(await readTextFromInput(textPath)).toBe('hello'); const bin = await readBinaryFromInput(textPath); expect(Buffer.isBuffer(bin)).toBe(true); @@ -40,19 +36,19 @@ describe("io helpers", () => { } }); - it("encodes and decodes text helpers", () => { - const encoded = encodeText("abc"); - expect(Buffer.from(encoded).toString("utf8")).toBe("abc"); + it('encodes and decodes text helpers', () => { + const encoded = encodeText('abc'); + expect(Buffer.from(encoded).toString('utf8')).toBe('abc'); - const base64 = encodeBase64(Buffer.from("xyz", "utf8")); - expect(base64).toBe("eHl6"); + const base64 = encodeBase64(Buffer.from('xyz', 'utf8')); + expect(base64).toBe('eHl6'); const decoded = decodeText(new Uint8Array([104, 105])); - expect(decoded).toBe("hi"); + expect(decoded).toBe('hi'); }); - it("extracts basenames from paths", () => { - expect(getBasename("/tmp/example.txt")).toBe("example.txt"); - expect(getBasename("C:\\\\temp\\\\example.txt")).toBe("example.txt"); + it('extracts basenames from paths', () => { + expect(getBasename('/tmp/example.txt')).toBe('example.txt'); + expect(getBasename('C:\\\\temp\\\\example.txt')).toBe('example.txt'); }); }); diff --git a/test/utils/testFactories.ts b/test/utils/testFactories.ts index 6501c2b..2ff62b9 100644 --- a/test/utils/testFactories.ts +++ b/test/utils/testFactories.ts @@ -1,16 +1,11 @@ // Test data factories and utilities for consistent test object creation -import { - AACTree, - AACPage, - AACButton, - AACSemanticIntent, -} from "../../src/index"; +import { AACTree, AACPage, AACButton, AACSemanticIntent } from '../../src/index'; export interface ButtonConfig { id?: string; label?: string; message?: string; - type?: "SPEAK" | "NAVIGATE"; + type?: 'SPEAK' | 'NAVIGATE'; targetPageId?: string; } @@ -39,7 +34,7 @@ export class ButtonFactory { id, label: config.label || `Button ${id}`, message: config.message || `Message for ${id}`, - type: config.type || "SPEAK", + type: config.type || 'SPEAK', targetPageId: config.targetPageId, }); } @@ -48,7 +43,7 @@ export class ButtonFactory { return this.create({ label, message: message || label, - type: "SPEAK", + type: 'SPEAK', }); } @@ -56,7 +51,7 @@ export class ButtonFactory { return this.create({ label, message: `Navigate to ${targetPageId}`, - type: "NAVIGATE", + type: 'NAVIGATE', targetPageId, }); } @@ -65,19 +60,16 @@ export class ButtonFactory { return this.create({ label, message: message || `Action: ${label}`, - type: "SPEAK", // Use SPEAK instead of ACTION since ACTION is not supported + type: 'SPEAK', // Use SPEAK instead of ACTION since ACTION is not supported }); } - static createBatch( - count: number, - type: "SPEAK" | "NAVIGATE" = "SPEAK", - ): AACButton[] { + static createBatch(count: number, type: 'SPEAK' | 'NAVIGATE' = 'SPEAK'): AACButton[] { return Array.from({ length: count }, (_, i) => this.create({ label: `${type} Button ${i + 1}`, type, - }), + }) ); } } @@ -109,10 +101,7 @@ export class PageFactory { return page; } - static createWithButtons( - name: string, - buttonConfigs: ButtonConfig[], - ): AACPage { + static createWithButtons(name: string, buttonConfigs: ButtonConfig[]): AACPage { return this.create({ name, buttons: buttonConfigs, @@ -121,13 +110,13 @@ export class PageFactory { static createHome(): AACPage { return this.create({ - id: "home", - name: "Home", + id: 'home', + name: 'Home', buttons: [ - { label: "Hello", message: "Hello!", type: "SPEAK" }, - { label: "Food", message: "I want food", type: "SPEAK" }, - { label: "Drink", message: "I want a drink", type: "SPEAK" }, - { label: "More", targetPageId: "more", type: "NAVIGATE" }, + { label: 'Hello', message: 'Hello!', type: 'SPEAK' }, + { label: 'Food', message: 'I want food', type: 'SPEAK' }, + { label: 'Drink', message: 'I want a drink', type: 'SPEAK' }, + { label: 'More', targetPageId: 'more', type: 'NAVIGATE' }, ], }); } @@ -136,11 +125,11 @@ export class PageFactory { const buttons = items.map((item) => ({ label: item, message: `I want ${item.toLowerCase()}`, - type: "SPEAK" as const, + type: 'SPEAK' as const, })); return this.create({ - id: categoryName.toLowerCase().replace(/\s+/g, "_"), + id: categoryName.toLowerCase().replace(/\s+/g, '_'), name: categoryName, buttons, }); @@ -149,12 +138,12 @@ export class PageFactory { static createNavigation(pageName: string, destinations: string[]): AACPage { const buttons = destinations.map((dest) => ({ label: `Go to ${dest}`, - targetPageId: dest.toLowerCase().replace(/\s+/g, "_"), - type: "NAVIGATE" as const, + targetPageId: dest.toLowerCase().replace(/\s+/g, '_'), + type: 'NAVIGATE' as const, })); return this.create({ - id: pageName.toLowerCase().replace(/\s+/g, "_"), + id: pageName.toLowerCase().replace(/\s+/g, '_'), name: pageName, buttons, }); @@ -189,12 +178,12 @@ export class TreeFactory { static createSimple(): AACTree { const homePage = PageFactory.createHome(); const morePage = PageFactory.create({ - id: "more", - name: "More Options", + id: 'more', + name: 'More Options', buttons: [ - { label: "Please", message: "Please", type: "SPEAK" }, - { label: "Thank you", message: "Thank you", type: "SPEAK" }, - { label: "Home", targetPageId: "home", type: "NAVIGATE" }, + { label: 'Please', message: 'Please', type: 'SPEAK' }, + { label: 'Thank you', message: 'Thank you', type: 'SPEAK' }, + { label: 'Home', targetPageId: 'home', type: 'NAVIGATE' }, ], }); @@ -225,40 +214,17 @@ export class TreeFactory { })), }, ], - rootId: "home", + rootId: 'home', }); } static createCommunicationBoard(): AACTree { const pages = [ PageFactory.createHome(), - PageFactory.createCategory("Food", [ - "Apple", - "Banana", - "Bread", - "Water", - "Milk", - ]), - PageFactory.createCategory("Activities", [ - "Play", - "Read", - "Music", - "TV", - "Walk", - ]), - PageFactory.createCategory("People", [ - "Mom", - "Dad", - "Friend", - "Teacher", - "Doctor", - ]), - PageFactory.createNavigation("Navigation", [ - "Home", - "Food", - "Activities", - "People", - ]), + PageFactory.createCategory('Food', ['Apple', 'Banana', 'Bread', 'Water', 'Milk']), + PageFactory.createCategory('Activities', ['Play', 'Read', 'Music', 'TV', 'Walk']), + PageFactory.createCategory('People', ['Mom', 'Dad', 'Friend', 'Teacher', 'Doctor']), + PageFactory.createNavigation('Navigation', ['Home', 'Food', 'Activities', 'People']), ]; return this.create({ @@ -273,14 +239,11 @@ export class TreeFactory { targetPageId: b.targetPageId, })), })), - rootId: "home", + rootId: 'home', }); } - static createLarge( - pageCount: number = 10, - buttonsPerPage: number = 8, - ): AACTree { + static createLarge(pageCount: number = 10, buttonsPerPage: number = 8): AACTree { const pages: PageConfig[] = []; for (let i = 0; i < pageCount; i++) { @@ -290,9 +253,8 @@ export class TreeFactory { buttons.push({ label: `Button ${j + 1}`, message: `Message ${j + 1} on page ${i + 1}`, - type: j % 3 === 0 ? "NAVIGATE" : "SPEAK", - targetPageId: - j % 3 === 0 ? `page_${((i + 1) % pageCount) + 1}` : undefined, + type: j % 3 === 0 ? 'NAVIGATE' : 'SPEAK', + targetPageId: j % 3 === 0 ? `page_${((i + 1) % pageCount) + 1}` : undefined, }); } @@ -305,7 +267,7 @@ export class TreeFactory { return this.create({ pages, - rootId: "page_1", + rootId: 'page_1', }); } @@ -313,18 +275,18 @@ export class TreeFactory { return this.create({ pages: [ { - id: "single", - name: "Single Page", + id: 'single', + name: 'Single Page', buttons: [ { - label: "Hello", - message: "Hello World", - type: "SPEAK", + label: 'Hello', + message: 'Hello World', + type: 'SPEAK', }, ], }, ], - rootId: "single", + rootId: 'single', }); } @@ -338,9 +300,8 @@ export class TreeFactory { */ export class TestDataUtils { static generateRandomString(length: number = 10): string { - const chars = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let result = ""; + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } @@ -352,46 +313,44 @@ export class TestDataUtils { } static generateUnicodeString(): string { - const unicodeChars = ["😀", "🎉", "🌟", "你好", "مرحبا", "Café", "∑∞≠"]; + const unicodeChars = ['😀', '🎉', '🌟', '你好', 'مرحبا', 'Café', '∑∞≠']; return ( - unicodeChars[Math.floor(Math.random() * unicodeChars.length)] + - this.generateRandomString(5) + unicodeChars[Math.floor(Math.random() * unicodeChars.length)] + this.generateRandomString(5) ); } static createTranslationMap( originalTexts: string[], - targetLanguage: string = "es", + targetLanguage: string = 'es' ): Map { const translations = new Map(); const commonTranslations: Record> = { es: { - Hello: "Hola", - Food: "Comida", - Drink: "Bebida", - Home: "Casa", - More: "Más", - Please: "Por favor", - "Thank you": "Gracias", - Yes: "Sí", - No: "No", + Hello: 'Hola', + Food: 'Comida', + Drink: 'Bebida', + Home: 'Casa', + More: 'Más', + Please: 'Por favor', + 'Thank you': 'Gracias', + Yes: 'Sí', + No: 'No', }, fr: { - Hello: "Bonjour", - Food: "Nourriture", - Drink: "Boisson", - Home: "Maison", - More: "Plus", + Hello: 'Bonjour', + Food: 'Nourriture', + Drink: 'Boisson', + Home: 'Maison', + More: 'Plus', Please: "S'il vous plaît", - "Thank you": "Merci", - Yes: "Oui", - No: "Non", + 'Thank you': 'Merci', + Yes: 'Oui', + No: 'Non', }, }; - const targetTranslations = - commonTranslations[targetLanguage] || commonTranslations.es; + const targetTranslations = commonTranslations[targetLanguage] || commonTranslations.es; originalTexts.forEach((text) => { if (targetTranslations[text]) { @@ -429,7 +388,7 @@ export class TestDataUtils { return true; } catch (error) { - console.error("Tree validation error:", error); + console.error('Tree validation error:', error); return false; } } diff --git a/test/utils/testHelpers.ts b/test/utils/testHelpers.ts index 909689c..50f933d 100644 --- a/test/utils/testHelpers.ts +++ b/test/utils/testHelpers.ts @@ -1,8 +1,8 @@ // Test helper utilities for setup, teardown, and common operations -import fs from "fs"; -import path from "path"; -import os from "os"; -import { performance } from "perf_hooks"; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { performance } from 'perf_hooks'; export interface TestEnvironment { tempDir: string; @@ -28,12 +28,7 @@ export class TestEnvironmentManager { private static environments: TestEnvironment[] = []; static createTempEnvironment(testName: string): TestEnvironment { - const tempDir = path.join( - os.tmpdir(), - "aac-processors-test", - testName, - Date.now().toString(), - ); + const tempDir = path.join(os.tmpdir(), 'aac-processors-test', testName, Date.now().toString()); // Ensure directory exists fs.mkdirSync(tempDir, { recursive: true }); @@ -59,17 +54,13 @@ export class TestEnvironmentManager { try { env.cleanup(); } catch (error) { - console.warn("Failed to cleanup environment:", error); + console.warn('Failed to cleanup environment:', error); } }); this.environments.length = 0; } - static createTestFile( - tempDir: string, - filename: string, - content: string | Buffer, - ): string { + static createTestFile(tempDir: string, filename: string, content: string | Buffer): string { const filePath = path.join(tempDir, filename); fs.writeFileSync(filePath, content); return filePath; @@ -77,7 +68,7 @@ export class TestEnvironmentManager { static createTestFiles( tempDir: string, - files: Record, + files: Record ): Record { const filePaths: Record = {}; @@ -95,7 +86,7 @@ export class TestEnvironmentManager { export class PerformanceHelper { static async measureAsync( operation: () => Promise, - description?: string, + description?: string ): Promise<{ result: T; metrics: PerformanceMetrics }> { // Force garbage collection if available if (global.gc) { @@ -134,7 +125,7 @@ export class PerformanceHelper { static measure( operation: () => T, - description?: string, + description?: string ): { result: T; metrics: PerformanceMetrics } { // Force garbage collection if available if (global.gc) { @@ -176,7 +167,7 @@ export class PerformanceHelper { expectations: { maxTime?: number; maxMemoryMB?: number; - }, + } ): void { if (expectations.maxTime !== undefined) { expect(metrics.executionTime).toBeLessThan(expectations.maxTime); @@ -195,7 +186,7 @@ export class PerformanceHelper { export class FileSystemHelper { static createLargeFile(filePath: string, sizeInMB: number): void { const chunkSize = 1024 * 1024; // 1MB chunks - const chunk = Buffer.alloc(chunkSize, "A"); + const chunk = Buffer.alloc(chunkSize, 'A'); const writeStream = fs.createWriteStream(filePath); @@ -208,13 +199,12 @@ export class FileSystemHelper { static createCorruptedFile(filePath: string, originalContent: string): void { // Create a file with corrupted content (truncated, invalid characters, etc.) - const corruptedContent = - originalContent.slice(0, originalContent.length / 2) + "\0\xFF\xFE"; - fs.writeFileSync(filePath, corruptedContent, "binary"); + const corruptedContent = originalContent.slice(0, originalContent.length / 2) + '\0\xFF\xFE'; + fs.writeFileSync(filePath, corruptedContent, 'binary'); } static createEmptyFile(filePath: string): void { - fs.writeFileSync(filePath, ""); + fs.writeFileSync(filePath, ''); } static createBinaryFile(filePath: string, size: number = 1024): void { @@ -251,7 +241,7 @@ export class AsyncTestHelper { static async waitFor( condition: () => boolean | Promise, timeoutMs: number = 5000, - intervalMs: number = 100, + intervalMs: number = 100 ): Promise { const startTime = Date.now(); @@ -273,13 +263,11 @@ export class AsyncTestHelper { static async withTimeout( promise: Promise, timeoutMs: number, - errorMessage?: string, + errorMessage?: string ): Promise { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { - reject( - new Error(errorMessage || `Operation timed out after ${timeoutMs}ms`), - ); + reject(new Error(errorMessage || `Operation timed out after ${timeoutMs}ms`)); }, timeoutMs); }); @@ -288,7 +276,7 @@ export class AsyncTestHelper { static async runConcurrently( operations: (() => Promise)[], - maxConcurrency: number = 5, + maxConcurrency: number = 5 ): Promise { const results: T[] = []; const executing: Promise[] = []; @@ -304,12 +292,7 @@ export class AsyncTestHelper { await Promise.race(executing); // Remove completed promises for (let i = executing.length - 1; i >= 0; i--) { - if ( - await Promise.race([ - executing[i].then(() => true), - Promise.resolve(false), - ]) - ) { + if (await Promise.race([executing[i].then(() => true), Promise.resolve(false)])) { executing.splice(i, 1); } } @@ -328,20 +311,20 @@ export class ErrorTestHelper { static expectError( operation: () => T, expectedErrorType?: new (...args: any[]) => Error, - expectedMessage?: string | RegExp, + expectedMessage?: string | RegExp ): Error { let thrownError: Error | null = null; try { operation(); - fail("Expected operation to throw an error, but it did not"); + fail('Expected operation to throw an error, but it did not'); } catch (error) { thrownError = error as Error; } expect(thrownError).toBeDefined(); if (!thrownError) { - throw new Error("Expected an error to be thrown."); + throw new Error('Expected an error to be thrown.'); } if (expectedErrorType) { @@ -349,7 +332,7 @@ export class ErrorTestHelper { } if (expectedMessage) { - if (typeof expectedMessage === "string") { + if (typeof expectedMessage === 'string') { expect(thrownError.message).toContain(expectedMessage); } else { expect(thrownError.message).toMatch(expectedMessage); @@ -362,20 +345,20 @@ export class ErrorTestHelper { static async expectAsyncError( operation: () => Promise, expectedErrorType?: new (...args: any[]) => Error, - expectedMessage?: string | RegExp, + expectedMessage?: string | RegExp ): Promise { let thrownError: Error | null = null; try { await operation(); - fail("Expected async operation to throw an error, but it did not"); + fail('Expected async operation to throw an error, but it did not'); } catch (error) { thrownError = error as Error; } expect(thrownError).toBeDefined(); if (!thrownError) { - throw new Error("Expected an error to be thrown."); + throw new Error('Expected an error to be thrown.'); } if (expectedErrorType) { @@ -383,7 +366,7 @@ export class ErrorTestHelper { } if (expectedMessage) { - if (typeof expectedMessage === "string") { + if (typeof expectedMessage === 'string') { expect(thrownError.message).toContain(expectedMessage); } else { expect(thrownError.message).toMatch(expectedMessage); @@ -418,7 +401,7 @@ export class TestPatterns { createData: () => T, serialize: (data: T) => string | Buffer, deserialize: (serialized: string | Buffer) => T, - compare: (original: T, deserialized: T) => boolean, + compare: (original: T, deserialized: T) => boolean ): void { const original = createData(); const serialized = serialize(original); @@ -430,7 +413,7 @@ export class TestPatterns { static async testConcurrentAccess( operation: () => Promise, concurrency: number = 5, - iterations: number = 10, + iterations: number = 10 ): Promise { const operations = Array(iterations) .fill(0) @@ -440,7 +423,7 @@ export class TestPatterns { static testMemoryUsage( operation: () => T, - maxMemoryMB: number = 50, + maxMemoryMB: number = 50 ): { result: T; metrics: PerformanceMetrics } { const { result, metrics } = PerformanceHelper.measure(operation); diff --git a/test/utils/zipAdapter.browser.test.ts b/test/utils/zipAdapter.browser.test.ts index 1fb3a2f..6c460f5 100644 --- a/test/utils/zipAdapter.browser.test.ts +++ b/test/utils/zipAdapter.browser.test.ts @@ -1,31 +1,31 @@ -import JSZip from "jszip"; +import JSZip from 'jszip'; async function getBrowserZipAdapter() { jest.resetModules(); - jest.doMock("../../src/utils/io", () => { - const actual = jest.requireActual("../../src/utils/io"); + jest.doMock('../../src/utils/io', () => { + const actual = jest.requireActual('../../src/utils/io'); return { ...actual, isNodeRuntime: () => false, }; }); - const module = await import("../../src/utils/zip"); + const module = await import('../../src/utils/zip'); return module.getZipAdapter; } -describe("zip adapter (browser)", () => { - it("does not include directories in listFiles", async () => { +describe('zip adapter (browser)', () => { + it('does not include directories in listFiles', async () => { const zip = new JSZip(); - zip.folder("dir"); - zip.file("dir/nested.txt", "nested"); - const buffer = await zip.generateAsync({ type: "uint8array" }); + zip.folder('dir'); + zip.file('dir/nested.txt', 'nested'); + const buffer = await zip.generateAsync({ type: 'uint8array' }); const getZipAdapter = await getBrowserZipAdapter(); const adapter = await getZipAdapter(buffer); const entries = adapter.listFiles(); - expect(entries).toContain("dir/nested.txt"); - expect(entries).not.toContain("dir/"); + expect(entries).toContain('dir/nested.txt'); + expect(entries).not.toContain('dir/'); }); }); diff --git a/test/utils/zipAdapter.test.ts b/test/utils/zipAdapter.test.ts index d98b059..1ad6f50 100644 --- a/test/utils/zipAdapter.test.ts +++ b/test/utils/zipAdapter.test.ts @@ -1,38 +1,38 @@ -import fs from "fs"; -import os from "os"; -import path from "path"; -import AdmZip from "adm-zip"; -import { getZipAdapter } from "../../src/utils/zip"; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import AdmZip from 'adm-zip'; +import { getZipAdapter } from '../../src/utils/zip'; function createTempDir(prefix: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } -describe("zip adapter (Node)", () => { - it("reads entries from a zip buffer", async () => { +describe('zip adapter (Node)', () => { + it('reads entries from a zip buffer', async () => { const zip = new AdmZip(); - zip.addFile("foo.txt", Buffer.from("hello", "utf8")); + zip.addFile('foo.txt', Buffer.from('hello', 'utf8')); const buffer = zip.toBuffer(); const adapter = await getZipAdapter(new Uint8Array(buffer)); - expect(adapter.listFiles()).toContain("foo.txt"); - const contents = await adapter.readFile("foo.txt"); - expect(Buffer.from(contents).toString("utf8")).toBe("hello"); + expect(adapter.listFiles()).toContain('foo.txt'); + const contents = await adapter.readFile('foo.txt'); + expect(Buffer.from(contents).toString('utf8')).toBe('hello'); }); - it("reads entries from a zip file path", async () => { - const tempDir = createTempDir("aac-zip-test-"); - const zipPath = path.join(tempDir, "sample.zip"); + it('reads entries from a zip file path', async () => { + const tempDir = createTempDir('aac-zip-test-'); + const zipPath = path.join(tempDir, 'sample.zip'); try { const zip = new AdmZip(); - zip.addFile("bar.txt", Buffer.from("world", "utf8")); + zip.addFile('bar.txt', Buffer.from('world', 'utf8')); zip.writeZip(zipPath); const adapter = await getZipAdapter(zipPath); - expect(adapter.listFiles()).toContain("bar.txt"); - const contents = await adapter.readFile("bar.txt"); - expect(Buffer.from(contents).toString("utf8")).toBe("world"); + expect(adapter.listFiles()).toContain('bar.txt'); + const contents = await adapter.readFile('bar.txt'); + expect(Buffer.from(contents).toString('utf8')).toBe('world'); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } diff --git a/test/validation.coverage.test.ts b/test/validation.coverage.test.ts index 79c8b5d..a0c8deb 100644 --- a/test/validation.coverage.test.ts +++ b/test/validation.coverage.test.ts @@ -3,17 +3,17 @@ import { GridsetValidator, SnapValidator, TouchChatValidator, -} from "../src/validation"; -import JSZip from "jszip"; -import fs from "fs"; -import os from "os"; -import path from "path"; -import AdmZip from "adm-zip"; -import Database from "better-sqlite3"; +} from '../src/validation'; +import JSZip from 'jszip'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import AdmZip from 'adm-zip'; +import Database from 'better-sqlite3'; function createSqliteDbBuffer(schemaSql: string, insertSql?: string): Buffer { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aac-test-sqlite-")); - const dbPath = path.join(dir, "db.sqlite"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aac-test-sqlite-')); + const dbPath = path.join(dir, 'db.sqlite'); const db = new Database(dbPath); try { db.exec(schemaSql); @@ -28,28 +28,28 @@ function createSqliteDbBuffer(schemaSql: string, insertSql?: string): Buffer { return buffer; } -describe("Validation Coverage Tests", () => { - describe("ObfValidator - Extended Coverage", () => { - it("should validate button with all valid attributes", async () => { +describe('Validation Coverage Tests', () => { + describe('ObfValidator - Extended Coverage', () => { + it('should validate button with all valid attributes', async () => { const validObf = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [ { id: 1, - label: "Test Button", - vocalization: "test", - image_id: "img1", - sound_id: "snd1", + label: 'Test Button', + vocalization: 'test', + image_id: 'img1', + sound_id: 'snd1', hidden: false, - background_color: "rgb(255, 0, 0)", - border_color: "rgba(255, 0, 0, 0.5)", - action: ":speak", - actions: [":speak", ":back"], + background_color: 'rgb(255, 0, 0)', + border_color: 'rgba(255, 0, 0, 0.5)', + action: ':speak', + actions: [':speak', ':back'], load_board: { - path: "other.obf", + path: 'other.obf', }, top: 0, left: 0, @@ -64,42 +64,38 @@ describe("Validation Coverage Tests", () => { }, images: [ { - id: "img1", + id: 'img1', width: 100, height: 100, - content_type: "image/png", - url: "http://example.com/img.png", + content_type: 'image/png', + url: 'http://example.com/img.png', }, ], sounds: [ { - id: "snd1", + id: 'snd1', duration: 1.5, - content_type: "audio/wav", - path: "/sounds/test.wav", + content_type: 'audio/wav', + path: '/sounds/test.wav', }, ], }; const content = Buffer.from(JSON.stringify(validObf)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.valid).toBe(true); expect(result.errors).toBe(0); }); - it("should validate background attribute", async () => { + it('should validate background attribute', async () => { const obfWithBackground = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', background: { - color: "rgb(255, 255, 255)", + color: 'rgb(255, 255, 255)', }, buttons: [], grid: { @@ -112,22 +108,18 @@ describe("Validation Coverage Tests", () => { }; const content = Buffer.from(JSON.stringify(obfWithBackground)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.valid).toBe(true); }); - it("should reject invalid background attribute", async () => { + it('should reject invalid background attribute', async () => { const obfWithBadBackground = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", - background: "not-an-object", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', + background: 'not-an-object', buttons: [], grid: { rows: 1, @@ -139,27 +131,23 @@ describe("Validation Coverage Tests", () => { }; const content = Buffer.from(JSON.stringify(obfWithBadBackground)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.valid).toBe(false); expect(result.errors).toBeGreaterThan(0); }); - it("should validate button with ext_ prefix attributes", async () => { + it('should validate button with ext_ prefix attributes', async () => { const obfWithExt = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [ { id: 1, - label: "Test", - ext_custom_field: "allowed", + label: 'Test', + ext_custom_field: 'allowed', }, ], grid: { @@ -172,27 +160,23 @@ describe("Validation Coverage Tests", () => { }; const content = Buffer.from(JSON.stringify(obfWithExt)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); // Should have warning but still be valid expect(result.errors).toBe(0); }); - it("should reject button without action prefix", async () => { + it('should reject button without action prefix', async () => { const obfWithBadAction = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [ { id: 1, - label: "Test", - action: "invalid-action", // Missing : or + prefix + label: 'Test', + action: 'invalid-action', // Missing : or + prefix }, ], grid: { @@ -205,21 +189,17 @@ describe("Validation Coverage Tests", () => { }; const content = Buffer.from(JSON.stringify(obfWithBadAction)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.valid).toBe(false); }); - it("should validate image with all attributes", async () => { + it('should validate image with all attributes', async () => { const obfWithFullImage = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [], grid: { rows: 1, @@ -228,35 +208,31 @@ describe("Validation Coverage Tests", () => { }, images: [ { - id: "img1", + id: 'img1', width: 100, height: 100, - content_type: "image/png", - data: "base64data", - url: "http://example.com", - path: "/path/to/image.png", - data_url: "data:image/png;base64,iVBORw0KG...", + content_type: 'image/png', + data: 'base64data', + url: 'http://example.com', + path: '/path/to/image.png', + data_url: 'data:image/png;base64,iVBORw0KG...', }, ], sounds: [], }; const content = Buffer.from(JSON.stringify(obfWithFullImage)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.valid).toBe(true); }); - it("should validate sound with all attributes", async () => { + it('should validate sound with all attributes', async () => { const obfWithFullSound = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [], grid: { rows: 1, @@ -266,32 +242,28 @@ describe("Validation Coverage Tests", () => { images: [], sounds: [ { - id: "snd1", + id: 'snd1', duration: 2.5, - content_type: "audio/mp3", - data: "base64data", - url: "http://example.com/sound.mp3", - path: "/sounds/test.mp3", + content_type: 'audio/mp3', + data: 'base64data', + url: 'http://example.com/sound.mp3', + path: '/sounds/test.mp3', }, ], }; const content = Buffer.from(JSON.stringify(obfWithFullSound)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.valid).toBe(true); }); - it("should warn about old format version", async () => { + it('should warn about old format version', async () => { const obfOldVersion = { - format: "open-board-0.0", // Old version - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.0', // Old version + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [], grid: { rows: 1, @@ -303,26 +275,21 @@ describe("Validation Coverage Tests", () => { }; const content = Buffer.from(JSON.stringify(obfOldVersion)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.warnings).toBeGreaterThan(0); const hasVersionWarning = result.results.some( - (r) => - r.type === "format_version" && r.warnings && r.warnings.length > 0, + (r) => r.type === 'format_version' && r.warnings && r.warnings.length > 0 ); expect(hasVersionWarning).toBe(true); }); - it("should reject future format version", async () => { + it('should reject future format version', async () => { const obfFutureVersion = { - format: "open-board-99.9", // Future version - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-99.9', // Future version + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [], grid: { rows: 1, @@ -334,22 +301,18 @@ describe("Validation Coverage Tests", () => { }; const content = Buffer.from(JSON.stringify(obfFutureVersion)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.valid).toBe(false); }); - it("should validate ext_ prefixed board attributes", async () => { + it('should validate ext_ prefixed board attributes', async () => { const obfWithExt = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", - ext_custom_data: "allowed", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', + ext_custom_data: 'allowed', ext_another_field: { valid: true }, buttons: [], grid: { @@ -362,21 +325,17 @@ describe("Validation Coverage Tests", () => { }; const content = Buffer.from(JSON.stringify(obfWithExt)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.errors).toBe(0); }); - it("should reject invalid sound duration", async () => { + it('should reject invalid sound duration', async () => { const obfWithBadDuration = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [], grid: { rows: 1, @@ -386,53 +345,49 @@ describe("Validation Coverage Tests", () => { images: [], sounds: [ { - id: "snd1", + id: 'snd1', duration: -1.5, // Negative duration - content_type: "audio/wav", - path: "/sounds/test.wav", + content_type: 'audio/wav', + path: '/sounds/test.wav', }, ], }; const content = Buffer.from(JSON.stringify(obfWithBadDuration)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.valid).toBe(false); }); }); - describe("ObfValidator - OBZ Coverage", () => { - it("should validate complete OBZ structure", async () => { + describe('ObfValidator - OBZ Coverage', () => { + it('should validate complete OBZ structure', async () => { const zip = new JSZip(); // Create manifest.json const manifest = { - format: "open-board-0.1", - root: "root.obf", + format: 'open-board-0.1', + root: 'root.obf', paths: { boards: { - board1: "boards/board1.obf", + board1: 'boards/board1.obf', }, images: { - img1: "images/img1.png", + img1: 'images/img1.png', }, sounds: { - snd1: "sounds/snd1.wav", + snd1: 'sounds/snd1.wav', }, }, }; - zip.file("manifest.json", JSON.stringify(manifest)); + zip.file('manifest.json', JSON.stringify(manifest)); // Create root board const rootBoard = { - format: "open-board-0.1", - id: "board1", - locale: "en", - name: "Root Board", + format: 'open-board-0.1', + id: 'board1', + locale: 'en', + name: 'Root Board', buttons: [], grid: { rows: 1, @@ -442,87 +397,73 @@ describe("Validation Coverage Tests", () => { images: [], sounds: [], }; - zip.file("boards/board1.obf", JSON.stringify(rootBoard)); + zip.file('boards/board1.obf', JSON.stringify(rootBoard)); // Create placeholder image and sound files - zip.file("images/img1.png", "fake-image-data"); - zip.file("sounds/snd1.wav", "fake-sound-data"); - - const content = await zip.generateAsync({ type: "nodebuffer" }); - const result = await new ObfValidator().validate( - content, - "test.obz", - content.length, - ); + zip.file('images/img1.png', 'fake-image-data'); + zip.file('sounds/snd1.wav', 'fake-sound-data'); - expect(result.format).toBe("obz"); - expect(result.filename).toBe("test.obz"); + const content = await zip.generateAsync({ type: 'nodebuffer' }); + const result = await new ObfValidator().validate(content, 'test.obz', content.length); + + expect(result.format).toBe('obz'); + expect(result.filename).toBe('test.obz'); }); - it("should detect missing manifest in OBZ", async () => { + it('should detect missing manifest in OBZ', async () => { const zip = new JSZip(); - zip.file("somefile.txt", "content"); + zip.file('somefile.txt', 'content'); - const content = await zip.generateAsync({ type: "nodebuffer" }); - const result = await new ObfValidator().validate( - content, - "test.obz", - content.length, - ); + const content = await zip.generateAsync({ type: 'nodebuffer' }); + const result = await new ObfValidator().validate(content, 'test.obz', content.length); expect(result.valid).toBe(false); - const hasManifestError = result.results.some( - (r) => r.type === "manifest" && r.error, - ); + const hasManifestError = result.results.some((r) => r.type === 'manifest' && r.error); expect(hasManifestError).toBe(true); }); - it("should detect manifest root file not in zip", async () => { + it('should detect manifest root file not in zip', async () => { const zip = new JSZip(); const manifest = { - format: "open-board-0.1", - root: "missing.obf", // File doesn't exist + format: 'open-board-0.1', + root: 'missing.obf', // File doesn't exist paths: { boards: {}, images: {}, sounds: {}, }, }; - zip.file("manifest.json", JSON.stringify(manifest)); + zip.file('manifest.json', JSON.stringify(manifest)); - const content = await zip.generateAsync({ type: "nodebuffer" }); - const result = await new ObfValidator().validate( - content, - "test.obz", - content.length, - ); + const content = await zip.generateAsync({ type: 'nodebuffer' }); + const result = await new ObfValidator().validate(content, 'test.obz', content.length); expect(result.valid).toBe(false); }); - it("should detect board ID mismatch in manifest", async () => { + it('should detect board ID mismatch in manifest', async () => { const zip = new JSZip(); const manifest = { - format: "open-board-0.1", - root: "boards/board1.obf", + format: 'open-board-0.1', + root: 'boards/board1.obf', paths: { boards: { - board1: "boards/board1.obf", + board1: 'boards/board1.obf', }, images: {}, sounds: {}, }, }; - zip.file("manifest.json", JSON.stringify(manifest)); + zip.file('manifest.json', JSON.stringify(manifest)); // Board has different ID than manifest claims const board = { - format: "open-board-0.1", - id: "different-id", // Mismatch! - locale: "en", - name: "Board", + format: 'open-board-0.1', + id: 'different-id', // Mismatch! + locale: 'en', + name: 'Board', buttons: [], grid: { rows: 1, @@ -532,37 +473,33 @@ describe("Validation Coverage Tests", () => { images: [], sounds: [], }; - zip.file("boards/board1.obf", JSON.stringify(board)); + zip.file('boards/board1.obf', JSON.stringify(board)); - const content = await zip.generateAsync({ type: "nodebuffer" }); - const result = await new ObfValidator().validate( - content, - "test.obz", - content.length, - ); + const content = await zip.generateAsync({ type: 'nodebuffer' }); + const result = await new ObfValidator().validate(content, 'test.obz', content.length); expect(result.valid).toBe(false); }); - it("should validate manifest paths structure", async () => { + it('should validate manifest paths structure', async () => { const zip = new JSZip(); const manifest = { - format: "open-board-0.1", - root: "root.obf", + format: 'open-board-0.1', + root: 'root.obf', paths: { boards: {}, images: {}, sounds: {}, }, }; - zip.file("manifest.json", JSON.stringify(manifest)); + zip.file('manifest.json', JSON.stringify(manifest)); const rootBoard = { - format: "open-board-0.1", - id: "root", - locale: "en", - name: "Root", + format: 'open-board-0.1', + id: 'root', + locale: 'en', + name: 'Root', buttons: [], grid: { rows: 1, @@ -572,21 +509,17 @@ describe("Validation Coverage Tests", () => { images: [], sounds: [], }; - zip.file("root.obf", JSON.stringify(rootBoard)); + zip.file('root.obf', JSON.stringify(rootBoard)); - const content = await zip.generateAsync({ type: "nodebuffer" }); - const result = await new ObfValidator().validate( - content, - "test.obz", - content.length, - ); + const content = await zip.generateAsync({ type: 'nodebuffer' }); + const result = await new ObfValidator().validate(content, 'test.obz', content.length); expect(result.valid).toBe(true); }); }); - describe("GridsetValidator - Extended Coverage", () => { - it("should validate gridset with all required elements", async () => { + describe('GridsetValidator - Extended Coverage', () => { + it('should validate gridset with all required elements', async () => { const fullGridset = ` @@ -603,17 +536,13 @@ describe("Validation Coverage Tests", () => { `; const content = Buffer.from(fullGridset); - const result = await new GridsetValidator().validate( - content, - "test.gridset", - content.length, - ); + const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); expect(result).toBeDefined(); - expect(result.format).toBe("gridset"); + expect(result.format).toBe('gridset'); }); - it("should handle gridset with wordlists", async () => { + it('should handle gridset with wordlists', async () => { const gridsetWithWordlists = ` @@ -630,34 +559,26 @@ describe("Validation Coverage Tests", () => { `; const content = Buffer.from(gridsetWithWordlists); - const result = await new GridsetValidator().validate( - content, - "test.gridset", - content.length, - ); + const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); expect(result).toBeDefined(); - expect(result.format).toBe("gridset"); + expect(result.format).toBe('gridset'); }); - it("should detect missing pages element", async () => { + it('should detect missing pages element', async () => { const gridsetWithoutPages = ` `; const content = Buffer.from(gridsetWithoutPages); - const result = await new GridsetValidator().validate( - content, - "test.gridset", - content.length, - ); + const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); expect(result).toBeDefined(); // Should have warnings about missing pages }); - it("should detect missing fixedCellSize", async () => { + it('should detect missing fixedCellSize', async () => { const gridsetWithoutCellSize = ` @@ -670,42 +591,30 @@ describe("Validation Coverage Tests", () => { `; const content = Buffer.from(gridsetWithoutCellSize); - const result = await new GridsetValidator().validate( - content, - "test.gridset", - content.length, - ); + const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); expect(result).toBeDefined(); // Should have warnings about missing cell size }); }); - describe("SnapValidator - Extended Coverage", () => { - it("should detect invalid zip format", async () => { - const notAZip = Buffer.from("This is not a zip file"); - const result = await new SnapValidator().validate( - notAZip, - "test.spb", - notAZip.length, - ); + describe('SnapValidator - Extended Coverage', () => { + it('should detect invalid zip format', async () => { + const notAZip = Buffer.from('This is not a zip file'); + const result = await new SnapValidator().validate(notAZip, 'test.spb', notAZip.length); expect(result.valid).toBe(false); }); - it("should validate .sps extension", async () => { - const notAZip = Buffer.from("Not a zip"); - const result = await new SnapValidator().validate( - notAZip, - "test.sps", - notAZip.length, - ); + it('should validate .sps extension', async () => { + const notAZip = Buffer.from('Not a zip'); + const result = await new SnapValidator().validate(notAZip, 'test.sps', notAZip.length); expect(result.valid).toBe(false); - expect(result.format).toBe("snap"); + expect(result.format).toBe('snap'); }); - it("should validate sqlite-based Snap files", async () => { + it('should validate sqlite-based Snap files', async () => { const sqliteBuffer = createSqliteDbBuffer(` CREATE TABLE Page (Id INTEGER PRIMARY KEY, UniqueId TEXT, Name TEXT, Title TEXT); CREATE TABLE Button (Id INTEGER PRIMARY KEY, Label TEXT, Message TEXT); @@ -716,17 +625,17 @@ describe("Validation Coverage Tests", () => { const result = await new SnapValidator().validate( sqliteBuffer, - "test.sps", - sqliteBuffer.length, + 'test.sps', + sqliteBuffer.length ); expect(result.valid).toBe(true); - expect(result.format).toBe("snap"); + expect(result.format).toBe('snap'); }); }); - describe("TouchChatValidator - Extended Coverage", () => { - it("should validate TouchChat zip with sqlite db", async () => { + describe('TouchChatValidator - Extended Coverage', () => { + it('should validate TouchChat zip with sqlite db', async () => { const sqliteBuffer = createSqliteDbBuffer( ` CREATE TABLE resources (id INTEGER PRIMARY KEY, name TEXT); @@ -743,25 +652,21 @@ describe("Validation Coverage Tests", () => { INSERT INTO button_boxes (id) VALUES (1); INSERT INTO button_box_cells (id, resource_id, button_box_id) VALUES (1, 1, 1); INSERT INTO button_box_instances (id, page_id, button_box_id) VALUES (1, 1, 1); - `, + ` ); const zip = new AdmZip(); - zip.addFile("vocab.c4v", sqliteBuffer); + zip.addFile('vocab.c4v', sqliteBuffer); const content = zip.toBuffer(); - const result = await new TouchChatValidator().validate( - content, - "test.ce", - content.length, - ); + const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); expect(result).toBeDefined(); - expect(result.format).toBe("touchchat"); + expect(result.format).toBe('touchchat'); expect(result.valid).toBe(true); }); - it("should validate complete TouchChat structure", async () => { + it('should validate complete TouchChat structure', async () => { const completeTouchChat = ` @@ -775,34 +680,26 @@ describe("Validation Coverage Tests", () => { `; const content = Buffer.from(completeTouchChat); - const result = await new TouchChatValidator().validate( - content, - "test.ce", - content.length, - ); + const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); expect(result).toBeDefined(); - expect(result.format).toBe("touchchat"); + expect(result.format).toBe('touchchat'); expect(result.valid).toBe(true); }); - it("should detect missing Pages element", async () => { + it('should detect missing Pages element', async () => { const touchChatWithoutPages = ` `; const content = Buffer.from(touchChatWithoutPages); - const result = await new TouchChatValidator().validate( - content, - "test.ce", - content.length, - ); + const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); expect(result).toBeDefined(); // Should have warnings about missing pages }); - it("should validate Page without Buttons", async () => { + it('should validate Page without Buttons', async () => { const touchChatWithoutButtons = ` @@ -811,17 +708,13 @@ describe("Validation Coverage Tests", () => { `; const content = Buffer.from(touchChatWithoutButtons); - const result = await new TouchChatValidator().validate( - content, - "test.ce", - content.length, - ); + const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); expect(result).toBeDefined(); // Should have warnings about missing buttons }); - it("should validate Button with minimal attributes", async () => { + it('should validate Button with minimal attributes', async () => { const minimalTouchChat = ` @@ -834,14 +727,10 @@ describe("Validation Coverage Tests", () => { `; const content = Buffer.from(minimalTouchChat); - const result = await new TouchChatValidator().validate( - content, - "test.ce", - content.length, - ); + const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); expect(result).toBeDefined(); - expect(result.format).toBe("touchchat"); + expect(result.format).toBe('touchchat'); }); }); }); diff --git a/test/validation.newFormats.test.ts b/test/validation.newFormats.test.ts index 9d5e1f1..13aaaaf 100644 --- a/test/validation.newFormats.test.ts +++ b/test/validation.newFormats.test.ts @@ -1,85 +1,73 @@ -import path from "path"; -import plist from "plist"; -import ExcelJS from "exceljs"; -import { - validateFileOrBuffer, - ValidationFailureError, -} from "../src/validation"; -import { OpmlProcessor } from "../src/processors/opmlProcessor"; -import { DotProcessor } from "../src/processors/dotProcessor"; -import { defaultFileAdapter } from "../src/utils/io"; +import path from 'path'; +import plist from 'plist'; +import ExcelJS from 'exceljs'; +import { validateFileOrBuffer, ValidationFailureError } from '../src/validation'; +import { OpmlProcessor } from '../src/processors/opmlProcessor'; +import { DotProcessor } from '../src/processors/dotProcessor'; +import { defaultFileAdapter } from '../src/utils/io'; -describe("Validation - additional formats", () => { - const asset = (...parts: string[]): string => - path.join(__dirname, "assets", ...parts); +describe('Validation - additional formats', () => { + const asset = (...parts: string[]): string => path.join(__dirname, 'assets', ...parts); - it("validates Asterics .grd assets", async () => { - const filePath = asset("asterics", "example.grd"); + it('validates Asterics .grd assets', async () => { + const filePath = asset('asterics', 'example.grd'); const result = await validateFileOrBuffer(filePath); expect(result.valid).toBe(true); - expect(result.format).toBe("asterics"); + expect(result.format).toBe('asterics'); }); - it("validates OPML asset", async () => { - const filePath = asset("opml", "example.opml"); + it('validates OPML asset', async () => { + const filePath = asset('opml', 'example.opml'); const result = await validateFileOrBuffer(filePath); expect(result.valid).toBe(true); - expect(result.format).toBe("opml"); + expect(result.format).toBe('opml'); }); - it("validates DOT asset", async () => { - const filePath = asset("dot", "example.dot"); + it('validates DOT asset', async () => { + const filePath = asset('dot', 'example.dot'); const result = await validateFileOrBuffer(filePath); expect(result.valid).toBe(true); - expect(result.format).toBe("dot"); + expect(result.format).toBe('dot'); }); - it("validates Excel workbook buffers", async () => { + it('validates Excel workbook buffers', async () => { const workbook = new ExcelJS.Workbook(); - const sheet = workbook.addWorksheet("Page 1"); - sheet.getCell("A1").value = "Hello"; - sheet.getCell("B2").value = "World"; + const sheet = workbook.addWorksheet('Page 1'); + sheet.getCell('A1').value = 'Hello'; + sheet.getCell('B2').value = 'World'; const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); - const result = await validateFileOrBuffer( - buffer, - defaultFileAdapter, - "sample.xlsx", - ); + const result = await validateFileOrBuffer(buffer, defaultFileAdapter, 'sample.xlsx'); expect(result.valid).toBe(true); - expect(result.format).toBe("excel"); + expect(result.format).toBe('excel'); }); - it("fails legacy .xls with structured result", async () => { + it('fails legacy .xls with structured result', async () => { const workbook = new ExcelJS.Workbook(); - workbook.addWorksheet("Sheet 1").getCell("A1").value = "legacy"; + workbook.addWorksheet('Sheet 1').getCell('A1').value = 'legacy'; const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); - const result = await validateFileOrBuffer( - buffer, - defaultFileAdapter, - "legacy.xls", - ); + const result = await validateFileOrBuffer(buffer, defaultFileAdapter, 'legacy.xls'); expect(result.valid).toBe(false); expect(result.errors).toBeGreaterThan(0); - expect(result.results.some((c) => c.error?.includes(".xls"))).toBe(true); + expect(result.results.some((c) => c.error?.includes('.xls'))).toBe(true); }); - it("validates Apple Panels plist buffers", async () => { + it('validates Apple Panels plist buffers', async () => { const plistContent = plist.build({ Panels: { panel1: { - ID: "panel1", - Name: "Panel 1", + ID: 'panel1', + Name: 'Panel 1', PanelObjects: [ { - PanelObjectType: "Button", - DisplayText: "Hi", - Rect: "{{0,0},{100,100}}", + PanelObjectType: 'Button', + DisplayText: 'Hi', + Rect: '{{0,0},{100,100}}', Actions: [ { - ActionType: "ActionPressKeyCharSequence", - ActionParam: { CharString: "Hi" }, + ActionType: 'ActionPressKeyCharSequence', + ActionParam: { CharString: 'Hi' }, }, ], }, @@ -87,50 +75,38 @@ describe("Validation - additional formats", () => { }, }, }); - const buffer = Buffer.from(plistContent, "utf8"); - const result = await validateFileOrBuffer( - buffer, - defaultFileAdapter, - "panel.plist", - ); + const buffer = Buffer.from(plistContent, 'utf8'); + const result = await validateFileOrBuffer(buffer, defaultFileAdapter, 'panel.plist'); expect(result.valid).toBe(true); - expect(result.format).toBe("applepanels"); + expect(result.format).toBe('applepanels'); }); - it("validates OBFSet bundles", async () => { + it('validates OBFSet bundles', async () => { const obfset = [ { - id: "board1", - buttons: [{ id: "b1", label: "Hi" }], + id: 'board1', + buttons: [{ id: 'b1', label: 'Hi' }], grid: { rows: 1, columns: 1 }, }, { - id: "board2", - buttons: [{ id: "b2", label: "There" }], + id: 'board2', + buttons: [{ id: 'b2', label: 'There' }], grid: { rows: 1, columns: 1 }, }, ]; const buffer = Buffer.from(JSON.stringify(obfset)); - const result = await validateFileOrBuffer( - buffer, - defaultFileAdapter, - "bundle.obfset", - ); + const result = await validateFileOrBuffer(buffer, defaultFileAdapter, 'bundle.obfset'); expect(result.valid).toBe(true); - expect(result.format).toBe("obfset"); + expect(result.format).toBe('obfset'); }); - it("exposes ValidationResult on OPML parse failures", async () => { - const invalid = Buffer.from("", "utf8"); - await expect(new OpmlProcessor().loadIntoTree(invalid)).rejects.toThrow( - ValidationFailureError, - ); + it('exposes ValidationResult on OPML parse failures', async () => { + const invalid = Buffer.from('', 'utf8'); + await expect(new OpmlProcessor().loadIntoTree(invalid)).rejects.toThrow(ValidationFailureError); }); - it("exposes ValidationResult on DOT binary content", async () => { + it('exposes ValidationResult on DOT binary content', async () => { const invalid = Buffer.from([0, 1, 2, 3]); - await expect(new DotProcessor().loadIntoTree(invalid)).rejects.toThrow( - ValidationFailureError, - ); + await expect(new DotProcessor().loadIntoTree(invalid)).rejects.toThrow(ValidationFailureError); }); }); diff --git a/test/validation.test.ts b/test/validation.test.ts index bb4016d..bd539e5 100644 --- a/test/validation.test.ts +++ b/test/validation.test.ts @@ -1,27 +1,26 @@ -import { Validation } from "../src/index"; -import path from "path"; +import { Validation } from '../src/index'; +import path from 'path'; // Destructure for convenience -const { ObfValidator, GridsetValidator, SnapValidator, TouchChatValidator } = - Validation; +const { ObfValidator, GridsetValidator, SnapValidator, TouchChatValidator } = Validation; type ValidationResult = Validation.ValidationResult; -const samplesDir = path.join(__dirname, "..", "examples", "obf"); +const samplesDir = path.join(__dirname, '..', 'examples', 'obf'); -describe("Validation System", () => { - describe("ObfValidator - Real File Tests (validation samples from obf-node)", () => { - it("should validate simple.obf successfully", async () => { - const filePath = path.join(samplesDir, "simple.obf"); +describe('Validation System', () => { + describe('ObfValidator - Real File Tests (validation samples from obf-node)', () => { + it('should validate simple.obf successfully', async () => { + const filePath = path.join(samplesDir, 'simple.obf'); const result = await ObfValidator.validateFile(filePath); expect(result.valid).toBe(true); expect(result.errors).toBe(0); - expect(result.format).toBe("obf"); - expect(result.filename).toBe("simple.obf"); + expect(result.format).toBe('obf'); + expect(result.filename).toBe('simple.obf'); }); - it("should identify aboutme.json as invalid OBF (missing locale)", async () => { - const filePath = path.join(samplesDir, "aboutme.json"); + it('should identify aboutme.json as invalid OBF (missing locale)', async () => { + const filePath = path.join(samplesDir, 'aboutme.json'); const result = await ObfValidator.validateFile(filePath); // aboutme.json is missing required fields like locale @@ -29,39 +28,39 @@ describe("Validation System", () => { expect(result.errors).toBeGreaterThan(0); }); - it("should identify hash.json as non-OBF JSON", async () => { - const filePath = path.join(samplesDir, "hash.json"); + it('should identify hash.json as non-OBF JSON', async () => { + const filePath = path.join(samplesDir, 'hash.json'); const result = await ObfValidator.validateFile(filePath); expect(result.valid).toBe(false); expect(result.errors).toBeGreaterThanOrEqual(1); }); - it("should identify array.json as non-object JSON", async () => { - const filePath = path.join(samplesDir, "array.json"); + it('should identify array.json as non-object JSON', async () => { + const filePath = path.join(samplesDir, 'array.json'); const result = await ObfValidator.validateFile(filePath); expect(result.valid).toBe(false); expect(result.errors).toBeGreaterThanOrEqual(1); }); - it("should validate links.obz", async () => { - const filePath = path.join(samplesDir, "links.obz"); + it('should validate links.obz', async () => { + const filePath = path.join(samplesDir, 'links.obz'); const result = await ObfValidator.validateFile(filePath); - expect(result.filename).toBe("links.obz"); - expect(result.format).toBe("obz"); + expect(result.filename).toBe('links.obz'); + expect(result.format).toBe('obz'); // OBZ files may have warnings but should be valid }); }); - describe("ObfValidator - Synthetic Tests", () => { - it("should validate a minimal valid OBF structure", async () => { + describe('ObfValidator - Synthetic Tests', () => { + it('should validate a minimal valid OBF structure', async () => { const validObf = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [], grid: { rows: 2, @@ -76,41 +75,33 @@ describe("Validation System", () => { }; const content = Buffer.from(JSON.stringify(validObf)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result).toBeDefined(); expect(result.valid).toBe(true); - expect(result.format).toBe("obf"); + expect(result.format).toBe('obf'); expect(result.errors).toBe(0); }); - it("should detect missing required fields", async () => { + it('should detect missing required fields', async () => { const invalidObf = { - format: "open-board-0.1", + format: 'open-board-0.1', // Missing id, locale, name, buttons, grid, images, sounds }; const content = Buffer.from(JSON.stringify(invalidObf)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.valid).toBe(false); expect(result.errors).toBeGreaterThan(0); }); - it("should validate filename extension", async () => { + it('should validate filename extension', async () => { const validObf = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [], grid: { rows: 1, @@ -124,24 +115,24 @@ describe("Validation System", () => { const content = Buffer.from(JSON.stringify(validObf)); const result = await new ObfValidator().validate( content, - "test.txt", // Wrong extension - content.length, + 'test.txt', // Wrong extension + content.length ); // Should have a warning about filename const hasFilenameWarning = result.results.some( - (r) => r.type === "filename" && r.warnings && r.warnings.length > 0, + (r) => r.type === 'filename' && r.warnings && r.warnings.length > 0 ); expect(hasFilenameWarning).toBe(true); }); - it("should validate grid structure", async () => { + it('should validate grid structure', async () => { const obfWithBadGrid = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", - buttons: [{ id: 1, label: "Test" }], + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', + buttons: [{ id: 1, label: 'Test' }], grid: { rows: 2, columns: 2, @@ -152,19 +143,15 @@ describe("Validation System", () => { }; const content = Buffer.from(JSON.stringify(obfWithBadGrid)); - const result = await new ObfValidator().validate( - content, - "test.obf", - content.length, - ); + const result = await new ObfValidator().validate(content, 'test.obf', content.length); expect(result.valid).toBe(false); // Should have error about grid order length }); }); - describe("GridsetValidator", () => { - it("should validate basic Gridset XML structure", async () => { + describe('GridsetValidator', () => { + it('should validate basic Gridset XML structure', async () => { const validGridset = ` @@ -178,53 +165,44 @@ describe("Validation System", () => { `; const content = Buffer.from(validGridset); - const result = await new GridsetValidator().validate( - content, - "test.gridset", - content.length, - ); + const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); expect(result).toBeDefined(); - expect(result.format).toBe("gridset"); + expect(result.format).toBe('gridset'); // May have warnings but should parse successfully }); - it("should detect invalid XML", async () => { + it('should detect invalid XML', async () => { const invalidXml = ` `; // Unclosed tags const content = Buffer.from(invalidXml); - const result = await new GridsetValidator().validate( - content, - "test.gridset", - content.length, - ); + const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); expect(result.valid).toBe(false); }); - it("should handle encrypted .gridsetx files", async () => { + it('should handle encrypted .gridsetx files', async () => { // .gridsetx files are encrypted, so we just validate the extension - const encryptedContent = Buffer.from("encrypted binary data"); + const encryptedContent = Buffer.from('encrypted binary data'); const result = await new GridsetValidator().validate( encryptedContent, - "test.gridsetx", - encryptedContent.length, + 'test.gridsetx', + encryptedContent.length ); expect(result).toBeDefined(); - expect(result.format).toBe("gridset"); + expect(result.format).toBe('gridset'); // Should have warning about encryption const hasEncryptionWarning = result.results.some( - (r) => - r.type === "encrypted_format" && r.warnings && r.warnings.length > 0, + (r) => r.type === 'encrypted_format' && r.warnings && r.warnings.length > 0 ); expect(hasEncryptionWarning).toBe(true); }); - it("should not require wordlists element", async () => { + it('should not require wordlists element', async () => { const gridsetWithoutWordlists = ` @@ -238,37 +216,33 @@ describe("Validation System", () => { `; const content = Buffer.from(gridsetWithoutWordlists); - const result = await new GridsetValidator().validate( - content, - "test.gridset", - content.length, - ); + const result = await new GridsetValidator().validate(content, 'test.gridset', content.length); expect(result).toBeDefined(); - expect(result.format).toBe("gridset"); + expect(result.format).toBe('gridset'); // Should NOT have warning about missing wordlists const hasWordlistsWarning = result.results.some( - (r) => r.type === "wordlists" && r.warnings && r.warnings.length > 0, + (r) => r.type === 'wordlists' && r.warnings && r.warnings.length > 0 ); expect(hasWordlistsWarning).toBe(false); }); }); - describe("SnapValidator", () => { - it("should validate a basic zip package structure", async () => { + describe('SnapValidator', () => { + it('should validate a basic zip package structure', async () => { // Create a minimal valid zip with settings.xml // Note: This test would require creating a real zip file // For now, we'll test with an empty buffer which should fail - const content = Buffer.from(""); - const result = await new SnapValidator().validate(content, "test.spb", 0); + const content = Buffer.from(''); + const result = await new SnapValidator().validate(content, 'test.spb', 0); // Should fail with zip error expect(result.valid).toBe(false); }); }); - describe("TouchChatValidator", () => { - it("should validate basic TouchChat XML structure", async () => { + describe('TouchChatValidator', () => { + it('should validate basic TouchChat XML structure', async () => { const validTouchChat = ` @@ -281,40 +255,32 @@ describe("Validation System", () => { `; const content = Buffer.from(validTouchChat); - const result = await new TouchChatValidator().validate( - content, - "test.ce", - content.length, - ); + const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); expect(result).toBeDefined(); - expect(result.format).toBe("touchchat"); + expect(result.format).toBe('touchchat'); }); - it("should detect missing required elements", async () => { + it('should detect missing required elements', async () => { const invalidXml = ` `; const content = Buffer.from(invalidXml); - const result = await new TouchChatValidator().validate( - content, - "test.ce", - content.length, - ); + const result = await new TouchChatValidator().validate(content, 'test.ce', content.length); // May have warnings about missing content expect(result).toBeDefined(); }); }); - describe("ValidationResult structure", () => { - it("should have all required fields", async () => { + describe('ValidationResult structure', () => { + it('should have all required fields', async () => { const validObf = { - format: "open-board-0.1", - id: "test-board", - locale: "en", - name: "Test Board", + format: 'open-board-0.1', + id: 'test-board', + locale: 'en', + name: 'Test Board', buttons: [], grid: { rows: 1, @@ -328,16 +294,16 @@ describe("Validation System", () => { const content = Buffer.from(JSON.stringify(validObf)); const result: ValidationResult = await new ObfValidator().validate( content, - "test.obf", - content.length, + 'test.obf', + content.length ); - expect(result.filename).toBe("test.obf"); + expect(result.filename).toBe('test.obf'); expect(result.filesize).toBe(content.length); - expect(result.format).toBe("obf"); - expect(typeof result.valid).toBe("boolean"); - expect(typeof result.errors).toBe("number"); - expect(typeof result.warnings).toBe("number"); + expect(result.format).toBe('obf'); + expect(typeof result.valid).toBe('boolean'); + expect(typeof result.errors).toBe('number'); + expect(typeof result.warnings).toBe('number'); expect(Array.isArray(result.results)).toBe(true); }); }); diff --git a/test/wordFormGenerator.test.ts b/test/wordFormGenerator.test.ts index cd04782..d8ec777 100644 --- a/test/wordFormGenerator.test.ts +++ b/test/wordFormGenerator.test.ts @@ -1,235 +1,213 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { WordFormGenerator } from "../src/utilities/analytics/morphology/wordFormGenerator"; -import { MorphologyEngine } from "../src/utilities/analytics/morphology/engine"; -import { Grid3VerbsParser } from "../src/utilities/analytics/morphology/grid3VerbsParser"; -import { join } from "path"; +import { WordFormGenerator } from '../src/utilities/analytics/morphology/wordFormGenerator'; +import { MorphologyEngine } from '../src/utilities/analytics/morphology/engine'; +import { Grid3VerbsParser } from '../src/utilities/analytics/morphology/grid3VerbsParser'; +import { join } from 'path'; -const SYNTHETIC_XML = join(__dirname, "assets", "grid3", "synthetic-verbs.xml"); +const SYNTHETIC_XML = join(__dirname, 'assets', 'grid3', 'synthetic-verbs.xml'); -describe("WordFormGenerator", () => { +describe('WordFormGenerator', () => { let generator: WordFormGenerator; let engine: MorphologyEngine; beforeEach(() => { generator = new WordFormGenerator(); - engine = new MorphologyEngine("en-gb"); + engine = new MorphologyEngine('en-gb'); }); - describe("generateFromEngineSlots", () => { - test("regular verb walk -> BASE + 3.PERS + PAST + GERUND", () => { - const forms = generator.generateFromEngineSlots("walk", "Verb", engine); - const base = forms.find((f) => f.tags.includes("BASE")); + describe('generateFromEngineSlots', () => { + test('regular verb walk -> BASE + 3.PERS + PAST + GERUND', () => { + const forms = generator.generateFromEngineSlots('walk', 'Verb', engine); + const base = forms.find((f) => f.tags.includes('BASE')); expect(base).toBeDefined(); - expect(base!.value).toBe("walk"); + expect(base!.value).toBe('walk'); - const third = forms.find((f) => f.tags.includes("3.PERS")); + const third = forms.find((f) => f.tags.includes('3.PERS')); expect(third).toBeDefined(); - expect(third!.value).toBe("walks"); + expect(third!.value).toBe('walks'); - const past = forms.find( - (f) => f.tags.includes("PAST") && !f.tags.includes("PARTICIPLE"), - ); + const past = forms.find((f) => f.tags.includes('PAST') && !f.tags.includes('PARTICIPLE')); expect(past).toBeDefined(); - expect(past!.value).toBe("walked"); + expect(past!.value).toBe('walked'); - const gerund = forms.find((f) => f.tags.includes("GERUND")); + const gerund = forms.find((f) => f.tags.includes('GERUND')); expect(gerund).toBeDefined(); - expect(gerund!.value).toBe("walking"); + expect(gerund!.value).toBe('walking'); }); - test("irregular verb go -> correct tagged forms", () => { - const forms = generator.generateFromEngineSlots("go", "Verb", engine); + test('irregular verb go -> correct tagged forms', () => { + const forms = generator.generateFromEngineSlots('go', 'Verb', engine); - const went = forms.find((f) => f.value === "went"); + const went = forms.find((f) => f.value === 'went'); expect(went).toBeDefined(); - expect(went!.tags).toContain("PAST"); + expect(went!.tags).toContain('PAST'); - const goes = forms.find((f) => f.value === "goes"); + const goes = forms.find((f) => f.value === 'goes'); expect(goes).toBeDefined(); - expect(goes!.tags).toContain("3.PERS"); + expect(goes!.tags).toContain('3.PERS'); - const gone = forms.find((f) => f.value === "gone"); + const gone = forms.find((f) => f.value === 'gone'); expect(gone).toBeDefined(); - expect(gone!.tags).toContain("PAST"); - expect(gone!.tags).toContain("PARTICIPLE"); + expect(gone!.tags).toContain('PAST'); + expect(gone!.tags).toContain('PARTICIPLE'); - const going = forms.find((f) => f.value === "going"); + const going = forms.find((f) => f.value === 'going'); expect(going).toBeDefined(); - expect(going!.tags).toContain("GERUND"); + expect(going!.tags).toContain('GERUND'); }); - test("noun book -> BASE + PLURAL", () => { - const forms = generator.generateFromEngineSlots("book", "Noun", engine); + test('noun book -> BASE + PLURAL', () => { + const forms = generator.generateFromEngineSlots('book', 'Noun', engine); - const base = forms.find((f) => f.tags.includes("BASE")); + const base = forms.find((f) => f.tags.includes('BASE')); expect(base).toBeDefined(); - expect(base!.value).toBe("book"); + expect(base!.value).toBe('book'); - const plural = forms.find((f) => f.tags.includes("PLURAL")); + const plural = forms.find((f) => f.tags.includes('PLURAL')); expect(plural).toBeDefined(); - expect(plural!.value).toBe("books"); + expect(plural!.value).toBe('books'); }); - test("adjective big -> BASE + COMPARATIVE + SUPERLATIVE", () => { - const forms = generator.generateFromEngineSlots( - "big", - "Adjective", - engine, - ); + test('adjective big -> BASE + COMPARATIVE + SUPERLATIVE', () => { + const forms = generator.generateFromEngineSlots('big', 'Adjective', engine); - const comp = forms.find((f) => f.tags.includes("COMPARATIVE")); + const comp = forms.find((f) => f.tags.includes('COMPARATIVE')); expect(comp).toBeDefined(); - expect(comp!.value).toBe("bigger"); + expect(comp!.value).toBe('bigger'); - const sup = forms.find((f) => f.tags.includes("SUPERLATIVE")); + const sup = forms.find((f) => f.tags.includes('SUPERLATIVE')); expect(sup).toBeDefined(); - expect(sup!.value).toBe("biggest"); + expect(sup!.value).toBe('biggest'); }); - test("all forms have lang", () => { - const forms = generator.generateFromEngineSlots( - "walk", - "Verb", - engine, - "en", - ); + test('all forms have lang', () => { + const forms = generator.generateFromEngineSlots('walk', 'Verb', engine, 'en'); for (const f of forms) { - expect(f.lang).toBe("en"); + expect(f.lang).toBe('en'); } }); - test("inflected forms have base set", () => { - const forms = generator.generateFromEngineSlots( - "go", - "Verb", - engine, - "en", - ); - const nonBase = forms.filter((f) => !f.tags.includes("BASE")); + test('inflected forms have base set', () => { + const forms = generator.generateFromEngineSlots('go', 'Verb', engine, 'en'); + const nonBase = forms.filter((f) => !f.tags.includes('BASE')); for (const f of nonBase) { - expect(f.base).toBe("go"); + expect(f.base).toBe('go'); } }); }); - describe("generateFromGrid3Conditions", () => { - test("maps person/time conditions to AsTeRICS tags", () => { + describe('generateFromGrid3Conditions', () => { + test('maps person/time conditions to AsTeRICS tags', () => { const forms = generator.generateFromGrid3Conditions( - "walk", + 'walk', [ { - value: "walks", + value: 'walks', conditions: new Map([ - ["person", "third"], - ["time", "present"], + ['person', 'third'], + ['time', 'present'], ]), }, { - value: "walked", - conditions: new Map([["time", "past"]]), + value: 'walked', + conditions: new Map([['time', 'past']]), }, ], - "en", + 'en' ); - const base = forms.find((f) => f.tags.includes("BASE")); + const base = forms.find((f) => f.tags.includes('BASE')); expect(base).toBeDefined(); - expect(base!.value).toBe("walk"); + expect(base!.value).toBe('walk'); - const walks = forms.find((f) => f.value === "walks"); + const walks = forms.find((f) => f.value === 'walks'); expect(walks).toBeDefined(); - expect(walks!.tags).toContain("3.PERS"); + expect(walks!.tags).toContain('3.PERS'); - const walked = forms.find((f) => f.value === "walked"); + const walked = forms.find((f) => f.value === 'walked'); expect(walked).toBeDefined(); - expect(walked!.tags).toContain("PAST"); + expect(walked!.tags).toContain('PAST'); }); - test("maps participleType to correct tags", () => { + test('maps participleType to correct tags', () => { const forms = generator.generateFromGrid3Conditions( - "go", + 'go', [ { - value: "gone", - conditions: new Map([["participleType", "pastparticiple"]]), + value: 'gone', + conditions: new Map([['participleType', 'pastparticiple']]), }, { - value: "going", - conditions: new Map([["participleType", "presentparticiple"]]), + value: 'going', + conditions: new Map([['participleType', 'presentparticiple']]), }, ], - "en", + 'en' ); - const gone = forms.find((f) => f.value === "gone"); + const gone = forms.find((f) => f.value === 'gone'); expect(gone).toBeDefined(); - expect(gone!.tags).toContain("PAST"); - expect(gone!.tags).toContain("PARTICIPLE"); + expect(gone!.tags).toContain('PAST'); + expect(gone!.tags).toContain('PARTICIPLE'); - const going = forms.find((f) => f.value === "going"); + const going = forms.find((f) => f.value === 'going'); expect(going).toBeDefined(); - expect(going!.tags).toContain("GERUND"); + expect(going!.tags).toContain('GERUND'); }); - test("deduplicates same value+tags combos", () => { + test('deduplicates same value+tags combos', () => { const forms = generator.generateFromGrid3Conditions( - "test", + 'test', [ - { value: "tested", conditions: new Map([["time", "past"]]) }, - { value: "tested", conditions: new Map([["time", "past"]]) }, + { value: 'tested', conditions: new Map([['time', 'past']]) }, + { value: 'tested', conditions: new Map([['time', 'past']]) }, ], - "en", + 'en' ); - const testedForms = forms.filter((f) => f.value === "tested"); + const testedForms = forms.filter((f) => f.value === 'tested'); expect(testedForms.length).toBe(1); }); }); - describe("conditionsToTags", () => { - test("maps person conditions", () => { - const tags = generator.conditionsToTags(new Map([["person", "first"]])); - expect(tags).toContain("1.PERS"); + describe('conditionsToTags', () => { + test('maps person conditions', () => { + const tags = generator.conditionsToTags(new Map([['person', 'first']])); + expect(tags).toContain('1.PERS'); }); - test("maps number conditions", () => { - const tags = generator.conditionsToTags(new Map([["number", "plural"]])); - expect(tags).toContain("PLURAL"); + test('maps number conditions', () => { + const tags = generator.conditionsToTags(new Map([['number', 'plural']])); + expect(tags).toContain('PLURAL'); }); - test("maps time conditions", () => { - const tags = generator.conditionsToTags(new Map([["time", "past"]])); - expect(tags).toContain("PAST"); + test('maps time conditions', () => { + const tags = generator.conditionsToTags(new Map([['time', 'past']])); + expect(tags).toContain('PAST'); }); - test("returns UNKNOWN for unmapped conditions", () => { - const tags = generator.conditionsToTags( - new Map([["unknownDim", "unknownVal"]]), - ); - expect(tags).toContain("UNKNOWN"); + test('returns UNKNOWN for unmapped conditions', () => { + const tags = generator.conditionsToTags(new Map([['unknownDim', 'unknownVal']])); + expect(tags).toContain('UNKNOWN'); }); }); - describe("end-to-end with synthetic XML", () => { - test("walk via synthetic parser produces correct word forms", () => { + describe('end-to-end with synthetic XML', () => { + test('walk via synthetic parser produces correct word forms', () => { const parser = new Grid3VerbsParser(); // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require("fs"); - const xml = fs.readFileSync(SYNTHETIC_XML, "utf-8"); + const fs = require('fs'); + const xml = fs.readFileSync(SYNTHETIC_XML, 'utf-8'); const detailed = parser.parseXmlDetailed(xml); - const walkForms = detailed.verbs.get("walk"); + const walkForms = detailed.verbs.get('walk'); expect(walkForms).toBeDefined(); - const astericsForms = generator.generateFromGrid3Conditions( - "walk", - walkForms!, - "en", - ); + const astericsForms = generator.generateFromGrid3Conditions('walk', walkForms!, 'en'); expect(astericsForms.length).toBeGreaterThan(1); - const base = astericsForms.find((f) => f.tags.includes("BASE")); - expect(base!.value).toBe("walk"); + const base = astericsForms.find((f) => f.tags.includes('BASE')); + expect(base!.value).toBe('walk'); }); }); });