lossless-json
Version:
Parse JSON without risk of losing numeric information
532 lines (528 loc) • 15.6 kB
JavaScript
import Decimal from 'decimal.js';
import { describe, expect, test } from 'vitest';
import { LosslessNumber, isLosslessNumber, parse, parseNumberAndBigInt, reviveDate, stringify } from './index';
import { isDeepEqual } from './parse';
// helper function to create a lossless number
function lln(value) {
return new LosslessNumber(value);
}
// deepEqual objects compared as plain JSON instead of JavaScript classes
function expectDeepEqual(a, b) {
expect(jsonify(a)).toEqual(jsonify(b));
}
// turn a JavaScript object into plain JSON
function jsonify(obj) {
return JSON.parse(JSON.stringify(obj));
}
test('full JSON object', () => {
const text = '{"a":2.3e100,"b":"str","c":null,"d":false,"e":[1,2,3]}';
const expected = {
a: lln('2.3e100'),
b: 'str',
c: null,
d: false,
e: [lln('1'), lln('2'), lln('3')]
};
const parsed = parse(text);
expect(jsonify(parsed)).toEqual(jsonify(expected));
});
test('object', () => {
expect(parse('{}')).toEqual({});
expect(parse(' { \n } \t ')).toEqual({});
expect(parse('{"a": {}}')).toEqual({
a: {}
});
expect(parse('{"a": "b"}')).toEqual({
a: 'b'
});
expect(parse('{"a": 2}')).toEqual({
a: lln('2')
});
});
test('array', () => {
expect(parse('[]')).toEqual([]);
expect(parse('[{}]')).toEqual([{}]);
expect(parse('{"a":[]}')).toEqual({
a: []
});
expect(parse('[1, "hi", true, false, null, {}, []]')).toEqual([lln('1'), 'hi', true, false, null, {}, []]);
});
test('number', () => {
expect(isLosslessNumber(parse('2.3e500'))).toBe(true);
expect(isLosslessNumber(parse('123456789012345678901234567890'))).toBe(true);
expect(parse('23')).toEqual(lln('23'));
expect(parse('0')).toEqual(lln('0'));
expect(parse('0e+2')).toEqual(lln('0e+2'));
expect(parse('0e+2').valueOf()).toEqual(0);
expect(parse('0.0')).toEqual(lln('0.0'));
expect(parse('-0')).toEqual(lln('-0'));
expect(parse('2.3')).toEqual(lln('2.3'));
expect(parse('2300e3')).toEqual(lln('2300e3'));
expect(parse('2300e+3')).toEqual(lln('2300e+3'));
expect(parse('-2')).toEqual(lln('-2'));
expect(parse('2e-3')).toEqual(lln('2e-3'));
expect(parse('2.3e-3')).toEqual(lln('2.3e-3'));
});
test('LosslessNumber', () => {
const str = '22222222222222222222';
expectDeepEqual(parse(str), lln(str));
const str2 = '2.3e+500';
expectDeepEqual(parse(str2), lln(str2));
const str3 = '2.3e-500';
expectDeepEqual(parse(str3), lln(str3));
});
test('string', () => {
expect(parse('"str"')).toEqual('str');
expect(JSON.parse('"\\"\\\\\\/\\b\\f\\n\\r\\t"')).toEqual('"\\/\b\f\n\r\t');
expect(parse('"\\"\\\\\\/\\b\\f\\n\\r\\t"')).toEqual('"\\/\b\f\n\r\t');
expect(JSON.parse('"\\u260E"')).toEqual('\u260E');
expect(parse('"\\u260E"')).toEqual('\u260E');
});
test('keywords', () => {
expect(parse('true')).toEqual(true);
expect(parse('false')).toEqual(false);
expect(parse('null')).toEqual(null);
});
test('reviver - replace values', () => {
const text = '{"a":123,"b":"str"}';
const expected = {
type: 'object',
value: {
a: {
type: 'object',
value: lln('123')
},
b: {
type: 'string',
value: 'str'
}
}
};
function reviver(_key, value) {
return {
type: typeof value,
value
};
}
expect(parse(text, reviver)).toEqual(expected);
});
test('reviver - invoke callbacks with key/value and correct context', () => {
const text = '{"a":123,"b":"str","c":null,"22":22,"d":false,"e":[1,2,3]}';
const expected = [{
context: {
22: 22,
a: 123,
b: 'str',
c: null,
d: false,
e: [1, 2, 3]
},
key: '22',
// ordered first
value: 22
}, {
context: {
22: 22,
a: 123,
b: 'str',
c: null,
d: false,
e: [1, 2, 3]
},
key: 'a',
value: 123
}, {
context: {
22: 22,
a: 123,
b: 'str',
c: null,
d: false,
e: [1, 2, 3]
},
key: 'b',
value: 'str'
}, {
context: {
22: 22,
a: 123,
b: 'str',
c: null,
d: false,
e: [1, 2, 3]
},
key: 'c',
value: null
}, {
context: {
22: 22,
a: 123,
b: 'str',
c: null,
d: false,
e: [1, 2, 3]
},
key: 'd',
value: false
}, {
context: [1, 2, 3],
key: '0',
value: 1
}, {
context: [1, 2, 3],
key: '1',
value: 2
}, {
context: [1, null, 3],
key: '2',
value: 3
}, {
context: {
22: 22,
a: 123,
b: 'str',
c: null,
e: [1, null, 3]
},
key: 'e',
value: [1, null, 3]
}, {
context: {
'': {
22: 22,
a: 123,
b: 'str',
c: null,
e: [1, null, 3]
}
},
key: '',
value: {
22: 22,
a: 123,
b: 'str',
c: null,
e: [1, null, 3]
}
}];
// convert LosslessNumbers to numbers for easy comparison with native JSON
function toRegularJSON(json) {
const str = stringify(json);
return str !== undefined ? JSON.parse(str) : undefined;
}
function reviver(key, value) {
return key === 'd' ? undefined : key === '1' ? null : value;
}
// validate expected outcome against reference implemenation JSON.parse
const logsReference = [];
JSON.parse(text, function (key, value) {
logsReference.push({
context: toRegularJSON(this),
key,
value
});
return reviver(key, value);
});
const logsActual = [];
parse(text, function (key, value) {
logsActual.push({
// @ts-expect-error
context: toRegularJSON(this),
key,
value: toRegularJSON(value)
});
return reviver(key, value);
});
expect(logsReference).toEqual(expected);
expect(logsActual).toEqual(expected);
});
test('correctly handle strings equaling a JSON delimiter', () => {
expect(parse('""')).toEqual('');
expect(parse('"["')).toEqual('[');
expect(parse('"]"')).toEqual(']');
expect(parse('"{"')).toEqual('{');
expect(parse('"}"')).toEqual('}');
expect(parse('":"')).toEqual(':');
expect(parse('","')).toEqual(',');
});
test('reviver - revive a lossless number correctly', () => {
const text = '2.3e+500';
const expected = [{
key: '',
value: lln('2.3e+500')
}];
const logs = [];
parse(text, (key, value) => {
logs.push({
key,
value
});
return value;
});
expectDeepEqual(logs, expected);
});
test('parse with a custom number parser creating bigint', () => {
const json = parse('[123456789123456789123456789, 2.3, 123]', null, parseNumberAndBigInt);
expect(json).toEqual([123456789123456789123456789n, 2.3, 123n]);
});
test('parse with a reviver to parse Date', () => {
const json = parse('["2022-08-25T09:39:19.288Z"]', reviveDate);
expect(json).toEqual([new Date('2022-08-25T09:39:19.288Z')]);
});
test('parse with a custom number parser creating Decimal', () => {
const parseDecimal = value => new Decimal(value);
const json = parse('[123456789123456789123456789,2.3,123]', null, parseDecimal);
expect(json).toEqual([new Decimal('123456789123456789123456789'), new Decimal('2.3'), new Decimal('123')]);
});
test('supports unicode characters in a string', () => {
expect(parse('"★"')).toBe('★');
expect(parse('"😀"')).toBe('😀');
expect(parse('"\ud83d\ude00"')).toBe('\ud83d\ude00');
expect(parse('"йнформация"')).toBe('йнформация');
});
test('supports escaped unicode characters in a string', () => {
expect(parse('"\\u2605"')).toBe('\u2605');
expect(parse('"\\ud83d\\ude00"')).toBe('\ud83d\ude00');
expect(parse('"\\u0439\\u043d\\u0444\\u043e\\u0440\\u043c\\u0430\\u0446\\u0438\\u044f"')).toBe('\u0439\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f');
});
test('supports unicode characters in a key', () => {
expect(parse('{"★":true}')).toStrictEqual({
'★': true
});
expect(parse('{"\u2605":true}')).toStrictEqual({
'\u2605': true
});
expect(parse('{"😀":true}')).toStrictEqual({
'😀': true
});
expect(parse('{"\ud83d\ude00":true}')).toStrictEqual({
'\ud83d\ude00': true
});
});
test('throws an error when an invalid character is encountered in a string', () => {
expect(() => parse('"\n"')).toThrow("Invalid character '\n' at position 1");
expect(() => parse('"\t"')).toThrow("Invalid character '\t' at position 1");
});
test('throws an error when an invalid character is encountered in a key', () => {
expect(() => parse('{"\n":true}')).toThrow("Invalid character '\n' at position 2");
expect(() => parse('{"\t":true}')).toThrow("Invalid character '\t' at position 2");
});
test('throws an error when a duplicate key is encountered', () => {
expect(() => parse('{"name": "Joe", "name": "Sarah"}')).toThrow("Duplicate key 'name' encountered at position 17");
});
test('does not throw a duplicate key error for build in methods like toString', () => {
expect(parse('{"toString": "test"}')).toEqual({
toString: 'test'
});
});
test('does not throw a duplicate key error when the values are equal', () => {
expect(parse('{"name": "Joe", "name": "Joe"}')).toStrictEqual({
name: 'Joe'
});
expect(parse('{"name": "Joe", "name": "Joe", "name": "Joe"}')).toStrictEqual({
name: 'Joe'
});
expect(parse('{"age": 41, "age": 41}')).toStrictEqual({
age: new LosslessNumber('41')
});
expect(parse('{"address": {"city": "Rotterdam"}, "address": {"city": "Rotterdam"}}')).toStrictEqual({
address: {
city: 'Rotterdam'
}
});
expect(parse('{"scores": [2.3, 7.1], "scores": [2.3, 7.1]}')).toStrictEqual({
scores: [new LosslessNumber('2.3'), new LosslessNumber('7.1')]
});
expect(parse('{"scores": [2.3, 7.1], "scores": [2.3, 7.1]}', null, Number.parseFloat)).toStrictEqual({
scores: [2.3, 7.1]
});
});
test('throw a duplicate key error when using a build in method name twice', () => {
expect(() => parse('{"toString": 1, "toString": 2}')).toThrow("Duplicate key 'toString' encountered at position 17");
});
describe('throw meaningful exceptions', () => {
const cases = [{
input: '',
expectedError: 'JSON value expected but reached end of input at position 0'
}, {
input: ' ',
expectedError: 'JSON value expected but reached end of input at position 2'
}, {
input: '{',
expectedError: "Quoted object key or end of object '}' expected but reached end of input at position 1"
}, {
input: '{"a",',
expectedError: "Colon ':' expected after property name but got ',' at position 4"
}, {
input: '{"a":}',
expectedError: "Object value expected after ':' at position 5"
}, {
input: '{a:2}',
expectedError: "Quoted object key expected but got 'a' at position 1"
}, {
input: '{"a":2,}',
expectedError: "Quoted object key expected but got '}' at position 7"
}, {
input: '{"a" "b"}',
expectedError: "Colon ':' expected after property name but got '\"' at position 5"
}, {
input: '{"a":2 "b":3}',
expectedError: "Comma ',' expected after value but got '\"' at position 7"
}, {
input: '{}{}',
expectedError: "Expected end of input but got '{' at position 2"
}, {
input: '[',
expectedError: "Array item or end of array ']' expected but reached end of input at position 1"
}, {
input: '[2,]',
expectedError: "Array item expected but got ']' at position 3"
}, {
input: '[2,,3]',
expectedError: "Array item expected but got ',' at position 3"
}, {
input: '[2 3]',
expectedError: "Comma ',' expected after value but got '3' at position 3"
}, {
input: '2.3.4',
expectedError: "Expected end of input but got '.' at position 3"
}, {
input: '2..3',
expectedError: "Invalid number '2.', expecting a digit but got '.' at position 2"
}, {
input: '2e3.4',
expectedError: "Expected end of input but got '.' at position 3"
}, {
input: '2e',
expectedError: "Invalid number '2e', expecting a digit but reached end of input at position 2"
}, {
input: '-',
expectedError: "Invalid number '-', expecting a digit but reached end of input at position 1"
}, {
input: '"a',
expectedError: "End of string '\"' expected but reached end of input at position 2"
}, {
input: 'foo',
expectedError: "JSON value expected but got 'f' at position 0"
}, {
input: '"\\a"',
expectedError: "Invalid escape character '\\a' at position 1"
}, {
input: '"\\u2',
expectedError: "Invalid unicode character '\\u2' at position 1"
}, {
input: '"\\u26',
expectedError: "Invalid unicode character '\\u26' at position 1"
}, {
input: '"\\u260',
expectedError: "Invalid unicode character '\\u260' at position 1"
}, {
input: '"\\u2605',
expectedError: "End of string '\"' expected but reached end of input at position 7"
}, {
input: '{"s \\ud',
expectedError: "Invalid unicode character '\\ud' at position 4"
}, {
input: '"\\u26"',
expectedError: "Invalid unicode character '\\u26\"' at position 1"
}, {
input: '"\\uZ000"',
expectedError: "Invalid unicode character '\\uZ000' at position 1"
}];
for (const {
input,
expectedError
} of cases) {
test(`should throw when parsing '${input}'`, () => {
expect(() => parse(input)).toThrow(expectedError);
});
}
});
describe('isDeepEqual', () => {
test('should test equality of primitive values', () => {
expect(isDeepEqual(2, 3)).toEqual(false);
expect(isDeepEqual(2, 2)).toEqual(true);
expect(isDeepEqual(2.4, 2.4)).toEqual(true);
expect(isDeepEqual(true, true)).toEqual(true);
expect(isDeepEqual(false, false)).toEqual(true);
expect(isDeepEqual(true, false)).toEqual(false);
expect(isDeepEqual(null, null)).toEqual(true);
expect(isDeepEqual(undefined, undefined)).toEqual(true);
expect(isDeepEqual(undefined, null)).toEqual(false);
expect(isDeepEqual(0, null)).toEqual(false);
expect(isDeepEqual('hello', 'hello')).toEqual(true);
expect(isDeepEqual('hello', 'there')).toEqual(false);
expect(isDeepEqual(new LosslessNumber('2'), new LosslessNumber('2'))).toEqual(true);
});
test('should test equality of arrays', () => {
expect(isDeepEqual([1, 2], [1, 2])).toEqual(true);
expect(isDeepEqual([1, 2], [1, 3])).toEqual(false);
expect(isDeepEqual([1, 2], [3, 2])).toEqual(false);
expect(isDeepEqual([1, 2], [1, 2, 3])).toEqual(false);
expect(isDeepEqual([1, 2, 3], [1, 2])).toEqual(false);
});
test('should test equality of objects', () => {
expect(isDeepEqual({
a: 2,
b: 3
}, {
a: 2,
b: 3
})).toEqual(true);
expect(isDeepEqual({
a: 2,
b: 3
}, {
a: 2,
b: 4
})).toEqual(false);
expect(isDeepEqual({
a: 2,
b: 3
}, {
a: 4,
b: 3
})).toEqual(false);
expect(isDeepEqual({
a: 2
}, {
a: 2,
b: 3
})).toEqual(false);
expect(isDeepEqual({
a: 2,
b: 3
}, {
a: 2
})).toEqual(false);
});
test('should test equality of nested objects / arrays', () => {
expect(isDeepEqual({
values: [1, 2]
}, {
values: [1, 2]
})).toEqual(true);
expect(isDeepEqual({
values: [1, 2]
}, {
values: [1, 2],
b: 3
})).toEqual(false);
expect(isDeepEqual({
values: [1, 3]
}, {
values: [1, 2]
})).toEqual(false);
expect(isDeepEqual([{
id: 2
}], [{
id: 2
}])).toEqual(true);
expect(isDeepEqual([{
id: 2
}], [{
id: 3
}])).toEqual(false);
});
});
//# sourceMappingURL=parse.test.js.map