From 3d22002aaecc5809047f2313484a424521536f32 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Wed, 15 Nov 2023 19:58:39 +0200 Subject: [PATCH] Roll our own Fraction implementation --- package-lock.json | 15 - package.json | 3 - src/__tests__/fraction-rawify.spec.ts | 1880 +++++++++++++++++++++++++ src/__tests__/fraction.spec.ts | 194 +++ src/__tests__/index.spec.ts | 20 - src/__tests__/monzo.spec.ts | 12 +- src/fraction.ts | 675 ++++++++- src/index.ts | 50 +- 8 files changed, 2738 insertions(+), 111 deletions(-) create mode 100644 src/__tests__/fraction-rawify.spec.ts create mode 100644 src/__tests__/fraction.spec.ts diff --git a/package-lock.json b/package-lock.json index 9be9564..d0c1b9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "xen-dev-utils", "version": "0.1.4", "license": "MIT", - "dependencies": { - "fraction.js": "^4.3.7" - }, "devDependencies": { "@types/benchmark": "^2.1.5", "@types/node": "^18.18.9", @@ -1798,18 +1795,6 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", diff --git a/package.json b/package.json index ad64c61..1098fb7 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,5 @@ "doc": "typedoc --entryPointStrategy packages . --name xen-dev-utils", "prebenchmark": "tsc -p tsconfig-benchmark.json", "benchmark": "node benchmarks/__benchmarks__/monzo.mark.js" - }, - "dependencies": { - "fraction.js": "^4.3.7" } } diff --git a/src/__tests__/fraction-rawify.spec.ts b/src/__tests__/fraction-rawify.spec.ts new file mode 100644 index 0000000..886eaa1 --- /dev/null +++ b/src/__tests__/fraction-rawify.spec.ts @@ -0,0 +1,1880 @@ +import {describe, it, expect} from 'vitest'; +import {Fraction, FractionValue} from '../fraction'; + +// License: MIT +// Copyright (c) 2023 Robert Eisele +// Modified for vitest by Lumi Pakkanen + +function DivisionByZero() { + return new Error('Division by Zero'); +} +/* +function InvalidParameter() { + return new Error('Invalid argument'); +} +function NonIntegerParameter() { + return new Error('Parameters must be integer'); +} +*/ + +const tests = [ + /* + { + set: '', + expectError: InvalidParameter(), + }, + { + set: 'foo', + expectError: InvalidParameter(), + }, + { + set: ' 123', + expectError: InvalidParameter(), + }, + { + set: 0, + expect: 0, + }, + { + set: 0.2, + expect: '0.2', + }, + { + set: 0.333, + expect: '0.333', + }, + { + set: 1.1, + expect: '1.1', + }, + { + set: 1.2, + expect: '1.2', + }, + { + set: 1.3, + expect: '1.3', + }, + { + set: 1.4, + expect: '1.4', + }, + */ + { + set: 1.5, + expect: '1.5', + }, + /* + { + set: 2.555, + expect: '2.555', + }, + { + set: ' - ', + expectError: InvalidParameter(), + }, + */ + { + set: '.5', + expect: '0.5', + }, + { + set: '-.5', + expect: '-0.5', + }, + { + set: '123', + expect: '123', + }, + { + set: '-123', + expect: '-123', + }, + { + set: '123.4', + expect: '123.4', + }, + { + set: '-123.4', + expect: '-123.4', + }, + { + set: '123.', + expect: '123', + }, + { + set: '-123.', + expect: '-123', + }, + { + set: '123.4(56)', + expect: '123.4(56)', + }, + { + set: '-123.4(56)', + expect: '-123.4(56)', + }, + { + set: '123.(4)', + expect: '123.(4)', + }, + { + set: '-123.(4)', + expect: '-123.(4)', + }, + { + set: '0/0', + expectError: DivisionByZero(), + }, + { + set: '9/0', + expectError: DivisionByZero(), + }, + { + label: '0/1+0/1', + set: '0/1', + param: '0/1', + expect: '0', + }, + { + label: '1/9+0/1', + set: '1/9', + param: '0/1', + expect: '0.(1)', + }, + /* + { + set: '123/456', + expect: '0.269(736842105263157894)', + }, + { + set: '-123/456', + expect: '-0.269(736842105263157894)', + }, + */ + /* + { + set: '19 123/456', + expect: '19.269(736842105263157894)', + }, + { + set: '-19 123/456', + expect: '-19.269(736842105263157894)', + }, + { + set: '123.(22)123', + expectError: InvalidParameter(), + }, + */ + { + set: '+33.3(3)', + expect: '33.(3)', + }, + { + set: "3.'09009'", + expect: '3.(09009)', + }, + /* + { + set: '123.(((', + expectError: InvalidParameter(), + }, + { + set: '123.((', + expectError: InvalidParameter(), + }, + { + set: '123.()', + expectError: InvalidParameter(), + }, + { + set: null, + expect: '0', // I would say it's just fine + }, + */ + { + set: [22, 7], + expect: '3.(142857)', // We got Pi! - almost ;o + }, + /* + { + set: '355/113', + expect: + '3.(1415929203539823008849557522123893805309734513274336283185840707964601769911504424778761061946902654867256637168)', // Yay, a better PI + }, + { + set: '3 1/7', + expect: '3.(142857)', + }, + */ + { + set: [36, -36], + expect: '-1', + }, + { + set: '9/12', + expect: '0.75', + }, + { + set: '0.09(33)', + expect: '0.09(3)', + }, + { + set: 1 / 2, + expect: '0.5', + }, + { + set: 1 / 3, + expect: '0.(3)', + }, + { + set: "0.'3'", + expect: '0.(3)', + }, + { + set: '0.00002', + expect: '0.00002', + }, + { + set: 7 / 8, + expect: '0.875', + }, + { + set: '0.003', + expect: '0.003', + }, + { + set: 4, + expect: '4', + }, + { + set: -99, + expect: '-99', + }, + { + set: '-92332.1192', + expect: '-92332.1192', + }, + { + set: '88.92933(12111)', + expect: '88.92933(12111)', + }, + { + set: '-192322.823(123)', + expect: '-192322.8(231)', + }, + { + label: '-99.12 % 0.09(34)', + set: '-99.12', + fn: 'mod', + param: '0.09(34)', + expect: '-0.07(95)', + }, + { + label: '0.4 / 0.1', + set: 0.4, + fn: 'div', + param: '.1', + expect: '4', + }, + { + label: '1 / -.1', + set: 1, + fn: 'div', + param: '-.1', + expect: '-10', + }, + { + label: '1 - (-1)', + set: 1, + fn: 'sub', + param: '-1', + expect: '2', + }, + { + label: '1 + (-1)', + set: 1, + fn: 'add', + param: '-1', + expect: '0', + }, + { + label: '-187 % 12', + set: '-187', + fn: 'mod', + param: '12', + expect: '-7', + }, + { + label: 'Negate by 99 * -1', + set: '99', + fn: 'mul', + param: '-1', + expect: '-99', + }, + { + set: [20, -5], + expect: '-4', + fn: 'toFraction', + param: true, + }, + /* + { + set: [-10, -7], + expect: '1 3/7', + fn: 'toFraction', + param: true, + }, + { + set: [21, -6], + expect: '-3 1/2', + fn: 'toFraction', + param: true, + }, + */ + { + set: '10/78', + expect: '5/39', + fn: 'toFraction', + param: true, + }, + { + set: '0/91', + expect: '0', + fn: 'toFraction', + param: true, + }, + { + set: '-0/287', + expect: '0', + fn: 'toFraction', + param: true, + }, + { + set: '-5/20', + expect: '-1/4', + fn: 'toFraction', + param: true, + }, + /* + { + set: '42/9', + expect: '4 2/3', + fn: 'toFraction', + param: true, + }, + { + set: '71/23', + expect: '3 2/23', + fn: 'toFraction', + param: true, + }, + */ + { + set: '6/3', + expect: '2', + fn: 'toFraction', + param: true, + }, + { + set: '28/4', + expect: '7', + fn: 'toFraction', + param: true, + }, + { + set: '105/35', + expect: '3', + fn: 'toFraction', + param: true, + }, + { + set: '4/6', + expect: '2/3', + fn: 'toFraction', + param: true, + }, + { + label: '99.(9) + 66', + set: '99.(999999)', + fn: 'add', + param: '66', + expect: '166', + }, + /* + { + label: '-82.124 / 66.(3)', + set: '-82.124', + fn: 'div', + param: '66.(3)', + expect: + '-1.238(050251256281407035175879396984924623115577889447236180904522613065326633165829145728643216080402010)', + }, + */ + { + label: '100 - .91', + set: '100', + fn: 'sub', + param: '.91', + expect: '99.09', + }, + /* + { + label: '381.(33411) % 11.119(356)', + set: '381.(33411)', + fn: 'mod', + param: '11.119(356)', + expect: '3.275(997225017295217)', + }, + */ + { + label: '13/26 mod 1', + set: '13/26', + fn: 'mod', + param: '1.000', + expect: '0.5', + }, + { + label: '381.(33411) % 1', // Extract fraction part of a number + set: '381.(33411)', + fn: 'mod', + param: '1', + expect: '0.(33411)', + }, + { + label: '-222/3', + set: { + n: 3, + d: 222, + s: -1, + }, + fn: 'inverse', + param: null, + expect: '-74', + }, + { + label: 'inverse', + set: 1 / 2, + fn: 'inverse', + param: null, + expect: '2', + }, + { + label: 'abs(-222/3)', + set: { + n: -222, + d: 3, + }, + fn: 'abs', + param: null, + expect: '74', + }, + { + label: '9 % -2', + set: 9, + fn: 'mod', + param: '-2', + expect: '1', + }, + { + label: '-9 % 2', + set: '-9', + fn: 'mod', + param: '-2', + expect: '-1', + }, + { + label: '1 / 195312500', + set: '1', + fn: 'div', + param: '195312500', + expect: '0.00000000512', + }, + { + label: '10 / 0', + set: 10, + fn: 'div', + param: 0, + expectError: DivisionByZero(), + }, + { + label: '-3 / 4', + set: [-3, 4], + fn: 'inverse', + param: null, + expect: '-1.(3)', + }, + { + label: '-19.6', + set: [-98, 5], + fn: 'equals', + param: '-19.6', + expect: 'true', // actually, we get a real bool but we call toString() in the test below + }, + { + label: '-19.6', + set: [98, -5], + fn: 'equals', + param: '-19.6', + expect: 'true', + }, + { + label: '99/88', + set: [99, 88], + fn: 'equals', + param: [88, 99], + expect: 'false', + }, + { + label: '99/88', + set: [99, -88], + fn: 'equals', + param: [9, 8], + expect: 'false', + }, + { + label: '12.5', + set: 12.5, + fn: 'add', + param: 0, + expect: '12.5', + }, + { + label: '0/1 -> 1/0', + set: 0, + fn: 'inverse', + param: null, + expectError: DivisionByZero(), + }, + { + label: 'abs(-100.25)', + set: -100.25, + fn: 'abs', + param: null, + expect: '100.25', + }, + { + label: '0.022222222', + set: '0.0(22222222)', + fn: 'abs', + param: null, + expect: '0.0(2)', + }, + { + label: '1.5 | 100.5', + set: 100.5, + fn: 'divisible', + param: '1.5', + expect: 'true', + }, + { + label: '1.5 | 100.6', + set: 100.6, + fn: 'divisible', + param: 1.6, + expect: 'false', + }, + { + label: '(1/6) | (2/3)', // == 4 + set: [2, 3], + fn: 'divisible', + param: [1, 6], + expect: 'true', + }, + { + label: '(1/6) | (2/5)', + set: [2, 5], + fn: 'divisible', + param: [1, 6], + expect: 'false', + }, + { + label: '0 | (2/5)', + set: [2, 5], + fn: 'divisible', + param: 0, + expect: 'false', + }, + { + label: '6 | 0', + set: 0, + fn: 'divisible', + param: 6, + expect: 'true', + }, + /* + { + label: 'fmod(4.55, 0.05)', // http://phpjs.org/functions/fmod/ (comment section) + set: 4.55, + fn: 'mod', + param: 0.05, + expect: '0', + }, + { + label: 'fmod(99.12, 0.4)', + set: 99.12, + fn: 'mod', + param: '0.4', + expect: '0.32', + }, + { + label: 'fmod(fmod(1.0,0.1))', // http://stackoverflow.com/questions/4218961/why-fmod1-0-0-1-1 + set: 1.0, + fn: 'mod', + param: 0.1, + expect: '0', + }, + { + label: "bignum", + set: [5385020324, 1673196525], + fn: "add", + param: 0, + expect: "3.21(840276592733181776121606516006839065124164060763872313206005492988936251824931324190982287630557922656455433410609073551596098372245902196097377144624418820138297860736950789447760776337973807350574075570710380240599651018280712721418065340531352107607323652551812465663589637206543923464101146157950573080469432602963360804254598843372567965379918536467197121390148715495330113717514444395585868193217769203770011415724163065662594535928766646225254382476081224230369471990147720394052336440275597631903998844367669243157195775313960803259497565595290726533154854597848271290188102679689703515252041298615534717298077104242133182771222884293284077911887845930112722413166618308629346454087334421161315763550250022184333666363549254920906556389124702491239037207539024741878423396797336762338781453063321417070239253574830368476888869943116813489676593728283053898883754853602746993512910863832926021645903191198654921901657666901979730085800889408373591978384009612977172541043856160291750546158945674358246709841810124486123947693472528578195558946669459524487119048971249805817042322628538808374587079661786890216019304725725509141850506771761314768448972244907094819599867385572056456428511886850828834945135927771544947477105237234460548500123140047759781236696030073335228807028510891749551057667897081007863078128255137273847732859712937785356684266362554153643129279150277938809369688357439064129062782986595074359241811119587401724970711375341877428295519559485099934689381452068220139292962014728066686607540019843156200674036183526020650801913421377683054893985032630879985)" + }, + */ + { + label: 'ceil(0.4)', + set: 0.4, + fn: 'ceil', + param: null, + expect: '1', + }, + { + label: 'ceil(0.5)', + set: 0.5, + fn: 'ceil', + param: null, + expect: '1', + }, + /* + { + label: 'ceil(0.23, 2)', + set: 0.23, + fn: 'ceil', + param: 2, + expect: '0.23', + }, + */ + { + label: 'ceil(0.6)', + set: 0.6, + fn: 'ceil', + param: null, + expect: '1', + }, + { + label: 'ceil(-0.4)', + set: -0.4, + fn: 'ceil', + param: null, + expect: '0', + }, + { + label: 'ceil(-0.5)', + set: -0.5, + fn: 'ceil', + param: null, + expect: '0', + }, + { + label: 'ceil(-0.6)', + set: -0.6, + fn: 'ceil', + param: null, + expect: '0', + }, + { + label: 'floor(0.4)', + set: 0.4, + fn: 'floor', + param: null, + expect: '0', + }, + /* + { + label: 'floor(0.4, 1)', + set: 0.4, + fn: 'floor', + param: 1, + expect: '0.4', + }, + */ + { + label: 'floor(0.5)', + set: 0.5, + fn: 'floor', + param: null, + expect: '0', + }, + { + label: 'floor(0.6)', + set: 0.6, + fn: 'floor', + param: null, + expect: '0', + }, + { + label: 'floor(-0.4)', + set: -0.4, + fn: 'floor', + param: null, + expect: '-1', + }, + { + label: 'floor(-0.5)', + set: -0.5, + fn: 'floor', + param: null, + expect: '-1', + }, + { + label: 'floor(-0.6)', + set: -0.6, + fn: 'floor', + param: null, + expect: '-1', + }, + { + label: 'floor(10.4)', + set: 10.4, + fn: 'floor', + param: null, + expect: '10', + }, + /* + { + label: 'floor(10.4, 1)', + set: 10.4, + fn: 'floor', + param: 1, + expect: '10.4', + }, + */ + { + label: 'floor(10.5)', + set: 10.5, + fn: 'floor', + param: null, + expect: '10', + }, + { + label: 'floor(10.6)', + set: 10.6, + fn: 'floor', + param: null, + expect: '10', + }, + { + label: 'floor(-10.4)', + set: -10.4, + fn: 'floor', + param: null, + expect: '-11', + }, + { + label: 'floor(-10.5)', + set: -10.5, + fn: 'floor', + param: null, + expect: '-11', + }, + { + label: 'floor(-10.6)', + set: -10.6, + fn: 'floor', + param: null, + expect: '-11', + }, + /* + { + label: 'floor(-10.543,3)', + set: -10.543, + fn: 'floor', + param: 3, + expect: '-10.543', + }, + { + label: 'floor(10.543,3)', + set: 10.543, + fn: 'floor', + param: 3, + expect: '10.543', + }, + { + label: 'round(-10.543,3)', + set: -10.543, + fn: 'round', + param: 3, + expect: '-10.543', + }, + { + label: 'round(10.543,3)', + set: 10.543, + fn: 'round', + param: 3, + expect: '10.543', + }, + */ + { + label: 'round(10.4)', + set: 10.4, + fn: 'round', + param: null, + expect: '10', + }, + { + label: 'round(10.5)', + set: 10.5, + fn: 'round', + param: null, + expect: '11', + }, + /* + { + label: 'round(10.5, 1)', + set: 10.5, + fn: 'round', + param: 1, + expect: '10.5', + }, + */ + { + label: 'round(10.6)', + set: 10.6, + fn: 'round', + param: null, + expect: '11', + }, + { + label: 'round(-10.4)', + set: -10.4, + fn: 'round', + param: null, + expect: '-10', + }, + { + label: 'round(-10.5)', + set: -10.5, + fn: 'round', + param: null, + expect: '-10', + }, + { + label: 'round(-10.6)', + set: -10.6, + fn: 'round', + param: null, + expect: '-11', + }, + { + label: 'round(-0.4)', + set: -0.4, + fn: 'round', + param: null, + expect: '0', + }, + { + label: 'round(-0.5)', + set: -0.5, + fn: 'round', + param: null, + expect: '0', + }, + { + label: 'round(-0.6)', + set: -0.6, + fn: 'round', + param: null, + expect: '-1', + }, + { + label: 'round(-0)', + set: -0, + fn: 'round', + param: null, + expect: '0', + }, + /* + { + label: 'round(big fraction)', + set: [ + '409652136432929109317120'.repeat(100), + '63723676445298091081155'.repeat(100), + ], + fn: 'round', + param: null, + expect: + '6428570341270001560623330590225448467479093479780591305451264291405695842465355472558570608574213642', + }, + { + label: 'round(big numerator)', + set: ['409652136432929109317'.repeat(100), 10], + fn: 'round', + param: null, + expect: '409652136432929109317'.repeat(99) + '40965213643292910932', + }, + { + label: '17402216385200408/5539306332998545', + set: [17402216385200408, 5539306332998545], + fn: 'add', + param: 0, + expect: '3.141587653589870', + }, + { + label: '17402216385200401/553930633299855', + set: [17402216385200401, 553930633299855], + fn: 'add', + param: 0, + expect: '31.415876535898660', + }, + { + label: '1283191/418183', + set: [1283191, 418183], + fn: 'add', + param: 0, + expect: '3.068491545567371', + }, + */ + { + label: '1.001', + set: '1.001', + fn: 'add', + param: 0, + expect: '1.001', + }, + { + label: '99+1', + set: [99, 1], + fn: 'add', + param: 1, + expect: '100', + }, + { + label: 'gcd(5/8, 3/7)', + set: [5, 8], + fn: 'gcd', + param: [3, 7], + expect: '1/56', + }, + { + label: 'gcd(52, 39)', + set: 52, + fn: 'gcd', + param: 39, + expect: '13', + }, + { + label: 'gcd(51357, 3819)', + set: 51357, + fn: 'gcd', + param: 3819, + expect: '57', + }, + { + label: 'gcd(841, 299)', + set: 841, + fn: 'gcd', + param: 299, + expect: '1', + }, + { + label: 'gcd(2/3, 7/5)', + set: [2, 3], + fn: 'gcd', + param: [7, 5], + expect: '1/15', + }, + { + label: 'lcm(-3, 3)', + set: -3, + fn: 'lcm', + param: 3, + expect: '3', + }, + { + label: 'lcm(3,-3)', + set: 3, + fn: 'lcm', + param: -3, + expect: '3', + }, + { + label: 'lcm(0,3)', + set: 0, + fn: 'lcm', + param: 3, + expect: '0', + }, + { + label: 'lcm(3, 0)', + set: 3, + fn: 'lcm', + param: 0, + expect: '0', + }, + { + label: 'lcm(0, 0)', + set: 0, + fn: 'lcm', + param: 0, + expect: '0', + }, + { + label: 'lcm(200, 333)', + set: 200, + fn: 'lcm', + param: 333, + expect: '66600', + }, + { + label: '1 + -1', + set: 1, + fn: 'add', + param: -1, + expect: '0', + }, + { + label: '3/10+3/14', + set: '3/10', + fn: 'add', + param: '3/14', + expect: '0.5(142857)', + }, + { + label: '3/10-3/14', + set: '3/10', + fn: 'sub', + param: '3/14', + expect: '0.0(857142)', + }, + { + label: '3/10*3/14', + set: '3/10', + fn: 'mul', + param: '3/14', + expect: '0.06(428571)', + }, + { + label: '3/10 / 3/14', + set: '3/10', + fn: 'div', + param: '3/14', + expect: '1.4', + }, + { + label: '1-2', + set: '1', + fn: 'sub', + param: '2', + expect: '-1', + }, + { + label: '1--1', + set: '1', + fn: 'sub', + param: '-1', + expect: '2', + }, + { + label: '0/1*1/3', + set: '0/1', + fn: 'mul', + param: '1/3', + expect: '0', + }, + { + label: '3/10 * 8/12', + set: '3/10', + fn: 'mul', + param: '8/12', + expect: '0.2', + }, + { + label: '.5+5', + set: '.5', + fn: 'add', + param: 5, + expect: '5.5', + }, + { + label: '10/12-5/60', + set: '10/12', + fn: 'sub', + param: '5/60', + expect: '0.75', + }, + { + label: '10/15 / 3/4', + set: '10/15', + fn: 'div', + param: '3/4', + expect: '0.(8)', + }, + { + label: '1/4 + 3/8', + set: '1/4', + fn: 'add', + param: '3/8', + expect: '0.625', + }, + { + label: '2-1/3', + set: '2', + fn: 'sub', + param: '1/3', + expect: '1.(6)', + }, + { + label: '5*6', + set: '5', + fn: 'mul', + param: 6, + expect: '30', + }, + { + label: '1/2-1/5', + set: '1/2', + fn: 'sub', + param: '1/5', + expect: '0.3', + }, + { + label: '1/2-5', + set: '1/2', + fn: 'add', + param: -5, + expect: '-4.5', + }, + { + label: '1*-1', + set: '1', + fn: 'mul', + param: -1, + expect: '-1', + }, + { + label: '5/10', + set: 5.0, + fn: 'div', + param: 10, + expect: '0.5', + }, + { + label: '1/-1', + set: '1', + fn: 'div', + param: -1, + expect: '-1', + }, + { + label: '4/5 + 13/2', + set: '4/5', + fn: 'add', + param: '13/2', + expect: '7.3', + }, + { + label: '4/5 + 61/2', + set: '4/5', + fn: 'add', + param: '61/2', + expect: '31.3', + }, + { + label: '0.8 + 6.5', + set: '0.8', + fn: 'add', + param: '6.5', + expect: '7.3', + }, + { + label: '2/7 inverse', + set: '2/7', + fn: 'inverse', + param: null, + expect: '3.5', + }, + { + label: 'neg 1/3', + set: '1/3', + fn: 'neg', + param: null, + expect: '-0.(3)', + }, + { + label: '1/2+1/3', + set: '1/2', + fn: 'add', + param: '1/3', + expect: '0.8(3)', + }, + { + label: '1/2+3', + set: '.5', + fn: 'add', + param: 3, + expect: '3.5', + }, + { + label: '1/2+3.14', + set: '1/2', + fn: 'add', + param: '3.14', + expect: '3.64', + }, + { + label: '3.5 < 4.1', + set: 3.5, + fn: 'compare', + param: 4.1, + expect: '-1', + }, + { + label: '3.5 > 4.1', + set: 4.1, + fn: 'compare', + param: 3.1, + expect: '1', + }, + { + label: '-3.5 > -4.1', + set: -3.5, + fn: 'compare', + param: -4.1, + expect: '1', + }, + { + label: '-3.5 > -4.1', + set: -4.1, + fn: 'compare', + param: -3.5, + expect: '-1', + }, + { + label: '4.3 == 4.3', + set: 4.3, + fn: 'compare', + param: 4.3, + expect: '0', + }, + { + label: '-4.3 == -4.3', + set: -4.3, + fn: 'compare', + param: -4.3, + expect: '0', + }, + { + label: '-4.3 < 4.3', + set: -4.3, + fn: 'compare', + param: 4.3, + expect: '-1', + }, + { + label: '4.3 == -4.3', + set: 4.3, + fn: 'compare', + param: -4.3, + expect: '1', + }, + { + label: '2^0.5', + set: 2, + fn: 'pow', + param: 0.5, + expect: 'null', + }, + { + label: 'sqrt(0)', + set: 0, + fn: 'pow', + param: 0.5, + expect: '0', + }, + { + label: '27^(2/3)', + set: 27, + fn: 'pow', + param: '2/3', + expect: '9', + }, + { + label: '(243/1024)^(2/5)', + set: '243/1024', + fn: 'pow', + param: '2/5', + expect: '0.5625', + }, + { + label: '-0.5^-3', + set: -0.5, + fn: 'pow', + param: -3, + expect: '-8', + }, + { + label: '', + set: -3, + fn: 'pow', + param: -3, + expect: '-0.(037)', + }, + { + label: '-3', + set: -3, + fn: 'pow', + param: 2, + expect: '9', + }, + { + label: '-3', + set: -3, + fn: 'pow', + param: 3, + expect: '-27', + }, + { + label: '0^0', + set: 0, + fn: 'pow', + param: 0, + expect: '1', + }, + { + label: '2/3^7', + set: [2, 3], + fn: 'pow', + param: 7, + expect: '128/2187', + }, + { + label: '-0.6^4', + set: '-0.6', + fn: 'pow', + param: 4, + expect: '0.1296', + }, + { + label: '8128371/12394 - 8128371/12394', + set: '8128371/12394', + fn: 'sub', + param: '8128371/12394', + expect: '0', + }, + { + label: '3/4 + 1/4', + set: '3/4', + fn: 'add', + param: '1/4', + expect: '1', + }, + { + label: '1/10 + 2/10', + set: '1/10', + fn: 'add', + param: '2/10', + expect: '0.3', + }, + { + label: '5/10 + 2/10', + set: '5/10', + fn: 'add', + param: '2/10', + expect: '0.7', + }, + { + label: '18/10 + 2/10', + set: '18/10', + fn: 'add', + param: '2/10', + expect: '2', + }, + { + label: '1/3 + 1/6', + set: '1/3', + fn: 'add', + param: '1/6', + expect: '0.5', + }, + { + label: '1/3 + 2/6', + set: '1/3', + fn: 'add', + param: '2/6', + expect: '0.(6)', + }, + { + label: '3/4 / 1/4', + set: '3/4', + fn: 'div', + param: '1/4', + expect: '3', + }, + { + label: '1/10 / 2/10', + set: '1/10', + fn: 'div', + param: '2/10', + expect: '0.5', + }, + { + label: '5/10 / 2/10', + set: '5/10', + fn: 'div', + param: '2/10', + expect: '2.5', + }, + { + label: '18/10 / 2/10', + set: '18/10', + fn: 'div', + param: '2/10', + expect: '9', + }, + { + label: '1/3 / 1/6', + set: '1/3', + fn: 'div', + param: '1/6', + expect: '2', + }, + { + label: '1/3 / 2/6', + set: '1/3', + fn: 'div', + param: '2/6', + expect: '1', + }, + { + label: '3/4 * 1/4', + set: '3/4', + fn: 'mul', + param: '1/4', + expect: '0.1875', + }, + { + label: '1/10 * 2/10', + set: '1/10', + fn: 'mul', + param: '2/10', + expect: '0.02', + }, + { + label: '5/10 * 2/10', + set: '5/10', + fn: 'mul', + param: '2/10', + expect: '0.1', + }, + { + label: '18/10 * 2/10', + set: '18/10', + fn: 'mul', + param: '2/10', + expect: '0.36', + }, + { + label: '1/3 * 1/6', + set: '1/3', + fn: 'mul', + param: '1/6', + expect: '0.0(5)', + }, + { + label: '1/3 * 2/6', + set: '1/3', + fn: 'mul', + param: '2/6', + expect: '0.(1)', + }, + { + label: '5/4 - 1/4', + set: '5/4', + fn: 'sub', + param: '1/4', + expect: '1', + }, + { + label: '5/10 - 2/10', + set: '5/10', + fn: 'sub', + param: '2/10', + expect: '0.3', + }, + { + label: '9/10 - 2/10', + set: '9/10', + fn: 'sub', + param: '2/10', + expect: '0.7', + }, + { + label: '22/10 - 2/10', + set: '22/10', + fn: 'sub', + param: '2/10', + expect: '2', + }, + { + label: '2/3 - 1/6', + set: '2/3', + fn: 'sub', + param: '1/6', + expect: '0.5', + }, + /* + { + label: '3/3 - 2/6', + set: '3/3', + fn: 'sub', + param: '2/6', + expect: '0.(6)', + }, + { + label: '0.006999999999999999', + set: 0.006999999999999999, + fn: 'add', + param: 0, + expect: '0.007', + }, + { + label: '1/7 - 1', + set: 1 / 7, + fn: 'add', + param: -1, + expect: '-0.(857142)', + }, + */ + { + label: '0.0065 + 0.0005', + set: '0.0065', + fn: 'add', + param: '0.0005', + expect: '0.007', + }, + { + label: '6.5/.5', + set: 6.5, + fn: 'div', + param: 0.5, + expect: '13', + }, + { + label: '0.999999999999999999999999999', + set: 0.999999999999999999999999999, + fn: 'sub', + param: 1, + expect: '0', + }, + /* + { + label: '0.5833333333333334', + set: 0.5833333333333334, + fn: 'add', + param: 0, + expect: '0.58(3)', + }, + { + label: '1.75/3', + set: 1.75 / 3, + fn: 'add', + param: 0, + expect: '0.58(3)', + }, + { + label: '3.3333333333333', + set: 3.3333333333333, + fn: 'add', + param: 0, + expect: '3.(3)', + }, + { + label: '4.285714285714285714285714', + set: 4.285714285714285714285714, + fn: 'add', + param: 0, + expect: '4.(285714)', + }, + */ + { + label: '-4', + set: -4, + fn: 'neg', + param: 0, + expect: '4', + }, + { + label: '4', + set: 4, + fn: 'neg', + param: 0, + expect: '-4', + }, + { + label: '0', + set: 0, + fn: 'neg', + param: 0, + expect: '0', + }, + /* + { + label: '6869570742453802/5329686054127205', + set: '6869570742453802/5329686054127205', + fn: 'neg', + param: 0, + expect: '-1.288925965373540', + }, + { + label: '686970702/53212205', + set: '686970702/53212205', + fn: 'neg', + param: 0, + expect: '-12.910021338149772', + }, + { + label: '1/3000000000000000', + set: '1/3000000000000000', + fn: 'add', + param: 0, + expect: '0.000000000000000(3)', + }, + { + label: 'toString(15) .0000000000000003', + set: '.0000000000000003', + fn: 'toString', + param: 15, + expect: '0.000000000000000', + }, + { + label: 'toString(16) .0000000000000003', + set: '.0000000000000003', + fn: 'toString', + param: 16, + expect: '0.0000000000000003', + }, + { + label: 'NAN', + set: NaN, + fn: 'toString', + param: null, + expect: 'NaN', + }, + { + label: '12 / 4.3', + set: 12, + set2: 4.3, + fn: 'toString', + param: null, + expectError: NonIntegerParameter(), + }, + { + label: '12.5 / 4', + set: 12.5, + set2: 4, + fn: 'toString', + param: null, + expectError: NonIntegerParameter(), + }, + */ + { + label: '0.9 round to multiple of 1/8', + set: 0.9, + fn: 'roundTo', + param: '1/8', + expect: '0.875', + }, + { + label: '1/3 round to multiple of 1/16', + set: 1 / 3, + fn: 'roundTo', + param: '1/16', + expect: '0.3125', + }, + { + label: '1/3 round to multiple of 1/16', + set: -1 / 3, + fn: 'roundTo', + param: '1/16', + expect: '-0.3125', + }, +]; + +function toQuoteCycle(value: any) { + if (typeof value === 'string') { + return value.replace('(', "'").replace(')', "'"); + } + return value; +} + +describe('Fraction', () => { + for (let i = 0; i < tests.length; i++) { + const testCase = tests[i]; + let action: () => any; + + let set: FractionValue; + let set2: number | undefined; + if (Array.isArray(testCase.set)) { + set = testCase.set[0]; + set2 = testCase.set[1]; + } else { + set = testCase.set as FractionValue; + if ('set2' in testCase) { + set2 = testCase.set2 as number; + } + } + set = toQuoteCycle(set); + const fn = testCase.fn; + + if (fn !== undefined || testCase.param !== undefined) { + action = () => { + let param: any = testCase.param; + if (Array.isArray(param)) { + param = new Fraction(param[0], param[1]); + } + param = toQuoteCycle(param); + const x = new Fraction(set, set2)[fn ?? 'add'](param); + return x; + }; + } else { + action = () => { + const x = new Fraction(set, set2); + return x; + }; + } + + it(String(testCase.label || testCase.set), () => { + if (testCase.expectError) { + expect(action).toThrowError(testCase.expectError); + } else { + const x = action(); + if (x === null) { + expect(testCase.expect).toBe('null'); + } else if (typeof x === 'number') { + expect(Math.sign(x)).toBeCloseTo( + testCase.expect as unknown as number + ); + } else if (x instanceof Fraction) { + const expected = new Fraction(toQuoteCycle(testCase.expect)); + expect( + x.equals(expected), + `${x.toFraction()} != ${expected.toFraction()} = ${testCase.expect}` + ).toBe(true); + } else { + expect(x.toString()).toBe(testCase.expect); + } + } + }); + } +}); + +describe('Arguments', () => { + it.each([ + ['0.1', '1/10'], + ['6234/6460', '3117/3230'], + [{n: 1, d: 3}, '1/3'], + ])('Should be possible to use param %s', (param, expected) => { + const fraction = new Fraction(param); + expect(`${fraction.n}/${fraction.d}`).toBe(expected); + expect(fraction.s).toBe(1); + }); +}); + +describe('fractions', () => { + it('Should pass 0.08 = 2/25', () => { + const fraction = new Fraction('0.08'); + expect('2/25').toBe(`${fraction.n}/${fraction.d}`); + }); + it('Should pass 0.200 = 1/5', () => { + const fraction = new Fraction('0.200'); + expect('1/5').toBe(`${fraction.n}/${fraction.d}`); + }); + + it('Should pass 0.125 = 1/8', () => { + const fraction = new Fraction('0.125'); + expect('1/8').toBe(`${fraction.n}/${fraction.d}`); + }); + + it('Should pass 8.36 = 209/25', () => { + const fraction = new Fraction(8.36).simplify(); + expect('209/25').toBe(`${fraction.n}/${fraction.d}`); + }); + + it('Should add complex values', () => { + const fraction = new Fraction(-1023461776, 334639305); + const sum = fraction.add({n: 4, d: 25}); + expect('-4849597436/1673196525').toBe(`${sum.s * sum.n}/${sum.d}`); + }); +}); + +describe('Fraction Output', () => { + it('Should pass -1.0000000000 = -1', () => { + const fraction = new Fraction('-1.0000000000'); + expect('-1').toBe(fraction.toFraction()); + }); + + it('Should pass -0.0000000000 = 0', () => { + const fraction = new Fraction('-0.0000000000'); + expect('0').toBe(fraction.toFraction()); + }); + + it('Should pass 1/-99/293 = -1/29007', () => { + const fraction = new Fraction(-99).inverse().div(293); + expect('-1/29007').toBe(fraction.toFraction()); + }); + + it('Should work with large calculations', () => { + const x = new Fraction(1123875); + const y = new Fraction(1238750184); + const z = new Fraction(1657134); + const r = new Fraction(77344464613500, 92063); + expect(x.mul(y).div(z).toFraction()).toBe(r.toFraction()); + }); +}); + +describe('Fraction toContinued', () => { + it('Should pass 415/93', () => { + const fraction = new Fraction(415, 93); + expect('4,2,6,7').toBe(fraction.toContinued().toString()); + }); + + it('Should pass 0/2', () => { + const fraction = new Fraction(0, 2); + expect('0').toBe(fraction.toContinued().toString()); + }); + + it('Should pass 1/7', () => { + const fraction = new Fraction(1, 7); + expect('0,7').toBe(fraction.toContinued().toString()); + }); + + it('Should pass 23/88', () => { + const fraction = new Fraction('23/88'); + expect('0,3,1,4,1,3').toBe(fraction.toContinued().toString()); + }); + + it('Should pass 1/99', () => { + const fraction = new Fraction('1/99'); + expect('0,99').toBe(fraction.toContinued().toString()); + }); + + it('Should pass 1768/99', () => { + const fraction = new Fraction('1768/99'); + expect('17,1,6,14').toBe(fraction.toContinued().toString()); + }); + + it('Should pass 1768/99', () => { + const fraction = new Fraction('7/8'); + expect('0,1,7').toBe(fraction.toContinued().toString()); + }); +}); + +describe('Fraction simplify', () => { + it('Should pass 415/93', () => { + const fraction = new Fraction(415, 93); + expect('9/2').toBe(fraction.simplify(0.1).toFraction()); + expect('58/13').toBe(fraction.simplify(0.01).toFraction()); + expect('415/93').toBe(fraction.simplify(0.0001).toFraction()); + }); +}); diff --git a/src/__tests__/fraction.spec.ts b/src/__tests__/fraction.spec.ts new file mode 100644 index 0000000..c79f496 --- /dev/null +++ b/src/__tests__/fraction.spec.ts @@ -0,0 +1,194 @@ +import {describe, it, expect} from 'vitest'; +import {Fraction, gcd, lcm, mmod} from '../fraction'; + +describe('gcd', () => { + it('can find the greates common divisor of 12 and 15', () => { + expect(gcd(12, 15)).toBe(3); + }); +}); + +describe('lcm', () => { + it('can find the least common multiple of 6 and 14', () => { + expect(lcm(6, 14)).toBe(42); + }); +}); + +describe('mmod', () => { + it('works with negative numbers', () => { + expect(mmod(-5, 3)).toBe(1); + }); +}); + +describe('Fraction', () => { + it('can be constructed from numerator and denominator', () => { + const fraction = new Fraction(-6, -12); + expect(fraction.s).toBe(1); + expect(fraction.n).toBe(1); + expect(fraction.d).toBe(2); + }); + + it('can be constructed from a floating point number', () => { + const fraction = new Fraction(-0.875); + expect(fraction.s).toBe(-1); + expect(fraction.n).toBe(7); + expect(fraction.d).toBe(8); + }); + + it('can be constructed from a plain number string', () => { + const fraction = new Fraction('5'); + expect(fraction.s).toBe(1); + expect(fraction.n).toBe(5); + expect(fraction.d).toBe(1); + }); + + it('can be constructed from a decimal string', () => { + const fraction = new Fraction('3.14159'); + expect(fraction.s).toBe(1); + expect(fraction.n).toBe(314159); + expect(fraction.d).toBe(100000); + }); + + it('can be construction from a fraction string', () => { + const fraction = new Fraction('-9/12'); + expect(fraction.s).toBe(-1); + expect(fraction.n).toBe(3); + expect(fraction.d).toBe(4); + }); + + it('can calculate the square root of 36/25', () => { + const fraction = new Fraction(36, 25); + const half = new Fraction(1, 2); + const result = fraction.pow(half); + expect(result).not.toBeNull(); + expect(result!.s).toBe(1); + expect(result!.n).toBe(6); + expect(result!.d).toBe(5); + }); + + it('infers zeroes for decimal components', () => { + const half = new Fraction('.5'); + expect(half.valueOf()).toBe(0.5); + const negativeQuarter = new Fraction('-.25'); + expect(negativeQuarter.valueOf()).toBe(-0.25); + const two = new Fraction('2.'); + expect(two.valueOf()).toBe(2); + const zero = new Fraction('.'); + expect(zero.valueOf()).toBe(0); + }); + + it('infers ones for slash components', () => { + const third = new Fraction('/3'); + expect(third.s).toBe(1); + expect(third.n).toBe(1); + expect(third.d).toBe(3); + const fifth = new Fraction('-/5'); + expect(fifth.s).toBe(-1); + expect(fifth.n).toBe(1); + expect(fifth.d).toBe(5); + const four = new Fraction('-4/'); + expect(four.s).toBe(-1); + expect(four.n).toBe(4); + expect(four.d).toBe(1); + const one = new Fraction('/'); + expect(one.s).toBe(1); + expect(one.n).toBe(1); + expect(one.d).toBe(1); + }); + + it('supports scientific notation', () => { + const thirtySevenPercent = new Fraction('37e-2'); + expect(thirtySevenPercent.s).toBe(1); + expect(thirtySevenPercent.n).toBe(37); + expect(thirtySevenPercent.d).toBe(100); + const minusTwelve = new Fraction('-1.2e1'); + expect(minusTwelve.s).toBe(-1); + expect(minusTwelve.n).toBe(12); + expect(minusTwelve.d).toBe(1); + const cursedTritone = new Fraction('14e-1'); + expect(cursedTritone.s).toBe(1); + expect(cursedTritone.n).toBe(7); + expect(cursedTritone.d).toBe(5); + const pleaseDont = new Fraction('-11/3e2'); + expect(pleaseDont.s).toBe(-1); + expect(pleaseDont.n).toBe(1100); + expect(pleaseDont.d).toBe(3); + }); + + // These obviously crashes the engine before failing the test. No way around that. + it('produces a finite continued fraction from a random value (0, 10)', () => { + const value = Math.random() * 10; + const fraction = new Fraction(value); + expect(fraction.toContinued().length).toBeLessThan(Infinity); + }); + + it.skip('produces a finite continued fraction from a random value (MAX_SAFE, 1e20<<', () => { + const value = Number.MAX_SAFE_INTEGER + Math.random() * 1e19; + const fraction = new Fraction(value); + // This obviously crashes the engine before failing the test. No way around that. + expect(fraction.toContinued().length).toBeLessThan(Infinity); + }); + + it.skip('produces a finite continued fraction from a balanced high complexity value', () => { + const fraction = new Fraction( + Number.MAX_SAFE_INTEGER + Math.random() * 1e18, + Number.MAX_SAFE_INTEGER + Math.random() * 1e18 + ); + expect(fraction.toContinued().length).toBeLessThan(Infinity); + }); + + it.skip('produces a finite continued fraction from an imbalanced high complexity value', () => { + const fraction = new Fraction( + Math.floor(Math.random() * 1000), + Number.MAX_SAFE_INTEGER + Math.random() * 1e18 + ); + expect(fraction.toContinued().length).toBeLessThan(Infinity); + }); + + it.skip('produces a finite continued fraction from infinity', () => { + const fraction = new Fraction(Infinity); + expect(fraction.toContinued().length).toBeLessThan(Infinity); + }); + + it('can approximate the golden ratio', () => { + let approximant = new Fraction(1); + for (let i = 0; i < 76; ++i) { + approximant = approximant.inverse().add(1); + // Finite numbers have two valid representations. + // This is the shorter one. + const expected = Array(i).fill(1); + expected.push(2); + expect(approximant.toContinued()).toEqual(expected); + } + }); + + it('can simplify a random number', () => { + const value = Math.random() * 2; + const fraction = new Fraction(value); + expect(fraction.simplify().valueOf()).toBeCloseTo(value); + }); + + it('can parse a repeated decimal', () => { + const fraction = new Fraction("3.'3'"); + expect(fraction.s).toBe(1); + expect(fraction.n).toBe(10); + expect(fraction.d).toBe(3); + }); + + it('can parse a repeated decimal (zero whole part)', () => { + const fraction = new Fraction("0.'1'"); + expect(fraction.s).toBe(1); + expect(fraction.n).toBe(1); + expect(fraction.d).toBe(9); + }); + + // Need BigInt for this. + it.skip('can parse repeated decimal (late cycle)', () => { + const fraction = new Fraction("0.269'736842105263157894'"); + console.log(fraction); + }); + + it('can produce repeated decimals', () => { + const fraction = new Fraction(5, 11); + expect(fraction.toString()).toBe("0.'45'"); + }); +}); diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index 0f45594..a85a8d6 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -12,8 +12,6 @@ import { gcd, getConvergents, iteratedEuclid, - lcm, - mmod, norm, PRIMES, valueToCents, @@ -27,24 +25,6 @@ describe('Array equality tester', () => { }); }); -describe('gcd', () => { - it('can find the greates common divisor of 12 and 15', () => { - expect(gcd(12, 15)).toBe(3); - }); -}); - -describe('lcm', () => { - it('can find the least common multiple of 6 and 14', () => { - expect(lcm(6, 14)).toBe(42); - }); -}); - -describe('mmod', () => { - it('works with negative numbers', () => { - expect(mmod(-5, 3)).toBe(1); - }); -}); - describe('extended Euclidean algorithm', () => { it('finds the Bézout coefficients for 15 and 42', () => { const a = 15; diff --git a/src/__tests__/monzo.spec.ts b/src/__tests__/monzo.spec.ts index ed7436e..0157d9b 100644 --- a/src/__tests__/monzo.spec.ts +++ b/src/__tests__/monzo.spec.ts @@ -24,7 +24,7 @@ describe('Monzo converter', () => { expect(monzo[3]).toBe(3); expect( new Fraction(2) - .pow(monzo[0]) + .pow(monzo[0])! .mul(3 ** monzo[1] * 7 ** monzo[3]) .equals('1029/1024') ).toBeTruthy(); @@ -52,7 +52,7 @@ describe('Fraction to monzo converter', () => { expect(monzo[2]).toBe(1); expect( new Fraction(2) - .pow(monzo[0]) + .pow(monzo[0])! .mul(3 ** monzo[1]) .mul(5 ** monzo[2]) .equals(new Fraction(45, 32)) @@ -68,7 +68,7 @@ describe('Fraction to monzo converter', () => { expect(monzo[2]).toBe(1); expect( new Fraction(2) - .pow(monzo[0]) + .pow(monzo[0])! .mul(3 ** monzo[1]) .mul(5 ** monzo[2]) .mul(residual) @@ -76,14 +76,14 @@ describe('Fraction to monzo converter', () => { ).toBeTruthy(); }); - it('throws for zero', () => { + it('leaves residual 0 for zero (vector part)', () => { const [monzo, residual] = toMonzoAndResidual(0, 1); expect(residual.equals(0)).toBeTruthy(); expect(monzo).toHaveLength(1); - expect(new Fraction(2).pow(monzo[0]).mul(residual).equals(0)).toBeTruthy(); + expect(new Fraction(2).pow(monzo[0])!.mul(residual).equals(0)).toBeTruthy(); }); - it('throws for zero (no vector part)', () => { + it('leaves residual 0 for zero (no vector part)', () => { const [monzo, residual] = toMonzoAndResidual(0, 0); expect(residual.equals(0)).toBeTruthy(); expect(monzo).toHaveLength(0); diff --git a/src/fraction.ts b/src/fraction.ts index 4607499..4bada93 100644 --- a/src/fraction.ts +++ b/src/fraction.ts @@ -1,16 +1,60 @@ -import FractionJS, {NumeratorDenominator} from 'fraction.js'; +// I'm rolling my own because fraction.js has trouble with TypeScript https://github.com/rawify/Fraction.js/issues/72 +// -Lumi -export * from 'fraction.js'; +import {PRIMES} from './primes'; + +export type UnsignedFraction = {n: number; d: number}; + +// Explicitly drop [number, number] because it overlaps with monzos +export type FractionValue = Fraction | UnsignedFraction | number | string; + +const MAX_CONTINUED_LENGTH = 1000; +const MAX_CYCLE_LENGTH = 128; + +/** + * Greatest common divisor of two integers. + * @param a The first integer. + * @param b The second integer. + * @returns The largest integer that divides a and b. + */ +export function gcd(a: number, b: number): number { + if (!a) return b; + if (!b) return a; + while (true) { + a %= b; + if (!a) return b; + b %= a; + if (!b) return a; + } +} + +/** + * Least common multiple of two integers. + * @param a The first integer. + * @param b The second integer. + * @returns The smallest integer that both a and b divide. + */ +export function lcm(a: number, b: number): number { + return (Math.abs(a) / gcd(a, b)) * Math.abs(b); +} + +/** + * Mathematically correct modulo. + * @param a The dividend. + * @param b The divisor. + * @returns The remainder of Euclidean division of a by b. + */ +export function mmod(a: number, b: number) { + return ((a % b) + b) % b; +} -// Subclass Fraction to remove default export status. /** * * This class offers the possibility to calculate fractions. - * You can pass a fraction in different formats: either as an array, an integer, a floating point number or a string. + * You can pass a fraction in different formats: either as two integers, an integer, a floating point number or a string. * - * Array/Object form + * Numerator, denominator form * ```ts - * new Fraction([numerator, denominator]); * new Fraction(numerator, denominator); * ``` * @@ -28,24 +72,607 @@ export * from 'fraction.js'; * ```ts * new Fraction("123.456"); // a simple decimal * new Fraction("123/456"); // a string fraction - * new Fraction("123.'456'"); // repeating decimal places - * new Fraction("123.(456)"); // synonym - * new Fraction("123.45'6'"); // repeating last place - * new Fraction("123.45(6)"); // synonym - * ``` - * Example: - * ```ts - * const fraction = new Fraction("9.4'31'"); - * fraction.mul([-4, 3]).div(4.9) // -37348/14553 + * new Fraction("13e-3"); // scientific notation * ``` - * */ -export class Fraction extends FractionJS {} +export class Fraction { + /** Sign: +1, 0 or -1 */ + s: number; + /** Numerator */ + n: number; + /** Denominator */ + d: number; -// Explicitly drop [number, number] because it overlaps with monzos -export type FractionValue = - | Fraction - | number - | string - | [string, string] - | NumeratorDenominator; + constructor(numerator: FractionValue, denominator?: number) { + if (denominator !== undefined) { + if (typeof numerator !== 'number') { + throw new Error('Numerator must be a number when denominator is given'); + } + if (isNaN(numerator) || isNaN(denominator)) { + throw new Error('Cannot represent NaN as a fraction'); + } + this.s = Math.sign(numerator * denominator); + this.n = Math.abs(numerator); + this.d = Math.abs(denominator); + if (this.d === 0) { + throw new Error('Division by Zero'); + } + this.defloat(); + } else if (typeof numerator === 'number') { + if (isNaN(numerator)) { + throw new Error('Cannot represent NaN as a fraction'); + } + this.s = Math.sign(numerator); + this.n = Math.abs(numerator); + this.d = 1; + this.defloat(); + } else if (typeof numerator === 'string') { + numerator = numerator.toLowerCase(); + this.n = 1; + this.d = 1; + if (numerator.includes('e')) { + const [mantissa, exponent] = numerator.split('e', 2); + numerator = mantissa; + const e = parseInt(exponent, 10); + if (e > 0) { + this.n = 10 ** e; + } else if (e < 0) { + this.d = 10 ** -e; + } + } + if (numerator.includes('/')) { + if (numerator.includes('.')) { + throw new Error('Parameters must be integer'); + } + const [n, d] = numerator.split('/', 2); + if (n === '-') { + this.n *= -1; + } else { + this.n *= n ? parseInt(n, 10) : 1; + } + this.d *= d ? parseInt(d, 10) : 1; + this.s = Math.sign(this.n * this.d); + this.n = Math.abs(this.n); + this.d = Math.abs(this.d); + } else if (numerator.includes('.')) { + let [n, f] = numerator.split('.', 2); + let r: string | undefined; + [f, r] = f.split("'", 2); + if (n.startsWith('-')) { + this.s = -1; + n = n.slice(1); + } else { + this.s = 1; + } + let m = n ? parseInt(n, 10) : 0; + if (this.n < 0) { + throw new Error('Double sign'); + } + for (const c of f) { + m = 10 * m + parseInt(c, 10); + this.d *= 10; + } + this.n *= m; + if (r) { + r = r.replace(/'/g, ''); + if (r.length) { + const cycleN = parseInt(r, 10); + if (cycleN > Number.MAX_SAFE_INTEGER) { + throw new Error('Cycle too long'); + } + const cycleD = (10 ** r.length - 1) * 10 ** f.length; + this.n = this.n * cycleD + this.d * cycleN; + this.d *= cycleD; + } + } + if (!this.n) { + this.s = 0; + } + } else { + this.n = parseInt(numerator, 10); + this.s = Math.sign(this.n); + this.n = Math.abs(this.n); + } + if (this.d === 0) { + throw new Error('Division by Zero'); + } + this.reduce(); + } else { + if (numerator.d === 0) { + throw new Error('Division by Zero'); + } + if ('s' in numerator) { + this.s = Math.sign(numerator.s); + } else { + this.s = 1; + } + if (numerator.n < 0) { + this.s = -this.s; + } + if (numerator.d < 0) { + this.s = -this.s; + } + this.n = Math.abs(numerator.n); + this.d = Math.abs(numerator.d); + this.reduce(); + } + this.validate(); + } + + /** + * Validate that this fraction represents the ratio of two integers. + */ + validate() { + if (isNaN(this.s) || isNaN(this.n) || isNaN(this.d)) { + throw new Error('Cannot represent NaN as a fraction'); + } + /* + if (!isFinite(this.d)) { + if (!isFinite(this.n)) { + throw new Error('Both numerator and denominator cannot be infinite'); + } + this.n = 0; + this.d = 1; + } + */ + if (this.n > Number.MAX_SAFE_INTEGER) { + throw new Error('Numerator above safe limit'); + } + if (this.d > Number.MAX_SAFE_INTEGER) { + throw new Error('Denominator above safe limit'); + } + } + + /** + * IEEE floats always have a denominator of a power of two. Reduce it out. + * If the process would produce integers too large for the Number type an approximation is used. + */ + defloat() { + while (this.n !== Math.floor(this.n) || this.d !== Math.floor(this.d)) { + this.n *= 2; + this.d *= 2; + } + // Cut back if defloating produces something not representable additively. + if (this.n > Number.MAX_SAFE_INTEGER || this.d > Number.MAX_SAFE_INTEGER) { + let x = this.n / this.d; + const coefs: number[] = []; + for (let i = 0; i < 20; ++i) { + const coef = Math.floor(x); + if (coef > 1e12) { + break; + } + coefs.push(coef); + if (x === coef) { + break; + } + x = 1 / (x - coef); + } + let n = coefs.pop()!; + let d = 1; + while (coefs.length) { + [n, d] = [d + n * coefs.pop()!, n]; + } + this.n = n; + this.d = d; + } + this.reduce(); + } + + /** + * Reduce out the common factor between the numerator and denominator. + */ + reduce() { + const commonFactor = gcd(this.n, this.d); + this.n /= commonFactor; + this.d /= commonFactor; + } + + /** + * Creates a string representation of a fraction with all digits + * + * Ex: new Fraction("100.'91823'").toString() => "100.'91823'" + **/ + toString() { + let result = this.s < 0 ? '-' : ''; + const whole = Math.floor(this.n / this.d); + result += whole.toString(); + let fractional = this.abs().sub(whole); + if (fractional.n === 0) { + return result; + } + result += '.'; + let decimals = ''; + const history = [fractional]; + + for (let i = 0; i < MAX_CYCLE_LENGTH; ++i) { + fractional = fractional.mul(10); + const digit = Math.floor(fractional.n / fractional.d); + decimals += digit.toString(); + fractional = fractional.sub(digit); + if (fractional.n === 0) { + return result + decimals; + } + for (let j = 0; j < history.length; ++j) { + if (fractional.equals(history[j])) { + return result + decimals.slice(0, j) + "'" + decimals.slice(j) + "'"; + } + } + history.push(fractional); + } + return result + decimals + '...'; + } + + /** + * Returns an array of continued fraction elements + * + * Ex: new Fraction("7/8").toContinued() => [0,1,7] + */ + toContinued() { + const result = []; + let a = this.n; + let b = this.d; + for (let i = 0; i < MAX_CONTINUED_LENGTH; ++i) { + const coef = Math.floor(a / b); + result.push(coef); + [a, b] = [b, a - coef * b]; + if (a === 1) { + break; + } + } + return result; + } + + /** + * Calculates the absolute value + * + * Ex: new Fraction(-4).abs() => 4 + **/ + abs() { + return new Fraction({ + s: Math.abs(this.s), + n: this.n, + d: this.d, + }); + } + + /** + * Returns a decimal representation of the fraction + * + * Ex: new Fraction("100.'91823'").valueOf() => 100.91823918239183 + **/ + valueOf() { + return (this.s * this.n) / this.d; + } + + /** + * Gets the inverse of the fraction, means numerator and denominator are exchanged + * + * Ex: new Fraction(-3, 4).inverse() => -4 / 3 + **/ + inverse() { + if (this.n === 0) { + throw new Error('Division by Zero'); + } + return new Fraction({s: this.s, n: this.d, d: this.n} as Fraction); + } + + /** + * Inverts the sign of the current fraction + * + * Ex: new Fraction(-4).neg() => 4 + **/ + neg() { + return new Fraction({s: -this.s, n: this.n, d: this.d} as Fraction); + } + + /** + * Returns a string-fraction representation of a Fraction object + * + * Ex: new Fraction("1.'3'").toFraction(true) => "4/3" + **/ + toFraction() { + const n = this.s * this.n; + if (this.d === 1) { + return n.toString(); + } + return `${n}/${this.d}`; + } + + /** + * Clones the actual object + * + * Ex: new Fraction("-17.'345'").clone() + **/ + clone() { + return new Fraction(this); + } + + /** + * Return a convergent of this fraction that is within the given tolerance. + */ + simplify(epsilon = 0.001) { + const abs = this.abs(); + const cont = abs.toContinued(); + const absValue = abs.valueOf(); + + for (let i = 1; i < cont.length; i++) { + let s = new Fraction({s: 1, n: cont[i - 1], d: 1} as Fraction); + for (let k = i - 2; k >= 0; k--) { + s = s.inverse().add(cont[k]); + } + + if (Math.abs(s.valueOf() - absValue) < epsilon) { + return new Fraction({s: this.s, n: s.n, d: s.d} as Fraction); + } + } + return this.clone(); + } + + /** + * Calculates the floor of a rational number + * + * Ex: new Fraction("4.'3'").floor() => (4 / 1) + **/ + floor() { + if (this.d > Number.MAX_SAFE_INTEGER) { + return new Fraction(Math.floor(this.valueOf())); + } + const n = this.s * this.n; + const m = mmod(n, this.d); + return new Fraction((n - m) / this.d); + } + + /** + * Calculates the ceil of a rational number + * + * Ex: new Fraction("4.'3'").ceil() => (5 / 1) + **/ + ceil() { + if (this.d > Number.MAX_SAFE_INTEGER) { + return new Fraction(Math.ceil(this.valueOf())); + } + const n = this.s * this.n; + const m = mmod(n, this.d); + if (m) { + return new Fraction(1 + (n - m) / this.d); + } + return this; + } + + /** + * Rounds a rational number + * + * Ex: new Fraction("4.'3'").round() => (4 / 1) + **/ + round() { + try { + return this.add(new Fraction({s: 1, n: 1, d: 2} as Fraction)).floor(); + } catch { + return new Fraction(Math.round(this.valueOf())); + } + } + + /** + * Rounds a rational number to a multiple of another rational number + * + * Ex: new Fraction('0.9').roundTo("1/8") => 7 / 8 + **/ + roundTo(other: FractionValue) { + const {n, d} = new Fraction(other); + + return new Fraction( + this.s * Math.round((this.n * d) / (this.d * n)) * n, + d + ); + } + + /** + * Adds two rational numbers + * + * Ex: new Fraction(\{n: 2, d: 3\}).add("14.9") => 467 / 30 + **/ + add(other: FractionValue) { + const {s, n, d} = new Fraction(other); + return new Fraction(this.s * this.n * d + s * n * this.d, this.d * d); + } + + /** + * Subtracts two rational numbers + * + * Ex: new Fraction(\{n: 2, d: 3\}).add("14.9") => -427 / 30 + **/ + sub(other: FractionValue) { + const {s, n, d} = new Fraction(other); + return new Fraction(this.s * this.n * d - s * n * this.d, this.d * d); + } + + /** + * Multiplies two rational numbers + * + * Ex: new Fraction("-17.'345'").mul(3) => 5776 / 111 + **/ + mul(other: FractionValue) { + const {s, n, d} = new Fraction(other); + return new Fraction(this.s * this.n * s * n, this.d * d); + } + + /** + * Divides two rational numbers + * + * Ex: new Fraction("-17.'345'").inverse().div(3) + **/ + div(other: FractionValue) { + const {s, n, d} = new Fraction(other); + if (n === 0) { + throw new Error('Division by Zero'); + } + return new Fraction(this.s * this.n * s * d, this.d * n); + } + + /** + * Calculates the modulo of two rational numbers - a more precise fmod + * + * Ex: new Fraction("4.'3'").mod("7/8") => (13/3) % (7/8) = (5/6) + **/ + mod(other: FractionValue) { + other = new Fraction(other); + return new Fraction( + (this.s * (other.d * this.n)) % (other.n * this.d), + this.d * other.d + ); + } + + /** + * Calculates the mathematically correct modulo of two rational numbers + * + * Ex: new Fraction("-4.'3'").mmod("7/8") => (-13/3) % (7/8) = (1/24) + **/ + mmod(other: FractionValue) { + other = new Fraction(other); + return new Fraction( + mmod(this.s * (other.d, this.n), other.n * this.d), + this.d * other.d + ); + } + + /** + * Calculates the fraction to some rational exponent, if possible + * + * Ex: new Fraction(-1,2).pow(-3) => -8 + */ + pow(other: FractionValue): Fraction | null { + const {s, n, d} = new Fraction(other); + if (s === 0) { + return new Fraction(1); + } + if (d === 1) { + if (s < 0) { + return new Fraction((this.s * this.d) ** n, this.n ** n); + } else { + return new Fraction((this.s * this.n) ** n, this.d ** n); + } + } + if (this.s === 0) { + return new Fraction(0); + } + if (this.s < 0 && d % 2 === 0) { + return null; + } + if (this.n === 1) { + if (this.s > 0) { + return new Fraction(1); + } + return new Fraction(-1); + } + let nProbe = 1; + let dProbe = 1; + let limitIndex = 0; + let numerator = this.s; + let denominator = 1; + do { + if (limitIndex >= PRIMES.length) { + return null; + } + let rootExponent = -1; + const prime = PRIMES[limitIndex]; + const primePower = prime ** d; + let lastProbe; + do { + lastProbe = nProbe; + nProbe *= primePower; + rootExponent++; + } while (this.n % nProbe === 0); + nProbe = lastProbe; + + for (let i = 1; i < d; ++i) { + lastProbe *= prime; + if (this.n % lastProbe === 0) { + return null; + } + } + + // The fraction is in lowest terms so we can skip the denominator + if (rootExponent) { + numerator *= prime ** (n * rootExponent); + limitIndex++; + continue; + } + + rootExponent = -1; + do { + lastProbe = dProbe; + dProbe *= primePower; + rootExponent++; + } while (this.d % dProbe === 0); + dProbe = lastProbe; + + for (let i = 1; i < d; ++i) { + lastProbe *= prime; + if (this.d % lastProbe === 0) { + return null; + } + } + + denominator *= prime ** (n * rootExponent); + limitIndex++; + } while (nProbe !== this.n || dProbe !== this.d); + + if (s > 0) { + return new Fraction(numerator, denominator); + } + return new Fraction(denominator, numerator); + } + + /** + * Compare if two rational numbers, return negative if this is less + * + * Ex: new Fraction("19.6").equals("98/5"); + **/ + compare(other: FractionValue) { + const {s, n, d} = new Fraction(other); + return this.s * this.n * d - s * n * this.d; + } + + /** + * Check if two rational numbers are the same + * + * Ex: new Fraction("19.6").equals("98/5"); + **/ + equals(other: FractionValue) { + const {s, n, d} = new Fraction(other); + return this.s === s && this.n === n && this.d === d; + } + + /** + * Check if two rational numbers are divisible + * + * Ex: new Fraction("19.6").divisible("1.5"); + */ + divisible(other: FractionValue) { + other = new Fraction(other); + return !(!(other.n * this.d) || (this.n * other.d) % (other.n * this.d)); + } + + /** + * Calculates the fractional gcd of two rational numbers + * + * Ex: new Fraction(5,8).gcd("3/7") => 1/56 + */ + gcd(other: FractionValue) { + const {n, d} = new Fraction(other); + return new Fraction(gcd(n, this.n) * gcd(d, this.d), d * this.d); + } + + /** + * Calculates the fractional lcm of two rational numbers + * + * Ex: new Fraction(5,8).lcm("3/7") => 15 + */ + lcm(other: FractionValue) { + const {n, d} = new Fraction(other); + if (n === 0 && this.n === 0) { + return new Fraction({s: 0, n: 0, d: 1}); + } + return new Fraction(n * this.n, gcd(n, this.n) * gcd(d, this.d)); + } +} diff --git a/src/index.ts b/src/index.ts index e48314c..b792fe1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import {valueToCents} from './conversion'; -import {Fraction, FractionValue} from './fraction'; +import {Fraction, FractionValue, mmod} from './fraction'; import {PRIMES, PRIME_CENTS} from './primes'; export * from './fraction'; @@ -39,44 +39,6 @@ export function arraysEqual(a: AnyArray, b: AnyArray) { return true; } -// Stolen from fraction.js, because it's not exported. -/** - * Greatest common divisor of two integers. - * @param a The first integer. - * @param b The second integer. - * @returns The largest integer that divides a and b. - */ -export function gcd(a: number, b: number): number { - if (!a) return b; - if (!b) return a; - while (true) { - a %= b; - if (!a) return b; - b %= a; - if (!b) return a; - } -} - -/** - * Least common multiple of two integers. - * @param a The first integer. - * @param b The second integer. - * @returns The smallest integer that both a and b divide. - */ -export function lcm(a: number, b: number): number { - return (Math.abs(a) / gcd(a, b)) * Math.abs(b); -} - -/** - * Mathematically correct modulo. - * @param a The dividend. - * @param b The divisor. - * @returns The remainder of Euclidean division of a by b. - */ -export function mmod(a: number, b: number) { - return ((a % b) + b) % b; -} - /** * Floor division. * @param a The dividend. @@ -387,13 +349,13 @@ export function approximateOddLimitWithErrors(cents: number, limit: number) { const exponent = Math.round((cents - oddCents - remainder) / 1200); const error = remainder; // Exponentiate to add the required number of octaves. - results.push([ODD_FRACTIONS[i].mul(TWO.pow(exponent)), error]); + results.push([ODD_FRACTIONS[i].mul(TWO.pow(exponent)!), error]); } // Undershot else { const exponent = Math.round((cents - oddCents - remainder) / 1200) + 1; const error = 1200 - remainder; - results.push([ODD_FRACTIONS[i].mul(TWO.pow(exponent)), error]); + results.push([ODD_FRACTIONS[i].mul(TWO.pow(exponent)!), error]); } } @@ -469,7 +431,9 @@ export function approximatePrimeLimitWithErrors( } push(error, () => approximation.mul( - TWO.pow(Math.round((cents - approximationCents - remainder) / 1200)) + TWO.pow( + Math.round((cents - approximationCents - remainder) / 1200) + )! ) ); } else { @@ -481,7 +445,7 @@ export function approximatePrimeLimitWithErrors( approximation.mul( TWO.pow( Math.round((cents - approximationCents - remainder) / 1200) + 1 - ) + )! ) ); }