From d6e0d04b2fdd9342a4dc013b7cb697bb91918d4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 01:30:08 +0000 Subject: [PATCH 1/3] Initial plan From f0310331d2bc2c7094b005e8a92f7e0cdb475faf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 01:41:49 +0000 Subject: [PATCH 2/3] Add backend core modules, API routes, and tests Co-authored-by: MaxonT <173550318+MaxonT@users.noreply.github.com> --- HumanLiker0.5/backend/jest.config.json | 11 + HumanLiker0.5/backend/package.json | 5 +- HumanLiker0.5/backend/src/core/ByteSize.js | 138 +++++++++++++ .../backend/src/core/CollectionHumanizer.js | 39 ++++ .../backend/src/core/DateTimeHumanizer.js | 78 ++++++++ .../backend/src/core/MetricNumeral.js | 86 ++++++++ .../backend/src/core/NumberToWords.js | 188 ++++++++++++++++++ HumanLiker0.5/backend/src/core/Ordinalize.js | 55 +++++ .../backend/src/core/RomanNumeral.js | 98 +++++++++ .../backend/src/core/TimeSpanHumanizer.js | 77 +++++++ HumanLiker0.5/backend/src/routes/bytesize.js | 41 ++++ .../backend/src/routes/collection.js | 40 ++++ HumanLiker0.5/backend/src/routes/datetime.js | 40 ++++ HumanLiker0.5/backend/src/routes/duration.js | 40 ++++ HumanLiker0.5/backend/src/routes/number.js | 73 +++++++ HumanLiker0.5/backend/src/server.js | 10 + .../backend/tests/core/bytesize.test.js | 80 ++++++++ .../backend/tests/core/collection.test.js | 42 ++++ .../backend/tests/core/datetime.test.js | 52 +++++ .../backend/tests/core/metricNumeral.test.js | 77 +++++++ .../backend/tests/core/numberToWords.test.js | 68 +++++++ .../backend/tests/core/ordinalize.test.js | 60 ++++++ .../backend/tests/core/romanNumeral.test.js | 79 ++++++++ .../backend/tests/core/timespan.test.js | 60 ++++++ 24 files changed, 1536 insertions(+), 1 deletion(-) create mode 100644 HumanLiker0.5/backend/jest.config.json create mode 100644 HumanLiker0.5/backend/src/core/ByteSize.js create mode 100644 HumanLiker0.5/backend/src/core/CollectionHumanizer.js create mode 100644 HumanLiker0.5/backend/src/core/DateTimeHumanizer.js create mode 100644 HumanLiker0.5/backend/src/core/MetricNumeral.js create mode 100644 HumanLiker0.5/backend/src/core/NumberToWords.js create mode 100644 HumanLiker0.5/backend/src/core/Ordinalize.js create mode 100644 HumanLiker0.5/backend/src/core/RomanNumeral.js create mode 100644 HumanLiker0.5/backend/src/core/TimeSpanHumanizer.js create mode 100644 HumanLiker0.5/backend/src/routes/bytesize.js create mode 100644 HumanLiker0.5/backend/src/routes/collection.js create mode 100644 HumanLiker0.5/backend/src/routes/datetime.js create mode 100644 HumanLiker0.5/backend/src/routes/duration.js create mode 100644 HumanLiker0.5/backend/src/routes/number.js create mode 100644 HumanLiker0.5/backend/tests/core/bytesize.test.js create mode 100644 HumanLiker0.5/backend/tests/core/collection.test.js create mode 100644 HumanLiker0.5/backend/tests/core/datetime.test.js create mode 100644 HumanLiker0.5/backend/tests/core/metricNumeral.test.js create mode 100644 HumanLiker0.5/backend/tests/core/numberToWords.test.js create mode 100644 HumanLiker0.5/backend/tests/core/ordinalize.test.js create mode 100644 HumanLiker0.5/backend/tests/core/romanNumeral.test.js create mode 100644 HumanLiker0.5/backend/tests/core/timespan.test.js diff --git a/HumanLiker0.5/backend/jest.config.json b/HumanLiker0.5/backend/jest.config.json new file mode 100644 index 0000000..7196e53 --- /dev/null +++ b/HumanLiker0.5/backend/jest.config.json @@ -0,0 +1,11 @@ +{ + "testEnvironment": "node", + "testMatch": [ + "**/tests/**/*.test.js" + ], + "collectCoverageFrom": [ + "src/core/**/*.js" + ], + "transform": {}, + "extensionsToTreatAsEsm": [".js"] +} diff --git a/HumanLiker0.5/backend/package.json b/HumanLiker0.5/backend/package.json index 88826e0..1a05915 100644 --- a/HumanLiker0.5/backend/package.json +++ b/HumanLiker0.5/backend/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node src/server.js", "dev": "node --watch src/server.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "NODE_OPTIONS=--experimental-vm-modules jest" }, "keywords": [ "humanliker", @@ -28,6 +28,9 @@ "express-rate-limit": "^7.1.5", "uuid": "^9.0.1" }, + "devDependencies": { + "jest": "^29.7.0" + }, "engines": { "node": ">=18.0.0 <=20.x" } diff --git a/HumanLiker0.5/backend/src/core/ByteSize.js b/HumanLiker0.5/backend/src/core/ByteSize.js new file mode 100644 index 0000000..ddb5448 --- /dev/null +++ b/HumanLiker0.5/backend/src/core/ByteSize.js @@ -0,0 +1,138 @@ +/** + * ByteSize - Represent and humanize byte sizes + * Example: 1024 bytes -> "1 KB" + */ + +class ByteSize { + /** + * Create a ByteSize instance + * @param {number} bytes - Number of bytes + */ + constructor(bytes) { + if (typeof bytes !== 'number' || isNaN(bytes) || bytes < 0) { + throw new TypeError('Bytes must be a non-negative number'); + } + this.bytes = Math.floor(bytes); + } + + /** + * Create ByteSize from bytes + * @param {number} bytes - Number of bytes + * @returns {ByteSize} ByteSize instance + */ + static fromBytes(bytes) { + return new ByteSize(bytes); + } + + /** + * Create ByteSize from kilobytes + * @param {number} kilobytes - Number of kilobytes + * @returns {ByteSize} ByteSize instance + */ + static fromKilobytes(kilobytes) { + return new ByteSize(kilobytes * 1024); + } + + /** + * Create ByteSize from megabytes + * @param {number} megabytes - Number of megabytes + * @returns {ByteSize} ByteSize instance + */ + static fromMegabytes(megabytes) { + return new ByteSize(megabytes * 1024 * 1024); + } + + /** + * Create ByteSize from gigabytes + * @param {number} gigabytes - Number of gigabytes + * @returns {ByteSize} ByteSize instance + */ + static fromGigabytes(gigabytes) { + return new ByteSize(gigabytes * 1024 * 1024 * 1024); + } + + /** + * Create ByteSize from terabytes + * @param {number} terabytes - Number of terabytes + * @returns {ByteSize} ByteSize instance + */ + static fromTerabytes(terabytes) { + return new ByteSize(terabytes * 1024 * 1024 * 1024 * 1024); + } + + /** + * Get kilobytes + * @returns {number} Size in kilobytes + */ + get kilobytes() { + return this.bytes / 1024; + } + + /** + * Get megabytes + * @returns {number} Size in megabytes + */ + get megabytes() { + return this.bytes / (1024 * 1024); + } + + /** + * Get gigabytes + * @returns {number} Size in gigabytes + */ + get gigabytes() { + return this.bytes / (1024 * 1024 * 1024); + } + + /** + * Get terabytes + * @returns {number} Size in terabytes + */ + get terabytes() { + return this.bytes / (1024 * 1024 * 1024 * 1024); + } + + /** + * Humanize the byte size + * @param {number} [precision=2] - Number of decimal places + * @returns {string} Human-readable size (e.g., "1.5 MB") + */ + humanize(precision = 2) { + if (typeof precision !== 'number' || precision < 0) { + precision = 2; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let size = this.bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + const formatted = unitIndex === 0 ? size.toString() : size.toFixed(precision); + return `${formatted} ${units[unitIndex]}`; + } + + /** + * Convert to string + * @returns {string} String representation + */ + toString() { + return this.humanize(); + } + + /** + * Convert to JSON + * @returns {object} JSON representation + */ + toJSON() { + return { + bytes: this.bytes, + humanized: this.humanize() + }; + } +} + +export default ByteSize; diff --git a/HumanLiker0.5/backend/src/core/CollectionHumanizer.js b/HumanLiker0.5/backend/src/core/CollectionHumanizer.js new file mode 100644 index 0000000..c8576e6 --- /dev/null +++ b/HumanLiker0.5/backend/src/core/CollectionHumanizer.js @@ -0,0 +1,39 @@ +/** + * CollectionHumanizer - Format arrays into human-readable strings + * Example: ["apple", "banana", "cherry"] -> "apple, banana and cherry" + */ + +/** + * Humanize an array into a readable string + * @param {Array} array - The array to humanize + * @param {string} [separator=", "] - The separator between items + * @param {string} [lastSeparator=" and "] - The separator before the last item + * @returns {string} The humanized string + */ +function humanize(array, separator = ', ', lastSeparator = ' and ') { + if (!Array.isArray(array)) { + throw new TypeError('Input must be an array'); + } + + if (array.length === 0) { + return ''; + } + + if (array.length === 1) { + return String(array[0]); + } + + if (array.length === 2) { + return `${array[0]}${lastSeparator}${array[1]}`; + } + + // For 3 or more items + const allButLast = array.slice(0, -1); + const last = array[array.length - 1]; + + return allButLast.join(separator) + lastSeparator + last; +} + +export default { + humanize +}; diff --git a/HumanLiker0.5/backend/src/core/DateTimeHumanizer.js b/HumanLiker0.5/backend/src/core/DateTimeHumanizer.js new file mode 100644 index 0000000..5573dca --- /dev/null +++ b/HumanLiker0.5/backend/src/core/DateTimeHumanizer.js @@ -0,0 +1,78 @@ +/** + * DateTimeHumanizer - Convert dates to human-readable relative time strings + * Example: "2 hours ago", "in 3 days" + */ + +/** + * Humanize a date relative to now + * @param {Date|string|number} date - The date to humanize + * @param {Date|string|number} [now=new Date()] - The reference date (defaults to current time) + * @returns {string} Human-readable time difference + */ +function humanize(date, now = new Date()) { + // Parse input dates + const targetDate = parseDate(date); + const referenceDate = parseDate(now); + + if (!targetDate || !referenceDate) { + throw new TypeError('Invalid date provided'); + } + + // Calculate difference in milliseconds + const diffMs = targetDate - referenceDate; + const absDiffMs = Math.abs(diffMs); + const isPast = diffMs < 0; + + // Define time units in milliseconds + const units = [ + { name: 'year', ms: 365 * 24 * 60 * 60 * 1000 }, + { name: 'month', ms: 30 * 24 * 60 * 60 * 1000 }, + { name: 'week', ms: 7 * 24 * 60 * 60 * 1000 }, + { name: 'day', ms: 24 * 60 * 60 * 1000 }, + { name: 'hour', ms: 60 * 60 * 1000 }, + { name: 'minute', ms: 60 * 1000 }, + { name: 'second', ms: 1000 } + ]; + + // Find the appropriate unit + for (const unit of units) { + const value = Math.floor(absDiffMs / unit.ms); + + if (value >= 1) { + const plural = value > 1 ? 's' : ''; + const timeStr = `${value} ${unit.name}${plural}`; + + return isPast ? `${timeStr} ago` : `in ${timeStr}`; + } + } + + // If less than a second + return 'just now'; +} + +/** + * Parse various date formats into Date object + * @param {Date|string|number} input - The date input + * @returns {Date|null} Parsed Date object or null if invalid + */ +function parseDate(input) { + if (input instanceof Date) { + return isNaN(input.getTime()) ? null : input; + } + + if (typeof input === 'number') { + const date = new Date(input); + return isNaN(date.getTime()) ? null : date; + } + + if (typeof input === 'string') { + const date = new Date(input); + return isNaN(date.getTime()) ? null : date; + } + + return null; +} + +export default { + humanize +}; diff --git a/HumanLiker0.5/backend/src/core/MetricNumeral.js b/HumanLiker0.5/backend/src/core/MetricNumeral.js new file mode 100644 index 0000000..eb1972f --- /dev/null +++ b/HumanLiker0.5/backend/src/core/MetricNumeral.js @@ -0,0 +1,86 @@ +/** + * MetricNumeral - Convert between standard numbers and metric notation + * Example: 1000 -> 1k, 1000000 -> 1M + */ + +/** + * Convert a number to metric notation + * @param {number} num - The number to convert + * @param {number} [precision=1] - Number of decimal places + * @returns {string} The metric notation (e.g., "1.5k", "2.3M") + */ +function toMetric(num, precision = 1) { + if (typeof num !== 'number' || isNaN(num)) { + throw new TypeError('Input must be a valid number'); + } + + if (typeof precision !== 'number' || precision < 0) { + precision = 1; + } + + const absNum = Math.abs(num); + const sign = num < 0 ? '-' : ''; + + const metricPrefixes = [ + { value: 1e12, symbol: 'T' }, // Tera + { value: 1e9, symbol: 'G' }, // Giga + { value: 1e6, symbol: 'M' }, // Mega + { value: 1e3, symbol: 'k' } // Kilo + ]; + + for (const { value, symbol } of metricPrefixes) { + if (absNum >= value) { + const converted = num / value; + return sign + converted.toFixed(precision) + symbol; + } + } + + // Return as-is if less than 1000 + return num.toFixed(precision); +} + +/** + * Convert metric notation to standard number + * @param {string} metricString - The metric notation string (e.g., "1.5k", "2M") + * @returns {number} The standard number + */ +function fromMetric(metricString) { + if (typeof metricString !== 'string') { + throw new TypeError('Input must be a string'); + } + + const trimmed = metricString.trim(); + + if (!trimmed) { + throw new Error('Input string cannot be empty'); + } + + const metricSuffixes = { + 'k': 1e3, // Kilo + 'K': 1e3, + 'M': 1e6, // Mega + 'G': 1e9, // Giga + 'T': 1e12 // Tera + }; + + // Match number with optional metric suffix + const match = trimmed.match(/^(-?\d+(?:\.\d+)?)\s*([kKMGT]?)$/); + + if (!match) { + throw new Error('Invalid metric format'); + } + + const numValue = parseFloat(match[1]); + const suffix = match[2]; + + if (suffix && metricSuffixes[suffix]) { + return numValue * metricSuffixes[suffix]; + } + + return numValue; +} + +export default { + toMetric, + fromMetric +}; diff --git a/HumanLiker0.5/backend/src/core/NumberToWords.js b/HumanLiker0.5/backend/src/core/NumberToWords.js new file mode 100644 index 0000000..b832711 --- /dev/null +++ b/HumanLiker0.5/backend/src/core/NumberToWords.js @@ -0,0 +1,188 @@ +/** + * NumberToWords - Convert numbers to their word representation + * Example: 123 -> "one hundred twenty-three" + */ + +const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; +const teens = ['ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']; +const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']; +const scales = ['', 'thousand', 'million', 'billion', 'trillion']; + +/** + * Convert a number (0-999) to words + * @param {number} num - Number between 0-999 + * @returns {string} Word representation + */ +function convertHundreds(num) { + let result = ''; + + const hundred = Math.floor(num / 100); + const remainder = num % 100; + const ten = Math.floor(remainder / 10); + const one = remainder % 10; + + if (hundred > 0) { + result += ones[hundred] + ' hundred'; + if (remainder > 0) { + result += ' '; + } + } + + if (remainder >= 10 && remainder < 20) { + result += teens[remainder - 10]; + } else { + if (ten > 0) { + result += tens[ten]; + if (one > 0) { + result += '-'; + } + } + if (one > 0) { + result += ones[one]; + } + } + + return result; +} + +/** + * Convert a number to words + * @param {number} number - The number to convert + * @param {boolean} [addAnd=false] - Whether to add "and" before tens (British style) + * @returns {string} The word representation + */ +function convert(number, addAnd = false) { + if (typeof number !== 'number' || isNaN(number)) { + throw new TypeError('Input must be a valid number'); + } + + // Handle special cases + if (number === 0) { + return 'zero'; + } + + if (number < 0) { + return 'negative ' + convert(Math.abs(number), addAnd); + } + + // Handle decimals + if (!Number.isInteger(number)) { + const parts = number.toString().split('.'); + const intPart = parseInt(parts[0]); + const decPart = parts[1]; + + let result = convert(intPart, addAnd) + ' point'; + for (const digit of decPart) { + result += ' ' + ones[parseInt(digit)]; + } + return result; + } + + // Handle large numbers (up to trillions) + if (number >= 1e15) { + return number.toExponential(); + } + + let result = ''; + let scaleIndex = 0; + let remaining = number; + + while (remaining > 0) { + const chunk = remaining % 1000; + + if (chunk > 0) { + let chunkWords = convertHundreds(chunk); + + if (scales[scaleIndex]) { + chunkWords += ' ' + scales[scaleIndex]; + } + + if (result) { + // Add "and" for British style if applicable + if (addAnd && scaleIndex === 0 && chunk < 100 && number >= 100) { + result = chunkWords + ' and ' + result; + } else { + result = chunkWords + ' ' + result; + } + } else { + result = chunkWords; + } + } + + remaining = Math.floor(remaining / 1000); + scaleIndex++; + } + + return result.trim(); +} + +/** + * Convert a number to ordinal words + * @param {number} number - The number to convert + * @returns {string} The ordinal word representation (e.g., "first", "second") + */ +function convertToOrdinal(number) { + if (typeof number !== 'number' || isNaN(number)) { + throw new TypeError('Input must be a valid number'); + } + + if (!Number.isInteger(number) || number < 1) { + throw new RangeError('Ordinal conversion only supports positive integers'); + } + + const ordinalOnes = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth']; + const ordinalTeens = ['tenth', 'eleventh', 'twelfth', 'thirteenth', 'fourteenth', 'fifteenth', 'sixteenth', 'seventeenth', 'eighteenth', 'nineteenth']; + const ordinalTens = ['', '', 'twentieth', 'thirtieth', 'fortieth', 'fiftieth', 'sixtieth', 'seventieth', 'eightieth', 'ninetieth']; + + // Simple cases (1-19) + if (number < 10) { + return ordinalOnes[number]; + } + + if (number >= 10 && number < 20) { + return ordinalTeens[number - 10]; + } + + // For numbers >= 20, convert to cardinal and modify the last word + const cardinal = convert(number, false); + const words = cardinal.split(/[\s-]+/); + const lastWord = words[words.length - 1]; + + // Map last word to ordinal + const ordinalMap = { + 'one': 'first', + 'two': 'second', + 'three': 'third', + 'four': 'fourth', + 'five': 'fifth', + 'six': 'sixth', + 'seven': 'seventh', + 'eight': 'eighth', + 'nine': 'ninth', + 'ten': 'tenth', + 'eleven': 'eleventh', + 'twelve': 'twelfth', + 'twenty': 'twentieth', + 'thirty': 'thirtieth', + 'forty': 'fortieth', + 'fifty': 'fiftieth', + 'sixty': 'sixtieth', + 'seventy': 'seventieth', + 'eighty': 'eightieth', + 'ninety': 'ninetieth' + }; + + if (ordinalMap[lastWord]) { + words[words.length - 1] = ordinalMap[lastWord]; + } else { + // Default: add 'th' suffix + words[words.length - 1] = lastWord + 'th'; + } + + return words.join(' '); +} + +export default { + convert, + convertToOrdinal +}; diff --git a/HumanLiker0.5/backend/src/core/Ordinalize.js b/HumanLiker0.5/backend/src/core/Ordinalize.js new file mode 100644 index 0000000..3be4de3 --- /dev/null +++ b/HumanLiker0.5/backend/src/core/Ordinalize.js @@ -0,0 +1,55 @@ +/** + * Ordinalize - Convert numbers to ordinal form + * Example: 1 -> 1st, 2 -> 2nd, 3 -> 3rd, 4 -> 4th + */ + +/** + * Get the ordinal suffix for a number + * @param {number} number - The number to get suffix for + * @returns {string} The ordinal suffix (st, nd, rd, th) + */ +function getOrdinalSuffix(number) { + if (typeof number !== 'number' || isNaN(number)) { + throw new TypeError('Input must be a valid number'); + } + + const absNumber = Math.abs(Math.floor(number)); + const lastDigit = absNumber % 10; + const lastTwoDigits = absNumber % 100; + + // Special cases for 11, 12, 13 + if (lastTwoDigits >= 11 && lastTwoDigits <= 13) { + return 'th'; + } + + // Regular cases + switch (lastDigit) { + case 1: + return 'st'; + case 2: + return 'nd'; + case 3: + return 'rd'; + default: + return 'th'; + } +} + +/** + * Convert a number to its ordinal form + * @param {number} number - The number to ordinalize + * @returns {string} The ordinalized number (e.g., "1st", "2nd", "3rd") + */ +function convert(number) { + if (typeof number !== 'number' || isNaN(number)) { + throw new TypeError('Input must be a valid number'); + } + + const suffix = getOrdinalSuffix(number); + return `${number}${suffix}`; +} + +export default { + convert, + getOrdinalSuffix +}; diff --git a/HumanLiker0.5/backend/src/core/RomanNumeral.js b/HumanLiker0.5/backend/src/core/RomanNumeral.js new file mode 100644 index 0000000..17e1517 --- /dev/null +++ b/HumanLiker0.5/backend/src/core/RomanNumeral.js @@ -0,0 +1,98 @@ +/** + * RomanNumeral - Convert between decimal and Roman numerals + */ + +/** + * Convert a decimal number to Roman numeral + * @param {number} num - The decimal number (1-3999) + * @returns {string} The Roman numeral representation + */ +function toRoman(num) { + if (typeof num !== 'number' || isNaN(num)) { + throw new TypeError('Input must be a valid number'); + } + + if (num < 1 || num > 3999) { + throw new RangeError('Number must be between 1 and 3999'); + } + + const romanMap = [ + { value: 1000, symbol: 'M' }, + { value: 900, symbol: 'CM' }, + { value: 500, symbol: 'D' }, + { value: 400, symbol: 'CD' }, + { value: 100, symbol: 'C' }, + { value: 90, symbol: 'XC' }, + { value: 50, symbol: 'L' }, + { value: 40, symbol: 'XL' }, + { value: 10, symbol: 'X' }, + { value: 9, symbol: 'IX' }, + { value: 5, symbol: 'V' }, + { value: 4, symbol: 'IV' }, + { value: 1, symbol: 'I' } + ]; + + let result = ''; + let remaining = Math.floor(num); + + for (const { value, symbol } of romanMap) { + while (remaining >= value) { + result += symbol; + remaining -= value; + } + } + + return result; +} + +/** + * Convert a Roman numeral to decimal number + * @param {string} roman - The Roman numeral string + * @returns {number} The decimal number + */ +function fromRoman(roman) { + if (typeof roman !== 'string') { + throw new TypeError('Input must be a string'); + } + + const romanUpper = roman.toUpperCase().trim(); + + if (!romanUpper || !/^[MDCLXVI]+$/.test(romanUpper)) { + throw new Error('Invalid Roman numeral format'); + } + + const romanValues = { + 'I': 1, + 'V': 5, + 'X': 10, + 'L': 50, + 'C': 100, + 'D': 500, + 'M': 1000 + }; + + let result = 0; + let prevValue = 0; + + // Process from right to left + for (let i = romanUpper.length - 1; i >= 0; i--) { + const currentValue = romanValues[romanUpper[i]]; + + if (currentValue < prevValue) { + // Subtract if smaller than previous (e.g., IV = 4) + result -= currentValue; + } else { + // Add if equal or larger + result += currentValue; + } + + prevValue = currentValue; + } + + return result; +} + +export default { + toRoman, + fromRoman +}; diff --git a/HumanLiker0.5/backend/src/core/TimeSpanHumanizer.js b/HumanLiker0.5/backend/src/core/TimeSpanHumanizer.js new file mode 100644 index 0000000..97582af --- /dev/null +++ b/HumanLiker0.5/backend/src/core/TimeSpanHumanizer.js @@ -0,0 +1,77 @@ +/** + * TimeSpanHumanizer - Convert milliseconds to human-readable duration strings + * Example: 3661000 -> "1 hour, 1 minute, 1 second" + */ + +/** + * Humanize a time span in milliseconds + * @param {number} milliseconds - The time span in milliseconds + * @param {number} [precision=2] - Number of units to include (1-7) + * @returns {string} Human-readable duration + */ +function humanize(milliseconds, precision = 2) { + if (typeof milliseconds !== 'number' || isNaN(milliseconds)) { + throw new TypeError('Input must be a valid number'); + } + + if (milliseconds < 0) { + throw new RangeError('Milliseconds must be non-negative'); + } + + if (typeof precision !== 'number' || precision < 1 || precision > 7) { + precision = 2; + } + + // Handle zero + if (milliseconds === 0) { + return '0 milliseconds'; + } + + // Define time units + const units = [ + { name: 'day', ms: 24 * 60 * 60 * 1000 }, + { name: 'hour', ms: 60 * 60 * 1000 }, + { name: 'minute', ms: 60 * 1000 }, + { name: 'second', ms: 1000 }, + { name: 'millisecond', ms: 1 } + ]; + + const parts = []; + let remaining = Math.floor(milliseconds); + + for (const unit of units) { + if (remaining >= unit.ms) { + const value = Math.floor(remaining / unit.ms); + remaining = remaining % unit.ms; + + const plural = value > 1 ? 's' : ''; + parts.push(`${value} ${unit.name}${plural}`); + + // Stop if we've reached desired precision + if (parts.length >= precision) { + break; + } + } + } + + if (parts.length === 0) { + return '0 milliseconds'; + } + + // Join parts with commas and "and" before last item + if (parts.length === 1) { + return parts[0]; + } + + if (parts.length === 2) { + return `${parts[0]} and ${parts[1]}`; + } + + const allButLast = parts.slice(0, -1); + const last = parts[parts.length - 1]; + return allButLast.join(', ') + ', and ' + last; +} + +export default { + humanize +}; diff --git a/HumanLiker0.5/backend/src/routes/bytesize.js b/HumanLiker0.5/backend/src/routes/bytesize.js new file mode 100644 index 0000000..babb3ea --- /dev/null +++ b/HumanLiker0.5/backend/src/routes/bytesize.js @@ -0,0 +1,41 @@ +import express from 'express'; +import ByteSize from '../core/ByteSize.js'; +import { setupLogger } from '../utils/logger.js'; + +const logger = setupLogger(); +const router = express.Router(); + +/** + * POST /api/bytesize/humanize + * Humanize a byte size + */ +router.post('/humanize', (req, res, next) => { + try { + const { bytes, precision = 2 } = req.body; + + if (typeof bytes !== 'number') { + return res.status(400).json({ + success: false, + error: 'Bytes is required and must be a valid number' + }); + } + + const byteSize = ByteSize.fromBytes(bytes); + const result = byteSize.humanize(precision); + + logger.info('ByteSize humanize', { bytes, precision, result }); + + res.json({ + success: true, + output: result + }); + } catch (error) { + logger.error('ByteSize humanize error', { error: error.message }); + res.status(400).json({ + success: false, + error: error.message + }); + } +}); + +export default router; diff --git a/HumanLiker0.5/backend/src/routes/collection.js b/HumanLiker0.5/backend/src/routes/collection.js new file mode 100644 index 0000000..51dab43 --- /dev/null +++ b/HumanLiker0.5/backend/src/routes/collection.js @@ -0,0 +1,40 @@ +import express from 'express'; +import CollectionHumanizer from '../core/CollectionHumanizer.js'; +import { setupLogger } from '../utils/logger.js'; + +const logger = setupLogger(); +const router = express.Router(); + +/** + * POST /api/collection/humanize + * Humanize an array into a readable string + */ +router.post('/humanize', (req, res, next) => { + try { + const { array, separator = ', ', lastSeparator = ' and ' } = req.body; + + if (!Array.isArray(array)) { + return res.status(400).json({ + success: false, + error: 'Array is required and must be an array' + }); + } + + const result = CollectionHumanizer.humanize(array, separator, lastSeparator); + + logger.info('Collection humanize', { arrayLength: array.length, result }); + + res.json({ + success: true, + output: result + }); + } catch (error) { + logger.error('Collection humanize error', { error: error.message }); + res.status(400).json({ + success: false, + error: error.message + }); + } +}); + +export default router; diff --git a/HumanLiker0.5/backend/src/routes/datetime.js b/HumanLiker0.5/backend/src/routes/datetime.js new file mode 100644 index 0000000..5b3a742 --- /dev/null +++ b/HumanLiker0.5/backend/src/routes/datetime.js @@ -0,0 +1,40 @@ +import express from 'express'; +import DateTimeHumanizer from '../core/DateTimeHumanizer.js'; +import { setupLogger } from '../utils/logger.js'; + +const logger = setupLogger(); +const router = express.Router(); + +/** + * POST /api/datetime/humanize + * Humanize a date relative to now + */ +router.post('/humanize', (req, res, next) => { + try { + const { date, now } = req.body; + + if (!date) { + return res.status(400).json({ + success: false, + error: 'Date is required' + }); + } + + const result = DateTimeHumanizer.humanize(date, now); + + logger.info('DateTime humanize', { date, now, result }); + + res.json({ + success: true, + output: result + }); + } catch (error) { + logger.error('DateTime humanize error', { error: error.message }); + res.status(400).json({ + success: false, + error: error.message + }); + } +}); + +export default router; diff --git a/HumanLiker0.5/backend/src/routes/duration.js b/HumanLiker0.5/backend/src/routes/duration.js new file mode 100644 index 0000000..4cc7304 --- /dev/null +++ b/HumanLiker0.5/backend/src/routes/duration.js @@ -0,0 +1,40 @@ +import express from 'express'; +import TimeSpanHumanizer from '../core/TimeSpanHumanizer.js'; +import { setupLogger } from '../utils/logger.js'; + +const logger = setupLogger(); +const router = express.Router(); + +/** + * POST /api/duration/humanize + * Humanize a duration in milliseconds + */ +router.post('/humanize', (req, res, next) => { + try { + const { milliseconds, precision = 2 } = req.body; + + if (typeof milliseconds !== 'number') { + return res.status(400).json({ + success: false, + error: 'Milliseconds is required and must be a valid number' + }); + } + + const result = TimeSpanHumanizer.humanize(milliseconds, precision); + + logger.info('Duration humanize', { milliseconds, precision, result }); + + res.json({ + success: true, + output: result + }); + } catch (error) { + logger.error('Duration humanize error', { error: error.message }); + res.status(400).json({ + success: false, + error: error.message + }); + } +}); + +export default router; diff --git a/HumanLiker0.5/backend/src/routes/number.js b/HumanLiker0.5/backend/src/routes/number.js new file mode 100644 index 0000000..2275962 --- /dev/null +++ b/HumanLiker0.5/backend/src/routes/number.js @@ -0,0 +1,73 @@ +import express from 'express'; +import NumberToWords from '../core/NumberToWords.js'; +import Ordinalize from '../core/Ordinalize.js'; +import { setupLogger } from '../utils/logger.js'; + +const logger = setupLogger(); +const router = express.Router(); + +/** + * POST /api/number/towords + * Convert number to words + */ +router.post('/towords', (req, res, next) => { + try { + const { number, addAnd = false } = req.body; + + if (typeof number !== 'number') { + return res.status(400).json({ + success: false, + error: 'Number is required and must be a valid number' + }); + } + + const result = NumberToWords.convert(number, addAnd); + + logger.info('Number to words conversion', { number, result }); + + res.json({ + success: true, + output: result + }); + } catch (error) { + logger.error('Number to words error', { error: error.message }); + res.status(400).json({ + success: false, + error: error.message + }); + } +}); + +/** + * POST /api/number/ordinalize + * Convert number to ordinal form + */ +router.post('/ordinalize', (req, res, next) => { + try { + const { number } = req.body; + + if (typeof number !== 'number') { + return res.status(400).json({ + success: false, + error: 'Number is required and must be a valid number' + }); + } + + const result = Ordinalize.convert(number); + + logger.info('Number ordinalize', { number, result }); + + res.json({ + success: true, + output: result + }); + } catch (error) { + logger.error('Number ordinalize error', { error: error.message }); + res.status(400).json({ + success: false, + error: error.message + }); + } +}); + +export default router; diff --git a/HumanLiker0.5/backend/src/server.js b/HumanLiker0.5/backend/src/server.js index 8013f55..e74bb51 100644 --- a/HumanLiker0.5/backend/src/server.js +++ b/HumanLiker0.5/backend/src/server.js @@ -10,6 +10,11 @@ import sessionsRouter from './routes/sessions.js'; import analyticsRouter from './routes/analytics.js'; import presetsRouter from './routes/presets.js'; import modelsRouter from './routes/models.js'; +import numberRouter from './routes/number.js'; +import datetimeRouter from './routes/datetime.js'; +import durationRouter from './routes/duration.js'; +import bytesizeRouter from './routes/bytesize.js'; +import collectionRouter from './routes/collection.js'; import { setupLogger } from './utils/logger.js'; import { initializeDatabase } from './index.js'; import { handleError } from './utils/errors.js'; @@ -67,6 +72,11 @@ app.use('/api/sessions', sessionsRouter); app.use('/api/analytics', analyticsRouter); app.use('/api/presets', presetsRouter); app.use('/api/models', modelsRouter); +app.use('/api/number', numberRouter); +app.use('/api/datetime', datetimeRouter); +app.use('/api/duration', durationRouter); +app.use('/api/bytesize', bytesizeRouter); +app.use('/api/collection', collectionRouter); // Root endpoint app.get('/', (req, res) => { diff --git a/HumanLiker0.5/backend/tests/core/bytesize.test.js b/HumanLiker0.5/backend/tests/core/bytesize.test.js new file mode 100644 index 0000000..040303b --- /dev/null +++ b/HumanLiker0.5/backend/tests/core/bytesize.test.js @@ -0,0 +1,80 @@ +import ByteSize from '../../src/core/ByteSize.js'; + +describe('ByteSize', () => { + describe('constructor', () => { + test('creates ByteSize from bytes', () => { + const bs = new ByteSize(1024); + expect(bs.bytes).toBe(1024); + }); + + test('throws error for negative bytes', () => { + expect(() => new ByteSize(-1)).toThrow(TypeError); + }); + + test('throws error for non-number', () => { + expect(() => new ByteSize('abc')).toThrow(TypeError); + }); + }); + + describe('static factory methods', () => { + test('fromBytes', () => { + const bs = ByteSize.fromBytes(1024); + expect(bs.bytes).toBe(1024); + }); + + test('fromKilobytes', () => { + const bs = ByteSize.fromKilobytes(1); + expect(bs.bytes).toBe(1024); + }); + + test('fromMegabytes', () => { + const bs = ByteSize.fromMegabytes(1); + expect(bs.bytes).toBe(1024 * 1024); + }); + + test('fromGigabytes', () => { + const bs = ByteSize.fromGigabytes(1); + expect(bs.bytes).toBe(1024 * 1024 * 1024); + }); + }); + + describe('getters', () => { + test('kilobytes getter', () => { + const bs = new ByteSize(2048); + expect(bs.kilobytes).toBe(2); + }); + + test('megabytes getter', () => { + const bs = new ByteSize(1024 * 1024 * 5); + expect(bs.megabytes).toBe(5); + }); + }); + + describe('humanize', () => { + test('humanizes bytes', () => { + const bs = new ByteSize(500); + expect(bs.humanize()).toBe('500 B'); + }); + + test('humanizes kilobytes', () => { + const bs = new ByteSize(1024); + expect(bs.humanize()).toBe('1.00 KB'); + }); + + test('humanizes megabytes', () => { + const bs = new ByteSize(1024 * 1024 * 1.5); + expect(bs.humanize()).toBe('1.50 MB'); + }); + + test('humanizes gigabytes', () => { + const bs = new ByteSize(1024 * 1024 * 1024 * 2.5); + expect(bs.humanize()).toBe('2.50 GB'); + }); + + test('respects precision', () => { + const bs = new ByteSize(1024 * 1.567); + expect(bs.humanize(1)).toBe('1.6 KB'); + expect(bs.humanize(3)).toBe('1.567 KB'); + }); + }); +}); diff --git a/HumanLiker0.5/backend/tests/core/collection.test.js b/HumanLiker0.5/backend/tests/core/collection.test.js new file mode 100644 index 0000000..9c6c02b --- /dev/null +++ b/HumanLiker0.5/backend/tests/core/collection.test.js @@ -0,0 +1,42 @@ +import CollectionHumanizer from '../../src/core/CollectionHumanizer.js'; + +describe('CollectionHumanizer', () => { + describe('humanize', () => { + test('returns empty string for empty array', () => { + expect(CollectionHumanizer.humanize([])).toBe(''); + }); + + test('returns single item as string', () => { + expect(CollectionHumanizer.humanize(['apple'])).toBe('apple'); + }); + + test('joins two items with "and"', () => { + expect(CollectionHumanizer.humanize(['apple', 'banana'])).toBe('apple and banana'); + }); + + test('joins three items with commas and "and"', () => { + expect(CollectionHumanizer.humanize(['apple', 'banana', 'cherry'])) + .toBe('apple, banana and cherry'); + }); + + test('joins multiple items', () => { + expect(CollectionHumanizer.humanize(['a', 'b', 'c', 'd'])) + .toBe('a, b, c and d'); + }); + + test('uses custom separator', () => { + expect(CollectionHumanizer.humanize(['a', 'b', 'c'], '; ')) + .toBe('a; b and c'); + }); + + test('uses custom last separator', () => { + expect(CollectionHumanizer.humanize(['a', 'b', 'c'], ', ', ' or ')) + .toBe('a, b or c'); + }); + + test('throws error for non-array input', () => { + expect(() => CollectionHumanizer.humanize('not an array')).toThrow(TypeError); + expect(() => CollectionHumanizer.humanize(123)).toThrow(TypeError); + }); + }); +}); diff --git a/HumanLiker0.5/backend/tests/core/datetime.test.js b/HumanLiker0.5/backend/tests/core/datetime.test.js new file mode 100644 index 0000000..d70af4f --- /dev/null +++ b/HumanLiker0.5/backend/tests/core/datetime.test.js @@ -0,0 +1,52 @@ +import DateTimeHumanizer from '../../src/core/DateTimeHumanizer.js'; + +describe('DateTimeHumanizer', () => { + describe('humanize', () => { + test('returns "just now" for very recent dates', () => { + const now = new Date(); + const almostNow = new Date(now.getTime() - 500); + expect(DateTimeHumanizer.humanize(almostNow, now)).toBe('just now'); + }); + + test('returns seconds ago for recent past', () => { + const now = new Date(); + const fiveSecondsAgo = new Date(now.getTime() - 5000); + expect(DateTimeHumanizer.humanize(fiveSecondsAgo, now)).toBe('5 seconds ago'); + }); + + test('returns minutes ago', () => { + const now = new Date(); + const twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000); + expect(DateTimeHumanizer.humanize(twoMinutesAgo, now)).toBe('2 minutes ago'); + }); + + test('returns hours ago', () => { + const now = new Date(); + const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000); + expect(DateTimeHumanizer.humanize(threeHoursAgo, now)).toBe('3 hours ago'); + }); + + test('returns days ago', () => { + const now = new Date(); + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + expect(DateTimeHumanizer.humanize(twoDaysAgo, now)).toBe('2 days ago'); + }); + + test('returns future times', () => { + const now = new Date(); + const inTwoHours = new Date(now.getTime() + 2 * 60 * 60 * 1000); + expect(DateTimeHumanizer.humanize(inTwoHours, now)).toBe('in 2 hours'); + }); + + test('handles string dates', () => { + const now = new Date('2024-01-15T12:00:00Z'); + const past = '2024-01-15T10:00:00Z'; + expect(DateTimeHumanizer.humanize(past, now)).toBe('2 hours ago'); + }); + + test('throws error for invalid dates', () => { + expect(() => DateTimeHumanizer.humanize('invalid')).toThrow(TypeError); + expect(() => DateTimeHumanizer.humanize(null)).toThrow(TypeError); + }); + }); +}); diff --git a/HumanLiker0.5/backend/tests/core/metricNumeral.test.js b/HumanLiker0.5/backend/tests/core/metricNumeral.test.js new file mode 100644 index 0000000..ba263f2 --- /dev/null +++ b/HumanLiker0.5/backend/tests/core/metricNumeral.test.js @@ -0,0 +1,77 @@ +import MetricNumeral from '../../src/core/MetricNumeral.js'; + +describe('MetricNumeral', () => { + describe('toMetric', () => { + test('converts numbers to metric notation', () => { + expect(MetricNumeral.toMetric(1000)).toBe('1.0k'); + expect(MetricNumeral.toMetric(1500)).toBe('1.5k'); + expect(MetricNumeral.toMetric(1000000)).toBe('1.0M'); + expect(MetricNumeral.toMetric(1500000)).toBe('1.5M'); + }); + + test('converts billions and trillions', () => { + expect(MetricNumeral.toMetric(1000000000)).toBe('1.0G'); + expect(MetricNumeral.toMetric(1000000000000)).toBe('1.0T'); + }); + + test('returns small numbers as-is', () => { + expect(MetricNumeral.toMetric(500)).toBe('500.0'); + expect(MetricNumeral.toMetric(999)).toBe('999.0'); + }); + + test('respects precision', () => { + expect(MetricNumeral.toMetric(1234, 0)).toBe('1k'); + expect(MetricNumeral.toMetric(1234, 2)).toBe('1.23k'); + expect(MetricNumeral.toMetric(1234, 3)).toBe('1.234k'); + }); + + test('handles negative numbers', () => { + expect(MetricNumeral.toMetric(-1000)).toBe('-1.0k'); + expect(MetricNumeral.toMetric(-1500000)).toBe('-1.5M'); + }); + + test('throws error for invalid input', () => { + expect(() => MetricNumeral.toMetric('abc')).toThrow(TypeError); + expect(() => MetricNumeral.toMetric(NaN)).toThrow(TypeError); + }); + }); + + describe('fromMetric', () => { + test('converts metric notation to numbers', () => { + expect(MetricNumeral.fromMetric('1k')).toBe(1000); + expect(MetricNumeral.fromMetric('1.5k')).toBe(1500); + expect(MetricNumeral.fromMetric('1M')).toBe(1000000); + expect(MetricNumeral.fromMetric('1.5M')).toBe(1500000); + }); + + test('handles case insensitivity for k', () => { + expect(MetricNumeral.fromMetric('1K')).toBe(1000); + }); + + test('converts billions and trillions', () => { + expect(MetricNumeral.fromMetric('1G')).toBe(1000000000); + expect(MetricNumeral.fromMetric('1T')).toBe(1000000000000); + }); + + test('handles numbers without suffix', () => { + expect(MetricNumeral.fromMetric('500')).toBe(500); + expect(MetricNumeral.fromMetric('1.5')).toBe(1.5); + }); + + test('handles negative numbers', () => { + expect(MetricNumeral.fromMetric('-1k')).toBe(-1000); + expect(MetricNumeral.fromMetric('-1.5M')).toBe(-1500000); + }); + + test('handles whitespace', () => { + expect(MetricNumeral.fromMetric('1 k')).toBe(1000); + expect(MetricNumeral.fromMetric(' 1.5M ')).toBe(1500000); + }); + + test('throws error for invalid format', () => { + expect(() => MetricNumeral.fromMetric('abc')).toThrow(Error); + expect(() => MetricNumeral.fromMetric('')).toThrow(Error); + expect(() => MetricNumeral.fromMetric(123)).toThrow(TypeError); + }); + }); +}); diff --git a/HumanLiker0.5/backend/tests/core/numberToWords.test.js b/HumanLiker0.5/backend/tests/core/numberToWords.test.js new file mode 100644 index 0000000..953ce8d --- /dev/null +++ b/HumanLiker0.5/backend/tests/core/numberToWords.test.js @@ -0,0 +1,68 @@ +import NumberToWords from '../../src/core/NumberToWords.js'; + +describe('NumberToWords', () => { + describe('convert', () => { + test('converts 0 to words', () => { + expect(NumberToWords.convert(0)).toBe('zero'); + }); + + test('converts single digit numbers', () => { + expect(NumberToWords.convert(1)).toBe('one'); + expect(NumberToWords.convert(5)).toBe('five'); + expect(NumberToWords.convert(9)).toBe('nine'); + }); + + test('converts teen numbers', () => { + expect(NumberToWords.convert(10)).toBe('ten'); + expect(NumberToWords.convert(13)).toBe('thirteen'); + expect(NumberToWords.convert(19)).toBe('nineteen'); + }); + + test('converts two digit numbers', () => { + expect(NumberToWords.convert(20)).toBe('twenty'); + expect(NumberToWords.convert(25)).toBe('twenty-five'); + expect(NumberToWords.convert(99)).toBe('ninety-nine'); + }); + + test('converts hundreds', () => { + expect(NumberToWords.convert(100)).toBe('one hundred'); + expect(NumberToWords.convert(123)).toBe('one hundred twenty-three'); + expect(NumberToWords.convert(999)).toBe('nine hundred ninety-nine'); + }); + + test('converts thousands', () => { + expect(NumberToWords.convert(1000)).toBe('one thousand'); + expect(NumberToWords.convert(1234)).toBe('one thousand two hundred thirty-four'); + }); + + test('converts negative numbers', () => { + expect(NumberToWords.convert(-5)).toBe('negative five'); + expect(NumberToWords.convert(-123)).toBe('negative one hundred twenty-three'); + }); + + test('throws error for invalid input', () => { + expect(() => NumberToWords.convert('abc')).toThrow(TypeError); + expect(() => NumberToWords.convert(NaN)).toThrow(TypeError); + }); + }); + + describe('convertToOrdinal', () => { + test('converts numbers to ordinal words', () => { + expect(NumberToWords.convertToOrdinal(1)).toBe('first'); + expect(NumberToWords.convertToOrdinal(2)).toBe('second'); + expect(NumberToWords.convertToOrdinal(3)).toBe('third'); + expect(NumberToWords.convertToOrdinal(4)).toBe('fourth'); + }); + + test('converts larger ordinals', () => { + expect(NumberToWords.convertToOrdinal(21)).toBe('twenty first'); + expect(NumberToWords.convertToOrdinal(100)).toBe('one hundredth'); + }); + + test('throws error for invalid input', () => { + expect(() => NumberToWords.convertToOrdinal(-1)).toThrow(RangeError); + expect(() => NumberToWords.convertToOrdinal(0)).toThrow(RangeError); + expect(() => NumberToWords.convertToOrdinal(1.5)).toThrow(RangeError); + }); + }); +}); diff --git a/HumanLiker0.5/backend/tests/core/ordinalize.test.js b/HumanLiker0.5/backend/tests/core/ordinalize.test.js new file mode 100644 index 0000000..c290e0b --- /dev/null +++ b/HumanLiker0.5/backend/tests/core/ordinalize.test.js @@ -0,0 +1,60 @@ +import Ordinalize from '../../src/core/Ordinalize.js'; + +describe('Ordinalize', () => { + describe('getOrdinalSuffix', () => { + test('returns correct suffix for numbers ending in 1', () => { + expect(Ordinalize.getOrdinalSuffix(1)).toBe('st'); + expect(Ordinalize.getOrdinalSuffix(21)).toBe('st'); + expect(Ordinalize.getOrdinalSuffix(101)).toBe('st'); + }); + + test('returns correct suffix for numbers ending in 2', () => { + expect(Ordinalize.getOrdinalSuffix(2)).toBe('nd'); + expect(Ordinalize.getOrdinalSuffix(22)).toBe('nd'); + expect(Ordinalize.getOrdinalSuffix(102)).toBe('nd'); + }); + + test('returns correct suffix for numbers ending in 3', () => { + expect(Ordinalize.getOrdinalSuffix(3)).toBe('rd'); + expect(Ordinalize.getOrdinalSuffix(23)).toBe('rd'); + expect(Ordinalize.getOrdinalSuffix(103)).toBe('rd'); + }); + + test('returns "th" for 11, 12, 13', () => { + expect(Ordinalize.getOrdinalSuffix(11)).toBe('th'); + expect(Ordinalize.getOrdinalSuffix(12)).toBe('th'); + expect(Ordinalize.getOrdinalSuffix(13)).toBe('th'); + expect(Ordinalize.getOrdinalSuffix(111)).toBe('th'); + expect(Ordinalize.getOrdinalSuffix(112)).toBe('th'); + expect(Ordinalize.getOrdinalSuffix(113)).toBe('th'); + }); + + test('returns "th" for other numbers', () => { + expect(Ordinalize.getOrdinalSuffix(4)).toBe('th'); + expect(Ordinalize.getOrdinalSuffix(10)).toBe('th'); + expect(Ordinalize.getOrdinalSuffix(100)).toBe('th'); + }); + + test('throws error for invalid input', () => { + expect(() => Ordinalize.getOrdinalSuffix('abc')).toThrow(TypeError); + expect(() => Ordinalize.getOrdinalSuffix(NaN)).toThrow(TypeError); + }); + }); + + describe('convert', () => { + test('converts numbers to ordinal strings', () => { + expect(Ordinalize.convert(1)).toBe('1st'); + expect(Ordinalize.convert(2)).toBe('2nd'); + expect(Ordinalize.convert(3)).toBe('3rd'); + expect(Ordinalize.convert(4)).toBe('4th'); + expect(Ordinalize.convert(11)).toBe('11th'); + expect(Ordinalize.convert(21)).toBe('21st'); + expect(Ordinalize.convert(100)).toBe('100th'); + }); + + test('throws error for invalid input', () => { + expect(() => Ordinalize.convert('abc')).toThrow(TypeError); + expect(() => Ordinalize.convert(NaN)).toThrow(TypeError); + }); + }); +}); diff --git a/HumanLiker0.5/backend/tests/core/romanNumeral.test.js b/HumanLiker0.5/backend/tests/core/romanNumeral.test.js new file mode 100644 index 0000000..02a5340 --- /dev/null +++ b/HumanLiker0.5/backend/tests/core/romanNumeral.test.js @@ -0,0 +1,79 @@ +import RomanNumeral from '../../src/core/RomanNumeral.js'; + +describe('RomanNumeral', () => { + describe('toRoman', () => { + test('converts single digit numbers', () => { + expect(RomanNumeral.toRoman(1)).toBe('I'); + expect(RomanNumeral.toRoman(5)).toBe('V'); + expect(RomanNumeral.toRoman(9)).toBe('IX'); + }); + + test('converts two digit numbers', () => { + expect(RomanNumeral.toRoman(10)).toBe('X'); + expect(RomanNumeral.toRoman(40)).toBe('XL'); + expect(RomanNumeral.toRoman(50)).toBe('L'); + expect(RomanNumeral.toRoman(90)).toBe('XC'); + }); + + test('converts hundreds', () => { + expect(RomanNumeral.toRoman(100)).toBe('C'); + expect(RomanNumeral.toRoman(400)).toBe('CD'); + expect(RomanNumeral.toRoman(500)).toBe('D'); + expect(RomanNumeral.toRoman(900)).toBe('CM'); + }); + + test('converts thousands', () => { + expect(RomanNumeral.toRoman(1000)).toBe('M'); + expect(RomanNumeral.toRoman(3999)).toBe('MMMCMXCIX'); + }); + + test('converts complex numbers', () => { + expect(RomanNumeral.toRoman(1984)).toBe('MCMLXXXIV'); + expect(RomanNumeral.toRoman(2024)).toBe('MMXXIV'); + }); + + test('throws error for out of range', () => { + expect(() => RomanNumeral.toRoman(0)).toThrow(RangeError); + expect(() => RomanNumeral.toRoman(4000)).toThrow(RangeError); + }); + + test('throws error for invalid input', () => { + expect(() => RomanNumeral.toRoman('abc')).toThrow(TypeError); + expect(() => RomanNumeral.toRoman(NaN)).toThrow(TypeError); + }); + }); + + describe('fromRoman', () => { + test('converts single numerals', () => { + expect(RomanNumeral.fromRoman('I')).toBe(1); + expect(RomanNumeral.fromRoman('V')).toBe(5); + expect(RomanNumeral.fromRoman('X')).toBe(10); + }); + + test('converts subtractive notation', () => { + expect(RomanNumeral.fromRoman('IV')).toBe(4); + expect(RomanNumeral.fromRoman('IX')).toBe(9); + expect(RomanNumeral.fromRoman('XL')).toBe(40); + expect(RomanNumeral.fromRoman('XC')).toBe(90); + expect(RomanNumeral.fromRoman('CD')).toBe(400); + expect(RomanNumeral.fromRoman('CM')).toBe(900); + }); + + test('converts complex numerals', () => { + expect(RomanNumeral.fromRoman('MCMLXXXIV')).toBe(1984); + expect(RomanNumeral.fromRoman('MMXXIV')).toBe(2024); + expect(RomanNumeral.fromRoman('MMMCMXCIX')).toBe(3999); + }); + + test('handles lowercase', () => { + expect(RomanNumeral.fromRoman('mcmlxxxiv')).toBe(1984); + expect(RomanNumeral.fromRoman('mmxxiv')).toBe(2024); + }); + + test('throws error for invalid format', () => { + expect(() => RomanNumeral.fromRoman('ABC')).toThrow(Error); + expect(() => RomanNumeral.fromRoman('')).toThrow(Error); + expect(() => RomanNumeral.fromRoman(123)).toThrow(TypeError); + }); + }); +}); diff --git a/HumanLiker0.5/backend/tests/core/timespan.test.js b/HumanLiker0.5/backend/tests/core/timespan.test.js new file mode 100644 index 0000000..3e93310 --- /dev/null +++ b/HumanLiker0.5/backend/tests/core/timespan.test.js @@ -0,0 +1,60 @@ +import TimeSpanHumanizer from '../../src/core/TimeSpanHumanizer.js'; + +describe('TimeSpanHumanizer', () => { + describe('humanize', () => { + test('returns "0 milliseconds" for zero', () => { + expect(TimeSpanHumanizer.humanize(0)).toBe('0 milliseconds'); + }); + + test('humanizes milliseconds', () => { + expect(TimeSpanHumanizer.humanize(500)).toBe('500 milliseconds'); + }); + + test('humanizes seconds', () => { + expect(TimeSpanHumanizer.humanize(5000)).toBe('5 seconds'); + expect(TimeSpanHumanizer.humanize(1000)).toBe('1 second'); + }); + + test('humanizes minutes', () => { + expect(TimeSpanHumanizer.humanize(60000)).toBe('1 minute'); + expect(TimeSpanHumanizer.humanize(120000)).toBe('2 minutes'); + }); + + test('humanizes hours', () => { + expect(TimeSpanHumanizer.humanize(3600000)).toBe('1 hour'); + expect(TimeSpanHumanizer.humanize(7200000)).toBe('2 hours'); + }); + + test('humanizes days', () => { + expect(TimeSpanHumanizer.humanize(86400000)).toBe('1 day'); + expect(TimeSpanHumanizer.humanize(172800000)).toBe('2 days'); + }); + + test('combines multiple units with default precision', () => { + const oneHourOneMinute = 60 * 60 * 1000 + 60 * 1000; + expect(TimeSpanHumanizer.humanize(oneHourOneMinute)) + .toBe('1 hour and 1 minute'); + + const oneDayTwoHours = 24 * 60 * 60 * 1000 + 2 * 60 * 60 * 1000; + expect(TimeSpanHumanizer.humanize(oneDayTwoHours)) + .toBe('1 day and 2 hours'); + }); + + test('respects precision parameter', () => { + const complex = 2 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000 + 15 * 60 * 1000 + 30 * 1000; + + expect(TimeSpanHumanizer.humanize(complex, 1)).toBe('2 days'); + expect(TimeSpanHumanizer.humanize(complex, 2)).toBe('2 days and 3 hours'); + expect(TimeSpanHumanizer.humanize(complex, 3)).toBe('2 days, 3 hours, and 15 minutes'); + }); + + test('throws error for negative values', () => { + expect(() => TimeSpanHumanizer.humanize(-1000)).toThrow(RangeError); + }); + + test('throws error for invalid input', () => { + expect(() => TimeSpanHumanizer.humanize('abc')).toThrow(TypeError); + expect(() => TimeSpanHumanizer.humanize(NaN)).toThrow(TypeError); + }); + }); +}); From 6b6270ee559c2737c88d6bdf0a56ea64adfba079 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 01:43:20 +0000 Subject: [PATCH 3/3] Changes before error encountered Co-authored-by: MaxonT <173550318+MaxonT@users.noreply.github.com> --- HumanLiker0.5/backend/package.json | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/HumanLiker0.5/backend/package.json b/HumanLiker0.5/backend/package.json index 1a05915..2402d27 100644 --- a/HumanLiker0.5/backend/package.json +++ b/HumanLiker0.5/backend/package.json @@ -17,16 +17,16 @@ "author": "", "license": "ISC", "dependencies": { - "express": "^4.18.2", + "better-sqlite3": "^11.7.0", "cors": "^2.8.5", "dotenv": "^16.3.1", - "winston": "^3.11.0", - "drizzle-orm": "^0.29.0", - "better-sqlite3": "^11.7.0", "drizzle-kit": "^0.20.6", - "zod": "^3.22.4", + "drizzle-orm": "^0.29.0", + "express": "^4.18.2", "express-rate-limit": "^7.1.5", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "winston": "^3.11.0", + "zod": "^3.22.4" }, "devDependencies": { "jest": "^29.7.0" @@ -35,4 +35,3 @@ "node": ">=18.0.0 <=20.x" } } -