Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'fix: version bump 0.23.4, matchCriteria coercion, ROUNDUP/ROUNDDOWN' (#244) from chore/version-bump-and-fixes into main

scott 619e8814 71847b60

+67 -7
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.23.3", 3 + "version": "0.23.4", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+26 -6
src/sheets/formulas.ts
··· 782 782 case 'DIVIDE': { const d = toNum(args[1]); return d === 0 ? '#DIV/0!' : toNum(args[0]) / d; } 783 783 case 'ABS': return Math.abs(toNum(args[0])); 784 784 case 'ROUND': return Math.round(toNum(args[0]) * Math.pow(10, toNum(args[1] ?? 0))) / Math.pow(10, toNum(args[1] ?? 0)); 785 - case 'ROUNDUP': { const f = Math.pow(10, toNum(args[1] ?? 0)); return Math.ceil(toNum(args[0]) * f) / f; } 786 - case 'ROUNDDOWN': { const f = Math.pow(10, toNum(args[1] ?? 0)); return Math.floor(toNum(args[0]) * f) / f; } 785 + case 'ROUNDUP': { const rv = toNum(args[0]); const f = Math.pow(10, toNum(args[1] ?? 0)); return (rv >= 0 ? Math.ceil(rv * f) : Math.floor(rv * f)) / f; } 786 + case 'ROUNDDOWN': { const rv = toNum(args[0]); const f = Math.pow(10, toNum(args[1] ?? 0)); return (rv >= 0 ? Math.floor(rv * f) : Math.ceil(rv * f)) / f; } 787 787 case 'INT': return Math.floor(toNum(args[0])); 788 788 case 'MOD': { const mDiv = toNum(args[1]); if (mDiv === 0) return '#DIV/0!'; const mR = toNum(args[0]) % mDiv; return (mR !== 0 && Math.sign(mR) !== Math.sign(mDiv)) ? mR + mDiv : mR; } 789 789 case 'POWER': return Math.pow(toNum(args[0]), toNum(args[1])); ··· 1685 1685 if (typeof criteria === 'string') { 1686 1686 if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2)); 1687 1687 if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2)); 1688 - if (criteria.startsWith('<>')) return String(value) !== criteria.slice(2); 1688 + if (criteria.startsWith('<>')) { 1689 + const cv = criteria.slice(2); 1690 + const nv = Number(cv); 1691 + if (!isNaN(nv) && cv !== '') return toNum(value) !== nv; 1692 + return String(value).toLowerCase() !== cv.toLowerCase(); 1693 + } 1689 1694 if (criteria.startsWith('>')) return toNum(value) > toNum(criteria.slice(1)); 1690 1695 if (criteria.startsWith('<')) return toNum(value) < toNum(criteria.slice(1)); 1691 - if (criteria.startsWith('=')) return String(value) === criteria.slice(1); 1696 + if (criteria.startsWith('=')) { 1697 + const cv = criteria.slice(1); 1698 + const nv = Number(cv); 1699 + if (!isNaN(nv) && cv !== '') return toNum(value) === nv; 1700 + return String(value).toLowerCase() === cv.toLowerCase(); 1701 + } 1692 1702 return String(value).toLowerCase() === String(criteria).toLowerCase(); 1693 1703 } 1694 1704 return value === criteria; ··· 1706 1716 if (typeof criteria === 'string') { 1707 1717 if (criteria.startsWith('>=')) return toNum(value) >= toNum(criteria.slice(2)); 1708 1718 if (criteria.startsWith('<=')) return toNum(value) <= toNum(criteria.slice(2)); 1709 - if (criteria.startsWith('<>')) return String(value) !== criteria.slice(2); 1719 + if (criteria.startsWith('<>')) { 1720 + const cv = criteria.slice(2); 1721 + const nv = Number(cv); 1722 + if (!isNaN(nv) && cv !== '') return toNum(value) !== nv; 1723 + return String(value).toLowerCase() !== cv.toLowerCase(); 1724 + } 1710 1725 if (criteria.startsWith('>')) return toNum(value) > toNum(criteria.slice(1)); 1711 1726 if (criteria.startsWith('<')) return toNum(value) < toNum(criteria.slice(1)); 1712 - if (criteria.startsWith('=')) return String(value) === criteria.slice(1); 1727 + if (criteria.startsWith('=')) { 1728 + const cv = criteria.slice(1); 1729 + const nv = Number(cv); 1730 + if (!isNaN(nv) && cv !== '') return toNum(value) === nv; 1731 + return String(value).toLowerCase() === cv.toLowerCase(); 1732 + } 1713 1733 // Check for wildcards 1714 1734 if (criteria.includes('*') || criteria.includes('?')) { 1715 1735 return wildcardToRegex(criteria).test(String(value));
+40
tests/formulas-edge-cases.test.ts
··· 1262 1262 const cells: Record<string, unknown> = { A1: 'a', A2: 'b', A3: 'a' }; 1263 1263 expect(evaluate('COUNTIF(A1:A3,"<>a")', (ref) => cells[ref] ?? '')).toBe(1); 1264 1264 }); 1265 + 1266 + it('COUNTIF with = should use numeric comparison for numbers', () => { 1267 + const cells: Record<string, unknown> = { A1: 1, A2: 2, A3: 1 }; 1268 + expect(evaluate('COUNTIF(A1:A3,"=1")', (ref) => cells[ref] ?? '')).toBe(2); 1269 + }); 1270 + 1271 + it('COUNTIF with <> case-insensitive for strings', () => { 1272 + const cells: Record<string, unknown> = { A1: 'Apple', A2: 'APPLE', A3: 'banana' }; 1273 + expect(evaluate('COUNTIF(A1:A3,"<>apple")', (ref) => cells[ref] ?? '')).toBe(1); 1274 + }); 1275 + }); 1276 + 1277 + // ============================================================ 1278 + // ROUNDUP/ROUNDDOWN with negative numbers (#415) 1279 + // ============================================================ 1280 + 1281 + describe('ROUNDUP/ROUNDDOWN — negative numbers', () => { 1282 + it('ROUNDUP(-2.5, 0) rounds away from zero to -3', () => { 1283 + expect(evalWith('ROUNDUP(-2.5, 0)')).toBe(-3); 1284 + }); 1285 + 1286 + it('ROUNDDOWN(-2.5, 0) rounds toward zero to -2', () => { 1287 + expect(evalWith('ROUNDDOWN(-2.5, 0)')).toBe(-2); 1288 + }); 1289 + 1290 + it('ROUNDUP(2.1, 0) rounds away from zero to 3', () => { 1291 + expect(evalWith('ROUNDUP(2.1, 0)')).toBe(3); 1292 + }); 1293 + 1294 + it('ROUNDDOWN(2.9, 0) rounds toward zero to 2', () => { 1295 + expect(evalWith('ROUNDDOWN(2.9, 0)')).toBe(2); 1296 + }); 1297 + 1298 + it('ROUNDUP(-3.14159, 2) rounds to -3.15', () => { 1299 + expect(evalWith('ROUNDUP(-3.14159, 2)')).toBe(-3.15); 1300 + }); 1301 + 1302 + it('ROUNDDOWN(-3.14159, 2) rounds to -3.14', () => { 1303 + expect(evalWith('ROUNDDOWN(-3.14159, 2)')).toBe(-3.14); 1304 + }); 1265 1305 });