const fs = require('fs'); const path = require('path'); const assert = require('assert'); // --- Mock Bangle.js environment --- let mock_display_str = ""; let mock_ui_callbacks = {}; let drawn_since_clear = false; global.last_display_num = null; // Mock 'g' (graphics) global.g = { _col: 0, _bg: 0, _font: "6x8", _font_size: 1, clear: () => { drawn_since_clear = false; }, clearRect: () => { drawn_since_clear = false; }, setColor: (c) => { global.g._col = c; return global.g; }, setBgColor: (c) => { global.g._bg = c; return global.g; }, fillRect: () => {}, setFont: function(font, size) { if (font && font.includes(":")) { const parts = font.split(":"); this._font = parts[0]; this._font_size = parseInt(parts[1], 10); } else { this._font = font; if (size) this._font_size = size; } return this; }, setFontAlign: () => {}, stringWidth: (s) => s.length * 4.7 * (global.g._font_size || 1), // approximation to match watch getWidth: () => 176, getHeight: () => 176, drawString: (s) => { if (!drawn_since_clear) { mock_display_str = String(s); drawn_since_clear = true; } }, getFontHeight: () => 8 * (global.g._font_size || 1) }; global.Graphics = {}; // Mock Bangle.js-specific process.env properties process.env.HWVERSION = 2; // Simulate Bangle.js 2 // Mock 'Bangle' global.Bangle = { setUI: (callbacks) => { mock_ui_callbacks = callbacks; }, http: () => new Promise(resolve => resolve({resp: "{}"})), buzz: () => {}, }; // Mock 'load' (to exit app) global.load = () => {}; // --- End Mock --- // --- Load calculator app --- // We inject a "spy" to capture the raw value passed to displayOutput, // making our tests independent of UI formatting. let calculatorCode = fs.readFileSync(path.join(__dirname, 'calculator.app.js'), 'utf8'); // 1. Rename the original function so we can call it. calculatorCode = calculatorCode.replace( 'function displayOutput(num)', 'function _original_displayOutput(num)' ); // 2. Prepend our spy that captures the value and then calls the original. const spyCode = ` var displayOutput = function(num) { global.last_display_num = num; _original_displayOutput(num); }; `; const processedCode = spyCode + calculatorCode; const wrappedCode = `(function(require) { ${processedCode}; return { buttonPress, scientificOperators }; })`; const getAppFns = eval(wrappedCode); const { buttonPress, scientificOperators } = getAppFns((name) => { if (name === "FontDylex7x13") { return { add: () => {} }; } return require(name); }); // --- End Load --- // --- Test Framework --- const test_suite = []; let tests_passed = 0; let tests_failed = 0; function test(name, fn) { test_suite.push({ name, fn }); } function runTests() { test_suite.forEach(t => { // Reset state before each test buttonPress('R'); // C buttonPress('R'); // AC if (global.last_display_num !== 0) { console.error(`[FAIL] ${t.name} - Failed to reset state. Last number is: "${global.last_display_num}"`); tests_failed++; return; } try { t.fn(); console.log(`[PASS] ${t.name}`); tests_passed++; } catch (e) { console.error(`[FAIL] ${t.name}`); console.error(e); tests_failed++; } }); console.log(`\nTests finished. Passed: ${tests_passed}, Failed: ${tests_failed}.`); if (tests_failed > 0) { process.exit(1); } } function press(buttons) { for (const button of buttons) { buttonPress(button); } } function checkDisplay(expected, message) { const actual = global.last_display_num; if (typeof expected === 'number' && isNaN(expected)) { assert.ok(typeof actual === 'number' && isNaN(actual), message || `Expected NaN, got ${actual}`); return; } // Coerce to numbers for comparison to handle string vs number differences ('5' vs 5) // and use a tolerance for floating point values. const actualNum = parseFloat(actual); const expectedNum = parseFloat(expected); if (!isNaN(actualNum) && !isNaN(expectedNum)) { const tolerance = 1e-9; if (Math.abs(expectedNum - actualNum) < tolerance || actualNum === expectedNum) { // second part handles Infinity return; // The numbers are close enough. } } // Fallback to strict equality for non-numeric strings or when numeric check fails assert.strictEqual(actual, expected, message); } // --- End Test Framework --- // --- Test Cases --- const basicNumbers = [ 0, 1, -1, 2, -2, 5, -5, 10, -10, 0.1, -0.1, 0.2, -0.2, 0.5, -0.5, 123, -123, 1.23, -1.23, 1234567, -1234567, 99999999, -99999999, ]; const edgeCaseNumbers = [ -0, Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER, Math.PI, Math.E, 0.000001, -0.000001, 0.00000001, -0.00000001, 1000000, -1000000, 100000000, -100000000, 0.1+0.2, // floating point fun 1/3, 2/3, 0.9999999, 1.0000001 ]; // Generate some more numbers to reach the target count const generatedNumbers = []; for (let i = 1; i <= 20; i++) { // integers generatedNumbers.push(i * 123); generatedNumbers.push(i * -123); // floats generatedNumbers.push(i / 10); generatedNumbers.push(i / -10); // small floats generatedNumbers.push(i / 1000); generatedNumbers.push(i / -1000); } const testNumbers = [...new Set([...basicNumbers, ...edgeCaseNumbers, ...generatedNumbers])]; // We need to filter out Infinity/-Infinity for pressNumber helper as they can't be typed. // They can only appear as results, which we test for separately. const finiteTestNumbers = testNumbers.filter(n => isFinite(n)); // Helper to press a number, handling negatives function pressNumber(n) { let s = String(n); // If string representation is in scientific notation, convert to decimal string if (s.includes('e')) { s = n.toFixed(20).replace(/0+$/, '').replace(/\.$/, ''); } if (s.startsWith('-')) { press(s.substring(1)); buttonPress('N'); } else { press(s); } } // --- Generated Tests: Binary Operations --- const binaryOps = { '+': (a, b) => a + b, '-': (a, b) => a - b, '*': (a, b) => a * b, '/': (a, b) => a / b, }; for (const op in binaryOps) { for (const a of finiteTestNumbers) { for (const b of finiteTestNumbers) { if (op === '/' && b === 0) { test(`Binary Edge Case: ${a} / 0`, () => { pressNumber(a); buttonPress('/'); pressNumber(0); buttonPress('='); checkDisplay(a === 0 ? NaN : (a > 0 ? Infinity : -Infinity)); }); continue; } test(`Binary: ${a} ${op} ${b}`, () => { pressNumber(a); buttonPress(op); pressNumber(b); buttonPress('='); const expected = binaryOps[op](a, b); checkDisplay(expected); }); } } } // --- Generated Tests: Operations with non-finite results --- const smallFiniteTestNumbers = [1, -1, 5, -5, 0, 100, -100]; // for speed // Test operations that result in Infinity, -Infinity, or NaN, and then continue with another operation for (const a of smallFiniteTestNumbers) { // a / 0 -> Infinity/-Infinity/NaN test(`Non-finite chaining: (${a} / 0) + 5`, () => { pressNumber(a); press('/0='); const intermediate = a/0; checkDisplay(intermediate); press('+5='); checkDisplay(intermediate + 5); }); test(`Non-finite chaining: (${a} / 0) * 5`, () => { pressNumber(a); press('/0='); const intermediate = a/0; checkDisplay(intermediate); press('*5='); checkDisplay(intermediate * 5); }); test(`Non-finite chaining: (${a} / 0) * 0`, () => { pressNumber(a); press('/0='); const intermediate = a/0; checkDisplay(intermediate); press('*0='); checkDisplay(intermediate * 0); // Should be NaN }); test(`Non-finite chaining: (${a} / 0) / 5`, () => { pressNumber(a); press('/0='); const intermediate = a/0; checkDisplay(intermediate); press('/5='); checkDisplay(intermediate / 5); }); test(`Non-finite chaining: (${a} / 0) / 0`, () => { pressNumber(a); press('/0='); const intermediate = a/0; checkDisplay(intermediate); press('/0='); checkDisplay(intermediate / 0); }); } // 0/0 = NaN. Test operations on NaN test(`Non-finite chaining: (0 / 0) + 5`, () => { press('0/0='); checkDisplay(NaN); press('+5='); checkDisplay(NaN + 5); // anything + NaN is NaN }); test(`Non-finite chaining: (0 / 0) * 5`, () => { press('0/0='); checkDisplay(NaN); press('*5='); checkDisplay(NaN * 5); }); test(`Non-finite chaining: (0 / 0) / 5`, () => { press('0/0='); checkDisplay(NaN); press('/5='); checkDisplay(NaN / 5); }); test(`Non-finite chaining: sqrt(negative) then op`, () => { press('9N'); // -9 press('r'); // sqrt checkDisplay(NaN); press('+1='); checkDisplay(NaN); }); // --- Generated Tests: Unary Operations --- const unaryOps = { 'r': { name: 'sqrt', fn: Math.sqrt }, 's': { name: 'x^2', fn: (a) => a * a }, 'i': { name: '1/x', fn: (a) => 1 / a }, }; for (const op in unaryOps) { for (const a of finiteTestNumbers) { if (op === 'r' && a < 0) { test(`Unary Edge Case: sqrt(${a})`, () => { pressNumber(a); buttonPress('r'); checkDisplay(NaN); }); continue; } test(`Unary: ${unaryOps[op].name}(${a})`, () => { pressNumber(a); buttonPress(op); const expected = unaryOps[op].fn(a); checkDisplay(expected); }); } } // --- Generated Tests: Chaining Operations --- // To keep runtime sane, we'll pick a smaller subset for chaining const chainNumbers = [0, 1, -1, 5, -5, 0.5, -0.5, 10, -10, 123, -123, 1.23]; const chainOps = ['+', '-', '*', '/']; for (const a of chainNumbers) { for (const b of chainNumbers) { for (const c of chainNumbers) { for (const op1 of chainOps) { for (const op2 of chainOps) { test(`Chaining: ${a} ${op1} ${b} ${op2} ${c}`, () => { pressNumber(a); buttonPress(op1); pressNumber(b); buttonPress(op2); // This will execute the first operation pressNumber(c); buttonPress('='); // This will execute the second operation // Calculator does sequential evaluation: (a op1 b) op2 c const expected = binaryOps[op2](binaryOps[op1](a, b), c); checkDisplay(expected); }); } } } } } // --- Generated Tests: Trigonometry --- const trigOps = { 'sin': { fn: Math.sin, name: 'sin' }, 'cos': { fn: Math.cos, name: 'cos' }, 'tan': { fn: Math.tan, name: 'tan' }, }; const trigAngles = [0, 15, 30, 45, 60, 75, 90, 180, 270, 360, -30, -45, -90, -180, 450, 720, Math.PI/6, Math.PI/4, Math.PI/3, Math.PI/2, Math.PI, 3*Math.PI/2, 2*Math.PI, -Math.PI/2, -Math.PI]; // Test in DEG mode (default) for (const op in trigOps) { for (const angle of trigAngles) { test(`Trig (deg): ${trigOps[op].name}(${angle})`, () => { pressNumber(angle); buttonPress(op); const expected = trigOps[op].fn(angle * Math.PI / 180); checkDisplay(expected); }); } } // Test in RAD mode test('Switch to RAD mode', () => { buttonPress('angleMode'); assert.strictEqual(scientificOperators.angleMode.val, 'rad'); }); for (const op in trigOps) { for (const angle of trigAngles) { test(`Trig (rad): ${trigOps[op].name}(${angle})`, () => { pressNumber(angle); buttonPress(op); const expected = trigOps[op].fn(angle); checkDisplay(expected); }); } } test('Switch back to DEG mode for subsequent tests', () => { buttonPress('angleMode'); assert.strictEqual(scientificOperators.angleMode.val, 'deg'); }); // --- Specific Functionality Tests --- test('Clear and All Clear', () => { press('123+'); checkDisplay('123'); press('R'); // Clear checkDisplay('0'); press('456'); checkDisplay('456'); press('='); checkDisplay('579'); // 123 + 456 press('R'); // Clear checkDisplay('0'); buttonPress('R'); // All Clear press('+1='); checkDisplay('1'); }); test('Backspace', () => { press('123.45B'); checkDisplay('123.4'); press('B'); checkDisplay('123.'); press('B'); checkDisplay('123'); press('B'); checkDisplay('12'); press('B'); checkDisplay('1'); press('B'); checkDisplay('0'); press('B'); checkDisplay('0'); }); test('Repeated equals', () => { press('2+3='); checkDisplay('5'); press('='); checkDisplay('8'); press('='); checkDisplay('11'); press('R'); press('R'); press('10-2='); checkDisplay('8'); press('='); checkDisplay('6'); }); test('Operator change', () => { press('10+-*/2='); checkDisplay('5'); // 10/2=5 }); test('Long number input', () => { press('1234567'); checkDisplay('1234567'); }); test('Input starting with decimal point', () => { press('.5'); checkDisplay('0.5'); press('*2='); checkDisplay('1'); }); test('Negative zero handling', () => { press('0N'); // get -0 checkDisplay(-0); press('*5'); checkDisplay('5'); // after typing 5, display should be 5 press('='); checkDisplay(-0); // -0 * 5 = -0 buttonPress('R'); buttonPress('R'); press('5*0N='); // 5 * -0 = -0 checkDisplay(-0); }); test('Pi button', () => { press('p'); checkDisplay(Math.PI); press('*2='); checkDisplay(Math.PI * 2); }); test('Pi replaces current number entry', () => { press('123p'); checkDisplay(Math.PI); }); test('Operation with Pi', () => { press('5*p='); checkDisplay(5 * Math.PI); }); test('Pi as second operand', () => { press('2+p='); checkDisplay(2 + Math.PI); }); test('Chaining operations with Pi', () => { press('p*2+3='); checkDisplay(Math.PI * 2 + 3); }); test('Unary operations on Pi', () => { press('p'); checkDisplay(Math.PI); press('r'); // sqrt checkDisplay(Math.sqrt(Math.PI)); press('R'); press('R'); // AC press('p'); press('s'); // x^2 checkDisplay(Math.PI * Math.PI); press('R'); press('R'); // AC press('p'); press('i'); // 1/x checkDisplay(1 / Math.PI); }); test('Trig functions with Pi (rad)', () => { buttonPress('angleMode'); // switch to RAD assert.strictEqual(scientificOperators.angleMode.val, 'rad'); press('p'); buttonPress('sin'); checkDisplay(0); // Math.sin(Math.PI) is ~0, calculator snaps to 0 press('R'); press('R'); press('p'); buttonPress('cos'); checkDisplay(-1); press('R'); press('R'); press('p'); buttonPress('tan'); checkDisplay(0); // Math.tan(Math.PI) is ~0, calculator snaps to 0 buttonPress('angleMode'); // switch back to DEG assert.strictEqual(scientificOperators.angleMode.val, 'deg'); }); test('Pi after equals', () => { press('1+2='); checkDisplay(3); press('p'); checkDisplay(Math.PI); press('+1='); checkDisplay(Math.PI + 1); }); test('Pi with negative operator', () => { press('pN'); checkDisplay(-Math.PI); press('*2='); checkDisplay(-Math.PI * 2); }); test('Negative number times Pi', () => { press('2N*p='); checkDisplay(-2 * Math.PI); }); // Run all the defined tests runTests();