diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..7e690b7b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,17 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: [ + '**/__tests__/**/*.ts', + '**/?(*.)+(spec|test).ts' + ], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'js', 'json'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + ], +}; \ No newline at end of file diff --git a/package.json b/package.json index ef4861f2..e96baa41 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,10 @@ "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes", "publish": "bun lib/publish.js --b", - "zip": "bedframe zip" + "zip": "bedframe zip", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" }, "targets": { "prod": { @@ -37,6 +40,7 @@ "@babel/runtime": "^7.26.9", "@bedframe/cli": "^0.0.91", "@crxjs/vite-plugin": "2.0.0-beta.32", + "@types/jest": "^29.5.14", "@types/mime-types": "^2.1.4", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", @@ -44,6 +48,7 @@ "dependency-cruiser": "^16.10.0", "eslint": "9.22.0", "glob": "^11.0.1", + "jest": "^29.7.0", "mime-types": "^2.1.35", "prettier": "^3.5.3", "process": "^0.11.10", @@ -52,6 +57,7 @@ "sass-loader": "^16.0.5", "semver": "^7.7.1", "tailwindcss": "3", + "ts-jest": "^29.3.4", "url": "^0.11.4" }, "dependencies": { diff --git a/src/plugins/built-in/globalSearch/src/components/Calculator.svelte b/src/plugins/built-in/globalSearch/src/components/Calculator.svelte index 4092df71..5a870a68 100644 --- a/src/plugins/built-in/globalSearch/src/components/Calculator.svelte +++ b/src/plugins/built-in/globalSearch/src/components/Calculator.svelte @@ -1,7 +1,6 @@ {#if result !== null} -
+

Calculator

@@ -124,7 +82,7 @@ {result}
- {outputUnit || 'Result'} + {outputUnit || (isPartial ? 'Partial' : 'Result')}
{:else} diff --git a/src/plugins/built-in/globalSearch/src/utils/calculator.ts b/src/plugins/built-in/globalSearch/src/utils/calculator.ts new file mode 100644 index 00000000..1e809185 --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/utils/calculator.ts @@ -0,0 +1,223 @@ +import * as math from 'mathjs'; +import { unitFullNames } from './unitMap'; + +export interface CalculatorResult { + result: string | null; + isValid: boolean; + isPartial: boolean; + inputUnit: string; + outputUnit: string; + error?: string; +} + +const expandedMath = math.create(math.all); + +expandedMath.import({ + five: 5, + ten: 10, + three: 3, + four: 4, + eight: 8, + sixteen: 16, + twenty: 20, + twentyfive: 25, + fifty: 50, + hundred: 100, + plus: (a: number, b: number) => a + b, + minus: (a: number, b: number) => a - b, + times: (a: number, b: number) => a * b, + divided: (a: number, b: number) => a / b, + power: (a: number, b: number) => Math.pow(a, b), + half: (a: number) => a / 2, + double: (a: number) => a * 2, + quarter: (a: number) => a / 4, + + // String functions + length: (str: string) => str.length, + concat: (...args: string[]) => args.join(''), + uppercase: (str: string) => str.toUpperCase(), + lowercase: (str: string) => str.toLowerCase(), + substr: (str: string, start: number, length: number) => str.substr(start, length), + + // Random functions + randomInt: (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min, + + // Comparison and Boolean operations + and: (a: boolean, b: boolean) => a && b, + or: (a: boolean, b: boolean) => a || b, + not: (a: boolean) => !a, + + // Combinatorics + permutations: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r), + nPr: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r), + nCr: (n: number, r: number) => expandedMath.combinations(n, r), + + // Number theory + gcd: (a: number, b: number) => expandedMath.gcd(a, b), + lcm: (a: number, b: number) => expandedMath.lcm(a, b), + + // Precision functions + precision: (num: number, digits: number) => parseFloat(num.toPrecision(digits)), + fix: (num: number, digits: number) => parseFloat(num.toFixed(digits)), + + // Percentage operations + percent: (value: number) => value / 100, + + // Financial operations + compound: (principal: number, rate: number, time: number) => principal * Math.pow(1 + rate, time), +}, { override: true }); + +function detectUnit(expression: string): string { + try { + const unit = expandedMath.unit(expression); + if (unit) { + const unitStr = unit.formatUnits(); + return unitFullNames[unitStr] || unitStr; + } + } catch (e) { + // Not a unit or invalid expression + } + return ''; +} + +function isLikelyMathExpression(input: string): boolean { + const trimmed = input.trim(); + + // Must contain at least one digit or mathematical operator + if (!/[\d+\-*/^()=.]/.test(trimmed)) { + return false; + } + + // Check for common non-math words that shouldn't trigger calculator + const nonMathWords = ['abs', 'function', 'class', 'const', 'let', 'var', 'if', 'else', 'while', 'for', 'return', 'import', 'export']; + const words = trimmed.toLowerCase().split(/\s+/); + + // If it's just a single non-math word, skip it + if (words.length === 1 && nonMathWords.includes(words[0]) && !/\d/.test(trimmed)) { + return false; + } + + // Must have some mathematical content + const mathPattern = /(\d+\.?\d*|\+|\-|\*|\/|\^|\(|\)|sin|cos|tan|log|sqrt|pi|e|=)/i; + return mathPattern.test(trimmed); +} + +function tryCompleteExpression(expression: string): string | null { + const trimmed = expression.trim(); + + // Common patterns for incomplete expressions + const incompletePatterns = [ + /[\+\-\*\/\^]\s*$/, // ends with operator + /\(\s*$/, // ends with opening parenthesis + /[\+\-\*\/\^]\s*\(/, // operator followed by opening parenthesis + ]; + + for (const pattern of incompletePatterns) { + if (pattern.test(trimmed)) { + // Try to evaluate what we have so far by removing the incomplete part + let partial = trimmed.replace(/[\+\-\*\/\^]\s*$/, '').trim(); + + // Handle cases like "4 + 3 *" -> evaluate "4 + 3" + if (partial && !partial.match(/[\+\-\*\/\^]\s*$/)) { + try { + const result = expandedMath.evaluate(partial); + if (typeof result === 'number' && !isNaN(result)) { + return expandedMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 }); + } + } catch (e) { + // Continue to other attempts + } + } + } + } + + return null; +} + +export function calculateExpression(input: string): CalculatorResult { + const trimmed = input.trim(); + + // Early exit for empty or very short inputs + if (!trimmed || (trimmed.length <= 2 && !/\d/.test(trimmed))) { + return { + result: null, + isValid: false, + isPartial: false, + inputUnit: '', + outputUnit: '', + }; + } + + // Check if this looks like a math expression at all + if (!isLikelyMathExpression(trimmed)) { + return { + result: null, + isValid: false, + isPartial: false, + inputUnit: '', + outputUnit: '', + }; + } + + try { + // First try to evaluate the expression as-is + const evaluated = expandedMath.evaluate(trimmed.replace('**', '^')); + + if (evaluated !== undefined) { + let result: string; + let inputUnit = ''; + let outputUnit = ''; + + if (math.typeOf(evaluated) === 'Unit') { + // Handle unit conversion results + result = expandedMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 }); + inputUnit = detectUnit(trimmed); + outputUnit = detectUnit(result); + } else if (typeof evaluated === 'number') { + // Handle regular numbers + result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 }); + } else { + result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 }); + } + + return { + result, + isValid: true, + isPartial: false, + inputUnit, + outputUnit, + }; + } + } catch (error) { + // Try to handle incomplete expressions + const partialResult = tryCompleteExpression(trimmed); + + if (partialResult) { + return { + result: partialResult, + isValid: true, + isPartial: true, + inputUnit: '', + outputUnit: '', + }; + } + + // If it still looks like math but failed, return the error + return { + result: null, + isValid: false, + isPartial: false, + inputUnit: '', + outputUnit: '', + error: error instanceof Error ? error.message : 'Invalid expression', + }; + } + + return { + result: null, + isValid: false, + isPartial: false, + inputUnit: '', + outputUnit: '', + }; +} \ No newline at end of file diff --git a/src/plugins/built-in/globalSearch/src/components/unitMap.ts b/src/plugins/built-in/globalSearch/src/utils/unitMap.ts similarity index 86% rename from src/plugins/built-in/globalSearch/src/components/unitMap.ts rename to src/plugins/built-in/globalSearch/src/utils/unitMap.ts index 35e50b31..eb6a8229 100644 --- a/src/plugins/built-in/globalSearch/src/components/unitMap.ts +++ b/src/plugins/built-in/globalSearch/src/utils/unitMap.ts @@ -177,7 +177,7 @@ export const unitFullNames: Record = { // --- Currency --- USD: "US Dollars", - EUR: "Euros", + EUR: "Euros", GBP: "British Pounds", AUD: "Australian Dollars", CAD: "Canadian Dollars", @@ -190,4 +190,31 @@ export const unitFullNames: Record = { NOK: "Norwegian Krone", SGD: "Singapore Dollars", HKD: "Hong Kong Dollars", + KRW: "South Korean Won", + MXN: "Mexican Peso", + BRL: "Brazilian Real", + RUB: "Russian Ruble", + TRY: "Turkish Lira", + ZAR: "South African Rand", + PLN: "Polish Zloty", + THB: "Thai Baht", + DKK: "Danish Krone", + CZK: "Czech Koruna", + HUF: "Hungarian Forint", + ILS: "Israeli Shekel", + PHP: "Philippine Peso", + TWD: "Taiwan Dollar", + MYR: "Malaysian Ringgit", + + // --- Cryptocurrencies --- + BTC: "Bitcoin", + ETH: "Ethereum", + XRP: "Ripple", + LTC: "Litecoin", + ADA: "Cardano", + DOT: "Polkadot", + SOL: "Solana", + MATIC: "Polygon", + AVAX: "Avalanche", + UNI: "Uniswap", }; diff --git a/tsconfig.json b/tsconfig.json index 55f06b66..5d73cc2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,7 @@ "paths": { "@/*": ["./src/*"] }, - "types": ["vite/client", "node"] + "types": ["vite/client", "node", "jest"] }, "include": [ "src/**/*.ts",