diff --git a/src/utils/collections/map.ts b/src/utils/collections/map.ts new file mode 100644 index 0000000..3f87d48 --- /dev/null +++ b/src/utils/collections/map.ts @@ -0,0 +1,454 @@ +import { EqualityComparer, createDefaultEqualityComparer } from "@/utils/comparison"; +import { $i, asArray, isIterable } from "./iterable"; + +/** + * Checks if a given value is an instance of a {@link Map}-like object. + * + * @template K - The key type of the `Map`-like object. + * @template V - The value type of the `Map`-like object. + * + * @param value - The value to be checked. + * + * @returns A boolean indicating whether the value is a `Map`-like object or not. + */ +export function isMap(value: unknown): value is Map { + if (value instanceof Map) { + return true; + } + + const map = value as Map; + return ( + !!map && + typeof map.keys === "function" && + typeof map.values === "function" && + typeof map.entries === "function" && + typeof map.get === "function" && + typeof map.set === "function" && + typeof map.has === "function" && + typeof map.delete === "function" && + typeof map[Symbol.iterator] === "function" + ); +} + +/** + * Checks if a given value is an instance of a {@link ReadOnlyMap}-like object. + * + * @template K - The key type of the `ReadOnlyMap`-like object. + * @template V - The value type of the `ReadOnlyMap`-like object. + * + * @param value - The value to be checked. + * + * @returns A boolean indicating whether the value is a `ReadOnlyMap`-like object or not. + */ +export function isReadOnlyMap(value: unknown): value is ReadonlyMap { + if (value instanceof Map) { + return true; + } + + const map = value as ReadonlyMap; + return ( + !!map && + typeof map.keys === "function" && + typeof map.values === "function" && + typeof map.entries === "function" && + typeof map.get === "function" && + typeof map.has === "function" && + typeof map[Symbol.iterator] === "function" + ); +} + +/** + * Checks if a given value is an instance of a {@link MultiMap}-like object. + * + * @template K - The key type of the `MultiMap`-like object. + * @template V - The value type of the `MultiMap`-like object. + * + * @param value - The value to be checked. + * + * @returns A boolean indicating whether the value is a `MultiMap`-like object or not. + */ +export function isMultiMap(value: unknown): value is MultiMap { + if (value instanceof MultiMap) { + return true; + } + + const multiMap = value as MultiMap; + return ( + isMap(multiMap) && + typeof multiMap.append === "function" + ); +} + +/** + * Implements {@link Map} using an array under the hood. + * + * @template K - The type of keys in the Map. + * @template V - The type of values in the Map. + * + * @remarks + * + * Recommended for small collections and/or for occasions when you need to provide a custom equality comparer. + */ +export class ArrayMap implements Map { + /** + * The array of keys. + */ + private readonly _keys: K[]; + + /** + * The array of values. + */ + private readonly _values: V[]; + + /** + * The equality comparer used to compare keys. + */ + private readonly _comparer: EqualityComparer; + + /** + * Constructs an empty {@link ArrayMap}. + * + * @param comparer - The equality comparer to use for comparing keys. + */ + constructor(comparer?: EqualityComparer); + + /** + * Constructs an {@link ArrayMap} from an iterable of key-value pairs. + * + * @param entries - The iterable of key-value pairs. + * @param comparer - The equality comparer to use for comparing keys. + */ + constructor(entries: Iterable, comparer?: EqualityComparer); + + /** + * Constructs an {@link ArrayMap} from either an iterable of key-value pairs or an equality comparer. + * + * @param entriesOrComparer - The iterable of key-value pairs or the equality comparer to use for comparing keys. + * @param comparer - The equality comparer to use for comparing keys (if `entriesOrComparer` is an iterable). + */ + constructor(entriesOrComparer?: Iterable | EqualityComparer, comparer?: EqualityComparer) { + // If entriesOrComparer is a function, it must be the comparer, so use it. + // Otherwise, use the default comparer. + comparer ??= typeof entriesOrComparer === "function" ? entriesOrComparer : createDefaultEqualityComparer(); + + this._keys = [] as K[]; + this._values = [] as V[]; + this._comparer = comparer; + + // If entriesOrComparer is undefined or is in fact a comparer, create an empty array of entries. + const entries = entriesOrComparer && entriesOrComparer !== comparer ? entriesOrComparer as Iterable : []; + + for (const [key, value] of entries) { + this.set(key, value); + } + } + + /** + * The number of key-value pairs in the map. + */ + get size(): number { + return this._keys.length; + } + + /** + * Gets the value associated with the specified key. + * + * @param key - The key of the value to get. + * + * @returns The value associated with the specified key, or `undefined` if the key is not found. + */ + get(key: K): V | undefined { + const i = $i(this._keys).indexOf(key, this._comparer); + + // Will return `undefined` if i === -1, which is exactly what we are looking for. + return this._values[i]; + } + + /** + * Sets the value associated with the specified key. + * + * @param key - The key of the value to set. + * @param value - The value to set. + * + * @returns This {@link ArrayMap} instance for chaining purposes. + */ + set(key: K, value: V): this { + const i = $i(this._keys).indexOf(key, this._comparer); + if (i === -1) { + this._keys.push(key); + this._values.push(value); + } else { + // Since we use a custom comparer, we need to update the key too. + this._keys[i] = key; + this._values[i] = value; + } + return this; + } + + /** + * Determines whether the map contains the specified key. + * + * @param key - The key to check for. + * + * @returns `true` if the map contains the key; otherwise, `false`. + */ + has(key: K): boolean { + return $i(this._keys).includes(key, this._comparer); + } + + /** + * Removes the entry with the specified key from the map. + * + * @param key - The key of the entry to remove. + * + * @returns `true` if an entry with the specified key was found and removed; otherwise, `false`. + */ + delete(key: K): boolean { + const i = $i(this._keys).indexOf(key, this._comparer); + if (i === -1) { + return false; + } + + this._keys.splice(i, 1); + this._values.splice(i, 1); + return true; + } + + /** + * Removes all key-value pairs from the map. + */ + clear(): void { + this._keys.splice(0); + this._values.splice(0); + } + + /** + * Returns an iterator over the keys in the map. + */ + keys(): IterableIterator { + return this._keys[Symbol.iterator](); + } + + /** + * Returns an iterator over the values in the map. + */ + values(): IterableIterator { + return this._values[Symbol.iterator](); + } + + /** + * Returns an iterator over the entries in the map. + */ + *entries(): IterableIterator<[K, V]> { + const keys = this._keys; + const values = this._values; + + for (let i = 0; i < keys.length; ++i) { + yield [keys[i], values[i]]; + } + } + + /** + * Calls the specified callback function for each key-value pair in the map. + * + * @param callbackFn - This function is called one time for each element in the map. It takes the value, key, and the map itself as arguments. + * @param thisArg - An optional object to which `this` keyword can refer in the `callbackFn` function. + */ + forEach(callbackFn: (value: V, key: K, map: ArrayMap) => void, thisArg?: unknown): void { + callbackFn = thisArg === undefined ? callbackFn : callbackFn.bind(thisArg); + const keys = this._keys; + const values = this._values; + + for (let i = 0; i < keys.length; ++i) { + callbackFn(values[i], keys[i], this); + } + } + + /** + * Returns an iterator over the entries in the map. + */ + [Symbol.iterator](): IterableIterator<[K, V]> { + return this.entries(); + } + + /** + * Returns a string representation of this object. + */ + get [Symbol.toStringTag](): string { + return "Map"; + } +} + +/** + * A multi-map class that allows multiple values per key. + * + * @template K - The type of keys in the MultiMap. + * @template V - The type of values in the MultiMap. + * + * @remarks + * + * This class extends {@link ArrayMap} and stores values in arrays. + */ +export class MultiMap extends ArrayMap { + /** + * Gets the first value associated with the specified key. + * + * @param key - The key of the value to get. + * + * @returns The first value associated with the specified key, or `undefined` if the key is not found. + */ + getFirst(key: K): V | undefined { + return this.get(key)?.[0]; + } + + /** + * Sets a single value associated with the specified key, replacing any existing values. + * + * @param key - The key of the value to set. + * @param value - The value to set. + * + * @returns This {@link MultiMap} instance for chaining purposes. + */ + set(key: K, value: V): this; + + /** + * Sets multiple values associated with the specified key, replacing any existing values. + * + * @param key - The key of the values to set. + * @param value - The iterable of values to set. + * + * @returns This {@link MultiMap} instance for chaining purposes. + */ + set(key: K, value: Iterable): this; + + /** + * Sets a single value or multiple values associated with the specified key, replacing any existing values. + * + * @param key - The key of the value to set. + * @param value - The value or values to set. + * + * @returns This {@link MultiMap} instance for chaining purposes. + */ + set(key: K, value: V | Iterable): this { + const values = isIterable(value) ? asArray(value) : [value]; + return super.set(key, values); + } + + /** + * Appends a single value to the values associated with the specified key. + * + * @param key - The key of the value to append. + * @param value - The value to append. + * + * @returns This {@link MultiMap} instance for chaining purposes. + */ + append(key: K, value: V): this; + + /** + * Appends multiple values to the values associated with the specified key. + * + * @param key - The key of the values to append. + * @param value - The iterable of values to append. + * + * @returns This {@link MultiMap} instance for chaining purposes. + */ + append(key: K, value: Iterable): this; + + /** + * Appends a single value or multiple values to the values associated with the specified key. + * + * @param key - The key of the values to append. + * @param value - The iterable of values to append. + * + * @returns This {@link MultiMap} instance for chaining purposes. + */ + append(key: K, value: V | Iterable): this { + const existingValues = this.get(key); + if (!existingValues) { + return this.set(key, value as V); + } + + if (isIterable(value)) { + existingValues.push(...value); + } else { + existingValues.push(value); + } + return this; + } + + /** + * Removes all values associated with the specified key. + * + * @param key - The key of the values to remove. + * + * @returns `true` if values were found and removed; otherwise, `false`. + */ + delete(key: K): boolean; + + /** + * Removes a specific value associated with the specified key using the optional comparer. + * + * @param key - The key of the value to remove. + * @param value - The value to remove. + * @param comparer - The optional equality comparer to use for comparing values. + * + * @returns `true` if the value was found and removed; otherwise, `false`. + */ + delete(key: K, value: V, comparer?: EqualityComparer): boolean; + + /** + * Removes value/values associated with the specified key. + * + * @param key - The key of the values to remove. + * @param value - The value to remove. + * @param comparer - The optional equality comparer to use for comparing values. + * + * @returns `true` if value/values were found and removed; otherwise, `false`. + */ + delete(key: K, value?: V, comparer?: EqualityComparer): boolean { + if (value === undefined) { + return super.delete(key); + } + + const values = this.get(key); + if (!values) { + return false; + } + + const i = $i(values).indexOf(value, comparer); + if (i === -1) { + return false; + } + + values.splice(i, 1); + return true; + } + + /** + * Returns an iterable of all values in the MultiMap. + */ + flatValues(): Iterable { + return $i(this.values()).flatMap(x => x); + } + + /** + * Returns an iterable of key-value pairs in the MultiMap, where each key is associated with a single value. + */ + flatEntries(): Iterable<[K, V]> { + return $i(this.entries()).flatMap(([key, values]) => $i(values).map(value => [key, value])); + } + + /** + * Calls the specified callback function for each key-value pair in the MultiMap, with each key associated with a single value. + * + * @param callbackFn - This function is called one time for each key-value pair in the MultiMap. It takes the value, key, and the MultiMap itself as arguments. + * @param thisArg - An optional object to which `this` keyword can refer in the `callbackFn` function. + */ + forEachFlat(callbackFn: (value: V, key: K, map: MultiMap) => void, thisArg?: unknown): void { + callbackFn = thisArg === undefined ? callbackFn : callbackFn.bind(thisArg); + + for (const [key, value] of this.flatEntries()) { + callbackFn(value, key, this); + } + } +} diff --git a/tests/unit/utils/collections/map.spec.ts b/tests/unit/utils/collections/map.spec.ts new file mode 100644 index 0000000..3a94404 --- /dev/null +++ b/tests/unit/utils/collections/map.spec.ts @@ -0,0 +1,623 @@ +import { IGNORE_CASE_EQUALITY_COMPARER } from "@/utils/comparison/string-equality-comparer"; +import { isMap, isReadOnlyMap, isMultiMap, ArrayMap, MultiMap } from "@/utils/collections/map"; + +const readOnlyMapLike = { + keys: () => {}, + values: () => {}, + entries: () => {}, + get: () => {}, + has: () => {}, + [Symbol.iterator]: () => {} +}; + +const mapLike = { + ...readOnlyMapLike, + set: () => {}, + delete: () => {}, +}; + +const multiMapLike = { + ...mapLike, + append: () => {}, +}; + +describe("isMap", () => { + test("returns true for Map instances", () => { + expect(isMap(new Map())).toBe(true); + }); + + test("returns true for Map-like objects", () => { + expect(isMap(mapLike)).toBe(true); + }); + + test("returns false for non-Map-like objects", () => { + expect(isMap({})).toBe(false); + }); + + test("returns false for null and undefined", () => { + expect(isMap(null)).toBe(false); + expect(isMap(undefined)).toBe(false); + }); +}); + +describe("isReadOnlyMap", () => { + test("returns true for Map instances", () => { + expect(isReadOnlyMap(new Map())).toBe(true); + }); + + test("returns true for ReadOnlyMap-like objects", () => { + expect(isReadOnlyMap(readOnlyMapLike)).toBe(true); + }); + + test("returns false for non-ReadOnlyMap-like objects", () => { + expect(isReadOnlyMap({})).toBe(false); + }); + + test("returns false for null and undefined", () => { + expect(isReadOnlyMap(null)).toBe(false); + expect(isReadOnlyMap(undefined)).toBe(false); + }); +}); + +describe("isMultiMap", () => { + test("returns true for MultiMap instances", () => { + expect(isMultiMap(new MultiMap())).toBe(true); + }); + + test("returns true for MultiMap-like objects", () => { + expect(isMultiMap(multiMapLike)).toBe(true); + }); + + test("returns false for non-MultiMap-like objects", () => { + expect(isMultiMap({})).toBe(false); + }); + + test("returns false for null and undefined", () => { + expect(isMultiMap(null)).toBe(false); + expect(isMultiMap(undefined)).toBe(false); + }); +}); + +describe("ArrayMap", () => { + describe("constructor", () => { + test("creates an empty map when no parameters are provided", () => { + const map = new ArrayMap(); + + expect(map.size).toBe(0); + }); + + test("creates a map from an iterable of entries", () => { + const map = new ArrayMap([[1, "one"], [2, "two"]]); + + expect(map.size).toBe(2); + expect(map.get(1)).toBe("one"); + expect(map.get(2)).toBe("two"); + }); + + test("creates a map from an iterable of entries and a custom comparer", () => { + const map = new ArrayMap([["one", 1], ["two", 2]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.size).toBe(2); + expect(map.get("ONE")).toBe(1); + expect(map.get("TWO")).toBe(2); + }); + + test("creates a map from an iterable of entries, and eliminates duplicates", () => { + const map = new ArrayMap([[1, "zero"], [2, "two"], [1, "one"]]); + + expect(map.size).toBe(2); + expect(map.get(1)).toBe("one"); + expect(map.get(2)).toBe("two"); + }); + + test("creates a map from an iterable of entries and a custom comparer, and eliminates duplicates", () => { + const map = new ArrayMap([["ONE", -1], ["two", 2], ["one", 1]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.size).toBe(2); + expect(map.get("ONE")).toBe(1); + expect(map.get("TWO")).toBe(2); + }); + }); + + describe("get", () => { + test("returns value associated with the specified key", () => { + const map = new ArrayMap([[1, "one"], [2, "two"]]); + + expect(map.get(1)).toBe("one"); + }); + + test("respects custom comparer when retrieving value by key", () => { + const map = new ArrayMap([["one", 1]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.get("one")).toBe(1); + expect(map.get("One")).toBe(1); + expect(map.get("ONE")).toBe(1); + }); + + test("returns undefined if the key is not found", () => { + const map = new ArrayMap(); + + expect(map.get(1)).toBeUndefined(); + }); + }); + + describe("set", () => { + test("adds a new key-value pair if the key is not present", () => { + const map = new ArrayMap(); + + map.set(1, "one"); + expect(map.get(1)).toBe("one"); + expect(map.size).toBe(1); + }); + + test("respects custom comparer when setting value by key", () => { + const map = new ArrayMap([["one", 1]], IGNORE_CASE_EQUALITY_COMPARER); + + map.set("Two", 2); + expect(map.get("two")).toBe(2); + + map.set("TWO", 3); + expect(map.get("two")).toBe(3); + }); + + test("updates the value if the key is already present", () => { + const map = new ArrayMap([[1, "one"]]); + + map.set(1, "updated"); + expect(map.get(1)).toBe("updated"); + expect(map.size).toBe(1); + }); + }); + + describe("has", () => { + test("returns true if the key is present", () => { + const map = new ArrayMap([[1, "one"]]); + + expect(map.has(1)).toBe(true); + }); + + test("respects custom comparer when checking for key presence", () => { + const map = new ArrayMap([["one", 1]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.has("one")).toBe(true); + expect(map.has("One")).toBe(true); + expect(map.has("ONE")).toBe(true); + }); + + test("returns false if the key is not present", () => { + const map = new ArrayMap(); + + expect(map.has(1)).toBe(false); + }); + }); + + describe("delete", () => { + test("removes the entry with the specified key", () => { + const map = new ArrayMap([[1, "one"], [2, "two"]]); + + expect(map.delete(1)).toBe(true); + expect(map.has(1)).toBe(false); + expect(map.size).toBe(1); + }); + + test("respects custom comparer when deleting by key", () => { + const map = new ArrayMap([["one", 1]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.delete("One")).toBe(true); + expect(map.has("one")).toBe(false); + expect(map.delete("ONE")).toBe(false); + }); + + test("returns false if the key is not present", () => { + const map = new ArrayMap(); + + expect(map.delete(1)).toBe(false); + }); + }); + + describe("clear", () => { + test("removes all entries", () => { + const map = new ArrayMap([[1, "one"], [2, "two"]]); + map.clear(); + + expect(map.size).toBe(0); + expect(map.get(1)).toBeUndefined(); + expect(map.has(1)).toBe(false); + }); + }); + + describe("keys", () => { + test("returns an iterator over the keys", () => { + const map = new ArrayMap([[1, "one"], [2, "two"]]); + const keys = Array.from(map.keys()); + + expect(keys).toEqual([1, 2]); + }); + }); + + describe("values", () => { + test("returns an iterator over the values", () => { + const map = new ArrayMap([[1, "one"], [2, "two"]]); + const values = Array.from(map.values()); + + expect(values).toEqual(["one", "two"]); + }); + }); + + describe("entries", () => { + test("returns an iterator over the entries", () => { + const map = new ArrayMap([[1, "one"], [2, "two"]]); + const entries = Array.from(map.entries()); + + expect(entries).toEqual([[1, "one"], [2, "two"]]); + }); + }); + + describe("forEach", () => { + test("calls the specified callback function for each entry", () => { + const map = new ArrayMap([[1, "one"], [2, "two"]]); + const callback = jest.fn(); + + map.forEach(callback); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledWith("one", 1, map); + expect(callback).toHaveBeenCalledWith("two", 2, map); + }); + + test("binds the callback function to the provided thisArg", () => { + const map = new ArrayMap([[1, "one"]]); + const thisArg = {}; + + map.forEach(function (this: typeof thisArg) { + expect(this).toBe(thisArg); + }, thisArg); + }); + }); + + describe("[Symbol.iterator]", () => { + test("returns an iterator over the entries", () => { + const map = new ArrayMap([[1, "one"], [2, "two"]]); + const entries = Array.from(map[Symbol.iterator]()); + + expect(entries).toEqual([[1, "one"], [2, "two"]]); + }); + }); + + describe("[Symbol.toStringTag]", () => { + test("returns 'Map'", () => { + const map = new ArrayMap(); + + expect(map[Symbol.toStringTag]).toBe("Map"); + }); + }); +}); + +describe("MultiMap", () => { + describe("constructor", () => { + test("creates an empty map when no parameters are provided", () => { + const map = new MultiMap(); + + expect(map.size).toBe(0); + }); + + test("creates a map from an iterable of entries", () => { + const map = new MultiMap([[1, ["one"]], [2, ["two"]]]); + + expect(map.size).toBe(2); + expect(map.getFirst(1)).toBe("one"); + expect(map.getFirst(2)).toBe("two"); + }); + + test("creates a map from an iterable of entries and a custom comparer", () => { + const map = new MultiMap([["one", [1]], ["two", [2]]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.size).toBe(2); + expect(map.getFirst("ONE")).toBe(1); + expect(map.getFirst("TWO")).toBe(2); + }); + + test("creates a map from an iterable of entries, and eliminates duplicates", () => { + const map = new MultiMap([[1, ["zero"]], [2, ["two"]], [1, ["one"]]]); + + expect(map.size).toBe(2); + expect(map.getFirst(1)).toBe("one"); + expect(map.getFirst(2)).toBe("two"); + }); + + test("creates a map from an iterable of entries and a custom comparer, and eliminates duplicates", () => { + const map = new MultiMap([["ONE", [-1]], ["two", [2]], ["one", [1]]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.size).toBe(2); + expect(map.getFirst("ONE")).toBe(1); + expect(map.getFirst("TWO")).toBe(2); + }); + }); + + describe("get", () => { + test("returns value associated with the specified key", () => { + const map = new MultiMap([[1, ["one"]], [2, ["two"]]]); + + expect(map.get(1)).toEqual(["one"]); + }); + + test("respects custom comparer when retrieving value by key", () => { + const map = new MultiMap([["one", [1]]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.get("one")).toEqual([1]); + expect(map.get("One")).toEqual([1]); + expect(map.get("ONE")).toEqual([1]); + }); + + test("returns undefined if the key is not found", () => { + const map = new MultiMap(); + + expect(map.get(1)).toBeUndefined(); + }); + }); + + describe("getFirst", () => { + test("returns the first value for a given key", () => { + const map = new MultiMap([[1, ["one", "One", "ONE"]]]); + + expect(map.getFirst(1)).toBe("one"); + }); + + test("respects custom comparer when retrieving the first value by key", () => { + const map = new MultiMap([["one", [1, -1]]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.getFirst("one")).toEqual(1); + expect(map.getFirst("One")).toEqual(1); + expect(map.getFirst("ONE")).toEqual(1); + }); + + test("returns undefined if the key is not found", () => { + const map = new MultiMap(); + + expect(map.getFirst(1)).toBeUndefined(); + }); + }); + + describe("set", () => { + test("adds a new key-value pair if the key is not present", () => { + const map = new MultiMap(); + + map.set(1, ["one"]); + expect(map.getFirst(1)).toBe("one"); + expect(map.size).toBe(1); + }); + + test("updates the value if the key is already present", () => { + const map = new MultiMap([[1, ["one"]]]); + + map.set(1, ["updated"]); + expect(map.getFirst(1)).toBe("updated"); + expect(map.size).toBe(1); + }); + + test("sets a single value for a given key", () => { + const map = new MultiMap(); + map.set("one", 1); + + expect(map.get("one")).toEqual([1]); + }); + + test("respects custom comparer when setting value by key", () => { + const map = new MultiMap([["one", [1]]], IGNORE_CASE_EQUALITY_COMPARER); + + map.set("Two", [2]); + expect(map.getFirst("two")).toBe(2); + + map.set("TWO", 3); + expect(map.getFirst("two")).toBe(3); + }); + }); + + describe("append", () => { + test("appends a single value to existing values for a given key", () => { + const map = new MultiMap([["one", [1]]]); + map.append("one", -1); + + expect(map.get("one")).toEqual([1, -1]); + }); + + test("appends multiple values to existing values for a given key", () => { + const map = new MultiMap([[1, ["one"]]]); + map.append(1, ["One", "ONE"]); + + expect(map.get(1)).toEqual(["one", "One", "ONE"]); + }); + + test("respects custom comparer when appending values by key", () => { + const map = new MultiMap([["one", [1]]], IGNORE_CASE_EQUALITY_COMPARER); + + map.append("One", -1); + map.append("ONE", [1, -1]); + + expect(map.get("one")).toEqual([1, -1, 1, -1]); + }); + }); + + describe("has", () => { + test("returns true if the key is present", () => { + const map = new MultiMap([[1, ["one"]]]); + + expect(map.has(1)).toBe(true); + }); + + test("respects custom comparer when checking for key presence", () => { + const map = new MultiMap([["one", [1]]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.has("one")).toBe(true); + expect(map.has("One")).toBe(true); + expect(map.has("ONE")).toBe(true); + }); + + test("returns false if the key is not present", () => { + const map = new MultiMap(); + + expect(map.has(1)).toBe(false); + }); + }); + + describe("delete", () => { + test("removes the entry with the specified key", () => { + const map = new MultiMap([[1, ["one"]], [2, ["two"]]]); + + expect(map.delete(1)).toBe(true); + expect(map.has(1)).toBe(false); + expect(map.size).toBe(1); + }); + + test("deletes a specific value for a given key", () => { + const map = new MultiMap([[1, ["one", "One", "ONE"]]]); + + expect(map.delete(1, "One")).toBe(true); + expect(map.get(1)).toEqual(["one", "ONE"]); + }); + + test("deletes a specific value for a given key using a custom comparer", () => { + const map = new MultiMap([[1, ["one", "not one"]]]); + + expect(map.delete(1, "ONE", IGNORE_CASE_EQUALITY_COMPARER)).toBe(true); + expect(map.get(1)).toEqual(["not one"]); + }); + + test("respects custom comparer when deleting by key", () => { + const map = new MultiMap([["one", [1]], ["two", [2, -2]]], IGNORE_CASE_EQUALITY_COMPARER); + + expect(map.delete("One")).toBe(true); + expect(map.has("one")).toBe(false); + expect(map.delete("ONE")).toBe(false); + + expect(map.delete("TWO", -2)).toBe(true); + expect(map.has("Two")).toBe(true); + expect(map.get("two")).toEqual([2]); + }); + + test("returns false if the key is not present", () => { + const map = new MultiMap(); + + expect(map.delete(1)).toBe(false); + }); + }); + + describe("clear", () => { + test("removes all entries", () => { + const map = new MultiMap([[1, ["one"]], [2, ["two"]]]); + map.clear(); + + expect(map.size).toBe(0); + expect(map.get(1)).toBeUndefined(); + expect(map.has(1)).toBe(false); + }); + }); + + describe("keys", () => { + test("returns an iterator over the keys", () => { + const map = new MultiMap([[1, ["one"]], [2, ["two"]]]); + const keys = Array.from(map.keys()); + + expect(keys).toEqual([1, 2]); + }); + }); + + describe("values", () => { + test("returns an iterator over the values", () => { + const map = new MultiMap([[1, ["one"]], [2, ["two"]]]); + const values = Array.from(map.values()); + + expect(values).toEqual([["one"], ["two"]]); + }); + }); + + describe("flatValues", () => { + test("returns an iterator over all values", () => { + const map = new MultiMap([[1, ["one", "One"]], [2, ["two", "Two"]]]); + const values = Array.from(map.flatValues()); + + expect(values).toEqual(["one", "One", "two", "Two"]); + }); + }); + + describe("entries", () => { + test("returns an iterator over the entries", () => { + const map = new MultiMap([[1, ["one"]], [2, ["two"]]]); + const entries = Array.from(map.entries()); + + expect(entries).toEqual([[1, ["one"]], [2, ["two"]]]); + }); + }); + + describe("flatEntries", () => { + test("returns an iterable of key-value pairs, with each key associated with a single value", () => { + const map = new MultiMap([[1, ["one", "One"]], [2, ["two", "Two"]]]); + const entries = Array.from(map.flatEntries()); + + expect(entries).toEqual([[1, "one"], [1, "One"], [2, "two"], [2, "Two"]]); + }); + }); + + describe("forEach", () => { + test("calls the specified callback function for each entry", () => { + const map = new MultiMap([[1, ["one"]], [2, ["two"]]]); + const callback = jest.fn(); + + map.forEach(callback); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledWith(["one"], 1, map); + expect(callback).toHaveBeenCalledWith(["two"], 2, map); + }); + + test("binds the callback function to the provided thisArg", () => { + const map = new MultiMap([[1, ["one"]]]); + const thisArg = {}; + + map.forEach(function (this: typeof thisArg) { + expect(this).toBe(thisArg); + }, thisArg); + }); + }); + + describe("forEachFlat", () => { + test("calls the callback function for each standalone value coupled with its key", () => { + const map = new MultiMap([[1, ["one", "One"]], [2, ["two", "Two"]]]); + const callbackFn = jest.fn(); + + map.forEachFlat(callbackFn); + + expect(callbackFn).toHaveBeenCalledTimes(4); + expect(callbackFn).toHaveBeenNthCalledWith(1, "one", 1, map); + expect(callbackFn).toHaveBeenNthCalledWith(2, "One", 1, map); + expect(callbackFn).toHaveBeenNthCalledWith(3, "two", 2, map); + expect(callbackFn).toHaveBeenNthCalledWith(4, "Two", 2, map); + }); + + test("binds the callback function to the provided thisArg", () => { + const map = new MultiMap([[1, ["one"]]]); + const thisArg = {}; + + map.forEachFlat(function (this: typeof thisArg) { + expect(this).toBe(thisArg); + }, thisArg); + }); + }); + + describe("[Symbol.iterator]", () => { + test("returns an iterator over the entries", () => { + const map = new MultiMap([[1, ["one"]], [2, ["two"]]]); + const entries = Array.from(map[Symbol.iterator]()); + + expect(entries).toEqual([[1, ["one"]], [2, ["two"]]]); + }); + }); + + describe("[Symbol.toStringTag]", () => { + test("returns 'Map'", () => { + const map = new MultiMap(); + + expect(map[Symbol.toStringTag]).toBe("Map"); + }); + }); +});