@ocavue/utils 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { describe, it, expect } from 'vitest'
4
4
 
5
- import { DefaultMap } from './default-map'
5
+ import { DefaultMap, DefaultWeakMap } from './default-map'
6
6
 
7
7
  describe('DefaultMap', () => {
8
8
  it('creates default values for missing keys', () => {
@@ -184,3 +184,100 @@ describe('DefaultMap', () => {
184
184
  expect(map.get('group2').get('item1')).toBe(0)
185
185
  })
186
186
  })
187
+
188
+ describe('DefaultWeakMap', () => {
189
+ it('creates default values for missing keys', () => {
190
+ const map = new DefaultWeakMap<object, number>(() => 0)
191
+ const key1 = {}
192
+ const key2 = {}
193
+
194
+ expect(map.get(key1)).toBe(0)
195
+ expect(map.get(key2)).toBe(0)
196
+ })
197
+
198
+ it('returns existing values for set keys', () => {
199
+ const map = new DefaultWeakMap<object, number>(() => 0)
200
+ const key = {}
201
+
202
+ map.set(key, 42)
203
+ expect(map.get(key)).toBe(42)
204
+ })
205
+
206
+ it('stores the default value when accessing missing key', () => {
207
+ const map = new DefaultWeakMap<object, number>(() => 5)
208
+ const key = {}
209
+
210
+ const value = map.get(key)
211
+ expect(value).toBe(5)
212
+ expect(map.has(key)).toBe(true)
213
+ expect(map.get(key)).toBe(5)
214
+ })
215
+
216
+ it('works with array factory', () => {
217
+ const map = new DefaultWeakMap<object, string[]>(() => [])
218
+ const key1 = {}
219
+ const key2 = {}
220
+
221
+ map.get(key1).push('item1')
222
+ map.get(key1).push('item2')
223
+ map.get(key2).push('item3')
224
+
225
+ expect(map.get(key1)).toEqual(['item1', 'item2'])
226
+ expect(map.get(key2)).toEqual(['item3'])
227
+ })
228
+
229
+ it('accepts initial entries', () => {
230
+ const key1 = {}
231
+ const key2 = {}
232
+ const key3 = {}
233
+ const initialEntries: [object, number][] = [
234
+ [key1, 1],
235
+ [key2, 2],
236
+ [key3, 3],
237
+ ]
238
+ const map = new DefaultWeakMap<object, number>(() => 0, initialEntries)
239
+
240
+ expect(map.get(key1)).toBe(1)
241
+ expect(map.get(key2)).toBe(2)
242
+ expect(map.get(key3)).toBe(3)
243
+
244
+ const key4 = {}
245
+ expect(map.get(key4)).toBe(0)
246
+ })
247
+
248
+ it('calls factory function only when key is missing', () => {
249
+ let callCount = 0
250
+ const map = new DefaultWeakMap<object, number>(() => {
251
+ callCount++
252
+ return 42
253
+ })
254
+ const existing = {}
255
+ const newKey = {}
256
+
257
+ map.set(existing, 100)
258
+
259
+ map.get(existing)
260
+ expect(callCount).toBe(0)
261
+
262
+ map.get(newKey)
263
+ expect(callCount).toBe(1)
264
+
265
+ map.get(newKey)
266
+ expect(callCount).toBe(1)
267
+ })
268
+
269
+ it('works with delete method', () => {
270
+ const map = new DefaultWeakMap<object, number>(() => 10)
271
+ const key = {}
272
+
273
+ map.get(key)
274
+ expect(map.has(key)).toBe(true)
275
+
276
+ map.delete(key)
277
+ expect(map.has(key)).toBe(false)
278
+
279
+ const value = map.get(key)
280
+ expect(value).toBe(10)
281
+ expect(map.has(key)).toBe(true)
282
+ })
283
+ })
@@ -2,6 +2,68 @@
2
2
  * A map that automatically creates values for missing keys using a factory function.
3
3
  *
4
4
  * Similar to Python's [defaultdict](https://docs.python.org/3.13/library/collections.html#collections.defaultdict).
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * // Group items by category using arrays
9
+ * const groupByCategory = new DefaultMap<string, string[]>(() => [])
10
+ *
11
+ * groupByCategory.get('fruits').push('apple', 'banana')
12
+ * groupByCategory.get('vegetables').push('carrot')
13
+ * groupByCategory.get('fruits').push('orange')
14
+ *
15
+ * console.log(groupByCategory.get('fruits')) // ['apple', 'banana', 'orange']
16
+ * console.log(groupByCategory.get('vegetables')) // ['carrot']
17
+ * console.log(groupByCategory.get('dairy')) // [] (auto-created)
18
+ * ```
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * // Build a graph with adjacency lists
23
+ * const graph = new DefaultMap<string, Set<string>>(() => new Set())
24
+ *
25
+ * graph.get('A').add('B').add('C')
26
+ * graph.get('B').add('C').add('D')
27
+ * graph.get('C').add('D')
28
+ *
29
+ * console.log([...graph.get('A')]) // ['B', 'C']
30
+ * console.log([...graph.get('B')]) // ['C', 'D']
31
+ * console.log([...graph.get('E')]) // [] (auto-created empty set)
32
+ * ```
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * // Initialize with existing entries
37
+ * const scores = new DefaultMap<string, number>(
38
+ * () => 0,
39
+ * [
40
+ * ['Alice', 100],
41
+ * ['Bob', 85]
42
+ * ]
43
+ * )
44
+ *
45
+ * scores.set('Alice', scores.get('Alice') + 10) // 100 -> 110
46
+ * console.log(scores.get('Alice')) // 110
47
+ * console.log(scores.get('Bob')) // 85
48
+ * console.log(scores.get('Charlie')) // 0 (auto-created)
49
+ * ```
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * // Nested DefaultMaps for 2D data structures
54
+ * const matrix = new DefaultMap<number, DefaultMap<number, number>>(
55
+ * () => new DefaultMap<number, number>(() => 0)
56
+ * )
57
+ *
58
+ * matrix.get(0).set(0, 1)
59
+ * matrix.get(0).set(1, 2)
60
+ * matrix.get(1).set(1, 3)
61
+ *
62
+ * console.log(matrix.get(0).get(0)) // 1
63
+ * console.log(matrix.get(0).get(1)) // 2
64
+ * console.log(matrix.get(1).get(0)) // 0 (auto-created)
65
+ * console.log(matrix.get(2).get(3)) // 0 (both auto-created)
66
+ * ```
5
67
  */
6
68
  export class DefaultMap<K, V> extends Map<K, V> {
7
69
  private readonly defaultFactory: () => V
@@ -20,3 +82,115 @@ export class DefaultMap<K, V> extends Map<K, V> {
20
82
  return value
21
83
  }
22
84
  }
85
+
86
+ /**
87
+ * A weak map that automatically creates values for missing keys using a factory function.
88
+ *
89
+ * Similar to {@link DefaultMap} but uses WeakMap as the base, allowing garbage collection of keys.
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * // Store metadata for DOM elements without preventing garbage collection
94
+ * const elementMetadata = new DefaultWeakMap<HTMLElement, { clicks: number; hovers: number }>(
95
+ * () => ({ clicks: 0, hovers: 0 })
96
+ * )
97
+ *
98
+ * const button = document.querySelector('button')!
99
+ * const div = document.querySelector('div')!
100
+ *
101
+ * elementMetadata.get(button).clicks++
102
+ * elementMetadata.get(button).clicks++
103
+ * elementMetadata.get(div).hovers++
104
+ *
105
+ * console.log(elementMetadata.get(button)) // { clicks: 2, hovers: 0 }
106
+ * console.log(elementMetadata.get(div)) // { clicks: 0, hovers: 1 }
107
+ * // When elements are removed from DOM and not referenced,
108
+ * // their metadata can be garbage collected
109
+ * ```
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * // Cache computed properties for objects
114
+ * const computedCache = new DefaultWeakMap<object, Map<string, any>>(
115
+ * () => new Map()
116
+ * )
117
+ *
118
+ * function getOrCompute(obj: object, key: string, compute: () => any) {
119
+ * const cache = computedCache.get(obj)
120
+ * if (!cache.has(key)) {
121
+ * cache.set(key, compute())
122
+ * }
123
+ * return cache.get(key)
124
+ * }
125
+ *
126
+ * const user = { name: 'Alice', age: 30 }
127
+ * const displayName = getOrCompute(user, 'displayName', () => user.name.toUpperCase())
128
+ * const birthYear = getOrCompute(user, 'birthYear', () => new Date().getFullYear() - user.age)
129
+ *
130
+ * console.log(displayName) // 'ALICE'
131
+ * console.log(birthYear) // 1994 (or current year - 30)
132
+ * ```
133
+ *
134
+ * @example
135
+ * ```typescript
136
+ * // Initialize with existing entries
137
+ * const obj1 = {}
138
+ * const obj2 = {}
139
+ *
140
+ * const objectData = new DefaultWeakMap<object, string[]>(
141
+ * () => [],
142
+ * [
143
+ * [obj1, ['tag1', 'tag2']],
144
+ * [obj2, ['tag3']]
145
+ * ]
146
+ * )
147
+ *
148
+ * objectData.get(obj1).push('tag4')
149
+ * console.log(objectData.get(obj1)) // ['tag1', 'tag2', 'tag4']
150
+ * console.log(objectData.get(obj2)) // ['tag3']
151
+ *
152
+ * const obj3 = {}
153
+ * console.log(objectData.get(obj3)) // [] (auto-created)
154
+ * ```
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * // Track event listeners per element using both DefaultWeakMap and DefaultMap
159
+ * const eventListeners = new DefaultWeakMap<EventTarget, DefaultMap<string, Function[]>>(
160
+ * () => new DefaultMap<string, Function[]>(() => [])
161
+ * )
162
+ *
163
+ * function addListener(target: EventTarget, event: string, handler: Function) {
164
+ * eventListeners.get(target).get(event).push(handler)
165
+ * }
166
+ *
167
+ * const element = document.createElement('button')
168
+ * addListener(element, 'click', () => console.log('clicked'))
169
+ * addListener(element, 'click', () => console.log('also clicked'))
170
+ * addListener(element, 'hover', () => console.log('hovered'))
171
+ *
172
+ * console.log(eventListeners.get(element).get('click').length) // 2
173
+ * console.log(eventListeners.get(element).get('hover').length) // 1
174
+ * // No need for has() checks or null assertions - everything auto-initializes!
175
+ * ```
176
+ */
177
+ export class DefaultWeakMap<K extends WeakKey, V> extends WeakMap<K, V> {
178
+ private readonly defaultFactory: () => V
179
+
180
+ constructor(
181
+ defaultFactory: () => V,
182
+ entries?: readonly (readonly [K, V])[] | null,
183
+ ) {
184
+ super(entries)
185
+ this.defaultFactory = defaultFactory
186
+ }
187
+
188
+ override get(key: K): V {
189
+ if (this.has(key)) {
190
+ return super.get(key)!
191
+ }
192
+ const value = this.defaultFactory()
193
+ this.set(key, value)
194
+ return value
195
+ }
196
+ }
@@ -0,0 +1,36 @@
1
+ // @vitest-environment node
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ import { x } from 'tinyexec'
7
+ import { glob } from 'tinyglobby'
8
+ import { describe, it, expect, beforeAll } from 'vitest'
9
+
10
+ const ROOT_DIR = path.join(import.meta.dirname, '..')
11
+ const E2E_OUT_DIR = path.join(ROOT_DIR, 'e2e', 'dist')
12
+
13
+ describe('e2e', () => {
14
+ beforeAll(async () => {
15
+ await x('pnpm', ['-w', 'build'], {
16
+ nodeOptions: { cwd: ROOT_DIR, stdio: 'inherit' },
17
+ throwOnError: true,
18
+ })
19
+ await x('pnpm', ['--filter', 'e2e', 'run', 'build'], {
20
+ nodeOptions: { cwd: ROOT_DIR, stdio: 'inherit' },
21
+ throwOnError: true,
22
+ })
23
+ })
24
+
25
+ it('bundler outputs match snapshot', async () => {
26
+ const files = await glob('**/*', { cwd: E2E_OUT_DIR, onlyFiles: true })
27
+ const output = ['']
28
+
29
+ for (const file of files.sort()) {
30
+ const content = fs.readFileSync(path.join(E2E_OUT_DIR, file), 'utf-8')
31
+ output.push('#'.repeat(80), file, '-'.repeat(80), content, '')
32
+ }
33
+
34
+ expect(output.join('\n')).toMatchSnapshot()
35
+ })
36
+ })
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
 
3
- import { getId } from './get-id'
3
+ import { getId, setMaxSafeInteger } from './get-id'
4
4
 
5
5
  describe('getId', () => {
6
6
  it('returns a positive number', () => {
@@ -11,4 +11,23 @@ describe('getId', () => {
11
11
  it('returns different values on consecutive calls', () => {
12
12
  expect(getId()).not.toBe(getId())
13
13
  })
14
+
15
+ it('never exceeds the configured maximum', () => {
16
+ const max = 5
17
+ setMaxSafeInteger(max)
18
+ let prevId = -1
19
+
20
+ try {
21
+ for (let i = 0; i < max * 3; i += 1) {
22
+ const id = getId()
23
+ expect(id).toBeLessThan(max)
24
+ expect(id).toBeGreaterThanOrEqual(1)
25
+ expect(id).not.toBe(prevId)
26
+
27
+ prevId = id
28
+ }
29
+ } finally {
30
+ setMaxSafeInteger(Number.MAX_SAFE_INTEGER)
31
+ }
32
+ })
14
33
  })
package/src/get-id.ts CHANGED
@@ -1,9 +1,23 @@
1
1
  let id = 0
2
2
 
3
+ let maxSafeInteger = Number.MAX_SAFE_INTEGER
4
+
5
+ /**
6
+ * Sets the maximum safe integer for the id generator. Only for testing purposes.
7
+ *
8
+ * @internal
9
+ */
10
+ export function setMaxSafeInteger(max: number) {
11
+ maxSafeInteger = max
12
+ }
13
+
3
14
  /**
4
15
  * Generates a unique positive integer.
5
16
  */
6
17
  export function getId(): number {
7
- id = (id % Number.MAX_SAFE_INTEGER) + 1
18
+ id++
19
+ if (id >= maxSafeInteger) {
20
+ id = 1
21
+ }
8
22
  return id
9
23
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './checker'
2
- export { DefaultMap } from './default-map'
2
+ export { Counter, WeakCounter } from './counter'
3
+ export { DefaultMap, DefaultWeakMap } from './default-map'
3
4
  export * from './dom'
4
5
  export { formatBytes } from './format-bytes'
5
6
  export { getId } from './get-id'
@@ -1,20 +1,12 @@
1
- import { describe, it, expect } from 'vitest'
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
2
2
 
3
- import {
4
- mapGroupBy,
5
- mapGroupByPolyfill,
6
- mapGroupByNative,
7
- } from './map-group-by'
3
+ import { mapGroupBy, mapGroupByPolyfill } from './map-group-by'
8
4
 
9
5
  const testCases = [
10
6
  { name: 'mapGroupBy', fn: mapGroupBy },
11
7
  { name: 'mapGroupByPolyfill', fn: mapGroupByPolyfill },
12
8
  ]
13
9
 
14
- if (!!Map.groupBy) {
15
- testCases.push({ name: 'mapGroupByNative', fn: mapGroupByNative })
16
- }
17
-
18
10
  describe.each(testCases)('$name', ({ fn }) => {
19
11
  it('groups items by key', () => {
20
12
  const items = [1, 2, 3, 4, 5, 6]
@@ -76,3 +68,24 @@ describe.each(testCases)('$name', ({ fn }) => {
76
68
  expect(result.get(1)).toEqual([1, 3, 5])
77
69
  })
78
70
  })
71
+
72
+ describe('mapGroupBy', () => {
73
+ beforeEach(() => {
74
+ if ('groupBy' in Map) {
75
+ // @ts-expect-error - spy
76
+ vi.spyOn(Map, 'groupBy', 'get').mockReturnValueOnce(undefined)
77
+ }
78
+ })
79
+
80
+ afterEach(() => {
81
+ vi.restoreAllMocks()
82
+ })
83
+
84
+ it('falls back to polyfill when Map.groupBy is not available', () => {
85
+ const items = [1, 2, 3, 4, 5, 6]
86
+ const result = mapGroupBy(items, (item) => item % 2)
87
+
88
+ expect(result.get(0)).toEqual([2, 4, 6])
89
+ expect(result.get(1)).toEqual([1, 3, 5])
90
+ })
91
+ })
@@ -21,21 +21,15 @@ export function mapGroupByPolyfill<K, T>(
21
21
  }
22
22
 
23
23
  /**
24
- * @internal
25
- */
26
- export function mapGroupByNative<K, T>(
27
- items: Iterable<T>,
28
- keySelector: (item: T, index: number) => K,
29
- ): Map<K, T[]> {
30
- return Map.groupBy(items, keySelector)
31
- }
32
-
33
- /**
34
- * A polyfill for the `Map.groupBy` static method.
24
+ * A polyfill for the [`Map.groupBy()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/groupBy) static method.
35
25
  *
36
26
  * @public
37
27
  */
38
- export const mapGroupBy: <K, T>(
28
+ export function mapGroupBy<K, T>(
39
29
  items: Iterable<T>,
40
30
  keySelector: (item: T, index: number) => K,
41
- ) => Map<K, T[]> = !!Map.groupBy ? mapGroupByNative : mapGroupByPolyfill
31
+ ): Map<K, T[]> {
32
+ return Map.groupBy
33
+ ? Map.groupBy(items, keySelector)
34
+ : mapGroupByPolyfill(items, keySelector)
35
+ }
@@ -17,13 +17,16 @@ export type ObjectEntries<T extends Record<string, any>> = {
17
17
  }[keyof T]
18
18
 
19
19
  /**
20
- * A type-safe wrapper around `Object.entries()` that preserves the exact types of object keys
21
- * and values. Unlike the standard `Object.entries()` which returns `[string, any][]`, this
22
- * function returns an array of tuples where each tuple is precisely typed according to the
23
- * input object's structure.
20
+ * A type-safe wrapper around
21
+ * [`Object.entries()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries)
22
+ * that preserves the exact types of object keys and values. Unlike the standard
23
+ * `Object.entries()` which returns `[string, any][]`, this function returns an
24
+ * array of tuples where each tuple is precisely typed according to the input
25
+ * object's structure.
24
26
  *
25
- * This is particularly useful when working with objects that have known, fixed property types
26
- * and you want to maintain type safety when iterating over entries.
27
+ * This is particularly useful when working with objects that have known, fixed
28
+ * property types and you want to maintain type safety when iterating over
29
+ * entries.
27
30
  *
28
31
  * @example
29
32
  * ```typescript
@@ -53,6 +56,8 @@ export type ObjectEntries<T extends Record<string, any>> = {
53
56
  *
54
57
  * @public
55
58
  */
56
- export const objectEntries: <T extends Record<string, any>>(
59
+ export function objectEntries<T extends Record<string, any>>(
57
60
  obj: T,
58
- ) => ObjectEntries<T>[] = Object.entries
61
+ ): ObjectEntries<T>[] {
62
+ return Object.entries(obj)
63
+ }
@@ -1,20 +1,12 @@
1
- import { describe, it, expect } from 'vitest'
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
- import {
4
- objectGroupBy,
5
- objectGroupByPolyfill,
6
- objectGroupByNative,
7
- } from './object-group-by'
3
+ import { objectGroupBy, objectGroupByPolyfill } from './object-group-by'
8
4
 
9
5
  const testCases = [
10
6
  { name: 'objectGroupBy', fn: objectGroupBy },
11
7
  { name: 'objectGroupByPolyfill', fn: objectGroupByPolyfill },
12
8
  ]
13
9
 
14
- if (!!Object.groupBy) {
15
- testCases.push({ name: 'objectGroupByNative', fn: objectGroupByNative })
16
- }
17
-
18
10
  describe.each(testCases)('$name', ({ fn }) => {
19
11
  it('groups items by key', () => {
20
12
  const items = [1, 2, 3, 4, 5, 6]
@@ -84,3 +76,26 @@ describe.each(testCases)('$name', ({ fn }) => {
84
76
  expect(result.odd).toEqual([1, 3, 5])
85
77
  })
86
78
  })
79
+
80
+ describe('objectGroupBy', () => {
81
+ beforeEach(() => {
82
+ if ('groupBy' in Object) {
83
+ // @ts-expect-error - spy
84
+ vi.spyOn(Object, 'groupBy', 'get').mockReturnValueOnce(undefined)
85
+ }
86
+ })
87
+
88
+ afterEach(() => {
89
+ vi.restoreAllMocks()
90
+ })
91
+
92
+ it('falls back to polyfill when Object.groupBy is not available', () => {
93
+ const items = [1, 2, 3, 4, 5, 6]
94
+ const result = objectGroupBy(items, (item) =>
95
+ item % 2 === 0 ? 'even' : 'odd',
96
+ )
97
+
98
+ expect(result.even).toEqual([2, 4, 6])
99
+ expect(result.odd).toEqual([1, 3, 5])
100
+ })
101
+ })
@@ -21,23 +21,15 @@ export function objectGroupByPolyfill<K extends PropertyKey, T>(
21
21
  }
22
22
 
23
23
  /**
24
- * @internal
25
- */
26
- export function objectGroupByNative<K extends PropertyKey, T>(
27
- items: Iterable<T>,
28
- keySelector: (item: T, index: number) => K,
29
- ): Partial<Record<K, T[]>> {
30
- return Object.groupBy(items, keySelector)
31
- }
32
-
33
- /**
34
- * A polyfill for the `Object.groupBy` static method.
24
+ * A polyfill for the [`Object.groupBy()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/groupBy) static method.
35
25
  *
36
26
  * @public
37
27
  */
38
- export const objectGroupBy: <K extends PropertyKey, T>(
28
+ export function objectGroupBy<K extends PropertyKey, T>(
39
29
  items: Iterable<T>,
40
30
  keySelector: (item: T, index: number) => K,
41
- ) => Partial<Record<K, T[]>> = !!Object.groupBy
42
- ? objectGroupByNative
43
- : objectGroupByPolyfill
31
+ ): Partial<Record<K, T[]>> {
32
+ return Object.groupBy
33
+ ? Object.groupBy(items, keySelector)
34
+ : objectGroupByPolyfill(items, keySelector)
35
+ }
package/src/once.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment node
2
2
 
3
- import { describe, it, expect, vi } from 'vitest'
3
+ import { describe, expect, it, vi } from 'vitest'
4
4
 
5
5
  import { once } from './once'
6
6
 
package/src/sleep.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  /**
2
- * Sleep for a given number of milliseconds.
2
+ * Returns a Promise that resolves after a specified number of milliseconds.
3
+ *
4
+ * @param ms - The number of milliseconds to wait.
5
+ *
6
+ * @example
7
+ * ```js
8
+ * await sleep(1000) // Wait 1 second
9
+ * ```
3
10
  */
4
11
  export function sleep(ms: number): Promise<void> {
5
12
  return new Promise((resolve) => setTimeout(resolve, ms))