diff --git a/CHANGELOG.md b/CHANGELOG.md index f5875826..d95232c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ # Change Log +## 1.1.0 + +### ✨ New + +- Add [`.drop()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/drop), [`.flapMap()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/flatMap), [`.take()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/take), and [`.toArray()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/toArray) iterator helper polyfills to `Enumerable`. + +### ♻️ Update + +- Update `SingletonAction.actions` to return an `Enumerable`. + ## 1.0.1 ### ♻️ Update diff --git a/package-lock.json b/package-lock.json index 506d406f..9bbd5939 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@elgato/streamdeck", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@elgato/streamdeck", - "version": "1.0.1", + "version": "1.1.0", "license": "MIT", "dependencies": { "@elgato/schemas": "^0.3.1", diff --git a/package.json b/package.json index 9a5497db..141ccca4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@elgato/streamdeck", - "version": "1.0.1", + "version": "1.1.0", "description": "The official Node.js SDK for creating Stream Deck plugins.", "main": "./dist/index.js", "type": "module", diff --git a/src/common/__tests__/enumerable.test.ts b/src/common/__tests__/enumerable.test.ts index e9bf40b0..75674850 100644 --- a/src/common/__tests__/enumerable.test.ts +++ b/src/common/__tests__/enumerable.test.ts @@ -195,6 +195,151 @@ describe("Enumerable", () => { expect(enumerable.length).toBe(3); }); }); + + /** + * With IterableIterator delegate. + */ + describe("IterableIterator", () => { + it("iterates mutated map", () => { + // Arrange. + const fn = jest.fn(); + const itr = function* () { + yield "One"; + yield "Two"; + }; + const enumerable = new Enumerable(itr); + + // Act, assert. + enumerable.forEach(fn); + expect(enumerable.length).toBe(2); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith(1, "One"); + expect(fn).toHaveBeenNthCalledWith(2, "Two"); + }); + + it("reads length", () => { + // Arrange. + const itr = function* () { + yield "One"; + yield "Two"; + }; + + // Act, assert. + const enumerable = new Enumerable(itr); + expect(enumerable.length).toBe(2); + }); + }); + }); + + /** + * Asserts {@link Enumerable} implements {@link IterableIterator}. + */ + describe("IterableIterator implementation", () => { + describe("next", () => { + it("iterates all items", () => { + // Arrange. + const enumerable = new Enumerable(["One", "Two", "Three"]); + + // Act, assert. + expect(enumerable.next()).toStrictEqual({ done: false, value: "One" }); + expect(enumerable.next()).toStrictEqual({ done: false, value: "Two" }); + expect(enumerable.next()).toStrictEqual({ done: false, value: "Three" }); + expect(enumerable.next()).toStrictEqual({ done: true, value: undefined }); + }); + + it("re-captures on return", () => { + // Arrange. + const enumerable = new Enumerable(["One", "Two", "Three"]); + + // Act, assert (1). + expect(enumerable.next()).toStrictEqual({ done: false, value: "One" }); + expect(enumerable.return?.("Stop")).toStrictEqual({ done: true, value: "Stop" }); + + // Act, assert (2). + expect(enumerable.next()).toStrictEqual({ done: false, value: "One" }); + expect(enumerable.next()).toStrictEqual({ done: false, value: "Two" }); + expect(enumerable.next()).toStrictEqual({ done: false, value: "Three" }); + expect(enumerable.next()).toStrictEqual({ done: true, value: undefined }); + }); + + it("does not re-capture on throw", () => { + // Arrange. + const enumerable = new Enumerable(["One", "Two", "Three"]); + + // Act, assert.. + expect(enumerable.next()).toStrictEqual({ done: false, value: "One" }); + expect(() => enumerable.throw?.("Staged error")).toThrow("Staged error"); + expect(enumerable.next()).toStrictEqual({ done: false, value: "Two" }); + expect(enumerable.next()).toStrictEqual({ done: false, value: "Three" }); + expect(enumerable.next()).toStrictEqual({ done: true, value: undefined }); + }); + }); + + test("return", () => { + // Arrange. + const enumerable = new Enumerable([1, 2, 3]); + + // Act, assert. + const res = enumerable.return?.("Hello world"); + expect(res?.done).toBe(true); + expect(res?.value).toBe("Hello world"); + }); + + test("throw", () => { + // Arrange. + const enumerable = new Enumerable([1, 2, 3]); + + // Act, assert. + expect(() => enumerable.throw?.("Hello world")).toThrow("Hello world"); + }); + }); + + /** + * Asserts chaining for methods of {@link Enumerable} that support it. + */ + describe("iterator helpers", () => { + it("chains iterators", () => { + // Arrange. + const fn = jest.fn(); + const source = ["One", "Two", "Three"]; + const enumerable = new Enumerable(source); + + // Act. + enumerable + .asIndexedPairs() // [0, "One"], [1, "Two"], [2, "Three"] + .drop(1) // [1, "Two"], [2, "Three"] + .flatMap(([i, value]) => [i, value].values()) // 1, "Two", 2, "Three" + .filter((x) => typeof x === "number") // 1, 2 + .map((x) => { + return { value: x }; + }) // { value: 1 }, { value: 2 } + .take(1) // { value: 1 } + .forEach(fn); + + // Assert. + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenNthCalledWith(1, { value: 1 }); + }); + + it("should not iterate unless necessary", () => { + // Arrange. + const fn = jest.fn(); + const enumerable = new Enumerable(fn); + + // Act. + enumerable + .asIndexedPairs() + .drop(1) + .flatMap(([i, value]) => [i, value].values()) + .filter((x) => typeof x === "number") + .map((x) => { + return { value: x }; + }) + .take(1); + + // Assert. + expect(fn).toHaveBeenCalledTimes(0); + }); }); /** @@ -214,6 +359,82 @@ describe("Enumerable", () => { expect(i).toBe(3); }); + /** + * Asserts the iterator of an {@link Enumerable.asIndexedPairs}. + */ + test("asIndexedPairs", () => { + // Arrange, act. + const fn = jest.fn(); + const res = enumerable.asIndexedPairs(); + + // Assert. + res.forEach(fn); + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenNthCalledWith(1, [0, { name: "Facecam" }]); + expect(fn).toHaveBeenNthCalledWith(2, [1, { name: "Stream Deck" }]); + expect(fn).toHaveBeenNthCalledWith(3, [2, { name: "Wave DX" }]); + }); + + /** + * Provides assertions for {@link Enumerable.drop}. + */ + describe("drop", () => { + it("accepts limit 0", () => { + // Arrange, act, assert + expect(enumerable.drop(0).length).toBe(source.length); + }); + + it("accepts limit 1", () => { + // Arrange. + const fn = jest.fn(); + + // Act. + const res = enumerable.drop(1); + + // Assert. + res.forEach(fn); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith(1, { name: "Stream Deck" }); + expect(fn).toHaveBeenNthCalledWith(2, { name: "Wave DX" }); + }); + + it("accepts limit less than length", () => { + // Arrange. + const fn = jest.fn(); + + // Act. + const res = enumerable.drop(2); + + // Assert. + res.forEach(fn); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith({ name: "Wave DX" }); + }); + + it("accepts limit exceeding length", () => { + // Arrange. + const fn = jest.fn(); + + // Act. + const res = enumerable.drop(4); + + // Assert. + res.forEach(fn); + expect(fn).toHaveBeenCalledTimes(0); + }); + + it("throw for negative", () => { + // Arrange, act, assert + expect(() => enumerable.drop(-1)).toThrow(RangeError); + }); + + it("throw for NaN", () => { + // Arrange, act, assert + // @ts-expect-error Test non-number + expect(() => enumerable.drop("false")).toThrow(RangeError); + }); + }); + /** * Provides assertions for {@link Enumerable.every}. */ @@ -359,6 +580,21 @@ describe("Enumerable", () => { }); }); + test("flatMap", () => { + // Arrange, act. + const fn = jest.fn(); + const res = enumerable.flatMap((x) => x.name.split(" ").values()); + + // Assert. + res.forEach(fn); + expect(fn).toHaveBeenCalledTimes(5); + expect(fn).toHaveBeenNthCalledWith(1, "Facecam"); + expect(fn).toHaveBeenNthCalledWith(2, "Stream"); + expect(fn).toHaveBeenNthCalledWith(3, "Deck"); + expect(fn).toHaveBeenNthCalledWith(4, "Wave"); + expect(fn).toHaveBeenNthCalledWith(5, "DX"); + }); + /** * Provides assertions for {@link Enumerable.forEach}. */ @@ -483,4 +719,96 @@ describe("Enumerable", () => { expect(fn).toHaveBeenCalledWith({ name: "Wave DX" }); }); }); + + /** + * Provides assertions for {@link Enumerable.take}. + */ + describe("take", () => { + it("accepts limit 0", () => { + // Arrange, act, assert + expect(enumerable.take(0).length).toBe(0); + }); + + it("accepts limit 1", () => { + // Arrange. + const fn = jest.fn(); + + // Act. + const res = enumerable.take(1); + + // Assert. + res.forEach(fn); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenNthCalledWith(1, { name: "Facecam" }); + }); + + it("accepts limit less than length", () => { + // Arrange. + const fn = jest.fn(); + + // Act. + const res = enumerable.take(2); + + // Assert. + res.forEach(fn); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith(1, { name: "Facecam" }); + expect(fn).toHaveBeenNthCalledWith(2, { name: "Stream Deck" }); + }); + + it("accepts limit exceeding length", () => { + // Arrange. + const fn = jest.fn(); + + // Act. + const res = enumerable.take(99); + + // Assert. + res.forEach(fn); + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenNthCalledWith(1, { name: "Facecam" }); + expect(fn).toHaveBeenNthCalledWith(2, { name: "Stream Deck" }); + expect(fn).toHaveBeenNthCalledWith(3, { name: "Wave DX" }); + }); + + it("throw for negative", () => { + // Arrange, act, assert + expect(() => enumerable.take(-1)).toThrow(RangeError); + }); + + it("throw for NaN", () => { + // Arrange, act, assert + // @ts-expect-error Test non-number + expect(() => enumerable.take("false")).toThrow(RangeError); + }); + }); + + /** + * Provides assertions for {@link Enumerable.toArray}. + */ + describe("toArray", () => { + it("returns a new array of items", () => { + // Arrange. + const arr = ["One", "Two"]; + const enumerable = new Enumerable(arr); + + // Act. + const res = enumerable.toArray(); + + // Assert. + expect(arr).toEqual(res); + expect(arr).not.toBe(res); + }); + + it("can return an empty array", () => { + // Arrange. + const enumerable = new Enumerable(function* () {}); + + // Act. + const res = enumerable.toArray(); + + // Assert + expect(res).toHaveLength(0); + }); + }); }); diff --git a/src/common/enumerable.ts b/src/common/enumerable.ts index cc122a36..03e92579 100644 --- a/src/common/enumerable.ts +++ b/src/common/enumerable.ts @@ -1,32 +1,52 @@ /** - * Provides a read-only iterable collection of items. + * Provides a read-only iterable collection of items that also acts as a partial polyfill for iterator helpers. */ -export class Enumerable { +export class Enumerable implements IterableIterator { /** * Backing function responsible for providing the iterator of items. */ - readonly #items: () => Iterable; + readonly #items: () => IterableIterator; /** * Backing function for {@link Enumerable.length}. */ readonly #length: () => number; + /** + * Captured iterator from the underlying iterable; used to fulfil {@link IterableIterator} methods. + */ + #iterator: Iterator | undefined; + /** * Initializes a new instance of the {@link Enumerable} class. * @param source Source that contains the items. * @returns The enumerable. */ - constructor(source: Enumerable | Map | Set | T[]) { + constructor(source: Enumerable | Map | Set | T[] | (() => IterableIterator)) { if (source instanceof Enumerable) { + // Enumerable this.#items = source.#items; this.#length = source.#length; } else if (Array.isArray(source)) { - this.#items = (): Iterable => source; + // Array + this.#items = (): IterableIterator => source.values(); this.#length = (): number => source.length; - } else { + } else if (source instanceof Map || source instanceof Set) { + // Map or Set this.#items = (): IterableIterator => source.values(); this.#length = (): number => source.size; + } else { + // IterableIterator delegate + this.#items = source; + this.#length = (): number => { + let i = 0; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of this) { + i++; + } + + return i; + }; } } @@ -48,13 +68,50 @@ export class Enumerable { } } + /** + * Transforms each item within this iterator to an indexed pair, with each pair represented as an array. + * @returns An iterator of indexed pairs. + */ + public asIndexedPairs(): Enumerable<[number, T]> { + return new Enumerable( + function* (this: Enumerable): IterableIterator<[number, T]> { + let i = 0; + for (const item of this) { + yield [i++, item] as [number, T]; + } + }.bind(this) + ); + } + + /** + * Returns an iterator with the first items dropped, up to the specified limit. + * @param limit The number of elements to drop from the start of the iteration. + * @returns An iterator of items after the limit. + */ + public drop(limit: number): Enumerable { + if (isNaN(limit) || limit < 0) { + throw new RangeError("limit must be 0, or a positive number"); + } + + return new Enumerable( + function* (this: Enumerable): IterableIterator { + let i = 0; + for (const item of this) { + if (i++ >= limit) { + yield item; + } + } + }.bind(this) + ); + } + /** * Determines whether all items satisfy the specified predicate. * @param predicate Function that determines whether each item fulfils the predicate. * @returns `true` when all items satisfy the predicate; otherwise `false`. */ public every(predicate: (value: T) => boolean): boolean { - for (const item of this.#items()) { + for (const item of this) { if (!predicate(item)) { return false; } @@ -64,16 +121,20 @@ export class Enumerable { } /** - * Returns an iterable of items that meet the specified condition. + * Returns an iterator of items that meet the specified predicate.. * @param predicate Function that determines which items to filter. - * @yields The filtered items; items that returned `true` when invoked against the predicate. + * @returns An iterator of filtered items. */ - public *filter(predicate: (value: T) => boolean): IterableIterator { - for (const item of this.#items()) { - if (predicate(item)) { - yield item; - } - } + public filter(predicate: (value: T) => boolean): Enumerable { + return new Enumerable( + function* (this: Enumerable): IterableIterator { + for (const item of this) { + if (predicate(item)) { + yield item; + } + } + }.bind(this) + ); } /** @@ -82,7 +143,7 @@ export class Enumerable { * @returns The first item that satisfied the predicate; otherwise `undefined`. */ public find(predicate: (value: T) => boolean): T | undefined { - for (const item of this.#items()) { + for (const item of this) { if (predicate(item)) { return item; } @@ -96,7 +157,7 @@ export class Enumerable { */ public findLast(predicate: (value: T) => boolean): T | undefined { let result = undefined; - for (const item of this.#items()) { + for (const item of this) { if (predicate(item)) { result = item; } @@ -105,12 +166,29 @@ export class Enumerable { return result; } + /** + * Returns an iterator containing items transformed using the specified mapper function. + * @param mapper Function responsible for transforming each item. + * @returns An iterator of transformed items. + */ + public flatMap(mapper: (item: T) => IterableIterator): Enumerable { + return new Enumerable( + function* (this: Enumerable): IterableIterator { + for (const item of this) { + for (const mapped of mapper(item)) { + yield mapped; + } + } + }.bind(this) + ); + } + /** * Iterates over each item, and invokes the specified function. * @param fn Function to invoke against each item. */ public forEach(fn: (item: T) => void): void { - for (const item of this.#items()) { + for (const item of this) { fn(item); } } @@ -125,14 +203,34 @@ export class Enumerable { } /** - * Maps each item within the collection to a new structure using the specified mapping function. + * Returns an iterator of mapped items using the mapper function. * @param mapper Function responsible for mapping the items. - * @yields The mapped items. + * @returns An iterator of mapped items. */ - public *map(mapper: (value: T) => U): Iterable { - for (const item of this.#items()) { - yield mapper(item); + public map(mapper: (value: T) => U): Enumerable { + return new Enumerable( + function* (this: Enumerable): IterableIterator { + for (const item of this) { + yield mapper(item); + } + }.bind(this) + ); + } + + /** + * Captures the underlying iterable, if it is not already captured, and gets the next item in the iterator. + * @param args Optional values to send to the generator. + * @returns An iterator result of the current iteration; when `done` is `false`, the current `value` is provided. + */ + public next(...args: [] | [undefined]): IteratorResult { + this.#iterator ??= this.#items(); + const result = this.#iterator.next(...args); + + if (result.done) { + this.#iterator = undefined; } + + return result; } /** @@ -164,7 +262,7 @@ export class Enumerable { } let result = initial; - for (const item of this.#items()) { + for (const item of this) { if (result === undefined) { result = item; } else { @@ -175,13 +273,27 @@ export class Enumerable { return result!; } + /** + * Acts as if a `return` statement is inserted in the generator's body at the current suspended position. + * + * Please note, in the context of an {@link Enumerable}, calling {@link Enumerable.return} will clear the captured iterator, + * if there is one. Subsequent calls to {@link Enumerable.next} will result in re-capturing the underlying iterable, and + * yielding items from the beginning. + * @param value Value to return. + * @returns The value as an iterator result. + */ + public return?(value?: TReturn): IteratorResult { + this.#iterator = undefined; + return { done: true, value }; + } + /** * Determines whether an item in the collection exists that satisfies the specified predicate. * @param predicate Function used to search for an item. * @returns `true` when the item was found; otherwise `false`. */ public some(predicate: (value: T) => boolean): boolean { - for (const item of this.#items()) { + for (const item of this) { if (predicate(item)) { return true; } @@ -189,4 +301,42 @@ export class Enumerable { return false; } + + /** + * Returns an iterator with the items, from 0, up to the specified limit. + * @param limit Limit of items to take. + * @returns An iterator of items from 0 to the limit. + */ + public take(limit: number): Enumerable { + if (isNaN(limit) || limit < 0) { + throw new RangeError("limit must be 0, or a positive number"); + } + + return new Enumerable( + function* (this: Enumerable): IterableIterator { + let i = 0; + for (const item of this) { + if (i++ < limit) { + yield item; + } + } + }.bind(this) + ); + } + + /** + * Acts as if a `throw` statement is inserted in the generator's body at the current suspended position. + * @param e Error to throw. + */ + public throw?(e?: TReturn): IteratorResult { + throw e; + } + + /** + * Converts this iterator to an array. + * @returns The array of items from this iterator. + */ + public toArray(): T[] { + return Array.from(this); + } } diff --git a/src/plugin/actions/singleton-action.ts b/src/plugin/actions/singleton-action.ts index c39c4de2..60e72cbe 100644 --- a/src/plugin/actions/singleton-action.ts +++ b/src/plugin/actions/singleton-action.ts @@ -1,4 +1,5 @@ import type streamDeck from "../"; +import type { Enumerable } from "../../common/enumerable"; import type { JsonObject, JsonValue } from "../../common/json"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; @@ -34,7 +35,7 @@ export class SingletonAction { * Gets the visible actions with the `manifestId` that match this instance's. * @returns The visible actions. */ - public get actions(): IterableIterator | KeyAction> { + public get actions(): Enumerable | KeyAction> { return actionStore.filter((a) => a.manifestId === this.manifestId); }