diff --git a/packages/shared/__tests__/looseEqual.spec.ts b/packages/shared/__tests__/looseEqual.spec.ts new file mode 100644 index 000000000..fe321cd15 --- /dev/null +++ b/packages/shared/__tests__/looseEqual.spec.ts @@ -0,0 +1,221 @@ +import { looseEqual } from '../src' + +describe('utils/looseEqual', () => { + test('compares booleans correctly', () => { + expect(looseEqual(true, true)).toBe(true) + expect(looseEqual(false, false)).toBe(true) + expect(looseEqual(true, false)).toBe(false) + expect(looseEqual(true, 1)).toBe(false) + expect(looseEqual(false, 0)).toBe(false) + }) + + test('compares strings correctly', () => { + const text = 'Lorem ipsum' + const number = 1 + const bool = true + + expect(looseEqual(text, text)).toBe(true) + expect(looseEqual(text, text.slice(0, -1))).toBe(false) + expect(looseEqual(String(number), number)).toBe(true) + expect(looseEqual(String(bool), bool)).toBe(true) + }) + + test('compares numbers correctly', () => { + const number = 100 + const decimal = 2.5 + const multiplier = 1.0000001 + + expect(looseEqual(number, number)).toBe(true) + expect(looseEqual(number, number - 1)).toBe(false) + expect(looseEqual(decimal, decimal)).toBe(true) + expect(looseEqual(decimal, decimal * multiplier)).toBe(false) + expect(looseEqual(number, number * multiplier)).toBe(false) + expect(looseEqual(multiplier, multiplier)).toBe(true) + }) + + test('compares dates correctly', () => { + const date1 = new Date(2019, 1, 2, 3, 4, 5, 6) + const date2 = new Date(2019, 1, 2, 3, 4, 5, 6) + const date3 = new Date(2019, 1, 2, 3, 4, 5, 7) + const date4 = new Date(2219, 1, 2, 3, 4, 5, 6) + + // Identical date object references + expect(looseEqual(date1, date1)).toBe(true) + // Different date references with identical values + expect(looseEqual(date1, date2)).toBe(true) + // Dates with slightly different time (ms) + expect(looseEqual(date1, date3)).toBe(false) + // Dates with different year + expect(looseEqual(date1, date4)).toBe(false) + }) + + test('compares files correctly', () => { + const date1 = new Date(2019, 1, 2, 3, 4, 5, 6) + const date2 = new Date(2019, 1, 2, 3, 4, 5, 7) + const file1 = new File([''], 'filename.txt', { + type: 'text/plain', + lastModified: date1.getTime(), + }) + const file2 = new File([''], 'filename.txt', { + type: 'text/plain', + lastModified: date1.getTime(), + }) + const file3 = new File([''], 'filename.txt', { + type: 'text/plain', + lastModified: date2.getTime(), + }) + const file4 = new File([''], 'filename.csv', { + type: 'text/csv', + lastModified: date1.getTime(), + }) + const file5 = new File(['abcdef'], 'filename.txt', { + type: 'text/plain', + lastModified: date1.getTime(), + }) + const file6 = new File(['12345'], 'filename.txt', { + type: 'text/plain', + lastModified: date1.getTime(), + }) + + // Identical file object references + expect(looseEqual(file1, file1)).toBe(true) + // Different file references with identical values + expect(looseEqual(file1, file2)).toBe(true) + // Files with slightly different dates + expect(looseEqual(file1, file3)).toBe(false) + // Two different file types + expect(looseEqual(file1, file4)).toBe(false) + // Two files with same name, modified date, but different content + expect(looseEqual(file5, file6)).toBe(false) + }) + + test('compares arrays correctly', () => { + const arr1 = [1, 2, 3, 4] + const arr2 = [1, 2, 3, '4'] + const arr3 = [1, 2, 3, 4, 5] + const arr4 = [1, 2, 3, 4, { a: 5 }] + + // Identical array references + expect(looseEqual(arr1, arr1)).toBe(true) + // Different array references with identical values + expect(looseEqual(arr1, arr1.slice())).toBe(true) + expect(looseEqual(arr4, arr4.slice())).toBe(true) + // Array with one value different (loose) + expect(looseEqual(arr1, arr2)).toBe(true) + // Array with one value different + expect(looseEqual(arr3, arr4)).toBe(false) + // Arrays with different lengths + expect(looseEqual(arr1, arr3)).toBe(false) + // Arrays with values in different order + expect(looseEqual(arr1, arr1.slice().reverse())).toBe(false) + }) + + test('compares RegExp correctly', () => { + const rx1 = /^foo$/ + const rx2 = /^foo$/ + const rx3 = /^bar$/ + const rx4 = /^bar$/i + + // Identical regex references + expect(looseEqual(rx1, rx1)).toBe(true) + // Different regex references with identical values + expect(looseEqual(rx1, rx2)).toBe(true) + // Different regex + expect(looseEqual(rx1, rx3)).toBe(false) + // Same regex with different options + expect(looseEqual(rx3, rx4)).toBe(false) + }) + + test('compares objects correctly', () => { + const obj1 = { foo: 'bar' } + const obj2 = { foo: 'bar1' } + const obj3 = { a: 1, b: 2, c: 3 } + const obj4 = { b: 2, c: 3, a: 1 } + const obj5 = { ...obj4, z: 999 } + const nestedObj1 = { ...obj1, bar: [{ ...obj1 }, { ...obj1 }] } + const nestedObj2 = { ...obj1, bar: [{ ...obj1 }, { ...obj2 }] } + + // Identical object references + expect(looseEqual(obj1, obj1)).toBe(true) + // Two objects with identical keys/values + expect(looseEqual(obj1, { ...obj1 })).toBe(true) + // Different key values + expect(looseEqual(obj1, obj2)).toBe(false) + // Keys in different orders + expect(looseEqual(obj3, obj4)).toBe(true) + // One object has additional key + expect(looseEqual(obj4, obj5)).toBe(false) + // Identical object references with nested array + expect(looseEqual(nestedObj1, nestedObj1)).toBe(true) + // Identical object definitions with nested array + expect(looseEqual(nestedObj1, { ...nestedObj1 })).toBe(true) + // Object definitions with nested array (which has different order) + expect(looseEqual(nestedObj1, nestedObj2)).toBe(false) + }) + + test('compares different types correctly', () => { + const obj1 = {} + const obj2 = { a: 1 } + const obj3 = { 0: 0, 1: 1, 2: 2 } + const arr1: any[] = [] + const arr2 = [1] + const arr3 = [0, 1, 2] + const date1 = new Date(2019, 1, 2, 3, 4, 5, 6) + const file1 = new File([''], 'filename.txt', { + type: 'text/plain', + lastModified: date1.getTime(), + }) + + expect(looseEqual(123, '123')).toBe(true) + expect(looseEqual(123, new Date(123))).toBe(false) + expect(looseEqual(`123`, new Date(123))).toBe(false) + expect(looseEqual([1, 2, 3], '1,2,3')).toBe(false) + expect(looseEqual(obj1, arr1)).toBe(false) + expect(looseEqual(obj2, arr2)).toBe(false) + expect(looseEqual(obj1, '[object Object]')).toBe(false) + expect(looseEqual(arr1, '[object Array]')).toBe(false) + expect(looseEqual(obj1, date1)).toBe(false) + expect(looseEqual(obj2, date1)).toBe(false) + expect(looseEqual(arr1, date1)).toBe(false) + expect(looseEqual(arr2, date1)).toBe(false) + expect(looseEqual(obj2, file1)).toBe(false) + expect(looseEqual(arr2, file1)).toBe(false) + expect(looseEqual(date1, file1)).toBe(false) + // Special case where an object's keys are the same as keys (indexes) of an array + expect(looseEqual(obj3, arr3)).toBe(false) + }) + + test('compares null and undefined values correctly', () => { + expect(looseEqual(null, null)).toBe(true) + expect(looseEqual(undefined, undefined)).toBe(true) + expect(looseEqual(void 0, undefined)).toBe(true) + expect(looseEqual(null, undefined)).toBe(false) + expect(looseEqual(null, void 0)).toBe(false) + expect(looseEqual(null, '')).toBe(false) + expect(looseEqual(null, false)).toBe(false) + expect(looseEqual(undefined, false)).toBe(false) + }) + + test('compares sparse arrays correctly', () => { + // The following arrays all have a length of 3 + // But the first two are "sparse" + const arr1 = [] + arr1[2] = true + const arr2 = [] + arr2[2] = true + const arr3 = [false, false, true] + const arr4 = [undefined, undefined, true] + // This one is also sparse (missing index 1) + const arr5 = [] + arr5[0] = arr5[2] = true + + expect(looseEqual(arr1, arr2)).toBe(true) + expect(looseEqual(arr2, arr1)).toBe(true) + expect(looseEqual(arr1, arr3)).toBe(false) + expect(looseEqual(arr3, arr1)).toBe(false) + expect(looseEqual(arr1, arr4)).toBe(true) + expect(looseEqual(arr4, arr1)).toBe(true) + expect(looseEqual(arr1, arr5)).toBe(false) + expect(looseEqual(arr5, arr1)).toBe(false) + }) +}) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0119f1159..7caf7d786 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -56,6 +56,7 @@ export const hasOwn = ( ): key is keyof typeof val => hasOwnProperty.call(val, key) export const isArray = Array.isArray +export const isDate = (val: unknown): val is Date => val instanceof Date export const isFunction = (val: unknown): val is Function => typeof val === 'function' export const isString = (val: unknown): val is string => typeof val === 'string' diff --git a/packages/shared/src/looseEqual.ts b/packages/shared/src/looseEqual.ts index bd58332dd..030f0338b 100644 --- a/packages/shared/src/looseEqual.ts +++ b/packages/shared/src/looseEqual.ts @@ -1,40 +1,51 @@ -import { isObject, isArray } from './' +import { isArray, isDate, isObject } from './' + +function looseCompareArrays(a: any[], b: any[]) { + if (a.length !== b.length) return false + let equal = true + for (let i = 0; equal && i < a.length; i++) { + equal = looseEqual(a[i], b[i]) + } + return equal +} export function looseEqual(a: any, b: any): boolean { if (a === b) return true - const isObjectA = isObject(a) - const isObjectB = isObject(b) - if (isObjectA && isObjectB) { - try { - const isArrayA = isArray(a) - const isArrayB = isArray(b) - if (isArrayA && isArrayB) { - return ( - a.length === b.length && - a.every((e: any, i: any) => looseEqual(e, b[i])) - ) - } else if (a instanceof Date && b instanceof Date) { - return a.getTime() === b.getTime() - } else if (!isArrayA && !isArrayB) { - const keysA = Object.keys(a) - const keysB = Object.keys(b) - return ( - keysA.length === keysB.length && - keysA.every(key => looseEqual(a[key], b[key])) - ) - } else { - /* istanbul ignore next */ - return false - } - } catch (e) { - /* istanbul ignore next */ + let aValidType = isDate(a) + let bValidType = isDate(b) + if (aValidType || bValidType) { + return aValidType && bValidType ? a.getTime() === b.getTime() : false + } + aValidType = isArray(a) + bValidType = isArray(b) + if (aValidType || bValidType) { + return aValidType && bValidType ? looseCompareArrays(a, b) : false + } + aValidType = isObject(a) + bValidType = isObject(b) + if (aValidType || bValidType) { + /* istanbul ignore if: this if will probably never be called */ + if (!aValidType || !bValidType) { return false } - } else if (!isObjectA && !isObjectB) { - return String(a) === String(b) - } else { - return false + const aKeysCount = Object.keys(a).length + const bKeysCount = Object.keys(b).length + if (aKeysCount !== bKeysCount) { + return false + } + for (const key in a) { + const aHasKey = a.hasOwnProperty(key) + const bHasKey = b.hasOwnProperty(key) + if ( + (aHasKey && !bHasKey) || + (!aHasKey && bHasKey) || + !looseEqual(a[key], b[key]) + ) { + return false + } + } } + return String(a) === String(b) } export function looseIndexOf(arr: any[], val: any): number {