diff --git a/src/JavaScript/__init__.py b/src/JavaScript/__init__.py index 82dbb51..ce24ecf 100644 --- a/src/JavaScript/__init__.py +++ b/src/JavaScript/__init__.py @@ -1,7 +1,12 @@ """JavaScript-style objects, methods and functions for Python.""" from ._array import Array +from ._json import JSON +from ._math import Math +from ._number import Boolean, Number, parseFloat, parseInt from ._object import Object +from ._string import String +from fetch_api import FetchError, Headers, Promise, Request, Response, fetch from ._values import ( Infinity, NaN, @@ -15,7 +20,8 @@ ) __all__ = [ - "Array", "Object", + "Array", "Boolean", "FetchError", "Headers", "JSON", "Math", "Number", + "Object", "Promise", "Request", "Response", "String", "fetch", "parseFloat", "parseInt", "Infinity", "NaN", "isFinite", "isNaN", "null", "strictEqual", "truthy", "typeof", "undefined", ] diff --git a/src/JavaScript/_json.py b/src/JavaScript/_json.py new file mode 100644 index 0000000..f0c8c88 --- /dev/null +++ b/src/JavaScript/_json.py @@ -0,0 +1,114 @@ +"""Adapt the standalone JSON parser to runtime values and serialization rules.""" + +from __future__ import annotations + +import json as _json +import math +from collections.abc import Mapping + +import JSON as _standalone + +from ._array import Array +from ._object import Object, own_entries +from ._string import String +from ._values import is_number, number_string, to_number, to_string, undefined + + +def _runtime(value): + if isinstance(value, dict): + return Object({key: _runtime(child) for key, child in dict.items(value)}) + if isinstance(value, list): + return Array.of(*(_runtime(child) for child in value)) + if isinstance(value, str): + return String(value) + return to_number(value) if is_number(value) else value + + +def _quote(value): + # Delegate escaping to the companion, preserving normal Unicode while + # escaping lone surrogates as required by well-formed JSON.stringify. + value = value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass") + quoted = _standalone.stringify(value, ensure_ascii=False) + return "".join(f"\\u{ord(char):04x}" if 0xD800 <= ord(char) <= 0xDFFF else char for char in quoted) + + +class _JSON: + JSONDecodeError = _standalone.JSONDecodeError + + @staticmethod + def parse(text): + """Parse JSON after JavaScript string conversion; no reviver overload.""" + if isinstance(text, (bytes, bytearray)): + raise TypeError("JavaScript.JSON.parse expects text, not bytes") + def reject_constant(value): + raise ValueError(f"{value} is not a valid JSON numeric constant") + + # The standalone parser preserves Python integers. Select binary64 + # decoder hooks here so nested -0 and large integers retain JS semantics. + value = _json.loads( + to_string(text), parse_int=float, parse_float=float, + parse_constant=reject_constant, + ) + return _runtime(value) + + @staticmethod + def stringify(value=undefined, replacer=undefined, space=undefined): + """Serialize supported values; replacers and toJSON hooks are not supported.""" + if replacer is not undefined and replacer is not None: + raise NotImplementedError("JSON replacers are not supported") + if is_number(space): + number = to_number(space) + gap = " " * (0 if math.isnan(number) else int(max(0, min(number, 10)))) + elif isinstance(space, str): + gap = String(space).slice(0, 10).valueOf() + else: + gap = "" + active = set() + + def encode(child, depth=0): + if child is undefined or callable(child): + return undefined + if child is None: + return "null" + if isinstance(child, bool): + return "true" if child else "false" + if isinstance(child, str): + return _quote(child) + if is_number(child): + return number_string(child) if math.isfinite(to_number(child)) else "null" + if not isinstance(child, (Object, Array, Mapping, list, tuple)): + raise TypeError(f"Unsupported JSON value: {type(child).__name__}") + if isinstance(child, (Object, Mapping)): + if any(key == "toJSON" and callable(item) for key, item in own_entries(child)): + raise NotImplementedError("JSON toJSON hooks are not supported") + identity = id(child) + if identity in active: + raise TypeError("Converting circular structure to JSON") + active.add(identity) + try: + array = isinstance(child, (Array, list, tuple)) + parts = [] + if array: + for item in child: + text = encode(item, depth + 1) + parts.append("null" if text is undefined else text) + else: + for key, item in own_entries(child): + text = encode(item, depth + 1) + if text is not undefined: + parts.append(_quote(key) + (": " if gap else ":") + text) + left, right = ("[", "]") if array else ("{", "}") + if not parts: + return left + right + if not gap: + return left + ",".join(parts) + right + prefix = gap * (depth + 1) + return left + "\n" + prefix + (",\n" + prefix).join(parts) + "\n" + gap * depth + right + finally: + active.remove(identity) + + result = encode(value) + return result if result is undefined else String(result) + + +JSON = _JSON() diff --git a/src/JavaScript/_math.py b/src/JavaScript/_math.py new file mode 100644 index 0000000..2744f66 --- /dev/null +++ b/src/JavaScript/_math.py @@ -0,0 +1,106 @@ +"""Selected Math functions with ECMAScript numeric edge behavior.""" + +from __future__ import annotations + +import math +import random + +from ._values import to_number, undefined + + +class Math: + PI = math.pi + E = math.e + LN2 = math.log(2) + LN10 = math.log(10) + LOG2E = math.log2(math.e) + LOG10E = math.log10(math.e) + SQRT1_2 = math.sqrt(0.5) + SQRT2 = math.sqrt(2) + + @staticmethod + def abs(value=undefined): + return abs(to_number(value)) + + @staticmethod + def floor(value=undefined): + number = to_number(value) + return float(math.floor(number)) if math.isfinite(number) and number != 0 else number + + @staticmethod + def ceil(value=undefined): + number = to_number(value) + if not math.isfinite(number) or number == 0: + return number + return -0.0 if -1 < number < 0 else float(math.ceil(number)) + + @staticmethod + def trunc(value=undefined): + number = to_number(value) + if not math.isfinite(number) or number == 0: + return number + return math.copysign(float(math.trunc(number)), number) + + @staticmethod + def round(value=undefined): + number = to_number(value) + if not math.isfinite(number) or number == 0: + return number + if -0.5 <= number < 0: + return -0.0 + lower = math.floor(number) + return float(lower if number - lower < 0.5 else lower + 1) + + @staticmethod + def sign(value=undefined): + number = to_number(value) + return number if math.isnan(number) or number == 0 else math.copysign(1.0, number) + + @staticmethod + def min(*values): + result = math.inf + for value in values: + number = to_number(value) + if math.isnan(number): + return math.nan + if number < result or (number == result == 0 and math.copysign(1, number) < 0): + result = number + return result + + @staticmethod + def max(*values): + result = -math.inf + for value in values: + number = to_number(value) + if math.isnan(number): + return math.nan + if number > result or (number == result == 0 and math.copysign(1, number) > 0): + result = number + return result + + @staticmethod + def sqrt(value=undefined): + number = to_number(value) + return math.nan if number < 0 else math.sqrt(number) + + @staticmethod + def pow(base=undefined, exponent=undefined): + base, exponent = to_number(base), to_number(exponent) + if exponent == 0: + return 1.0 + if math.isnan(exponent): + return math.nan + if abs(base) == 1 and math.isinf(exponent): + return math.nan + try: + return math.pow(base, exponent) + except OverflowError: + return -math.inf if base < 0 and exponent.is_integer() and exponent % 2 else math.inf + except ValueError: + if base == 0 and exponent < 0: + return -math.inf if math.copysign(1, base) < 0 and exponent.is_integer() and exponent % 2 else math.inf + return math.nan + + @staticmethod + def random(): + return random.random() diff --git a/src/JavaScript/_number.py b/src/JavaScript/_number.py new file mode 100644 index 0000000..c64d0d4 --- /dev/null +++ b/src/JavaScript/_number.py @@ -0,0 +1,90 @@ +"""Number conversion, predicates and prefix parsing.""" + +from __future__ import annotations + +import math +import re +import sys + +from ._values import WHITESPACE, is_number, number_string, to_number, to_string, truthy, undefined + + +class Number(float): + """Binary64 conversion with selected JavaScript Number static methods.""" + + EPSILON = 2**-52 + MAX_SAFE_INTEGER = 2**53 - 1 + MIN_SAFE_INTEGER = -(2**53 - 1) + MAX_VALUE = sys.float_info.max + MIN_VALUE = float.fromhex("0x0.0000000000001p-1022") + NaN = math.nan + POSITIVE_INFINITY = math.inf + NEGATIVE_INFINITY = -math.inf + + def __new__(cls, value=0): + return super().__new__(cls, to_number(value)) + + @staticmethod + def isNaN(value=undefined): + return is_number(value) and math.isnan(to_number(value)) + + @staticmethod + def isFinite(value=undefined): + return is_number(value) and math.isfinite(to_number(value)) + + @staticmethod + def isInteger(value=undefined): + return Number.isFinite(value) and to_number(value).is_integer() + + @staticmethod + def isSafeInteger(value=undefined): + return Number.isInteger(value) and abs(to_number(value)) <= Number.MAX_SAFE_INTEGER + + def toString(self): + from ._string import String + return String(number_string(self)) + + def valueOf(self): + return float(self) + + +def Boolean(value=undefined) -> bool: + return truthy(value) + + +def parseFloat(value=undefined): + text = to_string(value).lstrip(WHITESPACE) + match = re.match(r"[+-]?(?:Infinity|(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)", text) + return float(match[0]) if match else math.nan + + +def parseInt(value=undefined, radix=undefined): + from ._string import uint32 + text = to_string(value).lstrip(WHITESPACE) + sign = -1 if text.startswith("-") else 1 + if text.startswith(("-", "+")): + text = text[1:] + base = uint32(radix) + if base >= 2**31: + base -= 2**32 + if base != 0 and not 2 <= base <= 36: + return math.nan + if base in (0, 16) and text.startswith(("0x", "0X")): + text, base = text[2:], 16 + if base == 0: + base = 10 + digits = "0123456789abcdefghijklmnopqrstuvwxyz"[:base] + result = 0 + count = 0 + for char in text.lower(): + if char not in digits: + break + result = result * base + digits.index(char) + count += 1 + if not count: + return math.nan + return to_number(result) * sign + + +Number.parseFloat = staticmethod(parseFloat) +Number.parseInt = staticmethod(parseInt) diff --git a/src/JavaScript/_string.py b/src/JavaScript/_string.py new file mode 100644 index 0000000..5f74faa --- /dev/null +++ b/src/JavaScript/_string.py @@ -0,0 +1,170 @@ +"""Selected String methods using JavaScript UTF-16 code-unit indexing.""" + +from __future__ import annotations + +import math + +from ._array import Array +from ._values import WHITESPACE, integer, relative_index, to_number, to_string, undefined + + +def units(value: str) -> str: + data = value.encode("utf-16-le", "surrogatepass") + return "".join(chr(int.from_bytes(data[i:i + 2], "little")) for i in range(0, len(data), 2)) + + +def from_units(value: str) -> str: + return value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass") + + +def uint32(value): + number = to_number(value) + return math.trunc(number) % 2**32 if math.isfinite(number) and number else 0 + + +def clamp(value, length): + return max(0, min(integer(value), length)) + + +class String(str): + """A Python string exposing selected camelCase JavaScript String methods. + + JavaScript methods and bracket access use UTF-16 code units. Python's + inherited operators and methods otherwise retain their Python behavior. + """ + + def __new__(cls, value=""): + return super().__new__(cls, to_string(value)) + + @property + def length(self): + return len(self.encode("utf-16-le", "surrogatepass")) // 2 + + def __getitem__(self, key): + key = to_string(key) + if key == "length": + return self.length + if not key.isascii() or not key.isdecimal() or (len(key) > 1 and key.startswith("0")) or len(key) > 10: + return undefined + return self.at(int(key)) + + def __iter__(self): + return iter(from_units(units(self))) + + def at(self, index=undefined): + index = integer(index) + data = units(self) + if index < 0: + index += len(data) + return String(data[index]) if 0 <= index < len(data) else undefined + + def charAt(self, index=undefined): + index = integer(index) + data = units(self) + return String(data[index]) if 0 <= index < len(data) else String() + + def charCodeAt(self, index=undefined): + value = self.charAt(index) + return ord(value) if value else math.nan + + def codePointAt(self, index=undefined): + index = integer(index) + data = units(self) + if not 0 <= index < len(data): + return undefined + first = ord(data[index]) + if 0xD800 <= first <= 0xDBFF and index + 1 < len(data): + second = ord(data[index + 1]) + if 0xDC00 <= second <= 0xDFFF: + return (first - 0xD800) * 0x400 + second - 0xDC00 + 0x10000 + return first + + def slice(self, start=0, end=undefined): + data = units(self) + start = relative_index(start, len(data)) + end = len(data) if end is undefined else relative_index(end, len(data)) + return String(from_units(data[start:end])) + + def substring(self, start=0, end=undefined): + data = units(self) + start = clamp(start, len(data)) + end = len(data) if end is undefined else clamp(end, len(data)) + start, end = min(start, end), max(start, end) + return String(from_units(data[start:end])) + + def indexOf(self, searchString=undefined, position=0): + data = units(self) + return data.find(units(to_string(searchString)), clamp(position, len(data))) + + def lastIndexOf(self, searchString=undefined, position=undefined): + data, search = units(self), units(to_string(searchString)) + number = to_number(position) + start = len(data) if math.isnan(number) else clamp(number, len(data)) + return data.rfind(search, 0, min(start + len(search), len(data))) + + def includes(self, searchString=undefined, position=0): + return self.indexOf(searchString, position) >= 0 + + def startsWith(self, searchString=undefined, position=0): + data = units(self) + return data.startswith(units(to_string(searchString)), clamp(position, len(data))) + + def endsWith(self, searchString=undefined, endPosition=undefined): + data = units(self) + end = len(data) if endPosition is undefined else clamp(endPosition, len(data)) + return data[:end].endswith(units(to_string(searchString))) + + def split(self, separator=undefined, limit=undefined): + count = 2**32 - 1 if limit is undefined else uint32(limit) + if count == 0: + return Array() + if separator is undefined: + return Array.of(String(self)) + data, separator = units(self), units(to_string(separator)) + if separator == "": + return Array.of(*(String(char) for char in data[:count])) + return Array.of(*(String(from_units(part)) for part in data.split(separator, count)[:count])) + + def concat(self, *values): + return String(str(self) + "".join(to_string(value) for value in values)) + + def repeat(self, count=0): + count = integer(count) + if count < 0 or math.isinf(count): + raise ValueError("Invalid repeat count") + return String(str(self) * count) + + def trim(self): + return String(str(self).strip(WHITESPACE)) + + def trimStart(self): + return String(str(self).lstrip(WHITESPACE)) + + def trimEnd(self): + return String(str(self).rstrip(WHITESPACE)) + + def toLowerCase(self): + return String(str(self).lower()) + + def toUpperCase(self): + return String(str(self).upper()) + + def toString(self): + return self + + def valueOf(self): + return str(self) + + @staticmethod + def fromCharCode(*codes): + return String(from_units("".join(chr(uint32(code) % 2**16) for code in codes))) + + @staticmethod + def fromCodePoint(*codes): + values = [] + for code in codes: + number = to_number(code) + if not math.isfinite(number) or number != int(number) or not 0 <= number <= 0x10FFFF: + raise ValueError("Invalid code point") + values.append(chr(int(number))) + return String("".join(values)) diff --git a/src/JavaScript/_values.py b/src/JavaScript/_values.py index 8f1abd4..3b8ded2 100644 --- a/src/JavaScript/_values.py +++ b/src/JavaScript/_values.py @@ -141,7 +141,7 @@ def strictEqual(left, right) -> bool: if is_number(left) and is_number(right): return to_number(left) == to_number(right) if isinstance(left, str) and isinstance(right, str): - return str(left) == str(right) + return left.encode("utf-16-le", "surrogatepass") == right.encode("utf-16-le", "surrogatepass") return left is right diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..12a595a --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,26 @@ +import asyncio +import unittest + +import fetch_api + +from JavaScript import JSON, Array, FetchError, Headers, Promise, Request, Response, fetch + + +class CompanionTests(unittest.TestCase): + def test_fetch_and_promise_are_standalone_identities(self): + for value in (FetchError, Headers, Promise, Request, Response, fetch): + self.assertIs(value, getattr(fetch_api, value.__name__)) + + def test_runtime_transform_after_response_text(self): + async def run(): + response = Response('{"names": ["Ada", "Lin"]}') + value = JSON.parse(await response.text()) + self.assertEqual(list(value.names.map(lambda name: name.toUpperCase())), ["ADA", "LIN"]) + self.assertEqual(await Promise.resolve(value.names.length).then(lambda length: length + 1), 3) + asyncio.run(run()) + + def test_fetch_json_retains_standalone_contract(self): + value = Response('{"names": ["Ada"]}').json().result(timeout=2) + self.assertIsInstance(value, fetch_api.JSONObject) + self.assertIs(type(value.names), list) + self.assertFalse(Array.isArray(value.names)) diff --git a/tests/test_json.py b/tests/test_json.py new file mode 100644 index 0000000..a283b93 --- /dev/null +++ b/tests/test_json.py @@ -0,0 +1,62 @@ +import math +import unittest + +import JSON as standalone + +from JavaScript import Array, JSON, Number, Object, String, undefined + + +class JSONTests(unittest.TestCase): + def test_parse_wraps_runtime_values_and_rounds_binary64(self): + value = JSON.parse('{"items": [{"name": "Ada"}], "big": 9007199254740993, "null": null}') + self.assertIsInstance(value, Object) + self.assertIsInstance(value.items, Array) + self.assertIsInstance(value.items[0].name, String) + self.assertEqual(value.items[0].name.toUpperCase(), "ADA") + self.assertEqual(value.big, 2**53) + self.assertIsNone(value["null"]) + self.assertIs(value.absent, undefined) + self.assertIs(JSON.parse(True), True) + self.assertEqual(JSON.parse(12), 12) + self.assertEqual(standalone.parse("9007199254740993"), 2**53 + 1) + self.assertIs(type(standalone.parse("[]")), list) + self.assertEqual(math.copysign(1, JSON.parse('{"x": [-0]}').x[0]), -1) + + def test_parse_failure_and_no_byte_extension(self): + with self.assertRaises(JSON.JSONDecodeError): + JSON.parse("{") + with self.assertRaises(TypeError): + JSON.parse(b"{}") + + def test_undefined_functions_holes_and_non_finite_numbers(self): + value = Object({"missing": undefined, "fn": lambda: None, "null": None, "array": Array(1).concat(Array.of(undefined, math.nan, math.inf, -0.0))}) + self.assertEqual(JSON.stringify(value), '{"null":null,"array":[null,null,null,null,0]}') + self.assertIs(JSON.stringify(), undefined) + self.assertIs(JSON.stringify(lambda: None), undefined) + + def test_key_order_unicode_and_numeric_rendering(self): + value = Object({"b": "雪😀", "2": 1.0, "1": "\ud800", "number": 1e-7}) + self.assertEqual(JSON.stringify(value), '{"1":"\\ud800","2":1,"b":"雪😀","number":1e-7}') + self.assertEqual(JSON.stringify(1e20), "100000000000000000000") + self.assertEqual(JSON.stringify("\ud83d\ude00"), '"😀"') + + def test_space_and_roundtrip(self): + value = Object({"x": Array.of(1, Object({"y": True}))}) + text = JSON.stringify(value, None, 2) + self.assertEqual(text, '{\n "x": [\n 1,\n {\n "y": true\n }\n ]\n}') + self.assertEqual(JSON.stringify(JSON.parse(text)), '{"x":[1,{"y":true}]}') + self.assertEqual(JSON.stringify(Object(), None, 100), "{}") + + def test_cycles_shared_values_and_unsupported_overloads(self): + value = Object() + value.self = value + with self.assertRaisesRegex(TypeError, "circular"): + JSON.stringify(value) + shared = Object({"x": 1}) + self.assertEqual(JSON.stringify(Array.of(shared, shared)), '[{"x":1},{"x":1}]') + with self.assertRaises(NotImplementedError): + JSON.stringify({}, lambda key, value: value) + with self.assertRaises(NotImplementedError): + JSON.stringify(Object({"toJSON": lambda: 1})) + with self.assertRaises(TypeError): + JSON.stringify(object()) diff --git a/tests/test_primitives.py b/tests/test_primitives.py new file mode 100644 index 0000000..d5fbb8a --- /dev/null +++ b/tests/test_primitives.py @@ -0,0 +1,124 @@ +import math +import unittest + +from JavaScript import Array, Boolean, Math, Number, String, isFinite, parseFloat, parseInt, strictEqual, undefined + + +class StringTests(unittest.TestCase): + def test_conversion_and_utf16_indexing(self): + value = String("a😀b") + self.assertEqual(value.length, 4) + self.assertEqual(value[1], "\ud83d") + self.assertEqual(value.at(-2), "\ude00") + self.assertIs(value[-1], undefined) + self.assertIs(value["01"], undefined) + self.assertEqual(value.charAt(20), "") + self.assertTrue(math.isnan(value.charCodeAt(20))) + self.assertEqual(value.charCodeAt(1), 0xD83D) + self.assertEqual(value.codePointAt(1), 0x1F600) + self.assertEqual(value.codePointAt(2), 0xDE00) + self.assertIs(value.codePointAt(4), undefined) + self.assertEqual(list(value), ["a", "😀", "b"]) + self.assertEqual(list(Array.from_(value)), ["a", "😀", "b"]) + self.assertTrue(strictEqual("😀", "\ud83d\ude00")) + self.assertEqual(String(), "") + self.assertEqual(String(undefined), "undefined") + self.assertEqual(String(None), "null") + + def test_search_and_substrings_use_code_units(self): + value = String("a😀b😀") + self.assertEqual(value.indexOf("😀"), 1) + self.assertEqual(value.lastIndexOf("😀"), 4) + self.assertEqual(value.lastIndexOf("😀", 3), 1) + self.assertEqual(value.indexOf("", math.inf), 6) + self.assertTrue(value.startsWith("😀", 1)) + self.assertTrue(value.endsWith("b", 4)) + self.assertTrue(value.includes("\ude00")) + self.assertEqual(value.slice(1, 3), "😀") + self.assertEqual(value.slice(-2), "😀") + self.assertEqual(value.substring(3, 1), "😀") + self.assertEqual(value.substring(-5, 1), "a") + + def test_split_and_constructor_helpers(self): + self.assertEqual(list(String("😀").split("")), ["\ud83d", "\ude00"]) + self.assertEqual(list(String("a,b,c").split(",", 2)), ["a", "b"]) + self.assertEqual(list(String("a,").split(",")), ["a", ""]) + self.assertEqual(list(String("").split("")), []) + self.assertEqual(list(String("ab").split()), ["ab"]) + self.assertEqual(list(String("ab").split("", 2**32)), []) + self.assertEqual(String.fromCharCode(0xD83D, 0xDE00), "😀") + self.assertEqual(String.fromCodePoint(0x1F600), "😀") + self.assertEqual(String.fromCharCode(65537), "\x01") + with self.assertRaises(ValueError): + String.fromCodePoint(1.5) + + def test_trim_case_concat_and_repeat(self): + self.assertEqual(String("\ufeff hi \n").trim(), "hi") + self.assertEqual(String("\x85hi\x85").trim(), "\x85hi\x85") + self.assertEqual(String("AbC").toLowerCase().toUpperCase(), "ABC") + self.assertEqual(String("x").concat(1, None, True), "x1nulltrue") + self.assertEqual(String("ab").repeat(2.8), "abab") + with self.assertRaises(ValueError): + String("a").repeat(-1) + + +class NumberTests(unittest.TestCase): + def test_conversion_and_noncoercing_predicates(self): + self.assertEqual(Number(), 0) + self.assertEqual(Number("0xff"), 255) + self.assertTrue(math.isnan(Number(undefined))) + self.assertEqual(Number(2**53 + 1), 2**53) + self.assertFalse(Number.isNaN("bad")) + self.assertTrue(Number.isNaN(math.nan)) + self.assertTrue(isFinite("1")) + self.assertFalse(Number.isFinite("1")) + self.assertFalse(Number.isInteger(True)) + self.assertTrue(Number.isInteger(3.0)) + self.assertFalse(Number.isSafeInteger(2**53)) + self.assertEqual(Number(1e-6).toString(), "0.000001") + self.assertEqual(Number.MIN_VALUE, 5e-324) + self.assertTrue(Boolean([])) + self.assertFalse(Boolean(math.nan)) + + def test_prefix_parsers(self): + for value, expected in [("12px", 12), ("0xff", 255), ("010", 10), ("1e3", 1), ("-0", -0.0)]: + self.assertEqual(parseInt(value), expected) + self.assertEqual(parseInt("ffz", 16), 255) + self.assertEqual(Number.parseInt("11", 2), 3) + self.assertTrue(math.isnan(parseInt("12", 1))) + self.assertTrue(math.isnan(parseInt("0x"))) + self.assertEqual(math.copysign(1, parseInt("-0")), -1) + self.assertEqual(parseFloat("-1.2e2px"), -120) + self.assertEqual(parseFloat("1e+"), 1) + self.assertEqual(parseFloat("0xff"), 0) + self.assertEqual(Number.parseFloat("Infinity!"), math.inf) + self.assertTrue(math.isnan(parseFloat("bad"))) + + +class MathTests(unittest.TestCase): + def test_rounding_and_negative_zero(self): + self.assertEqual(Math.round(2.5), 3) + self.assertEqual(Math.round(-1.5), -1) + self.assertEqual(Math.round(0.49999999999999994), 0) + for value in (Math.round(-0.5), Math.ceil(-0.2), Math.trunc(-0.2), Math.sign(-0.0), Math.sqrt(-0.0)): + self.assertEqual(math.copysign(1, value), -1) + self.assertEqual(Math.floor(-1.1), -2) + self.assertEqual(Math.ceil(math.inf), math.inf) + self.assertTrue(math.isnan(Math.round())) + + def test_extrema_domains_and_powers(self): + self.assertEqual(Math.min(), math.inf) + self.assertEqual(Math.max(), -math.inf) + self.assertEqual(math.copysign(1, Math.min(0, -0.0)), -1) + self.assertEqual(math.copysign(1, Math.max(-0.0, 0)), 1) + self.assertTrue(math.isnan(Math.max(1, math.nan))) + self.assertTrue(math.isnan(Math.sqrt(-1))) + self.assertEqual(Math.pow(math.nan, 0), 1) + self.assertTrue(math.isnan(Math.pow(1, math.nan))) + self.assertTrue(math.isnan(Math.pow(1, math.inf))) + self.assertEqual(Math.pow(-0.0, -3), -math.inf) + self.assertEqual(Math.pow(-2, 1025), -math.inf) + self.assertTrue(math.isnan(Math.pow(-1, 0.5))) + self.assertEqual(Math.abs("-2"), 2) + self.assertGreaterEqual(Math.random(), 0) + self.assertLess(Math.random(), 1)