From 06e7025415264a87a9673a79b756f5ec94c2d346 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Wed, 30 Mar 2022 13:03:22 -0700 Subject: [PATCH 1/6] Frozen collections --- src/FrozenMap.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ src/FrozenSet.ts | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/FrozenMap.ts create mode 100644 src/FrozenSet.ts diff --git a/src/FrozenMap.ts b/src/FrozenMap.ts new file mode 100644 index 000000000..0d8f8e16e --- /dev/null +++ b/src/FrozenMap.ts @@ -0,0 +1,63 @@ +export class FrozenMap implements ReadonlyMap { + #_map: Map; + + public entries: ReadonlyMap['entries']; + + public forEach: ReadonlyMap['forEach']; + + public get: ReadonlyMap['get']; + + public has: ReadonlyMap['has']; + + public keys: ReadonlyMap['keys']; + + public values: ReadonlyMap['values']; + + public get size() { + return this.#_map.size; + } + + public get [Symbol.iterator]() { + return this.#_map[Symbol.iterator]; + } + + constructor(entries?: readonly (readonly [Key, Value])[] | null) { + this.#_map = new Map(entries); + + this.entries = this.#_map.entries.bind(this.#_map); + this.forEach = this.#_map.forEach.bind(this.#_map); + this.get = this.#_map.get.bind(this.#_map); + this.has = this.#_map.has.bind(this.#_map); + this.keys = this.#_map.keys.bind(this.#_map); + this.values = this.#_map.values.bind(this.#_map); + + Object.freeze(this); + } + + public toString(): string { + return `FrozenMap {${ + this.size > 0 + ? ` ${[...this.entries()] + .map(([key, value]) => `${String(key)} => ${String(value)}`) + .join(', ')} ` + : '' + }}`; + } +} + +const foo = new FrozenMap([ + ['a', 1], + ['b', 2], + ['c', 3], +]); +console.log(foo); +console.log(foo.toString()); +console.log(new FrozenMap().toString()); +console.log(new FrozenMap([['a', 1]]).toString()); +console.log( + new FrozenMap([ + ['a', 1], + ['b', 2], + ]).entries(), +); +console.log(new Map()); diff --git a/src/FrozenSet.ts b/src/FrozenSet.ts new file mode 100644 index 000000000..738c9784c --- /dev/null +++ b/src/FrozenSet.ts @@ -0,0 +1,49 @@ +export class FrozenSet implements ReadonlySet { + #_set: Set; + + public entries: ReadonlySet['entries']; + + public forEach: ReadonlySet['forEach']; + + public has: ReadonlySet['has']; + + public keys: ReadonlySet['keys']; + + public values: ReadonlySet['values']; + + public get size() { + return this.#_set.size; + } + + public get [Symbol.iterator]() { + return this.#_set[Symbol.iterator]; + } + + constructor(values?: readonly Type[] | null) { + this.#_set = new Set(values); + + this.entries = this.#_set.entries.bind(this.#_set); + this.forEach = this.#_set.forEach.bind(this.#_set); + this.has = this.#_set.has.bind(this.#_set); + this.keys = this.#_set.keys.bind(this.#_set); + this.values = this.#_set.values.bind(this.#_set); + + Object.freeze(this); + } + + public toString(): string { + return `FrozenSet {${ + this.size > 0 + ? ` ${[...this.values()].map((member) => String(member)).join(', ')} ` + : '' + }}`; + } +} + +const foo = new FrozenSet(['a', 'b', 'c']); +console.log(foo); +console.log(foo.toString()); +console.log(new FrozenSet().toString()); +console.log(new FrozenSet(['a']).toString()); +console.log(new FrozenSet(['a', 'b']).toString()); +console.log(new Set()); From 716391f15f28ee4d4f03ac248b7d7d6565f965f0 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 6 May 2022 12:14:54 -0700 Subject: [PATCH 2/6] Slight refactor, create test file --- src/FrozenMap.ts | 63 ------------------------ src/FrozenSet.ts | 49 ------------------- src/collections.test.ts | 46 +++++++++++++++++ src/collections.ts | 106 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 112 deletions(-) delete mode 100644 src/FrozenMap.ts delete mode 100644 src/FrozenSet.ts create mode 100644 src/collections.test.ts create mode 100644 src/collections.ts diff --git a/src/FrozenMap.ts b/src/FrozenMap.ts deleted file mode 100644 index 0d8f8e16e..000000000 --- a/src/FrozenMap.ts +++ /dev/null @@ -1,63 +0,0 @@ -export class FrozenMap implements ReadonlyMap { - #_map: Map; - - public entries: ReadonlyMap['entries']; - - public forEach: ReadonlyMap['forEach']; - - public get: ReadonlyMap['get']; - - public has: ReadonlyMap['has']; - - public keys: ReadonlyMap['keys']; - - public values: ReadonlyMap['values']; - - public get size() { - return this.#_map.size; - } - - public get [Symbol.iterator]() { - return this.#_map[Symbol.iterator]; - } - - constructor(entries?: readonly (readonly [Key, Value])[] | null) { - this.#_map = new Map(entries); - - this.entries = this.#_map.entries.bind(this.#_map); - this.forEach = this.#_map.forEach.bind(this.#_map); - this.get = this.#_map.get.bind(this.#_map); - this.has = this.#_map.has.bind(this.#_map); - this.keys = this.#_map.keys.bind(this.#_map); - this.values = this.#_map.values.bind(this.#_map); - - Object.freeze(this); - } - - public toString(): string { - return `FrozenMap {${ - this.size > 0 - ? ` ${[...this.entries()] - .map(([key, value]) => `${String(key)} => ${String(value)}`) - .join(', ')} ` - : '' - }}`; - } -} - -const foo = new FrozenMap([ - ['a', 1], - ['b', 2], - ['c', 3], -]); -console.log(foo); -console.log(foo.toString()); -console.log(new FrozenMap().toString()); -console.log(new FrozenMap([['a', 1]]).toString()); -console.log( - new FrozenMap([ - ['a', 1], - ['b', 2], - ]).entries(), -); -console.log(new Map()); diff --git a/src/FrozenSet.ts b/src/FrozenSet.ts deleted file mode 100644 index 738c9784c..000000000 --- a/src/FrozenSet.ts +++ /dev/null @@ -1,49 +0,0 @@ -export class FrozenSet implements ReadonlySet { - #_set: Set; - - public entries: ReadonlySet['entries']; - - public forEach: ReadonlySet['forEach']; - - public has: ReadonlySet['has']; - - public keys: ReadonlySet['keys']; - - public values: ReadonlySet['values']; - - public get size() { - return this.#_set.size; - } - - public get [Symbol.iterator]() { - return this.#_set[Symbol.iterator]; - } - - constructor(values?: readonly Type[] | null) { - this.#_set = new Set(values); - - this.entries = this.#_set.entries.bind(this.#_set); - this.forEach = this.#_set.forEach.bind(this.#_set); - this.has = this.#_set.has.bind(this.#_set); - this.keys = this.#_set.keys.bind(this.#_set); - this.values = this.#_set.values.bind(this.#_set); - - Object.freeze(this); - } - - public toString(): string { - return `FrozenSet {${ - this.size > 0 - ? ` ${[...this.values()].map((member) => String(member)).join(', ')} ` - : '' - }}`; - } -} - -const foo = new FrozenSet(['a', 'b', 'c']); -console.log(foo); -console.log(foo.toString()); -console.log(new FrozenSet().toString()); -console.log(new FrozenSet(['a']).toString()); -console.log(new FrozenSet(['a', 'b']).toString()); -console.log(new Set()); diff --git a/src/collections.test.ts b/src/collections.test.ts new file mode 100644 index 000000000..7758a9d6c --- /dev/null +++ b/src/collections.test.ts @@ -0,0 +1,46 @@ +import { FrozenMap, FrozenSet } from './collections'; + +// describe('FrozenMap', () => { +// it.todo('does the thing'); +// }) + +// describe('FrozenSet', () => { +// it.todo('does the thing'); +// }) + +console.log(Object.isFrozen(FrozenSet)); +console.log(Object.isFrozen(FrozenSet.prototype)); + +const foo = new FrozenSet(['a', 'b', 'c']); +console.log(foo); +console.log(foo.toString()); +console.log(new FrozenSet().toString()); +console.log(new FrozenSet(['a']).toString()); +console.log(new FrozenSet(['a', 'b']).toString()); +console.log(new Set()); +console.log(new Set(['a', 'b', 'c'])); + +console.log(); +console.log(); + +const bar = new FrozenMap([ + ['a', 1], + ['b', 2], + ['c', 3], +]); +console.log(bar); +console.log(bar.toString()); +console.log(new FrozenMap().toString()); +console.log(new FrozenMap([['a', 1]]).toString()); +console.log( + new FrozenMap([ + ['a', 1], + ['b', 2], + ]).entries(), +); +console.log(new Map()); +console.log(new Map([ + ['a', 1], + ['b', 2], + ['c', 3], +])); diff --git a/src/collections.ts b/src/collections.ts new file mode 100644 index 000000000..fdc02490e --- /dev/null +++ b/src/collections.ts @@ -0,0 +1,106 @@ +/** + * A {@link ReadonlyMap} that cannot be modified after instantiation. + */ +class FrozenMap implements ReadonlyMap { + #map: Map; + + public entries: ReadonlyMap['entries']; + + public forEach: ReadonlyMap['forEach']; + + public get: ReadonlyMap['get']; + + public has: ReadonlyMap['has']; + + public keys: ReadonlyMap['keys']; + + public values: ReadonlyMap['values']; + + public get size() { + return this.#map.size; + } + + public get [Symbol.iterator]() { + return this.#map[Symbol.iterator]; + } + + constructor(entries?: readonly (readonly [Key, Value])[] | null) { + this.#map = new Map(entries); + + this.entries = this.#map.entries.bind(this.#map); + this.forEach = this.#map.forEach.bind(this.#map); + this.get = this.#map.get.bind(this.#map); + this.has = this.#map.has.bind(this.#map); + this.keys = this.#map.keys.bind(this.#map); + this.values = this.#map.values.bind(this.#map); + + Object.freeze(this); + } + + public toString(): string { + return `FrozenMap(${this.size}) {${ + this.size > 0 + ? ` ${[...this.entries()] + .map(([key, value]) => `${String(key)} => ${String(value)}`) + .join(', ')} ` + : '' + }}`; + } +} + +/** + * A {@link ReadonlySet} that cannot be modified after instantiation. + */ +class FrozenSet implements ReadonlySet { + #set: Set; + + public entries: ReadonlySet['entries']; + + public forEach: ReadonlySet['forEach']; + + public has: ReadonlySet['has']; + + public keys: ReadonlySet['keys']; + + public values: ReadonlySet['values']; + + public get size() { + return this.#set.size; + } + + public get [Symbol.iterator]() { + return this.#set[Symbol.iterator]; + } + + constructor(values?: readonly Value[] | null) { + this.#set = new Set(values); + + this.entries = this.#set.entries.bind(this.#set); + this.forEach = this.#set.forEach.bind(this.#set); + this.has = this.#set.has.bind(this.#set); + this.keys = this.#set.keys.bind(this.#set); + this.values = this.#set.values.bind(this.#set); + + Object.freeze(this); + } + + public toString(): string { + return `FrozenSet(${this.size}) {${ + this.size > 0 + ? ` ${[...this.values()].map((member) => String(member)).join(', ')} ` + : '' + }}`; + } +} + +Object.freeze(FrozenSet) +Object.freeze(FrozenSet.prototype) + +Object.freeze(FrozenMap) +Object.freeze(FrozenMap.prototype) + + +export { + FrozenMap, + FrozenSet, +} From 4f477f77662cf74dd8e8fdc3493166c140091184 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 6 May 2022 13:32:57 -0700 Subject: [PATCH 3/6] Fixup, add tests --- src/collections.test.ts | 176 ++++++++++++++++++++++++++++++---------- src/collections.ts | 54 ++++++------ 2 files changed, 160 insertions(+), 70 deletions(-) diff --git a/src/collections.test.ts b/src/collections.test.ts index 7758a9d6c..eb6680c75 100644 --- a/src/collections.test.ts +++ b/src/collections.test.ts @@ -1,46 +1,134 @@ import { FrozenMap, FrozenSet } from './collections'; -// describe('FrozenMap', () => { -// it.todo('does the thing'); -// }) - -// describe('FrozenSet', () => { -// it.todo('does the thing'); -// }) - -console.log(Object.isFrozen(FrozenSet)); -console.log(Object.isFrozen(FrozenSet.prototype)); - -const foo = new FrozenSet(['a', 'b', 'c']); -console.log(foo); -console.log(foo.toString()); -console.log(new FrozenSet().toString()); -console.log(new FrozenSet(['a']).toString()); -console.log(new FrozenSet(['a', 'b']).toString()); -console.log(new Set()); -console.log(new Set(['a', 'b', 'c'])); - -console.log(); -console.log(); - -const bar = new FrozenMap([ - ['a', 1], - ['b', 2], - ['c', 3], -]); -console.log(bar); -console.log(bar.toString()); -console.log(new FrozenMap().toString()); -console.log(new FrozenMap([['a', 1]]).toString()); -console.log( - new FrozenMap([ - ['a', 1], - ['b', 2], - ]).entries(), -); -console.log(new Map()); -console.log(new Map([ - ['a', 1], - ['b', 2], - ['c', 3], -])); +describe('FrozenMap', () => { + describe('immutability', () => { + it('is frozen and cannot be mutated', () => { + const frozenMap: any = new FrozenMap(); + expect(frozenMap.set).toBeUndefined(); + expect(frozenMap.clear).toBeUndefined(); + + const expectedProperties = new Set([ + 'entries', + 'forEach', + 'get', + 'has', + 'keys', + 'values', + ]); + const properties = Object.getOwnPropertyNames(frozenMap); + expect(properties).toHaveLength(expectedProperties.size); + properties.forEach((propertyName) => + expect(expectedProperties.has(propertyName)).toBe(true), + ); + + expect(frozenMap.valueOf() instanceof Map).toBe(false); + + expect(Object.isFrozen(frozenMap)).toBe(true); + expect(Object.isFrozen(frozenMap.prototype)).toBe(true); + + expect(Object.isFrozen(FrozenMap)).toBe(true); + expect(Object.isFrozen(FrozenMap.prototype)).toBe(true); + }); + }); + + describe('iteration', () => { + it('can be used as an iterator', () => { + const input = [ + ['a', 1], + ['b', 2], + ['c', 3], + ] as const; + const map = new Map([...input]); + const frozenMap = new FrozenMap([...input]); + + for (const [key, value] of frozenMap) { + expect(map.get(key)).toStrictEqual(value); + } + }); + }); + + describe('toString', () => { + it('stringifies as expected', () => { + expect(new FrozenMap().toString()).toMatchInlineSnapshot( + `"FrozenMap(0) {}"`, + ); + + expect(new FrozenMap([['a', 1]]).toString()).toMatchInlineSnapshot( + `"FrozenMap(1) { a => 1 }"`, + ); + + expect( + new FrozenMap([ + ['a', 1], + ['b', 2], + ['c', 3], + ]).toString(), + ).toMatchInlineSnapshot(`"FrozenMap(3) { a => 1, b => 2, c => 3 }"`); + }); + }); + // describe('forEach', () => {}) + // describe('get', () => {}) + // describe('has', () => {}) + // describe('keys', () => {}) + // describe('values', () => {}) + // describe('size', () => {}) +}); + +describe('FrozenSet', () => { + describe('immutability', () => { + it('is frozen and cannot be mutated', () => { + const frozenSet: any = new FrozenSet(); + expect(frozenSet.set).toBeUndefined(); + expect(frozenSet.clear).toBeUndefined(); + + const expectedProperties = new Set([ + 'entries', + 'forEach', + 'has', + 'keys', + 'values', + ]); + const properties = Object.getOwnPropertyNames(frozenSet); + expect(properties).toHaveLength(expectedProperties.size); + properties.forEach((propertyName) => + expect(expectedProperties.has(propertyName)).toBe(true), + ); + + expect(frozenSet.valueOf() instanceof Set).toBe(false); + + expect(Object.isFrozen(frozenSet)).toBe(true); + expect(Object.isFrozen(frozenSet.prototype)).toBe(true); + + expect(Object.isFrozen(FrozenSet)).toBe(true); + expect(Object.isFrozen(FrozenSet.prototype)).toBe(true); + }); + }); + + describe('iteration', () => { + it('can be used as an iterator', () => { + const input = ['a', 'b', 'c']; + const set = new Set([...input]); + const frozenSet = new FrozenSet([...input]); + + for (const value of frozenSet) { + expect(set.has(value)).toBe(true); + } + }); + }); + + describe('toString', () => { + it('stringifies as expected', () => { + expect(new FrozenSet().toString()).toMatchInlineSnapshot( + `"FrozenSet(0) {}"`, + ); + + expect(new FrozenSet(['a']).toString()).toMatchInlineSnapshot( + `"FrozenSet(1) { a }"`, + ); + + expect(new FrozenSet(['a', 'b', 'c']).toString()).toMatchInlineSnapshot( + `"FrozenSet(3) { a, b, c }"`, + ); + }); + }); +}); diff --git a/src/collections.ts b/src/collections.ts index fdc02490e..f7d097d70 100644 --- a/src/collections.ts +++ b/src/collections.ts @@ -1,27 +1,30 @@ /** * A {@link ReadonlyMap} that cannot be modified after instantiation. + * The implementation uses an inner map hidden via a private field, and the + * immutability guarantee relies on it being impossible to get a reference + * to this map. */ class FrozenMap implements ReadonlyMap { - #map: Map; + readonly #map: Map; - public entries: ReadonlyMap['entries']; + public readonly entries: ReadonlyMap['entries']; - public forEach: ReadonlyMap['forEach']; + public readonly forEach: ReadonlyMap['forEach']; - public get: ReadonlyMap['get']; + public readonly get: ReadonlyMap['get']; - public has: ReadonlyMap['has']; + public readonly has: ReadonlyMap['has']; - public keys: ReadonlyMap['keys']; + public readonly keys: ReadonlyMap['keys']; - public values: ReadonlyMap['values']; + public readonly values: ReadonlyMap['values']; public get size() { return this.#map.size; } - public get [Symbol.iterator]() { - return this.#map[Symbol.iterator]; + public [Symbol.iterator]() { + return this.#map[Symbol.iterator](); } constructor(entries?: readonly (readonly [Key, Value])[] | null) { @@ -50,26 +53,29 @@ class FrozenMap implements ReadonlyMap { /** * A {@link ReadonlySet} that cannot be modified after instantiation. + * The implementation uses an inner set hidden via a private field, and the + * immutability guarantee relies on it being impossible to get a reference + * to this set. */ class FrozenSet implements ReadonlySet { - #set: Set; + readonly #set: Set; - public entries: ReadonlySet['entries']; + public readonly entries: ReadonlySet['entries']; - public forEach: ReadonlySet['forEach']; + public readonly forEach: ReadonlySet['forEach']; - public has: ReadonlySet['has']; + public readonly has: ReadonlySet['has']; - public keys: ReadonlySet['keys']; + public readonly keys: ReadonlySet['keys']; - public values: ReadonlySet['values']; + public readonly values: ReadonlySet['values']; public get size() { return this.#set.size; } - public get [Symbol.iterator]() { - return this.#set[Symbol.iterator]; + public [Symbol.iterator]() { + return this.#set[Symbol.iterator](); } constructor(values?: readonly Value[] | null) { @@ -93,14 +99,10 @@ class FrozenSet implements ReadonlySet { } } -Object.freeze(FrozenSet) -Object.freeze(FrozenSet.prototype) +Object.freeze(FrozenSet); +Object.freeze(FrozenSet.prototype); -Object.freeze(FrozenMap) -Object.freeze(FrozenMap.prototype) +Object.freeze(FrozenMap); +Object.freeze(FrozenMap.prototype); - -export { - FrozenMap, - FrozenSet, -} +export { FrozenMap, FrozenSet }; From 56c4ca30cb4e4a69b045d0a8fa0c5f564d6c0a87 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 6 May 2022 14:49:02 -0700 Subject: [PATCH 4/6] fixup! Fixup, add tests --- src/collections.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/collections.test.ts b/src/collections.test.ts index eb6680c75..3b0cab66b 100644 --- a/src/collections.test.ts +++ b/src/collections.test.ts @@ -66,12 +66,6 @@ describe('FrozenMap', () => { ).toMatchInlineSnapshot(`"FrozenMap(3) { a => 1, b => 2, c => 3 }"`); }); }); - // describe('forEach', () => {}) - // describe('get', () => {}) - // describe('has', () => {}) - // describe('keys', () => {}) - // describe('values', () => {}) - // describe('size', () => {}) }); describe('FrozenSet', () => { From 4b90ca4c200558d7d608073f2a745342de5aefdf Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 6 May 2022 22:50:40 -0700 Subject: [PATCH 5/6] Fix security & correctness issues, complete tests --- src/collections.test.ts | 323 ++++++++++++++++++++++++++++++++++++++-- src/collections.ts | 97 +++++++----- 2 files changed, 368 insertions(+), 52 deletions(-) diff --git a/src/collections.test.ts b/src/collections.test.ts index 3b0cab66b..dd9cc9840 100644 --- a/src/collections.test.ts +++ b/src/collections.test.ts @@ -2,26 +2,32 @@ import { FrozenMap, FrozenSet } from './collections'; describe('FrozenMap', () => { describe('immutability', () => { - it('is frozen and cannot be mutated', () => { - const frozenMap: any = new FrozenMap(); - expect(frozenMap.set).toBeUndefined(); - expect(frozenMap.clear).toBeUndefined(); - + it('has the expected class properties', () => { + // i.e., does not have 'delete', 'set', or 'clear' const expectedProperties = new Set([ + 'constructor', 'entries', 'forEach', 'get', 'has', 'keys', + 'size', 'values', + 'toString', ]); - const properties = Object.getOwnPropertyNames(frozenMap); + + const properties = Object.getOwnPropertyNames(FrozenMap.prototype); + expect(properties).toHaveLength(expectedProperties.size); properties.forEach((propertyName) => expect(expectedProperties.has(propertyName)).toBe(true), ); + }); - expect(frozenMap.valueOf() instanceof Map).toBe(false); + it('is frozen and cannot be mutated', () => { + const frozenMap: any = new FrozenMap(); + expect(frozenMap.set).toBeUndefined(); + expect(frozenMap.clear).toBeUndefined(); expect(Object.isFrozen(frozenMap)).toBe(true); expect(Object.isFrozen(frozenMap.prototype)).toBe(true); @@ -41,9 +47,166 @@ describe('FrozenMap', () => { const map = new Map([...input]); const frozenMap = new FrozenMap([...input]); + let callCount = 0; for (const [key, value] of frozenMap) { expect(map.get(key)).toStrictEqual(value); + callCount += 1; + } + expect(callCount).toStrictEqual(frozenMap.size); + }); + }); + + describe('entries', () => { + it('matches the behavior of Map.entries()', () => { + const mapEntries = new Map([ + ['a', 1], + ['b', 2], + ]).entries(); + const frozenMapEntries = new FrozenMap([ + ['a', 1], + ['b', 2], + ]).entries(); + + for (const key of frozenMapEntries) { + expect(key).toStrictEqual(mapEntries.next().value); + } + + expect(mapEntries.next()).toStrictEqual({ done: true, value: undefined }); + expect(frozenMapEntries.next()).toStrictEqual({ + done: true, + value: undefined, + }); + }); + }); + + describe('forEach', () => { + it('iterates over the map', () => { + const expected = new Map([ + ['a', 1], + ['b', 2], + ['c', 3], + ]); + const deleteSpy = jest.spyOn(expected, 'delete'); + + const frozenMap = new FrozenMap([ + ['a', 1], + ['b', 2], + ['c', 3], + ]); + + frozenMap.forEach((value, key) => { + expect(expected.get(key)).toStrictEqual(value); + expected.delete(key); + }); + + expect(deleteSpy).toHaveBeenCalledTimes(frozenMap.size); + expect(expected.size).toBe(0); + }); + + it('defaults `thisArg` to undefined', () => { + const frozenMap = new FrozenMap([['a', 1]]); + frozenMap.forEach(function () { + // @ts-expect-error: We have to shadow `this` here. + expect(this).toBeUndefined(); // eslint-disable-line no-invalid-this + }); + }); + + it('respects the specified `thisArg`', () => { + const frozenMap = new FrozenMap([['a', 1]]); + + const thisArg = {}; + frozenMap.forEach(function () { + // @ts-expect-error: We have to shadow `this` here. + expect(this).toBe(thisArg); // eslint-disable-line no-invalid-this + }, thisArg); + }); + + it('does not provide a reference to the inner map via the callback', () => { + const frozenMap = new FrozenMap([['a', 1]]); + frozenMap.forEach((_value, _key, map) => { + expect(map).not.toBeInstanceOf(Map); + expect(map).toBeInstanceOf(FrozenMap); + }); + }); + }); + + describe('get', () => { + it('matches the behavior of Map.get()', () => { + const map = new Map([ + ['a', 1], + ['b', 2], + ]); + const frozenMap = new FrozenMap([ + ['a', 1], + ['b', 2], + ]); + + ['a', 'b', 'c'].forEach((key) => { + expect(frozenMap.get(key)).toStrictEqual(map.get(key)); + }); + }); + }); + + describe('has', () => { + it('matches the behavior of Map.has()', () => { + const map = new Map([ + ['a', 1], + ['b', 2], + ]); + const frozenMap = new FrozenMap([ + ['a', 1], + ['b', 2], + ]); + + ['a', 'b', 'c'].forEach((key) => { + expect(frozenMap.has(key)).toStrictEqual(map.has(key)); + }); + }); + }); + + describe('keys', () => { + it('matches the behavior of Map.keys()', () => { + const mapKeys = new Map([ + ['a', 1], + ['b', 2], + ]).keys(); + const frozenMapKeys = new FrozenMap([ + ['a', 1], + ['b', 2], + ]).keys(); + + for (const key of frozenMapKeys) { + expect(key).toStrictEqual(mapKeys.next().value); + } + + expect(mapKeys.next()).toStrictEqual({ done: true, value: undefined }); + expect(frozenMapKeys.next()).toStrictEqual({ + done: true, + value: undefined, + }); + }); + }); + + describe('values', () => { + it('matches the behavior of Map.values()', () => { + const mapValues = new Map([ + ['a', 1], + ['b', 2], + ]).values(); + const frozenMapValues = new FrozenMap([ + ['a', 1], + ['b', 2], + ]).values(); + + for (const key of frozenMapValues) { + expect(key).toStrictEqual(mapValues.next().value); } + + expect(mapValues.next()).toStrictEqual({ done: true, value: undefined }); + expect(frozenMapValues.next()).toStrictEqual({ + done: true, + value: undefined, + }); }); }); @@ -70,25 +233,31 @@ describe('FrozenMap', () => { describe('FrozenSet', () => { describe('immutability', () => { - it('is frozen and cannot be mutated', () => { - const frozenSet: any = new FrozenSet(); - expect(frozenSet.set).toBeUndefined(); - expect(frozenSet.clear).toBeUndefined(); - + it('has the expected class properties', () => { + // i.e., does not have 'delete', 'add', or 'clear' const expectedProperties = new Set([ + 'constructor', 'entries', 'forEach', 'has', 'keys', + 'size', + 'toString', 'values', ]); - const properties = Object.getOwnPropertyNames(frozenSet); + + const properties = Object.getOwnPropertyNames(FrozenSet.prototype); + expect(properties).toHaveLength(expectedProperties.size); properties.forEach((propertyName) => expect(expectedProperties.has(propertyName)).toBe(true), ); + }); - expect(frozenSet.valueOf() instanceof Set).toBe(false); + it('is frozen and cannot be mutated', () => { + const frozenSet: any = new FrozenSet(); + expect(frozenSet.set).toBeUndefined(); + expect(frozenSet.clear).toBeUndefined(); expect(Object.isFrozen(frozenSet)).toBe(true); expect(Object.isFrozen(frozenSet.prototype)).toBe(true); @@ -104,9 +273,135 @@ describe('FrozenSet', () => { const set = new Set([...input]); const frozenSet = new FrozenSet([...input]); + let callCount = 0; for (const value of frozenSet) { expect(set.has(value)).toBe(true); + callCount += 1; + } + expect(callCount).toStrictEqual(frozenSet.size); + }); + }); + + describe('entries', () => { + it('matches the behavior of Set.entries()', () => { + const setEntries = new Set([ + ['a', 1], + ['b', 2], + ]).entries(); + const frozenSetEntries = new FrozenSet([ + ['a', 1], + ['b', 2], + ]).entries(); + + for (const key of frozenSetEntries) { + expect(key).toStrictEqual(setEntries.next().value); } + + expect(setEntries.next()).toStrictEqual({ done: true, value: undefined }); + expect(frozenSetEntries.next()).toStrictEqual({ + done: true, + value: undefined, + }); + }); + }); + + describe('forEach', () => { + it('iterates over the set', () => { + const expected = new Set(['a', 'b', 'c']); + const deleteSpy = jest.spyOn(expected, 'delete'); + + const frozenSet = new FrozenSet(['a', 'b', 'c']); + + frozenSet.forEach((value, value2) => { + expect(value).toBe(value2); + expected.delete(value); + }); + + expect(deleteSpy).toHaveBeenCalledTimes(frozenSet.size); + expect(expected.size).toBe(0); + }); + + it('defaults `thisArg` to undefined', () => { + const frozenSet = new FrozenSet(['a']); + frozenSet.forEach(function () { + // @ts-expect-error: We have to shadow `this` here. + expect(this).toBeUndefined(); // eslint-disable-line no-invalid-this + }); + }); + + it('respects the specified `thisArg`', () => { + const frozenSet = new FrozenSet(['a']); + + const thisArg = {}; + frozenSet.forEach(function () { + // @ts-expect-error: We have to shadow `this` here. + expect(this).toBe(thisArg); // eslint-disable-line no-invalid-this + }, thisArg); + }); + + it('does not provide a reference to the inner set via the callback', () => { + const frozenSet = new FrozenSet([['a', 1]]); + frozenSet.forEach((_value, _value2, set) => { + expect(set).not.toBeInstanceOf(Set); + expect(set).toBeInstanceOf(FrozenSet); + }); + }); + }); + + describe('has', () => { + it('matches the behavior of Set.has()', () => { + const set = new Set(['a', 'b']); + const frozenSet = new FrozenSet(['a', 'b']); + + ['a', 'b', 'c'].forEach((value) => { + expect(frozenSet.has(value)).toStrictEqual(set.has(value)); + }); + }); + }); + + describe('keys', () => { + it('matches the behavior of Set.keys()', () => { + const setKeys = new Set([ + ['a', 1], + ['b', 2], + ]).keys(); + const frozenSetKeys = new FrozenSet([ + ['a', 1], + ['b', 2], + ]).keys(); + + for (const key of frozenSetKeys) { + expect(key).toStrictEqual(setKeys.next().value); + } + + expect(setKeys.next()).toStrictEqual({ done: true, value: undefined }); + expect(frozenSetKeys.next()).toStrictEqual({ + done: true, + value: undefined, + }); + }); + }); + + describe('values', () => { + it('matches the behavior of Set.values()', () => { + const setValues = new Set([ + ['a', 1], + ['b', 2], + ]).values(); + const frozenSetValues = new FrozenSet([ + ['a', 1], + ['b', 2], + ]).values(); + + for (const key of frozenSetValues) { + expect(key).toStrictEqual(setValues.next().value); + } + + expect(setValues.next()).toStrictEqual({ done: true, value: undefined }); + expect(frozenSetValues.next()).toStrictEqual({ + done: true, + value: undefined, + }); }); }); diff --git a/src/collections.ts b/src/collections.ts index f7d097d70..5072f77f0 100644 --- a/src/collections.ts +++ b/src/collections.ts @@ -7,18 +7,6 @@ class FrozenMap implements ReadonlyMap { readonly #map: Map; - public readonly entries: ReadonlyMap['entries']; - - public readonly forEach: ReadonlyMap['forEach']; - - public readonly get: ReadonlyMap['get']; - - public readonly has: ReadonlyMap['has']; - - public readonly keys: ReadonlyMap['keys']; - - public readonly values: ReadonlyMap['values']; - public get size() { return this.#map.size; } @@ -29,15 +17,38 @@ class FrozenMap implements ReadonlyMap { constructor(entries?: readonly (readonly [Key, Value])[] | null) { this.#map = new Map(entries); + Object.freeze(this); + } - this.entries = this.#map.entries.bind(this.#map); - this.forEach = this.#map.forEach.bind(this.#map); - this.get = this.#map.get.bind(this.#map); - this.has = this.#map.has.bind(this.#map); - this.keys = this.#map.keys.bind(this.#map); - this.values = this.#map.values.bind(this.#map); + public entries() { + return this.#map.entries(); + } - Object.freeze(this); + public forEach( + callbackfn: (value: Value, key: Key, map: this) => void, + thisArg?: any, + ): void { + // We have to wrap the specified callback in order to prevent it from + // receiving a reference to the inner map. + return this.#map.forEach((value: Value, key: Key, _map: unknown) => + callbackfn.call(thisArg, value, key, this), + ); + } + + public get(key: Key) { + return this.#map.get(key); + } + + public has(key: Key) { + return this.#map.has(key); + } + + public keys() { + return this.#map.keys(); + } + + public values() { + return this.#map.values(); } public toString(): string { @@ -60,16 +71,6 @@ class FrozenMap implements ReadonlyMap { class FrozenSet implements ReadonlySet { readonly #set: Set; - public readonly entries: ReadonlySet['entries']; - - public readonly forEach: ReadonlySet['forEach']; - - public readonly has: ReadonlySet['has']; - - public readonly keys: ReadonlySet['keys']; - - public readonly values: ReadonlySet['values']; - public get size() { return this.#set.size; } @@ -80,14 +81,34 @@ class FrozenSet implements ReadonlySet { constructor(values?: readonly Value[] | null) { this.#set = new Set(values); + Object.freeze(this); + } - this.entries = this.#set.entries.bind(this.#set); - this.forEach = this.#set.forEach.bind(this.#set); - this.has = this.#set.has.bind(this.#set); - this.keys = this.#set.keys.bind(this.#set); - this.values = this.#set.values.bind(this.#set); + public entries() { + return this.#set.entries(); + } - Object.freeze(this); + public forEach( + callbackfn: (value: Value, value2: Value, set: this) => void, + thisArg?: any, + ): void { + // We have to wrap the specified callback in order to prevent it from + // receiving a reference to the inner set. + return this.#set.forEach((value: Value, value2: Value, _set: unknown) => + callbackfn.call(thisArg, value, value2, this), + ); + } + + public has(value: Value) { + return this.#set.has(value); + } + + public keys() { + return this.#set.keys(); + } + + public values() { + return this.#set.values(); } public toString(): string { @@ -99,10 +120,10 @@ class FrozenSet implements ReadonlySet { } } -Object.freeze(FrozenSet); -Object.freeze(FrozenSet.prototype); - Object.freeze(FrozenMap); Object.freeze(FrozenMap.prototype); +Object.freeze(FrozenSet); +Object.freeze(FrozenSet.prototype); + export { FrozenMap, FrozenSet }; From 931bd2a4fda86452c6c75e7d59d6e79cbe590830 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 6 May 2022 22:59:24 -0700 Subject: [PATCH 6/6] Replace complex test with inline snapshot --- src/collections.test.ts | 64 ++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/src/collections.test.ts b/src/collections.test.ts index dd9cc9840..1e5926422 100644 --- a/src/collections.test.ts +++ b/src/collections.test.ts @@ -4,24 +4,20 @@ describe('FrozenMap', () => { describe('immutability', () => { it('has the expected class properties', () => { // i.e., does not have 'delete', 'set', or 'clear' - const expectedProperties = new Set([ - 'constructor', - 'entries', - 'forEach', - 'get', - 'has', - 'keys', - 'size', - 'values', - 'toString', - ]); - - const properties = Object.getOwnPropertyNames(FrozenMap.prototype); - - expect(properties).toHaveLength(expectedProperties.size); - properties.forEach((propertyName) => - expect(expectedProperties.has(propertyName)).toBe(true), - ); + expect(Object.getOwnPropertyNames(FrozenMap.prototype)) + .toMatchInlineSnapshot(` + Array [ + "constructor", + "size", + "entries", + "forEach", + "get", + "has", + "keys", + "values", + "toString", + ] + `); }); it('is frozen and cannot be mutated', () => { @@ -95,7 +91,7 @@ describe('FrozenMap', () => { ]); frozenMap.forEach((value, key) => { - expect(expected.get(key)).toStrictEqual(value); + expect(value).toStrictEqual(expected.get(key)); expected.delete(key); }); @@ -235,23 +231,19 @@ describe('FrozenSet', () => { describe('immutability', () => { it('has the expected class properties', () => { // i.e., does not have 'delete', 'add', or 'clear' - const expectedProperties = new Set([ - 'constructor', - 'entries', - 'forEach', - 'has', - 'keys', - 'size', - 'toString', - 'values', - ]); - - const properties = Object.getOwnPropertyNames(FrozenSet.prototype); - - expect(properties).toHaveLength(expectedProperties.size); - properties.forEach((propertyName) => - expect(expectedProperties.has(propertyName)).toBe(true), - ); + expect(Object.getOwnPropertyNames(FrozenSet.prototype)) + .toMatchInlineSnapshot(` + Array [ + "constructor", + "size", + "entries", + "forEach", + "has", + "keys", + "values", + "toString", + ] + `); }); it('is frozen and cannot be mutated', () => {