diff --git a/centrallix-os/sys/js/ht_render.js b/centrallix-os/sys/js/ht_render.js index 547cf9343..02cd39c80 100644 --- a/centrallix-os/sys/js/ht_render.js +++ b/centrallix-os/sys/js/ht_render.js @@ -151,7 +151,7 @@ function cxjs_has_endorsement(e,ctx) function cxjs_min(v) { var lowest = undefined; - if (v instanceof Array) + if (Array.isArray(v)) { for(var i=0; i {}, + window: {}, + document: + { + getElementsByTagName: () => [], + addEventListener: () => {}, + releaseEvents: () => {}, + captureEvents: () => {}, + }, + console: console, + }; +sandbox.globalThis = sandbox; + +vm.createContext(sandbox); +vm.runInContext( + fs.readFileSync(HT_RENDER_PATH, 'utf8'), + sandbox, + { filename: HT_RENDER_PATH } +); + +module.exports = sandbox; diff --git a/centrallix-os/sys/js/tests/cxjs_abs.test.js b/centrallix-os/sys/js/tests/cxjs_abs.test.js new file mode 100644 index 000000000..136c8d450 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_abs.test.js @@ -0,0 +1,102 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim (and -0 distinctly from 0) while otherwise +// matching JSON.stringify, so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_abs', () => + { + for (const [ input, result ] of [ + // Input Result + [ 5, 5 ], + [ 5.5, 5.5 ], + [ -5, 5 ], + [ -5.5, 5.5 ], + [ 0, 0 ], + [ -0, 0 ], + [ Infinity, Infinity ], + [ -Infinity, Infinity ], + [ NaN, NaN ], + + // Extreme finite magnitudes survive unchanged (no overflow/underflow). + // Input Result + [ Number.MAX_VALUE, Number.MAX_VALUE ], + [ -Number.MAX_VALUE, Number.MAX_VALUE ], + [ Number.MIN_VALUE, Number.MIN_VALUE ], // smallest subnormal + [ -Number.MIN_VALUE, Number.MIN_VALUE ], + ]) { + test(`cxjs_abs(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_abs(input), result); + }); + } + + // null and undefined yield null instead of being coerced. + for (const input of [ null, undefined ]) + { + test(`cxjs_abs(${fmt(input)}) = null`, () => + { + assert.equal(env.cxjs_abs(input), null); + }); + } + + // Non-number inputs are coerced to number before taking the magnitude. + for (const [ input, result ] of [ + // Input Result + [ '5', 5 ], + [ '-5', 5 ], + [ '3.14', 3.14 ], + [ '', 0 ], // empty string coerces to 0 + [ 'foo', NaN ], // non-numeric string coerces to NaN + [ true, 1 ], + [ false, 0 ], + [ [], 0 ], // empty array coerces to 0 + [ [5], 5 ], // single-element array coerces to its element + [ [5, 6], NaN ], // multi-element array coerces to NaN + [ {}, NaN ], // object coerces to NaN + + // String coercion follows JS Number() rules: surrounding whitespace is + // stripped, hex and exponential literals parse, and all-whitespace is 0. + // Input Result + [ ' 5 ', 5 ], // surrounding whitespace stripped + [ ' 3.14 ', 3.14 ], + [ '0x10', 16 ], // hex literal + [ '1e3', 1000 ], // exponential literal + [ '-0', 0 ], // string negative zero -> +0 magnitude + [ ' ', 0 ], // all-whitespace coerces to 0 + [ ['5'], 5 ], // single string-element array coerces to its element + [ [' '], 0 ], // single whitespace-element array coerces to 0 + ]) { + test(`cxjs_abs(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_abs(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_char_length.test.js b/centrallix-os/sys/js/tests/cxjs_char_length.test.js new file mode 100644 index 000000000..9eb751e9d --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_char_length.test.js @@ -0,0 +1,82 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// cxjs_char_length() returns null for null/undefined, otherwise the +// String()-coerced length in UTF-16 code units (so surrogate pairs +// count as 2). + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim, distinguishes -0 from 0, and otherwise matches +// JSON.stringify, so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_char_length', () => + { + for (const [ input, result ] of [ + // Input Result + [ null, null ], // null short-circuits to null + [ undefined, null ], // == null also catches undefined + [ '', 0 ], // empty string is 0, not null + [ 'a', 1 ], + [ 'hello', 5 ], + [ ' ', 1 ], // spaces count + [ ' ', 2 ], + [ '\t\n ', 3 ], // whitespace chars count + [ 'a"b\\c', 5 ], // quotes/backslashes are literal chars + [ '😀', 2 ], // surrogate pair = 2 code units + [ 'café', 4 ], // precomposed accent = 1 code unit + [ 0, 1 ], // 0 != null, coerces to "0" + [ 123, 3 ], + [ -12, 3 ], // sign counts + [ 1.5, 3 ], // decimal point counts + [ false, 5 ], // coerced: "false" + [ true, 4 ], // coerced: "true" + [ NaN, 3 ], // coerced: "NaN" + [ Infinity, 8 ], // coerced: "Infinity" + [ -Infinity, 9 ], // coerced: "-Infinity" (sign counts) + [ [], 0 ], // coerced: "" + [ [1, 2], 3 ], // coerced: "1,2" + [ [1, [2, 3]], 5 ], // nested array flattens: "1,2,3" + [ {}, 15 ], // coerced: "[object Object]" + // Additional edge cases. + [ ' a ', 3 ], // surrounding whitespace counts + [ -0, 1 ], // negative zero coerces to "0" + [ 1e21, 5 ], // large float uses exponent form "1e+21" + [ '😀😀', 4 ], // two surrogate pairs = 4 code units + [ ['a'], 1 ], // single-element array coerces to "a" + [ ['a', 'b'], 3 ], // coerced: "a,b" + [ [null], 0 ], // null element renders as "" -> length 0 + [ [undefined], 0 ], // undefined element renders as "" -> length 0 + [ [null, null], 1 ], // coerced: "," (one separator) -> length 1 + ]) { + test(`cxjs_char_length(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_char_length(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_charindex.test.js b/centrallix-os/sys/js/tests/cxjs_charindex.test.js new file mode 100644 index 000000000..1486c89a1 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_charindex.test.js @@ -0,0 +1,91 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// charindex returns the 1-based position of needle in haystack, 0 when +// absent, and null when either argument is strictly null. +describe('cxjs_charindex', () => + { + for (const [ needle, haystack, result ] of [ + // Needle Haystack Result + [ 'h', 'hello', 1 ], // at start + [ 'lo', 'hello', 4 ], // multi-char, mid/end + [ 'o', 'hello', 5 ], // at end + [ 'l', 'hello', 3 ], // first of several matches + [ 'hello', 'hello', 1 ], // needle equals haystack + [ 'z', 'hello', 0 ], // absent + [ 'hellox', 'hello', 0 ], // needle longer than haystack + [ 'H', 'hello', 0 ], // case-sensitive: no match + [ '', 'hello', 1 ], // empty needle matches at start + [ '', '', 1 ], // empty needle, empty haystack + [ 'a', '', 0 ], // empty haystack, non-empty needle + [ ' ', 'a b', 2 ], // whitespace needle + [ '$', 'a$b', 2 ], // special character + [ '😀', 'a😀b', 2 ], // surrogate pair needle + [ '2', 123, 2 ], // numeric haystack coerced to string + [ 2, '123', 2 ], // numeric needle coerced to string + [ 2, 123, 2 ], // multiple coercions + [ null, 'hello', null ], // null needle + [ 'lo', null, null ], // null haystack + [ null, null, null ], // both null + + // undefined is not strictly null, so it is coerced, not short-circuited. + // Needle Haystack Result + [ undefined, 'hello', 0 ], // searches for 'undefined': absent + [ 'u', undefined, 1 ], // haystack becomes 'undefined' + [ 'x', undefined, 0 ], // 'x' absent from 'undefined' + [ undefined, undefined, 1 ], // both -> 'undefined'; found at start + + // A null in EITHER position short-circuits first, even when the other + // argument is undefined (which would otherwise be coerced). + // Needle Haystack Result + [ undefined, null, null ], // null haystack wins + [ null, undefined, null ], // null needle wins + + // Non-string needle/haystack coercion. + // Needle Haystack Result + [ true, 'xtrueb', 2 ], // boolean needle coerced to 'true' + [ 'b', true, 0 ], // boolean haystack coerced to 'true'; 'b' absent + [ false, 'xfalseb', 2 ], // boolean needle coerced to 'false' + [ 0, 'a0b', 2 ], // numeric-zero needle coerced to '0' + [ NaN, 'aNaNb', 2 ], // NaN needle coerced to 'NaN' + [ 'N', NaN, 1 ], // NaN haystack coerced to 'NaN' + [ Infinity, 'aInfinityb', 2 ], // Infinity needle coerced to 'Infinity' + + // Array needle/haystack coerce to comma-joined string. + // Needle Haystack Result + [ [], 'a', 1 ], // [] needle -> '' matches at start + [ [ 'a' ], 'bab', 2 ], // single-element array -> 'a' + [ [ 'a', 'b' ], 'a,bc', 1 ], // multi-element array -> 'a,b' (with comma) + [ 'o', [ 'hello' ], 5 ], // single-element array haystack -> 'hello' + + // Plain object needle/haystack coerce to '[object Object]'. + // Needle Haystack Result + [ 'c', {}, 6 ], // 'c' of '[obje[c]t Object]' + [ 'Object', {}, 9 ], // 'Object' substring of '[object Object]' + + // Surrogate-pair (UTF-16) indexing: positions count code units, and a + // lone surrogate half can match inside an emoji. + // Needle Haystack Result + [ '😀', '😀x', 1 ], // emoji needle found at start + [ 'x', '😀x', 3 ], // 'x' after a 2-code-unit emoji + [ '\uDE00', '😀', 2 ], // trailing surrogate half matches at unit 2 + ]) { + test(`cxjs_charindex(${JSON.stringify(needle)}, ${JSON.stringify(haystack)}) = ${JSON.stringify(result)}`, () => + { + assert.equal(env.cxjs_charindex(needle, haystack), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_condition.test.js b/centrallix-os/sys/js/tests/cxjs_condition.test.js new file mode 100644 index 000000000..d45b89e9e --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_condition.test.js @@ -0,0 +1,98 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", drops undefined, and +// renders -0 as "0", which would make distinct edge-case rows share a test +// name; fmt renders those verbatim so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_condition', () => + { + for (const [ c, vtrue, vfalse, result ] of [ + // null/undefined short-circuit to null before vtrue/vfalse. + // Condition vtrue vfalse Result + [ null, 'T', 'F', null ], + [ undefined, 'T', 'F', null ], + + // Truthy conditions yield vtrue. + // Condition vtrue vfalse Result + [ true, 'T', 'F', 'T' ], + [ 1, 'T', 'F', 'T' ], + [ -1, 'T', 'F', 'T' ], + [ 3.14, 'T', 'F', 'T' ], + [ Infinity, 'T', 'F', 'T' ], + [ '0', 'T', 'F', 'T' ], // nonempty string + [ 'false', 'T', 'F', 'T' ], + [ ' ', 'T', 'F', 'T' ], + [ [], 'T', 'F', 'T' ], // empty array is truthy + [ {}, 'T', 'F', 'T' ], + [ -Infinity, 'T', 'F', 'T' ], // any nonzero number is truthy + [ [0], 'T', 'F', 'T' ], // object identity, contents irrelevant + [ 'NaN', 'T', 'F', 'T' ], // nonempty string, not the NaN number + + // Falsy conditions yield vfalse. + // Condition vtrue vfalse Result + [ false, 'T', 'F', 'F' ], + [ 0, 'T', 'F', 'F' ], + [ -0, 'T', 'F', 'F' ], + [ NaN, 'T', 'F', 'F' ], + [ '', 'T', 'F', 'F' ], + + // vtrue/vfalse pass through verbatim, regardless of type. + // Condition vtrue vfalse Result + [ true, 42, 99, 42 ], + [ false, 42, 99, 99 ], + [ true, null, 'F', null ], // truthy c can still return null + [ true, undefined, 'F', undefined ], + [ false, 'T', undefined, undefined ], + [ true, 0, 1, 0 ], // falsy values pass through too + [ false, 1, '', '' ], + + // Both branches omitted: a defined-but-truthy c yields undefined vtrue, + // a falsy c yields undefined vfalse (null c is handled separately above). + // Condition vtrue vfalse Result + [ true, undefined, undefined, undefined ], + [ false, undefined, undefined, undefined ], + ]) { + test(`cxjs_condition(${fmt(c)}, ${fmt(vtrue)}, ${fmt(vfalse)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_condition(c, vtrue, vfalse), result); + }); + } + + // Signed-zero (JSON.stringify(-0) is "0", so name explicitly). -0 is a + // number == null is false, so it reaches the ternary and is falsy -> vfalse; + // and a -0 passed as a branch value comes back unchanged (verbatim). + test('cxjs_condition(-0, "T", "F") = "F" (-0 is falsy)', () => + { + assert.equal(env.cxjs_condition(-0, 'T', 'F'), 'F'); + }); + test('cxjs_condition(false, 1, -0) = -0 (branch value verbatim)', () => + { + assert.equal(env.cxjs_condition(false, 1, -0), -0); + }); + }); diff --git a/centrallix-os/sys/js/tests/cxjs_constrain.test.js b/centrallix-os/sys/js/tests/cxjs_constrain.test.js new file mode 100644 index 000000000..0045cbfad --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_constrain.test.js @@ -0,0 +1,133 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null" and omits undefined, which +// would make distinct edge-case rows share a test name; fmt renders those +// values verbatim (and distinguishes -0 from +0) so names stay unique. +function fmt(v) + { + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_constrain', () => + { + for (const [ n, min, max, result ] of [ + // Both bounds: clamp into [min, max], and bounds are inclusive. + // n min max Result + [ 5, 0, 10, 5 ], // within range + [ 0, 0, 10, 0 ], // lower bound + [ 10, 0, 10, 10 ], // upper bound + [ -5, 0, 10, 0 ], // below -> min + [ 15, 0, 10, 10 ], // above -> max + [ 0.5, 0, 10, 0.5 ], // fractional, within + [ 5, 5, 5, 5 ], // min == max + + // A 0 bound is a real bound, not skipped like null/undefined. + [ -1, 0, 10, 0 ], + + // Negative ranges and fractional bounds. + [ -5, -10, -1, -5 ], // within + [ -15, -10, -1, -10 ], // below -> min + [ 0, -10, -1, -1 ], // above -> max + [ 2.5, 1.5, 3.5, 2.5 ], // within + + // null/undefined min leaves the result unbounded below. + [ -100, null, 10, -100 ], + [ 5, null, 10, 5 ], + [ -100, undefined, 10, -100 ], + + // null/undefined max leaves the result unbounded above. + [ 100, 0, null, 100 ], + [ 5, 0, null, 5 ], + [ 100, 0, undefined, 100 ], + + // Both bounds absent: n is unchanged. + [ 5, null, null, 5 ], + [ -100, null, null, -100 ], + [ 5, undefined, undefined, 5 ], + + // null/undefined n always yields null, regardless of bounds. + [ null, 0, 10, null ], + [ undefined, 0, 10, null ], + [ null, null, null, null ], + + // A NaN bounds are ignored, so it is effectively ignored; + // the opposite bound (if any) still applies. + [ 5, NaN, 10, 5 ], + [ 15, NaN, 10, 10 ], // max still clamps + [ 5, 0, NaN, 5 ], + [ -5, 0, NaN, 0 ], // min still clamps + [ 5, NaN, NaN, 5 ], // both ignored + + // NaN propagates. + [ NaN, 0, 10, NaN ], + + // Infinity clamps like any other value; an Infinite n survives an + // absent bound on its side. + [ Infinity, 0, 10, 10 ], + [ -Infinity, 0, 10, 0 ], + [ Infinity, 0, null, Infinity ], + [ -Infinity, null, 10, -Infinity ], + [ 5, -Infinity, Infinity, 5 ], + + // Inverted bounds (min > max): min is checked first, so n < min returns + // min; otherwise n > max returns max. + [ 5, 10, 0, 10 ], + [ 100, 10, 0, 0 ], + [ -5, 10, 0, 10 ], + + // Inverted bounds with n equal to a bound: equality is not "<" or ">", + // so n == min still falls through to the max check (and vice versa). + [ 10, 10, 0, 0 ], // n == min, then 10 > 0 -> max + [ 0, 10, 0, 10 ], // n == max, but 0 < 10 -> min + + // Sign of zero: comparisons treat -0 and +0 as equal, so neither bound + // fires and the original (signed) n passes through unchanged. + [ -0, 0, 10, -0 ], // -0 < 0 false, -0 > 10 false + [ -0, 0, 0, -0 ], // both bounds == -0 numerically + [ 0, -0, 10, 0 ], + // A signed-zero bound is returned verbatim when n falls outside it. + [ -5, -0, 10, -0 ], // -5 < -0 -> returns min -0 + + // Bounds are returned without coercion, so a numeric-string bound that + // clamps is returned as the original string (compared numerically). + [ 3, '5', 10, '5' ], // 3 < "5" -> returns "5" + [ 7, '5', 10, 7 ], // 7 < "5" false -> n + [ 15, 0, '10', '10' ], // 15 > "10" -> returns "10" + [ 5, 0, '10', 5 ], + + // Infinite bounds and n: an infinity within infinite bounds is unclamped. + [ Infinity, -Infinity, Infinity, Infinity ], + [ -Infinity, -Infinity, Infinity, -Infinity ], + [ Infinity, 0, Infinity, Infinity ], + + // Both bounds NaN with NaN n: NaN bounds never fire and NaN passes through. + [ NaN, NaN, NaN, NaN ], + + // Extreme finite magnitudes clamp like any other value. + [ Number.MAX_VALUE, 0, 1, 1 ], + [ 1e308, 0, 1e308, 1e308 ], + ]) { + test(`cxjs_constrain(${fmt(n)}, ${fmt(min)}, ${fmt(max)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_constrain(n, min, max), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_convert.test.js b/centrallix-os/sys/js/tests/cxjs_convert.test.js new file mode 100644 index 000000000..f6b2494fb --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_convert.test.js @@ -0,0 +1,184 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +describe('cxjs_convert', () => + { + // A null datatype or value yields null, regardless of the other + // argument (== null also catches undefined). + for (const [ datatype, v, result ] of [ + // Datatype Value Result + [ null, 5, null ], + [ undefined, 5, null ], + [ 'integer', null, null ], + [ 'integer', undefined, null ], + [ 'double', null, null ], + [ 'string', null, null ], + [ null, null, null ], + ]) { + test(`cxjs_convert(${JSON.stringify(datatype)}, ${JSON.stringify(v)}) = ${result}`, () => + { + assert.equal(env.cxjs_convert(datatype, v), result); + }); + } + + // Conversion to integer. + // Note: The '$' is only stripped when it is the *second* character + // (e.g. 'x$5'). A leading '$5' is not, so it parses to NaN. Stripping + // the '$' also drops a leading sign. + for (const [ datatype, v, result ] of [ + // Datatype Value Result + [ 'integer', 0, 0 ], + [ 'integer', 5, 5 ], + [ 'integer', -7, -7 ], + [ 'integer', 5.9, 5 ], + [ 'integer', '42', 42 ], + [ 'integer', '42abc', 42 ], + [ 'integer', ' 10', 10 ], // Whitespace is stripped. + [ 'integer', '0x1F', 31 ], // Hex notation is handled. + [ 'integer', '1e3', 1 ], // Scientific notation is ignored. + [ 'integer', 'x$5', 5 ], + [ 'integer', '-$5', 5 ], + [ 'integer', '$5', NaN ], + [ 'integer', '', NaN ], + [ 'integer', 'abc', NaN ], + [ 'integer', true, NaN ], + [ 'integer', '-42', -42 ], // Leading sign kept when '$' is not the 2nd char. + [ 'integer', ' -42', -42 ], // Whitespace stripped, sign kept. + [ 'integer', 'a$bc', NaN ], // 2nd char '$' stripped, but 'bc' isn't numeric. + [ 'integer', Infinity, NaN ], // parseInt('Infinity') is NaN. + [ 'integer', false, NaN ], + [ 'integer', '+5', 5 ], // Leading '+' handled. + [ 'integer', '0x10', 16 ], // Hex notation handled. + [ 'integer', ' $12', 12 ], // 2nd char '$' stripped, parses '12'. + [ 'integer', 'x$-3', -3 ], // Stripped to '-3', sign honored. + [ 'integer', 'x$', NaN ], // Stripped to '', parseInt('') is NaN. + [ 'integer', [ 42 ], 42 ], // Array stringifies to '42'. + [ 'integer', [ 42, 1 ], 42 ], // '42,1' -> parseInt stops at comma. + ]) { + test(`cxjs_convert(${JSON.stringify(datatype)}, ${JSON.stringify(v)}) = ${result}`, () => + { + assert.equal(env.cxjs_convert(datatype, v), result); + }); + } + + // Conversion to double. + // A leading currency marker is stripped in several forms ('$', ' $', + // '+$', '$ ', '-$'). '-$' negates the result. Anything else is passed + // through to parseFloat. + for (const [ datatype, v, result ] of [ + // Datatype Value Result + [ 'double', 0, 0 ], + [ 'double', 5.5, 5.5 ], + [ 'double', '5.5', 5.5 ], + [ 'double', '3.14abc', 3.14 ], + [ 'double', '$5', 5 ], + [ 'double', '$5.50', 5.5 ], + [ 'double', ' $5', 5 ], + [ 'double', '+$5', 5 ], + [ 'double', '$ 5', 5 ], + [ 'double', '-$5', -5 ], + [ 'double', '-$ 5', -5 ], + [ 'double', '-$2.5', -2.5 ], + [ 'double', '$1,000', 1 ], + [ 'double', '-5.5', -5.5 ], // Plain negative, no currency marker. + [ 'double', Infinity, Infinity ], // parseFloat('Infinity') is Infinity. + [ 'double', 'Infinity', Infinity ], + [ 'double', '$', NaN ], + [ 'double', 'abc', NaN ], + [ 'double', '1.5e3', 1500 ], // Scientific notation honored. + [ 'double', ' 12', 12 ], // Leading whitespace tolerated by parseFloat. + [ 'double', '', NaN ], + [ 'double', '-$', NaN ], // -parseFloat('') -> -NaN -> NaN. + [ 'double', ' $', NaN ], // Strips ' $', parseFloat('') is NaN. + [ 'double', '+$', NaN ], + [ 'double', '$ ', NaN ], + [ 'double', ' $5', NaN ], // Two leading spaces: no prefix matches, parseFloat fails. + [ 'double', '1,234', 1 ], // parseFloat stops at the comma. + [ 'double', true, NaN ], // parseFloat('true') is NaN. + [ 'double', false, NaN ], + [ 'double', [ 1.5 ], 1.5 ], // Array stringifies to '1.5'. + ]) { + test(`cxjs_convert(${JSON.stringify(datatype)}, ${JSON.stringify(v)}) = ${result}`, () => + { + assert.equal(env.cxjs_convert(datatype, v), result); + }); + } + + // Conversion to string (using standard JS coercion). + for (const [ datatype, v, result ] of [ + // Datatype Value Result + [ 'string', 0, '0' ], + [ 'string', 5, '5' ], + [ 'string', 5.5, '5.5' ], + [ 'string', -3.2, '-3.2' ], + [ 'string', 'hello', 'hello' ], + [ 'string', true, 'true' ], + [ 'string', false, 'false' ], + [ 'string', Infinity, 'Infinity' ], + [ 'string', -Infinity, '-Infinity' ], + [ 'string', NaN, 'NaN' ], + [ 'string', '', '' ], + [ 'string', [ 1, 2, 3 ], '1,2,3' ], // Arrays join with commas. + [ 'string', [], '' ], + [ 'string', { a: 1 }, '[object Object]' ], + ]) { + test(`cxjs_convert(${JSON.stringify(datatype)}, ${JSON.stringify(v)}) = ${result}`, () => + { + assert.equal(env.cxjs_convert(datatype, v), result); + }); + } + + // Signed zero. + test('cxjs_convert("double", "-$0") is -0', () => + { + assert.ok(Object.is(env.cxjs_convert('double', '-$0'), -0)); + }); + test('cxjs_convert("double", "$0") is +0', () => + { + assert.ok(Object.is(env.cxjs_convert('double', '$0'), 0)); + }); + test('cxjs_convert("string", -0) is "0"', () => + { + assert.equal(env.cxjs_convert('string', -0), '0'); + }); + + // datatype that is falsy-but-not-null (0, '', false). + for (const [ datatype, v, result ] of [ + // Datatype Value Result + [ 0, 5, 5 ], + [ '', 5, 5 ], + [ false, 5, 5 ], + [ 'INTEGER', '5abc', '5abc' ], // Wrong case -> unknown datatype -> unchanged. + ]) { + test(`cxjs_convert(${JSON.stringify(datatype)}, ${JSON.stringify(v)}) = ${JSON.stringify(result)}`, () => + { + assert.deepEqual(env.cxjs_convert(datatype, v), result); + }); + } + + // An unrecognized datatype returns the value unchanged. + for (const [ datatype, v, result ] of [ + // Datatype Value Result + [ 'money', 5, 5 ], + [ 'datetime', 'x', 'x' ], + [ 'MyType', 42, 42 ], + ]) { + test(`cxjs_convert(${JSON.stringify(datatype)}, ${JSON.stringify(v)}) = ${result}`, () => + { + assert.equal(env.cxjs_convert(datatype, v), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_count.test.js b/centrallix-os/sys/js/tests/cxjs_count.test.js new file mode 100644 index 000000000..1d4e544da --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_count.test.js @@ -0,0 +1,166 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim, distinguishes -0 from 0, and otherwise matches +// JSON.stringify, so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_count', () => + { + for (const [ input, result ] of [ + // Input Result + [ [], 0 ], + [ [0, 1], 2 ], + [ [0, 1, 2, 3, 4], 5 ], + [ [9, 10.1, 10.2], 3 ], + [ [-1.1, 1], 2 ], + [ [-Infinity, Infinity], 2 ], + + // null and undefined are not counted. + [ [null], 0 ], + [ [undefined], 0 ], + [ [null, undefined], 0 ], + [ [null, 0], 1 ], + [ [undefined, 0], 1 ], + + // NaN is not counted. + [ [NaN], 0 ], + [ [NaN, 1], 1 ], + [ [NaN, null, undefined, 5], 1 ], + + // Numeric strings count; non-numeric strings do not. '' is numeric (0). + [ ['5', 6], 2 ], + [ ['', 6], 2 ], + [ ['abc', 6], 1 ], + [ ['abc', 'def'], 0 ], + + // Booleans are numeric under isNaN (true -> 1, false -> 0), so both count. + [ [true], 1 ], + [ [true, false], 2 ], + ]) { + test(`cxjs_count(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_count(input), result); + }); + } + + for (const [ input, result ] of [ + // Input Result + [ {}, 0 ], + [ { a: 0, b: 1 }, 2 ], + [ { c: 0, d: 1, e: 2, f: 3, g: 4 }, 5 ], + [ { l: 9, m: 10.1, n: 10.2 }, 3 ], + [ { t: -1.1, u: 1 }, 2 ], + [ { v: -Infinity, w: Infinity }, 2 ], + // null and undefined are not counted. + [ { D: null }, 0 ], + [ { E: undefined }, 0 ], + [ { F: null, G: undefined }, 0 ], + [ { H: null, I: 0 }, 1 ], + [ { J: undefined, K: 0 }, 1 ], + // NaN is not counted. + [ { L: NaN }, 0 ], + [ { M: NaN, N: 1 }, 1 ], + [ { O: NaN, P: null, Q: undefined, R: 5 }, 1 ], + // Numeric strings count; non-numeric strings do not. '' is numeric (0). + [ { S: '5', T: 6 }, 2 ], + [ { U: '', V: 6 }, 2 ], + [ { W: 'abc', X: 6 }, 1 ], + [ { Y: 'abc', Z: 'def' }, 0 ], + // Booleans are numeric under isNaN (true -> 1, false -> 0), so both count. + [ { a: true, b: false }, 2 ], + ]) { + test(`cxjs_count(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_count(input), result); + }); + } + + // Scalar (non-array, non-object) inputs always count as one, even when + // the value is null, undefined, or NaN. + for (const [ input, result ] of [ + // Input Result + [ 0, 1 ], + [ 42, 1 ], + [ -1.1, 1 ], + [ Infinity, 1 ], + [ 'foo', 1 ], + [ '', 1 ], + [ true, 1 ], + [ NaN, 1 ], + [ null, 1 ], + [ undefined, 1 ], + ]) { + test(`cxjs_count(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_count(input), result); + }); + } + + // Nested-array elements are coerced. + for (const [ input, result ] of [ + // Input Result + [ [[1, 2]], 0 ], // [1,2] -> NaN, not counted + [ [[1]], 1 ], // [1] -> 1, counted + [ [[]], 1 ], // [] -> 0, counted + [ [[1], [2]], 2 ], // both single-element, both counted + [ [[1, 2], 3], 1 ], // [1,2] skipped, 3 counted + [ [{}], 0 ], // {} -> NaN, not counted + [ [{ a: 1 }], 0 ], // object -> NaN, not counted + [ [{}, 5], 1 ], // object skipped, 5 counted + [ [Infinity], 1 ], // Infinity is not NaN, counted + [ [-0], 1 ], // -0 counts + [ [-0, 0], 2 ], + ]) { + test(`cxjs_count(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_count(input), result); + }); + } + + // Sparse arrays. + for (const [ input, result ] of [ + // Input Result + [ [1, , 3], 2 ], // one hole between two values + [ [, , 5], 1 ], // two holes (undefined), one value + ]) { + test(`cxjs_count(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_count(input), result); + }); + } + + // A value on the prototype is counted alongside properties. + test('cxjs_count(object with inherited enumerable prop) = 2', () => + { + function Proto() { this.a = 1; } + Proto.prototype.b = 2; + assert.equal(env.cxjs_count(new Proto()), 2); + }); + }); diff --git a/centrallix-os/sys/js/tests/cxjs_degrees.test.js b/centrallix-os/sys/js/tests/cxjs_degrees.test.js new file mode 100644 index 000000000..755eb9350 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_degrees.test.js @@ -0,0 +1,108 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim (and -0 distinctly from 0) while otherwise +// matching JSON.stringify, so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_degrees', () => + { + // Radians to degrees. Some multiples of PI land exactly; others (e.g. PI/6) + // carry floating-point error. + for (const [ input, result ] of [ + // Input Result + [ 0, 0 ], + [ -0, -0 ], // sign of zero is preserved + [ Math.PI, 180 ], + [ -Math.PI, -180 ], + [ Math.PI / 2, 90 ], + [ Math.PI / 4, 45 ], + [ 2 * Math.PI, 360 ], + [ 1, 180 / Math.PI ], // one radian + [ Math.PI / 6, 29.999999999999996 ], // not exactly 30 + [ 3 * Math.PI / 2, 270 ], + [ Infinity, Infinity ], + [ -Infinity, -Infinity ], + [ NaN, NaN ], + ]) { + test(`cxjs_degrees(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_degrees(input), result); + }); + } + + // Extreme magnitudes follow the exact source expression: a very large value + // overflows to Infinity, a very small one stays finite (no underflow to 0). + for (const input of [ 1e200, Number.MAX_VALUE, 1e-200, -1, Number.MIN_VALUE ]) + { + test(`cxjs_degrees(${fmt(input)}) = (radians*180)/PI`, () => + { + assert.equal(env.cxjs_degrees(input), (input * 180.0) / Math.PI); + }); + } + + // degrees(radians(x)) = x (within float tolerance). + for (const x of [ 1, 45, 123, 360, -90 ]) + { + test(`cxjs_degrees(cxjs_radians(${fmt(x)})) ~= ${fmt(x)}`, () => + { + assert.ok(Math.abs(env.cxjs_degrees(env.cxjs_radians(x)) - x) < 1e-9); + }); + } + + // null and undefined yield null (no coercion). + for (const input of [ null, undefined ]) + { + test(`cxjs_degrees(${fmt(input)}) = null`, () => + { + assert.equal(env.cxjs_degrees(input), null); + }); + } + + // Non-number inputs are coerced to numbers. + // Those that coerce to NaN yield NaN (null is never used). + for (const [ input, result ] of [ + // Input Result + [ '1', 180 / Math.PI ], + [ '-1', -180 / Math.PI ], // negative string coerces to -1 + [ '', 0 ], // empty string coerces to 0 + [ 'foo', NaN ], // non-numeric string coerces to NaN + [ true, 180 / Math.PI ], + [ false, 0 ], + [ [], 0 ], // empty array coerces to 0 + [ [Math.PI], 180 ], // single-element array coerces to its element + [ [1, 2], NaN ], // multi-element array coerces to NaN + [ {}, NaN ], // object coerces to NaN + ]) { + test(`cxjs_degrees(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_degrees(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_getdate.test.js b/centrallix-os/sys/js/tests/cxjs_getdate.test.js new file mode 100644 index 000000000..966d1e557 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_getdate.test.js @@ -0,0 +1,94 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// Run cxjs_getdate() at a fixed instant: swap in a fake Date pinned to epochMs +// that answers in UTC, so results don't depend on the host timezone. +function getdateAt(epochMs, clockoffset) + { + class FakeDate + { + constructor() { this._d = new Date(epochMs); } + getMilliseconds() { return this._d.getUTCMilliseconds(); } + setMilliseconds(v) { return this._d.setUTCMilliseconds(v); } + getSeconds() { return this._d.getUTCSeconds(); } + getMinutes() { return this._d.getUTCMinutes(); } + getHours() { return this._d.getUTCHours(); } + getDate() { return this._d.getUTCDate(); } + getMonth() { return this._d.getUTCMonth(); } + getFullYear() { return this._d.getUTCFullYear(); } + } + + env.Date = FakeDate; + env.pg_clockoffset = clockoffset; + try + { + return env.cxjs_getdate(); + } + finally + { + delete env.Date; + env.pg_clockoffset = 0; + } + } + +// Build epoch milliseconds from UTC calendar fields (month is 1-based here). +function utc(year, month, day, hour, min, sec, ms) + { + return Date.UTC(year, month - 1, day, hour, min, sec, ms || 0); + } + +describe('cxjs_getdate', () => + { + // Format is "M/D/YYYY H:MM:SS". Hour is unpadded, + // but minute and second are always zero-padded. + + // Test dates with no pg_clockoffset. + for (const [ when, result ] of [ + // When Result + [ utc(2026, 1, 1, 0, 5, 9), '1/1/2026 0:05:09' ], // midnight; single-digit min/sec padded, hour not + [ utc(2026, 12, 25, 23, 59, 59), '12/25/2026 23:59:59' ], // two-digit fields left unpadded + [ utc(2026, 6, 15, 10, 10, 10), '6/15/2026 10:10:10' ], // 10 is the pad boundary: no leading zero + [ utc(2026, 7, 4, 12, 0, 0), '7/4/2026 12:00:00' ], // zero min/sec render as "00" + [ utc(2026, 3, 9, 7, 30, 45), '3/9/2026 7:30:45' ], // single-digit hour stays unpadded + [ utc(1999, 12, 31, 23, 59, 59), '12/31/1999 23:59:59' ], // four-digit year from a different century + [ utc(2024, 2, 29, 12, 0, 0), '2/29/2024 12:00:00' ], // leap day + [ utc(2026, 6, 15, 12, 30, 45, 500), '6/15/2026 12:30:45' ], // milliseconds never appear in the output + [ utc(2026, 1, 31, 0, 0, 0), '1/31/2026 0:00:00' ], // January is month 1 (getMonth() + 1) + [ utc(2026, 10, 5, 9, 9, 9), '10/5/2026 9:09:09' ], // two-digit month; 9 is just below the pad boundary + ]) { + test(`cxjs_getdate() = "${result}"`, () => + { + assert.equal(getdateAt(when, 0), result); + }); + } + + // Test dates with pg_clockoffset, which shifts the time backward in ms (negative = forward). + for (const [ when, offset, result ] of [ + // When Offset Result + [ utc(2026, 6, 15, 12, 0, 30), 60000, '6/15/2026 11:59:30' ], // back 60s, crossing the minute and hour + [ utc(2026, 6, 15, 12, 0, 30), -90000, '6/15/2026 12:02:00' ], // forward 90s; minute padded back to "02" + [ utc(2026, 6, 15, 0, 0, 30), 60000, '6/14/2026 23:59:30' ], // back across the day boundary + [ utc(2026, 1, 1, 0, 0, 30), 60000, '12/31/2025 23:59:30' ], // back across the month and year boundary + [ utc(2026, 6, 15, 0, 0, 0), 500, '6/14/2026 23:59:59' ], // sub-second offset still rolls the day back + [ utc(2026, 6, 15, 23, 59, 59), -2000, '6/16/2026 0:00:01' ], // forward across the day boundary + [ utc(2025, 12, 31, 23, 59, 59), -1000, '1/1/2026 0:00:00' ], // forward 1s across the year boundary + ]) { + test(`cxjs_getdate() with pg_clockoffset ${offset}ms = "${result}"`, () => + { + assert.equal(getdateAt(when, offset), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_isnull.test.js b/centrallix-os/sys/js/tests/cxjs_isnull.test.js new file mode 100644 index 000000000..7b1160e10 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_isnull.test.js @@ -0,0 +1,116 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", drops undefined, and +// renders -0 as "0", which would make distinct edge-case rows share a test +// name; fmt renders those verbatim so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_isnull', () => + { + // null/undefined values use the default. + for (const [ value, default_value, result ] of [ + // Value Default Result + [ null, 5, 5 ], + [ undefined, 5, 5 ], + [ null, 'default', 'default' ], + [ undefined, 'default', 'default' ], + [ null, 0, 0 ], + [ null, '', '' ], + [ null, false, false ], + [ null, Infinity, Infinity ], + [ null, NaN, NaN ], + // The default may itself be null or undefined. + [ null, null, null ], + [ null, undefined, undefined ], + [ undefined, null, null ], + [ undefined, undefined, undefined ], + ]) { + test(`cxjs_isnull(${fmt(value)}, ${fmt(default_value)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_isnull(value, default_value), result); + }); + } + + // Non-null/undefined pass through unchanged. Falsy-but-defined values + // (0, '', false, NaN) are NOT treated as null. + for (const [ value, default_value, result ] of [ + // Value Default Result + [ 0, 5, 0 ], + [ '', 5, '' ], + [ false, 5, false ], + [ NaN, 5, NaN ], + [ 42, 5, 42 ], + [ -1.1, 5, -1.1 ], + [ Infinity, 5, Infinity ], + [ -Infinity, 5, -Infinity ], + [ 'foo', 'bar', 'foo' ], + [ true, false, true ], + // The value is returned even when the default is null/undefined. + [ 0, undefined, 0 ], + [ '', null, '' ], + ]) { + test(`cxjs_isnull(${fmt(value)}, ${fmt(default_value)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_isnull(value, default_value), result); + }); + } + + // Objects and arrays are treated as not null. + for (const value of [ + {}, + { a: 1 }, + [], + [1, 2, 3], + ]) { + test(`cxjs_isnull(${fmt(value)}, 'default') returns the value itself`, () => + { + assert.equal(env.cxjs_isnull(value, 'default'), value); + }); + } + + // Only the first argument is consulted; missing the default yields + // undefined when the value is null. + test('cxjs_isnull(null) = undefined (default omitted)', () => + { + assert.equal(env.cxjs_isnull(null), undefined); + }); + test('cxjs_isnull(0) = 0 (default omitted)', () => + { + assert.equal(env.cxjs_isnull(0), 0); + }); + + // Signed zero is preserved in both positions. + test('cxjs_isnull(-0, 5) = -0 (value not null, passes through)', () => + { + assert.equal(env.cxjs_isnull(-0, 5), -0); + }); + test('cxjs_isnull(null, -0) = -0 (default returned verbatim)', () => + { + assert.equal(env.cxjs_isnull(null, -0), -0); + }); + }); diff --git a/centrallix-os/sys/js/tests/cxjs_lower.test.js b/centrallix-os/sys/js/tests/cxjs_lower.test.js new file mode 100644 index 000000000..e5efb8f60 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_lower.test.js @@ -0,0 +1,88 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify renders NaN/Infinity as "null", a standalone undefined as +// undefined (not a string), and -0 as "0", which would give distinct edge-case +// rows the same (or a broken) test name; fmt renders those verbatim (and +// recurses into arrays/objects) so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_lower', () => + { + for (const [ input, result ] of [ + // Input Result + [ 'HELLO', 'hello' ], + [ 'hello', 'hello' ], // already lowercase + [ 'Hello World', 'hello world' ], + [ 'MiXeD', 'mixed' ], + [ '', '' ], + [ '123ABC!?', '123abc!?' ], // digits/symbols pass through + [ ' SPACES ', ' spaces ' ], // leading/trailing spaces kept + [ '\tT\r', '\tt\r' ], // tab/CR whitespace preserved + [ 'ÀÉÎ', 'àéî' ], // non-ASCII letters + [ 'İ', 'i̇' ], // one char expands to i + combining dot + [ 'ΟΔΟΣ', 'οδος' ], // trailing Σ lowercases to final sigma ς + [ 'ΣΟΣ', 'σος' ], // non-final Σ lowercases to normal sigma σ + [ '😀', '😀' ], // surrogate-pair emoji has no case + [ 'café', 'café' ], // already-lower accented stays + [ 'É', 'é' ], // precomposed accent lowercases + [ ' ', ' ' ], // whitespace-only unchanged + [ '12345', '12345' ], // digits-only unchanged + [ '😀ABC', '😀abc' ], // emoji kept, letters lowercased + ]) { + test(`cxjs_lower(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_lower(input), result); + }); + } + + // null is returned verbatim; everything else is coerced via String() + // before lowercasing, so non-strings get their string form lowercased. + for (const [ input, result ] of [ + // Input Result + [ null, null ], + [ undefined, 'undefined' ], + [ 5, '5' ], + [ 1.5, '1.5' ], + [ true, 'true' ], + [ false, 'false' ], + [ NaN, 'nan' ], + [ Infinity, 'infinity' ], + [ -Infinity, '-infinity' ], + [ [], '' ], // empty array coerces to '' + [ ['A', 'B'], 'a,b' ], // Joins with ',' and no spaces + [ ['ABC'], 'abc' ], // single-element array unwraps + [ 0, '0' ], // zero coerces to '0' + [ -0, '0' ], // negative zero stringifies to '0' + [ {}, '[object object]' ], + ]) { + test(`cxjs_lower(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_lower(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_ltrim.test.js b/centrallix-os/sys/js/tests/cxjs_ltrim.test.js new file mode 100644 index 000000000..0da69cd08 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_ltrim.test.js @@ -0,0 +1,67 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +describe('cxjs_ltrim', () => + { + for (const [ input, result ] of [ + // Input Result + [ ' hello', 'hello' ], // leading spaces stripped + [ 'hello', 'hello' ], // no leading spaces + [ 'hello ', 'hello ' ], // trailing spaces preserved + [ ' a b ', 'a b ' ], // interior/trailing spaces preserved + [ ' ', '' ], // single space + [ ' ', '' ], // all spaces collapse to empty + [ '', '' ], // empty stays empty + [ ' \thello', '\thello' ], // leading space stripped, tab kept + [ '\t hello', '\t hello' ], // leading tab is not a space: unchanged + [ '\nhi', '\nhi' ], // newline is not a space: unchanged + [ 42, '42' ], // non-string is coerced + [ 0, '0' ], // falsy but not null: coerced, not dropped + [ true, 'true' ], // boolean coerced to string + [ NaN, 'NaN' ], // NaN coerced to string + [ null, null ], // null returns null + [ undefined, null ], // undefined returns null + + // Only the ASCII space (U+0020) is stripped; every other whitespace + // character is left in place by the / */ regex. + // Input Result + [ '\rhi', '\rhi' ], // carriage return is not a space: unchanged + [ '\fhi', '\fhi' ], // form feed is not a space: unchanged + [ '\vhi', '\vhi' ], // vertical tab is not a space: unchanged + [ '\u00a0hi', '\u00a0hi' ], // non-breaking space is not ASCII space: kept + [ '\u3000hi', '\u3000hi' ], // ideographic space is not ASCII space: kept + [ ' \t', '\t' ], // strips leading spaces, stops at the tab + [ ' 😀', '😀' ], // spaces stripped, surrogate-pair emoji kept + + // More non-string coercions: String() runs before the regex. + // Input Result + [ ' 12', '12' ], // numeric string: leading spaces stripped + [ false, 'false' ], // boolean coerced to string + [ Infinity, 'Infinity' ], // Infinity coerced to string + [ -Infinity, '-Infinity' ], // -Infinity coerced to string + [ [], '' ], // empty array coerces to '' + [ [' hi'], 'hi' ], // single-element array unwraps then trims + [ [' a', ' b'], 'a, b' ], // join inserts ',', only first elem's spaces lead + [ [null], '' ], // [null] coerces to '' (null element -> '') + [ {}, '[object Object]' ], // plain object stringifies, no leading spaces + [ ' xxxxx', 'xxxxx' ], // longer run of leading spaces all stripped + ]) { + test(`cxjs_ltrim(${JSON.stringify(input)}) = ${JSON.stringify(result)}`, () => + { + assert.equal(env.cxjs_ltrim(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_max.test.js b/centrallix-os/sys/js/tests/cxjs_max.test.js new file mode 100644 index 000000000..b96114955 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_max.test.js @@ -0,0 +1,223 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim, distinguishes -0 from 0, and otherwise matches +// JSON.stringify, so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_max', () => + { + for (const [ input, result ] of [ + // Input Result + [ [], undefined ], + [ [0, 1], 1 ], + [ [0, 1, 2, 3, 4], 4 ], + [ [3, 4, 2, 1], 4 ], + [ [9, 10.1, 10.2], 10.2 ], + [ [3.4, 3.3, 3.2], 3.4 ], + [ [-1, 0], 0 ], + [ [-1.1, -1], -1 ], + [ [-Infinity, 0], 0 ], + [ [Infinity, 0], Infinity ], + [ [Infinity, Infinity], Infinity ], + [ [-Infinity, -Infinity], -Infinity ], + [ [undefined], undefined ], + [ [undefined, 0], 0 ], + ]) { + test(`cxjs_max(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_max(input), result); + }); + } + + for (const [ input, result ] of [ + // Input Result + [ {}, undefined ], + [ { a: 0, b: 1 }, 1 ], + [ { c: 0, d: 1, e: 2, f: 3, g: 4 }, 4 ], + [ { h: 3, i: 4, j: 2, k: 1 }, 4 ], + [ { l: 9, m: 10.1, n: 10.2 }, 10.2 ], + [ { o: 3.4, p: 3.3, q: 3.2 }, 3.4 ], + [ { r: -1, s: 0 }, 0 ], + [ { t: -1.1, u: -1 }, -1 ], + [ { v: -Infinity, w: 0 }, 0 ], + [ { x: Infinity, y: 0 }, Infinity ], + [ { z: Infinity, A: Infinity }, Infinity ], + [ { B: -Infinity, C: -Infinity }, -Infinity ], + [ { D: undefined }, undefined ], + [ { E: undefined, F: 0 }, 0 ], + ]) { + test(`cxjs_max(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_max(input), result); + }); + } + + // Scalar are returned as is. + for (const [ input, result ] of [ + // Input Result + [ 5, 5 ], + [ -3, -3 ], + [ 0, 0 ], + [ Infinity, Infinity ], + [ NaN, NaN ], + [ 'foo', 'foo' ], + [ true, true ], + [ null, null ], // typeof null is "object", but v !== null is false + [ undefined, undefined ], + ]) { + test(`cxjs_max(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_max(input), result); + }); + } + + // null and NaN within a collection are handled specially. + for (const [ input, result ] of [ + // Input Result + [ [5], 5 ], // single element + [ [NaN, 5], 5 ], // leading NaN replaced by a real value + [ [5, NaN], 5 ], // trailing NaN never beats a real value + [ [NaN], NaN ], // all NaN: NaN survives + [ [NaN, NaN], NaN ], + [ [null, 5], 5 ], // 5 > null (0), so 5 is the max + [ [5, null], 5 ], + [ [0, undefined], 0 ], // undefined after a value is ignored + [ { a: NaN, b: 5 }, 5 ], + [ { a: 5, b: NaN }, 5 ], + [ { a: NaN }, NaN ], + [ { a: null, b: 5 }, 5 ], + [ { a: 5, b: null }, 5 ], + ]) { + test(`cxjs_max(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_max(input), result); + }); + } + + // Non-numeric strings. + for (const [ input, result ] of [ + // Input Result + [ ['b', 'a', 'c'], 'c' ], // last element wins + [ ['apple', 'banana'], 'banana' ], + [ ['10', '9', '100'], '9' ], // lexical compare: '100' < '10' < '9' + ]) { + test(`cxjs_max(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_max(input), result); + }); + } + + // Lists with NaNs. + for (const [ input, result ] of [ + // Input Result + [ [5, NaN, 3], 5 ], // 5 retained, NaN never beats it, then 3<5 + [ [NaN, NaN, 5], 5 ], // NaN survives until a real value replaces it + [ [1, NaN, 2, NaN, 3], 3 ], // running max walks 1 -> 2 -> 3 across the NaNs + [ [3, NaN, 5, NaN, 1], 5 ], + ]) { + test(`cxjs_max(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_max(input), result); + }); + } + + // null comparisons to 1 and -1. + for (const [ input, result ] of [ + // Input Result + [ [1, null], 1 ], // 1 > null(0), so 1 wins + [ [null, 1], 1 ], + [ [-1, null], null ], // -1 not > null(0), so null wins and is preserved + [ [null, -1], null ], + ]) { + test(`cxjs_max(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_max(input), result); + }); + } + + // Misc edge cases. + for (const [ input, result ] of [ + // Input Result + [ [Infinity, -Infinity], Infinity ], + [ [-Infinity, Infinity], Infinity ], + [ [-0, 0], -0 ], // -0 < 0 is false, so -0 is kept + [ [0, -0], 0 ], // 0 < -0 is false, so 0 is kept + [ [-0], -0 ], + [ [true, false], true ], // true(1) > false(0) + [ [false, true], true ], + [ [2, true], 2 ], // 2 > true(1), returned as number + [ [0, false], 0 ], // false(0) not > 0, so 0 kept + ]) { + test(`cxjs_max(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_max(input), result); + }); + } + + // Numbers mixed with numeric strings. + for (const [ input, result ] of [ + // Input Result + [ [2, '10'], '10' ], // numeric compare: 10 > 2, kept as a string + [ ['10', 2], '10' ], + [ [5, '3', 4], 5 ], // 5 is numerically greatest + ]) { + test(`cxjs_max(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_max(input), result); + }); + } + + // Sparse arrays. + for (const [ input, result ] of [ + // Input Result + [ [1, , 3], 3 ], // 3 > 1, the hole is skipped + [ [, , 5], 5 ], + ]) { + test(`cxjs_max(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_max(input), result); + }); + } + + test('cxjs_max(array with extra non-index prop) = 3', () => + { + const a = [1, 2, 3]; + a.foo = 99; // ignored: numeric-index loop only + assert.equal(env.cxjs_max(a), 3); + }); + + // Value on the prototype participates in the comparison. + test('cxjs_max(object with inherited enumerable prop) = 3', () => + { + function Proto() { this.a = 3; } + Proto.prototype.b = 1; + assert.equal(env.cxjs_max(new Proto()), 3); + }); + }); diff --git a/centrallix-os/sys/js/tests/cxjs_min.test.js b/centrallix-os/sys/js/tests/cxjs_min.test.js new file mode 100644 index 000000000..59c256091 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_min.test.js @@ -0,0 +1,225 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim, distinguishes -0 from 0, and otherwise matches +// JSON.stringify, so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_min', () => + { + for (const [ input, result ] of [ + // Input Result + [ [], undefined ], + [ [0, 1], 0 ], + [ [0, 1, 2, 3, 4], 0 ], + [ [3, 1, 2, 4], 1 ], + [ [9, 10.1, 10.2], 9 ], + [ [3.4, 3.3, 3.2], 3.2 ], + [ [-1, 0], -1 ], + [ [-1.1, 1], -1.1 ], + [ [-Infinity, 0], -Infinity ], + [ [Infinity, 0], 0 ], + [ [Infinity, Infinity], Infinity ], + [ [-Infinity, -Infinity], -Infinity ], + [ [undefined], undefined ], + [ [undefined, 0], 0 ], + ]) { + test(`cxjs_min(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_min(input), result); + }); + } + + for (const [ input, result ] of [ + // Input Result + [ {}, undefined ], + [ { a: 0, b: 1 }, 0 ], + [ { c: 0, d: 1, e: 2, f: 3, g: 4 }, 0 ], + [ { h: 3, i: 1, j: 2, k: 4 }, 1 ], + [ { l: 9, m: 10.1, n: 10.2 }, 9 ], + [ { o: 3.4, p: 3.3, q: 3.2 }, 3.2 ], + [ { r: -1, s: 0 }, -1 ], + [ { t: -1.1, u: 1 }, -1.1 ], + [ { v: -Infinity, w: 0 }, -Infinity ], + [ { x: Infinity, y: 0 }, 0 ], + [ { z: Infinity, A: Infinity }, Infinity ], + [ { B: -Infinity, C: -Infinity }, -Infinity ], + [ { D: undefined }, undefined ], + [ { E: undefined, F: 0 }, 0 ], + ]) { + test(`cxjs_min(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_min(input), result); + }); + } + + // Scalar (non-array, non-object) inputs. + for (const [ input, result ] of [ + // Input Result + [ 5, 5 ], + [ -3, -3 ], + [ 0, 0 ], + [ Infinity, Infinity ], + [ NaN, NaN ], + [ 'foo', 'foo' ], + [ true, true ], + [ null, null ], // typeof null is "object", but v !== null is false + [ undefined, undefined ], + ]) { + test(`cxjs_min(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_min(input), result); + }); + } + + // null and NaN list edge cases. + for (const [ input, result ] of [ + // Input Result + [ [5], 5 ], // single element + [ [NaN, 5], 5 ], // leading NaN replaced by a real value + [ [5, NaN], 5 ], // trailing NaN never beats a real value + [ [NaN], NaN ], // all NaN: NaN survives + [ [NaN, NaN], NaN ], + [ [null, 5], null ], // null compares as 0, so it is the min + [ [5, null], null ], + [ [0, undefined], 0 ], // undefined after a value is ignored + [ { a: NaN, b: 5 }, 5 ], + [ { a: 5, b: NaN }, 5 ], + [ { a: NaN }, NaN ], + [ { a: null, b: 5 }, null ], + [ { a: 5, b: null }, null ], + ]) { + test(`cxjs_min(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_min(input), result); + }); + } + + // Non-numeric strings. + for (const [ input, result ] of [ + // Input Result + [ ['b', 'a', 'c'], 'c' ], // last element wins, not 'a' + [ ['apple', 'banana'], 'banana' ], + [ ['10', '9', '100'], '10' ], // lexical compare: '10' < '9' < '100' + ]) { + test(`cxjs_min(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_min(input), result); + }); + } + + // Lists with NaNs. + for (const [ input, result ] of [ + // Input Result + [ [5, NaN, 3], 3 ], + [ [NaN, NaN, 5], 5 ], + [ [3, NaN, 1], 1 ], + [ [1, NaN, 2, NaN, 3], 1 ], + [ [5, NaN, 3, NaN, 1], 1 ], + ]) { + test(`cxjs_min(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_min(input), result); + }); + } + + // null compares as 0. + for (const [ input, result ] of [ + // Input Result + [ [-1, null], -1 ], // -1 < null(0), so -1 wins + [ [null, -1], -1 ], + [ [1, null], null ], // null(0) < 1, so null wins and is preserved + [ [null, 1], null ], + ]) { + test(`cxjs_min(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_min(input), result); + }); + } + + // Misc edge cases. + for (const [ input, result ] of [ + // Input Result + [ [Infinity, -Infinity], -Infinity ], + [ [-Infinity, Infinity], -Infinity ], + [ [-0, 0], -0 ], // -0 > 0 is false, so -0 is kept + [ [0, -0], 0 ], // 0 > -0 is false, so 0 is kept + [ [-0], -0 ], + [ [true, false], false ], // false(0) < true(1) + [ [false, true], false ], + [ [2, true], true ], // true(1) < 2, returned as boolean + [ [0, false], 0 ], // false(0) not < 0, so 0 kept + ]) { + test(`cxjs_min(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_min(input), result); + }); + } + + // Numbers mixed with numeric strings. + for (const [ input, result ] of [ + // Input Result + [ [2, '10'], 2 ], // numeric compare: 2 < 10 + [ ['10', 2], 2 ], + [ [5, '3', 4], '3' ], // '3' is numerically least, kept as a string + ]) { + test(`cxjs_min(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_min(input), result); + }); + } + + // Sparse arrays. + for (const [ input, result ] of [ + // Input Result + [ [1, , 3], 1 ], // 1 < 3, the hole is skipped + [ [, , 5], 5 ], + ]) { + test(`cxjs_min(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_min(input), result); + }); + } + + test('cxjs_min(array with extra non-index prop) = 1', () => + { + const a = [1, 2, 3]; + a.foo = 99; // ignored: numeric-index loop only + assert.equal(env.cxjs_min(a), 1); + }); + + // for...in over an object visits inherited enumerable properties too, so a + // value on the prototype participates in the comparison. + test('cxjs_min(object with inherited enumerable prop) = 1', () => + { + function Proto() { this.a = 3; } + Proto.prototype.b = 1; + assert.equal(env.cxjs_min(new Proto()), 1); + }); + }); diff --git a/centrallix-os/sys/js/tests/cxjs_minus.test.js b/centrallix-os/sys/js/tests/cxjs_minus.test.js new file mode 100644 index 000000000..b86dd5177 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_minus.test.js @@ -0,0 +1,125 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", drops undefined, and +// renders -0 as "0", which would make distinct edge-case rows share a test +// name; fmt renders those verbatim so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_minus', () => + { + // Numeric operands subtract. NaN propagates. + for (const [ a, b, result ] of [ + // a b Result + [ 5, 3, 2 ], + [ 3, 5, -2 ], + [ 0, 0, 0 ], + [ 10.5, 0.5, 10 ], + [ -1, -1, 0 ], + [ -5, 3, -8 ], + [ 3, -5, 8 ], + [ Infinity, 1, Infinity ], + [ 1, Infinity, -Infinity ], + [ Infinity, Infinity, NaN ], + [ -Infinity, -Infinity, NaN ], + [ NaN, 1, NaN ], + [ 1, NaN, NaN ], + [ true, 1, 0 ], // Booleans are not strings: true -> 1. + [ false, false, 0 ], // false -> 0. + [ 5, 0, 5 ], + [ 0, 5, -5 ], + [ true, false, 1 ], // 1 - 0. + [ 5e-324, 5e-324, 0 ], // smallest denormals cancel to 0. + ]) { + test(`cxjs_minus(${fmt(a)}, ${fmt(b)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_minus(a, b), result); + }); + } + + // A null/undefined operand yields null. + for (const [ a, b ] of [ + [ null, 5 ], + [ 5, null ], + [ null, null ], + [ undefined, 5 ], + [ 5, undefined ], + ]) { + test(`cxjs_minus(${fmt(a)}, ${fmt(b)}) = null`, () => + { + assert.equal(env.cxjs_minus(a, b), null); + }); + } + + // When either operand is a string, both are coerced to strings and a + // matching suffix b is stripped from the end of a. + for (const [ a, b, result ] of [ + // a b Result + [ 'hello', 'lo', 'hel' ], + [ 'hello', 'hello', '' ], + [ 'hello', 'xyz', 'hello' ], // No match: unchanged. + [ 'hello', '', 'hello' ], // Empty suffix: unchanged. + [ 'hello', 'HELLO', 'hello' ], // Case-sensitive: unchanged. + [ 'abcabc', 'abc', 'abc' ], // Only the trailing match. + [ '', '', '' ], + [ 'a', 'abc', 'a' ], // b longer than a: unchanged. + [ 100, '0', '10' ], // Coercion: 100 -> '100'. + [ '5', 5, '' ], + [ 5, '5', '' ], + + // Only a suffix is stripped: the match must sit at the very end. + // a b Result + [ 'abcabc', 'bc', 'abca' ], // trailing 'bc' removed (lastIndexOf is at the end). + [ 'aXbXc', 'X', 'aXbXc' ], // 'X' occurs, but not at the end: unchanged. + + // Overlapping/repeated suffix. + // a b Result + [ 'aaa', 'aa', 'a' ], // lastIndexOf('aa') = 1 = len-2: strips one. + [ 'aaaa', 'aa', 'aa' ], // lastIndexOf('aa') = 2 = len-2: strips one. + [ '', 'abc', '' ], // empty a, longer b: lastIndexOf = -1, but -1 != 0-3, so a unchanged (''). + + // Coercion by only one string. + // a b Result + [ true, 'e', 'tru' ], + [ 'true', true, '' ], + [ '12', 2, '1' ], + [ NaN, 'N', 'Na' ], + ]) { + test(`cxjs_minus(${fmt(a)}, ${fmt(b)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_minus(a, b), result); + }); + } + + // Signed-zero: subtracting equal values gives +0, even -0 - -0. + // JSON.stringify(-0) is "0", so name these explicitly (Object.is + // distinguishes -0 from +0 in assert/strict). + test('cxjs_minus(-0, -0) = +0', () => + { + assert.equal(env.cxjs_minus(-0, -0), 0); + }); + }); diff --git a/centrallix-os/sys/js/tests/cxjs_plus.test.js b/centrallix-os/sys/js/tests/cxjs_plus.test.js new file mode 100644 index 000000000..5c474ba65 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_plus.test.js @@ -0,0 +1,101 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify renders Infinity/NaN as "null"; show numbers verbatim so +// those rows stay distinct, and quote strings so they're not confused with +// numbers (concatenation vs. addition hinges on the operand type). +const fmt = (v) => (typeof v === 'number') ? String(v) : JSON.stringify(v); + +describe('cxjs_plus', () => + { + for (const [ a, b, result ] of [ + // a b Result + // Null/undefined operands yield null, even alongside a string. + [ null, 1, null ], + [ 1, null, null ], + [ undefined, 1, null ], + [ 1, undefined, null ], + [ null, null, null ], + [ null, 'x', null ], + [ undefined, undefined, null ], + + // Numeric addition. + [ 0, 0, 0 ], + [ 1, 2, 3 ], + [ -1, 1, 0 ], + [ -2, -3, -5 ], + [ 1.5, 2.25, 3.75 ], + [ -2.5, 1.25, -1.25 ], + [ Infinity, 1, Infinity ], + [ -Infinity, 1, -Infinity ], + [ Infinity, Infinity, Infinity ], + [ Infinity, -Infinity, NaN ], // opposite infinities + + // String concatenation when either operand is a string. + [ 'foo', 'bar', 'foobar' ], + [ '', '', '' ], + [ 'a', '', 'a' ], + [ 'x', 1, 'x1' ], + [ 1, 'x', '1x' ], + [ 'x', -1, 'x-1' ], + [ '1', '2', '12' ], + [ '1', 2, '12' ], + [ 0, '', '0' ], // string branch beats numeric 0 + [ 'n', Infinity, 'nInfinity' ], + [ 'a', NaN, 'aNaN' ], + + // NaN is a number, so two non-string operands still add (to NaN). + [ NaN, 1, NaN ], + [ NaN, NaN, NaN ], + + // Booleans treated as numbers (true->1, false->0), + // unless a string operand forces concatenation. + [ true, 1, 2 ], + [ true, false, 1 ], + [ false, false, 0 ], + [ true, 'x', 'truex' ], + [ 'x', true, 'xtrue' ], + + // Object/array handling. + [ [1], 2, '12' ], + [ 2, [1], '21' ], + [ [1, 2], [3], '1,23' ], + [ [], [], '' ], // both arrays -> '' + '' = '' + [ {}, 1, '[object Object]1' ], + [ 1, {}, '1[object Object]' ], + [ [1], 'x', '1x' ], // string branch: String([1]) = '1' + [ false, 0, 0 ], // both numeric: 0 + 0 = 0 + [ '', 0, '0' ], // empty-string branch beats numeric 0 + + // Large magnitudes overflow to Infinity rather than wrapping. + [ 1e308, 1e308, Infinity ], + ]) { + test(`cxjs_plus(${fmt(a)}, ${fmt(b)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_plus(a, b), result); + }); + } + + // Signed-zero edge cases. + test('cxjs_plus(-0, -0) = -0', () => + { + assert.equal(env.cxjs_plus(-0, -0), -0); + }); + test('cxjs_plus(-0, 0) = +0', () => + { + assert.equal(env.cxjs_plus(-0, 0), 0); + }); + }); diff --git a/centrallix-os/sys/js/tests/cxjs_power.test.js b/centrallix-os/sys/js/tests/cxjs_power.test.js new file mode 100644 index 000000000..973828f96 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_power.test.js @@ -0,0 +1,190 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim (and recurses into arrays/objects) so names +// stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_power', () => + { + for (const [ n, p, result ] of [ + // n p Result + [ 2, 3, 8 ], + [ 5, 2, 25 ], + [ 2, 10, 1024 ], + [ 7, 1, 7 ], // identity exponent + [ 9, 0, 1 ], // zero exponent + [ 0, 0, 1 ], // 0^0 is defined as 1 + [ 0, 5, 0 ], + [ 1, 100, 1 ], + [ 2, -1, 0.5 ], // negative exponent + [ 2, -2, 0.25 ], + [ 10, -2, 0.01 ], + [ 4, 0.5, 2 ], // fractional exponent (root) + [ 2, 0.5, Math.SQRT2 ], + [ 27, 1 / 3, 3 ], + ]) { + test(`cxjs_power(${fmt(n)}, ${fmt(p)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_power(n, p), result); + }); + } + + // Negative base: integer exponents stay real, but a fractional exponent + // has no real root and yields NaN. + for (const [ n, p, result ] of [ + // n p Result + [ -2, 2, 4 ], + [ -2, 3, -8 ], + [ -3, 0, 1 ], + [ -2, -2, 0.25 ], + [ -8, 1 / 3, NaN ], + [ -1, 0.5, NaN ], + ]) { + test(`cxjs_power(${fmt(n)}, ${fmt(p)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_power(n, p), result); + }); + } + + // A null or undefined for either argument short-circuits to null before + // any exponentiation (and so never coerces to NaN). + for (const [ n, p ] of [ + [ null, 2 ], + [ 2, null ], + [ null, null ], + [ undefined, 2 ], + [ 2, undefined ], + [ undefined, undefined ], + [ null, undefined ], + ]) { + test(`cxjs_power(${fmt(n)}, ${fmt(p)}) = null`, () => + { + assert.equal(env.cxjs_power(n, p), null); + }); + } + + // Infinities and the IEEE-754 corner cases of exponentiation: a zero + // exponent always wins (1), a base of 1 or -1 against an infinite exponent + // is NaN, and 0^negative is a signed infinity. + for (const [ n, p, result ] of [ + // n p Result + [ Infinity, 2, Infinity ], + [ Infinity, -1, 0 ], + [ Infinity, 0, 1 ], + [ 2, Infinity, Infinity ], + [ 2, -Infinity, 0 ], + [ 0.5, Infinity, 0 ], + [ 0, Infinity, 0 ], + [ 0, -1, Infinity ], + [ 0, -2, Infinity ], + [ 1, Infinity, NaN ], + [ -1, Infinity, NaN ], + [ 1, -Infinity, NaN ], + [ -1, -Infinity, NaN ], + [ 0.5, -Infinity, Infinity ], // |base|<1 with -Infinity exp blows up + [ -Infinity, 2, Infinity ], + [ -Infinity, 3, -Infinity ], + [ -Infinity, -1, -0 ], + [ -Infinity, -2, 0 ], // even negative exp gives +0 + ]) { + test(`cxjs_power(${fmt(n)}, ${fmt(p)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_power(n, p), result); + }); + } + + // A base of -0 keeps the sign only for odd positive exponents; negative + // exponents give a signed infinity (odd -> -Infinity, even -> +Infinity). + for (const [ n, p, result ] of [ + // n p Result + [ -0, 2, 0 ], // even exponent -> +0 + [ -0, 3, -0 ], // odd exponent keeps the sign + [ -0, -1, -Infinity ], // odd negative exponent + [ -0, -2, Infinity ], // even negative exponent + ]) { + test(`cxjs_power(${fmt(n)}, ${fmt(p)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_power(n, p), result); + }); + } + + // Overflow saturates to Infinity and a fractional exponent on a negative + // base has no real value (NaN), regardless of the base's magnitude. + for (const [ n, p, result ] of [ + // n p Result + [ 2, 1024, Infinity ], // overflows to Infinity + [ Number.MAX_VALUE, 2, Infinity ], + [ 2, -1074, 5e-324 ], // smallest positive subnormal + [ -0.5, 0.5, NaN ], // negative fractional base -> NaN + ]) { + test(`cxjs_power(${fmt(n)}, ${fmt(p)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_power(n, p), result); + }); + } + + // NaN propagates. + for (const [ n, p, result ] of [ + // n p Result + [ NaN, 2, NaN ], + [ 2, NaN, NaN ], + [ NaN, NaN, NaN ], + [ NaN, Infinity, NaN ], + [ NaN, 0, 1 ], // Exception: 0 exponent yields 1. + ]) { + test(`cxjs_power(${fmt(n)}, ${fmt(p)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_power(n, p), result); + }); + } + + // Arguments that pass the null/undefined guard are coerced to numbers. + for (const [ n, p, result ] of [ + // n p Result + [ '2', '3', 8 ], // numeric strings coerce + [ '4', 0.5, 2 ], + [ '', 2, 0 ], // empty string coerces to 0 + [ 2, '', 1 ], // ...so this is 2^0 + [ 'foo', 2, NaN ], // non-numeric string coerces to NaN + [ 2, 'foo', NaN ], + [ true, 3, 1 ], // true coerces to 1 + [ false, 0, 1 ], // false coerces to 0, then 0^0 is 1 + [ 2, true, 2 ], + [ [], 2, 0 ], // empty array coerces to 0 + [ [3], 2, 9 ], // single-element array coerces to its element + [ 2, [3], 8 ], + [ {}, 2, NaN ], // object coerces to NaN + ]) { + test(`cxjs_power(${fmt(n)}, ${fmt(p)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_power(n, p), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_quote.test.js b/centrallix-os/sys/js/tests/cxjs_quote.test.js new file mode 100644 index 000000000..c581b91fb --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_quote.test.js @@ -0,0 +1,77 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", drops undefined, and +// renders -0 as "0", which would make distinct edge-case rows share a test +// name; fmt renders those verbatim so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +// cxjs_quote() wraps a value in double quotes, escaping any embedded +// double quote as \". Backslashes are NOT escaped, and non-string +// inputs are coerced via String() first. +describe('cxjs_quote', () => + { + for (const [ input, result ] of [ + // Input Result + [ '', '""' ], + [ 'hello', '"hello"' ], + [ '"', '"\\""' ], // lone quote + [ '""', '"\\"\\""' ], // adjacent quotes + [ 'a"b', '"a\\"b"' ], // quote in middle + [ '\\', '"\\"' ], // backslash quoted as-is + [ '\\"', '"\\\\""' ], // backslash + escaped quote + [ "'x'", '"\'x\'"' ], // single quotes untouched + [ 'a\nb', '"a\nb"' ], // newline preserved + [ '\t ', '"\t "' ], // tab/space preserved + [ 123, '"123"' ], // number coerced + [ true, '"true"' ], // boolean coerced + [ null, '"null"' ], // null coerced + [ undefined, '"undefined"' ], // undefined coerced + [ Infinity, '"Infinity"' ], // Infinity coerced + [ -Infinity, '"-Infinity"' ], // -Infinity coerced + [ NaN, '"NaN"' ], // NaN coerced + [ [1, 2], '"1,2"' ], // array coerced (with no spaces) + [ {}, '"[object Object]"' ], // plain object coerced + [ { a: 1 }, '"[object Object]"' ], // object contents are irrelevant to String() + [ '\\\\', '"\\\\"' ], // two backslashes both pass as is (unescaped) + [ 'a"b"c', '"a\\"b\\"c"' ], // every embedded quote is escaped + [ '\r', '"\r"' ], // carriage return preserved + [ '😀', '"😀"' ], // multi-byte char passes as is + [ 0, '"0"' ], // numeric zero coerced + [ -0, '"0"' ], // negative zero renders as "0" + [ false, '"false"' ], // boolean false coerced + [ [], '""' ], // empty array coerces to "" + [ [null], '""' ], // null element renders as "" inside array + [ ['a', null], '"a,"' ], // null element is empty between commas + [ ['a"b'], '"a\\"b"' ], // quote inside array element still escaped + ]) { + test(`cxjs_quote(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_quote(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_radians.test.js b/centrallix-os/sys/js/tests/cxjs_radians.test.js new file mode 100644 index 000000000..300abf221 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_radians.test.js @@ -0,0 +1,105 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim (and -0 distinctly from 0) while otherwise +// matching JSON.stringify, so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_radians', () => + { + for (const [ input, result ] of [ + // Input Result + [ 0, 0 ], + [ 180, Math.PI ], + [ 90, Math.PI / 2 ], + [ 45, Math.PI / 4 ], + [ 360, Math.PI * 2 ], + [ 270, Math.PI * 1.5 ], + [ -180, -Math.PI ], + [ -90, -Math.PI / 2 ], + [ -0, -0 ], // sign preserved + [ Infinity, Infinity ], + [ -Infinity, -Infinity ], + [ NaN, NaN ], // NaN propagates (no coercion). + ]) { + test(`cxjs_radians(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_radians(input), result); + }); + } + + // Extreme magnitudes follow the exact source expression: a very large value + // overflows to Infinity, a very small one stays finite (no underflow to 0). + for (const input of [ 1e200, Number.MAX_VALUE, 1e-200, -90, Number.MIN_VALUE ]) + { + test(`cxjs_radians(${fmt(input)}) = (degrees*PI)/180`, () => + { + assert.equal(env.cxjs_radians(input), (input * Math.PI) / 180.0); + }); + } + + // radians(degrees(x)) = x (within float tolerance). + for (const x of [ 1, Math.PI / 4, 2 * Math.PI, -1.5 ]) + { + test(`cxjs_radians(cxjs_degrees(${fmt(x)})) ~= ${fmt(x)}`, () => + { + assert.ok(Math.abs(env.cxjs_radians(env.cxjs_degrees(x)) - x) < 1e-9); + }); + } + + // Only null and undefined yield null (no coercion). + for (const input of [ null, undefined ]) + { + test(`cxjs_radians(${fmt(input)}) = null`, () => + { + assert.equal(env.cxjs_radians(input), null); + }); + } + + // Non-number inputs are coerced to numbers. + // Those that coerce to NaN yield NaN (null is never used). + for (const [ input, result ] of [ + // Input Result + [ '180', Math.PI ], + [ '-90', -Math.PI / 2 ], // negative string coerces to -90 + [ '', 0 ], // empty string coerces to 0 + [ 'foo', NaN ], // non-numeric string coerces to NaN + [ true, Math.PI / 180 ], + [ false, 0 ], + [ [], 0 ], // empty array coerces to 0 + [ [180], Math.PI ], // single-element array coerces to its element + [ [1, 2], NaN ], // multi-element array coerces to NaN + [ {}, NaN ], // object coerces to NaN + ]) { + test(`cxjs_radians(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_radians(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_rand.test.js b/centrallix-os/sys/js/tests/cxjs_rand.test.js new file mode 100644 index 000000000..8ef1f493c --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_rand.test.js @@ -0,0 +1,102 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// Renders NaN/Infinity/undefined verbatim so seed test names stay unique +// (JSON.stringify would collapse them). +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +// Runs fn with the sandbox's console.warn replaced by a counter, returning +// the number of warnings emitted. Restores the real console afterward. +function captureWarningCount(fn) + { + const real = env.console; + let count = 0; + env.console = { warn: () => { count++; } }; + try { fn(); } + finally { env.console = real; } + return count; + } + +describe('cxjs_rand', () => + { + // Result is always a float in [0,1). + test('returns a float in [0,1)', () => + { + for (let i = 0; i < 1000; i++) + { + const n = env.cxjs_rand(); + assert.equal(typeof n, 'number'); + assert.ok(n >= 0 && n < 1, `${n} not in [0,1)`); + } + }); + + // Output is not constant across calls. + test('produces varied output', () => + { + const seen = new Set(); + for (let i = 0; i < 1000; i++) seen.add(env.cxjs_rand()); + assert.ok(seen.size > 1, 'expected more than one distinct value'); + }); + + // A fixed seed is ignored, so it does not pin the output to one value. + test('ignores the seed (output stays non-deterministic)', () => + { + const seen = new Set(); + captureWarningCount(() => // Warnings suppressed (tested later). + { + for (let i = 0; i < 1000; i++) seen.add(env.cxjs_rand(42)); + }); + assert.ok(seen.size > 1, 'seed should not make output deterministic'); + }); + + // Any non-null/undefined seed -- even a falsy one -- emits one warning + // and still returns a valid result. + for (const seed of [0, 1, 42, -5, 3.14, '', 'abc', false, true, NaN, Infinity, [], {}]) + { + test(`warns when given seed ${fmt(seed)}`, () => + { + let result; + const warnings = captureWarningCount(() => { result = env.cxjs_rand(seed); }); + assert.equal(warnings, 1); + assert.equal(typeof result, 'number'); + assert.ok(result >= 0 && result < 1, `${result} not in [0,1)`); + }); + } + + // No seed, or an explicit null/undefined, produces no warning. + for (const [ label, rand_fn ] of [ + [ 'no argument', () => env.cxjs_rand() ], + [ 'null', () => env.cxjs_rand(null) ], + [ 'undefined', () => env.cxjs_rand(undefined) ], + ]) { + test(`does not warn with ${label}`, () => + { + let result; + const warnings = captureWarningCount(() => { result = rand_fn(); }); + assert.equal(warnings, 0); + assert.ok(result >= 0 && result < 1, `${result} not in [0,1)`); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_replace.test.js b/centrallix-os/sys/js/tests/cxjs_replace.test.js new file mode 100644 index 000000000..4d0993f7a --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_replace.test.js @@ -0,0 +1,146 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// replace globally substitutes every literal occurrence of search in str with +// replace, returning null when str or search is null/undefined. +describe('cxjs_replace', () => + { + for (const [ str, search, replace, result ] of [ + // Str Search Replace Result + [ 'hello world', 'o', '0', 'hell0 w0rld' ], // replaces every occurrence + [ 'hello world', 'h', 'J', 'Jello world' ], // first character + [ 'hello world', 'd', '!', 'hello worl!' ], // last character + [ 'hello world', ' ', '_', 'hello_world' ], // whitespace + [ 'hello world', 'l', ' ', 'he o wor d' ], + [ 'hello world', 'l', 'l', 'hello world' ], // identical result + [ 'hello world', ' ', ' ', 'hello world' ], + [ 'hello world', 'c', 'c', 'hello world' ], + [ 'aaa', 'a', 'b', 'bbb' ], + [ 'aaa', 'aa', 'b', 'ba' ], // non-overlapping, left to right + [ 'hello', 'z', 'x', 'hello' ], // absent + [ 'hello', 'l', 'LL', 'heLLLLo' ], // replacement longer than match + [ 'Hello', 'h', 'x', 'Hello' ], // case-sensitive: no match + [ 'hello', 'l', '', 'heo' ], // empty replace deletes matches + + // Matching is left-to-right and non-overlapping. + [ 'aaaa', 'aa', 'X', 'XX' ], + [ 'abab', 'ab', 'X', 'XX' ], + [ 'aaaaa', 'aaa', 'X', 'Xaa' ], + + // Search is matched literally (regex metacharacters have no special meaning). + [ '1.2.3', '.', ',', '1,2,3' ], + [ 'a|b|c', '|', '-', 'a-b-c' ], + [ '*x*', '*', '#', '#x#' ], + [ 'a+b', '+', '-', 'a-b' ], + [ 'a?b', '?', '!', 'a!b' ], + [ 'a^b', '^', '~', 'a~b' ], + [ '$5', '$', 'USD', 'USD5' ], + [ '(x)', '(', '[', '[x)' ], + [ '{x}', '{', '<', '"") + [ [1,2,3], ',', '-', '1-2-3' ], // array str coerces + + // Multi-byte / Unicode handling operates on UTF-16 code units. + [ 'café', 'é', 'X', 'cafX' ], // single composed code point (U+00E9) + [ 'x😀y', '😀', 'Z', 'xZy' ], // full surrogate pair matched + [ 'a😀b', '😀', 'X', 'aXb' ], + [ '😀', '', '-', '-\uD83D-\uDE00-' ], // empty search splits the pair's code units + + // Embedded newlines are ordinary characters; a literal '.' does not match them. + [ 'a\nb\nc', '\n', '|', 'a|b|c' ], // newline searched literally + [ 'a\nb', '.', 'X', 'a\nb' ], // '.' is literal, matches nothing here + ]) { + test(`cxjs_replace(` + + `${JSON.stringify(str)}, ` + + `${JSON.stringify(search)}, ` + + `${JSON.stringify(replace)}) ` + + `= ${JSON.stringify(result)}`, + () => + { + assert.equal(env.cxjs_replace(str, search, replace), result); + }); + } + + // null or undefined str/search short-circuits to null; a null/undefined replace + // (including an omitted third argument) instead defaults to "". + for (const [ str, search, replace, result ] of [ + // Str Search Replace Result + [ null, 'a', 'b', null ], + [ undefined, 'a', 'b', null ], + [ 'x', null, 'b', null ], + [ 'x', undefined, 'b', null ], + [ null, null, 'b', null ], + [ 'hello', 'l', null, 'heo' ], // null replace -> deletes matches + [ 'hello', 'l', undefined, 'heo' ], // undefined replace -> deletes matches + ]) { + test(`cxjs_replace(${str}, ${search}, ${replace}) = ${JSON.stringify(result)}`, () => + { + assert.equal(env.cxjs_replace(str, search, replace), result); + }); + } + + // replace is uses String.replace() logic, allowing $ patterns to be used: + // $& is the match, $$ a literal $, $` and $' the text before/after. + for (const [ str, search, replace, result ] of [ + // Str Search Replace Result + [ 'cat', 'a', '$&', 'cat' ], // match reinserted unchanged + [ 'cat', 'a', '[$&]', 'c[a]t' ], + [ 'cat', 'a', '$$', 'c$t' ], + [ 'cat', 'a', '$`', 'cct' ], // text preceding the match + [ 'cat', 'a', "$'", 'ctt' ], // text following the match + [ 'cat', 'a', '$1', 'c$1t' ], // no capture group: $1 left verbatim + [ 'cat', 'a', '$0', 'c$0t' ], // $0 is not special: left verbatim + [ 'cat', 'a', '$', 'c$t' ], // no named groups: $ left verbatim + [ 'cat', 'a', '$$&', 'c$&t' ], // escaped $ then literal &: yields "$&" + [ 'cat', 'a', '$$1', 'c$1t' ], // escaped $ then "1": yields "$1" + ]) { + test(`cxjs_replace(` + + `${JSON.stringify(str)}, ` + + `${JSON.stringify(search)}, ` + + `${JSON.stringify(replace)}) ` + + `= ${JSON.stringify(result)}`, + () => + { + assert.equal(env.cxjs_replace(str, search, replace), result); + }); + } + + // Omitted replace defaults to "", deleting every match. + test("cxjs_replace('hello', 'l') = 'heo'", () => + { + assert.equal(env.cxjs_replace('hello', 'l'), 'heo'); + }); + }); diff --git a/centrallix-os/sys/js/tests/cxjs_replicate.test.js b/centrallix-os/sys/js/tests/cxjs_replicate.test.js new file mode 100644 index 000000000..526d51fab --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_replicate.test.js @@ -0,0 +1,121 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// String(n)/JSON.stringify collapse distinct values into the same test name +// (e.g. the number 3 and the string "3", or NaN/Infinity/-0); fmt renders them +// verbatim (with quotes for strings, "-0" for negative zero) so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +// replicate concatenates n copies of s, returning null if s or n is +// null/undefined or if n is negative. +describe('cxjs_replicate', () => + { + for (const [ s, n, result ] of [ + // Str N Result + [ 'ab', 3, 'ababab' ], + [ 'x', 1, 'x' ], // single copy + [ 'ab', 0, '' ], // zero copies + [ '', 5, '' ], // empty str stays empty + [ '', 0, '' ], + [ 'a b', 2, 'a ba b' ], // whitespace preserved + [ 'abc', 3, 'abcabcabc' ], + + // n is floored toward zero's neighbor, so fractions truncate down. + [ 'ab', 3.9, 'ababab' ], + [ 'ab', 0.5, '' ], // floors to 0 + [ 'ab', 2.0, 'abab' ], + + // n is coerced to a number before flooring. + [ 'ab', '3', 'ababab' ], // numeric string + [ 'ab', '2.9', 'abab' ], // numeric string floored + [ 'ab', '', '' ], // "" -> Number 0 + [ 'ab', ' ', '' ], // blank -> Number 0 + [ 'ab', true, 'ab' ], // true -> 1 copy + [ 'ab', false, '' ], // false -> 0 copies + + // Non-string s is coerced to string. + [ 5, 3, '555' ], + [ true, 2, 'truetrue' ], + [ 0, 2, '00' ], + [ false, 2, 'falsefalse' ], // boolean false coerced + [ NaN, 2, 'NaNNaN' ], // NaN value coerced to "NaN" + [ [1,2], 2, '1,21,2' ], // array via Array.toString + [ {}, 2, '[object Object][object Object]' ], // object via Object.toString + [ '😀', 3, '😀😀😀' ], // multi-byte s repeated whole + + // NaN n yields "". + [ 'ab', NaN, '' ], + [ 'ab', 'abc', '' ], // String->NaN + ]) { + test(`cxjs_replicate(${fmt(s)}, ${fmt(n)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_replicate(s, n), result); + }); + } + + // null/undefined s or n short-circuits to null. + for (const [ s, n ] of [ + [ null, 3 ], + [ undefined, 3 ], + [ 'x', null ], + [ 'x', undefined ], + [ null, null ], + ]) { + test(`cxjs_replicate(${fmt(s)}, ${fmt(n)}) = null`, () => + { + assert.equal(env.cxjs_replicate(s, n), null); + }); + } + + // Negative n returns null. + for (const [ s, n ] of [ + [ 'ab', -1 ], + [ 'ab', -5 ], + [ 'ab', -0.1 ], // Floors away from 0 (-0.1 -> -1), resulting in null. + [ 'ab', -Infinity ], + ]) { + test(`cxjs_replicate(${fmt(s)}, ${fmt(n)}) = null`, () => + { + assert.equal(env.cxjs_replicate(s, n), null); + }); + } + + // Just below the cap: 254.9 floors to 254 copies (one short of the cap). + test("cxjs_replicate('a', 254.9) = 254 copies", () => + { + assert.equal(env.cxjs_replicate('a', 254.9), 'a'.repeat(254)); + }); + + // n is capped at 255 copies. + for (const n of [ 255, 255.9, 256, 300, Infinity ]) + { + test(`cxjs_replicate('ab', ${fmt(n)}) caps at 255 copies`, () => + { + assert.equal(env.cxjs_replicate('ab', n), 'ab'.repeat(255)); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_reverse.test.js b/centrallix-os/sys/js/tests/cxjs_reverse.test.js new file mode 100644 index 000000000..07310e311 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_reverse.test.js @@ -0,0 +1,105 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify renders NaN/Infinity as "null", a standalone undefined as +// undefined (not a string), and -0 as "0", which would give distinct edge-case +// rows the same (or a broken) test name; fmt renders those verbatim (and +// recurses into arrays/objects) so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_reverse', () => + { + for (const [ input, result ] of [ + // Input Result + [ 'abc', 'cba' ], + [ 'a', 'a' ], + [ '', '' ], + [ 'aba', 'aba' ], // palindrome unchanged + [ 'Hello World', 'dlroW olleH' ], + [ 'a b c', 'c b a' ], // spaces reversed + [ '\ta\n', '\na\t' ], // whitespace reversed + [ ' \tx ', ' x\t ' ], // surrounding whitespace preserved + [ '123!?', '?!321' ], // digits/symbols reverse like any char + [ 'àéî', 'îéà' ], // non-ASCII letters: a/e/i w/ accents + [ 'a"b', 'b"a' ], // embedded double quote reverses like any char + [ 'a\\b', 'b\\a' ], // embedded backslash reverses like any char + [ 'aabb', 'bbaa' ], // repeated chars + ]) { + test(`cxjs_reverse(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_reverse(input), result); + }); + } + + // null and undefined both short-circuit to null. Other non-strings are + // coerced before reversal. + for (const [ input, result ] of [ + // Input Result + [ null, null ], + [ undefined, null ], + [ 123, '321' ], + [ 1.5, '5.1' ], + [ -12, '21-' ], + [ true, 'eurt' ], + [ false, 'eslaf' ], + [ NaN, 'NaN' ], // 'NaN' reverses to itself + [ Infinity, 'ytinifnI' ], + [ -Infinity, 'ytinifnI-' ], + [ [], '' ], // empty array coerces to '' + [ ['a'], 'a' ], + [ ['a', 'b'], 'b,a' ], + [ 0, '0' ], // numeric zero coerces to '0' + [ -0, '0' ], // negative zero renders as '0' + [ [1, 2, 3], '3,2,1' ], // commas reverse with digits + [ {}, ']tcejbO tcejbo['], // object -> '[object Object]' reversed + ]) { + test(`cxjs_reverse(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_reverse(input), result); + }); + } + + // Reversal walks UTF-16 code units, not whole characters, so surrogate + // pairs and combining marks get split apart and reordered. Escapes are + // used directly so these cases do not depend on file encodings. + for (const [ name, input, result ] of [ + // emoji (one code point, two code units) splits into swapped halves. + [ 'surrogate pair split', '\uD83D\uDE00', '\uDE00\uD83D' ], + [ 'pair split before char', '\uD83D\uDE00a', 'a\uDE00\uD83D' ], + // each emoji's two halves swap in place, so the run is fully reordered. + [ 'two emoji halves reorder', '\uD83D\uDE00\uD83D\uDE00', '\uDE00\uD83D\uDE00\uD83D' ], + // char before an emoji ends up after the swapped halves. + [ 'char before pair split', 'a\uD83D\uDE00b', 'b\uDE00\uD83Da' ], + // 'e' + combining acute reverses to combining acute + 'e'. + [ 'combining mark reorder', 'e\u0301', '\u0301e' ], + ]) { + test(`cxjs_reverse: ${name}`, () => + { + assert.equal(env.cxjs_reverse(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_right.test.js b/centrallix-os/sys/js/tests/cxjs_right.test.js new file mode 100644 index 000000000..8394b7a12 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_right.test.js @@ -0,0 +1,98 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", drops undefined, and +// renders -0 as "0", which would make distinct edge-case rows share a test +// name; fmt renders those verbatim so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_right', () => + { + for (const [ s, l, result ] of [ + // String Length Result + [ 'hello', 2, 'lo' ], + [ 'hello', 1, 'o' ], + [ 'hello', 5, 'hello' ], // l equals length: whole string + [ 'hello', 10, 'hello' ], // l exceeds length: whole string + [ 'hello', 0, '' ], // zero length: empty + [ 'hello', -2, '' ], // negative length: empty + [ 'hello', 2.5, 'llo' ], // fractional length truncated to 2 + [ 'hello', NaN, 'hello' ], // NaN != null, so substr(NaN) -> substr(0) + [ '', 3, '' ], // empty string stays empty + [ '', 0, '' ], + [ 'x', 1, 'x' ], // single char + [ ' ab ', 2, 'b ' ], // trailing whitespace preserved + [ ' ab ', 4, ' ab ' ], // preceding whitespace preserved + [ 'abc', null, null ], // null length + [ 'abc', undefined, null ], // undefined length + [ null, 2, null ], // null string + [ undefined, 2, null ], // undefined string + [ null, null, null ], + + // l is coerced to a number by the subtraction s.length - l. + [ 'hello', '2', 'lo' ], // numeric string length + [ 'hello', '2.5', 'llo' ], // numeric string truncated + [ 'hello', 'abc', 'hello' ], // non-numeric string -> NaN -> substr(NaN)=substr(0) + [ 'hello', '', '' ], // "" -> Number 0 -> substr(5) -> "" + [ 'hello', true, 'o' ], // true -> 1 + [ 'hello', false, '' ], // false -> 0 -> substr(5) -> "" + [ 'hello', 2.9, 'llo' ], // fractional length: substr start truncated to 2 + [ 'hello', Infinity, 'hello' ], // length-Inf = -Inf -> substr(-Inf)=substr(0) + [ 'hello', -Infinity, '' ], // length+Inf = Inf -> substr past end -> "" + [ 'hello', [2], 'lo' ], // [2] -> Number 2 + [ 'hello', [], '' ], // [] -> Number 0 -> substr(5) -> "" + [ 'hello', {}, 'hello' ], // {} -> NaN -> substr(0) -> whole string + + // s is a string and is sliced by UTF-16 code units, not grapheme clusters. + [ 'a😀', 1, '\uDE00' ], // splits the surrogate pair: low half only + [ 'a😀', 2, '😀' ], // both code units of the pair + [ 'café', 1, 'é' ], // composed code point (U+00E9) + [ 'a\nb', 2, '\nb' ], // embedded newline preserved + ]) { + test(`cxjs_right(${fmt(s)}, ${fmt(l)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_right(s, l), result); + }); + } + + // A very long string is sliced correctly (substr handles the full length). + test("cxjs_right(<1000 x's>+'abc', 3) = 'abc'", () => + { + assert.equal(env.cxjs_right('x'.repeat(1000) + 'abc', 3), 'abc'); + }); + + // Unlike cxjs_substring(), cxjs_right() never coerces s to a string. + for (const s of [ 12345, true, 0, NaN, [ 1, 2, 3 ], {} ]) + { + test(`cxjs_right(${fmt(s)}, 2) throws (no String coercion)`, () => + { + // Note: This error originates in the vm sandbox, so it is an instance + // of the sandbox's TypeError, not this realm's, preventing instanceof checks. + assert.throws(() => env.cxjs_right(s, 2), (err) => err && err.name === 'TypeError'); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_round.test.js b/centrallix-os/sys/js/tests/cxjs_round.test.js new file mode 100644 index 000000000..8b2b6b7ac --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_round.test.js @@ -0,0 +1,149 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null" and omits undefined, which +// would make distinct edge-case rows share a test name; fmt renders those +// values verbatim (and otherwise matches JSON.stringify) so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_round', () => + { + for (const [ args, result ] of [ + // Rounding to the nearest integer (default dec). + // Exact halves round away from zero in both directions. + // Args Result + [ [2.4], 2 ], + [ [2.5], 3 ], + [ [2.6], 3 ], + [ [3], 3 ], + [ [0.5], 1 ], + [ [1.5], 2 ], + [ [-2.4], -2 ], + [ [-2.5], -3 ], + [ [-2.6], -3 ], + [ [-3], -3 ], + [ [-0.5], -1 ], + [ [-1.5], -2 ], + + // Rounding to dec decimal places. + // Args Result + [ [3.14159, 2], 3.14 ], + [ [3.14159, 4], 3.1416 ], + [ [2.345, 2], 2.35 ], + + // Negative dec rounds to tens, hundreds, thousands, etc. + // Args Result + [ [1234, -2], 1200 ], + [ [1250, -2], 1300 ], + [ [1251, -2], 1300 ], + [ [1240, -1], 1240 ], + [ [12345, -3], 12000 ], + + // A non-integer dec is rounded to the nearest integer before use. + // Args Result + [ [12.345, 1.4], 12.3 ], // dec -> 1 + [ [12.345, 1.5], 12.35 ], // dec -> 2 + [ [1.5, 0.4], 2 ], // dec -> 0 + [ [2.5, -0.5], 3 ], // dec -> 0 + + // null and undefined yield null (no coercion). + // Args Result + [ [null], null ], + [ [undefined], null ], + [ [null, 2], null ], + + // Special numeric values pass through. + // Args Result + [ [Infinity], Infinity ], + [ [Infinity, 2], Infinity ], + [ [-Infinity], -Infinity ], + [ [NaN], NaN ], + [ [5, 400], NaN ], // dec exceeds scaling factor. + + // Sign of a zero: strictly positive inputs give +0, while zero and + // negative inputs give -0. + // Args Result + [ [0.1], 0 ], + [ [0.4], 0 ], + [ [0], -0 ], + [ [-0.1], -0 ], + [ [-0.4], -0 ], + + // Values that are exact halves in decimal are not always representable in + // binary, so they might be rounded down rather than up. + // Args Result + [ [1.005, 2], 1 ], // not 1.01 + [ [1.255, 2], 1.25 ], // not 1.26 + [ [2.675, 2], 2.68 ], // stored slightly above 2.675 -> up + [ [0.615, 2], 0.62 ], // stored slightly above 0.615 -> up + [ [8.575, 2], 8.57 ], // stored slightly below 8.575 -> down + + // -0 is preserved across nonzero dec. + // Args Result + [ [-0, 2], -0 ], + [ [-0, 5], -0 ], + [ [0, -2], -0 ], // zero takes the ceil branch -> -0 + + // Extreme magnitudes: scaling can overflow to Infinity, and tiny + // subnormals round to a signed zero. + // Args Result + [ [Number.MAX_VALUE, 2], Infinity ], // n*100 overflows + [ [Number.MAX_VALUE], Number.MAX_VALUE ], + [ [Number.MIN_VALUE], 0 ], // smallest subnormal -> +0 + [ [-Number.MIN_VALUE], -0 ], + + // factor = 10**dec can itself overflow/underflow if |dec| is large. + // Args Result + [ [5, -400], NaN ], + [ [1e300, -400], NaN ], + + // dec coercion. + // Args Result + [ [1.555, '2'], 1.56 ], // dec "2" -> 2 + [ [1.5, ''], 2 ], // dec "" -> 0 + [ [1.5, 'foo'], NaN ], // dec NaN -> factor NaN -> NaN + [ [1.5, NaN], NaN ], + [ [1.5, Infinity], NaN ], // 10**Infinity = Infinity, n*Inf=Inf, /Inf=NaN + [ [1.5, -Infinity], NaN ], // 10**-Infinity = 0 + [ [1.5, -2.5], 0 ], // Math.round(-2.5) = -2 (ties toward +Inf) + + // n is coerced to a number before rounding (matching JS arithmetic rules). + // Args Result + [ ['2.5'], 3 ], + [ ['foo'], NaN ], // non-numeric string coerces to NaN + [ [''], -0 ], // empty string coerces to 0 + [ [true], 1 ], + [ [false], -0 ], + [ [[5]], 5 ], // single-element array coerces to its element + [ [{}], NaN ], // object coerces to NaN + ]) { + test(`cxjs_round(${args.map(fmt).join(', ')}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_round(...args), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_rtrim.test.js b/centrallix-os/sys/js/tests/cxjs_rtrim.test.js new file mode 100644 index 000000000..12e46d53d --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_rtrim.test.js @@ -0,0 +1,65 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +describe('cxjs_rtrim', () => + { + for (const [ s, result ] of [ + // String Result + [ 'hello ', 'hello' ], // trailing spaces trimmed + [ ' hello', ' hello' ], // leading spaces preserved + [ ' hello ', ' hello' ], // only trailing trimmed + [ 'hello', 'hello' ], // no change + [ 'a b ', 'a b' ], // internal spaces preserved + [ '', '' ], // empty stays empty + [ ' ', '' ], // single space + [ ' ', '' ], // all spaces trimmed + [ 'hello\t', 'hello\t' ], // trailing tab not trimmed + [ 'hello\n', 'hello\n' ], // trailing newline not trimmed + [ 'hello\t ', 'hello\t' ], // space after tab trimmed + [ 'hello \t', 'hello \t' ], // space before tab preserved + [ 42, '42' ], // number coerced to string + [ 0, '0' ], // falsy but not null: coerced, not dropped + [ true, 'true' ], // boolean coerced to string + [ NaN, 'NaN' ], // NaN coerced to string + [ null, null ], // null returns null + [ undefined, null ], // undefined returns null + + // Only the ASCII space (U+0020) is stripped; every other trailing + // whitespace character is left in place. + [ 'hi\r', 'hi\r' ], // carriage return is not a space: unchanged + [ 'hi\f', 'hi\f' ], // form feed is not a space: unchanged + [ 'hi\v', 'hi\v' ], // vertical tab is not a space: unchanged + [ 'hi\u00a0', 'hi\u00a0' ], // non-breaking space is not ASCII space: kept + [ '\t ', '\t' ], // strips trailing spaces, stops at the tab + [ '😀 ', '😀' ], // surrogate-pair emoji kept, spaces stripped + + // Non-string coercion edge cases. + [ '12 ', '12' ], // numeric string: trailing spaces stripped + [ false, 'false' ], // boolean coerced to string + [ Infinity, 'Infinity' ], // Infinity coerced to string + [ -Infinity, '-Infinity' ], // -Infinity coerced to string + [ [], '' ], // empty array coerces to '' + [ ['hi '], 'hi' ], // single-element array unwraps then trims + [ [null], '' ], // [null] coerces to '' (null element -> '') + [ {}, '[object Object]' ], // plain object stringifies, no trailing spaces + [ 'xxxxx ', 'xxxxx' ], // longer run of trailing spaces all stripped + ]) { + test(`cxjs_rtrim(${JSON.stringify(s)}) = ${JSON.stringify(result)}`, () => + { + assert.equal(env.cxjs_rtrim(s), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_sqrt.test.js b/centrallix-os/sys/js/tests/cxjs_sqrt.test.js new file mode 100644 index 000000000..59fcf7746 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_sqrt.test.js @@ -0,0 +1,102 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim (and -0 distinctly from 0) while otherwise +// matching JSON.stringify, so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_sqrt', () => + { + for (const [ input, result ] of [ + // Input Result + [ 4, 2 ], + [ 9, 3 ], + [ 0.25, 0.5 ], + [ 2, Math.SQRT2 ], + [ 1, 1 ], + [ 0, 0 ], + [ -0, -0 ], // preserved: Math.sqrt(-0) is -0, not NaN + [ Infinity, Infinity ], + [ 625, 25 ], // larger perfect square + ]) { + test(`cxjs_sqrt(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sqrt(input), result); + }); + } + + // Extreme magnitudes stay finite. + for (const input of [ 1e308, Number.MAX_VALUE, 1e-200, Number.MIN_VALUE ]) + { + test(`cxjs_sqrt(${fmt(input)}) = Math.sqrt(${fmt(input)})`, () => + { + assert.equal(env.cxjs_sqrt(input), Math.sqrt(input)); + }); + } + + // Negative inputs and NaN have no real root and yield null. + for (const input of [ -1, -5.5, -Infinity, NaN ]) + { + test(`cxjs_sqrt(${fmt(input)}) = null`, () => + { + assert.equal(env.cxjs_sqrt(input), null); + }); + } + + // null and undefined yield null (no coercion). + for (const input of [ null, undefined ]) + { + test(`cxjs_sqrt(${fmt(input)}) = null`, () => + { + assert.equal(env.cxjs_sqrt(input), null); + }); + } + + // Non-number inputs are coerced to numbers. + // If they yield NaN, sqrt() returns null. + for (const [ input, result ] of [ + // Input Result + [ '4', 2 ], + [ '2.25', 1.5 ], + [ '-1', null ], // string coerces to -1, which has no real root + [ '', 0 ], // empty string coerces to 0 + [ 'foo', null ], // non-numeric string coerces to NaN + [ true, 1 ], + [ false, 0 ], + [ [], 0 ], // empty array coerces to 0 + [ [4], 2 ], // single-element array coerces to its element + [ [4, 9], null ], // multi-element array coerces to NaN + [ {}, null ], // object coerces to NaN + ]) { + test(`cxjs_sqrt(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sqrt(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_square.test.js b/centrallix-os/sys/js/tests/cxjs_square.test.js new file mode 100644 index 000000000..e139517af --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_square.test.js @@ -0,0 +1,95 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim (and -0 distinctly from 0) while otherwise +// matching JSON.stringify, so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_square', () => + { + for (const [ input, result ] of [ + // Input Result + [ 4, 16 ], + [ 3, 9 ], + [ 0.5, 0.25 ], + [ 1, 1 ], + [ 0, 0 ], + [ -0, +0 ], // (-0)^2 is +0 + [ -2, 4 ], // negatives square to positives + [ -1.5, 2.25 ], + [ Infinity, Infinity ], + [ -Infinity, Infinity ], + [ 1e200, Infinity ], // overflows to Infinity + [ Number.MAX_VALUE, Infinity ], // also overflows + [ 1e-200, 0 ], // underflows to 0 + [ Number.MIN_VALUE, 0 ], // also underflows + ]) { + test(`cxjs_square(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_square(input), result); + }); + } + + // null and undefined yield null (no coercion). + for (const input of [ null, undefined ]) + { + test(`cxjs_square(${fmt(input)}) = null`, () => + { + assert.equal(env.cxjs_square(input), null); + }); + } + + // Unlike sqrt(), square() does not filter NaN: NaN squares to NaN, not null. + test(`cxjs_square(${fmt(NaN)}) = ${fmt(NaN)}`, () => + { + assert.equal(env.cxjs_square(NaN), NaN); + }); + + // Non-number inputs are coerced to numbers; those coercing to NaN square + // to NaN (null is reserved for an explicit null/undefined input). + for (const [ input, result ] of [ + // Input Result + [ '4', 16 ], + [ '2.5', 6.25 ], + [ '-2', 4 ], // negative string coerces to -2, squares positive + [ '', 0 ], // empty string coerces to 0 + [ 'foo', NaN ], // non-numeric string coerces to NaN + [ true, 1 ], + [ false, 0 ], + [ [], 0 ], // empty array coerces to 0 + [ [4], 16 ], // single-element array coerces to its element + [ [4, 9], NaN ], // multi-element array coerces to NaN + [ {}, NaN ], // object coerces to NaN + ]) { + test(`cxjs_square(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_square(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_sum.test.js b/centrallix-os/sys/js/tests/cxjs_sum.test.js new file mode 100644 index 000000000..96e844705 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_sum.test.js @@ -0,0 +1,224 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null", omits undefined, and renders +// -0 as "0", which would make distinct edge-case rows share a test name; fmt +// renders those values verbatim, distinguishes -0 from 0, and otherwise matches +// JSON.stringify, so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_sum', () => + { + for (const [ input, result ] of [ + // Input Result + [ [], null ], + [ [0, 1], 1 ], + [ [0, 1, 2, 3, 4], 10 ], + [ [3, 1, 2, 4], 10 ], + [ [0.5, 0.25], 0.75 ], + [ [1.5, 2.5], 4 ], + [ [9, 10, 11], 30 ], + [ [-1, 0], -1 ], + [ [-1.5, 1], -0.5 ], + [ [-1, -2, -3], -6 ], + [ [-Infinity, 0], -Infinity ], + [ [Infinity, 0], Infinity ], + [ [Infinity, Infinity], Infinity ], + [ [-Infinity, -Infinity], -Infinity ], + [ [Infinity, -Infinity], NaN ], + [ [undefined], null ], + [ [undefined, 0], 0 ], + [ [null], null ], + [ [null, 5], 5 ], + [ [undefined, null, 3], 3 ], + [ [NaN], null ], + [ [NaN, 2], 2 ], + [ [1, 2, undefined, 3], 6 ], + ]) { + test(`cxjs_sum(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sum(input), result); + }); + } + + for (const [ input, result ] of [ + // Input Result + [ {}, null ], + [ { a: 0, b: 1 }, 1 ], + [ { c: 0, d: 1, e: 2, f: 3, g: 4 }, 10 ], + [ { h: 3, i: 1, j: 2, k: 4 }, 10 ], + [ { l: 0.5, m: 0.25 }, 0.75 ], + [ { n: 1.5, o: 2.5 }, 4 ], + [ { p: 9, q: 10, r: 11 }, 30 ], + [ { s: -1, t: 0 }, -1 ], + [ { u: -1.5, v: 1 }, -0.5 ], + [ { w: -1, x: -2, y: -3 }, -6 ], + [ { z: -Infinity, A: 0 }, -Infinity ], + [ { B: Infinity, C: 0 }, Infinity ], + [ { D: Infinity, E: Infinity }, Infinity ], + [ { F: -Infinity, G: -Infinity }, -Infinity ], + [ { H: Infinity, I: -Infinity }, NaN ], + [ { J: undefined }, null ], + [ { K: undefined, L: 0 }, 0 ], + [ { M: null }, null ], + [ { N: null, O: 5 }, 5 ], + [ { P: undefined, Q: null, R: 3 }, 3 ], + [ { S: NaN }, null ], + [ { T: NaN, U: 2 }, 2 ], + [ { V: 1, W: 2, X: undefined, Y: 3 }, 6 ], + ]) { + test(`cxjs_sum(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sum(input), result); + }); + } + + // Scalar (non-array, non-object) inputs hit. + for (const [ input, result ] of [ + // Input Result + [ 5, 5 ], + [ 0, 0 ], + [ -3.5, -3.5 ], + [ Infinity, Infinity ], + [ 'abc', 'abc' ], // returned as-is, not parsed + [ true, true ], // boolean pass through + [ false, false ], + [ NaN, NaN ], // scalar NaN is kept (cnt is 1, not 0) + [ null, null ], // typeof null is "object", but v !== null is false + [ undefined, undefined ], + ]) { + test(`cxjs_sum(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sum(input), result); + }); + } + + // String and boolean coercion. + for (const [ input, result ] of [ + // Input Result + [ ['5', 6], '056' ], // 0 + '5' -> '05', then '05' + 6 -> '056' + [ [1, '2'], '12' ], // 0 + 1 -> 1, then 1 + '2' -> '12' + [ [''], '0' ], // '' is numeric (0); 0 + '' -> '0' + [ ['', 5], '05' ], + [ [true], 1 ], // true coerces to 1 + [ [true, false], 1 ], // false coerces to 0 + [ [1, true], 2 ], + [ { a: '5', b: 6 }, '056' ], // same concatenation in object form + [ { a: true }, 1 ], + ]) { + test(`cxjs_sum(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sum(input), result); + }); + } + + // Order-dependent concatenation with type coercions. + for (const [ input, result ] of [ + // Input Result + [ [1, '2', 3], '123' ], // 0+1->1, 1+'2'->'12', '12'+3->'123' + [ ['a', 1, 2], 3 ], // 'a' skipped, then 0+1+2 + [ [1, 2, 'a'], 3 ], // 'a' skipped at the end + [ ['x', 'y'], null ], // all values are non-numeric + [ ['x', true, 'y'], 1 ], // wrapped coercion + [ [true, true, 1], 3 ], // booleans coerce to 1 and add numerically + ]) { + test(`cxjs_sum(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sum(input), result); + }); + } + + // All-null and all-NaN collections count as nothing. + for (const [ input, result ] of [ + // Input Result + [ [null, null], null ], + [ [NaN, NaN], null ], + [ { a: null, b: null }, null ], + [ { a: NaN, b: NaN }, null ], + ]) { + test(`cxjs_sum(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sum(input), result); + }); + } + + // Sign of zero and floating-point accumulation. + for (const [ input, result ] of [ + // Input Result + [ [-0], 0 ], // 0 + -0 -> +0 + [ [-0, -0], 0 ], + [ [-0, 0], 0 ], + [ [0.1, 0.2], 0.30000000000000004 ], // binary float rounding + [ [1e308, 1e308], Infinity ], // overflow to Infinity + ]) { + test(`cxjs_sum(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sum(input), result); + }); + } + + // Nested array behaviors. + for (const [ input, result ] of [ + // Input Result + [ [[1], 2], '012' ], // 0+[1]->'01', '01'+2->'012' + [ [[1], [2]], '012' ], // 0+[1]->'01', '01'+[2]->'012' + [ [[], 5], '05' ], // [] coerces to 0 but 0+[]->'0', '0'+5->'05' + [ [[1, 2], 3], 3 ], // [1,2] is NaN -> skipped, then 0+3 + ]) { + test(`cxjs_sum(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sum(input), result); + }); + } + + // Sparse arrays. + for (const [ input, result ] of [ + // Input Result + [ [1, , 3], 4 ], // hole between two values + [ [, , 5], 5 ], // two holes, one value + ]) { + test(`cxjs_sum(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_sum(input), result); + }); + } + + test('cxjs_sum(array with extra non-index prop) = 6', () => + { + const a = [1, 2, 3]; + a.foo = 99; // ignored: numeric-index loop only + assert.equal(env.cxjs_sum(a), 6); + }); + + // Value on the prototype is summed alongside other properties. + test('cxjs_sum(object with inherited enumerable prop) = 4', () => + { + function Proto() { this.a = 3; } + Proto.prototype.b = 1; + assert.equal(env.cxjs_sum(new Proto()), 4); + }); + }); diff --git a/centrallix-os/sys/js/tests/cxjs_truncate.test.js b/centrallix-os/sys/js/tests/cxjs_truncate.test.js new file mode 100644 index 000000000..707568c25 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_truncate.test.js @@ -0,0 +1,133 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify collapses NaN/Infinity to "null" and omits undefined, which +// would make distinct edge-case rows share a test name; fmt renders those +// values verbatim (and otherwise matches JSON.stringify) so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_truncate', () => + { + for (const [ args, result ] of [ + // Truncating to an integer (default dec): values truncate toward + // zero in both directions. + // Args Result + [ [2.4], 2 ], + [ [2.5], 2 ], + [ [2.9], 2 ], + [ [3], 3 ], + [ [0.9], 0 ], + [ [-2.4], -2 ], + [ [-2.5], -2 ], + [ [-2.9], -2 ], + [ [-3], -3 ], + + // Truncating to dec decimal places (digits past dec are dropped). + // Args Result + [ [3.14159, 2], 3.14 ], + [ [3.14159, 4], 3.1415 ], // not rounded up to 3.1416 + [ [-3.14159, 2], -3.14 ], + [ [2.349, 2], 2.34 ], // not rounded up to 2.35 + + // Negative dec truncates to tens, hundreds, thousands, etc. + // Args Result + [ [1234, -2], 1200 ], + [ [1299, -2], 1200 ], + [ [5678, -3], 5000 ], + + // A non-integer dec is rounded to the nearest integer before use. + // Args Result + [ [12.345, 1.4], 12.3 ], // dec -> 1 + [ [12.345, 1.5], 12.34 ], // dec -> 2 + [ [1234.5, -1.6], 1200 ], // dec -> -2 + + // null and undefined yield null (no coercion). + // Args Result + [ [null], null ], + [ [undefined], null ], + [ [null, 2], null ], + + // Special numeric values pass through. + // Args Result + [ [Infinity], Infinity ], + [ [Infinity, 2], Infinity ], + [ [-Infinity], -Infinity ], + [ [NaN], NaN ], + [ [5, 400], NaN ], // dec exceeds scaling factor + + // Sign of a zero: a negative input under 1 truncates to -0, while zero + // and positive inputs give +0. + // Args Result + [ [0], +0 ], + [ [0.1], +0 ], + [ [-0.1], -0 ], + [ [-0.9], -0 ], + [ [-0], -0 ], // -0 input passes through + [ [-Number.MIN_VALUE], -0 ], // tiny negative subnormal -> -0 + + // Float traps. + // Args Result + [ [1.005, 2], 1 ], // stored just under 1.005 -> 1.00 + [ [2.349999, 2], 2.34 ], + + // Precision is lost before truncation when n*factor exceeds 2^53, and a + // large n*factor can overflow to Infinity.. + // Args Result + [ [123456789012.3456, 2], 123456789012.34 ], + [ [Number.MAX_VALUE, 2], Infinity ], // n*100 overflows + [ [Number.MAX_VALUE], Number.MAX_VALUE ], + [ [Number.MIN_VALUE], 0 ], // smallest subnormal -> +0 + + // factor = 10**Math.round(dec) underflows to 0 for a large negative dec, + // so n*0 = 0 then 0/0 = NaN (the large positive dec case is covered above). + // Args Result + [ [5, -400], NaN ], // 10**-400 underflows to 0 + + // dec coercion. + // Args Result + [ [1.5, '2'], 1.5 ], // dec "2" -> 2, no change + [ [1.555, '2'], 1.55 ], // truncated, not rounded + [ [1.5, NaN], NaN ], + [ [1.5, Infinity], NaN ], + [ [1.5, -Infinity], NaN ], + + // n is coerced to a number before truncating (matching JS arithmetic). + // Args Result + [ ['2.9'], 2 ], + [ ['foo'], NaN ], // non-numeric string coerces to NaN + [ [''], 0 ], // empty string coerces to 0 + [ [true], 1 ], + [ [false], 0 ], + [ [[5]], 5 ], // single-element array coerces to its element + [ [{}], NaN ], // object coerces to NaN + ]) { + test(`cxjs_truncate(${args.map(fmt).join(', ')}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_truncate(...args), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_upper.test.js b/centrallix-os/sys/js/tests/cxjs_upper.test.js new file mode 100644 index 000000000..50cf77105 --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_upper.test.js @@ -0,0 +1,80 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify renders undefined as the bare word "undefined" only inside +// arrays; as a standalone value it yields undefined (not a string), and -0 +// renders as "0", which break/collide template names. fmt renders such values +// verbatim (and recurses into arrays/objects) so names stay unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('cxjs_upper', () => + { + for (const [ input, result ] of [ + // Input Result + [ 'abc', 'ABC' ], + [ 'ABC', 'ABC' ], + [ 'AbC', 'ABC' ], + [ '', '' ], + [ 'hello world', 'HELLO WORLD' ], + [ 'a1b2c3', 'A1B2C3' ], // digits pass through + [ '!@# $%^', '!@# $%^' ], // punctuation/space unchanged + [ '\tn\r', '\tN\r' ], // whitespace preserved + [ 'café', 'CAFÉ' ], // accented letter uppercases + [ 'ß', 'SS' ], // sharp-s expands to two chars + [ 'fi', 'FI' ], // fi ligature expands to two chars + [ '😀', '😀' ], // surrogate-pair emoji has no case + [ 'CAFÉ', 'CAFÉ' ], // already-upper accented stays + [ 'ı', 'I' ], // dotless i uppercases to ASCII I + [ 'µ', 'Μ' ], // micro sign -> Greek capital Mu + [ ' ', ' ' ], // whitespace-only unchanged + [ '12345', '12345' ], // digits-only unchanged + [ '😀abc', '😀ABC' ], // emoji kept, letters uppercased + + // Non-strings are String()-coerced first, then uppercased. Only strict + // null short-circuits to null; undefined does not. + [ null, null ], // sole short-circuit case + [ undefined, 'UNDEFINED' ], // not null, so coerced + [ 123, '123' ], + [ 1.5, '1.5' ], + [ true, 'TRUE' ], + [ false, 'FALSE' ], + [ NaN, 'NAN' ], + [ Infinity, 'INFINITY' ], + [ -Infinity, '-INFINITY' ], + [ [], '' ], // empty array coerces to '' + [ ['a', 'b'], 'A,B' ], // Joins with ',' and no spaces + [ ['abc'], 'ABC' ], // single-element array unwraps + [ 0, '0' ], // zero coerces to '0' + [ -0, '0' ], // negative zero stringifies to '0' + [ {}, '[OBJECT OBJECT]' ], // plain object stringifies + ]) { + test(`cxjs_upper(${fmt(input)}) = ${fmt(result)}`, () => + { + assert.equal(env.cxjs_upper(input), result); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/cxjs_user_name.test.js b/centrallix-os/sys/js/tests/cxjs_user_name.test.js new file mode 100644 index 000000000..09a44eb9d --- /dev/null +++ b/centrallix-os/sys/js/tests/cxjs_user_name.test.js @@ -0,0 +1,45 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, it, after } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +let sandbox_username = env.pg_username; + +describe('cxjs_user_name', () => + { + // pg_username is a shared sandbox global; save and restore + // the username to prevent affects on other suites. + after(() => { env.pg_username = sandbox_username; }); + + for (const [ label, value ] of [ + // Label Value + ['alice', 'alice' ], + ['bob', 'bob' ], + ['', '' ], + [' !@#$%^&*()":;\' ', ' !@#$%^&*()":;\' ' ], + ['null', null ], + ['undefined', undefined ], + ['number 42', 42 ], + ['number 0', 0 ], + ['false', false ], + ['array', ['a','b'] ], + ['object', { x: 1 } ], + ]) { + it(`returns pg_username (\"${label}\")`, () => + { + env.pg_username = value; + assert.equal(env.cxjs_user_name(), value); + }); + } + }); diff --git a/centrallix-os/sys/js/tests/htr_boolean.test.js b/centrallix-os/sys/js/tests/htr_boolean.test.js new file mode 100644 index 000000000..44d4bb8f2 --- /dev/null +++ b/centrallix-os/sys/js/tests/htr_boolean.test.js @@ -0,0 +1,108 @@ +// Copyright (C) 2026 LightSys Technology Services, Inc. +// +// You may use these files and this library under the terms of the +// GNU Lesser General Public License, Version 2.1, contained in the +// included file "COPYING" or http://www.gnu.org/licenses/lgpl.txt. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. + +'use strict'; +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const env = require('./_setup'); + +// JSON.stringify renders undefined/NaN as "null" and -0 as "0", which would +// collapse distinct edge-case rows into duplicate test names; fmt renders them +// verbatim (and recurses into arrays/objects) so each name stays unique. +function fmt(v) + { + if (Array.isArray(v)) + return '[' + v.map(fmt).join(',') + ']'; + if (v !== null && typeof v === 'object') + return '{' + Object.keys(v).map((k) => JSON.stringify(k) + ':' + fmt(v[k])).join(',') + '}'; + if (Object.is(v, -0)) + return '-0'; + if (typeof v === 'number' || v === undefined) + return String(v); + return JSON.stringify(v); + } + +describe('htr_boolean', () => + { + // False values. + for (const input of [ + null, + undefined, + 0, + -0, + false, + '', + ' ', // whitespace coerces to 0 + '\t', + '0', + '00', + '0.0', + 'no', 'No', 'NO', + 'false', 'False', 'FALSE', + 'off', 'Off', 'OFF', + // More numeric strings that == 0 (compared numerically, not as text). + '0.00', + '+0', + '-0', // the string '-0', distinct from the number -0 above + '0e0', + '0x0', // hex zero also == 0 + '\n', + '\r\n', + '\t\n ', // mixed whitespace coerces to 0 + // Arrays whose string coercion is empty or a single 0-valued element. + [], // [] -> '' -> 0 + [0], // [0] -> '0' -> == 0 + [''], // [''] -> '' -> 0 + ['0'], // ['0'] -> '0' -> == 0 + ]) { + test(`htr_boolean(${fmt(input)}) === false`, () => + { + assert.equal(env.htr_boolean(input), false); + }); + } + + // True values. + for (const input of [ + 1, + -1, + 0.5, + true, + NaN, + '1', + 'yes', + 'true', + 'on', + 'hello', + 'n', + 'nope', + 'no ', // trailing space defeats the 'no' match + ' no', + Infinity, + -Infinity, + {}, // object -> '[object Object]', != 0 + { a: 1 }, + [1], // single non-zero element -> '1', != 0 + [1, 2], // multi-element array -> '1,2', != 0 + [0, 0], // -> '0,0' which is != 0 numerically + ' false', // leading space defeats the 'false' match + 'false ', // trailing space defeats the 'false' match + 'offf', // not exactly 'off' + 'noo', // not exactly 'no' + 'null', // the text, not the value + 'undefined', + 'NaN', // String(NaN) lower-cases to 'nan', no match + ]) { + test(`htr_boolean(${fmt(input)}) === true`, () => + { + assert.equal(env.htr_boolean(input), true); + }); + } + }); diff --git a/centrallix/Makefile.in b/centrallix/Makefile.in index 7c7407f8d..f232ec09b 100644 --- a/centrallix/Makefile.in +++ b/centrallix/Makefile.in @@ -51,6 +51,7 @@ AWK = @AWK@ CC = @CC@ STRIP = @STRIP@ SED = @SED@ +NODE = @NODE@ INSTALL = @INSTALL@ INSTALL_PROGRAM = @INSTALL_PROGRAM@ INSTALL_SCRIPT = @INSTALL_SCRIPT@ @@ -413,6 +414,8 @@ V3LSOBJS=$(V3BASEOBJS) $(HTDRIVERS) $(NETDRIVERS) $(WGTRDRIVERS) .PHONY: manpages_install .PHONY: test_install .PHONY: test +.PHONY: test-js +.PHONY: test-js-coverage all: centrallix mods config manpages cxpasswd test_obj linksign @@ -582,6 +585,30 @@ test: tests/centrallix.conf-test $(TOTESTDEPS) $(TOTESTFILES) $(CBTESTFILES) fi \ done +test-js: + @NODE_BIN="$(NODE)"; \ + [ -n "$$NODE_BIN" ] || NODE_BIN=$$(command -v node); \ + if [ -z "$$NODE_BIN" ]; then \ + echo "Error: node not found (not detected by configure, not on PATH)."; \ + echo "Install Node.js (or load it in nvm with 'nvm use'), then try again."; \ + exit 1; \ + fi; \ + "$$NODE_BIN" --test ../centrallix-os/sys/js/tests/*.test.js 2>&1 + +test-js-coverage: + @NODE_BIN="$(NODE)"; \ + [ -n "$$NODE_BIN" ] || NODE_BIN=$$(command -v node); \ + if [ -z "$$NODE_BIN" ]; then \ + echo "Error: node not found (not detected by configure, not on PATH)."; \ + echo "Install Node.js (or load it in nvm with 'nvm use'), then try again."; \ + exit 1; \ + fi; \ + cd .. && \ + "$$NODE_BIN" --test --experimental-test-coverage \ + --test-reporter=lcov --test-reporter-destination=lcov.info \ + --test-reporter=spec --test-reporter-destination=stdout \ + centrallix-os/sys/js/tests/*.test.js + sfeditor/sfedit.o: sfeditor/sfedit.c sfeditor/*.xpm $(CC) $(CFLAGS) `gtk-config --cflags` $< -c -o $@ diff --git a/centrallix/configure b/centrallix/configure index adf0045a8..edb3e3a28 100755 --- a/centrallix/configure +++ b/centrallix/configure @@ -677,6 +677,7 @@ EXPORT_DYNAMIC INSTALL_DATA INSTALL_SCRIPT INSTALL_PROGRAM +NODE SED STRIP GZIP @@ -3427,6 +3428,46 @@ $as_echo "no" >&6; } fi +# Extract the first word of "node", so it can be a program name with args. +set dummy node; ac_word=$2 +{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5 +$as_echo_n "checking for $ac_word... " >&6; } +if ${ac_cv_path_NODE+:} false; then : + $as_echo_n "(cached) " >&6 +else + case $NODE in + [\\/]* | ?:[\\/]*) + ac_cv_path_NODE="$NODE" # Let the user override the test with a path. + ;; + *) + as_save_IFS=$IFS; IFS=$PATH_SEPARATOR +for as_dir in $PATH +do + IFS=$as_save_IFS + test -z "$as_dir" && as_dir=. + for ac_exec_ext in '' $ac_executable_extensions; do + if as_fn_executable_p "$as_dir/$ac_word$ac_exec_ext"; then + ac_cv_path_NODE="$as_dir/$ac_word$ac_exec_ext" + $as_echo "$as_me:${as_lineno-$LINENO}: found $as_dir/$ac_word$ac_exec_ext" >&5 + break 2 + fi +done + done +IFS=$as_save_IFS + + ;; +esac +fi +NODE=$ac_cv_path_NODE +if test -n "$NODE"; then + { $as_echo "$as_me:${as_lineno-$LINENO}: result: $NODE" >&5 +$as_echo "$NODE" >&6; } +else + { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5 +$as_echo "no" >&6; } +fi + + ac_aux_dir= for ac_dir in "$srcdir" "$srcdir/.." "$srcdir/../.."; do if test -f "$ac_dir/install-sh"; then diff --git a/centrallix/configure.ac b/centrallix/configure.ac index c96525983..36b0e0841 100644 --- a/centrallix/configure.ac +++ b/centrallix/configure.ac @@ -14,6 +14,7 @@ AC_PROG_AWK AC_PATH_PROG([GZIP], [gzip], []) AC_PATH_PROG([STRIP], [strip], []) AC_PATH_PROG([SED], [sed], []) +AC_PATH_PROG([NODE], [node], []) AC_PROG_INSTALL AC_CACHE_SAVE