diff --git a/src/renderer/components/math-notebook/ResultsPanel.vue b/src/renderer/components/math-notebook/ResultsPanel.vue index 3275fb10..6f418e3b 100644 --- a/src/renderer/components/math-notebook/ResultsPanel.vue +++ b/src/renderer/components/math-notebook/ResultsPanel.vue @@ -5,6 +5,7 @@ import { useCopyToClipboard } from '@/composables' import { formatMathNumber } from '@/composables/math-notebook/math-engine/format' import { i18n, ipc } from '@/electron' import { LoaderCircle, Sigma } from 'lucide-vue-next' +import { sumNumericResults } from './sumNumericResults' interface Props { results: LineResult[] @@ -22,17 +23,7 @@ const MATH_NOTEBOOK_DOCUMENTATION_URL = 'https://masscode.io/documentation/math-notebook.html' const total = computed(() => { - return props.results.reduce((sum, r) => { - if (r.type === 'number' || r.type === 'assignment') { - const raw = r.value || '' - if (raw.includes(':')) - return sum - const num = Number.parseFloat(raw.replace(/[^\d.\-e+]/gi, '')) - if (!Number.isNaN(num)) - return sum + num - } - return sum - }, 0) + return sumNumericResults(props.results) }) const formattedTotal = computed(() => { diff --git a/src/renderer/components/math-notebook/__tests__/sumNumericResults.test.ts b/src/renderer/components/math-notebook/__tests__/sumNumericResults.test.ts new file mode 100644 index 00000000..19f30e9d --- /dev/null +++ b/src/renderer/components/math-notebook/__tests__/sumNumericResults.test.ts @@ -0,0 +1,26 @@ +import type { LineResult } from '@/composables/math-notebook' +import { describe, expect, it } from 'vitest' +import { sumNumericResults } from '../sumNumericResults' + +describe('sumNumericResults', () => { + it('sums numericValue and ignores other result types', () => { + const results: LineResult[] = [ + { value: '10', error: null, type: 'number', numericValue: 10 }, + { value: '12.03.2025, 0:00:00', error: null, type: 'assignment' }, + { value: '100 USD', error: null, type: 'assignment' }, + { value: '0xFF', error: null, type: 'number', numericValue: 255 }, + { value: '5.3e+3', error: null, type: 'number', numericValue: 5300 }, + ] + + expect(sumNumericResults(results)).toBe(5565) + }) + + it('ignores Infinity and NaN when numericValue is absent', () => { + const results: LineResult[] = [ + { value: 'Infinity', error: null, type: 'number' }, + { value: 'NaN', error: null, type: 'number' }, + ] + + expect(sumNumericResults(results)).toBe(0) + }) +}) diff --git a/src/renderer/components/math-notebook/sumNumericResults.ts b/src/renderer/components/math-notebook/sumNumericResults.ts new file mode 100644 index 00000000..0a5ac33b --- /dev/null +++ b/src/renderer/components/math-notebook/sumNumericResults.ts @@ -0,0 +1,9 @@ +import type { LineResult } from '@/composables/math-notebook' + +export function sumNumericResults(results: LineResult[]) { + return results.reduce( + (sum, result) => + result.numericValue != null ? sum + result.numericValue : sum, + 0, + ) +} diff --git a/src/renderer/composables/__tests__/useMathEngine.test.ts b/src/renderer/composables/__tests__/useMathEngine.test.ts index 8070d572..3907b66e 100644 --- a/src/renderer/composables/__tests__/useMathEngine.test.ts +++ b/src/renderer/composables/__tests__/useMathEngine.test.ts @@ -1,5 +1,6 @@ /* eslint-disable test/prefer-lowercase-title */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { preprocessMathExpression } from '../math-notebook/math-engine/preprocess' import { useMathEngine } from '../math-notebook/useMathEngine' const TEST_CURRENCY_RATES = { @@ -7,6 +8,7 @@ const TEST_CURRENCY_RATES = { EUR: 0.92, GBP: 0.79, CAD: 1.36, + RUB: 90, } const { evaluateDocument, setCurrencyServiceState, updateCurrencyRates } @@ -461,6 +463,73 @@ describe('number format', () => { }) }) +describe('numericValue for total', () => { + it('sets numericValue for plain number', () => { + const result = evalLine('42') + expect(result.numericValue).toBe(42) + }) + + it('sets numericValue for arithmetic result', () => { + const result = evalLine('10 + 5') + expect(result.numericValue).toBe(15) + }) + + it('sets numericValue for number assignment', () => { + const results = evalLines('x = 10') + expect(results[0].numericValue).toBe(10) + }) + + it('does not set numericValue for date', () => { + const result = evalLine('now') + expect(result.numericValue).toBeUndefined() + }) + + it('does not set numericValue for date assignment', () => { + const results = evalLines('x = 12.03.2025') + expect(results[0].numericValue).toBeUndefined() + }) + + it('does not set numericValue for unit result', () => { + const result = evalLine('10 USD') + expect(result.numericValue).toBeUndefined() + }) + + it('does not set numericValue for unit assignment', () => { + const results = evalLines('price = 10 USD') + expect(results[0].numericValue).toBeUndefined() + }) + + it('sets intValue for hex format', () => { + const result = evalLine('255 in hex') + expect(result.numericValue).toBe(255) + }) + + it('sets intValue (rounded) for hex with float', () => { + const result = evalLine('10.6 in hex') + expect(result.numericValue).toBe(11) + }) + + it('sets intValue for bin format', () => { + const result = evalLine('255 in bin') + expect(result.numericValue).toBe(255) + }) + + it('sets intValue for oct format', () => { + const result = evalLine('255 in oct') + expect(result.numericValue).toBe(255) + }) + + it('sets numericValue for sci format', () => { + const result = evalLine('5300 in sci') + expect(result.numericValue).toBe(5300) + }) + + it('does not set numericValue for Infinity', () => { + const result = evalLine('1/0') + expect(result.numericValue).toBeUndefined() + }) +}) + describe('area and volume aliases', () => { it('sq alias', () => { const result = evalLine('20 sq cm to cm^2') @@ -578,6 +647,118 @@ describe('sum and total', () => { }) }) +describe('adjacent digit concatenation', () => { + it('concatenates space-separated digits', () => { + expectValue('1 1 2', '112') + }) + + it('works with operators before grouped digits', () => { + expectValue('1 + 1 + 1 1 2', '114') + }) + + it('concatenates two digit groups', () => { + expectValue('1 0 + 2 0', '30') + }) + + it('does not break thousands grouping', () => { + expectValue('4 500', '4,500') + }) + + it('does not break stacked units', () => { + const result = evalLine('1 meter 20 cm') + expect(result.type).toBe('unit') + expect(result.value).toContain('1.2') + }) +}) + +describe('mixed currency and plain number', () => { + it('adds plain number to currency in addition', () => { + const result = evalLine('$100 + 10') + expect(result.value).toContain('110') + expect(result.type).toBe('unit') + }) + + it('adds plain number to currency with multiple terms', () => { + const result = evalLine('$100 + $200 + 10') + expect(result.value).toContain('310') + expect(result.type).toBe('unit') + }) + + it('adds plain number before currency', () => { + const result = evalLine('10 + $100') + expect(result.value).toContain('110') + expect(result.type).toBe('unit') + }) + + it('subtracts plain number from currency', () => { + const result = evalLine('$100 - 10') + expect(result.value).toContain('90') + expect(result.type).toBe('unit') + }) + + it('does not modify multiplication with plain number', () => { + const result = evalLine('$100 * 2') + expect(result.value).toContain('200') + expect(result.type).toBe('unit') + }) + + it('does not modify division with plain number', () => { + const result = evalLine('$100 / 4') + expect(result.value).toContain('25') + expect(result.type).toBe('unit') + }) + + it('does not modify expression without currency', () => { + expectValue('10 + 20', '30') + }) + + it('works with word operator plus', () => { + const result = evalLine('$100 plus 10') + expect(result.value).toContain('110') + expect(result.type).toBe('unit') + }) + + it('works with word operator minus', () => { + const result = evalLine('$100 minus 10') + expect(result.value).toContain('90') + expect(result.type).toBe('unit') + }) + + it('does not infer currency for percentage operands', () => { + expect(preprocessMathExpression('$100 + 10%')).toBe('100 USD + 10 / 100') + }) + + it('keeps trailing conversion on the whole inferred expression', () => { + const result = evalLine('10 USD + 1 in RUB') + expect(result.type).toBe('unit') + expect(result.value).toContain('RUB') + expectNumericClose('10 USD + 1 in RUB', 990, 2) + }) +}) + +describe('mixed unit and plain number', () => { + it('adds plain number to day unit', () => { + const result = evalLine('10 day + 34') + expect(result.type).toBe('unit') + expect(result.value).toContain('day') + expectNumericClose('10 day + 34', 44, 2) + }) + + it('adds plain number before day unit', () => { + const result = evalLine('34 + 10 day') + expect(result.type).toBe('unit') + expect(result.value).toContain('day') + expectNumericClose('34 + 10 day', 44, 2) + }) + + it('adds plain number after adjacent digit concatenation with unit', () => { + const result = evalLine('1 0 day + 34') + expect(result.type).toBe('unit') + expect(result.value).toContain('day') + expectNumericClose('1 0 day + 34', 44, 2) + }) +}) + describe('average and avg', () => { it('average of lines above', () => { const results = evalLines('10\n20\n30\naverage') diff --git a/src/renderer/composables/math-notebook/math-engine/preprocess.ts b/src/renderer/composables/math-notebook/math-engine/preprocess.ts index 9986fd93..c302cd40 100644 --- a/src/renderer/composables/math-notebook/math-engine/preprocess.ts +++ b/src/renderer/composables/math-notebook/math-engine/preprocess.ts @@ -44,8 +44,13 @@ function sanitizeForCurrencyDetection(line: string) { } function preprocessGroupedNumbers(line: string): string { - return line.replace(/\b\d{1,3}(?:\s\d{3})+\b/g, match => + let result = line.replace(/\b\d{1,3}(?:\s\d{3})+\b/g, match => match.replace(/\s+/g, '')) + + result = result.replace(/\b\d+(?:\s+\d+)+\b/g, match => + match.replace(/\s+/g, '')) + + return result } function preprocessDegreeSigns(line: string): string { @@ -237,6 +242,144 @@ function preprocessCurrencyWords(line: string): string { ) } +function splitTopLevelAddSubTerms(expression: string) { + const terms: string[] = [] + const operators: Array<'+' | '-'> = [] + let depth = 0 + let segmentStart = 0 + + for (let index = 0; index < expression.length; index++) { + const char = expression[index] + + if (char === '(') { + depth += 1 + continue + } + + if (char === ')') { + depth = Math.max(0, depth - 1) + continue + } + + if (depth !== 0 || (char !== '+' && char !== '-')) { + continue + } + + let prevIndex = index - 1 + while (prevIndex >= 0 && /\s/.test(expression[prevIndex])) { + prevIndex -= 1 + } + + let nextIndex = index + 1 + while (nextIndex < expression.length && /\s/.test(expression[nextIndex])) { + nextIndex += 1 + } + + if (prevIndex < 0 || nextIndex >= expression.length) { + continue + } + + if ('+-*/%^,('.includes(expression[prevIndex])) { + continue + } + + terms.push(expression.slice(segmentStart, index).trim()) + operators.push(char) + segmentStart = index + 1 + } + + if (operators.length === 0) { + return null + } + + terms.push(expression.slice(segmentStart).trim()) + + if (terms.some(term => !term)) { + return null + } + + return { terms, operators } +} + +function extractAdditiveUnit(term: string) { + const match = term.match(/^-?\d+(?:\.\d+)?\s+([a-z][a-z0-9^]*)$/i) + if (!match) { + return null + } + + return match[1].toUpperCase() + in Object.fromEntries(SUPPORTED_CURRENCY_CODES.map(code => [code, true])) + ? match[1].toUpperCase() + : match[1] +} + +function isPlainNumberTerm(term: string) { + return /^-?\d+(?:\.\d+)?$/.test(term) +} + +function inferAdditiveUnitsForPlainNumbers(expression: string) { + const split = splitTopLevelAddSubTerms(expression) + if (!split) { + return expression + } + + const explicitUnits = split.terms.map(extractAdditiveUnit) + + let changed = false + const inferredTerms = split.terms.map((term, index) => { + if (!isPlainNumberTerm(term)) { + return term + } + + const previousUnit = explicitUnits.slice(0, index).reverse().find(Boolean) + const nextUnit = explicitUnits.slice(index + 1).find(Boolean) + const inheritedUnit = previousUnit || nextUnit + + if (!inheritedUnit) { + return term + } + + changed = true + return `${term} ${inheritedUnit}` + }) + + if (!changed) { + return expression + } + + let rebuilt = inferredTerms[0] + for (let index = 0; index < split.operators.length; index++) { + rebuilt += ` ${split.operators[index]} ${inferredTerms[index + 1]}` + } + + return rebuilt +} + +function inferAdditiveUnits(line: string): string { + const conversionSplit = splitByKeyword(line, [ + ' into ', + ' as ', + ' to ', + ' in ', + ]) + const sourceExpression = conversionSplit ? conversionSplit[0] : line + const conversionTarget = conversionSplit ? conversionSplit[1] : null + + const inferredSource = inferAdditiveUnitsForPlainNumbers(sourceExpression) + const hasAdditiveSource = splitTopLevelAddSubTerms(sourceExpression) !== null + + if (!hasAdditiveSource) { + return line + } + + if (!conversionTarget) { + return inferredSource + } + + const groupedSource = `(${inferredSource})` + return `${groupedSource} to ${conversionTarget}` +} + function preprocessScales(line: string): string { return line .replace(/(\d+(?:\.\d+)?)\s*k\b/g, '($1 * 1000)') @@ -402,6 +545,7 @@ export function preprocessMathExpression(line: string) { processed = preprocessFunctionConversions(processed) processed = preprocessImplicitMultiplication(processed) processed = preprocessWordOperators(processed) + processed = inferAdditiveUnits(processed) processed = preprocessPercentages(processed) processed = preprocessConversions(processed) return processed diff --git a/src/renderer/composables/math-notebook/math-engine/types.ts b/src/renderer/composables/math-notebook/math-engine/types.ts index 8ed4a957..28d615c6 100644 --- a/src/renderer/composables/math-notebook/math-engine/types.ts +++ b/src/renderer/composables/math-notebook/math-engine/types.ts @@ -2,6 +2,7 @@ export interface LineResult { value: string | null error: string | null showError?: boolean + numericValue?: number type: | 'number' | 'unit' diff --git a/src/renderer/composables/math-notebook/useMathEngine.ts b/src/renderer/composables/math-notebook/useMathEngine.ts index b4beed12..9321913d 100644 --- a/src/renderer/composables/math-notebook/useMathEngine.ts +++ b/src/renderer/composables/math-notebook/useMathEngine.ts @@ -84,21 +84,29 @@ function applyFormat( value: `0x${intValue.toString(16).toUpperCase()}`, error: null, type: 'number', + numericValue: intValue, } case 'bin': return { value: `0b${intValue.toString(2)}`, error: null, type: 'number', + numericValue: intValue, } case 'oct': return { value: `0o${intValue.toString(8)}`, error: null, type: 'number', + numericValue: intValue, } case 'sci': - return { value: num.toExponential(), error: null, type: 'number' } + return { + value: num.toExponential(), + error: null, + type: 'number', + numericValue: num, + } } } @@ -158,10 +166,19 @@ function formatResult(result: any): LineResult { } if (typeof result === 'number') { + if (!Number.isFinite(result)) { + return { + value: formatMathNumber(result, activeLocale, activeDecimalPlaces), + error: null, + type: 'number', + } + } + return { value: formatMathNumber(result, activeLocale, activeDecimalPlaces), error: null, type: 'number', + numericValue: result, } }