Skip to content

Commit

Permalink
fix (runtime): arraysDiff() with duplicates (#263)
Browse files Browse the repository at this point in the history
  • Loading branch information
angelsolaorbaiceta committed Jun 10, 2024
1 parent 05b6f9d commit c58d78c
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 3 deletions.
54 changes: 54 additions & 0 deletions packages/runtime/src/__tests__/arrays.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,58 @@ describe('arrays diff', () => {
removed: [3],
})
})

test('duplicated item where one unit is removed', () => {
const oldArray = [1, 1]
const newArray = [1]

expect(arraysDiff(oldArray, newArray)).toEqual({
added: [],
removed: [1],
})
})

test('duplicated item where two items are removed', () => {
const oldArray = [1, 1]
const newArray = []

expect(arraysDiff(oldArray, newArray)).toEqual({
added: [],
removed: [1, 1],
})
})

test('duplicated item where one unit is added', () => {
const oldArray = [1]
const newArray = [1, 1]

expect(arraysDiff(oldArray, newArray)).toEqual({
added: [1],
removed: [],
})
})

test('duplicated item where two units are added', () => {
const oldArray = []
const newArray = [1, 1]

expect(arraysDiff(oldArray, newArray)).toEqual({
added: [1, 1],
removed: [],
})
})

test('array with duplicates', () => {
const oldArray = [1, 1, 2, 2, 3, 3, 3]
const newArray = [1, 1, 1, 2, 3, 4, 4]

const { added, removed } = arraysDiff(oldArray, newArray)
added.sort()
removed.sort()

expect({ added, removed }).toEqual({
added: [1, 4, 4],
removed: [2, 3, 3],
})
})
})
102 changes: 102 additions & 0 deletions packages/runtime/src/__tests__/maps.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, test } from 'vitest'
import { makeCountMap, mapsDiff } from '../utils/maps.js'

describe('make count map', () => {
test('empty array', () => {
expect(makeCountMap([])).toEqual(new Map())
})

test('array with one item', () => {
expect(makeCountMap(['A'])).toEqual(new Map([['A', 1]]))
})

test('array without duplicates', () => {
expect(makeCountMap(['A', 'B', 'C'])).toEqual(
new Map([
['A', 1],
['B', 1],
['C', 1],
])
)
})

test('array with duplicates', () => {
expect(makeCountMap(['A', 'B', 'A', 'C', 'B', 'B'])).toEqual(
new Map([
['A', 2],
['B', 3],
['C', 1],
])
)
})
})

describe('maps diff', () => {
test('empty maps', () => {
const oldMap = new Map()
const newMap = new Map()

expect(mapsDiff(oldMap, newMap)).toEqual({
added: [],
removed: [],
updated: [],
})
})

test('maps with the same keys and values', () => {
const oldMap = new Map([
['a', 1],
['b', 2],
['c', 3],
])
const newMap = new Map([
['a', 1],
['b', 2],
['c', 3],
])

expect(mapsDiff(oldMap, newMap)).toEqual({
added: [],
removed: [],
updated: [],
})
})

test('maps with the same keys but different values', () => {
const oldMap = new Map([
['a', 1],
['b', 2],
['c', 3],
])
const newMap = new Map([
['a', 1],
['b', 4],
['c', 3],
])

expect(mapsDiff(oldMap, newMap)).toEqual({
added: [],
removed: [],
updated: ['b'],
})
})

test('maps with different keys', () => {
const oldMap = new Map([
['a', 1],
['b', 2],
['c', 3],
])
const newMap = new Map([
['a', 1],
['b', 2],
['d', 3],
])

expect(mapsDiff(oldMap, newMap)).toEqual({
added: ['d'],
removed: ['c'],
updated: [],
})
})
})
41 changes: 38 additions & 3 deletions packages/runtime/src/utils/arrays.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { makeCountMap, mapsDiff } from './maps'

/**
* If the given value is an array, it returns it. Otherwise, it returns an array
* containing the given value.
Expand All @@ -23,14 +25,47 @@ export function withoutNulls(arr) {
* Given two arrays, it returns the items that have been added to the new array
* and the items that have been removed from the old array.
*
* NOTE TO READERS: your implementation of this function if you followed along
* with the book's chapter 7 listing is different from what's here. The version
* I wrote in the book has a bug, as it doesn't deal with duplicated items.
*
* @see https://github.com/angelsolaorbaiceta/fe-fwk-book/wiki/Errata#bug-in-the-arraysdiff-function check the errata for more information
*
* @param {any[]} oldArray the old array
* @param {any[]} newArray the new array
* @returns {{added: any[], removed: any[]}}}
* @returns {{added: any[], removed: any[]}}
*/
export function arraysDiff(oldArray, newArray) {
const oldsCount = makeCountMap(oldArray)
const newsCount = makeCountMap(newArray)
const diff = mapsDiff(oldsCount, newsCount)

// Added items repeated as many times as they appear in the new array
const added = diff.added.flatMap((key) =>
Array(newsCount.get(key)).fill(key)
)

// Removed items repeated as many times as they appeared in the old array
const removed = diff.removed.flatMap((key) =>
Array(oldsCount.get(key)).fill(key)
)

// Updated items have to check the difference in counts
for (const key of diff.updated) {
const oldCount = oldsCount.get(key)
const newCount = newsCount.get(key)
const delta = newCount - oldCount

if (delta > 0) {
added.push(...Array(delta).fill(key))
} else {
removed.push(...Array(-delta).fill(key))
}
}

return {
added: newArray.filter((newItem) => !oldArray.includes(newItem)),
removed: oldArray.filter((oldItem) => !newArray.includes(oldItem)),
added,
removed,
}
}

Expand Down
75 changes: 75 additions & 0 deletions packages/runtime/src/utils/maps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Given two maps, returns the keys that have been added, removed or updated.
* The comparison is shallow—only the first level of keys is compared.
* The keys keep their original type.
*
* For example:
*
* Given the following maps:
*
* ```js
* const oldMap = new Map([
* ['a', 1],
* ['b', 2],
* ['c', 3],
* ])
* const newMap = new Map([
* ['a', 1],
* ['b', 4],
* ['d', 5],
* ])
* ```
*
* The result will be:
*
* ```js
* mapsDiff(oldMap, newMap)
* // { added: ['d'], removed: ['c'], updated: ['b'] }
* ```
*
* @param {Map<any, any>} oldMap the old map
* @param {Map<any, any>} newMap the new map
* * @returns {{added: any[], removed: any[], updated: any[]}}
*/
export function mapsDiff(oldMap, newMap) {
const oldKeys = Array.from(oldMap.keys())
const newKeys = Array.from(newMap.keys())

return {
added: newKeys.filter((key) => !oldMap.has(key)),
removed: oldKeys.filter((key) => !newMap.has(key)),
updated: newKeys.filter(
(key) => oldMap.has(key) && oldMap.get(key) !== newMap.get(key)
),
}
}

/**
* Creates a `Map` that counts the occurrences of each item in the given array.
* The keys of the `Map` are the items in the array, and the values are the
* number of times each item appears in the array.
*
* A `Map` is used instead of an object because it can store any type of key,
* while an object can only store string keys. Thus, the key type is preserved.
*
* For example, given the array `[A, B, A, C, B, A]`, the object would be:
* ```
* {
* A: 3,
* B: 2,
* C: 1
* }
* ```
*
* @param {any[]} array an array of items
* @returns {Map<any, number>} a map of item counts
*/
export function makeCountMap(array) {
const map = new Map()

for (const item of array) {
map.set(item, (map.get(item) || 0) + 1)
}

return map
}
17 changes: 17 additions & 0 deletions packages/runtime/src/utils/objects.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
/**
* Given two objects, returns the keys that have been added, removed or updated.
* The comparison is shallow—only the first level of keys is compared.
* Note that the keys are always returned as strings.
*
* For example:
*
* Given the following objects:
*
* ```js
* const oldObj = { a: 1, b: 2, c: 3 }
* const newObj = { a: 1, b: 4, d: 5 }
* ```
*
* The result will be:
*
* ```js
* objectsDiff(oldObj, newObj)
* // { added: ['d'], removed: ['c'], updated: ['b'] }
* ```
*
* @param {object} oldObj the old object
* @param {object} newObj the new object
Expand Down

0 comments on commit c58d78c

Please sign in to comment.